Перейти к содержимому
PD
AI-автоматизация 7 мин чтения

PDF - это не текст: слой ингеста до того, как документ увидит LLM

Большинство плохих ответов RAG рождаются из сломанного извлечения PDF, а не из слабой модели. Показываю слой ингеста, который я строю: триаж страниц, извлечение по координатам, сборка структуры, OCR по страницам и quality gates.

PD

Pavel Duglas

AI Automation & MVP Architect

Почти в каждом втором проекте по автоматизации есть незаметный шаг: “клиент присылает PDF, мы достаем текст, дальше разбирается LLM”. И почти всегда потом ругают модель за ответы, которые были обречены еще до первого токена. Счета, где итоговая сумма прилипла не к той позиции. Договоры, где пункт 7 посреди предложения перебит колонтитулом. Научные статьи в две колонки, прочитанные поперек, так что каждая строка состоит из половины одной мысли и половины другой.

Проблема не в модели. Проблема во входных данных. Ниже слой ингеста, который я теперь ставлю перед любым промптом, и правила, которые помогают держать его дешевым и отлаживаемым.

Почему PDF вас обманывает

PDF не является документом в том смысле, в каком им является файл Word или HTML-страница. Это набор инструкций для отрисовки: нарисуй этот глиф в этих координатах этим шрифтом. Никаких абзацев, предложений, колонок и таблиц внутри нет. Все, что выглядит как структура, достраивает ваш глаз по отступам и пробелам.

Отсюда предсказуемый набор проблем:

  • Порядок чтения. Текст хранится в том порядке, в каком его записала программа-генератор. Иногда он совпадает с визуальным, часто нет.
  • Жесткие переносы строк. Каждая визуальная строка заканчивается переводом строки. Наивное извлечение превращает абзац в 12 обрывков.
  • Переносы слов. “реали-” в конце одной строки и “зация” в начале следующей. Эмбеддинг видит два мусорных токена.
  • Повторяющийся шум. Шапки, подвалы, номера страниц, водяные знаки “Конфиденциально” на каждой странице, вклинившиеся в середину контента.
  • Таблицы в кашу. Ячейки выходят в произвольном порядке, без надежного разделителя, и цифры теряют свою колонку.
  • Сломанная кодировка. У части шрифтов нет нормального маппинга в Unicode. Текстовый слой вроде есть, но декодируется в символы или сдвинутые буквы. С кириллицей в старых выгрузках из 1С и банковских систем это встречается особенно часто.
  • Текста нет вообще. Скан - это просто картинка. Извлечение возвращает пустую строку, и никто этого не замечает.

Если вы отдаете вывод pdftotext прямо в чанкинг и эмбеддинги, все семь пунктов едут в ваш поисковый индекс.

Шаг 1: триаж каждой страницы до извлечения

Я не считаю PDF единым целым. Для меня это список страниц, и каждая получает быструю классификацию:

  • Цифровая с чистым текстовым слоем. Простой случай.
  • Цифровая со сломанным слоем. Текст есть, но декодируется плохо.
  • Скан. Картинка без текста или с кривым OCR-слоем, который кто-то добавил до вас.
  • Смешанная. Цифровая страница со вставленной сканированной страницей подписей или приложением с печатью.

Проверки дешевые, миллисекунды на страницу:

  1. Количество символов на странице. Если на странице с видимым контентом их меньше порога (я начинаю примерно с 50), скорее всего это скан.
  2. Доля букв и цифр от всех символов. Сломанные шрифты дают кучу спецсимволов и кодов из private use area.
  3. Попадание в словарь. Берем выборку слов и смотрим, какая доля есть в словаре ожидаемого языка. Нормальный текст стабильно выше 80%. Мусор после битой кодировки проваливается резко.
  4. Покрытие изображениями. Если одна картинка занимает большую часть страницы, а текста мало, отправляем на OCR.

Главное решение: маршрутизация по страницам, а не по документу. Договор на 40 страниц с одной сканированной страницей подписей не нужно целиком гнать через OCR. Это медленнее, дороже и обычно хуже по качеству, чем родной текстовый слой.

Шаг 2: извлекайте с координатами, а не строками

Самый большой выигрыш дает переход от “дай мне текст” к “дай мне каждое слово с его bounding box и размером шрифта”. В Python это умеют PyMuPDF и pdfplumber. Когда у вас есть x0, y0, x1, y1, size, text для каждого слова, структуру можно собрать самому, а не доверять внутреннему порядку файла.

С координатами можно:

  • Группировать слова в строки по вертикальной позиции.
  • Находить колонки по устойчивому вертикальному промежутку, где на многих строках нет ни одного слова.
  • Правильно выстраивать порядок чтения: колонка за колонкой, сверху вниз.
  • Находить заголовки по размеру шрифта относительно медианы страницы.

В первый раз это примерно день работы, а дальше у вас переиспользуемый модуль, который кладется в каждый проект.

Шаг 3: собирайте структуру скучными эвристиками

Модель для большей части этой работы не нужна. Простые и объяснимые правила закрывают основную массу реальных документов.

Удаляйте повторяющиеся колонтитулы

Строка, которая стоит примерно в одной и той же вертикальной полосе на большинстве страниц и совпадает по тексту (после замены цифр, чтобы номера страниц тоже совпали), является шумом. Вот ядро того, что я использую:

import re
from collections import Counter

def find_repeating_lines(pages, band=0.08, min_share=0.6):
    # pages: список списков (text, y_ratio), где y_ratio от 0 до 1 сверху вниз
    counter = Counter()
    for lines in pages:
        seen = set()
        for text, y in lines:
            if y < band or y > 1 - band:
                key = re.sub(r'\d+', '#', text.strip().lower())
                seen.add(key)
        counter.update(seen)
    limit = len(pages) * min_share
    return {k for k, n in counter.items() if n >= limit}

Запускаете один раз на документ, потом выкидываете совпавшие строки из верхней и нижней полос. На типичном договоре это убирает название компании, заголовок документа и “Страница 3 из 41” с каждой страницы.

Склеивайте строки в абзацы

Для каждой пары соседних строк в одной колонке решаем, относятся ли они к одному абзацу. Сигналы, которые я комбинирую:

  • Вертикальный зазор. Если он близок к медианному межстрочному интервалу, это скорее продолжение. Заметно больше медианы означает новый абзац.
  • Конец строки. Точка, двоеточие или вопросительный знак повышают шанс, что абзац закончился.
  • Начало следующей строки. Строчная первая буква - сильный сигнал продолжения.
  • Длина строки. Строка сильно короче ширины колонки часто завершает абзац.
  • Отступ. Красная строка во многих макетах означает новый абзац.

С переносами так: если строка заканчивается дефисом и склеенное слово есть в словаре, дефис убираем и склеиваем. Если нет (“какой-нибудь”, “COVID-19”), дефис оставляем. Для русского языка словарь лучше брать с морфологией, например через pymorphy, иначе падежные формы будут считаться незнакомыми. Одна эта проверка чинит удивительно много в поиске.

Размечайте заголовки

Строки с размером шрифта заметно выше медианы страницы, или жирные и короткие, становятся заголовками. В очищенном выводе я пишу их как markdown-заголовки. Это важно дальше: чанкинг по заголовкам дает куски, у которых есть смысл.

Шаг 4: таблица - отдельный тип данных

Никогда не пускайте таблицу в поток обычного текста. Находите ее (поиск таблиц в pdfplumber хорошо работает на таблицах с линейками, а для таблиц без линий я ищу ряды слов, выровненных по повторяющимся x-позициям), извлекайте как строки и ячейки и сериализуйте отдельно.

По умолчанию я храню таблицы дважды: как JSON-строки для всего, где нужны точные цифры, и как компактную markdown-таблицу внутри текстового чанка, чтобы LLM видела ее в контексте. Когда пользователь спрашивает “какая сумма за третий квартал”, модель должна читать таблицу с шапкой, а не строку из 30 безымянных чисел.

Шаг 5: OCR и vision-модели только там, где нужно

Страницы, не прошедшие триаж, идут на OCR. Tesseract с русской языковой моделью нормально справляется с чистыми сканами. Для грязных стоит заплатить за облачный OCR или мультимодальную модель.

Модели дешевеют, и очень хочется просто отправлять каждую страницу картинкой в vision-модель и просить markdown. Я это тестировал. На сложных страницах работает отлично, на простых это стрельба из пушки по воробьям. К тому же медленнее, менее детерминированно, и иногда модель придумывает правдоподобную цифру. Поэтому маршрутизирую:

  • Чистая цифровая страница: извлечение по координатам, ноль вызовов модели.
  • Простой скан: классический OCR.
  • Скан со сложными таблицами, рукописным текстом или печатями: vision-модель, а результат помечается как сгенерированный моделью.

На реальной пачке документов от поставщиков это держало vision-вызовы примерно на одной странице из десяти, а стоимость ингеста одного документа оставалась настолько низкой, что о ней не приходилось думать.

Шаг 6: quality gates до индексации

Каждый обработанный документ получает маленький табель:

  • Страницы без текста после всех fallback-вариантов.
  • Доля мусорных символов.
  • Попадание финального текста в словарь.
  • Средняя длина слова (резкий скачок до 15+ символов обычно означает слипшиеся слова).
  • Доля страниц, которым понадобился OCR или vision-модель.

Если любая метрика выходит за порог, документ уходит в карантинную очередь, а не в индекс. Его смотрит человек или он перерабатывается другим маршрутом. Худшее в RAG-системе не отсутствующий документ. Худшее - мусорный документ, который уверенно находится поиском и цитируется пользователю.

Шаг 7: храните происхождение каждого чанка

Каждый чанк у меня несет исходный файл, номер страницы, bounding box области, откуда он взят, и маршрут извлечения (native, OCR, vision). Это окупается тремя способами:

  1. Цитаты. Можно показать пользователю конкретную страницу и подсветить фрагмент.
  2. Отладка. Когда ответ неверный, открываете страницу и сразу видите, что сломалось: извлечение или поиск.
  3. Переобработка. Когда пайплайн улучшился, пересобираете только чанки, пришедшие слабым маршрутом.

Мой стек по умолчанию

Для большинства клиентских проектов я беру вот что:

  • PyMuPDF для быстрого извлечения слов с координатами.
  • pdfplumber для поиска таблиц.
  • Небольшой собственный модуль для триажа, удаления колонтитулов, склейки строк и заголовков.
  • Tesseract для простого OCR, мультимодальную модель для сложных страниц.
  • Вывод в markdown с маркерами страниц плюс JSON-файл рядом с таблицами и данными о происхождении.
  • Метрики качества пишутся в ту же базу, что и задача, так что плохие документы видны в админке.

Ничего гламурного тут нет. Но когда клиент говорит “ИИ постоянно путает цифры”, в девяти случаях из десяти исправление лежит здесь, а не в промпте и не в более дорогой модели. Сначала постройте слой ингеста, измерьте его, и ваша LLM внезапно станет выглядеть заметно умнее.

Вопросы и ответы

Может, проще отправлять все PDF сразу в vision-модель и не городить пайплайн?

Для прототипа на десятке документов можно. В продакшене это дороже, медленнее и менее предсказуемо: модель иногда выдумывает правдоподобные цифры, а на чистых цифровых страницах она проигрывает родному текстовому слою по точности. Я использую vision только для страниц, которые не прошли триаж, обычно это около 10% от объема.

Как понять, что проблема в извлечении, а не в поиске или промпте?

Храните у каждого чанка файл, номер страницы, bounding box и маршрут извлечения. Когда ответ неверный, откройте найденный чанк рядом с исходной страницей. Если текст в чанке уже битый, склеенный или без таблицы, чинить нужно ингест. Если чанк чистый, но не тот, проблема в поиске или чанкинге.

Какие пороги для quality gates ставить на старте?

Я начинаю так: меньше 50 символов на странице с контентом считаю сканом, попадание в словарь ниже 80% считаю подозрительным, средняя длина слова выше 15 символов почти всегда означает слипшиеся слова. Дальше прогоните через пайплайн 50-100 реальных документов клиента, посмотрите, что попало в карантин, и подкрутите пороги под его данные.

Похожие статьи