5.1 · Переформулировка запроса и метаданные
Неделя 5 · RAG · ⏱ ~4 ч · Практика
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Покупатели задают вопросы не так, как написана база знаний: разговорно, с опечатками, без названия раздела, иногда с тремя вопросами в одном сообщении. База же написана языком правил магазина: «возврат», «нарушенная упаковка», «тариф по зоне». Индекс из гл. 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Техника 1: Query rewriting
Заголовок раздела «Техника 1: Query rewriting»Первые три техники меняют сам запрос, четвёртая добавляет к нему фильтр; во всех четырёх так или иначе участвует LLM.
LLM переписывает вопрос в один чистый поисковый запрос: словами базы знаний, с её терминами, без шума. Самый простой вариант, часто уже даёт заметный прирост, когда вопрос и база написаны разными словами (разговорный вопрос → официальный текст правил).
Плюсы: дёшево (короткий промпт, маленькая модель). Минусы: один запрос — одна интерпретация; если модель поняла вопрос неправильно, поиск уйдёт не туда.
Техника 2: Multi-query
Заголовок раздела «Техника 2: Multi-query»Слабое место rewriting — единственная интерпретация. Multi-query его закрывает: LLM генерирует 2–3 разных запроса: перефразировки, разные аспекты, подвопросы. По каждому делается поиск, результаты объединяются. Это страховка от неудачной формулировки и способ покрыть составной вопрос («можно вернуть лампу, если не подошла, и кто платит за пересылку?» → два подзапроса).
Объединение результатов:
- Простое: собрать все найденные чанки, дедуплицировать по id, для каждого взять лучший скор, отсортировать. Работает, когда скоры сравнимы (одна модель эмбеддингов, одна метрика).
- Reciprocal Rank Fusion (RRF): складывать не скоры, а
1 / (60 + rank)из каждой выдачи. Устойчиво к разным шкалам. Подробно — в гл. 5.2, где RRF нужен для гибрида; здесь можно начать с простого объединения и потом переключиться.
Важно, чтобы исходный вопрос тоже участвовал в поиске как один из запросов — переписывание иногда ухудшает.
Техника 3: HyDE (Hypothetical Document Embeddings)
Заголовок раздела «Техника 3: HyDE (Hypothetical Document Embeddings)»Rewriting и multi-query делают вопрос чище, но он остаётся коротким вопросом. HyDE заходит с другой стороны. Идея: попросить LLM написать гипотетический ответ — фрагмент, похожий на страницу справочного центра, — и искать по эмбеддингу этого фрагмента, а не вопроса. Эмбеддинг «выдуманного документа» лежит ближе к реальным документам, чем эмбеддинг короткого вопроса (вспомним гл. 4.1: вопросы и пассажи живут в немного разных регионах пространства).
вопрос: "скок будет стоить отправить 3 кило в другой город"HyDE: "Тарифы доставки. Зона 2 — другие города. Посылка 1–3 кг: 470 ₽, срок 3–5 рабочих дней. Бесплатная доставка — от суммы заказа ..."поиск по эмбеддингу HyDE-текстаВажная тонкость: гипотетический документ может содержать выдумки (неправильные цены и сроки) — это не страшно, он используется только для поиска и никогда не показывается покупателю и не попадает в контекст ответа. Минусы: дороже и медленнее (генерация 100–200 токенов), и на темах, о которых модель ничего не знает (своеобразные правила именно нашего магазина), выдумка может увести поиск не туда.
Техника 4: извлечение фильтров (self-query)
Заголовок раздела «Техника 4: извлечение фильтров (self-query)»До сих пор мы меняли текст запроса. Вторая половина задачи из схемы выше — структурные условия. Из вопроса извлекается структурированное условие для метаданных: категория страницы (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 docsimport json, osfrom anthropic import Anthropicfrom 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 queriesfor 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+ вопроса и разбираем конкретные изменения: какие вопросы починились, какие сломались.
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Для переписывания нужен один короткий вызов LLM с JSON на выходе. Здесь провайдеры заметно расходятся: у DeepSeek есть JSON-режим, но нет JSON по схеме, а у Ollama схему можно передать прямо в запросе. Плюс у DeepSeek по умолчанию включено рассуждение, и это бьёт по латентности и воспроизводимости.
Новое по сравнению с индексацией — приватность: в настоящем вопросе покупателя бывают имя, телефон, номер заказа. Данные клиентов в облачные модели не уходят (сервера DeepSeek в Китае, но правило касается любого облачного провайдера). Golden set выдуман, поэтому в практике DeepSeek допустим; в боевом боте настоящие сообщения переписывает Ollama.
DeepSeek: JSON-режим + выключенное рассуждение
Заголовок раздела «DeepSeek: JSON-режим + выключенное рассуждение»Факты на ноябрь 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 .envimport json, osfrom 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 NoneOllama: схема прямо в запросе
Заголовок раздела «Ollama: схема прямо в запросе»У 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 — любой из генераторов; гипотетический документ и так никуда, кроме поиска, не идёт.
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Перечитываем раздел «Обработка запроса» в 4.2, свой разбор промахов из 4.3 и sanity check из 4.4 (≈10 мин). Выписываем, какие промахи были из-за формулировки вопроса — их и чиним.
- Находим и читаем статью, которая ввела HyDE: «Precise Zero-Shot Dense Retrieval without Relevance Labels» (Gao et al., 2022, arXiv) — абстракт, введение и рисунок с общей схемой (≈25 мин). Выносим: почему гипотетический документ, а не вопрос, и почему фактические ошибки в нём не мешают поиску.
- Открываем Eugene Yan: Patterns for Building LLM-based Systems → в разделе RAG находим место про HyDE и про сочетание поиска с фильтрами (≈15 мин).
- Открываем DeepSeek: JSON Output и Thinking Mode (≈20 мин). Выносим:
json_objectбез схемы, требование слова «json» и примера в промпте, пустойcontentкак документированный случай, как выключить рассуждение и какие параметры в режиме рассуждения не действуют. - Открываем Ollama: Structured Outputs и Thinking (≈15 мин). Выносим:
format=Model.model_json_schema(),model_validate_jsonна выходе, параметрthink. - Для сравнения — документация по structured outputs у Anthropic или OpenAI (≈15 мин). Записываем, чем «JSON по схеме с гарантией» отличается от JSON-режима DeepSeek.
- Документация 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"] «наугад». Что лучше сделать?
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?
b. Ollama ограничивает генерацию переданной схемой, а JSON-режим DeepSeek обещает только синтаксически валидный JSON (и иногда пустой ответ). Смысл значений в обоих случаях проверяет наш валидатор.
Вопрос 15
Бот поддержки получает настоящие сообщения покупателей. Какую модель использовать для переписывания запроса?
c. В сообщении покупателя могут быть имя, телефон, номер заказа, а данные клиентов не уходят ни к одному облачному провайдеру. Облако — только для выдуманных вопросов golden set.
Что дальше
Заголовок раздела «Что дальше»Переписывание не до конца спасает точные идентификаторы — артикулы, названия похожих товаров, числа из таблиц. Это задача 5.2 · Гибридный поиск (BM25) и реранкинг, где добавим лексический поиск, RRF и cross-encoder поверх того же golden set.