Полный процесс — одной схемой
Пятнадцать стадий. Тяжёлые вычисления (OCR/docling/layout) выполняются один раз и кешируются в sidecar-файлах рядом с PDF; извлечение можно перезапускать без повторной обработки.
Модели:
OCR
Docling
Структура/layout
LLM
Язык.модель
Правила
0
Маршрут PDF по пайплайну
1. Вход: PDF и его страницы
Файл открывается PyMuPDF (fitz). Из него получают два представления: растр (картинка страницы — для OCR/детекторов) и текст (для парсинга строк).
PDFreport.pdf
→
PyMuPDF (fitz)страницы, размеры, /Rotate
→
Растрget_pixmap ×2.0 hi-res
Текстextract_text + слова pdfplumber
Что уже известно на входе
Размер страницыв pt (A4/ландшафт 842×595 у WTCM)
Поворот/Rotate: 90/180/270 — ландшафтные таблицы часто повёрнуты
Текстовый слойесть/нет/битый — решает роутинг (стадия 2)
Кешиsidecar'ы рядом с PDF — если свежие, тяжёлые стадии пропускаются
Hi-res рендер: 2.0× + Lanczos-ужатие до ширины 880px — RT-DETR (layout) слаб на мелких объектах, рендер должен быть чётким, но не растянутым.
1.1
Повороты страниц: что и как
PP-LCNet_x1_0_doc_ori
Tesseract OSD
валидация по числу слов
векторный поворот для docling
2. Роутинг страниц: скан или цифровой?
Не весь документ одинаков: даже «цифровой» PDF может содержать сканы (страницы-картинки) и страницы с повреждённым текстовым слоем. Каждая страница классифицируется отдельно.
Страница
→
символов текста достаточно?
шрифты с ToUnicode?
шрифты с ToUnicode?
→
✓ текстпропустить OCR
✗ сканв OCR
⚠ битый слойв OCR (замена)
Как определяется «скан»
Цифры из прогона: 21 документ, 1316 страниц → OCR понадобился на 185 (14%). WTCM — 73/73 (100%), UWGN — 53/59 (90%). Именно на этих страницах живут почти все расхождения извлечения.
2.1
ToUnicode-карты: почему «битый текстовый слой» — битый
Простой тест «битый ли слой»: извлечь текст со страницы — если это осмысленные слова, карты на месте; если «Ð¤Ð¸Ð»Ðµ» или пусто при видимом тексте — слой битый → OCR.
3. OCR: распознавание сканов
Отдельный модуль pdf_text_restore.py (свой venv_paddle). Выполняется только для маршрутизированных страниц. Результат — слова с координатами в PDF-пунктах.
3.1
Конвейер распознавания
| OCR_DET | Рамки | Скорость | Сильные стороны | Слабости |
|---|---|---|---|---|
| tesseract (дефолт) |
словесные сразу + conf + группировка в строки (block/par/line) | 3.97 с/стр | даёт conf и структуру строк; надёжные bbox | медленный (68% времени OCR); режет края глифов («конец» без «ц») |
| paddle | строчные → нарезка на слова (cal_ocr_word_box) | 0.01–0.02 с/стр (×200-320) | скорость; ориентация своей моделью | не даёт conf; нужна нарезка строк на слова |
| doctr | словесные сразу (db_resnet50, ONNX) | 0.31 с/стр | чистые рамки: 0 пустых/дублей (у tesseract 3/5/2/29), накрытие чернил 91.2% vs 87.9%; чинит разрез слов, не даёт рамок на 2 строки | не отдаёт текст и conf → маршрутизация идёт арбитром |
Почему «или»: это переключатель для A/B-отката. Каждый детектор решал свою проблему (tesseract — исторически первый; paddle — скорость, профиль показал 5.74 из 6.4 с/стр на нём; doctr — чистота рамок). Гейт держит все три живыми, чтобы мгновенно сравнивать/откатываться. В наших прогонах использовался OCR_DET=paddle.
PP-LCNet doc_ori
PP-OCRv5_mobile_det
eslav_PP-OCRv5_mobile_rec
cyrillic_v2 (дообучена)
db_resnet50 (docTR)
Tesseract
char-LM
3.2
Формат хранения — sidecar
// *_ocr.words.json — формат слова
{"text": "земельный", "x": 138.96, "x1": 146.52,
"y": 322.92, "y1": 339.84}
Кеш по mtime: sidecar свеж, пока новее PDF и скрипта OCR. Повторный прогон не пере-OCR'ит.
4. Docling: структура таблиц
Единственный CUDA-потребитель (TableFormer). Даёт авторитетный grid таблиц: какая ячейка в какой строке/колонке, объединения.
PDF (с OCR-слоем)
→
layout-heronблоки страницы
TableFormer v1/v2ячейки + row/col
→
*_docling.jsonсторона на страницу
4.1
Особенности режима
// *_docling.json — таблица
{"n_rows": 17, "n_cols": 13,
"cells": [{"bbox": [52.54, 93.9, 146.88, 102.74],
"row": 0, "col": 0, "rowspan": 1, "colspan": 1}]}
Реальная развилка: на стр.24 WTCM docling видит 1 таблицу 17×13 (209 ячеек), PP-StructureV3 — 2 таблицы (16×13 + 16×7, 305 ячеек). Разный grid → разные адреса ячеек → разные результаты извлечения.
5. Layout: заголовки нот
PP-DocLayout (RT-DETR, PaddleOCR 3.x) находит границы примечаний — «Примечание 14 — Основные средства». Это строит note_index: карту «номер ноты → страница».
// *_layout.json
{"label": "figure_title", "score": 0.851,
"bbox": [51.63, 71.78, 447.95, 82.29],
"text": "(i) Основные допущения, применяемые при использовании доходного подхода"}
LayoutDetection (RT-DETR)
5.1
Что такое «заголовки нот»
Реальный пример из прогона WTCM (лог): «индекс нот: 33 (heading-граница, номера [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16])» + «note subtypes detected: ppe_rollforward: 5, rou_lease: 3, intangibles_rollforward: 2, related_parties: 3». То есть 33 заголовка нот найдено, и по типам распределены — дальше роутинг работает адресно.
6. Аннотация: таблица → тегированные строки
Сердце табличного пути. Страница превращается в текст, где каждая строка таблицы несёт теги колонок. Дальше пайплайн работает ТОЛЬКО с этим текстом.
6.1
Четыре аннотатора (по приоритету)
| Аннотатор | Когда | Источник геометрии колонок |
|---|---|---|
| _annotate_from_ppstructure | есть PP-Structure sidecar | сетка ячеек row/col напрямую |
| _annotate_from_docling | есть docling sidecar | ячейки TableFormer + геометрия заголовков |
| _annotate_hybrid | OCR-страницы | строки-даты «2025/2024» по x → кластеры колонок |
| _annotate_table_lines | нативный PDF (фолбэк) | кластеризация чисел по x-позициям |
6.2
Результат аннотации
// Заголовок колонок (первая строка блока) # Колонки: [тек:] = 2025 | [пред:] = 2024 // Строки таблицы Основные средства [тек:121 769 257] [пред:108 901 174] [прим:14] Выручка [тек:138 151 147] [пред:128 454 108] [прим:25] Запасы [тек:4 934 329] [пред:4 380 625]
6.3
Особые таблицы: rollforward
6.4
Логика: docling-ячейки → теги (главная механика)
- год-токены («2025», «2024») в разных x-позициях → колонки current/previous
- узкая колонка, где только числа 1–99 → колонка примечаний (note)
- первая текстовая колонка → метка строки (label)
- остальные числовые → значения
Почему это хрупко: год-токены в шапке могут отсутствовать (перевёрнутая шапка на скане), узкая note-колонка сливается с label, а значения диапазона («2026-2090 гг.») маскируются под год — каждый такой случай — отдельная война в коде (видно по 5000+ строкам аннотации).
7. PdfRow: строка-кандидат
Тегированный текст парсится в объекты. Это единица, с которой работает эмбеддинг и guards.
// extract_table_rows → PdfRow (dataclass) PdfRow( page_num = 24, label = "Основные средства", // метка БЕЗ тегов numbers = [121769257.0, ...], // все числа строки tek = 121769257.0, // [тек:] — знак уже разрешён pred = 108901174.0, // [пред:] note = "14", // [прим:N] section = "внеоборотные активы", // раздел BS (позже) cf_section = None, // секция ОДДС stmt_type = "primary_bs", // тип таблицы-блока blk = 2, // индекс docling-таблицы )
Почему label чистится от тегов
8. Обогащение строк
Сырые PdfRow получают контекст: в каком разделе баланса строка, что это за таблица, не разорван ли отчёт поперёк страниц.
8.1
Секции BS
8.2
Тип таблицы
8.3
Склейка разорванных отчётов
8.4
tabnorm-стор
9. Эмбеддинг-матчинг: ранжирование строк
Главный механизм выбора. Метки всех строк кодируются один раз на документ, затем ранжируются против 61 показателя.
Метки строкlowercase, без тегов, row_label
→
ifrs-embed-v13XLM-RoBERTa 768d
bge-m3α=0.15
→
_row_cacheстраница → матрица
9.1
Три места ранжирования
// sim = (1-α)·custom + α·bgem3, BGE_ALPHA = 0.15 // custom gap=0.283, bgem3 gap=0.003 — bge-m3 почти не различает IFRS-концепты; // α нужен только для случаев custom_sim < 0 «Выручка» sim 0.982 ← best_row «Выручка от оказания услуг» 0.911 «Итого выручка» 0.904 «Прочие доходы» 0.342
10. Fast-path: извлечение без LLM
Детерминированный путь: top-строка проходит через guards, значение берётся из тега. Это 80-90% показателей.
best_rowtop-1 по sim
→
Guardsсекция · знак · масштаб · inverse-total
→
Значениеиз тега [тек:]
→
null?→ LLM (стадия 11)
Guards — фильтры, превращающие косинус в надёжное решение
| Guard | Что отсекает | Пример из кода |
|---|---|---|
| section | строка не в ожидаемом разделе BS | «Долгосрочные кредиты» должны быть в долгосрочных обязательствах |
| sign | знак тега противоречит роли статьи | «Прочие операционные ДОХОДЫ» вместо РАСХОДОВ (близкий эмбеддинг, разный знак) |
| scale | мелкое целое при среднем косинусе = чужая строка | x5 «Доходы от аренды»=26 при верном 29 726 (sim 0.79 → null) |
| inverse-total | агрегат вместо компоненты | не брать «Операционные расходы»-итог, когда нужны компоненты |
Значение из тега: «теги уже несут правильное значение колонки со знаком, без колонки примечаний и прозы — это ломало sofl/mtss».
10.1
Логика: как guards принимают решение
- чистый тег [тек:] + высокий sim (≥0.90) → мелкое целое авторитетно (delimobil «Гудвилл» = 39, sim 0.966 — легитимное значение)
- средний sim (<0.90) → мелкое целое = признак НЕВЕРНОЙ строки (x5 «Доходы от аренды» = 26 при верном 29 726, sim 0.794) → null
Итог каскада: строка проходит все фильтры → значение принято как надёжное. Любой фильтр может вернуть null → показатель уходит в LLM (стадия 11). Guards — это не «проверка на всякий случай», а основной механизм превращения косинуса в бухгалтерски осмысленное решение.
11. LLM-фолбэк
Только если fast-path вернул null. Модель получает аннотированный текст страницы целиком и возвращает JSON.
11.1
Устройство вызова
deepseek-v4-flash
61 агент
k=3 голосование
null-audit
critical-review
Ограничение: LLM вызывается ТОЛЬКО на null. Если fast-path вернул неверное значение (не null) — LLM его не перезапишет. Поэтому «расхождения в 163 раза» (rusagro) LLM не чинит.
12. Постобработка: знаки, агрегаты, сверка
После всех категорий значения проходят каскад правил. Бухгалтерские инварианты используются как валидация.
12.1
Каскад (по порядку)
Пример из прогона: баланс Россетей сошёлся ТОЧНО — Активы 158 878 431 = Капитал 64 818 135 + Обязательства 94 060 296 (извлечено, не вычислено).
13. Выход
Результат — JSON на каждый тикер, Excel по тикеру, агрегат по базе.
extracted.jsoncategories: PL/BS/CF
→
extracted_to_excel.py
→
<ticker>.xlsx
aggregate_deliverable.py
→
IFRS_EBITDA_DELIVERABLE.xlsx
// extracted.json — структура
{
"pdf": "...",
"categories": {
"BS": {
"period_current_label": "2024",
"period_previous_label": "2023",
"unit": "тысячах рублей",
"indicators": [
{"name": "Итого активы", "current": 158878431.0,
"previous": 148754288.0, "source_page": 11}
]
}, "PL": {...}, "CF": {...}
},
"restated_comparatives": {...},
"_meta": {}
}
14. Sidecar-файлы: полный реестр
Всё, что лежит рядом с PDF после обработки (все — JSON, кроме *_ocr.pdf).
| Файл | Содержимое | Пишет | Читает |
|---|---|---|---|
| *_ocr.pdf | PDF с невидимым текстовым слоем | OCR-модуль | pdfplumber, docling |
| *_ocr.words.json | слова: текст + bbox (pt) | OCR-модуль | аннотация, визуализация |
| *_docling.json | таблицы: ячейки row/col/rowspan/colspan | docling | аннотация, tabnorm |
| *_layout.json | заголовки нот (bbox + текст + score) | PP-DocLayout | note_index |
| *_struct.json | ячейки PP-StructureV3 | pdf_structure_extract | аннотация, tabnorm |
| *_pages.json | кеш аннотированных страниц | пайплайн | пайплайн (кеш) |
Кеш-инвалидация по mtime: sidecar свеж, пока новее исходного PDF и скрипта-производителя. Правка pdf_text_restore.py автоматически «протухает» все OCR-кеши.
15. Ключевые архитектурные решения
| Решение | Почему |
|---|---|
| LLM — fallback, не движок | Для точных чисел LLM ненадёжен; структурный путь + guards детерминирован и проверяем |
| Эмбеддинги — главный ранжировщик | Дообученный ifrs-embed-v13 понимает доменную семантику («Кредиты и займы полученные…» ≠ «кредиторская») |
| Таблица → тегированный текст | Детекторы решают только «какие числа в каких колонках»; дальше — текстовая инженерия |
| Инварианты как валидация | А=Е+L, CF=ΔДС, Σ компонент = итог — бухучёт сам проверяет извлечение |
| Sidecar-кеши | OCR/docling считаются один раз; извлечение перезапускается за секунды |
| GPU только у docling | 4 параллельных воркера не конфликтуют за CUDA; stagger-старты против Blackwell-deadlock |
| Двухфазный батч | Фаза 1: GPU-эмбеддинги одним процессом → prefill; фаза 2: LLM параллельно на CPU-эмбеддингах |
| Дообучали точечно | ifrs-embed-v13 (13 итераций) и cyrillic_v2 (+27 fix, 0 регрессий); остальное — сток + инженерные обходы |
15.1
А нельзя ли было обойтись Surya OCR + LLM?
- Семантика строк — «26» в ячейке: номер примечания или значение? HTML не отвечает. Это эмбеддинги+guards (x5-кейс, sim 0.794 → null)
- Проза вне таблиц — часть значений живёт в Text-блоках («на конец периода 121 769 тыс. руб.» в тексте примечания), HTML их не структурирует
- Многостраничные таблицы — HTML отдаётся на страницу; разорванная таблица ОС на 3 страницы требует склейки (у нас tabnorm_stitch)
- Сканы и битые слои — 14% страниц (185 из 1316) всё равно требуют OCR-этапа до surya
Замер сделан на живой системе: python -m surya.scripts.ocr_text (код surya, vLLM-бэкенд, surya-ocr-2) на рендере стр. 24 WTCM. Вывод: 4 блока, Table с HTML (14 строк, rowspan/colspan), все conf = 1.0 (хардкод без logprobs). Вывод: гибрид «детерминированный каркас + LLM только на пустотах» — уже эмпирически найденный оптимум (87.4% при полной проверяемости); surya-2 стоит проверить как замену OCR+геометрии, но не как замену guards/эмбеддингов.