Закажи экспресс-аудит своего дела онлайн всего за 199 ₽
и получи рекомендации по улучшению - Жми сюда !

Базовая версия Enterprise RAG: от PDF-файла до выделенного ответа.

Enterprise Document Intelligence [Том 1 #1] Самая компактная версия RAG, которая действительно работает с реальным PDF-файлом, с обоснованными ответами и выделенными строками исходного кода.

Делиться

7a2eacb880b9478a8f7143814a36b1a3
Фотография Curvd, предоставлена Unsplash.

Самый быстрый способ понять, что такое RAG, — это создать самую маленькую, работающую версию, запустить её на реальном документе и внимательно понаблюдать за тем, что произошло.

Это статья. Примерно сто строк кода на Python (без векторной базы данных, без фреймворка, без агентов), работающих над статьей «Внимание — это все, что вам нужно» (Vaswani et al. 2017; лицензия arXiv на неисключительное распространение, указанная на странице аннотации arXiv), возвращают ответ с указанием источников, причем точные строки исходного кода выделены на странице.

Затем мы возвращаемся к каждому блоку и задаём вопрос, который он естественным образом вызывает. Каждый вопрос становится темой для последующей статьи.

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

Эта статья является частью более обширной серии «Entreprise Document Intelligence Vol. 1», в которой пошагово выстраивается корпоративная система RAG, начиная с базового конвейера и заканчивая архитектурой масштаба корпуса документов.

2ce71e824cd4b659ba4307334136d5c7

1. Что мы строим

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

  • Анализ документа принимает путь к PDF-файлу и возвращает line_df (одна строка на каждую текстовую строку, содержащая page_num , line_num , text и ограничивающую рамку) плюс page_df . Минимальная версия хранит оба параметра в памяти; более крупные системы сохраняют их в памяти (статья 23 описывает, когда следует переходить к базе данных).
  • Анализ вопроса преобразует вопрос пользователя в объект ParsedQuestion , содержащий нормализованный вопрос и короткий список отмеченных ключевых слов. Он намеренно остается узким: здесь нет логики поиска, нет встраивания вопроса.
  • Функция извлечения обрабатывает ParsedQuestion и выдает k лучших номеров страниц (и, при необходимости, соответствующие номера строк на этих страницах). Передача только номеров страниц позволяет сохранить небольшой размер блока; на следующем шаге происходит восстановление отфильтрованных строк из line_df на месте. Встраивание вопроса находится в этом блоке, поскольку оно зависит от индекса корпуса.
  • Функция Generation объединяет вопрос, line_df и полученные номера страниц, и создает объект AnswerWithEvidence : типизированный JSON, содержащий ответ, диапазон доказательств ( start_page , start_line , end_page , end_line ), степень достоверности, обоснование, точные цитаты из источника и любые оговорки. Полный JSON стоит сохранить для анализа, проверки и воспроизведения.
  • Аннотирование PDF-файла является необязательным. Имея исходный PDF-файл и диапазон ссылок, программа создает аннотированный PDF-файл с прямоугольниками, обведенными вокруг цитируемых строк. Инструмент командной строки, пакетное задание или потребитель API могут пропустить этот шаг; ответ со ссылками уже готов после генерации.

Первые четыре — это четыре основных элемента (статья 5 разрабатывает алгоритм анализа документов, статья 6 — алгоритм анализа вопросов, статья 7 — поиск информации, статья 8 — генерация). Аннотирование PDF-файлов — это этап рендеринга, а не отдельный элемент.

6955ab9110ab064bf2c8665a0e310aba
Базовый конвейер обработки данных RAG от начала до конца – Изображение предоставлено автором.

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

Зависимости минимальны:

  • pymupdf преобразует PDF-файлы в текст с информацией о местоположении; возвращаемые ею ограничивающие рамки используются для выделения ответа на исходной странице.
  • openai — это клиент LLM; через base_url та же библиотека обслуживает Azure, OpenRouter, Ollama или любую совместимую конечную точку.
  • Библиотека pandas хранит документ в виде DataFrame — формата, используемого на каждом этапе анализа и извлечения данных.
  • pydantic определяет схему ответа, которая принудительно формирует структурированный JSON с цитатами.

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

«Для 15-страничной статьи магистр права может прочитать её целиком. Зачем вообще заниматься поиском?» Вполне справедливое замечание по поводу этого документа. Мы используем статью для обучения методу, а не для экономии токенов на этих 15 страницах. Возражение часто ссылается на тест «Иголка в стоге сена» (Камрадт, 2023), где модели, демонстрирующие практически идеальные результаты, находят одно дословное предложение в контексте из 1 миллиона токенов.

Этот критерий — исследование, а не практика. Иголка в стоге сена — это один изолированный, дословный факт, в то время как вопросы, касающиеся бизнеса, объединяют («каждый контракт, франшиза по которому превышает 5000 евро»), сравнивают («пункт 12 по этим трем полисам») или обобщают информацию по множеству фрагментов. Ни один из этих вариантов не представляет собой отдельное предложение.

Ещё две практические причины поддерживают постоянный поток информации. Корпоративные документы часто бывают длинными:

  • страховой договор на 300 страниц,
  • 500-страничный документ, поданный в регулирующие органы,
  • Многотомная техническая спецификация.

Отправка всего материала в LLM обходится в реальные деньги: каждый вопрос, каждый повторный запуск, каждый пользователь — и отвлекает внимание на нерелевантные страницы.

И тот же вопрос возникает одновременно в сотнях или тысячах документов:

  • «найдите все контракты, исключающие ущерб от землетрясений»,
  • «Обобщить изменения в законодательстве этого года по всем представленным документам».

В таком масштабе стратегия «всё бросить» перестаёт быть эффективной. Именно поиск информации позволяет конвейеру обработки данных выдержать оба этапа: от одной короткой статьи до одного длинного контракта и от одного документа до целого корпуса документов.

2. Четыре кирпича и фрагмент PDF-файла.

Каждый шаг объявляет свои входные и выходные данные, и шаги независимы. Выходные данные шага N являются входными данными шага N+1, сохраненными в виде именованного DataFrame, поэтому любой шаг можно повторно запустить самостоятельно, используя сохраненные выходные данные предыдущего шага. В эпоху ИИ-программирования помощник, которому поручено «исправить поиск», может незаметно изменить парсер вопросов, хотя он должен был остаться нетронутым. Независимые модули позволяют уверенно работать над одним компонентом, не нарушая работу остальных.

Приведённые ниже блоки настройки загружают их вместе с клиентом OpenAI.

Каждому блоку, взаимодействующему с моделью, необходим настроенный клиент. В серии используется Python SDK от OpenAI; любой поставщик, предоставляющий совместимую с OpenAI конечную точку (Azure OpenAI, vLLM, --api-server из llama.cpp и т. д.), подключается путем изменения base_url и имени модели.

 import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() client = OpenAI( api_key=os.getenv("API_KEY"), base_url=os.getenv("BASE_URL"), ) model_chat = os.getenv("MODEL_CHAT", "gpt-4.1") model_embed = os.getenv("MODEL_EMBED", "text-embedding-3-small")

2.1 Анализ документов

Мы извлекаем каждую текстовую строку PDF-файла вместе с её положением на странице. В результате получаем DataFrame, где каждая строка представляет собой одну строку, содержащую page_num , line_num , сам текст и четыре координаты ограничивающего прямоугольника x0, y0, x1, y1 .

  • В: путь к PDF-файлу.
  • В итоге получаем: line_df (одна строка на каждую текстовую строку, содержащая page_num , line_num , text и ограничивающую рамку) плюс page_df который мы создадим в разделе 2.3.

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

 def fitz_pdf_to_line_df(file_path): doc = fitz.open(file_path) data = [] for page_num in range(len(doc)): page = doc[page_num] blocks = page.get_text("dict").get("blocks", []) line_num = 0 for block in blocks: if block.get("type") != 0: continue for line in block.get("lines", []): spans = line.get("spans", []) if not spans: continue text = "".join(s["text"] for s in spans) rect = fitz.Rect(spans[0]["bbox"]) for span in spans[1:]: rect |= fitz.Rect(span["bbox"]) data.append({ "page_num": page_num + 1, "line_num": line_num + 1, "text": text, "x0": float(rect.x0), "y0": float(rect.y0), "x1": float(rect.x1), "y1": float(rect.y1), }) line_num += 1 return pd.DataFrame(data)

Применение line_df = fitz_pdf_to_line_df(pdf_path) к статье «Attention» возвращает 1048 строк на 15 страницах.

3350c2cf85c5327fb5b3a5145865f0bb
Первые пять строк файла line_df содержат номер страницы, номер строки, текст и ограничивающую рамку – Изображение предоставлено автором.

Бумага, разбитая на строки. Каждая строка — это отдельная строка с текстом и четырьмя числами, определяющими её местоположение на странице. Столбцы x0, y0, x1, y1 пока мало что значат; в разделе 2.5 мы будем использовать их для рисования прямоугольников на исходном PDF-файле точно по линиям, указанным в модели.

Этот DataFrame, line_df , является основной структурой данных для всей серии статей. В статье 5 представлена более сложная реляционная модель вокруг него ( line_df , chunk_df , toc_df , page_df , image_df ).

Чего этот парсер не делает: не распознает таблицы (таблица 1, страница 4, таблица 3, страница 9, преобразуются в простые строки), не восстанавливает заголовки, сноски, перекрестные ссылки и не обрабатывает многоколоночные макеты. Ничто из этого не имеет значения для вопроса, который мы задаем здесь. Для других вопросов по той же статье это будет важно. Статья 5 полностью посвящена анализу текста.

2.2 Разбор вопроса

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

  • В: текстовый вопрос.
  • Результат: объект ParsedQuestion , содержащий нормализованный вопрос и краткий список проверенных ключевых слов.

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

Зачем вообще нужен минимальный конвейер обработки данных? Две причины:

  • Вы можете объяснить, почему система поиска выбрала именно то, что выбрала. Когда система отвечает неправильно, мы можем увидеть, были ли ключевые слова неверны (проблема синтаксического анализа вопроса) или правильные ключевые слова привели на неправильную страницу (проблема поиска). Без синтаксического анализа вопроса поиск — это «чёрный ящик».
  • Вопрос представляет собой реальный входной параметр, как и документ. В разделе 2.1 документ был преобразован в line_df . В этом подразделе вопрос преобразуется в ParsedQuestionMinimal . Оба входных параметра заслуживают обработки перед этапом поиска. В статье 6 создается более сложный компонент ( parse_question , включающий форму ответа, фильтры области видимости, декомпозицию и т. д.).

В ответ на вопрос «Какие варианты позиционного кодирования упомянуты?» вызов parsed_question = get_keywords_from_question(question, client=client) возвращает parsed_question.keywords = ['positional encoding', 'options', 'mentioned'] .

 question = "What are the options mentioned for positional encoding?" parsed_question = get_keywords_from_question(question, client=client) print(parsed_question.keywords)
 ['positional encoding']

LLM генерирует одну буквальную фразу, например, ['positional encoding'] . Это сделано намеренно. В более раннем варианте этого запроса требовалось указать «3-5 коротких ключевых слов, полезных для поиска», и LLM с удовольствием выполнил эту квоту, используя перефразирования ( positional encoding options , types of positional encoding , transformer positional encoding ). Ни одно из них не указано в документе. Указано только positional encoding . Сопоставление подстрок строгое: одно пропущенное слово уничтожает совпадение. Минимальная версия требует от LLM меньше действий (извлечь буквальную именную фразу, убрать вопросительную формулировку) и доверяет следующему блоку выполнение остальной работы.

Чего не делает эта минимальная версия:

  • определить форму answer_shape (вопрос-ответ против краткого изложения)
  • разложение сложных вопросов
  • взять из предметного глоссария
  • прикрепить подсказки для поиска

Все это описано в статье 6, в разделе, посвященном более функциональному блоку parse_question . Здесь мы храним два поля: corrected_question и keywords — это самая простая версия, которая делает блок видимым.

Примечание: переопределение системной подсказки. Функция get_keywords_from_question предоставляет системную подсказку в качестве аргумента kwarg со значением KEYWORDS_PROMPT по умолчанию. Для тестирования варианта (другая область, более строгие правила, дополнительные примеры) передайте system_prompt=... в месте вызова. Никаких изменений в функции не требуется. Тот же шаблон для всех вспомогательных функций LLM в docintel ( llm_answer_with_evidence предоставляет доступ как system_prompt , так и user_template ). Ниже: тот же вызов, выполненный дважды для вопроса в стиле контракта. Сначала с подсказкой по умолчанию для исследовательской работы, которая остается общей. Затем с подсказкой в стиле контракта, которая использует страховую лексику, такую как exclusions , deductible .

 demo_question = "Are earthquakes excluded from coverage?" # Default: research-paper prompt. parsed_question_default = get_keywords_from_question(demo_question, client=client) print("Default (research-paper):", parsed_question_default.keywords) # Override: insurance / legal contract prompt. contract_prompt = ( "Extract 1 to 3 short keywords from the user question for searching an " "insurance contract or legal policy. Prefer literal terms the contract is " "likely to use: clauses, exclusions, named perils, deductibles, caps. Drop " "question framing words. Output 1 to 3 keywords." ) parsed_question_contract = get_keywords_from_question( demo_question, system_prompt=contract_prompt, client=client, ) print("Contract prompt: ", parsed_question_contract.keywords)
 Default (research-paper): ['earthquakes', 'coverage'] Contract prompt: ['earthquakes', 'exclusions', 'coverage']

2.3 Извлечение

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

  • В: проверенные ключевые слова (и/или нормализованный вопрос, в зависимости от метода) из раздела 2.2.
  • Выводится: номера первых k страниц , а также, при желании, соответствующие номера строк на этих страницах.

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

Стандартный ответ в руководствах RAG 2024 года — это эмбеддинги : преобразовать каждую страницу в вектор и оценить её по косинусному сходству. Статья 2 посвящена им. В минимальной версии мы намеренно этого не делаем по одной причине.

Эмбеддинги непрозрачны. Косинусное сходство возвращает число, например, 0.7798 и просит пользователя поверить, что «страница 6 имеет отношение к вопросу». Покажите этот результат эксперту в предметной области, владельцу продукта или менеджеру: никто не понимает, что означает 0,78 или почему оно выше, чем 0,65. Разработчики могут утверждать, что понимают это («скалярное произведение нормализованных векторов»). Они понимают математику, а не релевантность. На вопрос, почему именно эта страница получила оценку 0,7798 по отношению к этому конкретному вопросу, они пожимают плечами и указывают на модель.

В корпоративном контексте поиск информации — это этап, который вызывает у пользователей наибольшие вопросы. Почему система обратилась именно к этой странице, а не к той? Это нужно объяснить. Поэтому минимальная версия использует то, что мы можем увидеть своими глазами: сопоставление ключевых слов . В разделе 2.2 были отобраны ключевые слова; мы оцениваем каждую страницу по количеству встречающихся на ней ключевых слов и сохраняем три наиболее часто встречающихся.

Где мы ищем и что возвращаем: здесь представлены обе страницы. Реальный поиск имеет два уровня. Якорь — это место, куда фактически попадает ключевое слово или векторное представление (строка, предложение). Контекст — это то, что мы передаем в генерацию (строки вокруг него, страница). Мы ищем небольшое количество информации, и возвращаем большое. Здесь мы используем страницу для обоих случаев. Это работает в случае научной статьи, где каждая страница примерно соответствует одной идее. Статья 7 разделяет два уровня для длинных контрактов, многоколоночных отчетов, документов с большим количеством таблиц.

page_df = build_page_df(line_df) сворачивает 1048 строк в 15 страниц, по одной строке на страницу.

4b7f3a2ff35661ff950614ed879e843c
Первые пять строк файла page_df, по одной строке на страницу, с объединенным полным текстом – Изображение предоставлено автором.

2.3.a Встраивания + косинусное сходство

Встраиваем каждую страницу (один запрос на страницу), встраиваем вопрос, вычисляем косинусное сходство, сохраняем k лучших результатов. Результат: число, например, 0.7798 на страницу. Посмотрите на оценки ниже: можете ли вы сказать, почему страница попала в тройку лидеров? Могли бы вы объяснить рейтинг эксперту в данной области? Именно с этой проблемы непрозрачных оценок начинается статья.

002d4787b913b8a6f8022b6ba96aafe5
Три страницы, занимающие первые места по косинусному сходству. Точные оценки, непрозрачный рейтинг – Изображение предоставлено автором.

Три числа, все очень близки друг к другу (0,7843, 0,7798, 0,7728). Можете ли вы объяснить, почему страница 9 лучше страницы 6? Предварительный просмотр текста делает это очевидным: страница 9 — это таблица «Вариации архитектуры трансформера», страница 5 — о выходных значениях и конкатенации, страница 6 — таблица «Максимальные длины путей». Страница, которая фактически отвечает на вопрос, раздел 3.5 «Позиционное кодирование», находится на странице 6 и занимает последнее место в тройке лидеров. Не связанная с этим страница 5 занимает второе место. Оценки выглядят точными, но за рейтингом нет никакой истории: нет токена, на который можно указать, нет фразы, которую нужно защищать, просто скалярное произведение двух векторов-«черных ящиков». Встраивания работают во многих случаях, и в статье 2 объясняется, откуда берется эта оценка. Но сама оценка никогда не становится интерпретируемой, и в остальной части этой статьи мы используем средство поиска, которое вы можете прочитать своими глазами.

2.3.b Сопоставление ключевых слов

Для каждой страницы подсчитайте, сколько раз в ней встречается parsed_question.keywords (совпадение подстрок без учета регистра). Удалите страницы с нулевым количеством совпадений; сохраните количество k лучших совпадений. В приведенной ниже таблице указаны фактические matched_keywords для каждой страницы, поэтому любой может прочитать ее и понять, почему была выбрана та или иная страница.

retrieve_pages(page_df, line_df, parsed_question.keywords, top_k=3) возвращает три страницы с наибольшим количеством ключевых слов, а также отфильтрованные строки: 314 строк сохранено со страниц 6, 9, 7.

44a98163d8773a3c5a85b98cdf183ffe
Три страницы с наибольшим количеством совпадений по ключевым словам, с указанием соответствующих терминов на каждой странице – Изображение предоставлено автором

Три страницы, ранжированные по количеству совпадений, с фактическим расположением результатов. На страницах 6, 8 и 9 содержится дословная фраза positional encoding ; на странице 6 находится раздел 3.5 «Позиционное кодирование» с фактическим ответом. Любой, кто прочитает таблицу, может проверить результат вручную: найдите в источнике positional encoding , и вы найдете эти три страницы.

Два варианта дизайна:

  • Удаляйте страницы с нулевым количеством совпадений. Запрос, сообщающий об отсутствии совпадений, полезнее, чем запрос, дополняющий данные тремя случайными страницами. Путь к пустому значению в схеме (следующий подраздел) корректно обрабатывает пустой случай.
  • Мы не разрешаем конфликты. Когда страницы имеют одинаковое количество совпадений, порядок определяется значением nlargest возвращаемым pandas. Далее LLM видит строки со всех страниц с одинаковым количеством совпадений в порядке следования в документе и принимает решение.

Со 1048 строк до 300, и мы знаем, что нужный материал там есть.

 def cosine_sim_matrix(query_vec, doc_matrix): q = query_vec / (np.linalg.norm(query_vec) + 1e-12) d = doc_matrix / np.linalg.norm(doc_matrix, axis=1, keepdims=True) return d @ q def retrieve_pages(page_df, line_df, question, top_k=3): q_vec = np.asarray(get_embedding(question), dtype=np.float32) doc_matrix = np.vstack(page_df["embedding"].values) sims = cosine_sim_matrix(q_vec, doc_matrix) scored = page_df.copy() scored["similarity"] = sims retrieved_pages_df = scored.nlargest(top_k, "similarity") kept_pages = retrieved_pages_df["page_num"].tolist() filtered_line_df = line_df[line_df["page_num"].isin(kept_pages)] return retrieved_pages_df, filtered_line_df 

Примечание: ловушка «разделения на отдельные слова». Естественный рефлекс, когда многословные фразы не совпадают: разделить их и искать отдельные токены. Ниже мы разворачиваем каждое ключевое слово на слова, удаляем дубликаты, а затем повторно запускаем поиск. Мы получаем совпадения, а также ложные срабатывания, потому что такие слова, как encoding , transformer , network встречаются по всему документу в несвязанных контекстах.

Теперь каждая страница из первой тройки соответствует нескольким токенам, но посмотрите, каким именно. Слова вроде encoding и transformer занимают большую часть документа. Страницы о многослойном кодировании или стеках кодировщиков кажутся столь же важными, как и страница, которая фактически отвечает на вопрос. Разделение заменяет одну ошибку (ноль совпадений) другой (ложные срабатывания). В статье 7 рассматриваются реальные решения (расширение синонимов с помощью словаря, гибридная оценка); пока же оставьте фразу целиком.

2.3.c Более сложный вопрос: где каждый ретривер ломает

Тот же алгоритм, но другой вопрос. Мы спрашиваем о значении эпсилон, используемом при сглаживании меток. Ответ находится на странице 8 статьи и записан как ε_ls = 0.1 (греческая буква ε , никогда не английское слово epsilon ). Посмотрите, что делает каждый ретривер.

 question_2 = "What is the value of epsilon used in label smoothing?" parsed_question_2 = get_keywords_from_question(question_2, client=client) print("Keywords:", parsed_question_2.keywords)
 Keywords: ['epsilon', 'label smoothing']

Два отказа различной формы:

  • Встраивания ранжируют страницы по тематической близости. Правильная страница (страница 8, где ε_ls = 0.1 ) может входить или не входить в тройку лидеров. Страницы, насыщенные математическими обозначениями, отображаются даже если они не связаны с темой.
  • Ключевые слова не распознают символы. LLM выдает epsilon , label smoothing и т. д. В документе используется греческая буква ε . Поиск подстроки возвращает ноль для всего, что содержит только символ эпсилон. Страница, содержащая ответ, невидима для средства поиска ключевых слов.

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

2.4 Поколение

Мы отправляем полученные строки в магистратуру вместе с вопросом, оформленным в виде блока, разделенного табуляцией, где page_num и line_num располагаются рядом с каждой строкой. Такой формат предоставляет магистратуре точные координаты, необходимые для цитирования.

  • В: исходный вопрос, line_df и номера страниц, полученные из раздела 2.3.
  • На выходе: объект AnswerWithEvidence , структурированный JSON, содержащий ответ, диапазон доказательств ( start_page_num , start_line_num , end_page_num , end_line_num ), уровень достоверности, обоснование, точные цитаты и любые оговорки.
 class AnswerWithEvidence(BaseModel): answer: str = Field(...) start_page_num: int | None start_line_num: int | None end_page_num: int | None end_line_num: int | None confidence: float = Field(..., ge=0.0, le=1.0) justification: str = Field(...) quotes: list[str] = Field(default_factory=list) caveats: list[str] = Field(default_factory=list)

Исходный JSON-файл стоит сохранить в рабочей среде: обоснование, кавычки, оговорки и степень достоверности — всё это используется для оценки, аудита и воспроизведения, выходя далеко за рамки поля answer в пользовательском интерфейсе чата.

Мы сериализуем отфильтрованные строки в TSV-файл с заголовком page_numtline_numttext , по одной строке на каждую строку. LLM видит точные координаты рядом с каждым фрагментом текста, поэтому он может указать ссылку по (page_num, line_num) в своем ответе.

Именно это делает ответ обоснованным : схема заставляет модель заполнить поля (start_page, start_line, end_page, end_line) , дословную цитату и оговорки, если что-то непонятно. Никакого текста, только типизированный объект со ссылками.

Мы вызываем answer = llm_answer_with_evidence(question, filtered_line_df, client=client) и получаем в ответ экземпляр AnswerWithEvidence , который отображается ниже в виде стилизованного JSON-изображения, чтобы метки полей оставались читаемыми.

 def llm_answer_with_evidence(question, filtered_text_prompt): resp = client.responses.parse( model=model_chat, input=[ { "role": "system", "content": ( "Answer using ONLY the provided lines. " "Return JSON only." ), }, { "role": "user", "content": ( f"Lines:n{filtered_text_prompt}nn" f"Question:n{question}nn" "Pick a contiguous evidence span." ), }, ], text_format=AnswerWithEvidence, store=False, ) return resp.output_text

Мы вызываем функцию answer = llm_answer_with_evidence(question, filtered_line_df, client=client) и получаем в ответ экземпляр AnswerWithEvidence .

 { "answer": "The options for positional encoding mentioned are learned positional embeddings and fixed positional encodings (specifically, using sine and cosine functions of different frequencies).", "start_page_num": 6, "start_line_num": 31, "end_page_num": 6, "end_line_num": 32, "confidence": 0.98, "justification": "Lines 31–32 explicitly state: 'There are many choices of positional encodings, learned and fixed [9].' Additionally, further lines detail the sinusoidal encoding as the fixed choice, and Table 3 row (E) discusses using learned embeddings instead.", "quotes": [ "There are many choices of positional encodings, learned and fixed [9]." ], "caveats": [ "Further details about the specific implementation of learned embeddings are only touched on elsewhere, but both options are mentioned here." ], "complete_answer_found": true, "context_structured": true, "llm_discovered_keywords": [ "learned positional embeddings", "fixed positional encodings", "sinusoidal positional encoding" ] }

Произошли три важных события:

  • Ответ правильный. Оба варианта указаны и правильно перефразированы.
  • Указанный фрагмент текста (страница 6, строки 26-44) относится к конкретному региону. Не «где-то на странице 6». А именно к строкам.
  • Модель не могла сгенерировать цитирование с помощью галлюцинаций : она видела только строки с полученных страниц, а схема задавала реальный диапазон (page, line) который мы можем проверить.

Если модель не может заполнить схему, допускаются поля со значением null, а caveats указывается причина. Статья 8 развивает схему в гораздо более богатую форму с полями обратной связи для каждого блока; статья 23 строит архитектуру хранения на её основе.

Проверка на адекватность. В такой короткой статье мы можем также отправить весь line_df в LLM без повторного получения и проверить совпадение ответа. Это обнадеживает, но для больших документов это не сработает.

 { "answer": "The options mentioned for positional encoding are sinusoidal positional encodings (using sine and cosine functions of different frequencies) and learned positional embeddings.", "start_page_num": 6, "start_line_num": 27, "end_page_num": 6, "end_line_num": 41, "confidence": 0.99, "justification": "Lines 6:27-6:41 describe adding 'positional encodings' to the input embeddings, specify the sinusoidal method, and mention experimenting with learned positional embeddings, stating both options were tried and produced nearly identical results.", "quotes": [ "Since our model contains no recurrence and no convolution, in order for the model to make use of the order of the sequence, we must inject some information about the relative or absolute position of the tokens in the sequence. To this end, we add 'positional encodings' to the input embeddings at the bottoms of the encoder and decoder stacks. The positional encodings have the same dimension dmodel as the embeddings, so that the two can be summed. There are many choices of positional encodings, learned and fixed [9]. In this work, we use sine and cosine functions of different frequencies: ... We also experimented with using learned positional embeddings [9] instead, and found that the two versions produced nearly identical results (see Table 3 row (E)). We chose the sinusoidal version because it may allow the model to extrapolate to sequence lengths longer than the ones encountered during training." ], "caveats": [ "Exact mathematical formulas for sinusoidal encoding are present here, but full details for learned embeddings are not. Table 3 row (E) and further details may expand on results but are not needed for the options question." ], "complete_answer_found": true, "context_structured": true, "llm_discovered_keywords": [ "sinusoidal positional encoding", "learned positional embeddings", "sine and cosine functions", "relative or absolute position" ] }

2.5 Аннотация PDF-файла к исходному PDF-файлу

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

  • Исходный PDF-файл и подтверждающие документы взяты из документа AnswerWithEvidence .
  • В итоге: аннотированный PDF-файл с прямоугольниками, обведенными вокруг цитируемых строк.
  • Необязательно. Инструмент командной строки, пакетное задание или API могут пропустить этот пункт; ответ со ссылками уже является полным после раздела 2.4.

Три звонка решат проблему:

  • passage_lines_df_from_answer(line_df, answer) восстанавливает DataFrame цитируемых строк из фрагмента данных, содержащего доказательства.
  • passage_bbox_by_page(passage_df) группирует ограничивающие рамки на каждой странице.
  • draw_passage_rectangles(pdf_path, bboxes_df, out_pdf_path) записывает аннотированный PDF-файл.
21032c79e5effe8c134520bf15c048db
Один ограничивающий прямоугольник на каждую цитируемую страницу, переносящий текст на каждую цитируемую строку на этой странице. – Изображение предоставлено автором.
9c505c85f15895f7f45b357276e2e0d2
Аннотирование PDF-файла в три этапа: расширение диапазона, объединение на странице, рисование прямоугольников – Изображение предоставлено автором
 def passage_lines_df_from_answer(line_df, answer_json): a = json.loads(answer_json) sp, sl = a["start_page_num"], a["start_line_num"] ep, el = a["end_page_num"], a["end_line_num"] if sp is None: return line_df.iloc[0:0] mask = ( line_df["page_num"].between(sp, ep) & ((line_df["page_num"] != sp) | (line_df["line_num"] >= sl)) & ((line_df["page_num"] != ep) | (line_df["line_num"] <= el)) ) return line_df.loc[mask].copy() def passage_bbox_by_page(passage_df): return passage_df.groupby("page_num", as_index=False).agg( x0=("x0", "min"), y0=("y0", "min"), x1=("x1", "max"), y1=("y1", "max")) def draw_passage_rectangles(pdf_path, bboxes_df, out_path): doc = fitz.open(pdf_path) for _, r in bboxes_df.iterrows(): page = doc[int(r["page_num"]) - 1] page.add_rect_annot(fitz.Rect(r["x0"], r["y0"], r["x1"], r["y1"])) doc.save(out_path) 
7cb9e7a23e155aa07962663a81244e68
Страница 6 рекламного листа с выделенным абзацем, рядом с вопросом и ответом – Изображение предоставлено автором.

Ответ действительно содержится в самом тексте. Красная рамка обрамляет абзац, посвященный позиционному кодированию: предложение, представляющее вариант выбора («мы используем функции синуса и косинуса с разными частотами»), и двухстрочную формулу непосредственно под ним. Читатель может перейти от ответа в чате к ссылке и к абзацу с источником, не покидая тот же экран. В этом и весь смысл.

Почему рамка окружает весь абзац, а не отдельные слова? Потому что мы работали на уровне строк : line_df содержит одну ограничивающую рамку на каждую строку текста, LLM ссылается на диапазон (start_line, end_line) , а passage_bbox_by_page сворачивает каждую строку в этом диапазоне в один обтекающий прямоугольник. Если вы хотите нарисовать рамку вокруг отдельных слов sin(pos / 10000^(2i/d_model)) вместо всего абзаца, подход тот же . Просто измените детализацию. Замените line_df на word_df на уровне слов (pyMuPDF page.get_text("words") дает вам ограничивающую рамку для каждого слова), сделайте так, чтобы схема ссылалась на (start_word, end_word) , и passage_bbox_by_page уже делает все правильно. Тот же четырехэтапный конвейер, но с более тонкой настройкой.

3. Соединение блоков и тестирование конвейера.

3.1 Весь конвейер как единая функция

Все блоки объединяются в один вызов. Введите PDF-файл и вопрос; получите в ответ текстовый ответ с указанием ссылок и, при желании, PDF-файл с аннотациями.

  • Входные данные: путь к PDF-файлу и текстовый вопрос (плюс необязательный параметр top_k и необязательный путь к выходному PDF-файлу).
  • В результате: объект AnswerWithEvidence и (если указан annotate_pdf ) аннотированный PDF-файл на диске.

Внутри pdf_qa_baseline происходит цепочка от анализа документа до анализа вопроса, поиска, генерации и аннотирования PDF-файла. Граница между поиском и генерацией проходит только через номера страниц; отфильтрованный line_df перестраивается внутри генерации.

 def pdf_qa_baseline( pdf_path: str, question: str, top_k: int = 3, annotate_pdf: str | None = None, ): # 1. Parsing line_df = fitz_pdf_to_line_df(pdf_path) # 2. Retrieval page_df = embed_page_df(build_page_df(line_df)) _, filtered = retrieve_pages(page_df, line_df, question, top_k) # 3. Generation answer = llm_answer_with_evidence(question, filtered) # 4. Optional highlighting on the source PDF if annotate_pdf is not None: passage = passage_lines_df_from_answer(line_df, answer) bboxes = passage_bbox_by_page(passage) draw_passage_rectangles(pdf_path, bboxes, annotate_pdf) return answer
 { "answer": "The options mentioned for positional encoding are learned and fixed positional encodings, specifically sinusoidal positional encodings (using sine and cosine functions of different frequencies) and learned positional embeddings.", "start_page_num": 6, "start_line_num": 31, "end_page_num": 6, "end_line_num": 41, "confidence": 0.99, "justification": "Lines 31-41 discuss the choices for positional encodings, stating that there are many choices including learned and fixed encodings. It then explains the use of sine and cosine functions (sinusoidal encoding) and notes that learned positional embeddings were also experimented with.", "quotes": [ "There are many choices of positional encodings, learned and fixed [9].", "In this work, we use sine and cosine functions of different frequencies: ...", "We also experimented with using learned positional embeddings [9] instead, and found that the two versions produced nearly identical results (see Table 3 row (E))." ], "caveats": [], "complete_answer_found": true, "context_structured": true, "llm_discovered_keywords": [ "positional encodings", "learned", "fixed", "sinusoidal", "sine and cosine functions", "learned positional embeddings" ] }

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

3.2 Попробуйте это на другом документе

Вставьте любой имеющийся у вас PDF-файл: статью из вашей области, контракт, отчет с работы. Здесь мы выбрали «Обзор товарных рынков» Всемирного банка за апрель 2026 года (публикация Всемирного банка, выпуск за апрель 2026 года; CC BY 3.0 IGO, как указано на странице публикации этого выпуска в Открытом репозитории знаний Всемирного банка): 69-страничный отчет о рынках энергоресурсов, сельского хозяйства и удобрений, далекий от научной работы по тону и структуре.

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

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

Мы вызываем pdf_qa_baseline сквозным способом: передаем PDF-файл CMO, вопрос об алюминии, top_k=3 и путь к annotate_pdf , чтобы конвейер также записывал выделенный исходный код. Возвращаемый answer_cmo_al имеет ту же структуру AnswerWithEvidence которую мы видели в статье об Attention.

 { "answer": "Aluminum prices are projected to rise by about 22 percent in 2026 (y/y) to reach an all-time high—about 21 percent higher than their January 2026 projections—supported by tight supply conditions and solid demand growth. Prices are expected to decline by about 6 percent in 2027 as supply conditions gradually ease.", "start_page_num": 45, "start_line_num": 32, "end_page_num": 45, "end_line_num": 43, "confidence": 0.98, "justification": "The selected span explicitly provides the projected percentage increase for aluminum prices in 2026, the context for these movements, and the outlook for 2027. It also mentions the record-high level forecast and factors driving the price.", "quotes": [ "Aluminum prices are projected to rise by about 22 percent in 2026 (y/y) to reach an all-time high—about 21 percent higher than their January 2026 projections—supported by tight supply conditions and solid demand growth (table 1).", "Prices are expected to decline by about 6 percent in 2027 as supply conditions gradually ease." ], "caveats": [], "complete_answer_found": true, "context_structured": true, "llm_discovered_keywords": [ "all-time high", "tight supply conditions", "solid demand growth" ] }

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

bbf3be9c74b951459304bba53ab084ad

Более сложный вопрос по тому же отчету. Что, если мы спросим о чем-то, что в отчете упоминается лишь вскользь? Мы попробуем задать вопрос о спросе на электроэнергию, связанном с ИИ, ответ на который Всемирный банк разработал только в разделе «Позитивные риски» на странице 31.

Та же структура запроса, но более сложный вопрос: pdf_qa_baseline(pdf_path=pdf_path_cmo, question=question_cmo_ai, top_k=3, ...) . Конвейер должен определить, содержат ли полученные страницы фактически данные об использовании ресурсов ИИ, или же следует пометить ответ как не найденный.

 { "answer": "The provided lines mention that faster-than-anticipated expansion of AI-related data centers could boost demand for certain metals like aluminum and copper, but do not quantify the contribution of AI-related data centers to global electricity demand growth.", "start_page_num": 47, "start_line_num": 39, "end_page_num": 47, "end_line_num": 40, "confidence": 0.8, "justification": "The only mention of AI-related data centers is in relation to demand for metals, not electricity demand. There is no quantitative estimate or percentage given for their impact on global electricity demand growth.", "quotes": [ "Also, faster-than-antici-npated expansion of AI-related data centers could nboost demand for aluminum and copper, driving nprices higher." ], "caveats": [ "No specific figures or direct statements about global electricity demand growth caused by AI-related data centers were found in the provided lines." ], "complete_answer_found": false, "context_structured": true, "llm_discovered_keywords": [ "AI-related data centers", "electricity demand growth", "boost demand for aluminum and copper" ] } 
bf8781bc89b0c1a618636216f0ebad0a
Страница 47 CMO, ответ на нулевой путь: схема отказалась фабриковать данные, когда ответа не было – Изображение предоставлено автором.

Но как мы можем быть уверены, что ответа действительно нет в документе? Строго говоря, мы не можем, по крайней мере, только на основании этого нулевого пути. Схема гласит: «LLM не нашел ответа в показанных ему строках», что отличается от утверждения «ответа в документе нет». В боковой панели «Повышение риска» на странице 31 того же отчета CMO приводится количественная оценка этой цифры (Всемирный банк ссылается на прогноз МЭА о 8% росте мирового спроса на электроэнергию с 2024 по 2030 год). Вместо этого стандартный конвейер ключевых слов выдал страницу 47 и соседние страницы, где в тексте отчета обсуждается влияние ИИ на спрос на металлы. Доказать отсутствие ответа потребовалось бы либо запустить LLM на каждой странице, либо использовать метод поиска, который отображает текст боковой панели и краткие упоминания в ссылках. Именно это и разрабатывает статья 7 (Поиск); для минимальной версии мы сообщаем: «Я не нашел его на трех верхних страницах».

3.3 Больше вопросов в одной таблице

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

  • Числовое значение : скорость обучения базового трансформера. Конкретное число, ожидаемое значение на странице 7 (раздел 5.3 об оптимизаторе Adam).
  • В документе нет ответа : химический состав морской воды. Нулевой путь схемы должен сработать; оба извлекателя будут извлекать страницы, выглядящие случайными.
  • Другая тема на CMO : прогноз цен на мочевину. Аналогичная ситуация в разделе об удобрениях в отчете Всемирного банка, далеко от врезки об ИИ.
  • Составной вопрос : d_k и d_v в Transformer. Запрашиваются два значения одновременно. Также проверяется предел разбора таблицы (значения находятся в таблице 1 на странице 4, разбор ведется как прямые линии).
 def run_pipeline_test( question: str, line_df_in: pd.DataFrame, page_df_in: pd.DataFrame, page_df_emb_in: pd.DataFrame, top_k: int = 3, client=client, ) -> dict: """Run both retrievers + generation on one question; return a summary dict.""" parsed_q = get_keywords_from_question(question, client=client) retrieved_emb_df, _ = retrieve_pages_by_similarity( page_df_emb_in, line_df_in, question, top_k=top_k, client=client, ) retrieved_kw_df, filtered_lines_kw = retrieve_pages( page_df_in, line_df_in, parsed_q.keywords, top_k=top_k, ) # If keyword retrieval finds nothing, fall back to the whole doc so generation # still runs (small PDFs only: would not scale to a real corpus). lines_for_generation = ( filtered_lines_kw if len(filtered_lines_kw) > 0 else line_df_in ) answer = llm_answer_with_evidence( question, lines_for_generation, client=client, ) return { "question": question, "keywords": parsed_q.keywords, "emb_top3": retrieved_emb_df["page_num"].tolist(), "kw_top3": ( retrieved_kw_df["page_num"].tolist() if len(retrieved_kw_df) > 0 else "(no kw match)" ), "answer_excerpt": (answer.answer[:80] + ("..." if len(answer.answer) > 80 else "")), "cite_page": answer.start_page_num, } 
ba6147d0e44c443d5bc2428fa84ba322
Один и тот же алгоритм обработки четырех вопросов: два решены успешно, один однозначно отклонен, один выдает ошибку при разборе таблицы. – Изображение предоставлено автором.

Читайте таблицу слева направо по строкам. Выделите четыре закономерности:

  1. Ключевые слова превосходят эмбеддинги в строке скорости обучения. Базовый график обучения трансформера находится на странице 7 (раздел 5.3, Оптимизатор). Эмбеддинги ранжируют страницы 8/9/10; страница 7 не входит в тройку лидеров. Поиск ключевых слов находит страницу 7 сразу же по буквальной фразе « learning rate . Тот же вывод, что и в строке эпсилон в разделе 2.3.c: когда вопрос зависит от точного термина, который документ печатает дословно, ключевые слова являются лучшим инструментом.
  2. Оба устройства для извлечения данных выходят из строя в ряду, содержащем информацию о морской воде, и неисправность видна. В PDF-файле ничего не говорится о морской воде. В столбце ключевых слов прямо указано (no kw match) , без ложных «трех первых страниц», которые выглядели бы правдоподобно. Затем схема возвращает нулевой ответ с оговоркой. Чистый ответ «Я не знаю» — наиболее ценное поведение системы при ответах на вопросы, выходящие за рамки данной области.
  3. Оба инструмента поиска работают со строкой, посвященной мочевине. В CMO есть раздел, посвященный удобрениям; как эмбеддинги, так и ключевые слова возвращают страницу 42, и генерация корректно ее цитирует. Междоменные конвейеры работают, если лексика вопроса попадает в документ.
  4. Составная строка d_k и d_v выявляет ограничение, связанное с разбором таблиц. Эти два значения находятся в Таблице 1, на странице 4 статьи о Transformer, где каждая строка содержит d_model , h , d_k , d_v и т. д. Наш парсер преобразовал таблицу в простые строки, поэтому модель, запрашивающая две ячейки рядом, должна восстановить строку только из текста. Ключевые слова возвращают на страницу 4 (там появляется дословная фраза d_k ), но ссылка часто указывает на одно значение, в то время как другое перефразировано. Решение носит структурный характер: разбирать таблицы как таблицы, а не как строки. Это работа статей 5 (разбор) и 6 (разложение на составные вопросы).

4. Вопросы, которые поднимает каждый блок.

Что хорошо получается у этой минималистичной системы:

  • Реальный, проверяемый ответ. Структурированный объект, содержащий ответ, номер страницы, строки и цитату. Пользователь может проверить ссылку за считанные секунды.
  • Ошибка «Не найдено» обрабатывается корректно. Если ответ отсутствует в полученных строках, схема допускает значения NULL, а в поле « caveats указывается причина. Никаких искажений.
  • Ответ содержит ссылку на источник. Выделенный PDF-файл замыкает связь между утверждением LLM и самим документом. Именно это отличает полезную систему RAG от чат-бота, который просто читает документы.
  • Легко понять. Каждая функция делает одно дело. Никакого скрытого состояния, никакой магии фреймворка. Когда что-то идет не так, отладка заключается в чтении кода.

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

4.1 Анализ документа: мы читаем только строки

Мы извлекали текст построчно. Для научной статьи это вполне разумно, но посмотрите, что мы выбросили: структуру разделов, заголовки, макеты таблиц, рисунки, сноски, перекрестные ссылки. На 4-й странице этой статьи находится Таблица 1 со сложностью каждого слоя. Мы обработали каждую из ее строк как обычные строки, полностью потеряв структуру таблицы. На 9-й странице находится Таблица 3, исследование абляции. Та же проблема.

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

Это тема статьи 5: Анализ документов. Документы имеют структуру. Игнорирование её — главная причина последующих ошибок.

4.2 Анализ вопроса: мы запросили ключевые слова, но только ключевые слова.

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

Три вещи, которые эта минималистичная версия не делает.

Система не распознает намерения . Запросы типа «Кратко изложить главу 3», «Перевести этот пункт на французский», «Сравнить X и Y» запускают разные последующие этапы обработки. Одно поле keywords не может передать этот сигнал.

Он не разлагает составные вопросы на составляющие . Вопрос «Что такое исключения и франшиза?», рассматриваемый как плоский список ключевых слов, загрязняет результаты поиска (ключевые слова для «исключений» и «франшизы» охватывают две разные области, которые мешают друг другу). В статье 6 подробно описано, как обнаруживать составные вопросы, решать, следует ли их разлагать на составляющие, и направлять подвопросы независимо друг от друга.

Система не распознает ожидаемую форму ответа . Вопрос «Какова сумма страховой премии?» требует число в денежном эквиваленте. Вопрос «Каковы обязательства?» требует список. Вопрос «Сравните два полиса» требует таблицу. Минимальная версия рассматривает каждый ответ как свободный текст. В статье 6 представлено поле expected_answer_shape , которое управляет процессом генерации шаблона.

Это тема статьи 6: Анализ вопросов . Тот же самый блок, но в гораздо более функциональном формате JSON.

4.3. Разбивка на блоки: мы агрегировали данные по страницам.

В качестве единицы поиска мы выбрали страницы. Почему страницы? Почему не абзацы, разделы или фиксированные по размеру блоки из 512 токенов, как рекомендуется во всех стандартных руководствах по RAG?

Ответ заключается в том, что агрегирование на уровне страниц подходит для данного документа, поскольку страницы приблизительно соответствуют семантическим единицам. В договоре, юридическом тексте или техническом руководстве с пронумерованными пунктами страницы представляют собой произвольные фрагменты, и вместо этого вам понадобятся фрагменты на уровне пунктов или разделов. «Правильное» разбиение на фрагменты зависит от документа и вопроса, а не от значения по умолчанию.

Когда подход с фиксированным размером начинает давать сбои, возникает соблазн использовать поиск по сетке, охватывая размеры блоков и перекрытия. Это рефлекс машинного обучения. Но это неправильный подход к тому, что на самом деле является структурным решением. Статья 3: RAG — это не машинное обучение, и шестимесячная ошибка, заключавшаяся в том, чтобы относиться к нему как к таковому, полностью это доказывает.

4.4 Поиск: сопоставление по ключевым словам прозрачно, но не учитывает словарный запас.

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

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

Символ против слова. Спросите: «Каково значение эпсилон, используемое в сглаживании меток?» Ключевые слова, полученные в результате анализа вопроса, скорее всего, будут выглядеть примерно так: ["epsilon", "label smoothing"] . Фактический ответ ( ε_ls = 0.1 ) находится на 8-й странице, но в документе он записан как греческая буква ε , а не как английское слово «epsilon». Проверка подстроки возвращает ноль на странице, где указан только символ; на 8-й странице находится только буквальная фраза label smoothing меток".

Несоответствие синонимов. Спросите: «Как модель определяет порядок слов в предложении?» Ключевые слова могут быть ["word order", "sentence order"] . В документе это называется позиционным кодированием. Ни одно из ключевых слов вопроса не встречается на странице 6. Программа поиска выбирает страницы, на которых вскользь упоминаются слова «порядок» или «предложение», и ни на одной из них нет ответа.

Перефразируйте. Спросите: «Какой механизм внимания использует кодировщик?» В документе говорится о самовнимании и многоголовочном внимании, но нигде не встречается фраза «механизм внимания, используемый кодировщиком». Ключевые слова, извлеченные из вопроса, даже после расширения, могут содержать или не содержать точную формулировку из документа. Если содержат, поиск работает. Если нет, он незаметно ухудшается.

Первые две неудачи настолько распространены, что остальной части серии посвящены две статьи.

  • Статья 6: Анализ вопросов превращает извлечение ключевых слов в гораздо более сложный этап, который использует глоссарий предметной области, расширяет синонимический список и включает вероятные формулировки документов, а не буквальное содержание вопроса.
  • Статья 2: Встраивание векторов представляет векторные представления, которые позволяют сопоставлять информацию по различным поверхностным словарям: в чем преимущества встраивания векторов (синонимы, перефразирование, орфографические ошибки, межъязыковое сопоставление), в чем их недостатки (отрицание, точные значения, внутренние акронимы, многозначные термины) и как их комбинировать с сопоставлением по ключевым словам для достижения наилучших результатов.
  • В статьях 7 и 9 полученный в результате гибридный поиск помещается в настоящий индекс документов.

Правильный ответ — комбинировать, а не выбирать победителя. Оба метода терпят неудачу практически в противоположных случаях: векторные представления дают сбой, когда вопрос зависит от точного символа, именованного термина или точного значения; ключевые слова дают сбой, когда лексика задающего вопрос буквально не встречается в документе. Стандартный гибридный рецепт — это запуск обоих методов поиска, объединение их кандидатов и (при необходимости) переранжирование с помощью кросс-кодировщика. Статья 2 развивает этот подход; статьи 7 и 9 интегрируют его в корпус.

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

Поколение 4.5: мы запросили источники, и мы их получили.

Этот блок сработал лучше всего, почти слишком легко. Мы определили схему Pydantic с start_page_num , start_line_num , end_page_num , end_line_num , confidence , justification , quotes и caveats , и модель правильно её заполнила.

Чего еще можно желать? Структурированное сравнение для формулирования сравнительных вопросов, список противоречий, если документ противоречит сам себе, множество ссылок из разных частей документа, анализ достоверности каждого утверждения. Да, на все вышеперечисленное — да. Этап генерации гораздо более контролируем, чем думает большинство команд. Статья 8: Генерация как контролируемое выполнение подробно рассматривает этот вопрос.

5. Форма того, что будет дальше.

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

Ошибки, которые губят большинство проектов, возникают из-за неправильного понимания одного из этих блоков: RAG — это не машинное обучение (статья 3), эмбеддинги — это не магия (статья 2), не все задачи RAG выглядят одинаково (статья 4). Это часть I.

Затем каждый блок проходит свой собственный углубленный этап: разбор документа, разбор вопроса, поиск, генерация. Это часть II , четыре блока.

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

Затем мы меняем масштаб. От одного документа к множеству. От одной бумаги к архиву из сотен или тысяч документов. Архитектура существенно меняется. Это часть IV .

Наконец, что необходимо для работы системы в производственной среде: оценка, стоимость и мониторинг, безопасность и соответствие требованиям, архитектура самого кода. Это часть V.

Сами блоки не меняются. Меняется их внутреннее устройство.

Несколько пояснений:

  • Четыре основных элемента (Часть II) составляют концептуальную основу. Большая часть остальной серии посвящена усовершенствованию каждого из них. Часть III и Часть IV представляют собой рекомбинации: те же четыре идеи в разных масштабах и для разных типов вопросов.
  • Область применения данной серии — корпоративные документы. Контракты, технические спецификации, нормативные документы, внутренние процедуры: все они имеют структуру (оглавление, разделы, таблицы) и ограниченный словарный запас (отраслевой жаргон, экспертные термины). RAG работает с этими корпусами именно из-за их структуры, а не из-за героических уловок с встраиванием. Документы без структуры (романы, длинные неструктурированные стенограммы) и вопросы, требующие осмысления, а не поиска фрагмента текста, выходят за рамки данной серии; статья 4 возвращается к тому месту, где проходит граница.
  • Код приведен в качестве примера, а не готов к использованию в производственной среде. То, что вы прочитали, работает с реальным PDF-файлом, но не включает в себя обработку ошибок, проверку данных, кэширование, контроль затрат, мониторинг и безопасность, необходимые для производственной системы. Каждому аспекту посвящена отдельная статья.

Вот подробное описание соответствия этой минимальной системы остальным частям серии:

  • Анализ PDF-файлов отбрасывает структуру → Статья 5, Статья 10
  • Для анализа вопросов требуется больше, чем просто ключевые слова (намерение, декомпозиция, ожидаемая форма ответа) → Статья 6
  • Стратегия разбиения на блоки не является гиперпараметром → Статья 3
  • Словарь вопроса не соответствует терминам документа → Статья 2, Статья 6
  • Поиск выбирает не ту страницу → Статья 7, Статья 9
  • В модели приводится перефразированная цитата → Статья 8, Статья 21
  • «Не найдено» требует уточнения → Статья 4
  • Вопросы на составление сложных предложений, перечисление, сравнение и обобщение → Статья 6, Статьи 11-13
  • Многодокументный корпус → Часть IV (Статьи 15-20)
  • Производство, оценка, безопасность, архитектура → Часть V (статьи 21-25)

Вы можете прочитать вводную статью к этой серии:

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

a5d1c808dee4087e8c02844bf839c0c8

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

Сто строк кода на Python и схема Pydantic — этого достаточно, чтобы запустить работающую систему RAG на реальном PDF-файле. Доверие к системе обеспечивает не количество строк кода, а структурированный ответ с построчными ссылками, отсутствие фальсификации в схеме и выделение текста в PDF-файле, связывающее каждое утверждение с его источником. Четыре основных элемента (анализ, анализ вопросов, поиск, генерация) составляют концептуальное ядро; всё, что будет описано далее в этой серии, будет посвящено улучшению каждого из них.

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

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

В данной статье используется структурированный подход к представлению результатов поиска с указанием источников для AnswerWithEvidence , который соответствует направлению работы Бонета и др. (Attributed Question Answering, 2022). Полноценный производственный аналог подобного конвейера представлен в работе Anthropic's Contextual Retrieval (сентябрь 2024 г.), предварительный обзор которой будет опубликован в статье 9. Сам термин RAG заимствован у Льюиса и др. (2020). Том 3 (Agentic Bricks) возвращается к пути агентного обновления поверх четырех блоков, определенных здесь.

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

  • Бонет и др., Ответы на вопросы с указанием источника, 2022 (arXiv:2212.08037). Структурированный вывод с использованием цитирований в качестве механизма доверия; наиболее близкая к опубликованной идее схема AnswerWithEvidence .
  • Антропный контекстный поиск (инженерная статья, сентябрь 2024 г.). Производственный «минимальный, но готовый к использованию» конвейер обработки данных; переходит к гибридному поиску + переранжированию. Статья 9 продолжает работу с того места, где заканчивается эта.
  • Асаи и др., Self-RAG: Обучение извлечению, генерации и критике посредством саморефлексии, ICLR 2024 (arXiv:2310.11511). То же направление доверия через структуру. Токены саморефлексии указывают, когда извлечение информации помогло, а когда утверждение обосновано.
  • Льюис и др., Генерация с расширенным поиском для задач обработки естественного языка, требующих интенсивного использования знаний, NeurIPS 2020 (arXiv:2005.11401). Статья, давшая название RAG.

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

  • Карпухин и др., Плотный поиск фрагментов текста для вопросов и ответов в открытой предметной области, EMNLP 2020 (arXiv:2004.04906). Плотный поиск как продукционный примитив; большинство руководств по «минимальному RAG» происходят от него. В этой статье вместо этого используется сопоставление ключевых слов (обосновано в статье 2).
  • Яо и др., ReAct: Синергия рассуждений и действий в языковых моделях, ICLR 2023 (arXiv:2210.03629). Основополагающая статья агентного RAG. Контекст — универсальный выбор инструментов во время выполнения. Том 3 (Агентные блоки) развивает это направление на основе четырех блоков, определенных здесь.
  • Ли и др., Могут ли языковые модели с длинным контекстом заменить поиск, RAG, SQL и многое другое?, 2024 (arXiv:2406.13121). Подход «длинный контекст заменяет поиск». Эмпирические данные о том, где это работает, а где нет; в этой статье предполагается, что длинный контекст не заменяет структурированный поиск в корпоративных PDF-файлах.

Анджела Ши. Все материалы от Анджелы Ши.

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

✅ Найденные теги: Enterprise, PDF, RAG, Базовая, Версия, новости

Добавить комментарий

Новости других рубрик

Архив рубрики ~Обо всем~: Компания Perplexity запускает Bumblebee: чем отличается новый сканер для разработчиков, работающий только в режиме чтения, от Chainguard. Архив рубрики ~Обо всем~: Компания Ultrahuman добавляет терапию красным светом в свою линейку персонализированных оздоровительных услуг. Архив рубрики ~Обо всем~: Поэзия для инженеров: Киборг-лаборатория Архив рубрики ~Обо всем~: Кольцо Oura Ring 5 — это значительно более тонкое «умное» кольцо. Архив рубрики ~Обо всем~: RAG сжигает деньги — я создал систему контроля затрат, чтобы это исправить. Архив рубрики ~Обо всем~: Весы Gravitas от ThermoWorks оснащены съемным дисплеем и памятью на 20 минут. Архив рубрики ~Обо всем~: Весы Gravitas от ThermoWorks оснащены съемным дисплеем и памятью на 20 минут. Архив рубрики ~Обо всем~: Компания Anthropic выпускает Opus 4.8, главной отличительной чертой которого является честность.