Большая языковая модель
Перестаньте возвращать текст из RAG: контракт с типизированным ответом, предотвращающий галлюцинации
Анализ корпоративных документов [Том 1 #8A] — схема — это контракт: каждое поле — это вопрос, который конвейер задаёт модели, и каждый ответ можно проверить.
Кежан Ши
4 июля 2026
Читать 31 минуту
Делиться
Фото Анны Таразевич, via Pexels.
Эта статья открывает раздел «Генерация» Enterprise Document Intelligence, в котором корпоративная система RAG строится из четырёх элементов: анализа документов, анализа вопросов, поиска и генерации. Генерация — четвёртый и последний элемент. Это первая из трёх его частей: контракт, типизированная схема ответов, которую должна заполнить модель. В дополнениях рассказывается о том, как собирается вызов, который его заполняет (статья 8B, оперативная сборка), и как проверяется ответ и передаётся обратно в конвейер (статья 8C, проверка достоверности).

В какой серии находится эта статья: статья 8 (generation), часть контракта, часть II (the four bricks) — изображение автора.
📓 Доступные для запуска ноутбуки‑компаньоны находятся на GitHub: doc‑intel/notebooks‑vol1.

Общедоступный репозиторий сопутствующего кода в doc‑intel/notebooks‑том 1 — изображение автора.
- У модели галлюцинации; отвечайте по отрывкам, а не по памяти.
Здесь сходятся первые три элемента, каждый из которых описан в отдельных статьях:
При разборе документов PDF‑файл превратился в структурированные таблицы: статья 5A (что читать в PDF‑файле) и статья 5B (реляционная модель данных).
При разборе вопроса пользовательская строка превратилась в типизированный разобранный вопрос: статья 6A (тезис), статья 6B (извлечение) и статья 6C (отправка).
При извлечении отрывки были отфильтрованы до того, что должно содержать ответ: статья 7A (извлечение как фильтрация), статья 7B (определение привязки) и статья 7C (арбитр LLM).
Задача генератора состоит в том, чтобы превратить эти отрывки и этот вопрос в ответ, и модель будет галлюцинировать по ходу дела. Это не ошибка, которую нужно исправлять. Это то, что делает генеративный ИИ: он предсказывает наиболее правдоподобный следующий токен, он ничего не ищет. Для темы, которая насыщает его обучающими данными, предсказание является надёжным. В вашем контракте, который можно просмотреть один раз или никогда, он предсказывает продолжение так же бегло, так же уверенно — и с гораздо большей вероятностью окажется неверным. Вы не сможете отточить это. Вы можете только сократить пространство для этого.
К моменту запуска генерации большая часть этого пространства уже закрыта. Каждый блок перед генерацией выдаёт что‑то чистое:
Анализ документа даёт структурированные таблицы, а не искажённый текстовый дамп.
Анализ вопроса позволяет получить точный вопрос и заявленный формат ответа, форму и тип возвращаемого текста, а не разрозненную строку.
Поиск даёт минимум — несколько отрывков, которые действительно содержат ответ, каждый из которых чётко привязан к своим строкам.
Три кирпичика, три способа уменьшить пространство для изобретений. Почва подготовлена, и поколению остаётся только не растратить её впустую.

Модель предсказывает в любом случае, и вы не можете контролировать то, что она увидела во время обучения, поэтому закрепите ответ в извлечённом отрывке — изображение автора.
Генерация — это этап подготовки, а рычаг — не более чем разумная подсказка («не выдумывай» ничего не меняет). Это контролируемое выполнение. Модель отвечает только на те фрагменты, которые находятся перед ней, — в типизированном виде, с цитированием каждого утверждения. Вводится структурированный ввод: фрагменты плюс вопрос. Выводится структурированный вывод: типизированная схема с цитатами, флажками точности и обратной связью для конвейера. Попросите «ответ» — и модель заполнит пробелы по памяти. Попросите структурированный объект, каждое поле которого проверено на соответствие входным данным, — ему нечего будет придумывать.
- Попросите у модели нечто большее, чем «ответ».
Схема — это контракт между конвейером и моделью, и он не обязательно должен заканчиваться на «ответе». Минимальный ответ RAG‑конвейера с очевидностью — это тот минимум, за который можно получить слово «RAG»: прямой ответ, начало или конец доказательства, уверенность, необязательные цитаты и оговорки. Это работает для прозаических вопросов. Каждое поле, которое мы добавляем после этого, — это ещё один вопрос, который схема задаёт модели, и каждое из них занимает своё место, предоставляя конвейеру то, чего он не смог бы получить иначе.
Расширенный контракт включает в себя четыре вида полей поверх минимального:
Введённые значения для каждой формы (раздел 2.1): сумма (стоимость, валюта, единица измерения) вместо строки «1200 долларов США за заявку»; значение даты (iso, оригинал) вместо «15 марта 2024»; значение таблицы (заголовки, строки) вместо текста, разделённого столбцами. Нисходящий код никогда не выполняет повторный анализ строки.
Многоэлементные ответы с многопролётными цитатами (раздел 2.2): многие реальные вопросы содержат список ответов; многие одноэлементные ответы содержат несмежные доказательства (определение на стр. 5 и пример на стр. 23). Схемы моделируют оба варианта напрямую.
Поля самооценки + конвейер обратной связи (раздел 2.3): уверенность, предостережения, найденный ответ, завершённый ответ, контекстная структура, ключевые слова, обнаруженные LLM, противоречивые доказательства, предлагаемое разъяснение. Каждый из них заставляет модель выдавать сигнал, который конвейер считывает, чтобы принять решение о следующем шаге.
Программная завершённость (раздел 2.4): единственный сигнал, который мы намеренно не запрашиваем у модели. Он устанавливается конвейером в зависимости от того, какой поиск был параметризован для включения (например, перекрывающаяся страница за пределами раздела). Сильный, потому что детерминированный, основанный на структуре документа, а не на самооценке модели.
Мы разрабатываем эти четыре варианта. Список ещё не закрыт. Как только схема становится контрактом, добавление нового индикатора обходится в одно объявление поля и один фрагмент запроса: «помечать отредактированные блоки», «возвращать по каждому элементу table_confidence», «указывать юрисдикцию, когда в предложении упоминается закон» — всё, что нужно конвейеру. Правильные индикаторы зависят от корпуса и конечного потребителя; в этой статье показаны четыре индикатора общего назначения, а шаблон реестра (раздел 2.3) позволяет создавать пользовательские формы.
Схема строится снизу вверх на четырёх уровнях, по одному для каждого подраздела:
- Значение (раздел 2.1, типизированный примитив: сумма, значение даты, значение таблицы, а также интервал, содержащий один непрерывный диапазон цитирования);
- Элемент (раздел 2.2, одно значение и его фактические значения, например, AmountItem(сумма, интервалы));
- Ответ (раздел 2.3, список [элемент] плюс поля самооценки и конвейерной обратной связи, которые используются совместно через базу ответов; реестр сопоставляет метки фигур с подклассами ответов);
- Программная полнота (раздел 2.4, один сигнал, который вычисляется конвейером, а не запрашивается у модели).
Как этот контракт применяется во время декодирования (схемы Pydantic v2 и клиентский интерфейс OpenAI Responses API.responses.parse(...), а также резервная иерархия для поставщиков без ограниченного декодирования), описано в разделе 2.5.
Примечание по словарному запасу, который используется последовательно с этого момента. Value — это типизированный примитив (сумма, значение даты, адрес и т. д.), один логический класс для каждой концепции. Shape — строковая метка («сумма», «дата» и т. д.), которую выдаёт блок синтаксического анализа вопроса и которую реестр использует в качестве ключа. Item — одно значение и его диапазон значений (AmountItem(сумма, интервалы)). Schema (или Answer) — класс Pydantic верхнего уровня, который передаётся в responses.parse (AmountAnswer, TextAnswer и т. д.); он наследует базу ответов для общих полей обратной связи. Contract — неофициальное название того, что обеспечивает схема: форма, типы, обязательные поля, чтобы конвейер мог считывать result.answer.items без использования try/except.
2.1 Типизированные значения, одна схема для каждого answer_type
Первым слоем схемы является значение — типизированный примитив, который заполняет модель. Блок синтаксического анализа вопросов помечает каждый вопрос по двум ортогональным осям:
- answershape (количество элементов): single / listing / table / tree / nestedjson;
- answer_type (тип значения): сумма, дата, iban, текст, логическое значение и т. д.
Реестр, который сопоставляет каждый answer_type с конкретным классом значений, создаётся в разделе 2.3, как только AnswerBase попадает в область видимости. Ось формы определяет, как эти значения будут перенесены (одно значение, список или двумерная таблица). Здесь мы просто определяем классы значений и элемент цитирования (Span).
Все номера строк являются глобальными (для всего документа, а не для каждой страницы); это соглашение применяется в БАЗОВОМ приглашении статьи 8B (сборка приглашений).
Кратко о каждом типе значения:
- Сумма (значение, валюта, единица измерения): код ISO 4217, необязательная единица измерения, например «по заявке». Схема гарантирует, что валюта существует и является строкой; выполняются последующие суммы и преобразования.
- Значение даты (iso, оригинал): ISO 8601 плюс исходная формулировка, как указано в документе. Два поля, потому что потребителю нужна понятная форма, а пользователь хочет распознать, что находится в источнике.
- TableValue(заголовки, строки): настоящая 2D‑структура, а не текст, разделённый столбцами. Полезно для таблиц премиум‑класса, сравнительных таблиц и любых вопросов типа «укажите X для каждого Y».
- bool для логических ответов (охвачено? исключено? соответствует?) с оговорками, содержащими все необходимые нюансы.
- текст: str для всего остального — определений, перефразировок, описательных ответов.
Каждое значение будет заключено в элемент в разделе 2.2: AmountItem(сумма: Amount, интервалы: list[Span]) и так далее — по одному классу элемента для каждой фигуры. Это более подробное описание, чем один ответ: str, но именно подробность делает вывод полезным с программной точки зрения.
Вспомогательное поле extraction_method находится на один уровень выше в базе ответов (раздел 2.3) и содержит информацию о том, как был получен ответ. Это полевая версия пункта раздела 1: «дословно» и «вычислено» основаны на отрывках перед моделью, в то время как «выведено» — это модель, заполняющая пробел из своей собственной памяти, и мы не доверяем вашим документам. Четыре значения:
«дословно»: значение в отрывках записано слово в слово. Проверяющий (статья 8С, проверка достоверности) читает это и требует, чтобы по крайней мере одна цитата была подстрокой процитированных фрагментов.
«вычислено»: значение, требуемое для объединения нескольких элементов из отрывков (например, суммирование позиций). Должно быть проверено.
«выведено»: значение получено, но не является явным. Должно быть проверено человеком.
«нет»: нет ответа.
class Span(BaseModel):
line_start: int
line_end: int
quote: str | None = None
class Amount(BaseModel):
value: float
currency: str
unit: str | None = None
class DateValue(BaseModel):
iso: str
original: str
class TableValue(BaseModel):
headers: list[str]
rows: list[list[str]]
Пример работы: вопрос об адресе. Пользователь спрашивает: «Какой адрес?», и база данных, которая использует ответ, запрашивает четыре столбца: улица, почтовый индекс, город, страна. Инстинкт подсказывает задать четыре вопроса: «Какой адрес?», «Какой почтовый индекс?», «Какой город?», «Какая страна?». При каждом вызове извлекается один и тот же фрагмент (в источнике адрес представлен в виде одного блока: «Пятая авеню, 350, Нью-Йорк, 10118, США»), и модель просят отрезать один фрагмент. Четыре обхода в оба конца за одно извлечение. Четыре возможности для изменения модели. В четыре раза больше данных пересекает границы API. В четыре раза больше стоимость.
Решение разработчика — объявить введённое значение один раз. Адрес (базовая модель) с полями «улица», «почтовый индекс», «город», «страна», зарегистрированный рядом с суммой, значением даты, значением таблицы в ANSWER_REGISTRY. С этого момента один вопрос возвращает заполненный объект Address, который напрямую сопоставляется со значениями INSERT INTO addresses(улица, почтовый индекс, город, страна) (...). Один вызов, одно извлечение (в любом случае адрес источника содержался в одном блоке), одна строка, одно место для аудита.
Схема представляет собой как контракт, так и инструкцию. API обеспечивает наличие четырёх полей, и модель, видя четыре именованных поля в форме ответа, понимает, что нужно самостоятельно разбить блок на части.
Это расширяет экспертный шаблон на уровне поля. Конечные пользователи продолжают задавать свой естественный вопрос «Какой адрес?»; разработчик один раз кодифицирует структуру, и следующая тысяча ответов поступает в конвейер SQL без повторного запроса. Та же логика применима к каждому повторяющемуся извлечению, содержащемуся в корпусе: имя человека преобразуется в firstname / lastname / middleinitial, цена — в value / currency / unit, диапазон дат — в isostart / iso_end. Каждый из них представляет собой небольшой аналитический класс, который разработчик добавляет один раз.
Отработанный пример: сравнение сумм. Никогда не спрашивайте модель о сравнении. Пользователь спрашивает: «Превышает ли премия по контракту один миллион долларов?». Наивный путь состоит в том, чтобы задать именно этот вопрос: «Да или нет, премия превышает 1 000 000 долларов США?» — и доверять ответу. Нет. Модель должна выполнить три действия за один раз: найти премию, проанализировать её валюту и сравнить. Каждый шаг — это возможность отклониться от курса, а двоичный вывод стирает значение, которое его создало: контрольный журнал отсутствует. Хуже того, конвертация валюты происходит незаметно, без видимого обменного курса: премия в размере 100 000 000 иен становится «да» или «нет» в зависимости от того, каков, по мнению модели, курс иены к доллару США на сегодняшний день.
Правильный ход: сначала извлеките, а затем сравните в Python. Запросите сумму (значение, валюту, единицу измерения). Примените явное преобразование (сумма.значение * КУРС[сумма.валюта]["USD"]). Сравните с пороговым значением. Каждый шаг виден, доступен для проверки и воспроизведения, и если коэффициент конверсии обновляется, ответ может быть пересчитан без повторного вызова модели. Правило обобщает: никогда не делегируйте вычисление, сравнение или агрегацию LLM, если результат может быть получен детерминистически из извлечённых значений. LLM извлекает; Python сравнивает.

Сначала извлеките, сравните на Python: премия в иенах, молча проглоченная LLM — Изображение автора.
Как выглядит типизированное извлечение на четырёх реальных фигурах: адрес, сумма, значение даты, имя пользователя — с реалистичным шумом вокруг цели на необработанной стороне и чистым прямоугольным объектом справа:

Простой переход к структурированному объекту с помощью четырёх типизированных фигур — Изображение автора.
2.2 Многоэлементные ответы и цитаты с несколькими интервалами.
Минимальная схема предполагает один ответ с одним непрерывным интервалом вспомогательных строк. Реальные вопросы опровергают это предположение двумя способами.
В качестве ответа на многие вопросы используется список, а не одно значение. «Каковы категории в рамках функции идентификации?» — предполагается, что будет шесть пунктов, каждый из которых будет иметь свои собственные доказательства. «Какие исключения применяются к ущербу от наводнения?» — предполагается, что будет указано любое количество исключений, каждое из которых указывает на своё собственное предложение. Схема моделирует это с помощью элементов: list[XItem]. Ноль элементов означает, что они не найдены, один элемент — единственный ответ, N элементов — список. Каждый элемент имеет свою ценность и свои собственные доказательства. Никогда не бывает одного раздела, охватывающего весь список.
Даже ответы, состоящие из одного элемента, часто содержат несвязные подтверждающие доказательства. Определение на странице 5 плюс пример на странице 23. Условие и исключение из него — в отдельном абзаце. Значение плюс сноска к нему. Использование одного непрерывного диапазона приводит либо к чрезмерному цитированию (один большой интервал поглощает ненужные строки между ними), либо к недостаточному цитированию (выбирается один из интервалов и отбрасываются остальные). Схема моделирует это с помощью spans: список[Span] для каждого элемента. Однопролётный ответ — это просто список длиной в единицу. В многопролётном ответе каждая область указывается отдельно.
Span — это маленький элементарный элемент: непрерывный диапазон linestart..lineend, а также необязательная кавычка. Два отдельных шага. Модель возвращает только структурированные данные: номера строк, введённые значения, флаги. Она никогда не возвращает текст доказательства. После этого конвейер восстанавливает остальное из исходных таблиц: фактический фрагмент, соединяя linedf с linestart..line_end, и ограничивающую рамку для PDF (статья 8C, проверка). Цитата — это единственное поле, в котором модель может воспроизводить текст, и оно используется только в качестве проверки: валидатор подтверждает, что это подстрока цитируемых строк, в которой указан неправильный номер строки. Фрагмент, который читает пользователь, всегда является восстановленным, но никогда не принадлежит модели.
class TextItem(BaseModel):
text: str
spans: list[Span] = Field(default_factory=list)
class AmountItem(BaseModel):
amount: Amount
spans: list[Span] = Field(default_factory=list)
class DateItem(BaseModel):
date: DateValue
spans: list[Span] = Field(default_factory=list)
class BooleanItem(BaseModel):
boolean: bool
spans: list[Span] = Field(default_factory=list)
class TableItem(BaseModel):
table: TableValue
spans: list[Span] = Field(default_factory=list)
Одна и та же форма элемента охватывает два параллельных шаблона: многоэлементный список (по одному элементу на элемент, каждый со своим диапазоном) и один элемент, данные о котором охватывают несмежные области источника:
Многоэлементный список в сравнении с многопролётным цитированием: одинаковая форма элемента, разная мощность — изображение автора.
2.3 Поля самооценки и конвейерной обратной связи.
Поля обратной связи — это те области, где схема начинает управлять конвейером. Каждый ответ, относящийся к конкретной форме, наследует одни и те же поля базы ответов, разделённые на две группы: то, что модель думает о своих результатах, и то, что конвейер считывает для принятия решения о следующем шаге.
Самооценка: что модель думает о своих результатах:
достоверность: значение с плавающей точкой ∈ [0, 1] — самооценка достоверности модели. Это не калиброванная вероятность; рассматривайте её как сигнал для сортировки. Значения 0,5 или 0,6 заслуживают повторного рассмотрения; значение 0,9 в вопросе о сложной таблице не означает, что ответ правильный.
предостережения: список[str] — ограничения на естественном языке: «В данном предложении используется слово „разумный“ без его определения», «В двух отрывках приводятся противоречивые даты». Для юридического использования или соблюдения требований законодательства предостережения часто более ценны, чем сам ответ.
метод извлечения (уже описанный в разделе 2.1): дословный / вычисленный / выведенный / na.
Конвейер-обратная связь: то, что конвейер считывает для принятия решения о следующем шаге:
answerfound: bool и completeanswerfound: bool — два двоичных сигнала. answerfound=False означает, что мы не извлекли ничего полезного (несоответствие формы по количеству/дате или несоответствие по корпусу). answerfound=True, completeanswer_found=False означает, что мы что‑то получили, но частично. Модель устанавливает это, когда обнаруживает в тексте признаки неполноты: числовое ожидание, противоречащее тому, что там есть («пять исключений», но только три на странице), прямая ссылка («дополнительные сведения см. в разделе 7…»), предложение, которое заканчивается запятой в нижней части страницы. Случай, который модель не может обнаружить (чистое окончание, которое на самом деле является серединой списка), — это то, для чего используется сильный сигнал в разделе 2.4. Для полного ответа требуются оба флага True. Разделение сигнала позволяет конвейеру выполнять различные действия: в первом случае — поиск по узкому пути, во втором — более широкий поиск.
contextcompletenessweak: значение с плавающей запятой ∈ [0, 1] — мнение модели, полученное из извлечённой области, о том, достаточно ли контекста содержится в отрывках. Модель делает выводы по отсутствию знаков препинания, обрывам на середине предложения, прямым ссылкам, которые показывает сам отрывок. Слабый, потому что она может видеть только то, что было извлечено; если усечение невидимо изнутри (страница заканчивается чистой точкой в середине списка), этот сигнал не попадает в него. Раздел 2.4 связывает его с сильным программным сигналом, который выходит за рамки области поиска.
context_structured: bool — указывает, хорошо ли обработан фрагмент. Если модель получила то, что выглядит как искажённая таблица (значения столбцов перемешаны, заголовки и строки перемешаны), она присваивает этому параметру значение False. Затем конвейер может перенаправить эту страницу через другой синтаксический анализатор (Camelot, Docling, vision-language model) и повторить попытку. Модель становится детектором ошибок синтаксического анализа выше по потоку.
llmdiscoveredkeywords: список[str] — вклад модели в следующую итерацию. При чтении фрагментов модель часто замечает термины, которые могли бы улучшить исходный поиск. «Я вижу, что в этом фрагменте используется термин „страница объявления“. Это было в исходном запросе?» Эти ключевые слова регистрируются и могут быть добавлены в следующий раунд поиска.
keywords_found: список[str] — какие из терминов исходного запроса встречались в отрывках. Если пользователь задал вопрос о «премиум», а в тексте нет этого слова, связь между вопросом и ответом является чисто семантической. Информация, которая заслуживает внимания.
conflicting_evidence: bool: помечает фрагменты, которые противоречат друг другу. Часто встречается в контрактах с поправками, в документах с версиями, в нормативных документах с изменениями. Модель гласит: «Я вижу две даты, и они не совпадают», а не выбирает одну произвольно.
рекомендуемое_разъяснение: str | None: что предлагает модель, когда вопрос слишком неоднозначен, чтобы на него можно было уверенно ответить. Напрямую связано с этапом синтаксического анализа вопроса: когда система должна задавать вопросы, а не угадывать, модель предлагает разъяснение.
Архитектурное разделение: RichAnswer (или, скорее, семейство TextAnswer, AmountAnswer, ...) — это то, что создаёт LLM. Конвейер сохраняет свою трассировку отдельно для дочернего GenerationResult, поэтому он никогда не проходит через responsions.parse. По двум причинам. Архитектурная: трассировка заполняется диспетчером (статья 8B, оперативная сборка), а не моделью; исключение её из схемы, ориентированной на LLM, делает эту границу явной. Механическая: режим структурированного вывода OpenAI требует, чтобы каждая объектная схема объявляла дополнительные свойства: false. Поле произвольной формы dict[str, любое] в схеме, ориентированной на LLM, приводит к сбою запроса. Сохранение трассировки в GenerationResult позволяет обойти ограничение с помощью конструкции.
class AnswerBase(BaseModel):
extraction_method: Literal['verbatim','computed','inferred','na']
confidence: float
caveats: list[str] = []
answer_found: bool
complete_answer_found: bool
context_completeness_weak: float
context_structured: bool
llm_discovered_keywords: list[str] = []
keywords_found: list[str] = []
conflicting_evidence: bool
suggested_clarification: str | None = None
class TextAnswer(AnswerBase): items: list[TextItem]
class AmountAnswer(AnswerBase): items: list[AmountItem]
class DateAnswer(AnswerBase): items: list[DateItem]
class BooleanAnswer(AnswerBase): items: list[BooleanItem]
class TableAnswer(AnswerBase): items: list[TableItem]
ListAnswer = TextAnswer
ANSWER_REGISTRY = {
"text": TextAnswer, "amount": AmountAnswer,
"date": DateAnswer, "boolean": BooleanAnswer,
"table": TableAnswer, "list": ListAnswer,
}
Ответная полезная нагрузка — это только половина того, что возвращается при генерации. Трубопроводу также нужен след того, что было использовано для его производства (наименование модели, оперативная версия, извлечённый контекст), поэтому ответные данные завернуты в GenerationResult:
@dataclass
class GenerationResult:
answer: AnswerBase
meta: dict[str, Any] = field(default_factory=dict)
Четыре примера ниже показывают одну и ту же базу ответов, заполненную для четырёх разных случаев, каждый из которых (вопрос, извлечённый контекст, сгенерированный JSON) тройной. Поиск считается правильным во всех четырёх: вариация исходит из того, что содержит документ и как модель сообщает о нём. Комбинация ответов, полных ответов, противоречивых доказательств и оговорок — это то, что говорит диспетчеру (статья 8C, валидация), какой маршрут выбрать следующим: отправить ответ, повторно найти, пройти по пути без ответа или вернуть разъяснение.
- Полный ответ. Пользователь запрашивает пять функций NIST Cybersecurity Framework (работа правительства США, общественное достояние в США, см. Заявление об авторском праве NIST), поиск находит пассаж, в котором перечислены все пять, и модель возвращает один элемент на функцию с её диапазоном доказательств. answer found=True, complete answer found=True, caveats=[], высокая уверенность. Диспетчер читает эти сигналы и отправляет ответ как есть.
{
"items": [
{"text": "Identify", "spans": [{"line_start": 88, "line_end": 88, "quote": null}]},
{"text": "Protect", "spans": [{"line_start": 89, "line_end": 89, "quote": null}]},
{"text": "Detect", "spans": [{"line_start": 90, "line_end": 90, "quote": null}]},
{"text": "Respond", "spans": [{"line_start": 91, "line_end": 91, "quote": null}]},
{"text": "Recover", "spans": [{"line_start": 92, "line_end": 92, "quote": null}]}
],
"extraction_method": "verbatim",
"confidence": 0.95,
"caveats": [],
"answer_found": true,
"complete_answer_found": true,
"context_completeness_weak": 0.9,
"context_structured": true,
"llm_discovered_keywords": [],
"keywords_found": ["function", "framework"],
"conflicting_evidence": false,
"suggested_clarification": null
}
- Частичный ответ. Пользователь просит исключить стихийные бедствия, полученный пассаж перечисляет землетрясение (одно совпадение) и явно указывает «продолжаются в Разделе 7» на строке 236, но Раздел 7 не был восстановлен. Модель возвращает один элемент, который она может извлечь, устанавливает complete answer found=False и сообщает «Раздел 7» в llm discovered keywords. Диспетчер читает complete answer found=False и запускает более широкий раунд поиска с использованием обнаруженных ключевых слов, прежде чем вернуть окончательный ответ пользователю. Этот сценарий является случаем обнаружения в пассаже: усечение видно изнутри извлечённой области благодаря намеку «продолжение в». Более сложный случай, когда отрывок заканчивается без такого намека, — это то, что улавливает следующая страница раздела 2.4.
{
"items": [
{"text": "Damage from earthquake or seismic events",
"spans": [{"line_start": 234, "line_end": 234,
"quote": "(c) damage from earthquake or seismic events;"}]}
],
"extraction_method": "verbatim",
"confidence": 0.7,
"caveats": [
"Only 1 exclusion found in retrieved passage ; line 236 points to Section 7 (not retrieved)."
],
"answer_found": true,
"complete_answer_found": false,
"context_completeness_weak": 0.5,
"context_structured": true,
"llm_discovered_keywords": ["Section 7", "additional exclusions"],
"keywords_found": ["exclusion"],
"conflicting_evidence": false,
"suggested_clarification": null
}
Нет ответа. Пользователь спрашивает о периоде отмены, но поиск выявил фрагмент премиум-плана, в котором не упоминается отмена. Модель честно возвращает элементы=[], answer found=False, extraction method="na" и оговорку, указывая, что именно содержит отрывок по сравнению с тем, что отсутствует. Диспетчер выбирает путь без ответа: либо сообщить пользователю «не найдено в этом документе», либо перефразировать запрос и повторить один раз, прежде чем сдаться.
{
"items": [],
"extraction_method": "na",
"confidence": 0.0,
"caveats": [
"Retrieved passage covers premium, deductible and fees, not the cancellation period."
],
"answer_found": false,
"complete_answer_found": false,
"context_completeness_weak": 0.2,
"context_structured": true,
"llm_discovered_keywords": [],
"keywords_found": [],
"conflicting_evidence": false,
"suggested_clarification": null
}
- Противоречивые доказательства. Пользователь запрашивает эффективную дату, поиск возвращает как исходную дату (линия 56), так и более позднюю поправку (линия 178) — значения при этом различаются. Модель возвращает оба элемента, а не выбирает один, из‑за чего возникают противоречивые доказательства. При этом она обозначает конфликт как оговорки и предлагает уточнение. Диспетчер читает: «Противоречивые доказательства = Правда» — и показывает конфликт пользователю, а не пытается угадать.
{
"items": [
{"text": "2024-03-15",
"spans": [{"line_start": 56, "line_end": 56,
"quote": "Effective: 15 March 2024 (original)"}]},
{"text": "2024-04-01",
"spans": [{"line_start": 178, "line_end": 178,
"quote": "Effective date: 1 April 2024 (amended)"}]}
],
"extraction_method": "verbatim",
"confidence": 0.5,
"caveats": [
"Two effective dates found: 15 March 2024 (original) and 1 April 2024 (amendment)."
],
"answer_found": true,
"complete_answer_found": true,
"context_completeness_weak": 0.85,
"context_structured": true,
"llm_discovered_keywords": ["amendment"],
"keywords_found": ["effective", "date"],
"conflicting_evidence": true,
"suggested_clarification": "Original date (2024-03-15) or amended (2024-04-01)?"
}
2.4 Дополнение: программная полнота (сильный сигнал)
Один сигнал полноты слишком важен, чтобы доверять модели, поэтому его вычисляет трубопровод. Умышленно нет поля context completeness strong: оно задаётся трубопроводом — на основе того, что было указано включить в контекст.
Представьте, что вы спрашиваете модель о списке исключений в политике. Выберите якоря в разделе «Исключения» через TOC и нажмите на страницу 5 модели: там представлены элементы (a)–(e), последний из которых заканчивается чистым периодом. Модель читает страницу 5, выделяет пять элементов, устанавливает full answer found = True и context completeness weak high. Внутри страницы список выглядит полным.
Но ни модель, ни человек, читающий страницу 5, не могут определить, находятся ли элементы (f), (g), (h) на странице 6. Чистый период в нижней части страницы свидетельствует о том, что предложение закончено, а не список. Единственный способ узнать это — посмотреть на следующую страницу. Следующая страница может быть одной из двух вещей:
- заголовком нового раздела (например, «Раздел 5: Пределы охвата») — тогда предыдущий список был ограничен изменением раздела, и страница 5 была завершена;
- продолжением предыдущего раздела (пункты (f), (g), (h) — там, где вы ожидали увидеть заголовок) — тогда список был сокращён, а страница 5 выглядела полной лишь из‑за того, что разрыв страницы пришёлся на законченное предложение.
Проблема в том, что LLM никогда не видит страницу 6. Он судит по тому, что получает, и контекст на пятой странице всегда читается как полный, если вырезание чистое — даже когда документ продолжается. Таким образом, context completeness weak не способен выявить этот класс ошибок, независимо от того, насколько хорошо модель проводит интроспекцию.
Решение — это выбор поиска, а не поколения. Поиск лучше рассматривать как параметрический модуль с несколькими настройками:
- начальная и конечная страница;
- выбор уровня строки (иногда ответ состоит из двух конкретных строк, и вам не нужно ничего лишнего);
- необязательное одностраничное перекрытие за пределами последней известной страницы раздела (это актуально в данном случае).
Независимо от того, запрашивает ли запрос поиск «только соответствующего раздела» или «раздела плюс одной перекрывающейся страницы», устанавливается форма вопроса. Страница перекрытия не передаётся в LLM — она остаётся у конвейера как доказательство для проверки полноты после генерации. С перекрытием трубопровод получает детерминированный вердикт: новый заголовок вверху → раздел ограничен; содержание продолжения → раздел усечён.
Аналогия с частичным перекрытием такова: оно гарантирует, что ни один факт не будет разрезан пополам между двумя фрагментами; параметр перекрытия страницы гарантирует, что ни один список не будет разрезан пополам между двумя областями поиска. В любом случае безопасность достигается за счёт преднамеренного извлечения чуть большего объёма данных, чем кажется строго необходимым.
Когда хвост стоит своих денег? Это зависит от того, насколько хорош TOC.
Если TOC идеально подходит для линии — то есть при анализе toc df точно отмечает начало и конец каждого раздела, — поиск может извлечь точно соответствующий раздел без хвоста. Вы сэкономите жетоны. Сильный сигнал станет факультативным страхованием.
Если TOC несовершенен (типичный случай: документ не имеет TOC, парсер пропустил заголовок из‑за необычного шрифта, раздел длился немного дольше, чем предполагал TOC), одностраничный хвост является сеткой безопасности. Стоимость составляет одну дополнительную страницу за запрос (~500–1000 токенов для обычного PDF). Преимущество заключается в детерминированном обнаружении усечённых ответов — это класс неудачи, который ни модель, ни эксперт не могут выявить из извлечённого контекста.
Что нужно от анализа и поиска. Оба восходящих модуля вносят свой вклад. Parsing раскрывает раздел end page в toc df с помощью простого соглашения: TOC почти никогда не указывают, где заканчиваются разделы, но начальная страница следующего раздела является неявным концом + 1. С помощью этой колонки поиск даёт один ответ на вопрос «Как далеко идёт этот раздел?». Затем поиск решает — в зависимости от формы вопроса — вытащить [start page, section end page] точно или добавить хвост одной страницы. Поколение использует только полученное поле context completeness strong: оно не определяет форму поиска, а считывает сигнал и реагирует (отправляет ответ или запускает привязку).
На рисунке ниже показан случай усечения в действии. Янтарная панель — это то, что увидел LLM: только страница 5 с пятью элементами, заканчивающимися в чистый период. С позиции модели список читается как полный, и JSON, который она возвращает, говорит об этом (complete answer found=true). Голубая панель — это то, что трубопровод извлёк отдельно в качестве доказательства проверки постгена: первые строки страницы 6, которые начинаются с пункта (f) вместо нового заголовка раздела. Модель никогда не видела синюю панель; трубопровод её увидел, установил контекст «полнота сильная = ложная» и запустил поиск с более широким охватом.
Pipeline's page-6 peek улавливает усечение, пропущенное LLM; refetch triggered — Изображение автора.
Ограниченный случай — это зеркальное отражение. Тот же вопрос, тот же контент страницы 5, но синяя панель начинается с «Раздел 5. Пределы покрытия» вместо пункта (f). Трубопровод отмечает «контекст полнота сильное = Правда» и отправляет ответ: доказано, что на этот раз заявка модели подкреплена проверкой трубопровода.
Вторичная проверка: чистота границы на пролёт. Это помощник, а не заголовок. Для каждого отрезка в каждом пункте ответа трубопровод может спросить: начинается ли отрезок в начале абзаца или в середине предложения? Заканчивается ли он на чистой терминальной пунктуации? Являются ли линии смежными или существует разрыв? Эти проверки на размах выявляют другой режим отказа (один размах, который разрезает подтверждающие доказательства пополам), и они не требуют взгляда. Они полезны в качестве инструмента для сортировки по предмету, а не в качестве замены для следующего взгляда на страницу, когда возникает вопрос «является ли ответ полным?».
2.5 Как выполняется договор
Ограниченное декодирование — это то, что делает контракт реальным: с ответами. При анализе модель не может вернуть выход, который не анализируется. Ниже представлена иерархия более слабых откатов, упорядоченных по надёжности:
Pydantic + responses.parse (или эквивалентный собственный структурированный выходной API). API реализует схему во время декодирования: модель не может вернуть вывод, который не анализируется. Наиболее надёжный. Это то, что использует остальная часть этой статьи.
Схема JSON со структурированным режимом вывода. Та же идея, уроженец Джона. Используется, когда Pydantic недоступен или когда ориентирован на потребителя, не являющегося Python.
Схема JSON в подсказке с «возвратом действительного JSON». Никакого декодирования. Модель, как правило, соответствует, но вы должны проверить после факта. Используйте в качестве запасного варианта, когда провайдер не раскрывает структурированный API.
Просто «верните Джона» в виде расплывчатой инструкции. Избегайте этого. Модель в основном будет соответствовать требованиям, но иногда она будет оборачивать JSON в блоки Джонсона, писать «Вот ответ» или использовать лишние запятые. Всё это нарушает работу потребителей.
JSON и Pydantic взаимозаменяемы по своей сути: Pydantic — это просто удобный в Python способ задать схему JSON с валидацией. Это более осмысленно, чем требовать «вернуть Джона» в кратчайшие сроки и в надежде на успех.
В реальных условиях: модели с открытым исходным кодом и JSON. Надёжность структурированного вывода сильно различается в разных моделях. Структурированный режим вывода OpenAI и инструменты Anthropic очень надёжны. Среди моделей с открытым исходным кодом хорошо показывают себя Phi-4, Mistral-Nemo и Llama-3.3 с грамматикой ограниченного декодирования (vLLM-грамматики или llama.cpp GBNF). «Мыслительные» модели с явным рассуждением (в стиле DeepSeek-R1, некоторые режимы Qwen) менее надёжны для работы с JSON: ход рассуждений загрязняет вывод, и модель с трудом переключается на чистый JSON в конце. Для структурированных рабочих нагрузок предпочитают модели без рассуждений или явные режимы, в которых рассуждения отключены. Качество вывода JSON и размер исходной модели не связаны: меньшая модель с грамматическими ограничениями часто оказывается эффективнее большой без таких ограничений.
- Заключение
Контракт декларируется так: набранные значения гарантируют, что нижестоящий код никогда не восстановит строку, элементы, связывающие каждое значение с его доказательными диапазонами, поля самооценки и обратной связи трубопровода, а также один сигнал полноты, который вычисляет сам трубопровод. Объявить это — лишь половина работы. Статья 8В (незамедлительная сборка) формирует вызов, который заполняет договор: схема, выбранная из реестра, подсказка системы, составленная из фиксированного BASE плюс фрагменты, полный след, сохранённый для аудита. Статья 8С проверяет возвращаемые данные и определяет следующий шаг трубопровода.
- Источники и дальнейшее чтение
Контракт основан на ограниченном декодировании: в статье используются структурированные результаты OpenAI (responses.parse, август 2024) и механизм, описанный в работе Willard and Louf (Outlines, 2023). Идея маркера отражения от Asai et al. (Self-RAG, ICLR 2024) — это опубликованная концепция, лежащая в основе областей обратной связи трубопровода в разделе 2.3. Схема цитирования ответов (AnswerWithEvidence) относится к тому же семейству, что и работа Bohnet et al. (Attributed Question Answering, 2022). Словарь, используемый в литературе, связан с ограниченным декодированием и структурированной генерацией; контролируемое исполнение — это сборка этого элемента: вызов LLM находится внутри инженерного переключателя, а не перед петлёй агента.
То же направление, что и в статье:
OpenAI, Структурированные выходы. Официальный документ + пост, выпущенный в августе 2024 года, о «100% соблюдении схемы». Весь подход зависит от того, насколько это надёжно.
Willard & Louf, Efficient Guided Generation for Large Language Models (Outlines), 2023 (arXiv:2307.09702). Документ о ограниченном декодировании; эквивалент структурированных выходов OpenAI с открытым исходным кодом и механизм, превращающий «схему в контракт».
Asai et al., Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection, ICLR 2024 (arXiv:2310.11511). Токены отражения — это опубликованная идея полей обратной связи трубопровода, описанных в разделе 2.3.
Bohnet et al., Attributed Question Answering: Evaluation and Modeling for Attributed Large Language Models, 2022 (arXiv:2212.08037). Схемы цитирования ответов до волны ограниченного декодирования; опубликованная идея, лежащая в основе AnswerWithEvidence.
Ранее в серии:
Оригинальное название: Document Intelligence: Series Intro. Что строится в серии — кирпич за кирпичом и в каком порядке.
Что работает, что ломается
Базовый Enterprise RAG, от PDF до выделенного ответа. Четырёхкирпичный конвейер от начала до конца: PDF in, выделенный ответ.
Встраивание сходства не является магией: предсказуемые случаи сбоя при извлечении данных в RAG (недоступная ссылка — история). Там, где встраивание сходства эффективно (синонимы, опечатки, перефразирование), а где оно предсказуемо даёт сбой (неизвестные термины, отрицание, соответствие термина и релевантного ответа), и как его применять в любом случае.
Реранкеры тоже не волшебны: когда кросс-кодер обходится дорого. Что добавляет кросс-энкодер по сравнению с би-энкодерными вставками, как это измеряется и в каких случаях возникает задержка.
RAG — это не машинное обучение, а набор инструментов. ML решает не ту задачу. Почему подбор и настройка размера фрагмента оптимизируют не то, что нужно; вместо этого следует ориентироваться на тип вопроса.
От регулярных выражений до моделей компьютерного зрения: какая техника RAG подходит для той или иной задачи. Две оси — сложность документов и контроль вопросов — помогают выбрать технику для каждого конкретного случая.
10 распространённых ошибок в RAG, которые мы по-прежнему наблюдаем в производстве. Десять ошибок в производственной среде, разложенных по полочкам, с исправлениями для каждой.
Отбор документов
Помимо извлечения текста: два слоя в PDF, которые обеспечивают качество RAG. Первая половина «разборочного кирпича»: характер документа, сигналы и резюме.
Перестаньте возвращать простой текст из PDF: для RAG нужны реляционные таблицы. Вторая половина «разборчивого кирпича»: реляционные таблицы считываются каждым последующим элементом системы.
Когда PyMuPDF не распознаёт таблицу: анализ PDF‑файлов для RAG с помощью Azure Layout. Те же таблицы из Azure Layout: нативные ячейки таблиц, OCR, роли абзацев.
Парсинг PDF для RAG локально с Docling: богатые таблицы без загрузки в облако. Те же таблицы, обработанные локально с помощью Docling: ячейки TableFormer, данные не покидают компьютер.
Vision LLM также могут выступать в роли парсеров PDF: диаграммы для RAG. Компьютерное зрение как парсер: изображения преобразуются в текст для поиска.
Сканированные PDF‑файлы для RAG с EasyOCR: бесплатный OCR выдаёт вам слова, а не структурированный документ. Там, где традиционная OCR останавливается: текст восстанавливается, но структура теряется.
Создание изображений PDF для поиска в RAG без оплаты за их чтение. Каскад изображений: недорогой фильтр, классификация, описание только тех фрагментов, которые стоит прочитать.
Реконструкция таблицы содержимого PDF‑файла, которая была упущена при отправке, чтобы RAG мог охватить нужный раздел. Восстановление toc df, когда PDF выводит страницу содержимого, но не отображает контур.
Парсинг вопросов
Вопросы в RAG тоже требуют разбора: преобразуйте строку, введённую пользователем, в сводки для поиска и генерации. Суть разбора вопросов: почему строка, введённая пользователем, нуждается в таком же разборе, что и документ, и как её разбить на резюме для поиска и краткое изложение для генерации.
Что извлекает парсер вопросов из строки, введённой пользователем: ключевые слова, объём, форма, разложение, уточнение. Пять групп столбцов, которые парсер считывает непосредственно из вопроса пользователя, с кодом, заполняющим каждый из них.
Отправка обработанного вопроса в RAG: стратегия фрагментации, уровень модели, активации, аудит. Решения, которые парсер принимает на основе строки, введённой пользователем, с учётом профиля документа: отправка, активация, полная схема, аудиторский след (pipeline trace.json) и прохождение брокерского корпуса.
Разъяснительная петля и выявленные недостатки: когда вопрос недостаточно точен. Одно целенаправленное уточнение, если вопрос слишком расплывчатый, а по умолчанию — уточнение, выученное из ответа.
Поиск
Поиск — это фильтрация, а не поиск: ментальная модель для корпоративного RAG. Извлечение данных переосмыслено как фильтрация онлайн‑df и toc‑df: якоря маленькие, контекст большой.
Якорное обнаружение для RAG: параллельные детекторы, затем один вызов LLM в конце. Параллельные якорные детекторы: всегда ключевое слово, вставки рядом, один вызов LLM в конце.
Позвольте LLM выбрать правильную страницу в RAG: шаблон арбитра в конце поиска. Арбитр LLM: кандидаты ранжируются по причинам, один тип формирует JSON.
Структура контекстной инженерии: четыре фрагмента, набираемых за каждым ответом RAG. Контекстная инженерия имеет структуру из четырёх типизированных фрагментов (фиксированные строки системы, извлечённые строки, блок док-контекста, обёртка PromptContext), которые заполняют один однодокументальный вызов RAG LLM.
Кежан Ши
Смотреть все клипы Kezhan Shi
Deep Dives, обработка документов, Llm, Python, Rag
Поделиться
Эта статья
Поделиться на Facebook
Поделиться на LinkedIn
Поделиться на X
Towards Data Science является публикацией сообщества. Предоставьте свои идеи, чтобы охватить нашу глобальную аудиторию и заработать через Программу оплаты авторов TDS.
Написать для TDS