Почему ваш RAG теряет ключевые слова (и как это исправить)
Вы построили RAG-систему. Векторный поиск на современных эмбеддингах типа BGE-M3 или Snowflake-Arctic-Embed-L (актуальные на март 2026). Ответы иногда блестящие, а иногда - полная чушь. В чем дело?
Семантический поиск ищет по смыслу. Спросите "Как настроить HTTPS на Nginx?" - он найдет статьи про "конфигурация SSL сертификата". Это мощно. Но спросите "Ошибка 502 Bad Gateway в FastAPI" - и система может проигнорировать критичное ключевое слово 502, выдав общие советы по деплою. Алгоритм BM25 этого не допустит. Он заточен под точное совпадение терминов.
Гибридный поиск - это не просто "давайте запустим оба алгоритма". Это искусство взвешивания. Глупое сложение скорингов из разных шкал даст мусор. Нужно нормализовать, комбинировать и переранжировать. Вот как это делается правильно.
nvidia/NV-Embed-v2 или кроссязычные модели от Cohere. Всегда проверяйте лидерборд на Hugging Face.1 Готовим данные и разбираемся с теорией
Сначала документы. Разбейте их на чанки. 500-1000 токенов - хороший стандарт. Не забудьте про метаданные: источник, заголовок. Они пригодятся для фильтрации.
Теперь о двух столпах гибридного поиска:
- BM25 (Best Matching 25): Старый, как мир информационного поиска, но до сих пор чертовски эффективен. Оценивает релевантность документа запросу на основе частоты терминов (TF) и обратной частоты документа (IDF). Его сила - в точном матче ключевых слов, чисел, кодов ошибок.
- Семантический (векторный) поиск: Преобразует текст в вектор (эмбеддинг) с помощью нейросетевой модели. Релевантность определяется косинусной близостью между вектором запроса и векторами документов. Улавливает синонимы и контекст.
Проблема в том, что их скоринги живут в разных вселенных. BM25 выдает положительные числа, которые могут уходить в сотни. Косинусная близость - между -1 и 1. Простое сложение убьет одну из компонент. Нужна нормализация. И здесь многие спотыкаются, как описано в статье про баг с Log-Odds Conjunction.
2 Строим индекс BM25
Используем библиотеку rank_bm25. Она быстрая и простая. Не изобретайте велосипед.
!pip install rank_bm25 sentence-transformers faiss-cpu # или faiss-gpu для скорости
from rank_bm25 import BM25Okapi
import re
# Токенизация для BM25 (простая, по словам)
def bm25_tokenizer(text):
# Убираем спецсимволы, приводим к нижнему регистру, разбиваем
return re.sub(r"[^\w\s]", " ", text).lower().split()
# Ваши документы (чанки)
corpus = [
"Ошибка 502 Bad Gateway возникает, когда Nginx не может получить ответ от upstream сервера.",
"Для настройки HTTPS нужен SSL сертификат и правильная конфигурация Nginx.",
"FastAPI приложение может возвращать 502 из-за таймаутов или падения воркеров Gunicorn."
]
# Токенизируем корпус
tokenized_corpus = [bm25_tokenizer(doc) for doc in corpus]
# Создаем индекс BM25
bm25_index = BM25Okapi(tokenized_corpus)
# Пример поиска
query = "Ошибка 502 в FastAPI"
tokenized_query = bm25_tokenizer(query)
doc_scores = bm25_index.get_scores(tokenized_query)
print("BM25 scores:", doc_scores)
# Вывод: высокий балл у документа с '502' и 'FastAPI'
Видите? Документ с точным совпадением "502" и "FastAPI" получит высший балл. Но если запрос будет "Как пофиксить бад гейтвей?", BM25 может промахнуться. Время для семантики.
3 Создаем эмбеддинги и векторный индекс
Выбираем модель эмбеддингов. На 2026 год даже для CPU можно использовать эффективные модели типа intfloat/e5-mistral-7b-instruct (квантованную) или BAAI/bge-small-en-v2.5. Для продакшена с GPU берите что-то из семейства NV-Embed.
from sentence_transformers import SentenceTransformer
import numpy as np
import faiss
# Загружаем актуальную модель (проверьте хаб на наличие новее)
embedding_model = SentenceTransformer('BAAI/bge-base-en-v2.5', trust_remote_code=True)
# Устанавливаем опцию для корректной работы пулинга (актуально для новых моделей)
embedding_model.pooling_mode = 'mean'
# Генерируем эмбеддинги для корпуса
corpus_embeddings = embedding_model.encode(corpus, normalize_embeddings=True, show_progress_bar=False)
# Создаем FAISS индекс для быстрого поиска (L2 расстояние, но т.к. эмбеддинги нормализованы, L2 ~ 1 - cosine_sim)
dimension = corpus_embeddings.shape[1]
faiss_index = faiss.IndexFlatIP(dimension) # IndexFlatIP для скалярного произведения (косинусной близости)
faiss.add(faiss_index, corpus_embeddings)
# Поиск по семантике
def semantic_search(query, top_k=3):
query_embedding = embedding_model.encode([query], normalize_embeddings=True)
distances, indices = faiss_index.search(query_embedding, top_k)
# distances - это косинусная близость (т.к. используем IndexFlatIP и нормализованные векторы)
return indices[0], distances[0]
# Пример
query = "Как исправить bad gateway?"
indices, scores = semantic_search(query)
print("Semantic indices:", indices, "Scores:", scores)
# Вывод: найдет документы про Nginx и FastAPI, даже без слова 'ошибка'
Предупреждение: Не используйте одну и ту же токенизацию для BM25 и эмбеддингов. Для BM25 нужна простая разбивка на слова. Модели эмбеддингов имеют собственный токенизатор (чаще WordPiece/Byte-Pair), который вызывается внутри метода encode.
4 Комбинируем результаты - магия гибридного ранжирования
Вот где начинается настоящая работа. Простое взвешенное суммирование нормализованных скорингов - базовый, но рабочий подход. Более продвинутые методы, такие как Bayesian BM25, используют вероятностные модели для лучшей комбинации.
import numpy as np
def hybrid_search(query, bm25_index, faiss_index, embedding_model, corpus, alpha=0.5, top_k=10):
"""
Гибридный поиск с взвешенной комбинацией BM25 и семантического поиска.
alpha: вес семантического поиска (от 0 до 1). 0.5 - равный вес.
"""
# 1. BM25 поиск
tokenized_query = bm25_tokenizer(query)
bm25_scores = bm25_index.get_scores(tokenized_query)
# Нормализуем BM25 scores (приводим к диапазону [0, 1])
if np.max(bm25_scores) > 0:
bm25_scores_norm = (bm25_scores - np.min(bm25_scores)) / (np.max(bm25_scores) - np.min(bm25_scores))
else:
bm25_scores_norm = np.zeros_like(bm25_scores)
# 2. Семантический поиск
query_embedding = embedding_model.encode([query], normalize_embeddings=True)
distances, semantic_indices = faiss_index.search(query_embedding, len(corpus))
semantic_scores = distances[0]
# Косинусная близость уже в [-1, 1] или [0,1] если нормализованы. Приводим к [0,1]
semantic_scores_norm = (semantic_scores - np.min(semantic_scores)) / (np.max(semantic_scores) - np.min(semantic_scores))
# Важно: семантический поиск возвращает индексы, а не упорядоченный список по индексу 0..N
# Создаем массив семантических оценок для всех документов по порядку
semantic_scores_full = np.zeros(len(corpus))
for idx, score in zip(semantic_indices[0], semantic_scores_norm):
semantic_scores_full[idx] = score
# 3. Взвешенная комбинация
hybrid_scores = (1 - alpha) * bm25_scores_norm + alpha * semantic_scores_full
# 4. Получаем топ-K результатов
top_indices = np.argsort(hybrid_scores)[::-1][:top_k]
return top_indices, hybrid_scores[top_indices]
# Тестируем
query = "FastAPI 502 error fix"
top_indices, scores = hybrid_search(query, bm25_index, faiss_index, embedding_model, corpus, alpha=0.6)
print("Top hybrid indices:", top_indices)
for idx in top_indices:
print(f"Score: {scores[idx]:.3f} | Doc: {corpus[idx][:80]}...")
Параметр alpha - ваша ручка настройки. Для технической документации с кучей кодов ошибок ставьте 0.3-0.4 (больше веса BM25). Для поиска по новостям или общим статьям - 0.6-0.7 (больше семантики). Точную настройку смотрите в roadmap по RAG 2026.
5 Интегрируем в RAG-пайплайн
Теперь замените ваш старый ретривер на гибридный. Вместо того чтобы просто искать в FAISS, вызывайте hybrid_search. Полученные чанки передавайте в LLM с хорошим промптом, как из статьи Промпты для RAG.
from langchain_community.llms import HuggingFacePipeline
# Предположим, что у вас уже есть настройка LLM (например, через HuggingFace)
def rag_with_hybrid_search(query, llm, bm25_index, faiss_index, embedding_model, corpus, top_k=5):
# 1. Гибридный поиск релевантных чанков
top_indices, _ = hybrid_search(query, bm25_index, faiss_index, embedding_model, corpus, top_k=top_k)
context_chunks = [corpus[i] for i in top_indices]
context = "\n---\n".join(context_chunks)
# 2. Формируем промпт с контекстом
prompt = f"""Ты - ассистент, отвечающий на вопросы на основе предоставленного контекста.
Контекст:
{context}
Вопрос: {query}
Ответ (основывайся только на контексте, не придумывай):"""
# 3. Генерируем ответ с помощью LLM
answer = llm(prompt)
return answer, context_chunks
# Пример вызова (заглушка для LLM)
# answer, chunks = rag_with_hybrid_search("Как исправить 502?", my_llm, bm25_index, faiss_index, embedding_model, corpus)
# print(answer)
Распространенные ошибки и как их избежать
- Неправильная нормализация: Не нормализуете скоринги перед сложением? Получаете доминирование одного метода. Всегда приводите к общему диапазону (например, [0, 1]).
- Игнорирование распределения оценок: Оценки BM25 могут иметь выбросы. Используйте robust scaling (например, с помощью квантилей) вместо min-max, если данные зашумлены.
- Токенизация стоп-слов: BM25 чувствителен к стоп-словам. Удаляйте их для английского, но осторожно для других языков. Для семантики стоп-слова не так критичны.
- Кэширование: Пересчитывать эмбеддинги для каждого запроса дорого. Кэшируйте результаты семантического поиска для частых запросов. BM25 и так быстрый.
- Слепая вера в гибрид: Для маленьких или очень специфичных корпусов (например, поиск по коду) чистый BM25 может работать лучше. Всегда тестируйте A/B. Поможет анализ цены и точности.
Частые вопросы (FAQ)
| Вопрос | Ответ |
|---|---|
| Насколько гибридный поиск увеличивает точность? | На типовых датасетах (MS MARCO, BEIR) прирост составляет 20-40% по метрикам типа NDCG@10. Но всё зависит от ваших данных. Тестируйте. |
| Какую модель эмбеддингов выбрать для CPU? | На 2026 год: BAAI/bge-small-en-v2.5 (~30 МБ) или intfloat/e5-small-v3. Они быстрые и дают качество близкое к большим моделям. |
| Как масштабировать на миллионы документов? | Используйте приближенный поиск в FAISS (IndexIVFFlat) для семантики. Для BM25 - распределенные индексы (Elasticsearch с плагином BM25). Или посмотрите статью про оптимизацию на CPU. |
| Что делать с неанглийскими текстами? | Используйте многоязычные модели эмбеддингов (например, intfloat/multilingual-e5-base). Для BM25 настройте токенизатор под язык (используйте библиотеки типа pymorphy2 для русского). |
| Где взять полный пример кода? | Основы берите из этой статьи. Полную production-реализацию смотрите в полном руководстве по RAG. |
И последнее. Не зацикливайтесь только на BM25 и семантике. Экспериментируйте с переранжированием (re-ranking) с помощью кросс-энкодеров (например, BAAI/bge-reranker-v2.5), добавляйте фильтры по метаданным. Ваша RAG-система должна эволюционировать. И да, иногда самое простое решение - добавить в промпт строчку "Обрати особое внимание на числовые коды и точные термины в контексте". Работает почти так же хорошо, как и гибридный поиск, только без лишней инфраструктуры. Парадокс.