Технологии

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

Простое сложение баллов двух поисков даёт мусор, потому что шкалы несопоставимы. RRF складывает места, а не баллы — разбираем формулу, константу и расчёт на живом примере.

У вас два поиска. Первый вернул документы с оценками −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 отвечает за честность смешивания, а не за качество источников. Плохой поиск после слияния остаётся плохим, просто аккуратно отсортированным.

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт