- BrainTools - https://www.braintools.ru -
Представьте что вы получили 500 кредитных заявок. В каждой — паспорт, банковская выписка, справка о доходах, налоговая форма. Всё в PDF. Имена файлов:
upload1.pdf,upload2.pdf… Чтобы обработать их вручную — нужна неделя и несколько сотрудников. Чтобы обработать автоматически старым способом — нужно написать отдельный парсер под каждый тип документа, и молиться чтобы шрифт не поменялся. Эта статья о том как индустрия шла к решению этой задачи — и к чему пришла.
Документы — это не просто текст. Это таблицы где смысл в структуре строк и столбцов. Графики где тренд закодирован в форме линии. Блок-схемы где логика [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 смотрит на страницу через трубочку: видит один символ, потом следующий, потом следующий. Никакого понимания структуры.
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 → карта регионов с типами ──┘
Даже с правильным порядком чтения мы теряем огромный пласт информации.
График показывает тренд — но тренд закодирован в форме линии, не в числах. Блок-схема описывает процесс — но процесс закодирован в стрелках и их направлениях. Рукопись где слово обведено кружком — OCR прочитает слово, но не поймёт что оно выбрано.

OCR захватывает текст. Всё остальное теряется.
VLM — это обычный LLM перед которым стоит стек обработки изображений:
[Изображение] → Vision Encoder → Projector → LLM → [Текст ответа]
(CLIP/SigLIP) (переводной (обычная языковая
слой) модель)
Vision Encoder (например CLIP от OpenAI) превращает пиксели в векторы. CLIP обучен на сотнях миллионов пар “изображение + текст” и понял что картинка кошки и слово “кошка” описывают одно и то же — их векторы близки в пространстве эмбеддингов.
Projector — переходный слой. Визуальные векторы имеют другую природу чем текстовые токены. Projector конвертирует одно в другое.
LLM получает смешанную последовательность: визуальные токены + текстовые токены вопроса. Рассуждает над всем этим вместе.
Никакой магии — просто совместное обучение [3] на данных где изображения и тексты связаны.
VLM используется не вместо OCR и Layout Detection, а вместе с ними:
Layout Detection + LayoutReader → точная структура, правильный порядок
↓
Маршрутизация по типу:
Текст → OCR (быстро, точно, дёшево)
Таблица → специализированная модель или VLM
График → VLM с целевым промптом
↓
Агент получает весь контекст
Layout Detection даёт детерминированную основу. VLM обрабатывает то что требует визуального понимания.
Собрав 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 или ответить из уже имеющегося текста. Это и есть агентность в практическом смысле.
Всё описанное выше — OCR + Layout Detection + LayoutReader + VLM + LangChain агент — работает. Мы только что его и описали. Но у него есть фундаментальная проблема:
Каждый компонент настраивается отдельно. Стыки между ними хрупки. Обновление одного компонента требует перепроверки всей цепочки. Новый тип документа — снова тюнинг каждого звена. Всё это сложно, дорого и ненадёжно в продакшене.
LandingAI решила эту проблему иначе: вместо того чтобы собирать пайплайн из кубиков, они обучили специализированную модель которая делает всё это сразу.
Vision-First. Документ воспринимается как визуальный объект с самого начала. Не “сначала OCR, потом понимаем структуру” — а одновременное восприятие [4] текста, расположения, структуры и визуальных отношений.
Data-Centric. Правильно подобранные обучающие данные дают такой же прирост как улучшение архитектуры. Тысячи примеров обведённых слов, чекбоксов разных стилей, рукописных формул, печатей с изогнутым текстом — всё это размечено и вошло в обучение.
Agentic. Система не делает всё за один проход. Сложная таблица обрабатывается иначе чем параграф. Система итерирует до достижения порога качества.
Это вопрос который возникает сразу: как модель понимает что слово обведено кружком? Никакой магии — просто обучение. Модели показали тысячи примеров с разметкой:
Вот чекбокс с галочкой → в ответе [x]
Вот пустой чекбокс → в ответе [ ]
Вот слово “No” обведено кружком → в ответе No (circled)
Модель выучила сопоставление визуального паттерна с текстовым представлением. Она не “понимает” что такое галочка в человеческом смысле — она выучила: этот визуальный паттерн всегда маппится на этот текстовый символ.
Как раз это и есть Data-Centic, модель обучена на таком количестве примеров что способна решать даже такие задачи.


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

|
Система |
Точность |
|---|---|
|
Человек |
~98% |
|
Лучшие опубликованные модели |
< 99% |
|
LandingAI DPT-2 |
99.15% |
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 возвращает детальное текстовое описание каждого рисунка: “инструкция не собирать на твёрдой поверхности, рекомендуется защитный коврик”.


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 перед отправкой.
74-страничный отчёт Apple. Аналитик спрашивает: “Какая была выручка в 2023?”
Keyword-поиск ищет слово “revenue”. Документ использует “net sales”. Ноль результатов — хотя информация есть. Даже если найдём совпадение — “revenue” встречается 75 раз, и keyword-поиск не знает какое упоминание отвечает на конкретный вопрос. Ответ на “какие основные риски компании” вообще разбросан по страницам 12, 15 и 18 — его нужно синтезировать.
Нужно семантическое понимание.
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."
"nn"
"{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"])
Для каждого найденного чанка есть 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 [5] от DeepLearning.AI [6] и LandingAI
Автор: PureNothing
Источник [7]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/26876
URLs in this post:
[1] логика: http://www.braintools.ru/article/7640
[2] Image: https://sourcecraft.dev/
[3] обучение: http://www.braintools.ru/article/5125
[4] восприятие: http://www.braintools.ru/article/7534
[5] Document AI: From OCR to Agentic Doc Extraction: https://learn.deeplearning.ai/
[6] DeepLearning.AI: http://DeepLearning.AI
[7] Источник: https://habr.com/ru/articles/1008610/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1008610
Нажмите здесь для печати.