Содержание
  1. Что именно делает RRF и где проходят его границы
  2. Учебный вход и ручной эталон
  3. Копируемый код и порядок проверки
  4. Положительная и отрицательная пробы
  5. Учебная конвенция и настройки продуктов
  6. Что меняет окно и число шардов
  7. Как читать проверки входных списков
  8. Частые вопросы
Гайды

RRF: как объединить два списка выдачи

7 ноября 2026 · 14 минут чтения

RRF: как объединить два списка выдачи — Reciprocal rank fusion складывает результаты в один список, складывая для каждого документа обратные величины его позиций. Формула: score(d) = Σ 1/(k + rank(d)). Ранги берутся из каждого списка отдельно, сырые оценки не складываются. Ниже — ручной расчёт на двух списках, код на Python и проверка результата.

Что именно делает RRF и где проходят его границы

Метод описан в документации Elasticsearch как способ свести несколько наборов результатов с разными индикаторами релевантности в один набор. Ключевое свойство: индикаторы могут быть никак не связаны между собой, и настройка весов не требуется. Формула в документации проекта записана так: для каждого запроса q и документа d, если d входит в результат q, к счёту прибавляется 1.0 / (k + rank(result(q), d)). Переменные расшифрованы там же: k — константа ранжирования, q — запрос из набора, d — документ, rank — позиция документа в результате запроса, отсчёт с 1.

Документация OpenSearch формулирует то же через позицию: RRF комбинирует результаты нескольких поисковых условий, используя место документа в каждом списке. Документ, стоящий высоко в нескольких списках, получает более высокий итоговый счёт, чем документ, стоящий первым в одном списке. Там же названа причина, по которой метод удобен: оценки BM25 и векторной близости измеряют разные величины в разных шкалах, а ранг 1 указывает на лучший результат в обоих списках.

Границы метода очерчены в документации Qdrant. RRF учитывает позиции внутри каждого запроса и поднимает документы, которые стоят ближе к началу в нескольких наборах. Рядом описана альтернатива — DBSF: она сохраняет сырые оценки, но нормирует их распределения перед объединением. Qdrant приводит и практический довод против линейной комбинации сырых оценок: плотные векторы и BM25 живут в разных шкалах, которые к тому же сдвигаются от запроса к запросу, поэтому фиксированный коэффициент над сырыми оценками на конкретном запросе окажется под влиянием того ретривера, чьи значения крупнее.

Граница проходит по типу входа. RRF принимает ранжированные списки. Он не принимает сырые оценки как есть и не превращает их в сопоставимые величины. Документация OpenSearch описывает это свойство прямо: величины оценок отбрасываются, поэтому документ, обошедший следующий результат с небольшим отрывом, обрабатывается так же, как документ с большим отрывом. Для читателя это означает простую вещь: если задача требует сохранить величину отрыва между соседними документами, RRF такую информацию теряет.

Вторая граница — состав списков. В расчёт попадают только те документы, которые дошли до этапа объединения. Документация OpenSearch разбирает это на параметрах: значение k в векторном условии ограничивает число документов, возвращаемых условием на каждый шард, а параметр size ограничивает число результатов, которые каждое условие отдаёт на шард. По умолчанию каждое условие обрезается до size результатов перед объединением. Автор документации называет ограничение по size наиболее частой причиной неожиданных результатов и показывает пример: при size равном 1 возвращается документ 1: согласие двух условий, поднявшее документ 2 наверх при size 10, до этапа объединения уже не доходит.

Третья граница — число условий. Документация Elasticsearch требует минимум два дочерних ретривера для ранжирования. Каждый дочерний ретривер несёт равный вес в формуле. Это соответствует учебной постановке задачи: два списка, равный вклад каждого.

Отдельно стоит зафиксировать, что RRF не является гарантией роста качества. Документация OpenSearch приводит результат сравнения: на шести наборах BEIR RRF в среднем дал на 3,86% более низкий NDCG@10, чем гибридный конвейер на основе оценок, при сопоставимой задержке поиска и загрузке процессора координационного узла. Там же указано, когда нормализацию оценок стоит предпочесть: если величина отрыва между документами должна сохраниться в итоговом ранжировании. Практический вывод для читателя: RRF — разумная отправная точка при неизвестных распределениях оценок, и её стоит проверять на своих данных.

Учебный вход и ручной эталон

Возьмём два готовых ранжированных списка. Списки уже сформированы, этап поиска нас не интересует. Идентификаторы документов — сопоставимые строки.

  • L1 = [A, B, C]
  • L2 = [B, D, A]

Учебная конвенция: ранги отсчитываются от 1, константа k равна 10. Конвенция задана автором примера и служит для ручной проверки.

Разберём вклады по каждому идентификатору.

Документ A. В L1 он стоит на позиции 1, вклад 1/(10+1) = 0,090909. В L2 он стоит на позиции 3, вклад 1/(10+3) = 0,076923. Сумма 0,167832.

Документ B. В L1 позиция 2, вклад 1/(10+2) = 0,083333. В L2 позиция 1, вклад 1/(10+1) = 0,090909. Сумма 0,174242.

Документ C. В L1 позиция 3, вклад 1/(10+3) = 0,076923. В L2 документа C нет, слагаемое не появляется. Итог 0,076923.

Документ D. В L1 документа D нет. В L2 позиция 2, вклад 1/(10+2) = 0,083333. Итог 0,083333.

Порядок по убыванию суммы: B (0,174242), A (0,167832), D (0,083333), C (0,076923).

Обратите внимание на два места. Документ B стоит вторым в первом списке и первым во втором, и именно он выходит наверх. Документ A стоит первым в первом списке и третьим во втором, и он уступает B. Документы C и D присутствуют только в одном списке каждый, и оба оказываются внизу. Это и есть механика метода: согласие двух списков весит больше одиночного первого места.

Ручной расчёт удобно свести в таблицу.

Документ Позиция в L1 Вклад L1 Позиция в L2 Вклад L2 Сумма
A 1 0,090909 3 0,076923 0,167832
B 2 0,083333 1 0,090909 0,174242
C 3 0,076923 — 0 0,076923
D — 0 2 0,083333 0,083333

Эталонный порядок: B, A, D, C. Все дальнейшие проверки кода сверяются с этой таблицей.

Копируемый код и порядок проверки

Ниже — полная функция на Python. Она принимает список ранжированных списков и константу k, проверяет входные условия и возвращает пары «идентификатор — счёт», отсортированные по убыванию счёта.

PYTHON17 строк
from collections import defaultdict

def rrf(lists, k=10):
    if k <= 0:
        raise ValueError("k must be positive")
    scores = defaultdict(float)
    for ranked in lists:
        if len(ranked) != len(set(ranked)):
            raise ValueError("duplicate document ID in one list")
        for rank, doc_id in enumerate(ranked, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda item: (-item[1], item[0]))

L1 = ["A", "B", "C"]
L2 = ["B", "D", "A"]
for doc_id, score in rrf([L1, L2], k=10):
    print(doc_id, round(score, 6))

Ожидаемый вывод:

ТЕКСТ4 строки
B 0.174242
A 0.167832
D 0.083333
C 0.076923

Порядок проверки результата состоит из четырёх шагов.

Первый шаг. Сверьте суммы вкладов по каждому идентификатору с ручной таблицей до шестого знака. Расхождение означает ошибку в нумерации рангов или в константе.

Второй шаг. Сверьте порядок. Он должен совпасть с эталоном B, A, D, C.

Третий шаг. Проверьте документы, отсутствующие в одном из списков. Документ C не входит в L2 и получает единственное слагаемое. Документ D не входит в L1 и получает единственное слагаемое. Если в вашем выводе у C или D появилось лишнее слагаемое, значит где-то подставляется нулевой ранг или пустая позиция.

Четвёртый шаг. Прогоните функцию с другими значениями k, например k=1 и k=60. Абсолютные значения изменятся. Порядок при этом может сохраниться, и это нормально: смена k меняет вклады, при этом итоговый порядок может остаться прежним. Проверка на двух значениях k показывает, что вы понимаете, какой параметр управляет разбросом.

Отдельно проверьте поведение на k=60, близком к значению по умолчанию у продуктов. В учебном коде значение по умолчанию задано автором примера и равно 10. Документация Elasticsearch указывает, что параметр rank_constant по умолчанию равен 60 и должен быть не меньше 1. Документация OpenSearch приводит тот же диапазон допустимых значений [1, 10000] и то же значение по умолчанию 60, а также описывает эффект: малое значение константы создаёт большие разрывы между соседними рангами и концентрирует влияние на первых результатах, большое значение эти разрывы сокращает, и нижние позиции начинают весить почти столько же, сколько верхние.

Положительная и отрицательная пробы

Положительная проба — это разобранный выше пример. Она проверяет, что функция считает вклады по позициям и складывает их.

Отрицательная проба проверяет другое: что вы не подменили метод. Возьмите сырые оценки вместо рангов. Пусть текстовый поиск дал документу оценку BM25 = 12,4, а векторный поиск дал близость 0,87. Сумма этих чисел зависит от разных шкал оценок. Такой результат не является RRF. Важное уточнение: совпадение итогового порядка с эталоном не делает такую сумму корректной реализацией. Изменение порядка тоже не является обязательным признаком ошибки. Признак один — входом стали сырые оценки.

Две отрицательные пробы касаются входа функции.

Первая: rrf([["A", "A"]]) должна завершиться ошибкой ValueError. Один список не может дать два голоса одному идентификатору. Дубликат считается ошибкой входа.

Вторая: rrf([["A"]], k=0) должна завершиться ошибкой ValueError. Учебный код требует положительного k.

Третья проверка касается ничьей. Вызов rrf([["B"], ["A"]], k=10) вернёт A перед B. Это следствие явной политики сортировки по идентификатору, заложенной в код. Такая политика задана автором примера и служит для воспроизводимости. Реальные движки разрешают ничьи по своим правилам, и учебная сортировка по идентификатору не описывает их поведение.

Сводка проб:

Проба Вход Ожидаемое поведение Что проверяет
Положительная L1 = [A, B, C], L2 = [B, D, A], k=10 Порядок B, A, D, C Расчёт вкладов по позициям
Отрицательная Сумма BM25 = 12,4 и близости 0,87 Результат не является RRF Подмена рангов сырыми оценками
Ошибка входа rrf([[«A», «A»]]) ValueError Дубликат идентификатора в списке
Ошибка параметра rrf([[«A»]], k=0) ValueError Требование положительного k
Ничья rrf([[«B»], [«A»]], k=10) A перед B Явная политика сортировки по идентификатору

Учебная конвенция и настройки продуктов

Разделение здесь простое и его стоит держать в голове при переносе примера в рабочий код.

Учебная конвенция задана автором примера: ранги от 1, k = 10, сортировка ничьих по идентификатору, ошибка на дубликат внутри списка. Эти значения выбраны для того, чтобы расчёт проверялся вручную. Их нельзя выдавать за значения по умолчанию какого-либо движка.

Настройки продуктов устроены иначе. Документация Elasticsearch описывает параметр rank_constant со значением по умолчанию 60 и параметр rank_window_size, который задаёт размер отдельных наборов результатов и по умолчанию равен параметру size. Там же указано требование: rank_window_size должен быть не меньше size и не меньше 1. Документация OpenSearch называет те же значения по умолчанию для rank_constant и добавляет параметр pagination_depth, который определяет, сколько результатов каждое условие отдаёт на шард независимо от size.

Отдельное расхождение касается нумерации. Документация Qdrant указывает, что в её формуле используются позиции рангов с отсчётом от нуля: верхний результат имеет нулевой ранг. Формула Qdrant записана с учётом веса ранжирования и при единичном весе сводится к исходной функции RRF. Учебный пример использует отсчёт от единицы. Переносить индексацию между этими конвенциями нужно осознанно: сдвиг на единицу меняет каждое слагаемое.

Взвешивание — ещё одно место, где конвенции расходятся. Документация Qdrant описывает weighted RRF: веса задаются массивом, число весов должно совпадать с числом префетчей, и при весах 3,0 и 1,0 документ, стоящий третьим в первом запросе, получает тот же вклад, что документ, стоящий первым во втором. Документация OpenSearch описывает взвешивание иначе: веса должны суммироваться к 1,0 и умножают обратный ранг соответствующего условия. Это разные формулы, и подменять одну другой при переносе кода нельзя. Учебный код в этой статье невзвешенный.

Практическое следствие: при переносе учебного примера в продукт сначала выясните, какую нумерацию рангов и какое значение константы использует ваша система, и только потом сверяйте числа.

Что меняет окно и число шардов

Списки, которые доходят до объединения, зависят от настроек поиска. Документация OpenSearch разбирает два ограничителя. Значение k в векторном условии ограничивает число документов, возвращаемых условием на каждый шард, и тем самым ограничивает число документов, получающих векторный ранг. Параметр size ограничивает число результатов, которые каждое условие отдаёт на шард, и по умолчанию каждое условие обрезается до size результатов перед объединением.

Чтобы развести глубину объединения и размер страницы, в документации OpenSearch предлагается параметр pagination_depth в гибридном запросе. Он определяет, сколько результатов каждое условие отдаёт на шард независимо от size. Рекомендация там же: когда приложение запрашивает небольшое число результатов, задавайте pagination_depth выше наибольшего значения size, которое использует приложение.

Число шардов влияет на результат через два входа. Статистики BM25 считаются на шард, поэтому текстовое условие может упорядочить документы по-разному в зависимости от того, на каком шарде лежит документ. Значение k в векторном условии применяется на шард, поэтому индекс с тремя шардами может вернуть из векторного условия до трёх раз больше документов, чем индекс с одним шардом, и каждый дополнительный документ получает ранг и входит в объединение. Документация OpenSearch показывает это на том же наборе из пяти документов: на индексе с тремя шардами возвращается пять результатов вместо четырёх, а ранги документов 1 и 2 меняются местами. Вывод, который там же сформулирован: при подборе константы ранжирования или весов запускайте эксперимент на индексе с тем же числом шардов, что и рабочий индекс.

Для учебного примера это означает следующее. Списки L1 и L2 считаются уже готовыми и полными. Если в рабочей системе списки обрезаны окном, объединение увидит только обрезанную часть, и документ, который был бы поднят согласием двух условий, может до этапа объединения не дойти.

Как читать проверки входных списков

Документ может встретиться в нескольких списках: именно так он получает вклад от разных способов поиска. При этом повтор одного ID внутри отдельного списка недопустим для заданной функции. Сначала проверьте уникальность каждого списка, затем объединяйте их. Если повтор обнаружен, вернитесь к подготовке результатов этого поиска. Удаление повтора требует понятного правила о сохранении позиции, поэтому учебная функция останавливается с ошибкой вместо молчаливого исправления входа.

Идентификаторы должны обозначать одни и те же документы во всех списках. Если один документ получил разные ID в лексическом и векторном поиске, функция посчитает их разными результатами. Если разные документы получили одинаковый ID, их вклады ошибочно сложатся. Перед объединением полезно сверить соответствие ID исходному тексту. Сам расчёт RRF этого соответствия проверить не может: его вход содержит только порядок идентификаторов.

При равенстве суммы учебная функция использует ID как дополнительный ключ сортировки. Это делает печать результата воспроизводимой, но не добавляет сведений о релевантности. В собственной проверке сначала сравнивайте рассчитанные суммы, затем правило разрешения равенства. Различие только в порядке равных результатов следует разбирать отдельно от ошибки в формуле.

Зафиксируйте в журнале входные списки, значение k и полученный порядок. Для ручной сверки выберите документ, встречающийся в обоих списках, и выпишите каждый его вклад отдельно. Затем повторите расчёт для документа из одного списка. Такой разбор показывает, откуда возникла итоговая сумма, и помогает найти ошибку в начале отсчёта ранга. Он проверяет собственную реализацию на заданном наборе; качество поиска на других вопросах остаётся отдельной задачей.

Частые вопросы

Что такое reciprocal rank fusion простыми словами?

Это способ свести несколько ранжированных списков в один, используя позиции документов. Каждому документу достаётся сумма обратных величин его позиций по всем спискам, где он встретился. Документ, стоящий высоко в нескольких списках, набирает больше. Сырые оценки релевантности в расчёт не входят.

Как считается score в RRF?

Счёт документа равен сумме дробей вида 1/(k + rank) по всем спискам, в которых документ присутствует. Здесь k — константа ранжирования, rank — позиция документа в конкретном списке. Документ, отсутствующий в списке, не даёт слагаемого. В учебном примере с k=10 документ B набрал 0,174242 и вышел на первое место.

Почему нельзя просто сложить оценки BM25 и векторной близости?

Эти оценки измеряют разные величины в разных шкалах. BM25 не ограничен сверху, косинусная близость ограничена. Сумма таких чисел зависит от шкал и от конкретного запроса. Документация Qdrant называет линейную комбинацию сырых оценок ненадёжной без предварительной нормализации.

Какое значение k использовать?

В документации Elasticsearch и OpenSearch значение по умолчанию для rank_constant равно 60, допустимый диапазон начинается с 1. Малое значение концентрирует влияние на первых результатах, большое выравнивает вклады нижних позиций. Для своих данных значение подбирают экспериментом на наборе с известной релевантностью.

Что делать с документом, которого нет во втором списке?

Он получает только те слагаемые, которые соответствуют спискам с его участием. В учебном примере документ C есть только в L1 и набирает 0,076923, документ D есть только в L2 и набирает 0,083333. Оба оказываются ниже документов, подтверждённых двумя списками.

Читайте также

3 материала