Проблема: запросы пользователей плохи для поиска

Векторный поиск ищет семантически близкие документы. Но «семантически близкий» определяется моделью embedding — и эта модель обучена на определённом распределении текстов. Чанки в индексе — это фрагменты документации, написанной техническим языком, полными предложениями. Запросы пользователей — это нечто совсем другое.

Реальные запросы пользователей
«не работает таймаут» «как сделать быстрее» «ошибка 429» «а у asyncclient так же?» «питон код для retry» «почему падает при большой нагрузке»
Короткие, разговорные, неполные. Часть — ссылки на предыдущий контекст.
Как выглядят чанки в индексе
«Для настройки таймаутов используйте объект httpx.Timeout(connect=5.0, read=10.0). Параметр pool задаёт максимальное время ожидания свободного соединения в пуле.»
Полные предложения, технический язык, явные термины.

Разрыв между этими двумя типами текста — vocabulary mismatch — снижает точность поиска. Embedding запроса «не работает таймаут» и embedding чанка «httpx.Timeout(connect=5.0)» разделяет больший угол, чем должен быть. Поиск находит менее релевантные документы или не находит ничего.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
БЕЗ ТРАНСФОРМАЦИИ документация запрос «не работает таймаут» большое расстояние → поиск находит нерелевантные документы С ТРАНСФОРМАЦИЕЙ документация «не работает таймаут» «httpx timeout config» «httpx.Timeout parameters» гипотетический ответ → несколько запросов покрывают кластер документов Query transformation сближает пространство запросов с пространством документов — или расширяет охват несколькими вариантами

Vocabulary mismatch: почему слова не совпадают

Vocabulary mismatch — классическая проблема информационного поиска (IR), существовавшая задолго до нейросетей. Пользователь описывает свою потребность одними словами, автор документа описал решение другими словами. Оба правы — просто у них разный словарный запас.

Три типа vocabulary mismatch в RAG:

1
Терминологический
Пользователь: «не работает» → документ: «raises ConnectTimeout exception». Пользователь: «медленно» → документ: «latency, throughput, connection pool exhaustion». Смысл один, слова разные — bi-encoder находит их далёкими.
2
Уровень абстракции
Пользователь задаёт конкретный вопрос («как установить connect timeout в секундах»), документ объясняет концепцию («объект Timeout принимает параметры в виде float»). Конкретика и абстракция занимают разные места в embedding-пространстве.
3
Разговорный vs формальный стиль
Запросы в чате максимально неформальны и сжаты. Документация пишется полными предложениями в техническом стиле. Даже семантически близкие тексты могут иметь значительно разные embeddings из-за разницы стиля и регистра.

Dense retrieval (bi-encoder) значительно лучше справляется с vocabulary mismatch, чем BM25 — но не устраняет его полностью. Query transformation — следующий уровень: мы не ждём, что embedding сам преодолеет разрыв, а активно сближаем запрос с документами.

Пять техник трансформации

Каждая техника атакует vocabulary mismatch по-своему. Одни перефразируют запрос, другие генерируют несколько вариантов, третьи создают «гипотетический документ» и ищут по нему. Выбор зависит от типа запросов и допустимой latency.

Техника 1
Query Rewriting
Один запрос → один переформулированный. LLM делает запрос более явным, убирает разговорный тон, добавляет ключевые термины.
+1 LLM-вызов · latency +200–500 мс
Техника 2
Multi-Query
Один запрос → N вариантов формулировок. Поиск по каждому, дедупликация результатов. Шире покрытие.
+1 LLM · N×retrieval · latency +400–800 мс
Техника 3
HyDE
Hypothetical Document Embeddings. LLM генерирует гипотетический ответ на вопрос, его embedding используется для поиска.
+1 LLM · 1×retrieval · latency +300–700 мс
Техника 4
Decomposition
Сложный вопрос разбивается на подзапросы. Каждый ищет и отвечает самостоятельно. Ответы синтезируются вместе.
+1 LLM · N×(retrieval+gen) · latency +1–3 с
Техника 5
Step-Back
От конкретного вопроса к общему. «Как настроить X?» → «Как работает X?». Находит концептуальные объяснения, а не только инструкции.
+1 LLM · 2×retrieval · latency +400–700 мс

Техника 1: Query Rewriting

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

Исходный
«не работает таймаут, что делать?»
После rewriting
«httpx ConnectTimeout exception: как настроить и обработать таймаут соединения»
Что изменилось
Добавлен контекст (httpx), уточнён тип проблемы, добавлены технические термины
python — Query Rewriting
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» — каждый вариант ищет с разного угла.

python — Multi-Query: генерация вариантов + дедупликация
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 разговорного запроса.

Запрос
«как настроить таймаут в httpx»
Гипотетический ответ (HyDE)
«Для настройки таймаута используйте httpx.Timeout. Передайте объект в Client или AsyncClient. Параметры: connect — время на установку соединения, read — на чтение ответа. Пример: httpx.Client(timeout=httpx.Timeout(connect=5.0, read=10.0))»
Поиск
По embedding гипотетического ответа, а не запроса. Вектор теперь «рядом» с реальной документацией.

Почему это работает: embedding двух технических текстов об одном и том же всегда ближе друг к другу, чем embedding вопроса и ответа. LLM не знает правильного ответа — гипотетический ответ может содержать ошибки — но это не важно. Важна его форма и терминология, которые сближают его с индексом.

python — HyDE: поиск по гипотетическому ответу
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)."
HyDE: подводные камни. Если LLM генерирует уверенный, но полностью неверный ответ — embedding уведёт поиск не туда. Особенно опасно в узкоспециализированных или быстро меняющихся доменах. HyDE лучше всего работает для технических вопросов с однозначной формой ответа (API-документация). Для фактологических вопросов («какая версия X была выпущена в 2024?») — использовать с осторожностью.

Техника 4: Query Decomposition

Сложные вопросы содержат несколько подзапросов, каждый из которых требует собственных документов для ответа. Попытка ответить на весь вопрос сразу приводит к тому, что retrieval находит хорошие документы только для части вопроса.

Сложный запрос
«В чём разница между httpx и requests, и как мигрировать с requests на httpx?»
Подзапрос 1
«сравнение httpx и requests: функциональность, производительность, API»
Подзапрос 2
«миграция с requests на httpx: изменения API, совместимость»
Итог
Каждый подзапрос ищет релевантные чанки независимо, затем LLM синтезирует финальный ответ
python — Query Decomposition: подзапросы с последовательными ответами
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»). Техника дополняет конкретный поиск поиском по более общему вопросу.

Конкретный запрос
«как передать timeout только для одного запроса в httpx, не для всего клиента?»
Step-back запрос
«как работает система таймаутов в httpx — глобально и per-request?»
Поиск
По обоим запросам, результаты объединяются. Концептуальный чанк даёт контекст для ответа на конкретный вопрос.
python — Step-back: конкретный + обобщённый поиск
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
python — RAG-Fusion: multi-query + RRF
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

Практическая рекомендация по умолчанию:

Всегда включать Query Rewriting
Минимальная стоимость, заметный прирост качества. Работает для любого типа запросов. Особенно важно если ваши пользователи пишут через чат-интерфейс.
?
Multi-Query / RAG-Fusion — при низком recall
Если после rewriting пользователи жалуются «не нашёл по теме» — добавляйте multi-query. Измерьте recall до и после: если растёт на >10% — оправдано.
?
HyDE — при technical documentation с rich terminology
Библиотечная документация, API-справочники, RFC — HyDE работает хорошо. Для новостей, FAQ, разговорных корпусов — тестируйте осторожно.
Decomposition — только для сложных составных вопросов
Определяйте тип вопроса заранее (простой/сложный) — через heuristic (наличие «и», «или», «чем отличается») или отдельный LLM-классификатор. Не применяйте к простым вопросам.

Компоновка: rewriting + multi-query в одном pipeline

python — QueryTransformer: rewriting + multi-query с выбором стратегии
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())

Шпаргалка

Зачем нужна трансформация: пользователи пишут разговорным языком, документы — техническим. Vocabulary mismatch снижает точность retrieval. Трансформация сближает их.

Пять техник:
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× по стоимости.

Практика

  1. Измерьте эффект rewriting. Возьмите 20 реальных запросов к вашей системе. Для каждого запустите retrieval с исходным запросом и с переформулированным. Вручную оцените top-3 результата по шкале 1–3. Посчитайте средний score. Типичный прирост — 0.2–0.5 балла.
  2. Реализуйте эвристический классификатор запросов. Расширьте classify_query(): добавьте детекцию вопросов со словами «ошибка», «падает», «не работает» (→ rewriting с контекстом ошибки), вопросов-сравнений «лучше/хуже чем» (→ decomposition), очень коротких (1–2 слова) запросов (→ multi-query + step-back).
  3. Сравните HyDE vs прямой поиск. На 10 технических вопросах запустите поиск: (а) напрямую, (б) через HyDE. Для каждого варианта посмотрите топ-3 чанка и оцените их релевантность вручную. В каких случаях HyDE лучше? Когда хуже?