Перейти к содержимому

5.1 · Переформулировка запроса и метаданные

Неделя 5 · RAG · ⏱ ~4 ч · Практика

Покупатели задают вопросы не так, как написана база знаний: разговорно, с опечатками, без названия раздела, иногда с тремя вопросами в одном сообщении. База же написана языком правил магазина: «возврат», «нарушенная упаковка», «тариф по зоне». Индекс из гл. 4.4 ищет хорошо, когда вопрос похож на текст базы, — а разбор промахов в гл. 4.3 и sanity check в гл. 4.4 показывают, что так бывает не всегда. Переформулировка запроса и автоматические фильтры — самые дешёвые способы поднять качество поиска, не трогая индекс. На собеседованиях это стандартная тема «query understanding»: multi-query, HyDE, self-query (извлечение фильтров из вопроса).

Сравним:

  • Вопрос: «а вскрытый чай можно сдать обратно? пачку открыл, вкус не зашёл»
  • Документ: «## Возврат вскрытого чая. Чай и кофе на развес со вскрытой упаковкой не принимаем, обмену они тоже не подлежат.»

Эмбеддинг вопроса несёт «шум»: эмоции, разговорные слова («сдать обратно», «не зашёл»), опечатки, лишние детали. Эмбеддинг документа — сухой текст правил. Они близки, но не так близки, как могли бы. Задача этапа query understanding — превратить вопрос человека в запрос, похожий на документ, и извлечь из вопроса структурные условия (категория страницы, нужен ли архив), чтобы сузить поиск.

вопрос ─► LLM: {categories: ["returns"], include_archived: false,
queries: ["возврат вскрытого чая",
"какие товары не подлежат возврату"]}
─► поиск по каждому запросу с фильтром category=returns, archived=0
─► объединение результатов ─► top-k

Первые три техники меняют сам запрос, четвёртая добавляет к нему фильтр; во всех четырёх так или иначе участвует LLM.

LLM переписывает вопрос в один чистый поисковый запрос: словами базы знаний, с её терминами, без шума. Самый простой вариант, часто уже даёт заметный прирост, когда вопрос и база написаны разными словами (разговорный вопрос → официальный текст правил).

Плюсы: дёшево (короткий промпт, маленькая модель). Минусы: один запрос — одна интерпретация; если модель поняла вопрос неправильно, поиск уйдёт не туда.

Слабое место rewriting — единственная интерпретация. Multi-query его закрывает: LLM генерирует 2–3 разных запроса: перефразировки, разные аспекты, подвопросы. По каждому делается поиск, результаты объединяются. Это страховка от неудачной формулировки и способ покрыть составной вопрос («можно вернуть лампу, если не подошла, и кто платит за пересылку?» → два подзапроса).

Объединение результатов:

  • Простое: собрать все найденные чанки, дедуплицировать по id, для каждого взять лучший скор, отсортировать. Работает, когда скоры сравнимы (одна модель эмбеддингов, одна метрика).
  • Reciprocal Rank Fusion (RRF): складывать не скоры, а 1 / (60 + rank) из каждой выдачи. Устойчиво к разным шкалам. Подробно — в гл. 5.2, где RRF нужен для гибрида; здесь можно начать с простого объединения и потом переключиться.

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

Rewriting и multi-query делают вопрос чище, но он остаётся коротким вопросом. HyDE заходит с другой стороны. Идея: попросить LLM написать гипотетический ответ — фрагмент, похожий на страницу справочного центра, — и искать по эмбеддингу этого фрагмента, а не вопроса. Эмбеддинг «выдуманного документа» лежит ближе к реальным документам, чем эмбеддинг короткого вопроса (вспомним гл. 4.1: вопросы и пассажи живут в немного разных регионах пространства).

вопрос: "скок будет стоить отправить 3 кило в другой город"
HyDE: "Тарифы доставки. Зона 2 — другие города. Посылка 1–3 кг: 470 ₽,
срок 3–5 рабочих дней. Бесплатная доставка — от суммы заказа ..."
поиск по эмбеддингу HyDE-текста

Важная тонкость: гипотетический документ может содержать выдумки (неправильные цены и сроки) — это не страшно, он используется только для поиска и никогда не показывается покупателю и не попадает в контекст ответа. Минусы: дороже и медленнее (генерация 100–200 токенов), и на темах, о которых модель ничего не знает (своеобразные правила именно нашего магазина), выдумка может увести поиск не туда.

До сих пор мы меняли текст запроса. Вторая половина задачи из схемы выше — структурные условия. Из вопроса извлекается структурированное условие для метаданных: категория страницы (category: delivery, payment, returns, warranty, promo, orders, products) и нужен ли архив (archived). Затем фильтр применяется к поиску (гл. 4.4). В вопросе «а вскрытый чай можно сдать обратно?» речь явно о возврате — а без фильтра в топ-5 попадут семантически похожие куски карточки products/tea-sencha.md про вскрытую пачку.

Двухступенчатый подход — сначала правила, потом LLM:

import re
ALIASES = {
"returns": ["вернуть", "возвра", "сдать обратно", "обмен"],
"delivery": ["доставк", "курьер", "пвз", "самовывоз"],
"payment": ["оплат", "платёж", "наложенн"], # not "карт": it is inside "карточка", "картон"
"warranty": ["гаранти", "сломал", "брак"],
"promo": ["промокод", "купон", "скидк"],
"orders": ["где мой заказ", "статус заказа", "отменить заказ"],
}
def detect_category_rules(q: str) -> str | None:
ql = q.lower()
hits = {cat for cat, words in ALIASES.items() if any(w in ql for w in words)}
return hits.pop() if len(hits) == 1 else None # 0 or 2+ matches: undecided

Категорию products правилами не ловим: товар по названию распознаёт LLM. Правила дешёвые, предсказуемые и тестируемые. LLM подключается, когда правила не решили, и одновременно с переписыванием — один вызов возвращает и запросы, и категории, и решение про архив.

Правила фильтрации:

  • Категория не определена → без фильтра. Ошибочный фильтр хуже, чем никакого: он гарантированно исключает правильный ответ.
  • Нужны две категории («с какой суммы доставка бесплатная, если я оплачиваю с промокодом?» — ответ в delivery.md и в promo.md, и пороги там расходятся) → либо два поиска с разными фильтрами, либо фильтр category IN (...). В контекст должны попасть куски обеих страниц. То же с гарантией на товар: она и в warranty.md, и в products/lamp-light.md.
  • Значение фильтра валидируется по списку допустимых (delivery|payment|returns|warranty|promo|orders|products). LLM может вернуть "Returns", "возврат" или "refund" — такое либо нормализуется, либо отбрасывается.
  • Архив — только по явной просьбе («правила возврата прошлого года»). Ошибочно включённый архив хуже пропущенного: бот уверенно процитирует отменённые сроки.

Один вызов LLM со структурированным ответом

Заголовок раздела «Один вызов LLM со структурированным ответом»

Переписывание, multi-query и фильтры удобно получать одним вызовом: модель возвращает JSON с запросами, категориями и решением про архив. Структурированный вывод (JSON по схеме) разбирали в главе 2.2 · Structured output: Pydantic + Instructor — применяем его здесь. Сначала — эталонная версия на API, который умеет гарантировать схему (иллюстративный вызов в стиле официального SDK Anthropic); как то же сделать на DeepSeek и Ollama, где с гарантиями сложнее, — в следующем разделе. Pydantic-модель QueryPlan и промпт SYSTEM общие для всех вариантов:

# Illustrative: model name comes from .env; check the current Anthropic docs
import json, os
from anthropic import Anthropic
from pydantic import BaseModel, field_validator
CATEGORIES = {"delivery", "payment", "returns", "warranty", "promo", "orders", "products"}
class QueryPlan(BaseModel):
categories: list[str] # empty = unknown
include_archived: bool = False
queries: list[str] # 2-3 search queries in Russian
@field_validator("categories")
@classmethod
def only_known(cls, v):
return [c.lower() for c in v if c.lower() in CATEGORIES]
SYSTEM = """You turn a customer's question to an online shop's support into search queries
for the shop's help center, written in Russian. Page categories:
delivery, payment, returns, warranty, promo, orders, products.
Return JSON only: {"categories": [...], "include_archived": false, "queries": [...]}.
- categories: only those the question clearly refers to; [] if unsure.
- include_archived: true only if the customer explicitly asks about old or previous rules.
- queries: 2-3 different Russian queries in the help center's wording (returns, delivery
tariffs, warranty terms). Keep exact identifiers from the question unchanged:
product names, SKUs, weights, amounts, dates."""
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
def plan_query(question: str) -> QueryPlan:
msg = client.messages.create(
model=os.environ["REWRITE_MODEL"],
max_tokens=300,
system=SYSTEM,
messages=[{"role": "user", "content": question}],
)
return QueryPlan.model_validate_json(msg.content[0].text)

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

Четыре техники выше — то, что строим в практике. Ещё несколько встречаются в статьях и на собеседованиях:

  • Step-back prompting — сначала задать более общий вопрос («как устроен возврат товара в магазине»), потом конкретный.
  • Декомпозиция — разбить составной вопрос на подвопросы и собрать ответ из частей (зачаток агентного RAG).
  • Query routing — решить, нужен ли поиск вообще («привет» — не нужен) и в какой источник идти («где мой заказ 1042?» — это вопрос к базе заказов, а не к справочному центру; передаём человеку).
  • Conversational rewriting — в чате переписать «а на „Свет мини“?» в самостоятельный вопрос с учётом истории («какая гарантия на лампу „Свет мини“?»). Понадобится, если делаем бота с диалогом.

Каждая техника добавляет вызов LLM и сложность, поэтому оставляем только то, что подтверждается цифрами. Метрика та же, что в гл. 4.3: hit rate@k на golden set. Расширяем его до 20 вопросов разных типов (список — в «Практике»). Сравниваем варианты:

Вариант Что делает
baseline исходный вопрос, без фильтра по категории (архив исключён, как в гл. 4.4)
+ filter исходный вопрос + фильтр по категории и решение про архив
+ rewrite multi-query, без фильтра
+ rewrite + filter multi-query + фильтр
+ HyDE (опц.) гипотетический документ (+ фильтр)

Так виден вклад каждой техники отдельно, а не «стало лучше в целом». Кроме hit rate, записываем латентность и стоимость на вопрос: rewrite добавляет вызов LLM на горячий путь.

Помним про шум: на 20 вопросах разница в 1 вопрос — это 5 процентных пунктов, и она может быть случайной (например, LLM переписала иначе при повторе). Смотрим на разницу в 3+ вопроса и разбираем конкретные изменения: какие вопросы починились, какие сломались.

Для переписывания нужен один короткий вызов LLM с JSON на выходе. Здесь провайдеры заметно расходятся: у DeepSeek есть JSON-режим, но нет JSON по схеме, а у Ollama схему можно передать прямо в запросе. Плюс у DeepSeek по умолчанию включено рассуждение, и это бьёт по латентности и воспроизводимости.

Новое по сравнению с индексацией — приватность: в настоящем вопросе покупателя бывают имя, телефон, номер заказа. Данные клиентов в облачные модели не уходят (сервера DeepSeek в Китае, но правило касается любого облачного провайдера). Golden set выдуман, поэтому в практике DeepSeek допустим; в боевом боте настоящие сообщения переписывает Ollama.

Факты на ноябрь 2026 (по JSON Output, Thinking Mode и справке Chat Completions):

  • response_format={"type": "json_object"} гарантирует только «валидный JSON», а не нашу схему. Варианта json_schema документация не описывает. В промпте обязательно слово «json» и пример нужной структуры, а max_tokens должен быть с запасом.
  • Если модель не смогла выдать валидный JSON, API заменяет содержимое пробелами: content бывает пустым при finish_reason == "stop". Это надо обрабатывать: повтор или fallback.
  • Рассуждение (thinking) включено по умолчанию. В режиме рассуждения temperature не действует (ошибки нет, значение просто игнорируется). Для переписывания рассуждение не нужно: выключаем его через extra_body={"thinking": {"type": "disabled"}}. Тогда и вызов быстрее, и temperature=0 снова работает, что важно для воспроизводимых evals.
# Illustrative: DeepSeek via the OpenAI SDK; key and model name come from .env
import json, os
from openai import OpenAI
ds = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"], base_url="https://api.deepseek.com")
SYSTEM_JSON = SYSTEM + """
Output json only, exactly like this example:
{"categories": ["returns"], "include_archived": false, "queries": ["возврат вскрытого чая", "какие товары не подлежат возврату"]}"""
def plan_query_deepseek(question: str) -> QueryPlan | None:
resp = ds.chat.completions.create(
model=os.environ["REWRITE_MODEL"], # e.g. deepseek-flash
messages=[{"role": "system", "content": SYSTEM_JSON},
{"role": "user", "content": question}],
response_format={"type": "json_object"},
temperature=0, # has effect only with thinking disabled
max_tokens=400,
extra_body={"thinking": {"type": "disabled"}}, # DeepSeek-specific switch
)
text = (resp.choices[0].message.content or "").strip()
if not text: # documented: may come back blank
return None # caller falls back to the raw question
try:
return QueryPlan.model_validate_json(text) # schema is enforced HERE, not by the API
except ValueError:
return None

У Ollama нативный параметр format принимает JSON Schema, и вывод модели ограничивается этой схемой (Structured Outputs). Pydantic даёт схему одной строкой:

# Illustrative: small local model, e.g. qwen3:4b (~2.5 GB; tools and thinking per the Ollama library)
import ollama
def plan_query_ollama(question: str) -> QueryPlan:
resp = ollama.chat(
model=os.environ["REWRITE_MODEL_LOCAL"],
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": question}],
format=QueryPlan.model_json_schema(), # output is constrained to this schema
options={"temperature": 0},
think=False, # thinking models: no reasoning for a rewrite task
)
return QueryPlan.model_validate_json(resp.message.content)

Валидация Pydantic всё равно нужна: схема гарантирует форму, а не смысл ("categories": ["refund"] пройдёт схему со списком строк, но не наш валидатор). Про think: по документации Ollama false просит модель не рассуждать, а модель, которая не умеет выключать рассуждение, использует минимальный уровень.

Память. Рядом в памяти уже держится эмбеддинг-модель bge-m3 (гл. 4.4). RAM: 8 GB — 4B, qwen3:4b (скачивание ~2.5 GB, по странице библиотеки Ollama); 16 GB — 7–8B, например qwen3:8b (~5.2 GB); 32 GB и больше — 14B и крупнее. Модель занимает в памяти примерно свой размер плюс контекст; суммарный размер загруженных моделей прикидываем заранее (ollama ps). Для переписывания 4B-модели обычно хватает, но проверяем это цифрами на golden set. Ошибается в JSON или в категории — на выдуманных вопросах это аргумент за DeepSeek, а для настоящих сообщений — за локальную модель покрупнее, если позволяет память. Ставим модель заранее через ollama pull.

Что нужно Claude OpenAI DeepSeek Ollama
JSON на выходе Structured outputs / tool use (смотрим актуальную документацию) response_format с json_schema (структурированный вывод по схеме) Только json_object: валидный JSON без схемы format = JSON Schema (нативный API)
Гарантия схемы Да, в режиме structured outputs Да, в strict-режиме Нет → Pydantic + повтор/fallback Форма ограничена схемой; смысл проверяет Pydantic
Пустой ответ — — Возможен (документирован) → обработать —
Рассуждение по умолчанию Зависит от модели и параметров Зависит от модели Включено; выключается thinking: disabled У thinking-моделей; think=False
temperature У новых моделей есть ограничения (смотрим документацию) Зависит от модели Игнорируется в режиме рассуждения Работает через options
Латентность rewrite Сеть Сеть Сеть + рассуждение, если не выключено Без сети, но зависит от машины
Настоящие сообщения покупателей Нельзя (облако) Нельзя (облако) Нельзя (облако) Можно: данные не покидают машину
  • Основной путь: DeepSeek deepseek-flash с выключенным рассуждением. Данные — выдуманные вопросы golden set и публичная база знаний, поэтому отправлять их можно. В вопросах не должно быть ничего из настоящих обращений клиентов (имена, телефоны, номера заказов).
  • Локальный вариант: Ollama с format = схема — сравниваем hit rate и латентность с DeepSeek на тех же 20 вопросах. Он же — для боевого бота с настоящими сообщениями.
  • Для сравнения (по желанию): Claude или OpenAI со structured outputs — исходный код из «Теории».
  • HyDE — любой из генераторов; гипотетический документ и так никуда, кроме поиска, не идёт.
  1. Перечитываем раздел «Обработка запроса» в 4.2, свой разбор промахов из 4.3 и sanity check из 4.4 (≈10 мин). Выписываем, какие промахи были из-за формулировки вопроса — их и чиним.
  2. Находим и читаем статью, которая ввела HyDE: «Precise Zero-Shot Dense Retrieval without Relevance Labels» (Gao et al., 2022, arXiv) — абстракт, введение и рисунок с общей схемой (≈25 мин). Выносим: почему гипотетический документ, а не вопрос, и почему фактические ошибки в нём не мешают поиску.
  3. Открываем Eugene Yan: Patterns for Building LLM-based Systems → в разделе RAG находим место про HyDE и про сочетание поиска с фильтрами (≈15 мин).
  4. Открываем DeepSeek: JSON Output и Thinking Mode (≈20 мин). Выносим: json_object без схемы, требование слова «json» и примера в промпте, пустой content как документированный случай, как выключить рассуждение и какие параметры в режиме рассуждения не действуют.
  5. Открываем Ollama: Structured Outputs и Thinking (≈15 мин). Выносим: format=Model.model_json_schema(), model_validate_json на выходе, параметр think.
  6. Для сравнения — документация по structured outputs у Anthropic или OpenAI (≈15 мин). Записываем, чем «JSON по схеме с гарантией» отличается от JSON-режима DeepSeek.
  7. Документация sqlite-vec (гл. 4.4) по фильтрации — освежаем, как фильтр по category и archived ставится внутрь KNN-запроса (≈10 мин).
  • Переписывание теряет точные идентификаторы. LLM «улучшает» «Свет мини» до «настольная лампа», TEA-SENCHA до «зелёный чай», а «3 кг» выбрасывает как лишнюю деталь — и поиск находит гарантию не той лампы или не ту строку таблицы тарифов. В промпте явно: «сохраняй названия товаров, артикулы, вес и суммы без изменений». Полностью проблема решается гибридным поиском (гл. 5.2).
  • Ложный фильтр. Модель «догадалась», что вопрос «а если не понравится?» про возврат, хотя покупатель спрашивал о гарантии, — фильтр returns исключает правильный ответ. Лучше без фильтра, чем с неверным.
  • Ложный архив. «А раньше же было 30 дней?» — не просьба показать старые правила: покупателю нужен действующий срок. Архив включаем только по явной просьбе.
  • Алиасы-подстроки дают ложные срабатывания: "карт" найдётся в «карточка товара» и «картонная коробка», "доставк" — в «кто платит за обратную доставку» (вопрос про возврат). В боевом коде ищем по границам слов (re.search(r"\bкарт(а|ой|у)\b", ...)) и покрываем это тестом.
  • Не выкидываем исходный вопрос из multi-query — переписанные запросы иногда хуже.
  • HyDE-текст не идёт в ответ. Он может быть выдумкой; только для поиска.
  • Латентность. +1 вызов LLM на каждый вопрос: 300–1500 мс. Для чата поддержки терпимо, для автодополнения в поиске по сайту — нет. Маленькая модель, короткий max_tokens, кэш одинаковых вопросов.
  • Невалидный JSON / неизвестная категория. Всегда валидация (Pydantic) и fallback на исходный вопрос.
  • Недетерминизм. Один и тот же вопрос может переписываться по-разному между прогонами. Для eval фиксируем температуру низкой и/или кэшируем планы запросов на диск, чтобы сравнения были воспроизводимы.
  • Утечка golden set в промпт. Не вставляем в промпт переписывания примеры из golden set — получим завышенные цифры. Few-shot примеры берём из других вопросов.

Работаем в projects/02-support-kb-rag/rag/query.py и projects/02-support-kb-rag/eval/.

  • Golden set на 20 вопросов: переносим вопросы из notes/rag.md (гл. 4.2) и golden.jsonl из гл. 4.3 в eval/golden.jsonl и дополняем до 20 строк по всем категориям. Формат: {"q": ..., "category": "returns" | null, "must_contain": [...]} (null — вопрос не определяет категорию). Минимум: 5 разговорных вопросов или с опечатками, 4 без явной категории, 4 с точным идентификатором (название похожего товара, артикул, вес, сумма), 2 на две категории, 1 про старые правила возврата (ключевые строки — из returns-2025.md). Вопросы выдумываем сами.

  • detect_category_rules с алиасами. Офлайн-тесты в tests/test_query.py: «а вскрытый чай можно сдать обратно?» → returns; «кто платит за доставку, если я возвращаю лампу?» → None (две категории — решает LLM или фильтр по нескольким); «какая кружка лучше в подарок» → None; «где карточка кружки „Север“» → не payment.

  • plan_query(question) -> QueryPlan: один вызов LLM, JSON (categories, include_archived, queries), валидация Pydantic, fallback. Правила имеют приоритет над LLM, если они однозначны. Основной вариант — DeepSeek (json_object, рассуждение выключено, обработка пустого content); второй — Ollama с format=QueryPlan.model_json_schema(). Провайдер выбирается переменной окружения, DEEPSEEK_API_KEY — только в .env.

  • Офлайн-тест на fallback: фейковый клиент возвращает пустую строку и невалидный JSON — plan_query отдаёт исходный вопрос без фильтра (и без архива) и не падает.

  • Multi-query поиск: search_multi(question, k=5): исходный вопрос + 2–3 переписанных запроса, поиск по каждому через search(qvec, k, category=..., include_archived=...) из гл. 4.4 (для двух категорий — отдельный поиск на каждую), объединение с дедупликацией по id чанка (лучший скор или RRF).

  • Кэш планов: eval/cache/plans.json (вопрос → план), чтобы повторные прогоны были бесплатными и воспроизводимыми.

  • Eval-скрипт eval/run_query_eval.py: варианты baseline / +filter / +rewrite / +rewrite+filter (опц. +HyDE), для каждого hit@3 и hit@5, средняя латентность. Таблица в eval/RESULTS.md:

    Вариант hit@3 hit@5 латентность, мс
    baseline /20 /20
    + filter
    + rewrite
    + rewrite + filter
  • Разбор: список вопросов, которые починились и которые сломались по сравнению с baseline, с причиной в одну строку. Отдельно отмечаем вопросы с точными идентификаторами — они пригодятся в гл. 5.2; проверяем, что архив поднялся только на вопросе про старые правила.

  • (Опционально) HyDE: функция hyde(question) -> str (короткий фрагмент «в стиле справочного центра»), поиск по его эмбеддингу; добавляем строку в таблицу.

  • Провайдеры: строку «+ rewrite + filter» прогоняем дважды — с DeepSeek и с Ollama. Записываем hit@3, латентность и долю fallback’ов (пустой или невалидный JSON) для каждого. Для DeepSeek записываем ещё стоимость по usage.

  • (Для сравнения, по желанию) То же на Claude или OpenAI со structured outputs: стало ли меньше fallback’ов?

  • В eval/RESULTS.md есть цифра: насколько изменился hit rate@3 и @5 от baseline к лучшему варианту, на одном и том же golden set из 20 вопросов.
  • Вклад фильтра и переписывания виден по отдельности; есть разбор починившихся и сломавшихся вопросов.
  • Определение категории покрыто офлайн-тестами; ошибки LLM (невалидный JSON, неизвестная категория) не роняют поиск; архив попадает в выдачу только по явной просьбе.
  • Можем объяснить, какая модель переписывает настоящие сообщения покупателей и почему.

Вопрос 1

Почему эмбеддинг вопроса покупателя может плохо совпадать с эмбеддингом нужного куска базы знаний? Какие две причины главные?

Разговорные слова, опечатки и лишние детали в вопросе против официальных формулировок правил («сдать обратно» против «возврат»); короткий вопрос и длинный пассаж — разные по форме тексты и лежат в разных регионах пространства эмбеддингов.

Вопрос 2

Чем multi-query отличается от простого query rewriting и от чего он страхует?

Rewriting даёт один «чистый» запрос, multi-query — несколько разных, по каждому ищут и объединяют результаты. Страхует от неудачной интерпретации и покрывает составные вопросы.

Вопрос 3

Что такое HyDE? Почему ошибки в гипотетическом документе не так страшны?

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

Вопрос 4

Категория в вопросе не названа, LLM вернула "categories": ["delivery"] «наугад». Что лучше сделать?

  • a) применить фильтр — модель умная
  • b) не ставить фильтр
  • c) искать только по returns
  • d) отказаться отвечать

b. Неверный фильтр гарантированно исключает правильный ответ; без фильтра нужный кусок ещё может найтись.

Вопрос 5

Почему исходный вопрос стоит оставить среди запросов в multi-query?

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

Вопрос 6

Что не так в этом промпте для переписывания?

Rewrite the customer question into a clean, general search query.
Remove details like product names, SKUs and numbers.

Он требует удалить названия товаров, артикулы и числа — а это самые точные сигналы для поиска по базе знаний (TEA-SENCHA, «Свет мини» против «Свет», «3 кг» для таблицы тарифов). Нужно наоборот: сохранять их без изменений. Также не указан формат вывода и не учтены фильтры (категория, архив).

Вопрос 7

Какой вариант сравнения показывает вклад фильтра отдельно от вклада переписывания? Какой нужен набор прогонов?

Четыре прогона на одном golden set: baseline, только фильтр, только переписывание, переписывание + фильтр. Разница baseline→filter и baseline→rewrite показывает вклад каждого по отдельности.

Вопрос 8

Почему для переписывания запроса обычно выбирают маленькую быструю модель?

Задача простая, а вызов стоит на горячем пути каждого вопроса: латентность и стоимость важнее максимального качества.

Вопрос 9

На 20 вопросах hit@3 вырос с 13 до 14. Можно ли сказать, что переписывание помогает?

Нет: разница в один вопрос на 20 — в пределах шума (в том числе из-за недетерминизма LLM). Нужна разница побольше, повторные прогоны и разбор конкретных изменённых вопросов.

Вопрос 10

Как обработать вопрос «с какой суммы доставка бесплатная, если я оплачиваю с промокодом»?

Распознать две категории (delivery и promo), сделать два поиска (по фильтру каждой) или фильтр category IN (delivery, promo) и следить, чтобы в итоговый контекст попали куски обеих страниц: порог бесплатной доставки в них расходится, и ответ должен это учесть.

Вопрос 11

Что такое утечка golden set в промпт и чем она опасна?

Когда примеры из тестового набора попадают в промпт (few-shot) — модель «видела ответы», и метрики завышены, не отражая качество на новых вопросах.

Вопрос 12

DeepSeek вызывают с response_format={"type": "json_object"} и temperature=0, больше ничего не меняя. Какие две проблемы это создаёт для eval?

Первая: рассуждение включено по умолчанию, поэтому temperature=0 игнорируется (переписывание недетерминировано), а вызов медленнее и дороже. Нужно extra_body={"thinking": {"type": "disabled"}}. Вторая: JSON-режим не проверяет схему; нужны слово «json» и пример в промпте, валидация Pydantic и обработка пустого ответа.

Вопрос 13

Что не так в этом коде?

resp = ds.chat.completions.create(model=MODEL, messages=msgs,
response_format={"type": "json_object"})
plan = json.loads(resp.choices[0].message.content)
category = plan["categories"][0]

В промпте может не быть слова «json» и примера (требование DeepSeek); content бывает пустым — json.loads упадёт; схема не проверяется (может не быть ключа categories); [0] падает на пустом списке, неизвестная категория не отбрасывается, а вторая категория теряется. Нужны QueryPlan.model_validate_json, проверка пустоты и fallback на исходный вопрос.

Вопрос 14

Чем format у Ollama отличается от json_object у DeepSeek?

  • a) ничем
  • b) Ollama ограничивает вывод переданной JSON-схемой, DeepSeek гарантирует только валидный JSON
  • c) DeepSeek строже
  • d) format работает только в OpenAI-совместимом API

b. Ollama ограничивает генерацию переданной схемой, а JSON-режим DeepSeek обещает только синтаксически валидный JSON (и иногда пустой ответ). Смысл значений в обоих случаях проверяет наш валидатор.

Вопрос 15

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

  • a) DeepSeek — он дешевле
  • b) любую облачную, если выключить рассуждение
  • c) локальную модель через Ollama
  • d) облачную, но удалить из сообщения слово «заказ»

c. В сообщении покупателя могут быть имя, телефон, номер заказа, а данные клиентов не уходят ни к одному облачному провайдеру. Облако — только для выдуманных вопросов golden set.

Переписывание не до конца спасает точные идентификаторы — артикулы, названия похожих товаров, числа из таблиц. Это задача 5.2 · Гибридный поиск (BM25) и реранкинг, где добавим лексический поиск, RRF и cross-encoder поверх того же golden set.