Представьте что вы получили 500 кредитных заявок. В каждой — паспорт, банковская выписка, справка о доходах, налоговая форма. Всё в PDF. Имена файлов:
upload1.pdf,upload2.pdf... Чтобы обработать их вручную — нужна неделя и несколько сотрудников. Чтобы обработать автоматически старым способом — нужно написать отдельный парсер под каждый тип документа, и молиться чтобы шрифт не поменялся. Эта статья о том как индустрия шла к решению этой задачи — и к чему пришла.
Документы — это не просто текст. Это таблицы где смысл в структуре строк и столбцов. Графики где тренд закодирован в форме линии. Блок-схемы где логика закодирована в стрелках. Рукописные пометки, печати с изогнутым текстом, чекбоксы — всё это несёт информацию, но совершенно по-разному.
Долгое время единственным способом извлечь данные из таких документов был OCR — технология которая умеет одно: переводить пиксели в буквы. Посмотрим что с ней не так, и почему её оказалось недостаточно.
Часть 1. OCR — машина которая видит буквы но не понимает страницу
Как это работает под капотом
Tesseract появился в 1985 году в лабораториях HP. Принцип работы: разбить изображение на строки → строки на слова → слова на символы → каждый символ сравнить с эталоном.
Классический OCR работал через жёстко заданные признаки — замкнутые контуры (буква O содержит замкнутый контур, F — нет), соотношения сторон, количество пересечений с горизонтальными линиями. Обученный классификатор говорил: "это больше похоже на B чем на 8". На чистом тексте — работает. При малейшем отклонении от идеала — разваливается.
Современные движки вроде PaddleOCR заменили ручные признаки на нейросети:
CNN (свёрточная сеть) сама учится что важно: первые слои замечают края и линии, средние — части букв, последний — целые символы
Трансформер (SVTR) читает символы не изолированно, а с учётом контекста соседей — размытую букву в слове "привет" легче угадать зная что рядом стоит "_ривет"
Ниже простой пример использования:
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang='en') result = ocr.ocr('document.jpg') for line in result[0]: bbox, (text, confidence) = line print(f"'{text}' (уверенность: {confidence:.2f})") # 'Total amount due' (уверенность: 0.97) # '$155.15' (уверенность: 0.94)
PaddleOCR возвращает не просто текст, но и bounding box — координаты прямоугольника вокруг каждого текстового блока. Теперь мы знаем не только что написано, но и где.
Фундаментальная проблема
Вот что происходит с двухколоночной научной статьёй. OCR читает горизонтально — строку за строкой. Результат: текст из левой и правой колонок перемешивается в бессмыслицу. Таблица где числа отрываются от заголовков столбцов. График где подписи осей оказываются посреди основного текста.

Что наприме видит OCR в двухколоночном документе: "Methods Results" "We used The experiment" "N=100 showed p<0.05" Что получается после OCR: "Methods Results We used The experiment N=100 showed p<0.05"
OCR решает задачу распознавания символов. Он не решает задачу понимания документа.
И это не баг который можно починить — это принципиальное ограничение архитектуры. OCR смотрит на страницу через трубочку: видит один символ, потом следующий, потом следующий. Никакого понимания структуры.
Часть 2. Layout Detection — наконец поднимаем голову и смотрим на страницу целиком
Идея
Layout Detection — это отдельная модель которая смотрит на страницу как на изображение и отвечает на вопрос: какие типы регионов здесь есть?
Она находит параграфы, таблицы, графики, заголовки, подписи, колонтитулы — и рисует вокруг каждого ограничивающий прямоугольник с меткой типа. Принципиальная разница в пайплайне:
Без Layout Detection: Изображение → Text Detection → Text Recognition → "стена текста" С Layout Detection: Изображение → Layout Detection (какие регионы?) ↓ Text Detection (где текст внутри каждого региона?) ↓ Text Recognition (что написано?) ↓ Структурированный результат с типами блоков

Когда модель знает что перед ней таблица — она не читает строки как обычный текст, а сохраняет структуру ячеек. Когда знает что перед ней колонка — читает её сверху вниз полностью прежде чем перейти к следующей.
from paddleocr import PPStructure layout_engine = PPStructure(show_log=False) result = layout_engine('document.jpg') for region in result: print(f"Тип: {region['type']}") # text, table, figure, title... print(f"bbox: {region['bbox']}") # координаты региона
Порядок чтения — это отдельная задача
Layout Detection говорит где блоки и что они из себя представляют. Но порядок чтения — отдельная задача которую он не решает.
Проблема в том что OCR возвращает просто облако слов с координатами — никакого порядка. В простом одноколоночном документе можно отсортировать сверху вниз и этого хватит. Но в реальных документах — статья с врезкой, презентация с несколькими блокам��, газетная полоса — такая сортировка сломает текст.
LayoutReader решает именно эту задачу. Он берёт координаты текстовых блоков от OCR и определяет правильный порядок чтения. В основе — LayoutLM от Microsoft, трансформер обученный понимать пространственное расположение текста на странице. Обучен на 500 тысячах размеченных страниц где люди показывали правильный порядок.
На выходе LayoutReader отдаёт просто текст - уже собранный в правильном порядке. Никаких координат, никакой нумерации. Агент этих деталей вообще не видит.
Зачем это нужно агенту? Агент получает текст документа как контекст в системном промпте и отвечает на вопросы пользователя. Если текст перемешан - колонки смешались, абзацы перепутались — он просто не сможет адекватно читать документ. Порядок чтения нужен чтобы контекст в промпте был связным.
Layout Detection при этом работает параллельно и независимо — он нужен для другого: чтобы агент знал какие визуальные регионы есть на странице и мог вызвать нужный инструмент, например попадается бокс с лейблом (картинка/таблица) агент понимает что нужно вызвать VLM инструмент:
Итого агент получает в системный промпт две независимые вещи:
LayoutReader → связный текст документа ──┐ ├──→ системный промпт агента Layout Detection → карта регионов с типами ──┘

Часть 3. Визуальное понимание — то чего не хватало
Почему текста всё равно недостаточно
Даже с правильным порядком чтения мы теряем огромный пласт информации.
График показывает тренд — но тренд закодирован в форме линии, не в числах. Блок-схема описывает процесс — но процесс закодирован в стрелках и их направлениях. Рукопись где слово обведено кружком — OCR прочитает слово, но не поймёт что оно выбрано.

OCR захватывает текст. Всё остальное теряется.
Как устроена Vision-Language Model
VLM — это обычный LLM перед которым стоит стек обработки изображений:
[Изображение] → Vision Encoder → Projector → LLM → [Текст ответа] (CLIP/SigLIP) (переводной (обычная языковая слой) модель)
Vision Encoder (например CLIP от OpenAI) превращает пиксели в векторы. CLIP обучен на сотнях миллионов пар "изображение + текст" и понял что картинка кошки и слово "кошка" описывают одно и то же — их векторы близки в пространстве эмбеддингов.
Projector — переходный слой. Визуальные векторы имеют другую природу чем текстовые токены. Projector конвертирует одно в другое.
LLM получает смешанную последовательность: визуальные токены + текстовые токены вопроса. Рассуждает над всем этим вместе.
Никакой магии — просто совместное обучение на данных где изображения и тексты связаны.
Гибридная архитектура на практике
VLM используется не вместо OCR и Layout Detection, а вместе с ними:
Layout Detection + LayoutReader → точная структура, правильный порядок ↓ Маршрутизация по типу: Текст → OCR (быстро, точно, дёшево) Таблица → специализированная модель или VLM График → VLM с целевым промптом ↓ Агент получает весь контекст
Layout Detection даёт детерминированную основу. VLM обрабатывает то что требует визуального понимания.
Часть 4. Агентный подход — система которая принимает решения
ReAct: явное рассуждение перед каждым действием
Собрав OCR, Layout Detection и VLM вместе, мы получили набор инструментов. Но кто решает какой инструмент вызвать и когда? Это задача агента.
ReAct (Reason + Act) — паттерн где система явно рассуждает перед каждым действием:
💭 Thought → Что нужно? Есть ли ответ в уже имеющемся тексте? ⚡ Action → Вызвать нужный инструмент 👀 Observe → Изучить результат 💭 Thought → Достаточно? Нужен ещё шаг? ✅ Answer → Сформулировать ответ
Это принципиально отличается от "одного прохода". Агент может заметить что OCR вернул странное число ($7.99 вместо $7.95), усомниться, попытаться перепроверить. Человек так и работает с документами.
Что реально происходит
Агент получает системный промпт в котором содержится весь упорядоченный текст документа и список всех регионов с типами и ID:
## Текст документа (в порядке чтения) [результат OCR + LayoutReader] ## Регионы документа - region_id="text_0", type="text", page=0 - region_id="table_1", type="table", page=0 - region_id="chart_0", type="figure", page=1
Дальше агент сам решает:
Вопрос: "Какой тренд показывает график?" Thought: это визуальный вопрос, из текста не отвечу Action: AnalyzeChart(region_id="chart_0") Observe: {"trend": "declining", "x_axis": "Year 2020-2023"} Answer: "График показывает снижающийся тренд" --- Вопрос: "Какой заголовок у документа?" Thought: это есть в OCR тексте Answer: "US Economic Report Q3 2024"
Агент сам решает — тратить ли дорогой API вызов к VLM или ответить из уже имеющегося текста. Это и есть агентность в практическом смысле.
Часть 5. LandingAI DPT — когда пайплайн становится одной моделью
Проблема самодельного пайплайна
Всё описанное выше — OCR + Layout Detection + LayoutReader + VLM + LangChain агент — работает. Мы только что его и описали. Но у него есть фундаментальная проблема:
Каждый компонент настраивается отдельно. Стыки между ними хрупки. Обновление одного компонента требует перепроверки всей цепочки. Новый тип документа — снова тюнинг каждого звена. Всё это сложно, дорого и ненадёжно в продакшене.
LandingAI решила эту проблему иначе: вместо того чтобы собирать пайплайн из кубиков, они обучили специализированную модель которая делает всё это сразу.
Три принципа DPT
Vision-First. Документ воспринимается как визуальный объект с самого начала. Не "сначала OCR, потом понимаем структуру" — а одновременное восприятие текста, расположения, структуры и визуальных отношений.
Data-Centric. Правильно подобранные обучающие данные дают такой же прирост как улучшение архитектуры. Тысячи примеров обведённых слов, чекбоксов разных стилей, рукописных формул, печатей с изогнутым текстом — всё это размечено и вошло в обучение.
Agentic. Система не делает всё за один проход. Сложная таблица обрабатывается иначе чем параграф. Система итерирует до достижения п��рога качества.
Как обучены чекбоксы и обведённые слова (пример выше)
Это вопрос который возникает сразу: как модель понимает что слово обведено кружком? Никакой магии — просто обучение. Модели показали тысячи примеров с разметкой:
Вот чекбокс с галочкой → в ответе
[x]Вот пустой чекбокс → в ответе
[ ]Вот слово "No" обведено кружком → в ответе
No(circled)
Модель выучила сопоставление визуального паттерна с текстовым представлением. Она не "понимает" что такое галочка в человеческом смысле — она выучила: этот визуальный паттерн всегда маппится на этот текстовый символ.
Как раз это и есть Data-Centic, модель обучена на таком количестве примеров что способна решать даже такие задачи.


Производительность
DocVQA — стандартный бенчмарк: вопросы и ответы по реальным отсканированным документам (датасет UCSF Industry Documents Library). Вопросы типа "какой домашний телефон указан в этой форме?" — и ответ нужно найти в рукописном поле.

Система | Точность |
|---|---|
Человек | ~98% |
Лучшие опубликованные модели | < 99% |
LandingAI DPT-2 | 99.15% |
API
from landingai_ade import LandingAIADE client = LandingAIADE() # Весь пайплайн — один вызов parse_result = client.parse( document="contract.pdf", model="dpt-2-latest" )
Результат — иерархически организованные данные:
parse_result └── splits[] # одна запись на страницу ├── .markdown # чистый markdown с сохранённой структурой └── .chunks[] # структурные единицы страницы ├── chunk_id # UUID ├── text # содержимое ├── chunk_type # text|table|figure|logo|attestation ├── bbox # координаты [0..1] от размера страницы └── page # номер страницы
Чанк — не просто строка текста, а осмысленная структурная единица: логотип, таблица целиком, параграф, график, подпись к рисунку. Координаты нормализованы от 0 до 1 — работает для любого разрешения. То есть теперь каждый чанк хранит в себе и реализацию ORC и Layout Detection.
Что умеет с реально сложными документами
Тип attestation — новый тип чанка которого нет в обычных OCR системах. Печати и подписи. Изогнутый текст внутри круглой печати с фоновым шумом + отдельная подпись рядом. DPT читает и то и другое.

Рукописные математические формулы — √(√2/2) в рукописном виде возвращается в markdown с правильными математическими символами.
Мегатаблицы с тысячей ячеек — обычный LLM галлюцинирует потому что не может удержать такой объём в контекстном окне. Агентный подход обрабатывает по частям.


Документы без единого текста — инструкция IKEA, только иллюстрации. DPT-1 возвращает детальное текстовое описание каждого рисунка: "инструкция не собирать на твёрдой поверхности, рекомендуется защитный коврик".


Часть 6. Извлечение структурированных данных
Parse даёт структурированное представление документа. Extract вытаскивает конкретные поля по заданной схеме. Разделение имеет смысл: один распаршенный документ можно запрашивать с разными схемами без повторного парсинга.
from pydantic import BaseModel, Field from landingai_ade.lib import pydantic_to_json_schema class UtilityBillSchema(BaseModel): total_amount_due: float = Field( description="Total amount currently due on this bill" ) max_consumption_month: str = Field( description="Month with highest consumption in the last 12 months, " "determined from the usage history bar chart" ) extraction = client.extract( schema=pydantic_to_json_schema(UtilityBillSchema), markdown=parse_result.markdown, model="extract-latest" ) print(extraction.extraction) # {'total_amount_due': 155.15, 'max_consumption_month': 'January'} print(extraction.extraction_metadata) # {'total_amount_due': {'value': 155.15, 'references': ['0-e', '0-h']}}
Чем подробнее описание поля — тем точнее извлечение. Модель использует description чтобы понять что именно искать. "Total amount currently due" работает лучше чем просто "amount".
References в метаданных — ссылки на конкретные чанки (или ячейки таблицы) из которых получено значение. Короткие ID вида '0-e' — ячейки таблицы. Длинные UUID — фигуры или текстовые блоки. Это позволяет построить интерфейс где пользователь видит значение и может кликнуть чтобы увидеть точное место в оригинальном документе.
Реальный сценарий: обработка кредитных заявок
from enum import Enum from pydantic import BaseModel class DocumentType(str, Enum): ID = "ID" W2 = "W2" bank_statement = "bank_statement" investment_statement = "investment_statement" # Шаг 1: парсим документ с разбивкой по страницам parse_result = client.parse( document=document, split="page", # markdown разбивается по страницам → parse_result.splits[] model="dpt-2-latest" ) # Для категоризации достаточно первой страницы first_page_markdown = parse_result.splits[0].markdown doc_type = client.extract(schema=doc_type_json_schema, markdown=first_page_markdown) # Шаг 2: применить правильную схему для этого типа schema = schema_map[doc_type.extraction["type"]] data = client.extract(schema=schema, markdown=parse_result.markdown)
Трюк с первой страницей не случаен: для категоризации обычно достаточно шапки документа, а значит тратим значительно меньше токенов
Логика элегантная: сначала дёшево узнаём тип, потом точно извлекаем данные с правильной схемой (для нужного типа документа).
Extract принимает схему в двух форматах. Первый — чистый JSON Schema, удобен если схема генерируется динамически или приходит извне. Второй — Pydantic модель через конвертер pydantic_to_json_schema(), удобен когда схема описывается прямо в коде. Под капотом это одно и то же — Pydantic просто конвертируется в JSON Schema перед отправкой.
Часть 7. RAG — задаём вопросы документам
Почему keyword-поиск не работает
74-страничный отчёт Apple. Аналитик спрашивает: "Какая была выручка в 2023?"
Keyword-поиск ищет слово "revenue". Документ использует "net sales". Ноль результатов — хотя информация есть. Даже если найдём совпадение — "revenue" встречается 75 раз, и keyword-поиск не знает какое упоминание отвечает на конкретный вопрос. Ответ на "какие основные риски компании" вообще разбросан по страницам 12, 15 и 18 — его нужно синтезировать.
Нужно семантическое понимание.
Как работает RAG
RAG (Retrieval-Augmented Generation) — три фазы:
Фаза 1: Препроцессинг (делается один раз)
Каждый чанк из ADE превращается в embedding — вектор из 1536 чисел кодирующий смысл текста. Семантически похожие тексты получают близкие векторы. Именно поэтому запрос "revenue" находит чанк с "net sales": их векторы близки в 1536-мерном пространстве.
import chromadb from langchain_chroma import Chroma from langchain_openai import OpenAIEmbeddings CHROMA_DB_PATH = Path("./chroma_db") COLLECTION_NAME = "ade_documents" EMBEDDING_MODEL = "text-embedding-3-small" vectordb = Chroma( collection_name=COLLECTION_NAME, embedding_function=OpenAIEmbeddings(model=EMBEDDING_MODEL), persist_directory=str(CHROMA_DB_PATH) )
ChromaDB хранит вектор, текст и метаданные в одной записи. Векторы используются для поиска, текст достаётся и передаётся LLM — модель никогда не видит числа.
Фаза 2: Retrieval (при каждом запросе)
retriever = vectordb.as_retriever()
Можно добавить фильтрацию по метаданным — гибридный поиск:
q_embed = openai.embeddings.create( model=EMBEDDING_MODEL, input="What was Apple's total revenue in 2023?", ).data[0].embedding results = collection.query( query_embeddings=[q_embed], n_results=5, include=["documents", "metadatas", "distances"], where={"chunk_type": "table"}, )
Фаза 3: Generation
LLM получает найденные чанки как контекст. Ключевая инструкция в системном промпте — защита от галлюцинаций:
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain.chains import create_retrieval_chain system_prompt = ( "Use the following pieces of retrieved context to answer the " "user's question. " "If you don't know the answer, say that you don't know." "\n\n" "{context}" ) prompt = ChatPromptTemplate.from_messages([ ("system", system_prompt), ("human", "{input}"), ]) llm = ChatOpenAI(model="gpt-4o-mini", temperature=1) rag_chain = create_retrieval_chain(retriever, prompt | llm) response = rag_chain.invoke({"input": "What were Apple net sales in 2023?"}) print(response["answer"])
Visual Grounding в RAG
Для каждого найденного чанка есть bbox в метаданных. Это позволяет восстановить точный фрагмент из оригинального PDF — изображение генерируется на лету из координат, ничего не хранится в базе. В продакшене на AWS: PDF в S3, координаты из ChromaDB, изображение генерируется по запросу и отдаётся как presigned URL.
Почему это важно: аналитик получает ответ "выручка $383 млрд" и видит точную таблицу со страницы 28. Проверить правильность — один клик. Через полгода аудиторы спрашивают откуда число — есть конкретная страница, конкретная таблица, конкретная ячейка.
Итоговая картина
Весь путь который мы прошли — это не просто улучшение точности распознавания. Это смена парадигмы на каждом шаге:
Tesseract (1985) └─ Смотрит через трубочку: один символ за раз └─ Знает: "это буква B" └─ Не знает: что это заголовок PaddleOCR └─ Трубочка расширилась до строки └─ Знает: "это слово, вот где оно на странице" └─ Не знает: порядок чтения и структуру Layout Detection + LayoutReader └─ Наконец смотрит на страницу целиком └─ Знает: "это таблица, это колонки, вот правильный порядок" └─ Не знает: что нарисовано на графике VLM └─ Понимает визуальный смысл └─ Знает: "график показывает снижение, блок-схема идёт вправо" └─ Не знает: как всё это объединить надёжно Агентный пайплайн (всё выше + ReAct) └─ Принимает решения: какой инструмент вызвать └─ Знает: когда использовать VLM, а когда достаточно OCR └─ Хрупкий: сложно поддерживать, каждый стык может сломаться LandingAI DPT └─ Та же идея, реализованная как единая специализированная модель └─ Знает: всё вышеперечисленное из коробки └─ Точнее человека на стандартном бенчмарке
Самодельный агентный пайплайн и LandingAI DPT — это одна и та же идея. Разница в реализации: первый собран из разрозненных кубиков и требует постоянной поддержки, второй — единая модель обученная на миллионах документов с нуля.
Tesseract | PaddleOCR | Агентный пайплайн | LandingAI DPT | |
|---|---|---|---|---|
Простой текст | ✅ | ✅ | ✅ | ✅ |
Многоколоночный текст | ❌ | ⚠️ | ✅ | ✅ |
Таблицы без линий | ❌ | ❌ | ⚠️ | ✅ |
Графики и блок-схемы | ❌ | ❌ | ⚠️ | ✅ |
Рукопись + чекбоксы | ❌ | ⚠️ | ⚠️ | ✅ |
Математические формулы | ❌ | ⚠️ | ⚠️ | ✅ |
Печати и подписи | ❌ | ❌ | ❌ | ✅ |
Visual Grounding | ❌ | ❌ | ⚠️ | ✅ |
Настройка под новый тип документа | Много кода | Много кода | Очень много кода | JSON схема |
Надёжность в продакшене | Низкая | Средняя | Низкая | Высокая |
Заключение
Если вы дочитали до сюда — у вас теперь есть ответ на вопрос из начала статьи. 500 кредитных заявок с безымянными файлами обрабатываются так:
DPT парсит каждый документ, понимает структуру
Extract с DocType схемой определяет тип по первой странице
Extract с документо-специфичной схемой вытаскивает нужные поля
Автоматическая валидация: совпадают ли имена, актуальны ли документы, сколько суммарно активов
Visual Grounding: каждое значение привязано к конкретному месту в оригинале
Неделя ручной работы → несколько минут автоматической обработки.
Документы перестали быть чёрными ящиками.
Материал основан на курсе Document AI: From OCR to Agentic Doc Extraction от DeepLearning.AI и LandingAI
