Гибридный поиск в RAG: как совместить BM25 и векторную выдачу
Сотрудник спрашивает у корпоративного помощника: «Почему по заказу AB-2041 не выгружается акт?» В архиве есть старая инструкция с точным номером, новая карточка заказа с тем же номером и общая статья про выгрузку. Поиск по словам хорошо замечает AB-2041, но может поднять старую запись. Векторный поиск хорошо ловит смысл «не выгружается акт», но иногда пропускает точный артикул или ID. Для ответа нужны обе подсказки.
Гибридный поиск запускает две независимые ветки: лексическую, например BM25, и семантическую, которая ранжирует близость векторов. Затем результаты объединяются по стабильному ID документа. Это происходит до генерации ответа: LLM получит только выбранные фрагменты и их источники. Если нужная карточка вообще не попала ни в один список кандидатов, слияние её не создаст; сначала проверьте извлечение.
Почему нельзя просто сложить оценки
Оценка BM25 зависит от слов в запросе и корпусе, а близость векторов измеряется по другой шкале. Число 12.4 из одной ветки и 0.87 из другой не складываются осмысленно без отдельной калибровки. Проще начать с места документа в каждом списке. Метод RRF добавляет за попадание на позицию rank вклад 1 / (k + rank); здесь k = 60. Документ, который высоко стоит в обеих ветках, собирает два вклада. Число 60 задаёт скорость убывания вклада с позицией, а не порог релевантности.
Ниже уже полученные результаты поиска представлены списками ID. BM25 ставит старую карточку первой, текущую второй; семантическая ветка ставит текущую первой. Код выполняет только этап слияния. Он не изображает запуск индекса или получение эмбеддингов, зато позволяет точно проверить арифметику ранжирования.
from collections import defaultdict
bm25 = ["old-AB2041", "current-AB2041", "generic-export"]
vectors = ["current-AB2041", "export-guide", "old-AB2041"]
k = 60
scores = defaultdict(float)
for ranked in (bm25, vectors):
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] += 1 / (k + rank)
for doc_id, score in sorted(scores.items(), key=lambda row: (-row[1], row[0])):
print(f"{doc_id}: {score:.6f}")
Первой станет current-AB2041 с оценкой около 0.032522: второе место BM25 и первое место векторов. Старая карточка получит около 0.032266: первое и третье места. Затем идут документы, встретившиеся в одной ветке. Вывод в коде сортируется по рассчитанной сумме; ID используется только для устойчивого порядка при равенстве оценок. Для реального RAG к каждому ID надо восстановить текст, версию, права доступа и ссылку на источник.
Как проверить пользу на своих запросах
Разбейте контрольные вопросы на три группы: точный номер или артикул, свободное описание проблемы и сочетание обоих. Для каждого пометьте правильные документы и сравните BM25, векторную и объединённую выдачу при одинаковом лимите фрагментов, передаваемых модели. Смотрите, вошёл ли нужный документ в top-k и на какой позиции; затем отдельно проверяйте, верно ли генератор воспользовался найденным текстом. Если старый документ обгоняет актуальный из-за номера, фильтруйте статус и версию до слияния или на этапе получения кандидатов. RRF сам не знает, какой регламент действует сегодня.
Лимит каждого входного списка тоже важен: отсутствующий за его пределами документ не получает вклада. Не превращайте итоговую оценку RRF в «процент уверенности» и не ставьте единый числовой порог для разных вопросов; сумма выражает позиции в этих конкретных списках. Для запросов с кодом можно дополнительно требовать точного совпадения идентификатора в финальном кандидате. Так семантическая ветка расширяет поиск по описанию, но не подменяет номер похожим.
В платном курсе Vector DB & RAG Developer студент строит векторный поиск на FAISS, Qdrant и Weaviate, объединяет его с BM25, добавляет реранжирование и измеряет качество выдачи перед подключением RAG-бота. Это продолжение нашего расчёта: от двух готовых списков ID к работающему поиску по своим данным. В программе есть открытый урок «Qdrant на практике». Откройте программу и пробный урок.
Отдельно пригодятся разбор точных артикулов в поиске, статья о том, почему найденная цитата ещё не подтверждает ответ, и проверка качества RAG.
