Сравнение OCR-движков для RAG: какой выбрать для агентов в 2025 | AiManual
AiManual Logo Ai / Manual.
04 Янв 2026 Гайд

OCR для агентов: Unstructured, LlamaParse, Reducto — тест на 30 инвойсах

Практический тест Unstructured, LlamaParse и Reducto на 30 инвойсах. Скорость, качество, интеграция — что выбрать для вашего агента.

Когда обычный парсинг уже не справляется

Вы загружаете инвойс в 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 с разметкой. Потом можно прямо в промпт агента вставлять.

💡
Unstructured умеет работать локально, без API. Это критично для полностью локальных систем. Но модель детекции таблиц весит 244 МБ. Загружается при первом вызове.

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
)
💡
Сохраняйте категории элементов в метаданных. Потом в промпте агента можно делать: "Ищи только в таблицах" или "Игнорируй колонтитулы". Это повышает точность ответов на 20-30%.

Тонкая настройка для продакшена

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 с семантическим поиском по таблицам — вот где начинается магия.