Архив рубрики ~Обо всем~

Прекратите возвращать плоский текст из PDF-файлов: реляционная структура, необходимая для RAG.

Прекратите возвращать плоский текст из PDF-файлов: реляционная структура, необходимая для RAG.

Enterprise Document Intelligence [Том 1 #5B] – Один PDF-файл на входе, на выходе – реляционный набор DataFrames: строки, страницы, оглавление, изображения, перекрестные ссылки, подписи, фрагменты и сводная информация.

Делиться

5be5a1e7660ca7f1dee1dfd1c17d6bbb
Фотография Глена Кэрри, предоставлена Unsplash.

Эта статья — первый этап парсинга в серии статей «Enterprise Document Intelligence», которая строит корпоративную систему RAG из четырех компонентов: парсинг, парсинг вопросов, поиск и генерация. Парсинг идёт первым, и это вторая из двух частей. В предыдущей части PDF-файл был преобразован в line_df , где каждая строка текста соответствует отдельной строке на странице. В этой статье рассматривается остальная часть модели: полный набор таблиц, которые должен генерировать парсер, что каждая из них содержит и как они связаны между собой, так что таблица на странице 14 сохраняет свои столбцы, а плата за продление остаётся привязанной к её метке. Три других компонента, а также выделенный ответ в конце, считывают эти таблицы, а не исходный PDF-файл.

eb701872a67e449f3c895e337b5c0e5d
Место данной статьи в серии: Статья 5, часть II (четыре части, посвященные анализу данных), посвященная модели данных – Изображение предоставлено автором.

В обучающих материалах RAG всё начинается одинаково: text = extract_text(pdf) . Именно в этой единственной строке начинаются проблемы с PDF-файлами.

Вы создаёте конвейер обработки данных RAG. Он работает с несколькими чистыми документами. Затем клиент присылает вам реальный договор: 30 страниц, с таблицей расписания платежей на 14-й странице. Пользователь спрашивает: «Какова плата за продление?», и модель возвращает неверное число.

Команда заявляет: «Модель не может читать таблицы».

Модель корректно считывает таблицы. Проблема на более раннем этапе. Ваш парсер прошелся по ячейкам таблицы и объединил их в одну длинную строку. Структура столбцов исчезла. Связь между меткой и ее значением также исчезла. Вашей модели предлагается угадать, какое число является платой за продление. Иногда она угадывает правильно. Часто — нет.

72e943d206cf8bf6ae683d8058e5db6a
Те же четыре строки, соединенные ячейка за ячейкой в один блок. Единовременная оплата 200 евро, за просрочку платежа 75: кто с кем в паре? – Изображение автора

Парсер не дал сбоя. Он дал вам то, что вы запрашивали. Вы запрашивали не то.

Хороший парсер PDF-файлов не извлекает текст. Он моделирует документ как реляционный набор таблиц . Один PDF-файл на входе, одна таблица на выходе для каждого типа данных (семи-восьми сегодня, и больше по мере появления новых потребностей).

  • toc_df : разделы, как их написал автор.
  • page_df и line_df : тело документа. Каждая страница. Каждая строка.
  • image_df : каждый рисунок на каждой странице.
  • span_df : жирный, курсив, цвет, размер шрифта. Для каждого элемента span в каждой строке.
  • object_registry : каждая подпись к рисунку, каждая подпись к таблице, каждое приложение.
  • cross_ref_df : каждый «см. Рисунок 2», каждый «см. Таблицу 4», каждый «см. Приложение B».
  • parsing_summary : показывает, является ли PDF-файл изначально цифровым, отсканированным или смешанным. Также показывает, хорошее или плохое качество распознавания текста (OCR).

Функция извлечения считывает эти таблицы. Функция генерации считывает эти таблицы. Функция подсветки считывает эти таблицы. Вы открываете PDF-файл один раз. После этого вы работаете только с таблицами.

В этой статье подробно рассматривается каждая таблица, а затем сопоставляется работа parse_pdf на двух совершенно разных PDF-файлах, чтобы показать, что одни и те же столбцы охватывают оба. Предыдущая статья («За пределами extract_text: два уровня PDF, определяющие качество RAG») рассматривает сторону обработки данных: объявленные сигналы, которые парсер считывает первыми, и классификацию на уровне страницы, которую он выполняет до присвоения номера какой-либо строке.

934b1a83ce81f1ff8cff4b1f97e90d76
Как создается каждая таблица: line_df, parsing_summary, toc_df и image_df берутся непосредственно из анализа; page_df, span_df, object_registry и cross_ref_df выводятся из line_df – Изображение предоставлено автором

1. Одна таблица на каждую сущность.

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

Соглашение об именовании _df позволяет считывать детализацию из самого имени. Диаграмма в верхней части этой статьи показывает , как создается каждая таблица . Четыре таблицы получаются непосредственно из анализа: line_df (текстовые строки), parsing_summary (синтез на уровне документа), toc_df (собственная структура, через doc.get_toc ) и image_df (через page.get_image_info ). Остальные четыре выводятся из line_df : page_df агрегирует его по страницам, а span_df , object_registry и cross_ref_df извлекаются из его строк. Как затем таблицы соединяются друг с другом — это отдельный вопрос, рассматриваемый в разделе 2.

1.1. toc_df: оглавление

Оглавления встречаются повсюду в корпоративных документах. Контракты, отчеты, политики, руководства для сотрудников, нормативные документы: почти все они содержат заявленную структуру разделов, и эта структура является самым дешевым семантическим сигналом, который вы можете передать поисковому роботу.

Подвох в том, что это не всегда исходный код. Иногда это всего лишь типографические элементы (жирные заголовки, нумерованные разделы, подзаголовки с отступом), и их приходится восстанавливать из line_df + span_df .

Здесь мы сосредоточимся на исходном случае (распространенном для экспорта в цифровые форматы LaTeX, Word и InDesign); восстановление оглавления из типографики при отсутствии закладок — это отдельная тема, которая рассматривается с помощью адаптивного парсера и подробно освещается в отдельной статье.

3416e165a55b4858080651dcd315324a
объявленная структура с parent_idx и breadcrumb ; пусто, если нет собственных закладок – Изображение автора

Как это построить: build_toc_df(doc) вызывает doc.get_toc(simple=False) (одна запись на закладку, с прикрепленным словарем назначения) и обрабатывает результат для вычисления parent_idx , breadcrumb , end_page и start_y . При запуске на примере статьи об Attention вы получите 22 записи, уже показанные в разделе 1.2 выше: три уровня заголовков, собственные закладки, перестройка не требуется.

Неявное соглашение end_page : оглавления отмечают начало разделов, почти никогда не их конец. build_toc_df в любом случае материализует конец в виде столбца: для каждой строки end_page — это start_page следующей записи на том же уровне или ниже (следующий аналог или предок), а total_pages используется в качестве резервного значения для последнего раздела. Посмотрите заключение к статье об Attention: start_page=10 , end_page=15 . Документ содержит всего 15 страниц, поэтому последний раздел поглощает все до конца документа. Соглашение сохраняет перекрытие в одну страницу по замыслу ( end_page раздела — это start_page его преемника, а не successor.start_page - 1 ), что делает просмотр следующей страницы в блоке генерации (сильный сигнал полноты, который обнаруживает усеченные списки на границах разделов) однократным поиском, а не сканированием во время выполнения.

Для информации о столбце start_y : каждая закладка в структуре PDF-файла содержит целевую Point(x, y) на целевой странице, а не просто номер страницы. build_toc_df отображает значение y как start_y (сырое значение, возвращаемое функцией fitz). Она привязывает каждый заголовок раздела к точному положению внутри start_page , что обеспечивает разрешение на уровне строк: тот же самый способ соединения (target_page, target_y) → строка, который используется для нативных ссылок в разделе 1.6. Та же оговорка относительно ориентации координат: 720 в статье Attention (LaTeX, снизу вверх) и 72 в NIST CSF (Acrobat, сверху вниз) указывают на верхнюю часть страницы, но с противоположных точек отсчета. Мы храним сырое значение; вызывающие функции нормализуют его, когда им нужно попасть на определенную строку.

start_page и end_page это привязки на уровне страницы. Привязки на уровне строки ( start_line , end_line ) являются естественным уточнением: они позволяют последующим этапам точно определить раздел по строке в line_df и обеспечивают обнаружение смещения оглавления, когда в документ вставляется вводная часть после генерации оглавления (все оглавление смещается на 1 или 2 страницы, что является реальной проблемой). Полное описание содержится в отдельной дополнительной статье о привязках и проверке оглавления; на данный момент toc_df ограничивается детализацией на уровне страницы (с start_y в качестве дополнительной колонки для вызывающих переменных, готовых к разрешению до строки).

Роль: toc_df — это самый дешевый семантический сигнал во всем конвейере обработки данных. Каждая запись обозначает раздел: знание того, что строки 100–150 относятся к «3.5 Позиционному кодированию», сообщает извлекателю и языковой модели, о чем эти строки, до вычисления каких-либо эмбеддингов. Эмбеддинги обеспечивают тематическую близость; оглавление дает собственное структурное значение каждого региона документа, заявленное автором, а не выведенное из контекста. breadcrumb расширяют это иерархическим контекстом: фрагмент помечается как «Методы > 3.5 Позиционное кодирование», что дает языковой модели привязку на уровне раздела без увеличения объема текста фрагмента. end_page позволяет генеративному блоку просматривать одну страницу за извлеченным разделом и обнаруживать усеченные ответы без предварительной обработки. Когда документ имеет собственное оглавление, все это происходит бесплатно.

Внимание: записи в оглавлении могут указывать на несуществующие страницы (некорректный или усеченный экспорт). Перед записью строки убедитесь, что значение 0 <= page_num < n_pages , иначе якорь раздела никуда не приведет, и объединение диапазона страниц из раздела 2 молча вернет пустую строку.

1.2. line_df: детализация строки

Источник достоверной информации о текстовом содержании. Каждая строка PDF-файла, с указанием ее положения и преобладающего типографского стиля.

09237730c80c959fcde52b12af1758f8
Одна строка на текстовую строку с ограничивающим прямоугольником, настройками типографики, режимом рендеринга и column_position — Изображение предоставлено автором.

Как это построить: fitz_pdf_to_line_df(pdf_path) проходит по каждому текстовому блоку каждой страницы и выдает по одной строке на каждую строку. Затем assign_column_positions(line_df) аннотирует каждую строку с помощью single / left / right / multi . Запустите на data/paper/1706.03762v7.pdf , статьи Attention Is All You Need (Vaswani et al. 2017; лицензия arXiv non-exclusive distribution, указанная на странице аннотации arXiv). Вот страница 4 статьи (область рисунка 2, занимающая две колонки):

426676a372aa4b3103c518a78c7ab0e8
Строки 1-2 соответствуют подписям к рисунку 2, расположенным в противоположных столбцах. – Изображение предоставлено автором.

Роль: line_df — это единый манифест документа для каждого элемента. Сначала текстовые строки, но та же структура строк также содержит заполнители изображений и таблиц: каждый видимый элемент контента на странице представляет собой одну строку со своим собственным bbox, column_position и флагом content_type ( text , image , table ). Поля, специфичные для текста (font, render_mode), имеют значение NaN для строк, не содержащих текст; подробные метаданные изображений и таблиц хранятся в image_df и в выходных данных экстрактора таблиц, объединенных обратно через (page_num, line_num) . В результате один отсортированный запрос к line_df.page_num возвращает каждый элемент на странице в порядке чтения, независимо от его типа. Последующим этапам не нужно объединять три таблицы, чтобы узнать, что находится на этой странице.

Внимание: при работе с PDF-файлами объемом в несколько гигабайт или тысяч страниц одновременное хранение каждой строки (и изображения) в памяти представляет собой проблему. Легковесный режим, пропускающий line_df и image_df для конечных точек, которым требуется только parsing_summary (классификация, краткое описание на уровне документа), позволяет снизить затраты на эти функции; для остальных случаев полный анализ выполняется во время загрузки данных.

Скриншот ниже сделан из Enterprise Document Intelligence, настольного приложения, которое я разрабатываю. На панели «Текст» справа отображается line_df : исходный текст страницы, построчно, проанализированный один раз и прочитанный непосредственно из таблицы рядом с исходной страницей, с которой он был взят.

13b5dc86586fb3424f990f1c0f6c56f0
line_df отображается: исходный текст страницы, считываемый непосредственно из таблицы, рядом с оригинальным содержимым страницы – Изображение предоставлено автором

1.3. page_df: гранулярность страниц

Постраничный анализ. Классификация, флаги, агрегированные метрики.

d4c4e06784ba7cd8840e4d88884c8899
Постраничный синтез: page_type , флаги аддитивности, количество символов, n_columns – Изображение предоставлено автором

Как это построить: build_page_df(line_df) группирует line_df по page_num . detect_columns_per_page(line_df) вычисляет n_columns , и результат объединяется.

Что ещё сюда подходит: build_page_df — это подходящее место для любых сигналов для каждой страницы, которые можно агрегировать из line_df за один проход. Помимо основной тройки, сюда бесплатно попадают простые агрегации: n_lines (плотность страницы), native_chars против ocr_chars (быстрый вывод о том, отсканировано или нет, классификатор не требуется), n_fonts и разброс размеров шрифтов (приблизительный структурный индикатор, который отличает страницы с большим количеством заголовков от простого текста), image_coverage_ratio (объединение с image_df ). Столбцы, требующие последующего прохода, ждут своей очереди: page_type создаётся функцией classify_page (описанной в предыдущей статье), а parsing_method / context_structured — адаптивным каскадом, который переключается на более мощный парсер, когда fitz недостаточно.

Запустите программу "Внимание":

6e08090e4935ac7fc8bd34483d8afc07
Дешевые агрегации рядом с основной триплетной структурой в статье «Внимание» — изображение автора.

Роль: page_df — это база данных для извлечения информации. Каждый парсер, каждый запуск OCR, каждый классификатор обрабатывают каждую страницу по отдельности; page_df — это таблица, которая записывает, что представляет собой каждая страница и как с ней следует работать. Страница также является хорошей семантической единицей сама по себе: примерно одна или две идеи на странице в научных статьях, один пункт на странице в контрактах, одна подтема на странице в технических отчетах. Достаточно мала, чтобы быть сфокусированной, достаточно велика, чтобы нести контекст. Именно поэтому в минимальном конвейере RAG извлечение информации обычно по умолчанию осуществляется постраничными фрагментами, и именно поэтому большинство последующих координационных операций используют page_num в качестве ключевого параметра. Когда вы запрашиваете «о чем страница 5», page_df — это строка, которая дает ответ; когда вы запрашиваете «все отсканированные страницы с плохим OCR», page_df — это то, что вы фильтруете.

Внимание: храните page_width и page_height для каждой строки, никогда не храните их один раз для всего документа. В технической издательской практике используются форматы Letter и A4, а для широкой таблицы часто вставляется страница альбомной ориентации; единый размер страницы на уровне документа приводит к тому, что все показатели, полученные с помощью bbox (обнаружение столбцов, охват изображения на всю страницу), начинают изменяться на страницах нестандартных размеров.

1.4. image_df: гранулярность изображения

Одна строка на каждое встроенное изображение.

5af9c38faee52df09ce5282cb151df13
Одна строка на каждое встроенное изображение с указанием ограничивающей рамки, размеров и хеша содержимого – Изображение предоставлено автором.

Как это построить: Парсер проходит по каждой странице и вызывает page.get_image_info() , которая возвращает каждое встроенное изображение с отображаемым ограничивающим прямоугольником и внутренними размерами. В статье об механизме внимания (Attention) их три:

834f9d5f1983ca5a5f7f3309079bf36c
3 изображения: страница 3, Рисунок 1, страница 4, две панели Рисунка 2 – Изображение предоставлено автором.

Описание содержимого изображения: На данный момент image_df определяет местоположение каждого изображения только по ограничивающей рамке, размеру и хешу содержимого. Он ничего не говорит о том, что изображено на картинке, и ограничивающая рамка недоступна для извлечения. Диаграмма или график не содержат извлекаемого текста, поэтому OCR и парсеры, основанные на компоновке, оставляют эту часть пустой: для них эта область невидима. Чтобы сделать изображение доступным для поиска, мы применяем Vision LLM к каждому извлеченному изображению и сохраняем краткое описание рядом со строкой, например, «линейный график цен на сырьевые товары с 2022 года» или «архитектура Transformer, кодировщик из N слоев, расположенных друг над другом». Это описание представляет собой текст, поэтому поиск может быть сопоставлен с ним. В дополнительной статье, посвященной обогащению Vision LLM, этот шаг описан подробно.

8ef341473d3e0aeb00934714c21baabc
Каждое извлеченное изображение получает описание в одно предложение, которое может быть сопоставлено при поиске текста. – Изображение автора

1.5. object_registry: перекрестная ссылка на цели

Перекрестная ссылка имеет две стороны. Цель — это местоположение именованного объекта в документе: строка «Рисунок 2: Архитектура модели Transformer» на странице 3, строка «Таблица 1: Показатели BLEU» на странице 8. Источник — это упоминание в основном тексте, указывающее на цель: «как показано на Рисунке 2», «см. Таблицу 1». object_registry фиксирует целевую сторону, по одной строке на каждый заголовок. Следующий подраздел (раздел 1.6) фиксирует исходную сторону. Разрешение источников в целевые страницы, так что полученный фрагмент, в котором упоминается «см. Таблицу 1», также извлекает страницу, где находится Таблица 1, — это последующий проход перекрестной ссылки, который обрабатывает обе таблицы.

c68179b46a63ffb6ec0bd3477759b639
Подписи для именованных объектов, по одной строке на каждый целевой объект, (object_type, object_id) — ключ объединения — Изображение предоставлено автором

Как это построить: Обнаружение использует шаблоны регулярных выражений, привязанные к началу строки (там начинается реальная подпись, а упоминание в основном тексте — нет); build_object_registry проходит line_df , сопоставляет каждую строку с шаблонами и сохраняет первое совпадение для каждой пары (object_type, object_id) . О статье об механизме внимания:

 OBJECT_PATTERNS = [ (re.compile(r"^s*(?:Figure|Fig.?)s+(d+)b", re.IGNORECASE), "figure"), (re.compile(r"^s*Tables+(d+)b", re.IGNORECASE), "table"), (re.compile(r"^s*(?:Annex|Appendix)s+([A-Z0-9]+)b", re.IGNORECASE), "annex"), ] def build_object_registry(line_df: pd.DataFrame) -> pd.DataFrame: """Returns one row per (object_type, object_id), first match wins."""

При использовании элемента интерфейса Attention, конструктор размещает по одной строке для каждого именованного объекта, используя строку заголовка в качестве якоря:

93937113babd899ba4e2a8cef37ae2cf
На листе «Внимание» представлено 5 рисунков и 4 таблицы, каждая с пояснительной подписью – Изображение предоставлено автором.

1.6. cross_ref_df: перекрестные ссылки на источники

Симметричная половина object_registry . Каждая строка содержит одно упоминание именованного объекта в основном тексте: «как показано на рисунке 2» на странице 4, «см. таблицу 1» на странице 7, «подробности см. в Приложении B» на странице 12. Каждое такое упоминание является источником, который после разрешения переходит на страницу, зарегистрированную в object_registry .

Аналогично оглавлению, эти строки можно получить двумя способами: с помощью собственных ссылок PDF (детерминированный источник, если документ их содержит) и путем сопоставления текстовых шаблонов с line_df (общий резервный вариант, используемый функцией build_cross_ref_df ). Метод 1 является точным, но частичным. Метод 2 является приблизительным, но полным.

Метод 1, собственные ссылки PDF: PDF-файл может содержать собственные кликабельные перекрестные ссылки. fitz.Page.get_links() возвращает одну запись для каждого прямоугольника ссылки, при этом целевой объект закодирован в виде тройки (target_page, to.x, to.y) для внутреннего перехода или URI для внешнего перехода:

 import fitz doc = fitz.open("data/nist/NIST.CSWP.29.pdf") for page in doc: for ln in page.get_links(): tgt_page = ln.get("page") tgt_pt = ln.get("to") # Point(x, y) on the target page print(page.number + 1, ln.get("kind"), tgt_page, tgt_pt, ln.get("uri"))

Самое интересное — это to.y Зная только целевую страницу, вы можете определить, где именно в документе находится ссылка, но не на что она указывает; координата y фиксирует строку внутри этой страницы. Мы разделяем целевую страницу на два скалярных столбца, tgt_page и tgt_y , и определяем целевую строку, находя в line_df строку, y0 которой наиболее близка к tgt_y на tgt_page .

Здесь следует учесть два практических момента:

  • Генераторы PDF-файлов различаются по ориентации по оси Y. LaTeX возвращает ориентацию снизу вверх, Acrobat — сверху вниз. Нормализатор пробует оба варианта и выбирает тот, который лучше соответствует заданным параметрам.
  • tgt_y может находиться между двумя строками. Мы округляем до ближайшего целого числа.

Преимущество: как только мы узнаем конечную страницу, мы можем объединить (target_page, landing_text) с toc_df и напрямую получить индекс раздела . Никаких регулярных выражений, никакого сопоставления текста с хлебными крошками. Собственная ссылка точно указывает, в какой toc_idx мы попали.

9c1b0e7732ab86c41ba309721ea0d253
4 ссылки на оглавление NIST, указывающие на начало разделов, которые можно объединить с toc_df – Изображение предоставлено автором

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

72bcf78badb396e216db61d080c7c842
3. Ссылки на статьи, размещенные в библиографических строках, преобразованы в landing_text – Изображение предоставлено автором.

Подвох в охвате данных. Два демонстрационных PDF-файла показывают один и тот же шаблон:

  • В статье содержится 95 внутренних ссылок, все цитаты ведут к библиографическим записям, а также 18 внешних URI (github, arxiv). Отсутствуют нативные ссылки для упоминаний в основном тексте, например, «как показано на рисунке 2».
  • Структура кибербезопасности NIST 2.0 (CSWP-29; разработка правительства США, общественное достояние в США, см. заявление NIST об авторских правах): 47 внутренних ссылок, все записи в оглавлении и список рисунков указывают на начало разделов, плюс 56 внешних URI. Та же история: ссылки на рисунки или таблицы в основном тексте отсутствуют.

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

Метод 2, сопоставление текстовых шаблонов: для обнаружения используется тот же словарь, что и в OBJECT_PATTERNS , но без привязки, поэтому регулярное выражение соответствует любому месту внутри строки; строки с подписями исключаются, поэтому строка, которая определяет Рисунок 2, не считается его упоминанием.

da2d87afb262bcd547c629f5b9a58f00
Одна строка на каждое упоминание в основном тексте, с возможностью присоединения к object_registry – Изображение предоставлено автором

В информационном документе:

 REFERENCE_PATTERNS = [ (re.compile(r"b(?:Figure|Fig.?)s+(d+)b", re.IGNORECASE), "figure"), (re.compile(r"bTables+(d+)b", re.IGNORECASE), "table"), (re.compile(r"b(?:Annex|Appendix)s+([A-Z0-9]+)b", re.IGNORECASE), "annex"), ] def build_cross_ref_df(line_df: pd.DataFrame) -> pd.DataFrame: """One row per body-text mention, with ~30 chars of context."""

При использовании механизма Attention каждое упоминание рисунка или таблицы в основном тексте документа отображается как строка, которую можно объединить с object_registry :

a3c521e877c0fe0158c50f6541b77cbf
6 из 13 упоминаний; Рисунок 2 встречается трижды на страницах 4-5 – Изображение предоставлено автором

При запуске на демонстрационных PDF-файлах статья Attention содержит 13 упоминаний в основном тексте, охватывающих 6 уникальных объектов (Рисунок 1, Рисунок 2, Таблица 1–4): некоторые рисунки упоминаются несколько раз, что как раз и предназначено для отображения в таблице со стороны источника.

В NIST CSF 2.0 содержится 13 упоминаний (7 ссылок на рисунки, 5 ссылок на приложения, 1 ссылка на таблицу), охватывающих 10 уникальных объектов (5 рисунков, 4 приложения, 1 таблица). Несоответствие с object_registry NIST (6 рисунков + 3 приложения + 2 таблицы) носит информативный характер:

  • Одно приложение упоминается в основном тексте без привязанного к нему заголовка (регулярное выражение обнаруживает ссылку, цель которой находится за пределами проанализированного текста).
  • Один зарегистрированный рисунок и одна зарегистрированная таблица никогда не используются в качестве ссылок.

Оба сигнала являются реальными и заслуживающими внимания, поскольку их стоит передать на рассмотрение системе перекрестных ссылок.

1.7. span_df: детализация построчных данных (необязательно)

Иногда линия слишком грубая. В строке может сочетаться жирный и нежирный текст (определенный термин в договоре). В исследовательской работе может быть встроенное уравнение, выделенное курсивом, рядом с прозаическим текстом. В поправке к договору исходный текст может быть черным, а измененный — красным.

 class Span(BaseModel): # Identity & ordering pdf_hash: str page_num: int line_num: int span_id: int # What it says, where it sits text: str bbox: tuple[float, float, float, float] # Typography signals font_name: str font_size: float is_bold: bool is_italic: bool color_rgb: tuple[int, int, int]

Параметр span_df более детализирован, чем line_df . В статье об механизме внимания соотношение составляет 3480 элементов span на 1048 строк , что примерно в 3,3 раза больше. Это оправдывает себя только на этапах проверки типографики:

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

Как это реализовать: Поведение по умолчанию: parse_pdf(...) возвращает span_df пустым . На последующих этапах, которым это необходимо, вызывается специальный построитель в той же строке:

 paper = parse_pdf(paper_pdf) paper["span_df"] = build_span_df(paper_pdf) # 3,480 rows on the Attention paper

Сохранение строковых данных за явным вызовом позволяет избежать их затрат при каждом разборе на этапах, где требуется только line_df . См. статью Attention:

049a2d44d26de72b45d3c33399a1addc
Строки 1-5 — основной текст, строки 6-7 — заголовок раздела, выделенный жирным шрифтом; ключ is_bold инструмент для восстановления оглавления. — Изображение предоставлено автором.

1.8. parsing_summary: технический синтез

Для каждого документа используется один сериализуемый в JSON словарь. Он с первого взгляда отвечает на вопросы: «Этот PDF-файл отсканирован?», «Нужно ли распознавание текста?», «Какую стратегию извлечения следует использовать на следующем этапе?» И еще один, семантический, который считывают последующие блоки: «Что это за документ и о чем он?»

Словарь организован в пять зон. Первые четыре являются детерминированными и создаются парсером без вызова LLM. Пятая, семантическая, содержит тип документа плюс краткое резюме, написанное с помощью LLM, которое парсер вопросов внедряет в свою системную подсказку.

 { "pdf_hash": "abc123...", "n_pages": 87, "pdf_version": "1.7", "source_software": "word_export", "creator_raw": "Microsoft Word 2019", "producer_raw": "Microsoft Word for Microsoft 365", "content_type": "scanned_with_ocr", "is_scanned": true, "has_text_layer": true, "ocr_quality": "good", "page_type_counts": {"scanned_ocr_good": 80, "native": 5, "empty": 2}, "scanned_page_ratio": 0.92, "has_toc": true, "n_toc_entries": 24, "n_named_objects": 11, "is_encrypted": false, "has_form_fields": false, "recommended_strategy": "use_existing_ocr", "needs_reocr": false, "pages_needing_ocr": [], "doc_type": "annual_report", "typical_fields": ["fiscal_year", "revenue", "net_income", "auditor"], "summary": "87-page annual report for fiscal year 2023. Covers revenue, net income, and auditor's notes across operating segments. Standard sections: Letter to Shareholders, MD&A, Financial Statements, Notes." }

Важно различать source_software (из метаданных) и content_type (определяемый по содержимому). Эти два параметра могут расходиться: PDF-файл, у которого Producer указано «Microsoft Word», но содержимое которого на 100% отсканировано, означает, что кто-то вставил изображения в документ Word и экспортировал их. Это полезная информация; не следует заменять одну другой.

Семантическая зона следует тому же правилу, но по другой оси. doc_type — это грубое семейство ( resume , contract , academic_paper , invoice , memo , annual_report , …), полученное из имени файла + текста первой страницы. Детерминированное, без LLM. typical_fields — это таблица названий полей для каждого типа документа, на которую, скорее всего, будет направлен вопрос о документе такого типа; резюме получает [name, email, phone, experience, …] , контракт — [policyholder, premium, deductible, …] . summary — единственное значение, полученное с помощью LLM в словаре: три-четыре фактических предложения, указывающих тип документа, основную тему и содержащиеся в нем поля. Один вызов LLM во время синтаксического анализа, кэшированный навсегда, внедряется в системную подсказку парсера вопросов, так что вопрос «как называется?» в резюме больше не возвращает «не найдено». Сопутствующая статья о том, что нужно прочитать перед тем, как любой строке присваивается номер («Beyond extract_text»), описывает полную структуру этого summary.

2. Реляционная модель: как связаны таблицы.

Создание таблиц — это одно, а их связывание — совсем другое. После создания таблиц общие ключи превращают восемь отдельных DataFrame в одну модель, доступную для запросов, и почти каждая ссылка ведет обратно к line_df , источнику достоверной информации для каждой строки.

8e30e0d379e07d359367f2d9298505d0
Как происходит соединение таблиц: таблица line_df находится в центре, каждая таблица связана своим общим ключом – Изображение предоставлено автором.

Основная часть информации сосредоточена в нескольких ссылках:

  • toc_dfline_df . Запись в оглавлении знает свою start_pagestart_y ), поэтому из любого раздела вы сразу переходите к строкам, которые к нему относятся. «Сводка раздела 3.5» становится фильтром диапазона страниц по line_df , поиск не требуется.
  • image_dfline_df . Изображение занимает позицию на странице, поэтому в line_df у него есть строка. text этой строки изначально пуст, поскольку изображение не содержит извлекаемого текста. При необходимости, обработка изображения считывает его и записывает краткое описание в эту text ячейку, чтобы при последующем поиске можно было сопоставить ее с «архитектурной диаграммой». Именно эта связь обеспечивает постепенное обогащение: заполняйте ее, когда это необходимо, и оставляйте пустой, когда она не нужна.
  • cross_ref_df → его цель. Упоминание в теле документа указывает на местонахождение цели. «см. Рисунок 2» указывает на object_registry по (ref_type, ref_id) ; «см. раздел 2.3» указывает на запись toc_df . Таблица заполняется по мере совпадения ссылок, поэтому разрешение выполняется лениво, по одному упоминанию за раз.
  • page_df , span_df , object_registry привязываются к line_df по page_num или (page_num, line_num) , то же самое соединение, на которое опирается каждый последующий блок.

В частности, распространенные вопросы сводятся к одному или двум фильтрам:

  • «Кратко опишите раздел 3.5». Найдите его start_page и end_page в toc_df , затем line_df[line_df.page_num.between(start, end)] . Без встраивания, без поиска по ключевым словам, только строки раздела.
  • «Каковы итоговые суммы?» в счете-фактуре из раздела 3.2 → line_df[line_df.column_position == "right"] . Столбец, обнаруженный парсером, теперь является запросом.
  • «Что изображено на рисунке 2?» object_registry преобразует подпись в номер страницы и строку; line_df возвращает текст подписи; и если в ячейке изображения уже заполнена информация, вы получаете и описание.
  • «Где находится ссылка на Таблицу 1?» cross_ref_df[(cross_ref_df.ref_type == "table") & (cross_ref_df.ref_id == 1)] перечисляет каждое упоминание с его (page_num, line_num) , соединенным с toc_df для указания раздела, в котором находится каждое упоминание.

Каждая операция представляет собой фильтрацию или объединение таблиц, уже находящихся в памяти, и никогда не выполняется повторный анализ.

Вот что дают вам объединения на последующих этапах. При извлечении данных извлекается раздел из toc_df , разворачивается до строк в line_df и расширяется до упомянутых в нем рисунков с помощью object_registry ; при генерации считываются эти строки; при выделении текста цитаты отображаются обратно на страницу по (page_num, line_num) . Весь конвейер становится цепочкой недорогих объединений на одном этапе анализа, вместо повторного чтения PDF-файла на каждом шаге. Как эти объединения становятся конкретными первичными ключами SQL, внешними ключами и индексами — это задача уровня хранения данных, выходящая за рамки данной статьи.

3. parse_pdf на двух реальных PDF-файлах, расположенных рядом.

parse_pdf — это единственная точка входа, которая вызывает все вспомогательные функции, описанные выше, и возвращает полный набор связанных таблиц за один раз. Запустите её на двух совершенно разных PDF-файлах, и структура выходных данных будет идентичной: одинаковые ключи, сопоставимые формы.

3.1. parse_pdf side-by-side на двух реальных PDF-файлах

Выполнение обоих запросов и сравнение полученных словарей показывает, что ключи остаются неизменными, а подсчеты по ячейкам отражают структуру каждого документа:

2666a5dbd5143d94da42100456efa5ac
Одинаковые ключи, одинаковая форма во всех документах, с подсчетом количества ячеек в каждой колонке — Изображение предоставлено автором.

Научная статья, написанная на LaTeX, и документ NIST Cybersecurity Framework 2.0 (CSWP-29, работа правительства США, общественное достояние). Два совершенно разных документа: один содержит 15 страниц математических обозначений в двухколоночном формате в стиле NeurIPS, а другой — 32 страницы текста с политикой, сочетающего одноколоночные и двухколоночные разделы. Один и тот же вызов parse_pdf , одинаковые ключи, каждая колонка сопоставима. Статья Attention преподносит полезный сюрприз: эта версия на arXiv содержит 22 записи в оглавлении , вопреки распространенному мнению о том, что arXiv удаляет закладки.

PDF-файл открывается один раз с помощью fitz, каждый вспомогательный модуль использует одно и то же состояние документа, и файл закрывается перед возвратом. Повторного открытия не требуется, повторной загрузки из S3 не происходит, не возникает несоответствий между двумя вспомогательными модулями, которые видят разные версии страниц. После этого извлечение, генерация и аннотирование больше не затрагивают PDF-файл. Они обращаются к словарю.

3.2. Позиция столбца в действии (счет-фактура)

Для column_position в счетах-фактурах используется стандартный подход: позиции располагаются в левой колонке (описания), цены и итоговые суммы — в правой. Мы выбираем вымышленный одностраничный счет-фактуру ( data/invoices/invoice_01.pdf , с открытой лицензией, сгенерированный для этой серии), чтобы макет представлял собой честную двухколоночную систему выставления счетов, а не подпись к рисунку в научной статье.

14e6d30b6015c60657468951bc7aa72d
Каждая строка заключена в рамку столбца, назначенного парсером: синий = слева (описания), зеленый = справа (суммы и итоги) – Изображение предоставлено автором

Сначала посмотрите исходный код. Каждая строка выделена рамкой в соответствии с столбцом, заданным парсером: синий цвет для левой части (описания), зеленый для правой (суммы и итоги). assign_column_positions выбирает именно это разделение:

e3506dfb56fe209493ad11dd373c2423
Заголовочная строка слева при x0 ≈ 54, итоговые суммы справа при x0 ≈ 391-514, позиция товара разделяет описание слева и количество + цену справа в той же точке y0 – Изображение предоставлено автором

Заголовочная строка находится в левом столбце при x0 = 54 Ниже таблицы товаров итоги располагаются справа: «ИТОГО К ОПЛАТЕ:» при x0 ≈ 391 , сумма $2,027.56 . США при x0 ≈ 497 Строка при y0 = 397.13 наглядно демонстрирует разделение: описание «Обучение персонала» находится при x0 = 54 (слева), количество 0.5 и цена за единицу $197.58 США находятся при x0 ≈ 343 и x0 ≈ 395 (справа). Далее запрос «итогов» сводится к однострочному запросу к line_df : line_df[line_df["column_position"] == "right"] .

Никакого визуального анализа, никаких вычислений по ограничивающим рамкам. Просто фильтр столбцов в структурированной таблице.

3.3. Два PDF-файла, один и тот же парсер, одинаковая форма.

Два совершенно разных документа, один и тот же парсер, но структурированные результаты напрямую сопоставимы:

1c1cd41ebe0062028fca251165e3b631
Парсер ame, два PDF-файла; строки, столбцы, записи в оглавлении, именованные объекты — все доступно для запросов — Изображение предоставлено автором

Вот как бы это выглядело с наивным парсером get_text() : строка на документ, невозможно определить, какие строки были распознаны OCR, а какие — исходные, нет информации о местоположении подписи к рисунку, нет разделения между левой и правой половинами двухколоночной страницы. Этапы поиска и генерации текста были бы построены на песке.

4. Сохраните один раз, загружайте бесконечно.

Парсинг — самый затратный этап в конвейере обработки данных. Парсинг, поиск и генерация вопросов требуют по одному вызову LLM; парсинг считывает байты и определяет структуру. С PyMuPDF это остается недорогим процессом (менее секунды на небольшом документе). С более мощными движками (Azure Layout, Tesseract, резервный вариант vision-LLM) обработка одного и того же PDF-файла может занять от 30 секунд до нескольких минут . Три итерации обработки запроса на последующем этапе — это три запуска OCR. В этом нет необходимости.

Решение основано на пути к файлу. Каждый PDF-файл записывает свои проанализированные таблицы в зеркальную папку в выходном каталоге, точно соответствующую пути к исходному файлу. Исходя только из пути к PDF-файлу, каждый последующий этап (получение, генерация, аннотирование) знает, где находится кэш.

94359e7c83b3c30bb263b6ac56116cf8
Каждый PDF-файл в data/ имеет двойную папку output/ содержащую разобранные таблицы – Изображение предоставлено автором

Реляционные таблицы сохраняются в формате .xlsx (один файл на таблицу, открывается двойным щелчком), parsing_summary в формате JSON. На данном этапе достаточно Excel: pandas обрабатывает данные без проблем, и каждая таблица остается доступной для просмотра в любом инструменте для работы с электронными таблицами. В качестве уровня хранения данных используется SQLite (внешние ключи, объединения документов, добавление данных при обновлении), но последующие блоки в любом случае используют DataFrames.

save_parsed записывает содержимое папки; load_parsed возвращает тот же словарь или None , если кэш отсутствует. Вызов функции осуществляется в одну строку:

 parsed = load_parsed(pdf_path) if parsed is None: parsed = parse_pdf(pdf_path) save_parsed(pdf_path, parsed)

Дальнейшие этапы обработки следуют тому же принципу. Анализ вопроса записывает ParsedQuestion в questions//parsed_question.json , извлечение сохраняет retrieved_pages.xlsx , генерация сохраняет answer.json . Каждый шаг полностью восстанавливается с диска, каждый шаг можно воспроизвести, не затрагивая LLM снова. При изменении запроса генерации вы не платите за повторный запуск анализа или извлечения.

5. Заключение

Хороший RAG-парсер не извлекает текст. Он преобразует неструктурированный PDF-файл в реляционную модель документа : набор связанных таблиц, объединенных общими идентификаторами ( page_num , line_num , (ref_type, ref_id) ), каждая из которых содержит одну сущность. Извлечение, генерация и аннотирование никогда не считывают PDF-файл повторно; они обращаются к DataFrames. Сохранение парсинга один раз и его постоянная перезагрузка превращают 30-секундную задержку на каждый вопрос в одноразовую стоимость для всего корпуса.

Реляционная структура таблиц, один PDF-файл на входе, никаких плоских строк на выходе. Каждый инструмент, который команда подключает к парсеру (поиск по ключевым словам, сравнение встраиваний, поиск разделов, отображение цитат, журнал аудита, отслеживание изменений), считывает данные из этих таблиц, а не из исходных байтов. PDF-файл открывается один раз, при загрузке. После этого все работает с SQL или pandas. Именно это свойство делает парсерный модуль стоящим вложенных в разработку средств: затраты оплачиваются один раз за документ, и каждая итерация в остальной части конвейера выполняется с использованием стабильного, доступного для запросов артефакта.

Эта статья является частью серии материалов по корпоративной аналитике документов. Минимальный конвейер RAG демонстрирует использование реляционных таблиц от начала до конца в реальном PDF-файле.

6. Источники и дополнительная литература

Ранее в серии:

  • От регулярных выражений до моделей машинного зрения: какой метод RAG подходит для какой задачи — какой метод извлечения данных необходим для каждого типа документа.
  • Анализ документов — введение к серии — четыре составляющие, которые обеспечивают эти таблицы.
  • Базовая версия Enterprise RAG: от PDF-файла до выделенного ответа — минимальный конвейер, который считывает именно эти таблицы.
  • Переранжировщики тоже не волшебство: когда кросс-кодировщикный слой оправдывает затраты — переоценка фрагментов, построенных из этих строк.

Описанный в этой статье парсер имеет ту же архитектуру, что и Docling (Auer et al., Docling Technical Report, IBM Research 2024): определение макета, TableFormer, порядок чтения. Извлечение таблиц без границ использует модель из работы Smock et al. (PubTables-1M / Table Transformer, CVPR 2022). Таксономия классов страниц построена на той же базовой модели, что и в работе Pfitzmann et al. (DocLayNet, KDD 2022). В статье добавлен проход определения режима рендеринга (нативный / отсканированный / смешанный) с оценкой качества OCR. Парсер создает реляционный набор таблиц ( line_df , page_df , image_df , toc_df , object_registry , cross_ref_df , span_df , а также словарь parsing_summary ); При последующем извлечении, генерации и аннотировании PDF-файл не считывается повторно, а запрашиваются DataFrames.

В том же направлении, что и в статье:

  • Ауэр и др., Технический отчет Docling, IBM Research 2024 (arXiv:2408.09869). Эталонная архитектура конвейера, описываемого в данной статье: определение макета, TableFormer, порядок чтения, унифицированное представление документа.
  • Смок, Песала, Абрахам, PubTables-1M / Table Transformer (TATR), CVPR 2022 (arXiv:2110.00061). Обнаружение таблиц и распознавание структуры на основе машинного зрения; модель, лежащая в основе большинства современных парсеров таблиц.
  • Пфицманн и др., DocLayNet, KDD 2022 (arXiv:2206.01062). Эмпирическая базовая модель для таксономии классов страниц и эталонных показателей обнаружения макета.
  • Ло и др., PaperMage, демонстрации EMNLP 2023. Соответствует разделению на индексирование и чтение (анализ для поиска не является анализом для генерации ответов).

Другой ракурс, другой контекст:

  • Фейсс и др., ColPali: Эффективный поиск документов с использованием языковых моделей визуального восприятия, 2024 (arXiv:2407.01449). Поиск визуального восприятия на основе изображения страницы. Контекстом является поиск, где изображение страницы является артефактом, без этапа преобразования в таблицы. В этой статье в качестве основы используются DataFrames, привязанные к ограничивающим рамкам.
  • Ван и др., DocLLM: Генеративная языковая модель с учетом компоновки для понимания мультимодальных документов, JPMorgan 2024 (arXiv:2401.00908). Языковая модель с учетом компоновки, которая считывает PDF-файл напрямую без явного блока реляционного анализа. Тот же подход, что и у ColPali; отличается от реляционного артефакта, доступного для запросов, описанного в этой статье.
  • Ким и др., OCR-free Document Understanding Transformer (Donut), ECCV 2022 (arXiv:2111.15664). Сквозное понимание документов без OCR; полезный контраст с этапом оценки качества OCR, который в этой статье добавляется к обнаружению в режиме рендеринга.

Кежан Ши Посмотреть все Кежан Ши

Источник: towardsdatascience.com

✅ Найденные теги: PDF, Возвращать, новости, Плоский, Прекратите, текст
Читайте также
Архив рубрики ~Лента новостей~ Истинная многозадачность – возможна. И это не суета, и не «слив дофамина» Архив рубрики ~Лента новостей~ ИИ-миллиардеры начинают бояться Архив рубрики ~Лента новостей~ Tether возглавил раунд финансировании NEURA Robotics из Германии Серии С на $1,4 млрд. Архив рубрики ~Лента новостей~ Находим конфликты в пользовательских историях за 10 минут с помощью ИИ Архив рубрики ~Лента новостей~ Генетики прочитали ДНК пожилой пары из эллинистического склепа Фанагории. Их захоронение относится ко II веку до нашей эры Архив рубрики ~Лента новостей~ Обзор Honda Prelude 2026 года: Не ожидал такого поворота событий. Архив рубрики ~Лента новостей~ Мир, о котором мы предупреждали, уже наступил Архив рубрики ~Лента новостей~ Intel более подробно пояснила суть инициативы Firefly по созданию дешёвых ноутбуков Архив рубрики ~Лента новостей~ Компания OpenAI готовится к выпуску продукта для локального развертывания? Архив рубрики ~Лента новостей~ Последствия атаки на Canvas: какие риски возникнут дальше? Архив рубрики ~Лента новостей~ Компания SpaceX официально установила цену акций на уровне 135 долларов, проведя крупнейшее IPO в истории. Архив рубрики ~Лента новостей~ Делаем автоматизацию для Spotify, которая создаёт плейлисты из избранного Архив рубрики ~Лента новостей~ Врачи и Национальная служба здравоохранения могут быть привлечены к ответственности за ошибки, допущенные инструментами искусственного интеллекта, предупреждает доклад. Архив рубрики ~Лента новостей~ Врачи и Национальная служба здравоохранения могут быть привлечены к ответственности за ошибки, допущенные инструментами искусственного интеллекта, предупреждает доклад. Архив рубрики ~Лента новостей~ Истинная многозадачность – возможна. И это не суета, и не «слив дофамина» Архив рубрики ~Лента новостей~ ИИ-миллиардеры начинают бояться Архив рубрики ~Лента новостей~ Tether возглавил раунд финансировании NEURA Robotics из Германии Серии С на $1,4 млрд. Архив рубрики ~Лента новостей~ Находим конфликты в пользовательских историях за 10 минут с помощью ИИ Архив рубрики ~Лента новостей~ Генетики прочитали ДНК пожилой пары из эллинистического склепа Фанагории. Их захоронение относится ко II веку до нашей эры Архив рубрики ~Лента новостей~ Обзор Honda Prelude 2026 года: Не ожидал такого поворота событий. Архив рубрики ~Лента новостей~ Мир, о котором мы предупреждали, уже наступил Архив рубрики ~Лента новостей~ Intel более подробно пояснила суть инициативы Firefly по созданию дешёвых ноутбуков Архив рубрики ~Лента новостей~ Компания OpenAI готовится к выпуску продукта для локального развертывания? Архив рубрики ~Лента новостей~ Последствия атаки на Canvas: какие риски возникнут дальше? Архив рубрики ~Лента новостей~ Компания SpaceX официально установила цену акций на уровне 135 долларов, проведя крупнейшее IPO в истории. Архив рубрики ~Лента новостей~ Делаем автоматизацию для Spotify, которая создаёт плейлисты из избранного Архив рубрики ~Лента новостей~ Врачи и Национальная служба здравоохранения могут быть привлечены к ответственности за ошибки, допущенные инструментами искусственного интеллекта, предупреждает доклад. Архив рубрики ~Лента новостей~ Врачи и Национальная служба здравоохранения могут быть привлечены к ответственности за ошибки, допущенные инструментами искусственного интеллекта, предупреждает доклад.

Оставить комментарий

Подписка на рассылку

Получайте свежие новости и идеи на почту. Без спама — только самое интересное.

Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.