Гибридный поиск в RAG: как мы подняли Top-1 с 62% до 88% на базе из 50 000 документов

от автора

Если вы строили RAG, вы знаете эту боль: вектор-поиск красиво работает на демо-вопросах, но в продакшене теряется на номерах договоров, артикулах и аббревиатурах. Я расскажу, как мы добавили к чисто векторному поиску BM25, слили два ранкинга через RRF и поставили сверху cross-encoder. На внутренней базе из 50 000+ корпоративных документов это подняло Top-1 с примерно 62% до 88%, а время ответа осталось в районе 2–4 секунд.

Будет немного теории, формула RRF, рабочий код на Python и таблица «было/стало». Все цифры — реальные, из проекта по автоматизации документооборота.

Почему чистый векторный поиск ломается

Embedder понимает смысл. Это сильная сторона и одновременно слабая. Когда пользователь пишет «договор аренды офиса на Тверской», вектор-поиск находит близкие по смыслу документы и выдаёт релевантный топ. Всё хорошо.

Но запрос «найди договор № **-2024/17» превращается embedder-ом в плотный вектор, у которого с другими номерами договоров косинусная близость почти такая же. Точное совпадение по строке для векторного поиска — не цель, он этого не умеет.

Симптомы в продакшене:

  • релевантные документы есть в базе, но в топ-5 их нет

  • запросы с аббревиатурами (ИНН, КПП, ОКВЭД) промахиваются стабильно

  • метрика Top-1 сидит на ~60% и не растёт, сколько ни тюнь embedder

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

RRF: Reciprocal Rank Fusion простыми словами

Идея: каждый источник (BM25 и vector search) даёт свой ранкинг. Документ, который оказался высоко в обоих списках, должен подняться в финале. Документ, который высоко только в одном — должен опуститься.

Формула:

score(d) = sum по всем источникам i:  1 / (k + rank_i(d))

Где rank_i(d) — позиция документа d в ранкинге источника i, а k — сглаживающий параметр (стандарт — 60). Чем меньше rank, тем больше вклад. Документ, который занял первое место сразу в обоих ранкингах, получает максимум.

Почему именно так:

  • RRF не требует обучения, никаких гиперпараметров кроме k

  • Не нужно нормализовать сырые скоры (косинус и BM25 живут в разных шкалах — нормализация всегда субъективна и ломается при смене модели)

  • RRF не требует обучения, никаких гиперпараметров кроме k

  • Алгоритм устойчив к разным длинам ранкингов

В литературе показано, что RRF почти всегда обгоняет наивные стратегии вроде «бери максимум скора» или «усредняй». На наших данных это подтвердилось.

Cross-encoder сверху: последнее сито

Embedder работает в bi-encoder режиме: кодирует запрос и документ независимо, сравнивает векторы. Это быстро, но недостаточно точно.

Cross-encoder читает пару (запрос, документ) одновременно через один transformer и выдаёт скаляр-скор. Намного точнее, но в 100+ раз медленнее. Поэтому его нельзя пускать на всю базу — только на топ-20–30 кандидатов после RRF.

Эта связка — стандарт индустрии для high-precision поиска: дешёвые методы режут базу до 20 кандидатов, дорогая модель выбирает финальные top-k.

Код

Минимальный рабочий конвейер на Python с asyncio. Embedder и реранкер — любые подходящие, у нас BAAI/bge-m3 и BAAI/bge-reranker-v2-m3.

from collections import defaultdictasync def hybrid_search(query: str, k: int = 8) -> list[Chunk]:    # 1. Семантический поиск через Qdrant    q_vec = await embedder.encode(query)    dense = await qdrant.search(        collection="docs",        vector=q_vec,        limit=k * 3,           # берём с запасом для дальнейшей фильтрации        with_payload=True,    )    # 2. Лексический поиск BM25 — ловит точные термины    sparse = bm25_index.search(query, top_k=k * 3)    # 3. RRF — честное слияние двух ранкингов    fused = reciprocal_rank_fusion([dense, sparse], k=60)    # 4. Cross-encoder реранкер — финальное сито    reranked = await reranker.rerank(query, fused[:30], top_k=k)    # 5. Аудит: логируем для последующего анализа качества    log_retrieval(query, reranked, scores=[c.score for c in reranked])    return rerankeddef reciprocal_rank_fusion(rankings, k=60):    """Документ X получает балл = sum(1 / (k + rank_i(X))) по всем источникам."""    scores = defaultdict(float)    docs_by_id = {}    for ranking in rankings:        for rank, doc in enumerate(ranking):            scores[doc.id] += 1.0 / (k + rank)            docs_by_id[doc.id] = doc    sorted_ids = sorted(scores, key=scores.get, reverse=True)    return [docs_by_id[i] for i in sorted_ids]

Что важно в этом коде:

limit=k * 3 для каждого источника — RRF любит хвосты, не стоит резать слишком жадно

fused[:30] перед реранкером — компромисс между качеством и латентностью; на 30 кандидатах реранкер укладывается в 700 мс на GPU

log_retrieval сохраняет запрос, выданный топ и скоры. Без этого вы не сможете отлаживать качество — это один из главных уроков продакшена

Архитектура одной картинкой

Запрос → параллельно (Qdrant dense, BM25 sparse) → RRF слияние → cross-encoder реранкер → top-k → ответ LLM с цитированием.

Было / Стало. Метрики на одном и том же наборе из 200 размеченных запросов от реальных пользователей.

Метрика

Только vector

+ BM25 + RRF

+ cross-encoder

Top-1 hit rate

62%

79%

88%

Top-5 hit rate

84%

93%

96%

Mean p50 latency

1.1 сек

1.3 сек

2.4 сек

Mean p95 latency

1.8 сек

2.0 сек

3.9 сек

То есть половина прироста точности приходится на BM25+RRF и не стоит почти ничего по латентности. Вторая половина — cross-encoder, и он добавляет около секунды. На наших нагрузках это приемлемо.

Что пошло не так и как мы это починили

Три урока, которые я бы хотел получить заранее.

  1. Первое. BM25 на русском языке требует нормальной токенизации. Дефолтный whitespace-токенайзер режет «Договор», «договоры», «договору» как три разных слова — рецал падает. Решили через pymorphy2 для лемматизации перед индексацией.

  2. Второе. Реранкер любит длинный контекст. Если чанки слишком короткие (например, по 256 токенов), он теряет смысл и выдаёт случайные скоры. Подняли чанки до 800 токенов с overlap 100 — стало стабильно.

  3. Третье. Кэш на уровне (query, top_k_ids) экономит до 30% запросов. Корпоративные пользователи задают одни и те же вопросы. Простой Redis с TTL на сутки — и нагрузка на reranker падает почти в два раза.

Когда это не нужно

Если у вас база до 1000 документов — хватит обычного векторного поиска с хорошим embedder-ом. Гибрид имеет смысл начиная примерно с 10 000 документов и/или когда в запросах часто встречаются точные термины (артикулы, номера, коды).

Если важна латентность ниже 1 секунды — выкидывайте cross-encoder и оставайтесь на RRF, теряя около 9 процентных пунктов Top-1.

Итог

Гибридный поиск + RRF + cross-encoder — это не магия, а склейка трёх известных техник в правильном порядке. У нас это подняло Top-1 c 62% до 88% и сэкономило отделу около 120 человеко-часов в неделю, потому что нужный документ находится с первого раза.

Если есть вопросы по конкретному шагу — спрашивайте в комментариях, отвечу. В следующей статье разберу LoRA-дообучение модели на 8B параметров на одной RTX 3090 за 6 часов.

ссылка на оригинал статьи https://habr.com/ru/articles/1042980/