Представьте что вы получили 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 галлюцинирует потому что не может удержать такой объём в контекстном окне. Агентный подход обрабатывает по частям.

(потом можно преобразовать output в csv и вывести в удобном виде)
(потом можно преобразовать output в csv и вывести в удобном виде)

Документы без единого текста — инструкция 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 кредитных заявок с безымянными файлами обрабатываются так:

  1. DPT парсит каждый документ, понимает структуру

  2. Extract с DocType схемой определяет тип по первой странице

  3. Extract с документо-специфичной схемой вытаскивает нужные поля

  4. Автоматическая валидация: совпадают ли имена, актуальны ли документы, сколько суммарно активов

  5. Visual Grounding: каждое значение привязано к конкретному месту в оригинале

Неделя ручной работы → несколько минут автоматической обработки.

Документы перестали быть чёрными ящиками.


Материал основан на курсе Document AI: From OCR to Agentic Doc Extraction от DeepLearning.AI и LandingAI