Обработка разобранного вопроса RAG: стратегия сегментации, уровень модели, активации, аудит.
Enterprise Document Intelligence [Том 1 #6c] – Решения, принимаемые парсером на основе пользовательской строки с использованием профиля документа: отправка, активации, полная схема, три подхода к определению того, что должно срабатывать, блок _meta аудита и пошаговое описание корпуса брокера.
Делиться
Эта статья завершает раздел «Интеллектуальное управление документами» серии статей, посвященной построению корпоративной системы RAG из четырех компонентов: анализ, анализ вопроса, поиск и генерация. В предыдущих частях: статья 6_a (тезис) обосновывала необходимость анализа вопроса и показывала, на какие два типа запросов потребителей разбивается проанализированная строка. Статья 6_b (извлечение) рассматривала пять семейств столбцов, которые анализатор считывает непосредственно из пользовательской строки. Эта статья продолжает рассмотрение второй половины: столбцы, которые анализатор выбирает на основе этих данных, используя профиль документа, а также архитектурные и логистические решения, которые должен принять компонент.

Приведенные в этой статье исполняемые фрагменты кода используют семейство алгоритмов OpenAI gpt-4.1 для анализа вопросов; этот сервис является собственностью компании и регулируется Условиями использования OpenAI.
1. Поля, которые парсер определяет на основе профиля документа.
Возьмем одностраничное резюме и вопрос «Как вас зовут?». Анализ вопроса сам по себе возвращает keywords=["name"] , а поиск ищет в файле буквальное слово «name». В резюме никогда не говорится «name». Ничего не совпадает, ответ оказывается пустым. Человек не стал бы отвечать на этот вопрос, не имея никакой другой информации: он бы сначала бегло просмотрел документ, увидел резюме и воспринял «name» как запрос имени кандидата. Анализатору нужна та же отправная точка. Как только он видит, что документ является резюме и что имя кандидата находится вверху первой страницы, ключевое слово » name преобразуется в имя человека, а не в буквальный токен для поиска.
После завершения анализа вопроса диспетчер заполняет еще две группы столбцов, используя проанализированный вопрос ПЛЮС профиль документа. В поставляемом коде этот профиль представляет собой семантическую зону parsing_summary — словаря уровня документа, созданного парсером. Он содержит doc_type (резюме, договор, счет-фактура и т. д.), typical_fields (поля, о которых обычно спрашивают в вопросах, касающихся документов такого типа) и краткое summary , написанное LLM, которое помещается в начало системной подсказки. Диспетчер считывает эти три поля и использует их для установки стратегии обработки фрагментов и контекста ответа. Они попадают в одну и ту же строку question_df , поэтому при извлечении и генерации используется одна запись.
1.1 Диспетчеризация: сколько контекста, какая стратегия обработки фрагментов, какая модель.
После того, как парсер получит буквальную информацию, ему предстоит принять еще три решения: сколько окружающего текста прочитать и вернуть, следует ли объединять k лучших фрагментов в один вызов LLM или передавать их последовательно, и какую модель вызвать. Все три являются значениями по умолчанию, которые проект может переопределить для каждой концепции, типа ответа или вопроса. Каскад действий всегда одинаков: переопределение на уровне концепции > значение по умолчанию для формы/типа > резервный вариант проекта.
Объем контекста, который необходимо прочитать и вернуть: Три поля в StructuralHints содержат эту информацию:
-
detection_context: уровень детализации зоны подтверждения регулярного выражения (статья 6_b, форма и тип ответа)."line"для сумм и дат,"paragraph"для повествования. -
answer_context: объем окружающего текста, который получает генератор."line"для одного значения,"paragraph"— для пояснения,"page"для краткого обзора,"section"для темы,"chapter"или"document"для общего резюме. -
needs_summary:Trueесли ответ занимает больше места, чем помещается в дословной цитате.
Значения по умолчанию берутся из тех же двух вспомогательных таблиц, с которыми мы уже познакомились: answer_shapes_df для значений по умолчанию на уровне фигур (столбец default_answer_context ), concepts_df для переопределений на уровне концепций. «Какова годовая премия?» — это (single, amount) без конкретной концепции; в ответ на нее берется answer_context = "line" из answer_shapes_df . «Какие исключения предусмотрены в этом контракте?» соответствует концепции exclusions ; в ответ на нее берется answer_context = "chapter" из concepts_df , что переопределяет значение по умолчанию для фигуры списка — "section" .
Почему форма и длина важны не только для поиска. Те же самые подсказки используются в процессе генерации, определяя, является ли отправка комбинированной или последовательной. Когда ответ представляет собой единый факт в одном блоке (сумма, дата, IBAN, да/нет), генерация последовательно вызывает LLM, блок за блоком в порядке ранжирования поиска, и останавливается, как только answer_found=True и complete_answer_found=True . Это экономит примерно ⅔ входных токенов при k=3, когда ответ находится в первом блоке. Когда ответ синтезируется по нескольким фрагментам (список исключений, разбросанных по страницам, определение с его сноской, сравнение), генерация объединяет все k блоков в один вызов. Решение принимается один раз, здесь, парсером; поиск и генерация просто выполняются. В масштабах предприятия (миллионы документов × k лучших блоков на вопрос) экономия на каждом вопросе суммируется с основной суммой затрат на LLM.
Сама стратегия находится на верхнем уровне объекта ParsedQuestion.chunk_strategy , а значение по умолчанию берется из вспомогательных таблиц со следующим порядком разрешения:
def resolve_chunk_strategy( answer_shape: str, matched_concept: str | None, answer_shapes_df: pd.DataFrame, concepts_df: pd.DataFrame, ) -> Literal["combined", "sequential"]: """Concept-level override > answer-shape default > hard default.""" if matched_concept is not None: row = concepts_df[concepts_df["concept"] == matched_concept] if not row.empty and pd.notna(row.iloc[0].get("default_chunk_strategy")): return row.iloc[0]["default_chunk_strategy"] row = answer_shapes_df[answer_shapes_df["shape"] == answer_shape] if not row.empty: return row.iloc[0]["default_chunk_strategy"] return "combined"
Детерминированный диспетчер (раздел 2.1, подход B) вызывает его сразу после возврата результата синтаксического анализа вопроса, записывает результат в parsed.chunk_strategy (верхний уровень), и та же каскадная обработка выполняется для answer_context (также управляемого формой). needs_summary остается в structural_hints поскольку описывает документ, а не диспетчеризацию. LLM может переопределить любой из параметров по умолчанию в PARSE_PROMPT (подзадача 6), когда сам вопрос противоречит соглашению. «Дайте мне краткое изложение исключений в одну строку» переопределяет default_answer_context = "chapter" концепции exclusions . Параметры по умолчанию являются соглашениями, а не ограничениями.
Выбор модели: два сателлита в каскаде. Тот же принцип применим и к выбору модели. Для извлечения amount из одной строки не требуется та же модель, что и для чтения трех страниц сложного юридического текста. Для первого случая достаточно небольшой модели; для второго требуется более мощная. Жесткое кодирование gpt-4.1 повсюду неэффективно в простых случаях и выглядит дешево в сложных. Мы разделили это на два сателлита не случайно: концептуальный llm_model_tiers_df для анализа сегментов и точный llm_models_df с одной строкой для каждой конкретной модели. Значение по умолчанию для каждого вопроса указывает на точное имя модели, потому что во время выполнения нам нужно назвать что-то конкретное. Модели, упомянутые в этой статье (семейство OpenAI gpt-4.1 и семейство Claude от Anthropic), являются проприетарными облачными сервисами, регулируемыми соответственно Условиями использования OpenAI и Политикой использования Anthropic.
Сначала концептуальная группировка. Четыре уровня, которые сохраняются при постоянном обновлении каталога поставщика:

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

Цены и контекстные окна обновляются каждые несколько месяцев; схема же этого не делает. Привязка таблицы к одному запросу («по состоянию на май 2026 года, какие модели разрешено вызывать проекту?») обеспечивает развертывание единым источником достоверной информации. Когда команда будет проверять gpt-4.5 через шесть месяцев, это будет всего лишь обновление одной строки плюс повторный запуск оценочного набора тестов, а не изменение кода.
Значения по умолчанию указывают на конкретную модель, а не на уровень. Вопрос на уровне nano не просит диспетчер «использовать какую-либо нано-модель»; он запрашивает утвержденную проектом нано-модель, ту, которую команда оценила на корпусе. Таким образом, answer_types_df.default_model и concepts_df.default_model содержат точное имя (внешний ключ к llm_models_df.model ). Каскад напрямую разрешается в это имя:
def resolve_model( answer_type: str, matched_concept: str | None, answer_types_df: pd.DataFrame, concepts_df: pd.DataFrame, fallback: str = "gpt-4.1-mini", ) -> str: """Concept-level override > answer-type default > project fallback. Returns a precise model name.""" if matched_concept is not None: row = concepts_df[concepts_df["concept"] == matched_concept] if not row.empty and pd.notna(row.iloc[0].get("default_model")): return row.iloc[0]["default_model"] row = answer_types_df[answer_types_df["type"] == answer_type] if not row.empty and pd.notna(row.iloc[0].get("default_model")): return row.iloc[0]["default_model"] return fallback
Диспетчер записывает результат в parsed.suggested_model (верхний уровень). Блок генерации считывает это имя, извлекает строку из llm_models_df для проверки контекстного окна / ценообразования / возможностей и вызывает функцию. Когда команда хочет заменить gpt-4.1 на gpt-4.5 после оценки, это делается с помощью команды UPDATE llm_models_df SET model='gpt-4.5' WHERE tier='standard' (или двух вставок строк плюс обновление столбца по умолчанию), а не путем изменения кода.
1.2 Активации: адаптация к профилю документа
До сих пор мы исходили из предположения, что документ соответствует действительности. Но это не всегда так.
Возьмем, к примеру, вопрос: «Что написано на странице 1?» В PDF-файле «страница 1» — это реальный объект: страницы являются физическими элементами формата, и парсер знает их границы. В файле Word «страница 1» зависит от средства отображения: шрифт пользователя, ширина экрана, драйвер принтера — все это влияет на разрывы страниц. «Страница 1», которую увидел пользователь, может отличаться от «страницы 1» в другом средстве просмотра. Если парсер жестко задает extract_page_numbers=True , система с высокой степенью вероятности вернет «см. страницу 2» в документе Word.
Та же ловушка возникает всякий раз, когда вопрос ссылается на структурный элемент, которого нет в документе: несуществующее оглавление, необъявленный заголовок раздела, таблица, которую парсер не смог извлечь. Решение состоит в том, чтобы парсер проанализировал профиль документа (метаданные, возвращаемые функцией parse_pdf парсера) и понизил уровень активации, которая не соответствует профилю. Профиль представляет собой небольшой типизированный объект:
class DocumentProfile(BaseModel): format: Literal["pdf", "docx", "html", "txt", "xlsx"] has_toc: bool = False has_tables: bool = False n_pages: int | None = None # None when the format has no real pages languages: list[str] = Field(default_factory=list) is_scanned: bool = False # OCR'd, expect more spelling noise
Затем парсер обращается к профилю, чтобы обеспечить корректность активаций:
class ExecutionPlan(BaseModel): use_toc_navigation: bool = True use_keyword_retrieval: bool = True use_embeddings: bool = False follow_cross_references: bool = False decompose_compound: bool = False iterate_on_feedback: bool = True extract_page_numbers: bool = True def parse_question(question, doc_profile) -> ParsedQuestion: parsed = base_parse(question) if doc_profile.format == 'docx': parsed.activations.extract_page_numbers = False if not doc_profile.has_toc: parsed.activations.use_toc_navigation = False return parsed
Поле parsing_notes фиксирует то, что заметил парсер, но не смог учесть. Оно передается в блок _meta ответа на стороне генерации, чтобы пользователь знал, что система поняла ограничение. Он не получает неправильный ответ с высокой степенью уверенности «страница 2»; он получает ответ с примечанием о том, что ссылки на страницы в этом формате являются приблизительными.
Та же идея применима и в других местах:

Распространенная ошибка: жесткое кодирование флагов активации в качестве значений по умолчанию независимо от типа документа. Конвейер, который всегда устанавливает
extract_page_numbers=Trueгенерирует ссылки на страницы, даже если документ не содержит реальных страниц. Активация должна происходить на основе фактических свойств документа, а не на основе общепроектных значений по умолчанию.
1.3 Полная схема
На данном этапе схема охватывает все, что было построено по разделам, как в статье 6_b (извлечение), так и до настоящего момента в этой статье. Несколько полей появляются здесь впервые: это связи между строкой вопроса и вспомогательными таблицами, которые стоит явно обозначить, чтобы два потребительских брифа, представленные в статье 6_a, имели смысл после их составления.
class ParsedQuestion(BaseModel): # The raw input, kept for audit original_question: str corrected_question: str = "" # spell-corrected (section 2.1) # What the user is asking keywords: list[Keyword] = Field(default_factory=list) # → concept_keywords_df → concepts_df # Two orthogonal axes for the expected answer (section 2.2) answer_shape: Literal["single", "listing", "table", "tree", "nested_json"] = "single" answer_type: str = "text" # → FK into answer_types_df # How the question is structured (sections 2.4 + 2.3) decomposition: Decomposition = Field(default_factory=Decomposition) scope_filters: ScopeFilters = Field(default_factory=ScopeFilters) structural_hints: StructuralHints = Field(default_factory=StructuralHints) # How the pipeline dispatches the LLM calls (cascade from concept/type, section 2.6) chunk_strategy: Literal["combined", "sequential"] = "combined" # generation dispatch suggested_model: str = "gpt-4.1-mini" # → FK into llm_models_df # When the LLM should distinguish related concepts (section 3.2) disambiguation: str | None = None distractors: list[str] = Field(default_factory=list) # What the system should do (section 2.7) activations: ExecutionPlan = Field(default_factory=ExecutionPlan) # What the parser noticed about its own choices parsing_notes: list[str] = Field(default_factory=list) suggested_clarification: str | None = None ambiguity_reason: str | None = None # The two consumer briefs (derived assemblies, section 3) retrieval: RetrievalQuery | None = None generation: GenerationBrief | None = None
Вступает в действие несколько реляционных уровней, имитирующих анализ документов. Центральная таблица фиксирована; перечисленные здесь вспомогательные таблицы — это примеры, которые обычно требуются для проекта, а не исчерпывающий набор:
-
question_df(всегда). Одна строка на каждый проанализированный вопрос, с указанными выше столбцами. Строка содержит данные, которые блок_meta(раздел 2.3) записывает на диск для аудита, и данные, которые SQL-запросы на уровне корпуса обрабатывают: «сколько вопросов типаamountзадавали пользователи в прошлом месяце?», «какие вопросы вызвали уточнение?», «какие ключевые слова были наиболее часто использованы?». -
concepts_df(типичный). Одна строка на каждую концепцию (premium,non_compete, …) с указаниемdocument_typeиdefinition. Поддерживается экспертами в предметной области в рамках всего проекта. -
concept_keywords_df(типичный). Одна строка на каждый(concept, language, keyword). Объединено сconcepts_dfпоconcept. Основная область роста: каждый пропущенный поиск, который оказывается несоответствием словаря, становится новой строкой здесь. -
answer_types_df(типовой формат). Одна строка на каждый зарегистрированный тип ответа (amount,date,iban,text,address, …) сretrieval_patterns(используется блоком поиска),output_schema_ref(используется блоком генерации),definitionиdefault_modelдля каждого типа. Добавление нового типа осуществляется одной операцией вставки. -
answer_shapes_df(небольшой, фиксированный). Одна строка на каждую зарегистрированную форму ответа (single,listing,table,tree,nested_json) с настройками по умолчанию дляchunk_strategyиanswer_contextдля каждой формы. Shape определяет структуру ответа; type определяет содержимое каждого значения. Разделение делает вопрос «Перечислите годовые премии» (вопрос типа(listing, amount)) иным решением для отправки ответа, чем вопрос «Какова премия?» (вопрос типа(single, amount)). -
llm_model_tiers_df(небольшой, концептуальный). Четыре строки (nano,mini,standard,reasoning) с указанием относительной стоимости, задержки и вариантов использования каждого уровня. Позволяет команде обдумывать выбор модели с точки зрения независимости от поставщика. -
llm_models_df(одна строка на каждую конкретную модель, которую проект может вызывать). Включает поставщика, уровень (внешний ключ кllm_model_tiers_df), контекстное окно, поддержку структурированного вывода, ценообразование за 1 миллион токенов и примечания. Значение по умолчанию для каждого вопроса указывает на конкретное имя модели в этой таблице; заменаgpt-4.1наgpt-4.5после оценки является обновлением строки.
Другие сателлиты добавляются по мере необходимости, в зависимости от предметной области. В юридическом корпусе RAG часто создается файл regulations_df , сопоставляющий коды («L131-1») с их фактическими текстами, чтобы парсер мог разрешать ссылки. В корпоративном корпусе создается файл entity_alias_df , чтобы «BNP», «BNP Paribas» и «Банк» относились к одной и той же сущности. В научном корпусе создается файл unit_conversions_df . Принцип тот же, что и для столбцов: начинайте с того, что вам нужно, добавляйте по мере необходимости, когда этого требует реальный случай.
Два столбца в question_df ( retrieval , generation ) формируются из остальных: парсер собирает их из исходных столбцов, поэтому retrieval и generation получают только то, что им нужно. В следующем разделе будет рассказано о причинах такого разделения.
Краткое описание столбцов question_df: Для каждого столбца: что он содержит, когда он установлен, кто его использует в дальнейшем.

2. Выбор архитектуры
В первом разделе рассматривалось, какие решения принимает диспетчер на основе проанализированной строки: значения по умолчанию для диспетчера, флаги активации, собранная схема. Во втором разделе рассматривается вопрос о том, кто принимает каждое из этих решений (пользователь, детерминированное правило или LLM во время выполнения), как эти решения попадают в вызов верхнего уровня и как каждое решение проверяется.
2.1 Три подхода к принятию решений об активации
Поле плана выполнения в разобранном вопросе содержит набор флагов активации: use_toc_navigation , use_keyword_retrieval , decompose_compound и так далее. Они были представлены в разделе 1.2; в этом разделе рассматривается, кто решает, какие значения им присваиваются в конкретном запуске.
Это один из главных архитектурных решений в сериале. Три подхода.
Подход А. Явное переопределение пользователем. Пользователь передает флаги активации в качестве аргументов функции pdf_qa . Чтобы принудительно выполнить семантический поиск и пропустить циклы декомпозиции и обратной связи, вызов выглядит следующим образом: pdf_qa(contract, question="What are all the obligations?", use_embeddings=True, decompose_compound=False, iterate_on_feedback=False) .
Плюсы : полный контроль, полная воспроизводимость, возможность отладки. Минусы : пользователь должен понимать систему, чтобы делать разумный выбор. На практике никто этого не делает для рутинных запросов; это ручное вмешательство в процессе разработки и отладки.
Подход B. Детерминированный диспетчер. Система анализирует разобранный вопрос и профиль документа и применяет правила на основе кода для принятия решений об активации. Приведенная ниже функция является иллюстративной; производственный диспетчер хранит 15-30 таких правил, накопленных за время эксплуатации системы:
def decide_activations(parsed: ParsedQuestion, doc_profile: DocumentProfile) -> ExecutionPlan: plan = ExecutionPlan() # defaults if parsed.decomposition.pattern == "independent": plan.decompose_compound = True if doc_profile.format == "docx": plan.extract_page_numbers = False if parsed.answer_shape == "listing": plan.iterate_on_feedback = True return plan
Плюсы : воспроизводимость, возможность отладки, накопленный командой опыт хранится в коде. Минусы : требуется написание и поддержка правил. Каждый новый шаблон вопроса, который не подходит, — это правило, которое нужно добавить.
Подход C. LLM принимает все решения (автономный). Система описывает доступные подфункции для LLM и запрашивает у него выбор. Плюсы : гибкость, обработка случаев, которые команда не предусмотрела. Минусы : невоспроизводимость (LLM может принимать разные решения при каждом запуске), дороговизна (каждый вопрос требует дополнительного вызова LLM для маршрутизации), сложность отладки (логика кроется в весах LLM).
Позиция серии: подход B — по умолчанию, подход A — вручную, подход C — отклонен для корпоративного использования.
Это тот же самый аргумент, который повторяется всякий раз, когда речь заходит об «агентичном RAG». В корпоративных контекстах (юридические, страховые, финансовые услуги) воспроизводимость, возможность аудита и ограниченные затраты важнее, чем дополнительная гибкость, которую обеспечивает подход C. Подход B предоставляет все три преимущества. Подход A позволяет вам отменять проверку конкретной конфигурации, когда это необходимо.
Именно поэтому «агентный RAG» работает лучше, чем наивный RAG, если он реализован правильно. Агентная часть — это не магия. Дело в том, что система анализирует вопрос до начала поиска, вместо того чтобы рассматривать извлечение как механический первый шаг. Как только вы разделите работу между подготовкой к извлечению и подготовкой к генерации, остальная часть конвейера становится гораздо проще для понимания, без необходимости участия LLM в контуре управления.
2.2 Вызов верхнего уровня: пять семейств аргументов
После того как диспетчер автоматически определит активации, в большинстве случаев достаточно вызова pdf_qa(pdf_path, question) от пользователя. Но иногда пользователь хочет переопределить определенное поведение: изменить top_k для получения данных, пропустить маршрутизацию оглавления для документа, не имеющего пригодной для использования структуры, внедрить предварительно загруженный PromptContext . Вызов верхнего уровня должен обрабатывать это без лишних сложностей.
Рабочий алгоритм: разделить аргументы переопределения на пять семейств , каждое из которых названо в честь блока, на который оно влияет. Текущая версия pdf_qa из docintel.pipeline.qa.pdf содержит восемь аргументов kwargs, сгруппированных таким образом:
def pdf_qa( pdf_path: str | Path, question: str, *, # Parsing overrides (Article 5 / 10) method: str = "fitz", # Question-parsing overrides (this article) expert_dict: dict[str, list[str]] | None = None, # Retrieval overrides (Articles 7 / 9) top_k: int = 5, use_toc: bool = True, # Generation overrides (Article 8) include_bbox: bool = False, # Pipeline-behavior overrides store: "Store | None" = None, client: "OpenAI | None" = None, context: PromptContext | None = None, ) -> AnswerWithEvidence: ...
Пользователь, которому не нужны никакие переопределения, просто вызывает pdf_qa(contract_pdf, "What is the premium?") . Пользователь, который хочет отключить маршрутизатор LLM TOC для документа с нарушенной структурой, вызывает pdf_qa(contract_pdf, "What is the premium?", use_toc=False) . Никакие переопределения не требуются; для всех заданы значения по умолчанию, определяемые диспетчером. Аналогичный механизм для других документов corpus_pdf_qa использует тот же шаблон с project_id в начале и ограничением top_k_docs .
Что нас ждёт в будущем. Несколько переопределений, для которых в архитектуре есть место, но которые в данный момент не включены в пакет:
answer_schema=MyCustomSchemaдля переопределения реестра для каждого вопроса (статья 8 (генерация), раздел 3.5),retrieval_methods=["keyword", "embedding"]для выбора стека методов во время выполнения (статья 7, получение данных),iterate_on_feedback=True+max_iterations=3для повторного запуска при неполных ответах (статья 13 (конвейер рабочих процессов) и статья 14 (проблема корпуса)). Каждое из них расширяет одно из пяти семейств, описанных выше, без изменения остальных. В статье точно сохранена структура семейств по блокам, поэтому добавление аргументов kwargs позже будет механическим.
2.3 Блок _meta в выходных данных
Разобранный вопрос является внутренним для функции pdf_qa . Но его следы отображаются в выходных данных, и это важно для пользователя.
В выходном JSON-файле содержится ответ (результат генерации) и блок _meta , в котором записывается, что было сделано:
{ "answer": "The premium is €125,000 annually.", "page_number": 4, "line_start": 12, "line_end": 14, "quote": "Annual premium: €125,000", "_meta": { "decomposition": "single", "activations": { "use_toc_navigation": true, "use_keyword_retrieval": true, "use_embeddings": false, "extract_page_numbers": true }, "skipped": [], "parsing_notes": [], "iterations": 1, "retrieval_methods_used": ["toc", "keyword"], "model": "gpt-4.1", "prompt_versions": {"question_parsing": "v2.4", "generation": "v4.2"} } }
Блок _meta содержит схему декомпозиции, информацию о том, какие активации были включены или выключены, что было пропущено (и почему, согласно parsing_notes ), сколько итераций выполнил конвейер, какие методы извлечения были запущены, а также версии модели и подсказки для воспроизводимости (те же поля, которые считываются при оценке каждого режима отказа).
Это не опционально. Именно это делает систему доступной для аудита . Когда пользователь оспаривает ответ, блок _meta содержит объяснение. Когда команда отлаживает регрессию, блок _meta содержит трассировку. Когда отдел соответствия спрашивает: «Почему система дала такой ответ?», блок _meta содержит ответ.
Пользователь, который не хочет видеть _meta в своем пользовательском интерфейсе, может его скрыть. Но он всегда генерируется и всегда записывается в лог, поскольку его создание ничего не стоит, а журнал аудита необходим для развертывания в производственной среде.
Разобранный вопрос также сохраняется на диске в соответствии с соглашением, устанавливаемым блоком анализа документов: save_parsed_question(pdf_path, question, parsed_question) записывает полный ParsedQuestion в output/ . Слаг объединяет читаемый префикс вопроса с коротким хешем, поэтому почти идентичные вопросы никогда не будут конфликтовать. Следующий блок (получение данных) считывает тот же файл. При итерации по получению или генерации данных не требуется повторный вызов LLM для анализа вопроса.
3. На практике
3.1 parse_question end-to-end
В статье 6_b каждый компонент парсера рассматривался как отдельный вспомогательный механизм, каждый со своим собственным вызовом LLM. Именно так в тексте схема строится столбец за столбцом. В производственной среде один объединенный вызов LLM возвращает всю строку сразу. Один цикл обработки, один запрос на поддержку, одно место, где LLM видит полный вопрос.
Схема, которую заполняет степень магистра права:
class FullParse(BaseModel): """Everything the LLM produces in a single call.""" corrected_question: str keywords_extracted: list[str] keywords_rewritten: list[str] answer_shape: str # single | listing | table | tree | nested_json answer_type: str # FK into answer_types_df decomposition: Decomposition structural_hints: StructuralHints chunk_strategy: Literal['combined','sequential'] = 'combined' suggested_model: str = 'gpt-4.1-mini' suggested_clarification: str | None = None disambiguation: str | None = None distractors: list[str] = Field(default_factory=list)
В задании студентам магистратуры предлагается пошаговое руководство по выполнению подзадач. Каждая подзадача в задании соответствует одному столбцу в FullParse :
def build_parse_prompt( answer_types_df: pd.DataFrame, answer_shapes_df: pd.DataFrame, ) -> str: """The answer-type and answer-shape lists are injected from the satellites so adding a new type or a new shape is a single row insert, no prompt edit.""" types_label = ", ".join(answer_types_df["type"]) shapes_label = ", ".join(answer_shapes_df["shape"]) return ( "You parse user questions into a structured object that downstream retrieval and " "generation will consume. Return JSON matching the FullParse schema.nn" "Sub-tasks:n" "1. corrected_question: fix typos. No meaning change.n" "2. keywords_extracted: 1-3 content noun phrases from the question.n" "3. keywords_rewritten: 3-5 short phrases matching how the answer is likely to " "appear in the document. Document vocabulary, not the user's casual phrasing.n" f"4. answer_shape: one label from {{{shapes_label}}}. The cardinality of the " "answer: 'single' for one value, 'listing' for a flat enumeration, 'table' for " "rows x columns, 'tree' for nested hierarchy, 'nested_json' for a structured " "object with named sub-fields.n" f"5. answer_type: one label from {{{types_label}}}. The value type each element " "of the answer carries. 'List the annual premiums' is (listing, amount) ; 'What " "is the premium?' is (single, amount) ; 'List the exclusions' is (listing, text).n" "6. decomposition: pattern (single/independent/sequential/unified/conditional), " "sub-questions if compound, and conditional_filter if the pattern is conditional.n" "7. structural_hints: WHERE (toc_section_hint, pages_hint, layout_hint) and HOW " "MUCH (detection_context, answer_context, needs_summary). Leave answer_context, " "needs_summary, chunk_strategy, suggested_model at their defaults UNLESS the " "question itself contradicts them (eg 'one-line summary of the exclusions' " "overrides the exclusions concept's chapter-level default ; 'compare the indemnity " "clauses in this contract and the previous version' bumps suggested_model to a " "reasoning-tier model like o4-mini).n" "8. suggested_clarification: short follow-up question if the input is too vague. " "null otherwise.n" "9. disambiguation + distractors: 'limit, not deductible' patterns." ) PARSE_PROMPT = build_parse_prompt(answer_types_df, answer_shapes_df)
Конвейер обработки данных. Единственный вызов LLM выполняет работу по синтаксическому анализу. Два этапа, не относящиеся к LLM, остаются отдельными: ключевые слова-якоря (regex, deterministic, fast) и поиск в экспертном словаре (pandas filter, no model).
def parse_question( question: str, *, expert_kw_df: pd.DataFrame | None = None, system_prompt: str = PARSE_PROMPT, ) -> ParsedQuestion: resp = client.responses.parse( model="gpt-4.1-mini", input=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": question}, ], text_format=FullParse, ) full = FullParse.model_validate_json(resp.output_text) anchor_kw = extract_anchor_keywords(full.corrected_question) # regex dict_kw_df = ( lookup_expert_keywords(full.corrected_question, expert_kw_df) if expert_kw_df is not None else pd.DataFrame() ) keywords = ( [Keyword(text=t, source="direct") for t in full.keywords_extracted] + [Keyword(text=t, source="anchor") for t in anchor_kw] + [Keyword(text=t, weight=0.7, source="llm_expansion") for t in full.keywords_rewritten] + [Keyword(text=row["keyword"], weight=row["weight"], source="expert_dictionary", semantic_group=row["concept"]) for _, row in dict_kw_df.iterrows()] ) return ParsedQuestion( original_question=question, corrected_question=full.corrected_question, keywords=keywords, answer_shape=full.answer_shape, answer_type=full.answer_type, decomposition=full.decomposition, structural_hints=full.structural_hints, chunk_strategy=full.chunk_strategy, suggested_model=full.suggested_model, suggested_clarification=full.suggested_clarification, disambiguation=full.disambiguation, distractors=full.distractors, # scope_filters, activations, retrieval, generation are filled by the # dispatcher (section 4.1) once the document profile is available. # parsing_notes is appended later when activations downgrade. )
Компромисс по сравнению с поэтапным механизмом, предусмотренным статьей 6b:
- Пошаговое руководство (один помощник на каждую задачу) : легко отлаживать каждую задачу, легко переопределить один запрос, не затрагивая другие, легко проводить A/B-тестирование отдельных подзапросов. Более 5 звонков LLM на каждый вопрос.
- Объединенный подход (один вызов, одна схема) : один цикл обработки, один запрос, один контекст модели. Сложнее отнести ошибку к конкретной подзадаче. Примерно в 5 раз дешевле и примерно в 5 раз быстрее.
В стандартной версии серии для производственной среды используется консолидированный подход . Пошаговый конвейер остается полезным для тестирования и отладки. Если какое-либо поле выглядит неправильно, замените его на отдельный вспомогательный код, запустите процесс заново и сравните результаты.
Большинство вопросов не заполняют все столбцы. Для простого поиска требуется corrected_question + keywords + answer_shape + answer_type . Для составного вопроса-списка требуется decomposition , structural_hints.answer_context = "chapter" , needs_summary = True . Настройки схемы по умолчанию обрабатывают неиспользуемые поля, поэтому parse_question всегда возвращает полную строку.
3.2 Примеры из брокерского корпуса
Несколько конкретных примеров из контекста работы страхового брокера, которые будут рассмотрены в частях IV и V. В каждом примере показаны только столбцы, используемые в данном случае; остальная часть схемы ParsedQuestion ( corrected_question , structural_hints , retrieval , generation , …) сохраняет значения по умолчанию.
Пример 1. Точечный поиск с использованием ключевых слов экспертов.
Вопрос пользователя: «Quel est le montant de la prime annuelle?»
ParsedQuestion( original_question="Quel est le montant de la prime annuelle ?", answer_shape="single", answer_type="amount", keywords=[ Keyword(text="prime", weight=1.0, source="direct"), Keyword(text="montant", weight=0.8, source="direct"), Keyword(text="annuelle", weight=0.7, source="direct"), Keyword(text="premium", weight=0.9, source="expert_dictionary", semantic_group="prime"), Keyword(text="cotisation", weight=0.9, source="expert_dictionary", semantic_group="prime"), Keyword(text=r"d+[s.,]?d*s*(?:EUR|€)", weight=0.8, source="expert_dictionary", is_regex=True), ], decomposition=Decomposition(pattern="single"), activations=ExecutionPlan( use_toc_navigation=True, use_keyword_retrieval=True, extract_page_numbers=True, ), parsing_notes=["Question in French; expert dictionary applied."], )
Регулярное выражение для указания amount в списке ключевых слов представляет собой шаблон подтверждения типа из статьи 6_b (извлечение), раздел 1.2: для извлечения потребуется денежная сумма в соответствующей зоне, а не просто совпадение ключевых слов.
Пример 2. Составной вопрос, независимое разложение.
Вопрос пользователя: «Какова годовая страховая премия и каковы основные исключения из страхового покрытия?»
ParsedQuestion( original_question="What is the annual premium and what are the main exclusions?", decomposition=Decomposition( pattern="independent", sub_questions=[ "What is the annual premium?", "What are the main exclusions?", ], ), activations=ExecutionPlan(decompose_compound=True), parsing_notes=["Compound question detected. Decomposed into 2 independent sub-questions."], )
Программа-оркестратор видит decompose_compound=True и запускает pdf_qa дважды параллельно (по одному разу для каждого подвопроса), после чего формирует объединенный результат.
Пример 3. Неоднозначный вопрос, требующий уточнения.
Вопрос пользователя: «Каков лимит?»
ParsedQuestion( original_question="What's the limit?", suggested_clarification=( "Several types of limits exist in this contract: coverage limit, sublimit, " "deductible, aggregate limit. Which one are you asking about?" ), ambiguity_reason="single_term_with_multiple_referents", parsing_notes=["Ambiguous question; clarification suggested before running pipeline."], )
Пример 4. Понижение версии активации с учетом документа.
Вопрос пользователя: «Что написано на странице 3 договора?», документ в формате Word.
ParsedQuestion( original_question="What does it say on page 3 of the contract?", activations=ExecutionPlan( extract_page_numbers=False, ), parsing_notes=[ "User mentioned 'page 3' but document is Word format. " "Page numbers in Word depend on renderer; treating as approximate location.", ], )
В реальных условиях: шесть месяцев работы брокерской системы в реальных условиях:
- Средняя задержка синтаксического анализа: 280 мс (один вызов LLM промежуточного уровня для декомпозиции + расширение ключевых слов)
- Распределение по типу декомпозиции: одиночная 71%, независимая 19%, условная 6%, последовательная 3%, унифицированная 1%.
- Уточнение было вызвано по 4% вопросов.
- Словарные статьи экспертов: 340, количество увеличивается на 5-10 в месяц.
- Снижение уровня активации с учетом документа: 12% вопросов содержат хотя бы один из указанных пунктов.
Снижение точности: при отключенном синтаксическом анализе (вопросы обрабатывались как прямые строки) точность упала с 91% до 76%. Разница в 15 баллов — это то, чего удалось достичь благодаря синтаксическому анализу.
3.3 Распространенные ошибки при внедрении
Несколько подводных камней, которые могут возникнуть при практическом применении синтаксического анализа вопросов.
Рассматривать разбор вопросов просто как «извлечение ключевых слов» — это неправильно. Ключевые слова — лишь один из многих результатов. Конвейеры, останавливающиеся на извлечении ключевых слов, упускают из виду декомпозицию, фильтры области видимости, ограничения формата и решения об активации, что влияет на качество на последующих этапах конвейера.
Кэширование проанализированного вопроса между документами. Проанализированный вопрос зависит от профиля документа. Один и тот же вопрос, проанализированный для PDF-файла и для документа Word, будет иметь разные флаги активации. Ключ кэша должен включать профиль документа, а не только текст вопроса.
Мы пропускаем экспертный словарь, потому что «встраивания обрабатывают синонимы». Они обрабатывают словарные синонимы. Они не обрабатывают внутренние аббревиатуры, термины, специфичные для конкретной юрисдикции, или бизнес-кодированную лексику, с которой модель встраивания никогда не сталкивалась. Экспертный словарь — это то, что проект постоянно расширяет.
Агрессивное разложение: чрезмерное разложение приводит к ответам, которые не складываются в единое целое. Вопрос «Каковы исключения и ограничения?» в большинстве политических контекстов является единым, а не независимым. Тест на уточнение (замена «и» на «также») — это дешевая проверка; классификация LLM — это страховка.
Включение ограничений формата в поисковый запрос. Запрос «Сумма премии, отформатированная как целое число, в евро» должен выдавать поисковый запрос «сумма премии» и формировать краткое описание запроса, содержащее ограничение формата. Включение ограничений формата в поисковый запрос загрязняет результаты поиска.
Устанавливать все флаги активации вручную при каждом вызове. Это полностью противоречит смыслу диспетчера. Значение по умолчанию pdf_qa(pdf_path, question) должно генерировать осмысленные активации на основе проанализированного вопроса и профиля документа. Явные переопределения предназначены для случаев, когда команда знает, что значение по умолчанию лучше.
Забывать, что проанализированный вопрос — это данные. Проанализированный вопрос — это тот артефакт, который считывает остальная часть конвейера обработки. Стоит сделать его доступным для проверки, регистрации и контроля версий. Производственные системы должны иметь возможность показать: «Для вопроса X проанализированная структура была Y, и поэтому система сделала Z».
4. Заключение
Полезность проанализированного брифа зависит от уровня маршрутизации, который преобразует его столбцы в поведение конвейера. Маршрутизация состоит из трех частей: представления RetrievalQuery , которое предоставляет процессу извлечения только те столбцы, с которыми он может работать (ключевые слова, перезаписи, якоря, фильтры области действия), и ничего больше; представления GenerationBrief , которое предоставляет процессу генерации все необходимое (исходный вопрос, ограничения формата, разрешение неоднозначностей, отвлекающие факторы); и карты активаций , которая отключает определенные блоки, когда профиль документа делает их бесполезными. Блок _meta записывает каждое решение по маршрутизации, поэтому неправильно маршрутизированный вопрос отображается как разница в журнале аудита, а не как загадочный ответ.
Конвейер обработки данных, который настраивает модели встраивания и размеры фрагментов, но направляет необработанную строку пользователя к каждому блоку, теряет большую часть своего качества еще до начала извлечения. Этап диспетчеризации не добавляет модель. Он направляет уже существующие модели.
5. Источники и дополнительная литература
В данной статье рассматривается выбор архитектуры, лежащей в основе диспетчеризации вопросов. В качестве стандарта используется детерминированный диспетчер (подход B в разделе 2.1): воспроизводимый, проверяемый, с ограниченной стоимостью. Противоположной точкой является агентный подход, при котором LLM принимает решения о маршрутизации во время выполнения, к которому в литературе пришли под несколькими названиями. В третьем томе («Агентные блоки») развивается агентная альтернатива на основе структурированного плана, определенного в данной статье; здесь мы приводим аргументы в пользу противопоставления детерминированного диспетчера.
Другой ракурс, другой контекст:
- Шик и др., Toolformer: Языковые модели могут научиться использовать инструменты, NeurIPS 2023 (arXiv:2302.04761). Модель решает, когда и какой инструмент вызывать, непосредственно в коде, без предварительного анализа запроса. Это противоположность детерминированному диспетчеру, используемому в данной статье: маршрутизация передается в языковую модель во время выполнения, а не извлекается заранее в типизированный план.
- Яо и др., ReAct: Синергия рассуждений и действий в языковых моделях, ICLR 2023 (arXiv:2210.03629). Шаблон агентного цикла, который в режиме реального времени обеспечивает маршрутизацию между рассуждениями и вызовами инструментов. Тот же компромисс, что и в Toolformer: гибкость за счет воспроизводимости и ограниченных затрат. Том 3 охватывает область аудита, которая делает это работоспособным в регулируемых контекстах.
Ранее в серии:
- Документальная разведка: введение к серии. Что представляет собой серия, кирпичик за кирпичиком, и в каком порядке.
Часть I:
- Базовая модель Enterprise RAG: от PDF-файла до выделенного ответа. Четырехэтапный конвейер от начала до конца: PDF-файл на входе, выделенный ответ на выходе.
- Эмбеддинги — это не магия: предсказуемые сбои в поиске RAG. Где сходство эмбеддингов приносит пользу (синонимы, опечатки, перефразирование), где оно предсказуемо дает сбой (неизвестные термины, отрицание, релевантность термина и ответа) и как его все равно использовать.
- Переранжировщики тоже не волшебство: когда слой кросс-кодировщика оправдывает затраты. Что добавляет кросс-кодировщик по сравнению с встраиванием на основе двух кодировщиков (измеренные показатели) и когда оправдана задержка.
- RAG — это не машинное обучение, и инструментарий машинного обучения решает не ту задачу. Почему оптимизация по размеру фрагментов и тонкая настройка оптимизируют не то, что нужно; вместо этого следует ориентироваться на тип вопроса.
- От регулярных выражений до моделей компьютерного зрения: какой метод RAG подходит для какой задачи? Две оси — сложность документа и контроль вопросов — определяют, какой метод подходит для каждого конкретного случая.
- 10 распространенных ошибок RAG, которые мы постоянно видим на производстве. Десять производственных ошибок, расположенные по пунктам, с указанием способа их исправления.
Часть II:
- Помимо функции extract_text: два слоя PDF-файла, определяющие качество RAG. Первая половина блока анализа: характер документа, сигналы и резюме.
- Прекратите возвращать плоский текст из PDF-файла: необходима реляционная структура RAG. Вторая половина блока парсинга: реляционные таблицы, которые считывает каждый последующий блок.
Анджела Ши. Все материалы от Анджелы Ши.
Источник: towardsdatascience.com
Похожие записи
Оцените материал:
Похожие записи
Анри Пуанкаре: Мастер теней на стене науки
03.01.2026
На цепочку и к стене: зумеры «переизобретают» проводные телефоны, чтобы снизить зависимость от смартфонов
14.10.2025
Рынок IT умер или просто стало сложнее?
26.12.2025Присоединяйтесь и подпишитесь на рассылку самых свежих новостей по Email
Получайте свежие новости и идеи на почту. Без спама — только самое интересное.
Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.
