Учебник

Гибридный поиск: AI находит нужный документ в 9 из 10 случаев

Ваши менеджеры тратят часы на поиск ответов в документации, а AI-ассистент даёт неполные ответы? Разбираем гибридный поиск и реранкер - схему, которая поднимает точность поиска с 59% до 82%. Без найма программистов и сложных курсов.

Макс Космов··9 мин чтения

У вас есть база знаний: прайсы, договоры, инструкции, ответы на частые вопросы. Вы подключаете AI-ассистента, чтобы сотрудники и клиенты быстро находили нужное. Но он отвечает не то: путает артикулы, не видит точные коды, выдаёт похожий, но не тот документ. Знакомо?

Проблема в поиске. Один метод - семантический поиск (по смыслу) - отлично находит синонимы, но проваливается на точных совпадениях: артикул «E-7234» он может перепутать с «код 7234». Другой метод - поиск по ключевым словам (BM25) - находит точные совпадения, но не понимает, что «ускорить запросы» и «оптимизация производительности» - одно и то же. По отдельности каждый даёт до 40% промахов.

Решение: гибридный поиск (оба метода вместе) + реранкер (нейронная сеть, которая дочищает результаты). Итог: Recall@5 (доля запросов, где нужный фрагмент попал в топ-5) = 0.816 вместо 0.587. Это значит: из 10 запросов ассистент находит правильный документ в 8-9 случаях, а не в 6.

Разберём на примере стройфирмы. У вас есть база с типовыми договорами подряда, прайсами на материалы и инструкциями по монтажу. Клиент спрашивает: «какой фильтр для воды ставить при жёсткости 8 мг-экв/л?». Семантический поиск найдёт документ про «умягчение воды», но может пропустить пункт с конкретной моделью «Аквафор DWM-101S». А ключевой поиск найдёт «DWM-101S», но не поймёт, что речь о фильтре от жёсткости. Гибрид берёт лучшее от обоих, а реранкер ставит наверх самый точный фрагмент.

Как работает BM25 и зачем он бизнесу

BM25 (Best Match 25) - алгоритм 1994 года, который до сих пор бьёт нейронные сети на точных совпадениях. Если ваши пользователи ищут по артикулам, именам, кодам - BM25 обязателен. Без него половина таких запросов уйдёт в пустоту.

Формула BM25 для документа D и запроса Q:

BM25(Q, D) = Σ IDF(qi) * (f(qi, D) * (k1+1)) / (f(qi, D) + k1*(1 - b + b*|D|/avgdl))

Где:

  • f(qi, D) - частота слова qi в документе D
  • |D| - длина документа в словах
  • avgdl - средняя длина документа в корпусе
  • k1=1.2 - насыщение частоты: каждое повторение слова даёт убывающую отдачу
  • b=0.75 - нормализация по длине: b=1.0 полная нормализация, b=0 нет нормализации

Для русского языка BM25 нужна лемматизация (приведение слов к начальной форме), иначе «фильтр», «фильтры», «фильтром» будут разными токенами, и поиск пропустит часть результатов.

Этот код создаёт BM25-индекс для корпуса русских текстов. Сначала лемматизирует каждый токен через pymorphy3, потом строит индекс. Функция bm25_search возвращает индексы и баллы лучших документов. Если у вас нет программиста - попросите фрилансера настроить этот скрипт один раз.

from rank_bm25 import BM25Okapi
import pymorphy3

morph = pymorphy3.MorphAnalyzer()

def normalize_ru(text: str) -> list[str]:
 tokens = text.lower().split()
 return [
 morph.parse(token)[0].normal_form
 for token in tokens
 if len(token) > 2
 ]

corpus = [chunk.page_content for chunk in chunks]
tokenized_corpus = [normalize_ru(doc) for doc in corpus]

bm25 = BM25Okapi(tokenized_corpus, k1=1.2, b=0.75)

def bm25_search(query: str, top_k: int = 20) -> list[tuple[int, float]]:
 tokenized_query = normalize_ru(query)
 scores = bm25.get_scores(tokenized_query)
 top_indices = scores.argsort()[-top_k:][::-1]
 return [(idx, scores[idx]) for idx in top_indices]

RRF: как объединить два списка результатов без магических весов

RRF (Reciprocal Rank Fusion, ранговое слияние через обратный ранг) - метод объединения нескольких списков без нормализации и взвешивания баллов. Балл BM25 измеряется в одних единицах, косинусная похожесть - в других. Напрямую их не сложишь. RRF берёт только позицию в списке, а не абсолютный балл.

Формула:

RRF_score(d) = Σ 1/(k + rank_i(d))

Где k=60 - константа, смягчающая влияние топовых позиций. rank_i(d) - позиция документа в i-том списке.

Преимущество: ранг 1 - это ранг 1, независимо от того, какой балл стоит за ним в BM25 или семантическом поиске.

Этот код принимает несколько списков и возвращает объединённый, отсортированный по суммарному RRF-баллу. Потом берём топ-20 из обоих методов, конвертируем в формат [(id, score)] и передаём в функцию слияния.

from collections import defaultdict
from typing import List, Tuple, Dict

def reciprocal_rank_fusion(
 rankings: List[List[Tuple[str, float]]],
 k: int = 60
) -> List[Tuple[str, float]]:
 fused_scores: Dict[str, float] = defaultdict(float)

 for ranking in rankings:
 for rank, (doc_id, score) in enumerate(ranking, start=1):
 fused_scores[doc_id] += 1.0 / (k + rank)

 return sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)

dense_results = vectorstore.similarity_search_with_score(query, k=20)
bm25_results = bm25_search(query, top_k=20)

dense_ranking = [(r.metadata["chunk_id"], score) for r, score in dense_results]
bm25_ranking = [(corpus_ids[idx], score) for idx, score in bm25_results]

hybrid_results = reciprocal_rank_fusion([dense_ranking, bm25_ranking])
top_20_hybrid = hybrid_results[:20]

Важная деталь: запрашиваем топ-20 из каждой системы, а не топ-5. Нужный документ может быть на позиции 15 у BM25 и на 8 у семантического поиска. После RRF он окажется в итоговом топ-5.

Cohere Rerank 3.5: готовый реранкер через API

Реранкер - это вторая ступень после поиска. Поиск находит 20 кандидатов за миллисекунды. Реранкер проверяет каждого кандидата в паре с запросом и расставляет их точнее. Аналогия: первичный скрининг резюме (быстро, грубо) и собеседование (медленно, точно). Для бизнеса реранкер - это разница между «ответ из правильного документа» и «ответ из похожего, но не того».

Cohere Rerank 3.5 - нейронный реранкер через API. Получает пару (запрос, документ) и возвращает балл релевантности от 0 до 1. Не нужно разворачивать и поддерживать свою модель.

Ключевые параметры:

  • Задержка: p50 около 200 мс, p95 около 400 мс для 20 документов.
  • Стоимость: $2 за 1000 поисков (при реранкинге топ-20).
  • Максимальная длина документа: 4096 токенов.
  • Русский язык: поддерживается хорошо.

Этот код отправляет 20 кандидатов из гибридного поиска в Cohere API и получает обратно топ-5 с баллами релевантности. Параметр top_n=5 означает: вернуть только 5 лучших.

import cohere

co = cohere.Client(api_key="your-key")

candidates = [chunks[idx].page_content for idx, _ in top_20_hybrid]

rerank_response = co.rerank(
 model="rerank-v3.5",
 query=user_query,
 documents=candidates,
 top_n=5,
 return_documents=True
)

final_chunks = [
 result.document.text
 for result in rerank_response.results
]

print(f"Relevance scores: {[r.relevance_score for r in rerank_response.results]}")
# [0.89, 0.84, 0.71, 0.68, 0.55]

BGE Reranker v2-m3: свой реранкер с нулевой стоимостью за запрос

BGE Reranker v2-m3 от BAAI - open-source реранкер. При развёртывании на своём сервере стоимость за запрос - ноль. Данные не покидают инфраструктуру компании. Для банков, медицины и юриспруденции с требованиями к конфиденциальности - это ключевое преимущество.

Характеристики:

  • Качество: сравнимо с Cohere Rerank на большинстве задач.
  • Задержка на GPU A10G (24 ГБ): p50 около 80 мс для 20 документов.
  • Задержка на CPU (8 ядер): p50 около 600 мс - уже граничит с SLA.
  • Память: около 2.5 ГБ для модели.

Этот код загружает BGE Reranker, формирует пары «запрос + документ» для каждого кандидата, получает баллы релевантности и сортирует по ним. Параметр normalize=True переводит баллы в диапазон 0-1 для удобства сравнения.

from FlagEmbedding import FlagReranker

reranker = FlagReranker(
 'BAAI/bge-reranker-v2-m3',
 use_fp16=True
)

pairs = [[user_query, doc] for doc in candidates]

scores = reranker.compute_score(pairs, normalize=True)

ranked = sorted(
 zip(candidates, scores),
 key=lambda x: x[1],
 reverse=True
)
final_chunks = [doc for doc, score in ranked[:5]]

Для боевой среды BGE Reranker нужен FastAPI-сервис с батчингом. Этот код создаёт HTTP-конечную точку, которая принимает запрос и список документов, возвращает отсортированный топ-N с баллами. Другие сервисы обращаются к нему как к микросервису.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

class RerankRequest(BaseModel):
 query: str
 documents: list[str]
 top_n: int = 5

@app.post("/rerank")
async def rerank(req: RerankRequest):
 pairs = [[req.query, doc] for doc in req.documents]
 scores = reranker.compute_score(pairs, normalize=True)
 ranked = sorted(
 zip(req.documents, scores),
 key=lambda x: x[1], reverse=True
 )
 return {"results": [{"text": d, "score": s} for d, s in ranked[:req.top_n]]}

Voyage rerank-2.5: управление критерием релевантности через инструкцию

Voyage rerank-2.5 от Voyage AI добавляет следование инструкции: помимо пары «запрос + документ», можно передать текстовую инструкцию, которая уточняет критерий релевантности.

Пример из практики: юридическая компания ищет по базе прецедентов. Запрос: «нарушение авторских прав в интернете». Без инструкции реранкер поставит вперёд самые общие статьи. С инструкцией «оценивай релевантность с точки зрения применимости к делам о нарушении авторских прав в e-commerce» - поднимет именно нужные прецеденты.

Это особенно полезно для специализированных вертикальных RAG-систем: медицина, юриспруденция, финансы, где контекст запроса критически важен.

Задержка: p50 около 250 мс для 20 документов. Стоимость: $1.2 за 1000 поисков - на 40% дешевле Cohere Rerank 3.5.

Сравнительный тест: числа по методам

Эти числа - результат экспериментов на корпусе технической документации (русский + английский, 250 тысяч фрагментов, 500 тестовых вопросов):

Стратегия Recall@5 Задержка p95
Только BM25 0.644 12 мс
Только семантический поиск 0.587 15 мс
Гибрид RRF (топ-20 каждый) 0.695 27 мс
Гибрид RRF + Cohere Rerank 0.816 ~330 мс
Гибрид RRF + BGE Reranker 0.809 ~95 мс (GPU)

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

Прирост от реранкинга (+12% к гибриду): это не «находит новые документы», а «лучше упорядочивает уже найденные». Нужные фрагменты с позиций 6-20 перемещаются в топ-5.

Боевая реализация: кэширование BM25 и асинхронный реранкинг

Кэширование BM25-индекса критично. Его пересчёт из файла при каждом запросе даёт задержку 200-500 мс. Pickle-дамп индекса для 250 тысяч документов занимает 2-3 ГБ RAM - приемлемо для большинства серверов.

Этот код собирает полный боевой пайплайн: параллельно запускает семантический и BM25 поиск, объединяет через RRF, отправляет результаты в BGE Reranker через HTTP. Итоговая задержка: семантический поиск (15 мс) и BM25 (10 мс) параллельно + реранкинг (80 мс на GPU) = около 100 мс p50.

import asyncio
from functools import lru_cache
import httpx

@lru_cache(maxsize=1)
def get_bm25_index():
 import pickle
 with open("/data/bm25_index.pkl", "rb") as f:
 return pickle.load(f)

async def hybrid_search_with_rerank(
 query: str,
 top_k_retrieve: int = 20,
 top_k_final: int = 5
) -> list[dict]:
 bm25 = get_bm25_index()

 # Параллельно запускаем семантический поиск и BM25
 dense_task = asyncio.create_task(
 vectorstore.asimilarity_search_with_score(query, k=top_k_retrieve)
 )

 bm25_results = bm25_search(query, top_k=top_k_retrieve)

 dense_results = await dense_task

 # RRF слияние
 hybrid_top = reciprocal_rank_fusion([
 [(r.metadata["chunk_id"], s) for r, s in dense_results],
 [(corpus_ids[idx], s) for idx, s in bm25_results]
 ])[:top_k_retrieve]

 candidates = [chunks_by_id[cid].page_content for cid, _ in hybrid_top]

 # Асинхронный реранкинг через HTTP к self-hosted BGE Reranker
 async with httpx.AsyncClient(timeout=2.0) as client:
 resp = await client.post(
 "http://reranker-service:8080/rerank",
 json={"query": query, "documents": candidates, "top_n": top_k_final}
 )

 return resp.json()["results"]

Типичные ошибки

Добавлять гибридный поиск без измерения исходного Recall. Сначала проверьте: если на ваших запросах BM25-only даёт Recall@5 выше 0.75, гибрид не даст значимого прироста. Запустите тест на 50 реальных запросах - это 30 минут работы.

Запрашивать топ-5 вместо топ-20 из каждой системы. Нужный документ может быть на позиции 15 у BM25. Если запросить только топ-5, он не попадёт в объединённый список и реранкер его не увидит. Запрашивайте топ-20 из каждой системы перед RRF.

Не нормализовать русский текст для BM25. Без лемматизации «фильтр», «фильтры», «фильтром» - разные токены. Поиск по слову «фильтр» пропустит документы, где написано «фильтры».

Держать BM25-индекс без кэширования. Пересчёт индекса на каждый запрос - это 200-500 мс накладных расходов. Загружайте один раз при старте сервиса.

Ставить CPU-реранкер без GPU. BGE Reranker на CPU даёт задержку около 600 мс - это часто за пределами SLA. Либо платите за Cohere API, либо арендуйте GPU-инстанс только под реранкер-сервис.

Частые вопросы

Когда гибридный поиск даёт прирост, а когда это лишние сложности?

Гибрид даёт прирост когда: корпус содержит специализированные термины, аббревиатуры, коды; пользователи ищут точные названия; документы на нескольких языках. Без выгоды: корпус из коротких FAQ без технических терминов; все запросы - синонимы и перефразировки; задержка критична и нет GPU для реранкера. Простой тест: запустите только BM25 на 50 реальных запросах. Если Recall@5 выше 0.75 - гибрид, вероятно, не нужен.

Как настроить веса между BM25 и семантическим поиском в RRF?

RRF не использует веса напрямую - в этом его преимущество. Если всё же нужна настройка, используйте гибридный поиск с параметром alpha: alpha=1.0 - чистый семантический, alpha=0.0 - чистый BM25, alpha=0.5 - равный вес. Оптимальное значение подбирается на валидационном наборе через перебор. Обычно 0.3-0.7 для смешанных корпусов.

BGE Reranker vs Cohere Rerank - что выбрать при ограниченном бюджете?

При наличии GPU (даже A10G за $0.75/час в облаке) - BGE Reranker выгоднее от 5000 запросов в день. Без GPU: Cohere Rerank на CPU даёт задержку 600 мс - неприемлемо для real-time. В этом случае либо платите Cohere, либо арендуйте GPU-инстанс только под реранкер-сервис.

Как добавить BM25 к Qdrant, если он нативно не поддерживает?

Qdrant с версии 1.7 поддерживает sparse vectors для SPLADE (алгоритм генерации разреженных векторов) и BM25-подобных моделей через именованные векторы. Альтернатива: держать BM25 отдельно (rank_bm25 или Elasticsearch) и делать RRF-слияние на стороне приложения. Третий вариант: FastEmbed внутри Qdrant поддерживает sparse encoding через SPLADE.

Влияет ли реранкер на качество итогового ответа LLM?

Прямо влияет. Более релевантные фрагменты в топ-5 дают LLM (большой языковой модели) более точный контекст, что снижает галлюцинации и повышает faithfulness (точность ответа по источнику). По данным Ragas-метрик, переход с чистого семантического поиска на гибрид + реранкер повышает faithfulness с 0.71 до 0.84 на типичных RAG-задачах. Корреляция устойчивая.

Что дальше

Следующий шаг: как разбирать PDF с таблицами и колонками, которые ломают стандартную нарезку - /uchebnik/rag-pdf-complex-documents.

Метрики качества поиска: как измерить Recall@5 и faithfulness через Ragas - /uchebnik/rag-evaluation.

AI Компас (t.me/kosmoslab_ai) - канал для предпринимателей в РФ и СНГ, которые применяют AI в своём бизнесе без программиста. Разбираем инструменты и схемы - без курсов и теории.