Вот маленький фрагмент прайс-листа. В оригинале он выглядит так: над колонками стоят названия, а под ними числа.
| Объём | Цена за месяц, руб. без НДС | Цена за месяц, руб. с НДС |
|---|---|---|
| 100 ГБ | 500 | 600 |
| 500 ГБ | 1 800 | 2 160 |
Теперь представим, что разбиение на фрагменты прошло небрежно: текст порезали по числу символов, и граница пришлась между заголовком и данными. В индекс попал второй фрагмент:
500 ГБ 1 800 2 160Вопрос пользователя: «Сколько стоит 500 ГБ в месяц?» Модель увидит три числа и без колебаний выберет одно из них. Возможно, 1 800. Возможно, 2 160. Каждое число в этом фрагменте присутствует и каждое «правильное», но ответ превращается в лотерею: без заголовка неизвестно, какая цена с налогом, а какая без. Число осталось, смысл ушёл. Именно поэтому в RAG по таблицам нельзя ограничиваться вопросом «текст извлёкся?». Надо спрашивать, сохранилась ли связь между значением и тем, что оно значит.
Что на самом деле хранит таблица
Обычный абзац несёт смысл в самих словах. Таблица устроена иначе: значение ячейки определяется её положением. Чтобы понять «1 800», нужно знать минимум четыре вещи.
- Строку. Она говорит, к какому объекту относится число: к тарифу на 500 ГБ, а не на 100 ГБ.
- Колонку. Она называет величину: цена, количество, срок.
- Единицу и условия. Рубли это или тысячи рублей, с НДС или без, за месяц или за год. Часто это живёт не в ячейке, а в подписи над таблицей или в сноске под ней.
- Объединённые ячейки. Если над двумя колонками стоит общая шапка «Цена за месяц», то подзаголовки «без НДС» и «с НДС» читаются только вместе с ней.
Если при разборе PDF потерять любое из звеньев, текст останется, а ответ станет недостоверным. Особенно коварны объединённые ячейки. Они бывают по горизонтали (шапка над несколькими колонками) и по вертикали (название группы товаров слева, а рядом несколько строк). При плоском выводе значение такой группы либо записывается один раз и «повисает» над первой строкой, либо пропадает у остальных.
Разберём это на маленьком примере. Допустим, в прайсе слева стоит ячейка «Архивное хранение» с rowspan на две строки, а рядом идут строки «100 ГБ» и «500 ГБ». Плоский вывод даёт такой результат:
Архивное хранение; 100 ГБ; 500; 600
500 ГБ; 1 800; 2 160У второй строки группа пропала. Вопрос «Сколько стоит архивное хранение 500 ГБ?» теперь опирается на фрагмент, где слова «архивное» нет вообще, и поиск легко подставит вместо него строку из другой группы с тем же объёмом. Если же программа размножает значение группы по всем строкам, то получается обратная проблема: одна и та же подпись встречается дважды, и при подсчёте групп их окажется больше, чем в оригинале. Поэтому для таблиц с вертикальными объединениями полезно сравнивать не только числа, но и то, сколько раз встречается каждая группа и сколько строк под ней лежит.
С горизонтальной шапкой ситуация зеркальная. Над колонками «без НДС» и «с НДС» стоит общая надпись «Цена за месяц, руб.». Если сохранить только нижний ряд заголовков, в индексе останутся «без НДС» и «с НДС» без слова «цена» и без единицы. Человек догадается, модель по одному фрагменту может не связать их с рублями и месяцем. Читать такую шапку нужно как составное имя колонки: общая надпись плюс подзаголовок.
Здесь же стоит сразу оговорить ожидания: тот факт, что таблица извлечена, ещё не означает, что арифметика и смысл верны. Налог не равен итогу, а у таблицы с объединениями число ячеек в строках может быть разным. Проверка «в каждой строке одинаковое количество ячеек» для таких таблиц ложна по определению, и её не нужно ставить в контрольный набор.
Текстовый PDF и скан: где OCR нужен, а где вреден
Первое решение при разборе: есть ли в файле настоящий текстовый слой. Если PDF сформирован из Excel или Word, символы в нём лежат как текст, и их можно прочитать напрямую. Скан или фотография страницы — это картинка; текста там нет, и пока его не распознали, извлекать нечего.
Простая проверка: попробуйте выделить в просмотрщике одну ячейку и скопировать. Если вставляются нормальные символы, слой есть. Если выделяется вся страница как картинка или вставляется пустота, перед вами скан. Бывает и смешанный случай: основной текст страницы настоящий, а печать, вставка или приложенная таблица сфотографированы и вклеены картинкой.
OCR (распознавание символов) стоит включать там, где текстового слоя нет, и это не бесплатное удовольствие. Он добавляет типичные ошибки: путает ноль с буквой О, запятую с точкой, склеивает соседние колонки при плотной вёрстке. В таблице цен такая ошибка опаснее, чем в прозе: «1 800» вместо «1 000» читается как правдоподобное число. Поэтому для текстовых PDF лишний OCR — это добавление шума к уже точным данным.
В Docling для этого случая есть пример с полностраничным OCR и распознаванием структуры таблиц, см. пример full_page_ocr.py. В нём же отмечено, что выбранный движок распознавания требует собственных зависимостей, а полностраничный режим обычно медленнее гибридного. Практический вывод для нас таков: полный OCR не нужно включать на всю папку «на всякий случай». Разумнее разделить документы на две группы и обрабатывать их по-разному, а пограничные страницы пересматривать вручную.
Как это выглядит в рабочем процессе:
- Для каждого файла проверить, есть ли текстовый слой; для смешанных документов проверить по страницам.
- Текстовые обрабатывать без полностраничного OCR, скан — с ним.
- Сравнить результаты на нескольких одинаковых по сложности страницах: если OCR на текстовом PDF дал расхождения с прямым чтением, значит, он только вредит.
Скорость тоже факт, а не мелочь. Если библиотека из сотен документов наполовину состоит из сканов, разница между гибридным и полным режимом выльется в часы ожидания. Какие именно ресурсы это потребует, зависит от документов и выбранного движка, поэтому узнавать это нужно пробным прогоном на своей выборке, а не по общим цифрам.
От страницы к DoclingDocument: где живёт структура
После разбора важно, во что превращается страница. Если на выходе простая склейка строк, структура уже потеряна: колонки превратились в пробелы, и никто не скажет, где кончалась ячейка. Хорошо, когда результат хранит таблицу как таблицу.
Модель документа Docling построена именно так: по описанию DoclingDocument она хранит текст, таблицы, иерархию элементов и сведения о происхождении. Для нашей задачи это значит, что таблица остаётся отдельным объектом с ячейками, а не растворяется в потоке текста. Подпись, заголовок раздела, в котором лежит таблица, и сама сетка могут быть связаны друг с другом через иерархию.
Про координаты нужно сказать аккуратно. В документации сказано, что координаты элементов доступны, если они получены при разборе. Это условие, а не гарантия: у части источников и режимов извлечения их может не быть. Поэтому если вы строите функцию «показать пользователю, где на странице лежит источник ответа», сначала проверьте на своих файлах, что координаты вернулись. Не обещайте подсветку фрагмента на странице, пока не убедились, что данные для неё есть. Без координат ссылка на источник всё равно возможна, но на уровне документа и страницы, если сведения о происхождении её дают.
Структура важна ещё по одной причине. Когда таблица хранится ячейками, её можно превратить в текст для индекса разными способами: строка за строкой, с названиями колонок в каждой строке, в виде пар «колонка: значение». Выбор формата определяет, сколько смысла сохранится в одном фрагменте.
Различие между форматами проще всего увидеть на одной строке нашего прайса. Если записать её просто как значения через точку с запятой, получится «500 ГБ; 1 800; 2 160», и мы вернулись к исходной проблеме. Если дописать названия колонок к каждой строке, выйдет «Объём: 500 ГБ; Цена за месяц, руб. без НДС: 1 800; Цена за месяц, руб. с НДС: 2 160». Такая запись длиннее, зато любой фрагмент, в который она попадёт, объясняет себя сам, даже если соседних строк рядом нет.
У этого выбора есть цена. Повторяющиеся названия колонок занимают токены в каждой строке, и в длинной таблице на сотни строк они могут съесть заметную часть лимита фрагмента, оставив меньше места под данные. Значит, формат нужно подбирать под таблицу: узкая таблица с двумя-тремя колонками переносит пары «колонка: значение» легко, а широкая с двадцатью колонками выигрывает от того, что в каждый фрагмент попадает меньше строк, но с полной расшифровкой. Проверяйте это по числу токенов в итоговых фрагментах для вашей модели эмбеддингов, а не по числу символов.
Отдельно сохраняйте подпись таблицы и название раздела как общий префикс. В нашем примере слова «цены условные» из подписи защищают от неверного прочтения: без них число легко принять за действующий тариф. Структура, которую хранит модель документа Docling, позволяет достать эти части отдельно и собрать префикс заново, а не выковыривать его из плоского текста.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Разбиение: где рождается потерянный заголовок
Теперь вернёмся к началу статьи. Потерянный заголовок обычно не следствие плохого извлечения: к моменту разбиения таблица могла быть извлечена безупречно. Беда случается на следующем шаге, когда документ режут на фрагменты, подходящие по размеру модели эмбеддингов.
Наивное разбиение считает символы или слова и режет там, где кончился лимит. Таблица для такого резака — просто длинная строка. Первая часть получает шапку и несколько строк, вторая — только строки, и вопрос из первого примера оказывается без ответа.
Docling предлагает другой путь. Согласно описанию разбиения, HybridChunker уточняет иерархическое разбиение с учётом токенизатора. Идея в том, что сначала фрагменты строятся по структуре документа (разделы, абзацы, таблицы), а затем те из них, что не умещаются в лимит токенов, дробятся дальше. Лимит считается в тех же единицах, в каких работает ваша модель, а не в символах, поэтому фрагмент не обрезается посреди ограничения.
Для таблиц ключевое свойство другое: в той же документации сказано, что заголовки таблицы могут повторяться в её фрагментах. Слово «могут» здесь стоит читать внимательно. Это возможность, которую нужно проверить на своих данных, а не автоматическое свойство любого результата. Проверка простая: возьмите длинную таблицу, разбейте и посмотрите на второй и третий фрагменты. Если в начале каждого видны названия колонок, поведение подтверждено на вашем файле. Если нет, значит, нужно подбирать другой способ записи таблицы или добавлять заголовок вручную на этапе подготовки текста для индекса.
Как выглядит такая проверка на практике. Допустим, таблица на 47 тарифов разбилась на три фрагмента. Откройте текст каждого и ответьте на три вопроса: видны ли названия колонок в начале; видна ли подпись или название раздела; не оборвалась ли строка посередине, так что часть цены попала в один фрагмент, а единица измерения в другой. Если первый вопрос даёт «да» только для первого фрагмента, то повтор заголовков в вашем случае не сработал, и заголовок придётся приписывать самим при подготовке текста для индекса.
Важно и то, чем именно считается лимит. Поскольку HybridChunker учитывает токенизатор, при смене модели эмбеддингов границы фрагментов сдвинутся: то, что раньше умещалось в один фрагмент, может разделиться, а строки, стоявшие вместе, окажутся порознь. Поэтому после замены модели или её токенизатора контрольные вопросы нужно прогонять заново, даже если сами PDF и способ извлечения не менялись.
Если фрагмент всё равно получился слишком большим, надёжнее резать таблицу по границам строк и каждому куску возвращать шапку, чем делить по числу токенов. Тогда ни одна строка не окажется разрезанной, а любой фрагмент можно прочитать отдельно от остальных.
Тот же фрагмент после исправления
Вернёмся к прайсу. Плохой фрагмент выглядел так:
500 ГБ 1 800 2 160Хороший фрагмент несёт вместе с данными их контекст. Например, так:
Тарифы на хранение (цены условные). Колонки: Объём; Цена за месяц, руб. без НДС; Цена за месяц, руб. с НДС.
Строка: 500 ГБ; 1 800; 2 160.Теперь модель видит, что 1 800 — цена без НДС, а 2 160 — с НДС. Заметьте, что мы не добавили ни слова от себя: названия колонок и подпись взяты из оригинала. Если же вопрос про «стоимость для бухгалтерии с налогом», ответ берётся из последней колонки, а не из середины.
Этот пример хорош тем, что показывает разницу между точностью и смыслом. В плохом варианте модель могла ответить точно цитируя число и всё равно ошибиться по существу. В исправленном ей не нужно ничего угадывать.
Таблица на двух страницах
Отдельный случай — длинный прайс, который перетекает на следующую страницу. Для читателя-человека всё очевидно: на второй странице то же самое, просто строки продолжаются, часто с повторённой шапкой. Для разбора это две разные таблицы.
Тут возможны два сценария, и они требуют разного поведения.
- Шапка повторяется на второй странице. Система может считать её отдельной строкой данных. Тогда в индексе окажется «строка» вида «Объём; Цена без НДС; Цена с НДС» среди тарифов. Её нужно распознать как повтор и не вставлять как значение.
- Шапки на второй странице нет. Тогда продолжение выглядит как таблица без заголовков, и связь с первой частью надо восстанавливать: либо объединять части в одну таблицу до разбиения, либо явно приписывать им заголовок первой части.
Как проверить, что вы справились? Подсчитайте строки данных в оригинале и в результате. Если в оригинале 47 тарифов, а в результате 46 или 49, что-то потерялось или продублировалось. Учтите, что число строк данных проверять можно, а вот одинаковое число ячеек в каждой строке — нельзя, если в таблице есть объединения. Различие между этими проверками важно: первая ловит потерю строк, вторая даёт ложные тревоги.
Теперь про связь частей. Мало, чтобы заголовок повторился. Нужно ещё, чтобы фрагмент знал, что он продолжение. Простейший признак — одинаковая подпись и название раздела в начале каждого фрагмента. Тогда при поиске «тариф на 500 ГБ» вы получите не обрывок, а кусок с контекстом, и модель сможет упомянуть, из какого прайса он взят.
Контрольные вопросы: как поймать то, что не видно глазом
Сравнивать извлечённую таблицу с оригиналом «на глаз» утомительно, и глаз пропускает именно мелкое: сдвинутую колонку, потерянную единицу, склеенную пару строк. Надёжнее собрать маленький контрольный набор вопросов и ответов и прогонять его после каждого изменения способа разбора.
Хороший вопрос для такого набора устроен так, чтобы ошибка в извлечении меняла ответ. Вот несколько типов.
| Тип вопроса | Пример | Что выдаёт при ошибке |
|---|---|---|
| Выбор колонки | «Сколько стоит 500 ГБ с НДС?» | Потерянный заголовок или сдвиг колонок |
| Единица измерения | «В каких единицах указана цена?» | Потеряна подпись или сноска над таблицей |
| Соседняя строка | «Чем 500 ГБ отличается от 100 ГБ по цене?» | Строки попали в разные фрагменты |
| Объединённая ячейка | «К какой группе относится тариф на 500 ГБ?» | Название группы осталось только у первой строки |
| Продолжение таблицы | «Какая цена у последнего тарифа на второй странице?» | Нет шапки или она записана как данные |
| Подсчёт | «Сколько тарифов в прайсе?» | Потеряна или продублирована строка |
Для каждого вопроса запишите эталонный ответ и страницу, откуда он взят, глядя в оригинал, а не в результат разбора. Иначе вы проверите не извлечение, а собственную веру в него. Начать можно с десяти-пятнадцати вопросов на тип документа; для первых прогонов хватит и такого объёма.
Результат тоже стоит разбирать по уровням. Если ответ неверен, вернитесь на шаг назад и посмотрите на найденные фрагменты. Если фрагмент неверный, смотрите на разбиение. Если фрагмент верный, а ответ нет, проблема в поиске или в самой генерации. Такая цепочка избавляет от соблазна чинить всё сразу.
И ещё одно наблюдение. Извлечённая таблица, которая прошла все проверки, не гарантирует, что цифры в оригинале верны: если в прайсе опечатка, мы честно перенесём опечатку. Задача разбора — сохранить то, что написано, и связи между написанным, а не вычитывать документ вместо автора.
Где всё это запускать
Для пробного прогона на своих документах обычно хватает небольшого сервера. Если вы собираете такой стенд на VPS, имеет смысл начинать с малого тарифа и смотреть, как поведёт себя разбор на реальных файлах. В публичном предложении VPS MAATRIX указаны NVMe-диски, процессоры AMD EPYC и root-доступ, а линейка тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идёт до Djed (16 vCPU, 32 ГБ, 320 ГБ). Это заявленные ресурсы, а не обещание конкретной скорости: сколько страниц в минуту вы обработаете, покажет только замер на вашем наборе, особенно если среди документов много сканов с полным OCR. Сам разбор и сравнение результатов с эталоном можно разворачивать как отдельный шаг. Общую схему работы с PDF на собственном сервере мы описывали в материале про RAG по своим PDF без облака.
Читаем ответ вместе с таблицей
Вернёмся к вопросу из начала. Пользователь спрашивает, сколько стоит 500 ГБ в месяц. Система находит фрагмент с подписью «Тарифы на хранение», названиями колонок и строкой «500 ГБ; 1 800; 2 160». Ответ звучит так: цена за месяц без НДС составляет 1 800 рублей, с НДС — 2 160 рублей. Под ответом видно, откуда он взят: подпись таблицы и сама строка прайса.
Теперь можно прочитать его так же, как читал бы внимательный человек: число стоит рядом с заголовком своей колонки, единица измерения названа, строка соответствует запросу. Каждое звено цепочки «вопрос — строка — колонка — единица — ответ» восстановлено, и ответ можно сверить с оригиналом одним взглядом.