У вас два поиска. Первый вернул документы с оценками −8.4, −6.1, −5.9. Второй — те же и другие документы с оценками 0.83, 0.79, 0.71. Что делать дальше — вопрос, на котором ломается большинство самодельных гибридных поисков, потому что интуитивный ответ («сложить») неверен, а неверность его не видна ни в логах, ни в тестах.
#Почему нельзя складывать баллы
Три причины, каждой достаточно.
Разные единицы. Ранжирование bm25 измеряет насыщенность документа редкими словами запроса; в SQLite оно к тому же возвращает отрицательные значения — чем меньше, тем релевантнее. Косинусная близость измеряет угол между векторами и живёт в диапазоне примерно от нуля до единицы. Между этими числами нет общего знаменателя.
Разные распределения. Даже если привести обе шкалы к отрезку от нуля до единицы, у одного источника оценки будут стянуты в узкую полосу, а у другого растянуты — и после нормировки разница между «отличным» и «средним» попаданием раздуется до масштаба всей шкалы.
Нестабильность от запроса к запросу. Диапазон оценок bm25 зависит от редкости слов запроса: на частых словах оценки сжимаются, на уникальном термине разъезжаются. Веса, подобранные на одном запросе, окажутся неверными на другом.
Итог: сумма баллов даёт результат, в котором один источник тихо доминирует, причём переменно. Ошибка не проявляется как сбой — выдача просто хуже, чем могла быть, и никто не знает почему.
#Формула
RRF решает задачу, отказываясь от баллов вообще. Значение имеет только позиция документа в каждом списке.
score(d) = Σ 1 / (k + rank_i(d))
i
Здесь rank_i(d) — место документа d в списке источника i, считая с единицы; k — сглаживающая константа, по традиции 60. Документ, которого в списке источника нет, просто не даёт вклада от этого источника.
Три свойства, ради которых это работает:
- Шкалы не участвуют. На вход идут только целые места. Источники можно смешивать любые: полнотекстовый, векторный, поиск по тегам, ручной список избранного.
- Убывание мягкое. Разница между первым и вторым местом заметна, между двадцатым и двадцать первым — почти нет. Это разумно: точное упорядочивание в хвосте всё равно шум.
- Согласие вознаграждается. Документ, попавший в топ обоих источников, обгоняет документа, который у одного источника первый, а у другого отсутствует.
#Зачем константа 60 и что она делает
Константа k управляет тем, насколько сильно первые места отрываются от остальных.
При маленьком k (например, 1) первое место даёт вклад 0.5, второе — 0.33, десятое — 0.09. Разрыв огромный: документ, оказавшийся первым у одного источника, почти невозможно обогнать согласием двух источников на средних местах.
При большом k (например, 200) первое место даёт 0.00498, десятое — 0.00476. Разница почти исчезает, и результат определяется в основном тем, в скольких списках документ вообще присутствует.
Значение 60 — компромисс из исходной работы по RRF, и оно живучее: при нём топ-5 каждого источника заметно весомее хвоста, но два согласных источника на местах 3 и 4 обходят одиночного лидера. Persona использует именно 60 (_RRF_K в app/memory_vec.py).
Подбирать k под себя имеет смысл только при одном условии: у вас есть двадцать размеченных запросов с известным правильным ответом. Без разметки подбор превращается в подгонку под последний запомнившийся случай.
#Расчёт на живом примере
Запрос: «что решили по договору с Олегом». Полнотекстовый поиск нашёл по словам «договор» и «Олег», векторный — по смыслу «договорённость».
Списки источников:
| Место | Полнотекстовый (bm25) | Векторный (KNN) |
|---|---|---|
| 1 | D1 | D5 |
| 2 | D2 | D1 |
| 3 | D3 | D7 |
| 4 | D4 | D2 |
Считаем при k = 60:
- D1:
1/(60+1) + 1/(60+2)= 0.01639 + 0.01613 = 0.03252 - D2:
1/(60+2) + 1/(60+4)= 0.01613 + 0.01563 = 0.03176 - D5:
1/(60+1)= 0.01639 - D3:
1/(60+3)= 0.01587 - D7:
1/(60+3)= 0.01587 - D4:
1/(60+4)= 0.01563
Итоговый порядок: D1, D2, D5, D3, D7, D4.
Обратите внимание на два места. D5 был первым у векторного поиска, но оказался третьим — потому что полнотекстовый его не увидел вовсе, а D2 подтвердили оба источника. D3 и D7 сравнялись — оба на третьем месте, каждый у своего источника; их взаимный порядок RRF не определяет, его решает то, как отсортированы равные значения.
Последнее — не мелочь. При равных суммах порядок задаётся реализацией сортировки, и на границе отсечки это влияет на то, какой документ дойдёт до модели. Если такие ничьи у вас частые, добавьте детерминированный тай-брейкер: по свежести, по важности, по идентификатору.
#Что делать после слияния
RRF даёт порядок, но не решает двух задач. Повторы: пять почти одинаковых записей одного факта получат близкие суммы и займут весь топ — нужна отдельная дедупликация. Разнообразие: даже без прямых дублей выдача собирается вокруг одного аспекта запроса, и здесь помогает жадный проход, выбирающий следующий документ с поправкой на непохожесть на уже выбранные.
И то и другое — надстройки над RRF, а не его замена. Куда всё это встраивается, разобрано в статье про гибридный поиск, а точность внутри отобранного топа поднимается отдельным этапом — переранжированием.
#Где интуиция подводит
Кажется, что RRF «теряет информацию»: мы выбрасываем точные оценки и оставляем грубые места. Формально так и есть. Практически это ровно то, что нужно: оценки от разных источников несопоставимы, а места сопоставимы всегда. Выбрасывается не информация, а ложная точность.
Второе заблуждение — что RRF заменяет качественный поиск. Он не улучшает ни один из источников: если оба промахнулись, слияние вернёт хорошо упорядоченный мусор. Что и почему промахивается у каждого — в разборе bm25 и в объяснении того, как считается близость векторов.
Третье: RRF не даёт абсолютной меры уверенности. Сумма 0.032 не значит «релевантно» — числа осмысленны только в сравнении внутри одной выдачи. Отсекать надо по количеству, а не по порогу.
Место этой механики в памяти ассистента описано в статье о том, как работает память у ИИ, а решение, нужен ли вам второй источник вообще, — в обзоре векторных баз данных.
#Частые вопросы
#Можно ли задать источникам разные веса?
Да: формула становится суммой w_i / (k + rank_i). Но веса требуют разметки для подбора, иначе вы просто закрепите своё впечатление от нескольких запросов. Начинайте с равных весов и меняйте их, только имея двадцать размеченных примеров.
#Чем RRF отличается от переранжирования?
RRF смешивает уже готовые списки и ничего не знает о содержании документов. Переранжирование заново читает пару «запрос — документ» отдельной моделью и потому точнее, но стоит времени на каждый запрос. Их обычно применяют последовательно: RRF собирает кандидатов, переранжирование упорядочивает финальный десяток.
#Подходит ли RRF для трёх и более источников?
Формула суммирует любое их число. Оговорка: чем больше источников, тем сильнее итог определяется согласием, а не качеством, — три посредственных источника, согласных между собой, обгонят один хороший.
#Нужен ли RRF, если источник один?
Нет. При одном источнике RRF просто сохраняет исходный порядок — сумма из одного слагаемого монотонна по рангу. Смысл появляется ровно со второго источника.
#Вывод
Если вы смешиваете два поиска, складывайте места, а не баллы: это единственный способ обойтись без калибровки несопоставимых шкал. Берите k = 60 и не трогайте константу, пока у вас нет размеченного набора запросов, — подбор без разметки ухудшает результат чаще, чем улучшает. И помните границу применимости: RRF отвечает за честность смешивания, а не за качество источников. Плохой поиск после слияния остаётся плохим, просто аккуратно отсортированным.