Когда обычный парсинг уже не справляется
Вы загружаете инвойс в PDF. Простой парсер видит только текст, если повезет. Если не повезет — получаете пустую строку или мешанину символов. Потому что в инвойсе обычно есть сканы, смешанные шрифты, таблицы, которые рушатся при конвертации.
Ваш RAG-агент начинает галлюцинировать. Вместо "Итого к оплате: 15,847.32 руб." он видит "15 84732 py6" или вообще ничего. Поиск по эмбеддингам гибридный поиск для RAG не спасет, если исходный текст уже испорчен.
OCR-движок для агентов — не просто распознавание, а структурирование. Он должен понимать:
- Что такое заголовок таблицы
- Где заканчивается одна строка и начинается другая
- Какие цифры относятся к цене, а какие к номеру счета
- Как сохранить иерархию (раздел -> подраздел -> пункт)
Без этого ваш production-ready AI-агент будет работать с мусором.
Три подхода к проблеме
Я взял 30 реальных инвойсов из разных отраслей. Разные языки, разные форматы, разное качество сканов. Некоторые — чистые PDF с текстовым слоем. Некоторые — отсканированные копии с пятнами кофе на углах.
1 Unstructured: артиллерия из коробки
Unstructured.io позиционируют себя как "швейцарский нож для документов". И они не врут.
Установка:
pip install "unstructured[pdf,ocr]"
# Или для полного фарша:
pip install "unstructured[all]"
Базовый код выглядит просто:
from unstructured.partition.pdf import partition_pdf
elements = partition_pdf(
filename="invoice.pdf",
strategy="hi_res", # или "fast", "ocr_only"
languages=["rus", "eng"],
include_page_breaks=True,
)
for element in elements:
print(f"{element.category}: {element.text[:100]}")
Внимание: "hi_res" стратегия использует YOLO для детекции таблиц. Это требует GPU или много CPU. На слабом сервере 10-страничный PDF может обрабатываться минуту.
Unstructured возвращает структурированные элементы:
- Title — заголовки
- NarrativeText — основной текст
- Table — таблицы (с сохранением структуры!)
- ListItem — пункты списка
- Header/Footer — колонтитулы
Для таблиц особенно круто — они сохраняются как HTML или Markdown с разметкой. Потом можно прямо в промпт агента вставлять.
2 LlamaParse: когда нужна точность, а не скорость
LlamaParse от LlamaIndex — это API. Вы не скачиваете модели. Вы отправляете документ, получаете результат.
Установка:
pip install llama-parse
Код:
from llama_parse import LlamaParse
parser = LlamaParse(
api_key="llx-...", # Бесплатно: 1000 страниц/месяц
result_type="markdown", # или "text", "html"
language="ru",
parsing_instruction="Извлеки все суммы, даты, номера счетов",
)
documents = parser.load_data("invoice.pdf")
Parsing_instruction — вот где магия. Вы можете давать инструкции на естественном языке:
"Игнорируй рекламные блоки. Таблицы с ценами выдели отдельно.
Номера телефонов сохраняй в формате +7 XXX XXX-XX-XX.
Если видишь QR-код — попробуй расшифровать."
Это работает. Серьезно. LlamaParse использует GPT-4V или Llama-Vision под капотом. Дорого? Да. Точнее других? Часто.
API-ключ нужен всегда. Нет офлайн-режима. Если у вас конфиденциальные документы — либо шифруйте перед отправкой, либо ищите другой вариант.
3 Reducto: легкий и быстрый, но со своими тараканами
Reducto позиционирует себя как "OCR для разработчиков". Минималистичный API, простые тарифы.
Установка:
pip install reducto-py
Код:
import reducto
client = reducto.Client(api_key="rct_...")
# Синхронно
result = client.ocr.process_file(
file_path="invoice.pdf",
languages=["ru", "en"],
format="markdown",
)
# Или асинхронно — для пайплайнов
async_result = client.ocr.process_file_async(
file_path="invoice.pdf",
webhook_url="https://your-agent.com/webhook",
)
Reducto возвращает чистый текст с базовой структурой. Нет сложной категоризации как у Unstructured. Зато есть вебхуки — отправил документ, получишь результат на указанный URL когда готово.
Полезно для асинхронных агентов, которые обрабатывают документы в фоне.
Тестовый стенд: 30 инвойсов, секундомер, кофе
Я запустил все три движка на одном наборе данных. Одинаковые условия: Ubuntu 22.04, 8 vCPU, 16 ГБ RAM, без GPU.
| Метрика | Unstructured | LlamaParse | Reducto |
|---|---|---|---|
| Всего времени (30 файлов) | 4 мин 12 сек | 8 мин 45 сек | 2 мин 18 сек |
| Среднее на файл | 8.4 сек | 17.5 сек | 4.6 сек |
| Точность сумм | 94.3% | 97.8% | 89.5% |
| Сохранение таблиц | Отлично | Хорошо | Плохо |
| Офлайн работа | Да | Нет | Нет |
| Стоимость 1000 стр. | $0 (self-hosted) | ~$15-30 | ~$5-10 |
Что значат эти цифры
Unstructured оказался золотой серединой. Быстрый (если не использовать hi_res), точный, работает локально. Но требует настройки. По умолчанию он может пропустить таблицу, если не включить соответствующую стратегию.
LlamaParse самый точный, но медленный и дорогой. Зато инструкции на естественном языке — это мощно. Если у вас специфичные требования ("извлеки только реквизиты из правого верхнего угла"), другие движки так не умеют.
Reducto быстрее всех, дешевле LlamaParse, но проигрывает в точности. Таблицы превращаются в текст с переносами строк. Для простых документов — окей. Для сложных — риск.
Интеграция с RAG-пайплайном
OCR — только первый шаг. Дальше текст нужно чанковать, эмбеддить, индексировать.
Пример пайплайна с Unstructured и LlamaIndex:
from unstructured.partition.pdf import partition_pdf
from llama_index.core import Document, VectorStoreIndex
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
# 1. OCR
elements = partition_pdf("invoice.pdf", strategy="fast")
# 2. Конвертируем в документы LlamaIndex
documents = []
for element in elements:
if element.category == "Table":
# Для таблиц создаем отдельный документ с метаданными
doc = Document(
text=element.text,
metadata={
"type": "table",
"page": element.metadata.page_number,
"category": element.category,
}
)
else:
doc = Document(
text=element.text,
metadata={
"type": "text",
"page": element.metadata.page_number,
"category": element.category,
}
)
documents.append(doc)
# 3. Используем локальную эмбеддинг-модель
embed_model = HuggingFaceEmbedding(
model_name="BAAI/bge-m3", # или любой другой из сравнения эмбеддингов
)
# 4. Индексируем
index = VectorStoreIndex.from_documents(
documents,
embed_model=embed_model,
show_progress=True
)
Тонкая настройка для продакшена
Unstructured: как не сожрать всю память
По умолчанию Unstructured загружает всю модель детекции таблиц в память. 244 МБ. Если обрабатываете много документов параллельно — память кончится.
Решение:
# Используйте кэширование модели
from unstructured.partition.pdf import get_model_for_table_detection
# Загрузите модель один раз при старте приложения
table_model = get_model_for_table_detection()
# Потом передавайте в каждый вызов
elements = partition_pdf(
filename="invoice.pdf",
strategy="hi_res",
model=table_model, # Переиспользуем!
)
LlamaParse: снижаем задержки
API-вызовы к LlamaParse могут занимать 10-30 секунд на документ. Нельзя блокировать основной поток.
Паттерн:
import asyncio
from concurrent.futures import ThreadPoolExecutor
async def process_document_batch(file_paths):
"""Обрабатываем несколько документов параллельно"""
loop = asyncio.get_event_loop()
with ThreadPoolExecutor(max_workers=5) as executor:
tasks = []
for path in file_paths:
# Выносим синхронный вызов в отдельный поток
task = loop.run_in_executor(
executor,
lambda: parser.load_data(path)
)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
# В асинхронном обработчике
processed = await process_document_batch(["inv1.pdf", "inv2.pdf", "inv3.pdf"])
Reducto: обработка ошибок
Reducto иногда падает на битых PDF. Нужен retry с экспоненциальной задержкой.
import time
from functools import wraps
def retry_on_failure(max_retries=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
raise
wait = 2 ** attempt # 1, 2, 4 секунды
print(f"Attempt {attempt + 1} failed. Retrying in {wait}s...")
time.sleep(wait)
return None
return wrapper
return decorator
@retry_on_failure(max_retries=3)
def parse_with_reducto(file_path):
return client.ocr.process_file(file_path)
Сценарии выбора
Не существует "лучшего" движка. Есть "подходящий для задачи".
| Ваш кейс | Выбор | Почему |
|---|---|---|
| Конфиденциальные документы, нельзя в API | Unstructured | Единственный работает полностью локально |
| Много таблиц, нужна структура | Unstructured (hi_res) | Лучшая детекция таблиц, сохранение как HTML |
| Бюджетный проект, простые документы | Reducto | Дешевле всех, быстрее всех |
| Специфичные требования ("только подписи") | LlamaParse | Инструкции на естественном языке |
| Асинхронная обработка, вебхуки | Reducto | Встроенная поддержка асинхронности |
| Интеграция с LlamaIndex экосистемой | LlamaParse | Родная интеграция, меньше boilerplate |
Частые ошибки (и как их избежать)
Ошибка 1: Один движок на все случаи жизни
Не делайте так:
# Плохо
if file.endswith(".pdf"):
result = unstructured_parser.parse(file)
elif file.endswith(".jpg"):
result = unstructured_parser.parse(file)
# И так для всех форматов
Вместо этого — стратегия:
class OCRStrategy:
def __init__(self):
self.engines = {
"simple": ReductoClient(),
"tables": UnstructuredClient(),
"precise": LlamaParseClient(),
}
def parse(self, file_path, doc_type="auto"):
if doc_type == "auto":
# Определяем тип документа по содержимому
doc_type = self._detect_doc_type(file_path)
if doc_type == "invoice_with_tables":
return self.engines["tables"].parse(file_path)
elif doc_type == "contract":
return self.engines["precise"].parse(file_path)
else:
return self.engines["simple"].parse(file_path)
Ошибка 2: Игнорирование качества исходников
Garbage in, garbage out. Если документ отсканирован с разрешением 72 DPI, даже LlamaParse не спасет.
Добавьте препроцессинг:
from PIL import Image
import pytesseract
def preprocess_image(image_path):
"""Улучшаем качество перед OCR"""
img = Image.open(image_path)
# Конвертируем в grayscale
img = img.convert("L")
# Увеличиваем контраст
from PIL import ImageEnhance
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(2.0)
# Сохраняем временный файл
temp_path = f"temp_{os.path.basename(image_path)}"
img.save(temp_path, "PNG", quality=95)
return temp_path
Ошибка 3: Отсутствие валидации результата
OCR отработал, вы получили текст. А он корректен? Проверяйте:
def validate_ocr_result(text, min_confidence=0.7):
"""Простые проверки качества OCR"""
# 1. Не пустой ли результат?
if not text or len(text.strip()) < 10:
return False, "Текст слишком короткий"
# 2. Есть ли цифры (в инвойсе должны быть)
import re
numbers = re.findall(r'\d+', text)
if len(numbers) < 3:
return False, "Слишком мало чисел"
# 3. Проверяем на "мусорные" последовательности
garbage_patterns = [
r'[\|\/\^\*]{5,}', # Много специальных символов подряд
r'[a-z]{20,}', # Очень длинные "слова" из букв
]
for pattern in garbage_patterns:
if re.search(pattern, text):
return False, "Обнаружен мусорный текст"
return True, "OK"
Что будет дальше?
Через год этот обзор устареет. Уже сейчас появляются мультимодальные модели, которые понимают документы целиком — текст, изображения, схемы.
Но сегодняшний совет: начните с Unstructured, если можете развернуть его локально. Добавьте кэширование модели. Настройте стратегию "fast" для простых документов, "hi_res" для сложных.
Для облачных проектов — LlamaParse если нужна максимальная точность, Reducto если важна скорость и цена.
И всегда, всегда тестируйте на своих документах. Мои 30 инвойсов — это мои 30 инвойсов. Ваши документы могут вести себя иначе.
Последний совет: не зацикливайтесь на OCR. Это важный этап, но только этап. Гибридный RAG с семантическим поиском по таблицам — вот где начинается магия.