Проблема: запросы пользователей плохи для поиска
Векторный поиск ищет семантически близкие документы. Но «семантически близкий» определяется моделью embedding — и эта модель обучена на определённом распределении текстов. Чанки в индексе — это фрагменты документации, написанной техническим языком, полными предложениями. Запросы пользователей — это нечто совсем другое.
Разрыв между этими двумя типами текста — vocabulary mismatch — снижает точность поиска. Embedding запроса «не работает таймаут» и embedding чанка «httpx.Timeout(connect=5.0)» разделяет больший угол, чем должен быть. Поиск находит менее релевантные документы или не находит ничего.
Vocabulary mismatch: почему слова не совпадают
Vocabulary mismatch — классическая проблема информационного поиска (IR), существовавшая задолго до нейросетей. Пользователь описывает свою потребность одними словами, автор документа описал решение другими словами. Оба правы — просто у них разный словарный запас.
Три типа vocabulary mismatch в RAG:
Dense retrieval (bi-encoder) значительно лучше справляется с vocabulary mismatch, чем BM25 — но не устраняет его полностью. Query transformation — следующий уровень: мы не ждём, что embedding сам преодолеет разрыв, а активно сближаем запрос с документами.
Пять техник трансформации
Каждая техника атакует vocabulary mismatch по-своему. Одни перефразируют запрос, другие генерируют несколько вариантов, третьи создают «гипотетический документ» и ищут по нему. Выбор зависит от типа запросов и допустимой latency.
Техника 1: Query Rewriting
Самая простая и самая часто применяемая техника. Один LLM-вызов преобразует разговорный запрос в форму, оптимальную для поиска: убирает неопределённые местоимения, добавляет явные термины, делает запрос самодостаточным.
from anthropic import Anthropic
client = Anthropic()
REWRITE_PROMPT = """\
Переформулируй запрос пользователя для поиска в технической документации.
Правила:
- Сделай запрос явным и самодостаточным — без местоимений «это», «оно», «там»
- Добавь технические термины, если они подразумеваются
- Сохрани исходный смысл, не расширяй тему
- Верни только переформулированный запрос, без пояснений
Запрос: {query}"""
def rewrite_query(query: str, model: str = "claude-haiku-4-5-20251001") -> str:
"""
Переформулирует запрос для улучшения retrieval.
Использует дешёвую модель — это не финальная генерация, а preprocessing.
"""
response = client.messages.create(
model=model,
max_tokens=128,
messages=[{
"role": "user",
"content": REWRITE_PROMPT.format(query=query),
}],
)
return response.content[0].text.strip()
# ── Примеры ─────────────────────────────────────────────────────────
examples = [
"не работает таймаут",
"как сделать быстрее",
"а у asyncclient так же?",
"ошибка 429 что делать",
]
for q in examples:
rewritten = rewrite_query(q)
print(f"Было: {q}")
print(f"Стало: {rewritten}")
print()
# Было: не работает таймаут
# Стало: httpx ConnectTimeout exception: настройка и обработка таймаута
#
# Было: как сделать быстрее
# Стало: оптимизация производительности HTTP-запросов в httpx: connection pooling, keep-alive
#
# Было: а у asyncclient так же?
# Стало: настройка таймаутов в httpx AsyncClient — совпадает ли с синхронным Client?
#
# Было: ошибка 429 что делать
# Стало: httpx обработка HTTP 429 Too Many Requests: retry-стратегия и rate limiting
Техника 2: Multi-Query Retrieval
Rewriting улучшает один запрос, но проблема остаётся: один вектор — одна точка в пространстве. Multi-query генерирует N формулировок одного вопроса, выполняет поиск по каждой и объединяет результаты. Разные формулировки «накрывают» разные части пространства документов.
Ключевой момент: формулировки должны быть семантически разными, а не просто перестановкой слов. «httpx timeout config» и «configure httpx timeout» — это не multi-query, это шум. Настоящий multi-query: «httpx timeout config», «httpx ConnectTimeout exception handling», «httpx Timeout object parameters» — каждый вариант ищет с разного угла.
import asyncio
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
MULTI_QUERY_PROMPT = """\
Сгенерируй {n} разных формулировок запроса для поиска в документации.
Каждая формулировка должна подходить к теме с другого угла: другие термины,
другой уровень детализации, другой тип вопроса (как?, почему?, что такое?).
Верни только список формулировок — по одной в строке, без нумерации.
Исходный запрос: {query}"""
async def generate_queries(
query: str,
n: int = 4,
model: str = "claude-haiku-4-5-20251001",
) -> list[str]:
"""Генерирует N семантически разных вариантов запроса."""
response = await client.messages.create(
model=model,
max_tokens=256,
messages=[{
"role": "user",
"content": MULTI_QUERY_PROMPT.format(query=query, n=n),
}],
)
lines = response.content[0].text.strip().splitlines()
queries = [q.strip().lstrip("•-–").strip() for q in lines if q.strip()]
return [query] + queries[:n] # добавляем исходный запрос
async def multi_query_retrieve(
query: str,
retriever, # callable: query_str -> list[RetrievedChunk]
n_variants: int = 3,
top_k_per_query: int = 3,
) -> list:
"""
Retrieval по N формулировкам с дедупликацией по chunk_id.
"""
queries = await generate_queries(query, n=n_variants)
# Параллельный retrieval по всем формулировкам
tasks = [retriever(q, top_k=top_k_per_query) for q in queries]
results_per_query = await asyncio.gather(*tasks)
# Дедупликация: если один чанк нашёлся несколько раз — берём с лучшим score
seen: dict[str, object] = {}
for chunks in results_per_query:
for chunk in chunks:
chunk_id = chunk.chunk_id
if chunk_id not in seen or chunk.score > seen[chunk_id].score:
seen[chunk_id] = chunk
# Сортируем по score
return sorted(seen.values(), key=lambda c: c.score, reverse=True)
# ── Пример ─────────────────────────────────────────────────────────
async def main():
query = "не работает таймаут httpx"
queries = await generate_queries(query, n=4)
for i, q in enumerate(queries):
print(f"[{i}] {q}")
# [0] не работает таймаут httpx ← исходный
# [1] httpx.Timeout: настройка connect и read таймаутов
# [2] ConnectTimeout exception в httpx: причины и обработка
# [3] httpx клиент зависает при подключении: как установить лимит времени
# [4] параметры таймаута httpx: connect, read, write, pool
Техника 3: HyDE — Hypothetical Document Embeddings
HyDE (Gao et al., 2022) — нестандартный подход: вместо того чтобы искать по вектору запроса, генерируем гипотетический ответ и ищем по его вектору. Гипотетический ответ написан в том же стиле, что и документация, — поэтому его embedding находится ближе к реальным документам, чем embedding разговорного запроса.
Почему это работает: embedding двух технических текстов об одном и том же всегда ближе друг к другу, чем embedding вопроса и ответа. LLM не знает правильного ответа — гипотетический ответ может содержать ошибки — но это не важно. Важна его форма и терминология, которые сближают его с индексом.
from anthropic import Anthropic
from sentence_transformers import SentenceTransformer
client = Anthropic()
embed_model = SentenceTransformer("BAAI/bge-small-en-v1.5")
HYDE_PROMPT = """\
Напиши короткий (2–4 предложения) технический ответ на вопрос, как будто ты пишешь документацию.
Используй технические термины и конкретные примеры API.
Ответ может быть неточным — главное, чтобы он был сформулирован как документация.
Без заголовков, только текст.
Вопрос: {query}"""
def generate_hypothetical_document(query: str) -> str:
"""Генерирует гипотетический документ для HyDE."""
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=200,
messages=[{"role": "user", "content": HYDE_PROMPT.format(query=query)}],
)
return response.content[0].text.strip()
def hyde_retrieve(
query: str,
qdrant_client,
collection_name: str,
top_k: int = 5,
) -> list:
"""
Поиск через HyDE: генерируем гипотетический документ,
ищем по его embedding.
"""
# Шаг 1: Гипотетический ответ
hyp_doc = generate_hypothetical_document(query)
# Шаг 2: Embedding гипотетического документа
hyp_vec = embed_model.encode(
[hyp_doc], normalize_embeddings=True
)[0].tolist()
# Шаг 3: Поиск по этому вектору
hits = qdrant_client.search(
collection_name=collection_name,
query_vector=hyp_vec,
limit=top_k,
with_payload=True,
)
return hits
# ── Пример гипотетического документа ───────────────────────────────
query = "как настроить таймаут в httpx"
hyp = generate_hypothetical_document(query)
print(hyp)
# → "Для управления таймаутами в httpx используйте класс httpx.Timeout,
# который принимает отдельные параметры для каждого этапа: connect, read,
# write и pool. Передайте экземпляр в Client(timeout=...) или задайте
# per-request через client.get(url, timeout=5.0)."
Техника 4: Query Decomposition
Сложные вопросы содержат несколько подзапросов, каждый из которых требует собственных документов для ответа. Попытка ответить на весь вопрос сразу приводит к тому, что retrieval находит хорошие документы только для части вопроса.
import json
from anthropic import Anthropic
client = Anthropic()
DECOMPOSE_PROMPT = """\
Разбей сложный вопрос на простые подзапросы для поиска в документации.
Каждый подзапрос должен быть самодостаточным и отвечать на одну конкретную вещь.
Верни JSON-массив строк. Пример: ["подзапрос 1", "подзапрос 2"]
Максимум 4 подзапроса. Если вопрос уже простой — верни ["исходный вопрос"].
Вопрос: {query}"""
SYNTHESIZE_PROMPT = """\
На основе приведённых ответов на подзапросы дай исчерпывающий ответ на исходный вопрос.
Используй информацию из всех подответов. Ответ должен быть структурированным и логичным.
Исходный вопрос: {question}
Ответы на подзапросы:
{sub_answers}"""
def decompose_query(query: str) -> list[str]:
"""Разбивает сложный вопрос на подзапросы."""
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=256,
messages=[{"role": "user",
"content": DECOMPOSE_PROMPT.format(query=query)}],
)
try:
text = response.content[0].text.strip()
# Ищем JSON-массив в ответе
start = text.find("[")
end = text.rfind("]") + 1
return json.loads(text[start:end])
except Exception:
return [query] # fallback: исходный запрос
def decomposed_rag(
query: str,
rag_pipeline, # объект с методом .generate(query) -> str
) -> str:
"""
Разбивает запрос, отвечает на каждый подзапрос, синтезирует итог.
"""
sub_queries = decompose_query(query)
if len(sub_queries) == 1:
# Простой вопрос — обычный RAG
return rag_pipeline.generate(sub_queries[0]).answer
# Ответы на каждый подзапрос
sub_answers = []
for sq in sub_queries:
result = rag_pipeline.generate(sq)
if not result.skipped:
sub_answers.append(f"Вопрос: {sq}\nОтвет: {result.answer}")
if not sub_answers:
return "Информация по данному вопросу не найдена в документации."
# Синтез финального ответа
synthesis = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": SYNTHESIZE_PROMPT.format(
question=query,
sub_answers="\n\n---\n\n".join(sub_answers),
),
}],
)
return synthesis.content[0].text
# ── Пример ─────────────────────────────────────────────────────────
query = "в чём разница между httpx и requests и как мигрировать?"
sub_queries = decompose_query(query)
print(sub_queries)
# → ["сравнение httpx и requests: функции и производительность",
# "API httpx vs requests: ключевые отличия",
# "миграция с requests на httpx: пошаговое руководство"]
Техника 5: Step-Back Prompting
Step-back (Zheng et al., 2023) — движение от конкретики к общему. Пользователь спрашивает о конкретной детали («как передать timeout в конкретный метод»), но чтобы ответить правильно, нужно сначала понять общую концепцию («как вообще работает объект Timeout в httpx»). Техника дополняет конкретный поиск поиском по более общему вопросу.
from anthropic import Anthropic
client = Anthropic()
STEP_BACK_PROMPT = """\
Сформулируй более общий (step-back) вопрос, который охватывает тему данного вопроса.
Цель: найти концептуальную информацию, которая поможет ответить на конкретный вопрос.
Конкретный вопрос: {query}
Общий вопрос (1 строка, без пояснений):"""
def step_back_query(query: str) -> str:
"""Генерирует обобщённый вариант запроса."""
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=80,
messages=[{"role": "user",
"content": STEP_BACK_PROMPT.format(query=query)}],
)
return response.content[0].text.strip()
def step_back_retrieve(
query: str,
retriever,
top_k: int = 3,
) -> list:
"""
Retrieval по двум запросам: конкретному и обобщённому.
Результаты объединяются с дедупликацией.
"""
# Оба запроса
general_query = step_back_query(query)
specific_chunks = retriever(query, top_k=top_k)
general_chunks = retriever(general_query, top_k=top_k)
# Дедупликация: конкретные документы приоритетнее
seen: dict[str, object] = {}
for chunk in specific_chunks + general_chunks:
if chunk.chunk_id not in seen:
seen[chunk.chunk_id] = chunk
return list(seen.values())
# ── Пример ─────────────────────────────────────────────────────────
q = "как передать timeout только для одного запроса в httpx?"
step_back = step_back_query(q)
print(f"Исходный: {q}")
print(f"Step-back: {step_back}")
# Step-back: «как работает механизм таймаутов в httpx — уровни Client и Request?»
RAG-Fusion: Multi-Query + Reciprocal Rank Fusion
RAG-Fusion (Rackauckas, 2023) объединяет multi-query с алгоритмом Reciprocal Rank Fusion (RRF). Идея: документы, которые попали высоко в ранжировании по нескольким запросам, с высокой вероятностью действительно релевантны — это сигнал сильнее, чем один высокий score.
Формула RRF для документа d по k запросам:
RRF(d) = Σ 1 / (60 + rank_i(d))
i=1..k
rank_i(d) — позиция документа d в результатах i-го запроса
60 — константа сглаживания (уменьшает вес топ-1 позиции)
k — количество запросов
Константа 60 подобрана эмпирически в оригинальной статье по RRF (Cormack et al., 2009). Она «сглаживает» первые позиции: документ на 1-м месте не получает в 60 раз больший вес, чем документ на 60-м, что делало бы алгоритм слишком агрессивным.
| Документ | Запрос 1 «httpx timeout» |
Запрос 2 «ConnectTimeout exc» |
Запрос 3 «connect read pool» |
RRF score |
|---|---|---|---|---|
| httpx-guide.md §Timeout | rank 1 → 1/61 | rank 1 → 1/61 | rank 2 → 1/62 | 0.0484 |
| httpx-async.md §AsyncClient | rank 2 → 1/62 | rank 5 → 1/65 | rank 3 → 1/63 | 0.0468 |
| httpx-errors.md §Exceptions | rank 6 → 1/66 | rank 2 → 1/62 | rank 7 → 1/67 | 0.0316 |
| requests-guide.md §timeout | rank 3 → 1/63 | rank 8 → — | rank 9 → — | 0.0159 |
import asyncio
from collections import defaultdict
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
def reciprocal_rank_fusion(
results_per_query: list[list],
k: int = 60,
) -> list:
"""
Объединяет ранжированные списки через RRF.
results_per_query: список списков чанков,
каждый список отсортирован по score (лучший первый).
k: константа сглаживания (обычно 60)
"""
scores: dict[str, float] = defaultdict(float)
chunks_by_id: dict[str, object] = {}
for ranked_list in results_per_query:
for rank, chunk in enumerate(ranked_list, start=1):
chunk_id = chunk.chunk_id
scores[chunk_id] += 1.0 / (k + rank)
# Сохраняем объект чанка (первое вхождение)
if chunk_id not in chunks_by_id:
chunks_by_id[chunk_id] = chunk
# Сортируем по RRF score
sorted_ids = sorted(scores, key=lambda cid: scores[cid], reverse=True)
result = []
for cid in sorted_ids:
chunk = chunks_by_id[cid]
chunk.rrf_score = scores[cid] # добавляем RRF score для отладки
result.append(chunk)
return result
async def rag_fusion_retrieve(
query: str,
retriever, # async callable: (query, top_k) -> list[Chunk]
n_variants: int = 3,
top_k_per_query: int = 5,
) -> list:
"""
Полный RAG-Fusion pipeline:
1. Генерация N вариантов запроса
2. Параллельный retrieval по каждому
3. RRF-объединение результатов
"""
# Генерация вариантов запроса
queries = await generate_queries(query, n=n_variants)
# Параллельный retrieval
tasks = [retriever(q, top_k=top_k_per_query) for q in queries]
all_results = await asyncio.gather(*tasks)
# RRF fusion
fused = reciprocal_rank_fusion(list(all_results))
# Возвращаем топ-5 по RRF
return fused[:5]
# ── Пример ─────────────────────────────────────────────────────────
async def main():
query = "как настроить таймауты в httpx"
chunks = await rag_fusion_retrieve(query, retriever=my_retriever)
for c in chunks:
print(f"RRF {c.rrf_score:.4f} | {c.source} | {c.text[:60]}...")
asyncio.run(main())
Когда применять каждую технику
| Техника | Тип запросов | Latency | Стоимость | Ограничения |
|---|---|---|---|---|
| Query Rewriting | Разговорные, неформальные, с местоимениями | +200–500 мс | минимум | Не расширяет охват, только улучшает один запрос |
| Multi-Query | Широкие темы, несколько аспектов в вопросе | +400–800 мс | N × retrieval | Увеличивает шум при слабом corpusе |
| HyDE | Технические вопросы с однозначной формой ответа | +300–700 мс | 1 × LLM | Опасен при неточных гипотетических ответах |
| Decomposition | Многосоставные, сравнительные, «объясни и покажи» | +1–3 с | N × (LLM + retrieval) | Избыточен для простых вопросов |
| Step-Back | Конкретные вопросы про детали API, конфигурацию | +400–700 мс | 1 × LLM, 2× retrieval | Может приносить слишком общие документы |
| RAG-Fusion | Когда важно не пропустить релевантные документы | +600–1200 мс | 1 × LLM, N × retrieval | Сложнее отлаживать; не ускоряет, только улучшает recall |
Практическая рекомендация по умолчанию:
Компоновка: rewriting + multi-query в одном pipeline
import re
from dataclasses import dataclass
from enum import Enum
class Strategy(Enum):
SIMPLE = "simple" # только rewriting
MULTI = "multi" # multi-query
HYDE = "hyde" # HyDE
DECOMPOSE = "decompose" # decomposition
def classify_query(query: str) -> Strategy:
"""
Простая эвристика для выбора стратегии.
В продакшне можно заменить на LLM-классификатор.
"""
q = query.lower()
# Составные / сравнительные → декомпозиция
compound_signals = [" и как ", " и почему ", "чем отличается", "сравни ", "разница между"]
if any(s in q for s in compound_signals):
return Strategy.DECOMPOSE
# Короткие (< 4 слов) → multi-query для расширения охвата
if len(q.split()) < 4:
return Strategy.MULTI
# Конкретные технические вопросы → HyDE
tech_signals = [".py", "class ", "method", "параметр", "аргумент", "exception"]
if any(s in q for s in tech_signals):
return Strategy.HYDE
# По умолчанию — простой rewriting
return Strategy.SIMPLE
@dataclass
class TransformResult:
strategy: Strategy
queries: list[str] # один или несколько запросов для retrieval
original: str
async def transform_query(
query: str,
strategy: Strategy | None = None,
) -> TransformResult:
"""
Трансформирует запрос по выбранной (или автоопределённой) стратегии.
"""
if strategy is None:
strategy = classify_query(query)
if strategy == Strategy.SIMPLE:
rewritten = rewrite_query(query)
return TransformResult(strategy, [rewritten], query)
elif strategy == Strategy.MULTI:
queries = await generate_queries(query, n=3)
return TransformResult(strategy, queries, query)
elif strategy == Strategy.HYDE:
hyp_doc = generate_hypothetical_document(query)
# Для HyDE возвращаем гипотетический документ как «запрос»
return TransformResult(strategy, [hyp_doc], query)
elif strategy == Strategy.DECOMPOSE:
sub_queries = decompose_query(query)
return TransformResult(strategy, sub_queries, query)
return TransformResult(Strategy.SIMPLE, [query], query)
# ── Использование ───────────────────────────────────────────────────
async def main():
queries = [
"не работает таймаут", # → MULTI
"как передать httpx.Timeout в AsyncClient.get() method", # → HYDE
"чем httpx отличается от requests и как мигрировать?", # → DECOMPOSE
"как настроить таймаут соединения в httpx", # → SIMPLE
]
for q in queries:
result = await transform_query(q)
print(f"[{result.strategy.value}] {q!r}")
for tq in result.queries:
print(f" → {tq!r}")
print()
asyncio.run(main())
Шпаргалка
Пять техник:
• Rewriting — 1→1, перефразировка. Всегда включать. +200–500 мс.
• Multi-Query — 1→N, разные углы. При низком recall. +400–800 мс, N×retrieval.
• HyDE — запрос→гипотетический ответ→поиск. Для technical docs. +300–700 мс.
• Decomposition — сложный→подзапросы→синтез. Для многосоставных вопросов. +1–3 с.
• Step-Back — конкретный + обобщённый поиск. Для деталей API. +400–700 мс, 2×retrieval.
RAG-Fusion = Multi-Query + Reciprocal Rank Fusion. RRF: score = Σ 1/(60 + rank_i).
Документы, высоко ранжированные по нескольким запросам, получают наибольший вес.
Выбор стратегии:
• Короткий / разговорный → Multi-Query
• «как работает X method?» → HyDE
• «чем A отличается от B?» или «сравни» → Decomposition
• По умолчанию → Rewriting
Используй дешёвую модель (haiku) для трансформации — это preprocessing, не финальная генерация. Экономия 5–10× по стоимости.
Практика
- Измерьте эффект rewriting. Возьмите 20 реальных запросов к вашей системе. Для каждого запустите retrieval с исходным запросом и с переформулированным. Вручную оцените top-3 результата по шкале 1–3. Посчитайте средний score. Типичный прирост — 0.2–0.5 балла.
-
Реализуйте эвристический классификатор запросов.
Расширьте
classify_query(): добавьте детекцию вопросов со словами «ошибка», «падает», «не работает» (→ rewriting с контекстом ошибки), вопросов-сравнений «лучше/хуже чем» (→ decomposition), очень коротких (1–2 слова) запросов (→ multi-query + step-back). - Сравните HyDE vs прямой поиск. На 10 технических вопросах запустите поиск: (а) напрямую, (б) через HyDE. Для каждого варианта посмотрите топ-3 чанка и оцените их релевантность вручную. В каких случаях HyDE лучше? Когда хуже?