Составьте каждое задание для поколения RAG, исходя из базового задания и правил, используемых для каждого вопроса.
Enterprise Document Intelligence [Том 1 #8B] – Фиксированная база, правила, необходимые для каждого вопроса, один реестр: диспетчер, который преобразует проанализированный вопрос в типизированный вызов LLM.
Делитироваться

Эта статья — вторая часть цикла статей о построении корпоративной системы RAG из четырех компонентов: разбора документов, разбора вопросов, поиска и генерации. В статье 8A (контракт ответа) объявлен классотипических схем и ANSWER_REGISTRY , который сопоставляет каждую форму ответа с ее схемой. Эта часть формирует вызов, который выполняет контракт: принимает ParsedQuestion ; выбирает схему из реестра, формирует системную подсказку из фиксированного BASE плюс диспетчерские фрагменты, формирует пользовательскую подсказку, создает модель и сохраняет трассировку полной. Что происходит с ответом после вызова — это статья 8C (проверка).
Поколение — это четвёртый кирпичик. Читатель, попавший сюда, может взять первые три из их нормальных статей:
- В результате анализа документа PDF-файл был преобразован в структурированные таблицы: Статья 5А (что читать в PDF-файле) и статья 5Б (реляционная модель данных).
- В результат анализа результатов пользовательская строка была преобразована в типизированный
ParsedQuestion: Article 6A (тезис), Article 6B (извлечение) и статья 6C (отправка). - В процессе поиска фрагменты текста отфильтровываются до тех, которые должны сохранить ответ: статья 7A (поиск как фильтрация), статья 7B (обнаружение якоря) и статья 7C (арбитр LLM).

📓 Запускаемые сопутствующие блокноты доступны на GitHub : документ-Intel/ноутбуки-vol1.

1. Краткое сообщение оперативному: диспетчеру
Один запрос на каждый вопрос, формируемый во время вызова. Это и есть диспетчер. Альтернативой является мега-запрос, в котором скатывается каждый код RAG: один системный запрос, обрабатывающий одновременно количество, даты, упорядоченные таблицы и свободный текст. При каждом вызове он добавляет новое условное условие («если ответ — дата, воспользуйтесь ISO 8601; если часть — ISO 4217; если список — один элемент на каждый элемент…»), модель каждый раз считывает всё это, и через два месяца никто уже не помнит, какое условие было добавлено для какого случая.
Создаваемый нами диспетчер заменяет этот беспорядок. Контракт: на вход приходит ParsedQuestion ; на выходе три вещи: схема (выбранная из ANSWER_REGISTRY параметром expected_answer_shape ), системная подсказка (фиксированная BASE плюс фрагменты, запрашиваемые в кратком описании) и пользовательская подсказка (вопрос + ключевые слова + помеченные следующие тексты). Он создает модель, сохраняет полный необработанный ответ в трассировке и возвращает типизированный результат. Добавление новой формы включает один фрагмент; добавление новых ограничений, добавление одного фрагмента; ничего комбинаторного.

Отклоненные нами альтернативы: один мега-запрос со стороны, всегда присутствующих в нем (тратит токены, составляет отладку, когда условие формирования фигур в ответе на вопрос о сумме), или N отдельных примеров для каждой фигуры (более один подход для каждой фигуры, но дублирует общие ограничения (форматирование, различение, обязательное уточнение) во всех шаблонах и заставить выполнять повторную синхронизацию при каждом шаге).
1.1 Задание: ParsedQuestion
Диспетчер считывает объект ParsedQuestion созданный блоком анализа вопроса. Полная схема представляет собой реляционной (вложенные объекты Pydantic, не плоское краткое описание): ключевые слова (типизированные объекты, а не строки), ожидаемая форма ответа, декомпозиция, фильтры области действия, плановое выполнение, примечания к парсингу, а также две логические операции для следующих блоков (<код>поиск: RetrivalQuery и generation: GenerationBrief .
В этой статье анализируются три части этого запроса: expected_answer_shape (выбирает форму и фрагмент формы), <код>генерациякод>(краткое описание, содержащее ограничение по форме, определение совпадения между близкими кандидатами и значения для различия) и <код>ключевые словакод> (отражаются в подсказке пользователя, чтобы можно было отметить отметки модели, какие из них встретились в полученных фрагментах текста). Граница четкая: определение формы происходит в процессе анализа вопроса; сборка шаблонов происходит здесь.
Ниже мы создаём ParsedQuestion непосредственно в коде; Текущий пакет src/docintel/question/parse_question.py содержит только минимальную версию. Схема и диспетчер будут добавлены в пакет после того, как будет реализована полная версия компонента для разбора вопросов.
class Ключевое слово (BaseModel): текст: str вес: float = 1.0 источник: Literal['direct','llm_expansion','expert_dictionary'] = 'direct' is_regex: bool = False class RetrivalQuery(BaseModel): main_query: str перезаписывает: list[str] = []anchor_keywords: list[str] = []section_hint: str | None = Нет. Область действия: str = 'default' класс GenerationBrief(BaseModel): original_question: str format_constraint: dict[str, str] = {} значения: str | None = Нет must_distinguish: list[Distinction] = [] class ExecutionPlan(BaseModel): use_toc_navigation: bool = True use_keyword_retrival: bool = True use_embeddings: bool = False iterate_on_feedback: bool = True max_iterations: int = 3 class ParsedQuestion(BaseModel): original_question: str ключевые слова: список[Ключевое слово] ожидаемый_ответ_форма: Литерал['текст','количество','дата','логическое значение','list','таблица'] decompose_subquestions: list[str] = [] активации: ExecutionPlan = ExecutionPlan() parsing_notes: list[str] = [] предложенное_разъяснение: str | Нет = Нет извлечения: Генерация запроса: GenerationBrief
1.2 Структурные подсказки по вопросам
Формулировка вопроса Пользователь определяет область поиска без добавления каких-либо новых флагов в конвейерной обработке. Когда в вопросе говорится «на странице 1», «страницы с 5 по 7» или «в прайс-листе», этот индикатор применяется через ParsedQuestion.structural_hints . Поиск считывает его и фильтрует пространство поиска. Нет chunk_strategy="passthrough" , нет аргумента обхода, нет специального короткого пути к документации. Оператор управляет поиском, указывая ее внутри вопроса.
Поле содержит один список для каждой формы. Данная статья доступна только в формате PDF, поэтому здесь важен параметр pages_hint ; поля-аналоги для других форматов, рассмотренных в сериях статей, остаются вне области видимости:
class StructuralHints(BaseModel):pages_hint: list[int] | Нет = Нет # PDF; для более поздних форматов следует эквивалент листа/слайда
Фразы, содержащие одиночные, диапазонные и списочные данные, созданные из одного и тому же плоскому списку во время разбора: «страница 1» становится [1] , «страницы с 5 по 7» становится [5, 6, 7] , «страница 2 и страница 7» становится [2, 7]. Затем поиск фильтруется по фразе page_df[page_df.page_num.isin(pages_hint)] , и не разветвляется в зависимости от формы подсказки. Страницы, на которых есть подсказки, главное, даже если ни одно ключевое слово им не соответствует: пользователь явно их закрепил, то есть это поверхность ответа.
В случае с короткими документами это действительно работает. Когда следствием является заключение, одностраничный счет-фактура или служебная записка на 1-2 страницы, весь документ помещается в контекстное окно модели. Оператор пишет: «Извлечь эти поля со страницы 1 этого резюме», «Прочитать страницу 1 и вернуть позиции счета-фактуры», «На странице 1 перечислить всех названных участников». Анализ запроса извлекает pages_hint=[1] ; фильтр поиска line_dfдо строки на странице 1 (что в одностраничном документе означает каждый символ); генерация читает весь документ и выполняет запрошенную схему. Структура конвейера идентична запросу к корпусу из 1000 страниц, который случайно включает одну страницу: тот же код, та же цепочка аудита, тот же контракт. Эти формы представляют собой рамки для данной области; главное, что указатель области поиска зависит от вопроса, а не от флага конвейера.
Следует соблюдать противоположное соединение: добавление фильтра по ключевым словам или встраивание сходства в одностраничный документ, который в этом не нуждается, и наблюдение за тем, как фильтр отбрасывает поля, которые LLM обнаружил. Схема выполнения пошаговой работы с механизмом внимания LLM; Корпус для фильтра слишком мал, чтобы какой-либо сигнал поиска имел ценность. Структурная подсказка в вопросе — единственный сигнал, принцип конвейера.
1.3 Системная подсказка: BASE + фрагменты
База данных BASE не зависит от формы: она кодирует контракт, действующий для каждого вызова: цитировать, вводить, честно признавать ошибку. Фрагменты, специфичные для каждой задачи: по одному на каждую форму ( amount , date , list , table , <код>логическое значениекод> , <код>тексткод>), по одному на каждое сквозное ограничение (формат, различать, разрешать неоднозначность, декомпозицию). Диспетчер формирует только то, что требуется в задании;
В 8А, почему это важно: модель предсказывает правдоподобное продолжение, она ничего не ищет в базе данных, поэтому мы привязываем каждое утверждение к номеру строки исходного текста, который не может проверить конвейерную обработку, а не к тексту, который модель может сгладить. Эта привязка хороша только настолько, насколько хорош номер строки. Правило GLOBAL_LINE в BASE не является стандартным. Когда мы впервые запустили это в статье Внимание, модель вернула line_start=33 для котировок, которые представлены в глобальной строке 267 (страница 6, <код>line_in_page=33код>). Модель выбрана третий столбец строки текста, введенных пользователем, вместо первого, потому что столбцы не были помечены. Указание номера столбца в системном запросе и его отметка в запросе пользователя (раздел 1.3) устранили ошибку. Когда текст строки содержит несколько столбцов с целочисленными значениями, модель выберет тот, который посчитает нужным, если вы не укажете, в каком столбце находится номер строки.
BASE = """Вы отвечаете на вопросы строго из предоставленных отрывков документа. Правила: - Используйте только информацию из отрывков. - Каждый элемент в `items` должен содержать хотя бы один интервал со ссылкой на номера строк источника. - Промежуток является непрерывным (line_start, line_end). Используйте несколько интервалов для одного элемента, когда подтверждающие доказательства разделены на несмежные области. - ВАЖНО: Span.line_start и Span.line_end ДОЛЖНЫ быть Значение GLOBAL_LINE (ПЕРВЫЙ столбец каждой строки отрывка), а НЕ строка_на_странице для каждой страницы. - Если отрывки не содержат запрошенный ответ, верните items=[], answer_found=False и объясните в предостережениях, что было или не найдено. - Установите Complete_answer_found=False, если ответ существует, но является частичным. - Если отрывки конфликтуют, установите конфликтующие_евиденс=True и всплывите в предостережениях. выглядит искаженным (разбитая таблица, искажения OCR), установите context_structured=False. """
Фрагмент формы не заменяет выбор схемы: схема уже задана заданной responses.parse(text_format=...) . Фрагмент управляет стратегией извлечения модели: «возвращать один AnswerItem в каждом элементе списка», «валюта ДОЛЖНА допустима в формате ISO 4217», «не конвертировать, если в документе указана другая валюта, вместо этого установите answer_found=False». Схема обеспечивает тип при декодировании; фрагмент управления извлечением во время запроса.
SHAPE_FRAGMENTS = { "text": "Используйте `text=...` для каждого элемента. Оставайтесь ближе к исходной формулировке.", "amount": "Fill `amount = Amount(value,currency, unit)`.", "date": "Fill `date = DateValue(iso, original)`. iso = ГГГГ-ММ-ДД.", "list": "ОДИН элемент для каждого элемента.", "table": "TableValue(headers, rows). Прямоугольный. ОДИН элемент в таблице.", "boolean": "Верно/ложно. Условные ответы.", } Def format_fragment(ограничение: dict[str, str]) ->ул | Нет: если нет ограничения: return None Rules = [] если "валюта" в ограничении: Rules.append(f"Валюта ДОЛЖНА быть {валюта!r}.") если "период" в ограничении: Rules.append(f"Период ДОЛЖЕН быть {период!r}.") если "date_format" в ограничении: Rules.append(f"Даты как {date_format!r}.") return "Ограничения формата:n- " + "n- ".join(rules) def ignore_fragment(различия: список[Distinction]) -> str | Нет: если нет различий: верните Нет строк = [f"- {d.this!r} НЕ является {d.not_!r}. Возвращайте только {d.this!r}." for d в различиях] return "Будьте осторожны с этими различиями:n" + "n".join(lines)
1.4 Диспетчер и подсказка пользователя
Диспетчер считывания разобранный вопрос, выбирает схему из ANSWER_REGISTRY[parsed_q.expected_answer_shape] (или явное переопределение answer_schema=... для формы формы: см. раздел 1.5), формирует BASE + соответствующие фрагменты и возвращает пару (prompt, apply) . Список applied фрагментов включается в трассировку, поэтому неправильный формат через шесть месяцев можно отследить до точного набора фрагментов, которые были сформированы. Никогда не до «решения агента».
def build_system_prompt(parsed_q: ParsedQuestion) ->tuple[str, list[str]]: parts: list[str] = [BASE] применено: list[str] = ["BASE"] parts.append(SHAPE_FRAGMENTS[parsed_q.expected_answer_shape]) apply.append(f"SHAPE:{parsed_q.expected_answer_shape}") Brief = parsed_q.generation if frag := format_fragment(brief.format_constraint): parts.append(фраг); apply.append("FORMAT") if frag := различать_фрагмент(brief.must_distinguish): parts.append(frag); apply.append("РАСПОЗНАВАНИЕ") if Brief.disambiguation: parts.append(f"Disambiguation: {brief.disambiguation}") apply.append("РАСПОЗНАВАНИЕ") if parsed_q.decompose_subquestions: parts.append("Этот вопрос распадается на ...") apply.append("РАЗДЕЛЕНИЕ") return "nn".join(parts), apply
Запрос к пользователю представляет собой тонкую оболочку: вопрос, исходные ключевые слова (чтобы были возможные отметки, которые из них были найдены в отрывках) и строки-кандидаты. Заголовок столбца GLOBAL_LINEtpagetline_in_pagettextповторяется в виде однострочного напоминания непосредственно перед данными: в той же форме, что и правило BASE, но заглавными буквами. Два напоминания в двух местах являются единственными резервными, но именно они исправили ошибку нумерации по страницам и глобальной нумерации из раздела 1.3. Дешевая страховка от повторяющихся сбоев.
def build_user_prompt(parsed_q: ParsedQuestion, filtered_line_df: pd.DataFrame) ->str: df = filtered_line_df, если "overall_line_num" отсутствует в df.columns: df = df.reset_index(drop=False).rename(columns={"index": "overall_line_num"})lines = "n".join( f"{int(r.overall_line_num)}t{int(r.page_num)}t{int(r.line_num)}t{r.text}" for r в df.itertuples() ) ключевое слово_strs = [k.text for k в parsed_q.keywords] return ( f"Question: {parsed_q.original_question}nn" f"Исходный запрос ключевые слова: {keyword_strs}nn" "Отрывки (разделенные табуляцией: GLOBAL_LINE\tpage\tline_in_page\ttext).n" "Цитировать через Span.line_start = GLOBAL_LINE (первый столбец).nn" f"{lines}" )
Один вызов против k вызовов: внутри build_user_prompt скрывает небольшой архитектурный выбор. При получении функции возвращается k=3 фрагмента фрагмента, мы можем передать все три модели одним объединенным вызовом или диспетчером сети последовательно , фрагмент за фрагментом, останавливаясь в тот момент, когда получим то, что нам нужно.
Эти два режима имеют совершенно разные профили затрат:
- Комбинированный режим(один вызов со всеми k фрагментами). Модель видит все сразу, может создавать перекрестные ссылки между фрагментами текста, автоматически переходя по ссылкам для каждого фрагмента через
items: list[XItem](один элемент на каждый вывод, каждый со своимSpan). Стоимость: один запрос туда и обратно, с полным контекстом. Это значение по умолчанию, когда ответ может быть синтезирован по фрагментам текста (список исключений, разбросанных по страницам, определение плюс пример сноски к нему). - Последовательный подход с досрочным завершением (один вызов на каждый фрагмент, остановка при успехе). Обработка фрагментов в порядке ранжирования при извлечении. После каждого вызова,
answer_found=TrueиComplete_answer_found=True: если оба значения изменения, прогресс от исправления и остальные про результат. Стоимость в лучшем случае: контекст одного фрагмента. Это правильный шаг, когда ответ представляет собой единственный факт, который, вероятно, находится в одном месте (сумма, дата, имя человека, ответ «да/нет»), поэтому фрагмент с шагом рейтингом почти всегда содержит его. Экономика 2/3 токенов при k=3.
В двух других случаях требуется последовательное выполнение независимо от формы запроса: каждый фрагмент достаточно велик, так что его объединение выводит контекст за 70-80% допустимого разделения (раздел 1.5) или фрагментов. неоднородныйтаким образом, что нарушают такую одну схему (раздел контракта, его поправка или приложение, каждый со своим протоколом проверок).
Решение о том, использовать ли комбинированный или последующий ответ, принимается на этапе анализ вопроса , а не здесь. Вопрос анализа уже классифицирует вопрос ( answer_shape , answer_type , неоднозначность и answer_context определяющий объем окружающего текста для чтения). Подсказка маршрутизации находится рядом с тем же ParsedQuestion , в поле верхних уровней chunk_strategy: "combined" | "последовательный"код> . Затем процесс извлечения считывает эту подсказку и решает, что произошло (один принцип шифрования или более широкий диапазон). В данной статье поколение получает окончательный список фрагментов плюс снижение и просто выполняет его. Все решения принимаются на этапе анализа; в этой статье результат только цикл.
Три преимущества композиции такой:
- Изменение формирует только один файл: Изменение суммы суммы (всегда с двумя десятичными знаками, всегда с указанием валюты в конце) → отредактируйте
SHAPE_FRAGMENTS["amount"]. Ничего больше не меняется. Структура типаAmountзащиты Pydantic отдельно. - Добавление ограничений затрагивает один файл. Новый тип вопроса начинает запрашивать «самое последнее значение, когда документ обеспечивает историю» → группы в диспетчере ветку <код>Prefer_recent_fragment(brief.prefer_recent) . Другие элементы остаются без изменений.
- Возможность аудита применяется бесплатно:
result.meta["fragments_applied"]точно пересчитывается, какие фрагменты были составлены для этого вызова. Неправильный формат через шесть месяцев можно отследить до неправильного отклонения формы (проблема с разбором вопросов) или ошибочного фрагмента (проблема, описанная в этом).
1.5 Вызовы моделей, сохранение трассировки, пользовательские схемы
статья
Температура 0. Создание RAG — это извлечение информации, нетворческое письмо. Воспроизводимость важной вариативности.
Всегда сохраняйте полный необработанный ответ в конвейере трассировки. Токены, модель версии, запрос идентификатора, причина, системный отпечаток, все остальное, что обеспечивает SDK: все это хранится в usage , model , id , system_fingerprint . Сохранение только output_text — это ошибка; Хранение всей полезной нагрузки стоит несколько килобайт на вызов и экономит часы, работая по анализу. Распространённая противоположная ошибка: оценка токенов с помощью локального токенизатора перед вызовом. tiktoken меняется в зависимости от версии модели, и вы тратите ресурс локального процессора на пересчёт того, что возвращает API, бесплатно.
Сохраняйте запас.Ограничьте использование на уровне 70-80%. Модели начинают враждовать задолго до достижения жесткого предела: если заполнить 127 тыс. руб. из 128 тыс. в окне, качество ответа, поведение человека и логическое мышление развиваются одновременно. Если вы постоянно используете больше, возникает проблема на низком уровне: поиск возвращает слишком много фрагментов, слишком большая схема или требуется более агрессивная фильтрация области более строгой. Соблюдать меры usage["input_tokens"]/model_max_input за окном последних вызовов, а не за разовой оценкой перед вызовом.
В производственной среде необработанные ответы таблица в таблице <код>ответыкод>или в объектном хранилище ключом к нему является идентификационный запрос. По прошествии шести месяцев, когда пользователь сообщает: «Ответ на вопрос X раньше был другим», вы можете получить ответ шифрования шифрования, ту версию модели и восстановить произошедшее. Без необработанных данных этот диалог невозможен.
defgenerate(parsed_q: ParsedQuestion, filtered_line_df, client, answer_schema: type[BaseModel] | None = None) ->GenerationResult: Answer = answer_schema или ANSWER_REGISTRY[parsed_q.expected_answer_shape] system, apply = build_system_prompt(parsed_q) user = build_user_prompt(parsed_q, filtered_line_df) resp = client.responses.parse( model=model_chat, input=[{"role": "system", "content": system}, {"role": "user", "content": user}], text_format=Answer, temp=0.0, store=False, ) ответ = Answer.model_validate_json(resp.output_text) return GenerationResult(ответ=ответ, мета={ "schema_used": Answer.__name__, "fragments_applied": применено, "template_version": "v1", "raw_response": resp.model_dump(mode="json"), })
Вот что содержит result.answer для определения запроса «Какие варианты кодирования позиции упоминаются?», полученного с помощью generate(parsed_q, filtered_line_df, client) на основе статьи «Внимание — это всё, что вам нужно» (Vaswani et al. 2017; лицензия arXiv на неисключительное распространение, указанная на странице аннотации arXiv). Выполняемые пути вызова сервисов OpenAI ( gpt-4.1 , gpt-4o-mini ), регулируемые Условия использования OpenAI:
{ "extraction_method": "дословно", "уверенность": 1.0, "предостережения": [], "ответ_найден": правда, "полный_ответ_найден": правда, "context_completeness_weak": 1.0, "context_structured": правда, "llm_discovered_keywords": ["позиционное кодирование", "обученный", "фиксированный", "синусоидальный", "синусоидальный" и косинус", "позиционные вложения"], "keywords_found": ["позиционное кодирование", "обученное", "фиксированное", "синусоидальное", "позиционное вложение"], "conflicting_evidence": false, "suggested_clarification": null, "items": [ {"text": "Изученные позиционные кодировки", "spans": [{"line_start": 165, "line_end": 165, "quote": "Существует множество вариантов позиционных кодировок, изученных и фиксированных [9]."}, {"line_start": 273, "line_end": 273, "quote": "Вместо этого мы экспериментировали с использованием изученных позиционных вложений",}]}, {"text": "Фиксированные (синусоидальные) позиционные кодировки", "spans": [{"line_start": 200, "line_end": 200, "quote": "Существует множество вариантов позиционных кодировок, изученных и фиксированных [9]."}, {"line_start": 165, "line_end": 165, "quote": "В этой работе мы используем функции синуса и косинуса разных частот."}]} ] }
А result.meta сохраненный вместе с ними, содержит: schema_used подтвержденный список параметров выбора, fragments_applied — это журнал аудита формирования запроса, а raw_response — это полезная функция OpenAI, сокращенная здесь до ключей, которые вы будете использовать позже ( model , id , usage ), плюс список оставшихся ключей верхних уровней для полноты анализа:
{ "schema_used": "TextAnswer", "fragments_applied": ["BASE", "SHAPE:list"], "template_version": "v1", "raw_response": { "model": "gpt-4.1", "id": "resp_06c182cee1f7d692016a16beb1074c8196...", "usage": { "input_tokens": 5051, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 546, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 5597 }, "_other_keys": ["background", "completed_at", "content_filters", "conversation", "create_at", "error", " Frequency_penalty", "incomplete_details", "instructions", "max_output_tokens", "max_tool_calls", "metadata", "moderation", "object", "output", "parallel_tool_calls", "presence_penalty", "previous_response_id", "prompt", "prompt_cache_key", "prompt_cache_retention", "reasonation", "safety_identifier", "service_tier", "status", "store", "temperature", "text", "tool_choice", "tools", "top_logprobs", "top_p", "truncation", "user"] } }
Пользовательское переопределение: Иногда проект имеет специфическую для предметной области структуру, который реестр не опровергает: пример Address из статьи 8A (контракт ответа) — именно такой случай. функция generate() в статье принимает answer_schema=MyCustomSchema в качестве переопределения; диспетчер использует его значения по умолчанию из реестра. Пользовательская схема должна быть подклассом AnswerBase , чтобы поля обратной связи оставались на месте. В поставляемой библиотеке эквивалентным интерфейсом является pdf_fields_qa(fields=[...])для структуры предметной области для каждого поля; которым сегодня требуется полностью настраиваемая схема ответа, напрямую используется llm_answer_with_evidence (переопределение аргумента для pdf_qa — это запланированное продвижение, отслеживаемое при переносе реестра команды в src/docintel/generation/ ).
Исходные данные, по институциональным принципам: API LLM возвращает гораздо больше, чем структурированный ответ, который видит пользователь. JSON-контейнер вокруг content содержит три группы полей, и каждая из них занимает свое место, передавая информацию нижестоящему узлу:
- Содержимое : вывод модели, экранированный внутри
output[0].content[0].textпока Pydantic не преобразует его в типизированный подклассAnswerBase. Именно эта часть становится видимым для ответа пользователя. - Использование :
input_tokens,output_tokens, а также счетчики попаданий/промахов в кэш. Следующий слой для расчета стоимости и задержки считываетusage.input_tokensиusage.output_tokensдля расчета стоимости каждого вопроса; отображаются счетчики кэша, какую часть запроса провайдер обработал из своего префиксного кэша. - Трассировка :
id, <код>моделькод> , <код>created_atкод> , <код>статускод> . Последующий аудит безопасности считывает <код>idкод> и <код>моделькод>
Правило : никогда не сохраняйте только content . На следующем уровне хранения подробно описана таблица llm_response , которая содержит этот JSON в постоянном виде. Несколько дополнительных байтов для запроса, целые категории анализа, которые мы можем активировать позже, вместо того, чтобы гадать.
{ "id": "resp_063b40a2e3406595016a0c4d62ee3c8195", "object": "response", "created_at": 1742832000, "model": "gpt-4o-mini-2024-07-18", "status": "completed", "output": [ {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "{"items": [{"text": "Изученные позиционные кодировки", "spans": [{"line_start": 266, "line_end": 266, "quote": "Существует множество вариантов позиционных кодировок, изученных и фиксированных [9]."}]}, ...], "ответ_найденный": правда, "полный_ответ_найденный": правда, "достоверность": 0,9, "предостережения": [], "extraction_method": "дословно", "keywords_found": ["позиционный", "кодирование"], "конфликтирующее_свидетельство": ложь, "предлагаемое_разъяснение": null}"}]} ], "usage": { "input_tokens": 2347, "input_tokens_details": {"cached_tokens": 1850}, "output_tokens": 412, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 2759 } }
2. Доказательства, полученные в ходе выполнения отдельных действий: путь эскалации.
Когда ответ состоит из двадцати типизированных полей вместо одного предложения, перенесите оку AnswerWithEvidence на поля уровня. Схема становится классом Pydantic, каждое поле представляет собой FieldExtraction[T] : каждое содержит свое типизированное значение плюс разнообразие, из которого его модель. читает. LLM выполняет все дерево одним структурированным вызовом; Нижестоящий код считывает profile.email.value для полей и profile.email.page/line_start/line_end для цитат. AnswerWithEvidence для одного положения действует по умолчанию; это путь эскалации для извлечения нескольких полей в регулируемых контекстах (сортировка персонала, кредитные решения, прием медицинских документов, обработка счетов).
В серии это уже реализовано на уровне элементов . AddressItem из статьи 8A (контракт ответа) оборачивает значение Address с его spans . AmountItem оборачивает Amount с его spans . DateItem оборачивает DateValue с его spans . Элемент — это значение плюс место, где это значение было считано. Тот же шаблон, примененный на уровне поля , а не на уровне элементарного списка, явно показан в этом разделе.
2.1 Три наивные формы, которые ломаются
Первый рефлекс — один вызов на каждое поле . Каждый вызов — это <код>qa.ask(pdf, «Какой адрес электронной почты кандидата?», response_format=str) . Аудит проходит без проблем (одна строка лога на поле), но контекстный документ отправляется N раз, и стоимость увеличивается по строке. Для одностраничного резюме с четырьмя полями это самый дорогой из существующих вариантов.
Европейский рефлекс — это один вызов, возвращающий запрос JSON без каких-либо доказательств . <код>{имя: «…», адрес электронной почты: «…», телефон: «…»}код> . Стоимость снижается в N раз, потому что документ отправляется один раз. Происхождение полностью исчезает; неправильное поле невозможно отследить до конкретных строк. Кандидат, подавший апелляцию, не может ответить, какой именно текст моего резюме прочитала модель?
Третий вариант — это один вызов с блоком доказательства верхних уровней : {profile: {...все поля...}, охватывает: [...]} . Лучше, но ссылка на каждое ответ, а не на каждое поле. Рецензент просматривает список страниц, но не может сопоставить фразу «эта страница подтвердила электронное письмо» с фразой «эта страница подтвердила текущую роль». Если ошибочно, хотя бы одно поле, весь блок доказательства становится подозрительным.
Ни один из трех вариантов не является корректным расширением AnswerWithEvidence . Правильным решением было перенести обертку на уровень поля.
2.2 Шаблон: утверждения для каждого поля
Примитив представляет собой обобщенный класс Pydantic, который инкапсулирует типизированное значение элемента. подтверждениям:
из ввода import Generic, TypeVar из pydantic import BaseModel, Field T = TypeVar("T") class FieldExtraction(BaseModel, Generic[T]): """ Аналог AnswerWithEvidence для каждого поля. Каждое поле содержит свое собственное типизированное значение плюс диапазон LLM прочитал его из.""" значение: T | Нет = Поле(..., описание="Введенное значение или ноль, если не найдено.") цитата: str = Поле(по умолчанию="", описание="Дословно строки из проанализированного источника.") страница: int | Нет = Поле (по умолчанию = Нет) line_start: int | Нет = Поле (по умолчанию = Нет) line_end: int | Нет = Поле (по умолчанию = Нет) найдено: bool = Поле (по умолчанию = True) Предостережение: str = Поле (default="")
Выходная схема становится классом Pydantic, каждое поле которого представляет собой экземпляр FieldExtraction[T] . LLM выполняет всё дерево одним структурированным вызовом. Для примера с резюме из дополнительных статей о полях, основанных на правилах:
class CandidateProfile(BaseModel): имя: FieldExtraction[str] электронная почта: FieldExtraction[str] телефон: FieldExtraction[str] linkedin_url: FieldExtraction[str] current_role: FieldExtraction[str]years_experience: FieldExtraction[int] # ... и так далее, двадцать с лишним полей
Нижестоящий код считывает profile.email.value для полей и <код>profile.email.page/начало_строки/конец_строкикод>для цитат. Подсветка рамок из статьи 8C (проверка) результата для каждого поля, а не для каждого запроса; каждый поле получает свой собственный желтый оттенок. Журнал аудита записывает одну запись для каждого поля, если это необходимо, и одну запись для каждого запроса, если нет. Тот же примитив, две степени детализации.
2.3 Проверяйте ссылки по каждому полю.
Работа после LLM заключается в том, что JSON становится надежным объектом: model_validate_json анализирует его, валидаторы на уровне полей обеспечивают соответствие форм доменов, а model_validator(mode="after") выполняет все производные поля. Здесь основная роль играет слой verify . Для каждого FieldExtraction[T] значение не None , quoteНеобходимо обязательно добавить (за вычетом пробелов) где-либо в проанализированном исходном коде. Вымышленная цитата выглядит правдоподобно для рецензента, но на самом деле отсутствует на странице. Верификатор проходит по схеме, вчера каждый quote на соответствие проанализированным строкам и записывает каждую, которая отсутствует. Этот недорогой и эффективный способ выявить наиболее опасные для предприятий ошибки: модель изобрела обоснование. Эта версия валидатора, построенного в 8C (валидация), для каждого поля.
defverify_citations(profile: BaseModel, line_df: pd.DataFrame) -> статьяlist[str]: """Вернуть список (field_path, quote) для цитат, которые не появляются в line_df. Пустой список = каждая цитируемая цитата находится в источнике.""" Flat = " ".join(line_df["text"].astype(str)).lower() промахивается = [] для field_path, поле в _walk_fields_of_type(profile, FieldExtraction): if field.value равно None или нет field.quote: продолжить, если field.quote.lower().strip() не в плоском виде: Misses.append((field_path, field.quote)) return Misses
Профиль, возвращающий misses == [] , безопаснее, чем тот же профиль, возвращаемый без проверок. Профиль, возвращающий две или три вымышленные цитаты, должен быть прочитан рецензентом вместе с исходным кодом. Ошибка режима — это именно тот, который уже обрабатывает цикл обратной связи статьи 8C (проверка), применяемый к каждому полю.
Остальная часть истории с многопольными данными — это отдельная тема, которая определяет бонусную строку: распределение значений по типизированным слотам, чтобы SQL-фильтр мог его обработать (postcode как отдельное поле, не скрытое в адресной строке), вычисление производных полей после того, как LLM прочитает исходное значение (код страны ISO из «итальянского»), объединение вопросов разной формы для одной точки статьи. qa.ask(...) и чередование этих выгрузок LLM с полями на основе правил для регулируемых случаев (сортировка персонала, кредитные решения, прием в медицинском учреждении). Часть, относящаяся к генерации, — описанная выше: перенос выполнения подтверждения на поля уровня, а затем проверка каждой цитаты по источнику.Ещё один фрагмент, добавляемый во время запроса: проверенные образцы, наиболее близкие к новому вопросу, извлекаются из банка и включаются в запрос перед вызовом. Это FAQ, ориентированный на модель. Классический FAQ подготавливает пару вопросов и ответов для пользователей; этот банк подготавливает их для моделей, ориентируясь на форматы конфликтов и сложные операции вывода. Механизм повторно использует блок определения. Диспетчер раздела 1 собирает запрос из BASE плюс фрагменты, полученные во время сборки; здесь во время запроса добавляется еще один фрагмент: примеры, наиболее близкие к новому вопросу, извлекаются из банка и включаются в запрос перед вызовом. Тот же самый результат retrive_pages для example_bank_df вместо line_dfвозвращает 3-5 корпусов (большее количество размывает новый вопрос). Банк растёт по мере того, как команда отбирает проверенные образцы; запрос подхватывает их при следующем запросе, развертывание не требуется.

3.1 Один пример, от начала до конца
Определите конкретный пример ошибки. Пользователь спрашивает: «Какова годовая премия?» в контракте, в строке 212, которая премия указана со знаком доллара (<код>)1850,50 $код>). Без приведенной модели слишком буквально копируется исходный код: она возвращает валюту в виде "$" вместо кода ISO-4217 "USD" . Все правила есть в BASE, но модель всё равно допускает ошибку при форматировании символов и кодов.
В банке хранятся ответы на прошлые вопросы, которые команда уже проверила, по одной строке в каждом: текстовые вопросы, проверенный JSON-ответ и несколько тегов. Поиск (по тексту вопроса, с использованием того же эмбеддера, что и в корпусе) извлекает ближайшую прошлую фразу, где уже была выполнена именно эта нормализация:
{ "question": "Какая была премия? в прошлом году", "answer_json": { "answer_type": "amount", "items": [ {"amount": {"value": 1850.5, "currency": "USD"}, "spans": [{"line_start": 212, "line_end": 212, "quote": "Ежегодная премия: $1,850,50"}]} ], "answer_found": true }, "tags": ["amount", "premium", "usd"] }
Диспетчер вводит пример в подсказку, данный параметр в качестве примера, непосредственно перед новым Вопрос:
Вот прошлый ответ в точной ожидаемой форме. Вопрос: Какова была прошлогодняя премия? (строка источника: «Годовая премия: $1,850,50») A: {"answer_type": "amount", "items": [{"amount": {"value": 1850,5, "currency": "USD"}, "spans": [{"line_start": 212, "line_end": 212}]}], "answer_found": true} Теперь ответьте на этот вопрос в той же форме. Вопрос: Какова годовая премия?
В сборнике процесса обучения. Он сопоставляет некорректный источник ( $1,850.50 ) с корректным проверенным ответом ( value 1850.5 , currency "USD" ), поэтому модель копирует именно это соответствие, а не выводит его заново. из абзаца правил, которые она явно проигнорировала. В этой же строке также отображается вложенность items/spans и флаг answer_found, которые используются по запросу.
3.2 Три места, где это выгодно
Каждый пример ценится только в том случае, если он владеет конкретной, повторяющейся ошибкой:
- Примеры формированиясильного>исправляют ошибки нормализации. В приведенном выше примере: корректная запись предыдущих результатов винтажной модели символа
"$"в качестве валюты вместо кода ISO"USD". Используйте такой пример всякий раз, когда исходный и открытый форматы существуют, и модель продолжает копировать исходный формат. - Примеры извлечения данных исправляют ошибку в поведении. Если IBAN отображается в двух строках, а предыдущая строка включает две половины в одно значение, модель не возвращает только первый текст. Пример описывает способ извлечения данных, а не просто форму возвращаемого значения.
- Примеры разбора вопросовИспользуют неоднозначность. В предыдущем нечетком вопросе с уточненным разбором («часть, касающаяся гарантии» →
target_field="warranty_duration") отображается новый нечеткий вопрос, на каком поле следует нацелиться, вместо того, чтобы гадатьwarranty_termsилиwarranty_exclusions.
Стоимость реальна, поэтому оставляем ее условной. Пропустите фрагмент, если банка ещё нет, если новый вопрос не похож ни на что в банке (выбор вопроса, выходящие за пределы распределения, добавить шум, а не сигнал), или если банк содержит конфиденциальные данные, которые не должны сохраняться в запросе. В бонусной статье FAQ как RAG вариант крайней версии, где весь корпус создан банком.
4. Заключение
Входящий запрос ParsedQuestionвыбранный текстовый запрос: реестр выбирает схему, BASE плюс фрагменты составляют системную подсказку, пользовательская подсказка содержит вопрос, ключевые слова и помеченные строки текста, а трассировка записывает каждое решение, так что неверный формат через шесть месяцев можно отследить до точного набора фрагментов. Пока что никто не доверяет моделям. Полученный ответ впоследствии получен на соответствие условиям статьи 8A (договор на ответ) в соответствии со статьей 8C (проверка) до того, как кто-либо его прочтет.Диспетчер формирует запрос на этапе сборки выбранных деталей; Основной альтернативный подход, описанный в литературе, дает управление моделями во время выполнения. Сравнение двух подходов — лучший способ увидеть, что дает разработанный переключатель (воспроизводимость, ограниченная стоимость, проверяемая трассировка), и от этого он отказывается (открытая гибкость). Агентная модель, выбор инструментов во время выполнения этажа этого диспетчера, — это дальнейшее исследование, выходящее за рамки данной серии.
В том же направлении, что и в статье:
- Миалон и др., Расширенные языковые модели: обзор, 2023 г. (arXiv:2302.07842). Обзор пространства, проектирование расширенных языковых моделей. Полезный обзор для чтения вместе с описанием шаблона диспетчера.
Другой ракурс, другой контекст:
- Яо и др., ReAct: Синергия рассуждений и действий в языковых моделях, ICLR 2023 (arXiv:2210.03629). Агент выбирает инструменты во время выполнения: языковая модель определяет, когда осуществляется поиск, что искать и когда остановиться. Противоположный конец отрицательного управления по сравнению с фрагментами, рассмотренными в данной статье на этапе построения. Модель принимает решение о том, какой инструмент вызвал непосредственно в процессе работы, без предварительной сборки. Тот же компромисс, что и в ReAct: гибкость против воспроизводимости и ограниченная стоимость.
Ранее в сериях:
- Документальная разведка: введение к серии. Что представляет собой серия, кирпичик за кирпичиком, и в таком порядке.
Что работает, а что ломается
- Базовая модель Enterprise РЭГ: из PDF-файла до выделенного ответа. Четырехэтапный конвейер от начала до конца: PDF-файл на входе, выделенный ответ на вывод.
- Эмбеддинги — это не магия: выгодные способы в поиске RAG. Где сходство эмдингов приводит (пользование синонимами, опечатки, перефразирование), где оно выгодно предлагает сбой (неизвестные термины, отрицание, релевантность термина и ответа) и как его все используют.
- Переранжировщики тоже не волшебство: когда слой кросс-кодировщика оправдывает затраты. Что включает кросс-кодировщик по сравнению со встраиванием на основе двух кодировщиков (измеренные показатели) и когда оправдана задержка.
- RAG — это не машинное обучение, и инструментарий машинного обучения решает не ту задачу. Почему оптимизация по размеру фрагментов и тонкая настройка оптимизируют не то, что нужно; вместо этого следует ориентироваться на тип вопроса.
- От регулярных выражений до моделей компьютерного вопроса: какой метод RAG подходит для двух каких задач? оси — технические документы и контрольные вопросы — определить, какой метод подходит для каждого конкретного случая.
- 10 ошибок RAG, которые мы постоянно наблюдаем на производстве. Десять производственных ошибок, расположенных по пунктам, с выполнением пути их исправления.
Анализ документов
- Помимо функции extract_text: два слоя PDF-файла, определяющие качество RAG. Анализ первой половины блока: характер документа, сигналы и резюме.
- Прекратите возвращать плоский текст из PDF-файлов: реляционные таблицы, необходимый RAG. Анализ второй половины блока: реляционные таблицы, которые читают каждый последующий блок.
- Если PyMuPDF не видит таблицу: анализ PDF-файлов для RAG с помощью Azure Layout. Те же таблицы из Azure Layout: ячейки ячеек, OCR, ролики абзацев.
- Анализ PDF-файлов для RAG локально с помощью Docling: расширенные таблицы, без загрузки в облако. Те же таблицы, вычисленные локально с помощью Docling: ячейки TableFormer, не покидает машину.
- Программное обеспечение для обработки изображений также является парсером PDF-файлов: оно ничего не считывает диаграммы и схемы для анализа данных. Обработчик изображений: изображения преобразуются в текст, доступный для поиска.
- Анализ отсканированных PDF-файлов для RAG с помощью EasyOCR: бесплатное распознавание текста Позволяет получить слова, а не документ. Там, где традиционное распознавание текста останавливается: текст восстанавливается, но структура ухудшается.
- Как сделать так, чтобы изображения в PDF-файлах можно было искать по RAG (Research, Goodreads, Acquisition, Reading, Get, Reading, Get, Reading, Get, Reading, Get, Reading, Get, Reading, Get, Reading, Get, Get, Reading.
- Восстановление оглавления, которое не было включено в PDF-файл, чтобы RAG мог обработать его по разделам. Перестройка toc_df, если PDF-файл печатает оглавление, но не включает структуру документа.
разбор вопросов
- Вопросы RAG также необходимы в синтаксическом анализе: необходимо преобразовать форму пользователя в краткое описание для поиска и генерации. Основная идея синтаксического анализа вопросов: почему строка пользователя требуется в том же анализе, что и документ, и как она разделяет краткое описание для поиска и краткое описание для генерации. Пять семейств столбцов, которые анализатор обрабатывает непосредственно из запроса пользователя вместе с кодом, сопровождающим каждого из них.
- Обработка проанализированного вопросированного RAG: стратегия обработки фрагментов, модель уровня, эффективность, аудит. Решения, принятые парсером на основе пользовательской строки с использованием профиля документа: обработка, активация, полная схема, журнал аудита (pipeline_trace.json) и обход корпуса брокера.
- Цикл уточнения и значения по умолчанию: когда вопрос достаточно точен. Одно целенаправленное уточнение, когда вопрос слишком расплывчатый, и значение по умолчанию, проявленное на основе объяснения.
Извлечение
- Поиск — это фильтрация, а не поиск: ментальная модель для частного RAG. Поиск переосмыслен как фильтрация по line_df и toc_df: якоря малы, контекст велик.
- Обнаружение якорей для RAG: внешние детекторы, а затем один вызов LLM в конце. Параллельные детекторы якорей: ключевое слово всегда, встраивание параллельно, один вызов LLM в конце.
- Предоставление возможности основного права выбрать правильную страницу RAG: шаблон арбитра в конце поиска. Арбитр из числа магистров прав: кандидаты ранжируются с указанием причины, одна из которых выводится в формате JSON.
- Структура для проектирования контекста: четыре типизированных элемента, объединение в основе каждого ответа RAG. Проектирование контекста с заданной структурой: четыре типизированных элемента (фиксированная системная подсказка, извлеченные строки, блок контекста документа, оболочка PromptContext), которые заполняют один вызов RAG LLM для одного документа.
Кежан Ши Посмотреть все Кежан Ши
Источник: towardsdatascience.com
Похожие записи
Оцените материал:
Присоединяйтесь и подпишитесь на рассылку самых свежих новостей по Email
Получайте свежие новости и идеи на почту. Без спама — только самое интересное.
Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.
