У вас есть база знаний: прайсы, договоры, инструкции, ответы на частые вопросы. Вы подключаете 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 в своём бизнесе без программиста. Разбираем инструменты и схемы - без курсов и теории.