Если вы строили 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, и он добавляет около секунды. На наших нагрузках это приемлемо.
Что пошло не так и как мы это починили
Три урока, которые я бы хотел получить заранее.
-
Первое. BM25 на русском языке требует нормальной токенизации. Дефолтный whitespace-токенайзер режет «Договор», «договоры», «договору» как три разных слова — рецал падает. Решили через pymorphy2 для лемматизации перед индексацией.
-
Второе. Реранкер любит длинный контекст. Если чанки слишком короткие (например, по 256 токенов), он теряет смысл и выдаёт случайные скоры. Подняли чанки до 800 токенов с overlap 100 — стало стабильно.
-
Третье. Кэш на уровне (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/