Слияние рангов с обратной величиной
Слияние рангов с обратной величиной (RRF) — это метод объединения нескольких наборов результатов с различными показателями релевантности в единый набор результатов. RRF не требует настройки, и различные показатели релевантности не обязательно должны быть взаимосвязаны для достижения высоких результатов.
RRF использует следующую формулу для определения оценки ранжирования каждого документа:
score = 0.0
for q in queries:
if d in result(q):
score += 1.0 / ( k + rank( result(q), d ) )
return score
# where
# k is a ranking constant
# q is a query in the set of queries
# d is a document in the result set of q
# result(q) is the result set of q
# rank( result(q), d ) is d's rank within the result(q) starting from 1 API слияния рангов с обратной величиной
Вы можете использовать RRF в рамках поиска для объединения и ранжирования документов, используя отдельные наборы лучших документов (наборы результатов) от комбинации подключаемых извлекателей с помощью извлекателя RRF. Для ранжирования требуется как минимум два подключаемых извлекателя.
Извлекатель RRF — это необъект, определённый в качестве части параметра retriever запроса поиска. Объект извлекателя RRF содержит следующие параметры:
-
retrievers -
(Обязательный, массив объектов извлекателей)
Список подключаемых извлекателей, указывающий, к каким наборам возвращённых лучших документов будет применяться формула RRF. Каждый подключаемый извлекатель имеет равный вес в рамках формулы RRF. Требуется два или более подключаемых извлекателя.
-
rank_constant -
(Необязательный, целое число)
Это значение определяет, насколько сильно документы в отдельных наборах результатов каждого запроса влияют на итоговый ранжированный набор результатов. Более высокое значение означает, что документы с более низким рангом оказывают большее влияние. Это значение должно быть больше или равно
1. По умолчанию60. -
rank_window_size -
(Необязательный, целое число)
Это значение определяет размер отдельных наборов результатов для каждого запроса. Более высокое значение улучшит релевантность результатов за счёт производительности. Итоговый ранжированный набор результатов усекается до параметра запроса поиска size.
rank_window_sizeдолжно быть больше или равноsizeи больше или равно1. По умолчанию параметрsize.
Пример запроса с использованием RRF:
resp = client.search(
index="example-index",
retriever={
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "shoes"
}
}
}
},
{
"knn": {
"field": "vector",
"query_vector": [
1.25,
2,
3.5
],
"k": 50,
"num_candidates": 100
}
}
],
"rank_window_size": 50,
"rank_constant": 20
}
},
)
print(resp) const response = await client.search({
index: "example-index",
retriever: {
rrf: {
retrievers: [
{
standard: {
query: {
term: {
text: "shoes",
},
},
},
},
{
knn: {
field: "vector",
query_vector: [1.25, 2, 3.5],
k: 50,
num_candidates: 100,
},
},
],
rank_window_size: 50,
rank_constant: 20,
},
},
});
console.log(response); GET example-index/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "shoes"
}
}
}
},
{
"knn": {
"field": "vector",
"query_vector": [1.25, 2, 3.5],
"k": 50,
"num_candidates": 100
}
}
],
"rank_window_size": 50,
"rank_constant": 20
}
}
} В приведенном выше примере мы выполняем knn и standard извлекатели независимо друг от друга. Затем мы используем rrf извлекатель для объединения результатов.
| Сначала мы выполняем поиск kNN, заданный извлекателем | |
| Во-вторых, мы выполняем запрос, заданный извлекателем | |
| Затем на координационном узле мы объединяем лучшие документы поиска kNN с лучшими документами запроса и ранжируем их на основе формулы RRF, используя параметры из извлекателя |
Обратите внимание, что если k из поиска knn больше, чем rank_window_size, результаты усекаются до rank_window_size. Если k меньше, чем rank_window_size, результаты имеют размер k.
Поддерживаемые функции слияния рангов с обратной величиной
Извлекатель rrf поддерживает:
Извлекатель rrf в настоящее время не поддерживает:
Использование неподдерживаемых функций в рамках поиска с извлекателем rrf приводит к исключению.
Лучше избегать указания момента времени в запросе, так как RRF создаёт его внутренне и он общий для всех подключаемых извлекателей, чтобы обеспечить согласованные результаты.
Слияние рангов с обратной величиной, используя несколько стандартных извлекателей
Извлекатель rrf предоставляет способ объединения и ранжирования нескольких standard извлекателей. Основной случай использования — объединение лучших документов из традиционного запроса BM25 и запроса ELSER для достижения улучшенной релевантности.
Пример запроса с использованием RRF с несколькими стандартными извлекателями:
resp = client.search(
index="example-index",
retriever={
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "blue shoes sale"
}
}
}
},
{
"standard": {
"query": {
"sparse_vector": {
"field": "ml.tokens",
"inference_id": "my_elser_model",
"query": "What blue shoes are on sale?"
}
}
}
}
],
"rank_window_size": 50,
"rank_constant": 20
}
},
)
print(resp) const response = await client.search({
index: "example-index",
retriever: {
rrf: {
retrievers: [
{
standard: {
query: {
term: {
text: "blue shoes sale",
},
},
},
},
{
standard: {
query: {
sparse_vector: {
field: "ml.tokens",
inference_id: "my_elser_model",
query: "What blue shoes are on sale?",
},
},
},
},
],
rank_window_size: 50,
rank_constant: 20,
},
},
});
console.log(response); GET example-index/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "blue shoes sale"
}
}
}
},
{
"standard": {
"query": {
"sparse_vector":{
"field": "ml.tokens",
"inference_id": "my_elser_model",
"query": "What blue shoes are on sale?"
}
}
}
}
],
"rank_window_size": 50,
"rank_constant": 20
}
}
} В приведенном выше примере мы выполняем каждый из двух standard извлекателей независимо друг от друга. Затем мы используем rrf извлекатель для объединения результатов.
| Сначала мы запускаем извлекатель | |
| Затем мы запускаем извлекатель | |
| Извлекатель |
Это не только избавляет от необходимости определения подходящего весового коэффициента с использованием линейной комбинации, но RRF также демонстрирует улучшение релевантности по сравнению с любым запросом по отдельности.
Слияние рангов с обратной величиной с использованием подзапросов
Слияние рангов с обратной величиной с использованием подзапросов больше не поддерживается. Используйте API извлекателей вместо этого. Смотрите примеры с несколькими стандартными извлекателями.
Полный пример слияния рангов обратной зависимости
Начнем с создания отображения для индекса с текстовым полем, векторным полем и целочисленным полем, а также индексирования нескольких документов. В этом примере мы будем использовать вектор только с одной размерностью, чтобы упростить объяснение ранжирования.
resp = client.indices.create(
index="example-index",
mappings={
"properties": {
"text": {
"type": "text"
},
"vector": {
"type": "dense_vector",
"dims": 1,
"index": True,
"similarity": "l2_norm",
"index_options": {
"type": "hnsw"
}
},
"integer": {
"type": "integer"
}
}
},
)
print(resp)
resp1 = client.index(
index="example-index",
id="1",
document={
"text": "rrf",
"vector": [
5
],
"integer": 1
},
)
print(resp1)
resp2 = client.index(
index="example-index",
id="2",
document={
"text": "rrf rrf",
"vector": [
4
],
"integer": 2
},
)
print(resp2)
resp3 = client.index(
index="example-index",
id="3",
document={
"text": "rrf rrf rrf",
"vector": [
3
],
"integer": 1
},
)
print(resp3)
resp4 = client.index(
index="example-index",
id="4",
document={
"text": "rrf rrf rrf rrf",
"integer": 2
},
)
print(resp4)
resp5 = client.index(
index="example-index",
id="5",
document={
"vector": [
0
],
"integer": 1
},
)
print(resp5)
resp6 = client.indices.refresh(
index="example-index",
)
print(resp6) const response = await client.indices.create({
index: "example-index",
mappings: {
properties: {
text: {
type: "text",
},
vector: {
type: "dense_vector",
dims: 1,
index: true,
similarity: "l2_norm",
index_options: {
type: "hnsw",
},
},
integer: {
type: "integer",
},
},
},
});
console.log(response);
const response1 = await client.index({
index: "example-index",
id: 1,
document: {
text: "rrf",
vector: [5],
integer: 1,
},
});
console.log(response1);
const response2 = await client.index({
index: "example-index",
id: 2,
document: {
text: "rrf rrf",
vector: [4],
integer: 2,
},
});
console.log(response2);
const response3 = await client.index({
index: "example-index",
id: 3,
document: {
text: "rrf rrf rrf",
vector: [3],
integer: 1,
},
});
console.log(response3);
const response4 = await client.index({
index: "example-index",
id: 4,
document: {
text: "rrf rrf rrf rrf",
integer: 2,
},
});
console.log(response4);
const response5 = await client.index({
index: "example-index",
id: 5,
document: {
vector: [0],
integer: 1,
},
});
console.log(response5);
const response6 = await client.indices.refresh({
index: "example-index",
});
console.log(response6); PUT example-index
{
"mappings": {
"properties": {
"text" : {
"type" : "text"
},
"vector": {
"type": "dense_vector",
"dims": 1,
"index": true,
"similarity": "l2_norm",
"index_options": {
"type": "hnsw"
}
},
"integer" : {
"type" : "integer"
}
}
}
}
PUT example-index/_doc/1
{
"text" : "rrf",
"vector" : [5],
"integer": 1
}
PUT example-index/_doc/2
{
"text" : "rrf rrf",
"vector" : [4],
"integer": 2
}
PUT example-index/_doc/3
{
"text" : "rrf rrf rrf",
"vector" : [3],
"integer": 1
}
PUT example-index/_doc/4
{
"text" : "rrf rrf rrf rrf",
"integer": 2
}
PUT example-index/_doc/5
{
"vector" : [0],
"integer": 1
}
POST example-index/_refresh Теперь выполним поиск с помощью ретривера rrf с ретривером standard, задающим запрос BM25, ретривером knn, задающим поиск kNN, и агрегацией терминов.
resp = client.search(
index="example-index",
retriever={
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "rrf"
}
}
}
},
{
"knn": {
"field": "vector",
"query_vector": [
3
],
"k": 5,
"num_candidates": 5
}
}
],
"rank_window_size": 5,
"rank_constant": 1
}
},
size=3,
aggs={
"int_count": {
"terms": {
"field": "integer"
}
}
},
)
print(resp) const response = await client.search({
index: "example-index",
retriever: {
rrf: {
retrievers: [
{
standard: {
query: {
term: {
text: "rrf",
},
},
},
},
{
knn: {
field: "vector",
query_vector: [3],
k: 5,
num_candidates: 5,
},
},
],
rank_window_size: 5,
rank_constant: 1,
},
},
size: 3,
aggs: {
int_count: {
terms: {
field: "integer",
},
},
},
});
console.log(response); GET example-index/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "rrf"
}
}
}
},
{
"knn": {
"field": "vector",
"query_vector": [3],
"k": 5,
"num_candidates": 5
}
}
],
"rank_window_size": 5,
"rank_constant": 1
}
},
"size": 3,
"aggs": {
"int_count": {
"terms": {
"field": "integer"
}
}
}
} И получим ответ с отсортированными hits и результатом агрегации терминов. У нас есть возможность отображения наших высокоранжированных документов как с использованием ранжировщика, так и с использованием _rank варианта.
{
"took": ...,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 5,
"relation" : "eq"
},
"max_score" : ...,
"hits" : [
{
"_index" : "example-index",
"_id" : "3",
"_score" : 0.8333334,
"_source" : {
"integer" : 1,
"vector" : [
3
],
"text" : "rrf rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "2",
"_score" : 0.5833334,
"_source" : {
"integer" : 2,
"vector" : [
4
],
"text" : "rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "4",
"_score" : 0.5,
"_source" : {
"integer" : 2,
"text" : "rrf rrf rrf rrf"
}
}
]
},
"aggregations" : {
"int_count" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : 1,
"doc_count" : 3
},
{
"key" : 2,
"doc_count" : 2
}
]
}
}
} Давайте разберем, как были ранжированы эти совпадения. Мы начинаем с выполнения ретривера standard, задающего запрос, и ретривера knn, задающего поиск kNN, по отдельности, чтобы получить их индивидуальные совпадения.
Сначала рассмотрим совпадения для запроса от ретривера standard.
"hits" : [
{
"_index" : "example-index",
"_id" : "4",
"_score" : 0.16152832,
"_source" : {
"integer" : 2,
"text" : "rrf rrf rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "3",
"_score" : 0.15876243,
"_source" : {
"integer" : 1,
"vector" : [3],
"text" : "rrf rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "2",
"_score" : 0.15350538,
"_source" : {
"integer" : 2,
"vector" : [4],
"text" : "rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "1",
"_score" : 0.13963442,
"_source" : {
"integer" : 1,
"vector" : [5],
"text" : "rrf"
}
}
] | ранг 1, | |
| ранг 2, | |
| ранг 3, | |
| ранг 4, |
Обратите внимание, что для нашего первого совпадения нет значения для поля vector. Теперь посмотрим на результаты поиска kNN от ретривера knn.
"hits" : [
{
"_index" : "example-index",
"_id" : "3",
"_score" : 1.0,
"_source" : {
"integer" : 1,
"vector" : [3],
"text" : "rrf rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "2",
"_score" : 0.5,
"_source" : {
"integer" : 2,
"vector" : [4],
"text" : "rrf rrf"
}
},
{
"_index" : "example-index",
"_id" : "1",
"_score" : 0.2,
"_source" : {
"integer" : 1,
"vector" : [5],
"text" : "rrf"
}
},
{
"_index" : "example-index",
"_id" : "5",
"_score" : 0.1,
"_source" : {
"integer" : 1,
"vector" : [0]
}
}
] | ранг 1, | |
| ранг 2, | |
| ранг 3, | |
| ранг 4, |
Теперь мы можем взять два набора результатов, ранжированных по отдельности, и применить к ним формулу RRF, используя параметры от ретривера rrf, чтобы получить окончательный ранг.
# doc | query | knn | score _id: 1 = 1.0/(1+4) + 1.0/(1+3) = 0.4500 _id: 2 = 1.0/(1+3) + 1.0/(1+2) = 0.5833 _id: 3 = 1.0/(1+2) + 1.0/(1+1) = 0.8333 _id: 4 = 1.0/(1+1) = 0.5000 _id: 5 = 1.0/(1+4) = 0.2000
Мы ранжируем документы на основе формулы RRF с rank_window_size 5, отбрасывая нижние 2 документы в нашем наборе результатов RRF с size 3. В результате мы получаем _id: 3 как _rank: 1, _id: 2 как _rank: 2, и _id: 4 как _rank: 3. Этот ранг соответствует ожидаемому набору результатов из исходного поиска RRF.
Объяснение в RRF
Помимо отдельных деталей оценки запроса, мы можем использовать параметр explain=true, чтобы получить информацию о том, как вычислялись оценки RRF для каждого документа. Используя пример выше и добавив explain=true в запрос поиска, мы получим ответ, который будет выглядеть следующим образом:
{
"hits":
[
{
"_index": "example-index",
"_id": "3",
"_score": 0.8333334,
"_explanation":
{
"value": 0.8333334,
"description": "rrf score: [0.8333334] computed for initial ranks [2, 1] with rankConstant: [1] as sum of [1 / (rank + rankConstant)] for each query",
"details":
[
{
"value": 2,
"description": "rrf score: [0.33333334], for rank [2] in query at index [0] computed as [1 / (2 + 1]), for matching query with score: ",
"details":
[
{
"value": 0.15876243,
"description": "weight(text:rrf in 0) [PerFieldSimilarity], result of:",
"details":
[
...
]
}
]
},
{
"value": 1,
"description": "rrf score: [0.5], for rank [1] in query at index [1] computed as [1 / (1 + 1]), for matching query with score: ",
"details":
[
{
"value": 1,
"description": "within top k documents",
"details":
[]
}
]
}
]
}
}
...
]
} | окончательная оценка RRF для документа с | |
| описание того, как эта оценка вычислялась на основе рангов этого документа в каждом отдельном запросе | |
| подробности о том, как вычислялась оценка RRF для каждого из запросов | |
|
| |
| стандартный вывод | |
|
|
В дополнение к вышесказанному, объяснение в RRF также поддерживает именованные запросы с помощью параметра _name. Использование именованных запросов позволяет проще и интуитивно понять вычисление оценки RRF, особенно при работе с несколькими запросами. Таким образом, мы теперь имеем:
GET example-index/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"text": "rrf"
}
}
}
},
{
"knn": {
"field": "vector",
"query_vector": [3],
"k": 5,
"num_candidates": 5,
"_name": "my_knn_query"
}
}
],
"rank_window_size": 5,
"rank_constant": 1
}
},
"size": 3,
"aggs": {
"int_count": {
"terms": {
"field": "integer"
}
}
}
} | Здесь мы задаем |
Ответ теперь будет включать именованный запрос в объяснении:
{
"hits":
[
{
"_index": "example-index",
"_id": "3",
"_score": 0.8333334,
"_explanation":
{
"value": 0.8333334,
"description": "rrf score: [0.8333334] computed for initial ranks [2, 1] with rankConstant: [1] as sum of [1 / (rank + rankConstant)] for each query",
"details":
[
{
"value": 2,
"description": "rrf score: [0.33333334], for rank [2] in query at index [0] computed as [1 / (2 + 1]), for matching query with score: ",
"details":
[
...
]
},
{
"value": 1,
"description": "rrf score: [0.5], for rank [1] in query [my_knn_query] computed as [1 / (1 + 1]), for matching query with score: ",
"details":
[
...
]
}
]
}
}
...
]
} | Вместо анонимного |
Пагинация в RRF
При использовании rrf вы можете странировать результаты, используя параметр from. Поскольку окончательный ранг полностью зависит от первоначальных рангов запроса, чтобы обеспечить согласованность при странировании, мы должны убедиться, что, хотя from изменяется, порядок уже увиденного остается неизменным. Для этого мы используем фиксированный rank_window_size в качестве всего доступного набора результатов, на котором можно странировать. Это по существу означает, что если:
-
from + size≤rank_window_size: мы можем получитьresults[from: from+size]документов из окончательного отсортированного набора результатовrrf -
from + size>rank_window_size: мы получим 0 результатов, так как запрос выйдет за пределы доступного набора результатов размеромrank_window_size.
Важно отметить, что поскольку rank_window_size — это все результаты, которые мы увидим от отдельных компонентов запроса, пагинация гарантирует согласованность, т.е. никакие документы не пропускаются и не дублируются на разных страницах, если rank_window_size остается неизменным. Если rank_window_size изменяется, порядок результатов также может измениться, даже для одинаковых рангов.
Чтобы проиллюстрировать всё вышесказанное, рассмотрим следующий упрощенный пример, где у нас есть два запроса, queryA и queryB, и их отсортированные документы:
| queryA | queryB | _id: | 1 | 5 | _id: | 2 | 4 | _id: | 3 | 3 | _id: | 4 | 1 | _id: | | 2 |
Для rank_window_size=5 мы увидим все документы из обоих queryA и queryB. Предполагая rank_constant=1, оценки rrf будут:
# doc | queryA | queryB | score _id: 1 = 1.0/(1+1) + 1.0/(1+4) = 0.7 _id: 2 = 1.0/(1+2) + 1.0/(1+5) = 0.5 _id: 3 = 1.0/(1+3) + 1.0/(1+3) = 0.5 _id: 4 = 1.0/(1+4) + 1.0/(1+2) = 0.533 _id: 5 = 0 + 1.0/(1+1) = 0.5
Таким образом, окончательный отсортированный набор результатов будет [1, 4, 2, 3, 5], и мы будем странировать по нему, так как rank_window_size == len(results). В этом сценарии у нас будет:
-
from=0, size=2вернет документы [1,4] с рангами[1, 2] -
from=2, size=2вернет документы [2,3] с рангами[3, 4] -
from=4, size=2вернет документ [5] с рангом[5] -
from=6, size=2вернет пустой набор результатов, так как больше результатов для итерации нет
Теперь, если у нас есть rank_window_size=2, мы увидим только документы [1, 2] и [5, 4] для запросов queryA и queryB соответственно. Вычислив, мы увидим, что результаты теперь будут немного отличаться, потому что у нас не будет информации о документах в позициях [3: end] ни для одного запроса.
# doc | queryA | queryB | score _id: 1 = 1.0/(1+1) + 0 = 0.5 _id: 2 = 1.0/(1+2) + 0 = 0.33 _id: 4 = 0 + 1.0/(1+2) = 0.33 _id: 5 = 0 + 1.0/(1+1) = 0.5
Окончательный отсортированный набор результатов будет [1, 5, 2, 4], и мы сможем странировать по верхним rank_window_size результатам, т.е. [1, 5]. Таким образом, для тех же параметров, что и выше, у нас будет:
-
from=0, size=2вернет [1,5] с рангами[1, 2] -
from=2, size=2вернет пустой набор результатов, так как он выйдет за пределы доступныхrank_window_sizeрезультатов.
Агрегации в RRF
Обработчик rrf поддерживает агрегации из всех указанных под-обработчиков. Важные замечания об агрегациях:
- Они работают со всем набором результатов от всех под-обработчиков
- Они не ограничены параметром
rank_window_size - Они обрабатывают объединение всех совпадающих документов
Например, рассмотрим следующий набор документов:
{
"_id": 1, "termA": "foo",
"_id": 2, "termA": "foo", "termB": "bar",
"_id": 3, "termA": "aardvark", "termB": "bar",
"_id": 4, "termA": "foo", "termB": "bar"
} Выполните агрегацию по термину в поле termA, используя обработчик rrf:
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"term": {
"termB": "bar"
}
}
}
},
{
"standard": {
"query": {
"match_all": { }
}
}
}
],
"rank_window_size": 1
}
},
"size": 1,
"aggs": {
"termA_agg": {
"terms": {
"field": "termA"
}
}
}
} Результаты агрегации будут включать все совпадающие документы, независимо от rank_window_size.
{
"foo": 3,
"aardvark": 1
} Выделение в RRF
Используя обработчик rrf, вы можете добавить выделенные фрагменты, чтобы показать релевантные фрагменты текста в результатах поиска. Выделенные фрагменты вычисляются на основе соответствующих текстовых запросов, определенных в под-обработчиках.
Выделение по векторным полям, используя либо обработчик knn, либо запрос knn, не поддерживается.
Более конкретный пример выделения в RRF также можно найти на странице примеров обработчиков.
Внутренние результаты в RRF
Обработчик rrf поддерживает функциональность внутренних результатов, позволяя получать связанные вложенные или родительские/дочерние документы наряду с основными результатами поиска. Внутренние результаты могут быть указаны в качестве части любого вложенного под-обработчика и будут переданы в родительский обработчик верхнего уровня. Обратите внимание, что вычисление внутренних результатов будет выполнено только в конце оценки обработчика rrf по лучшим совпадающим документам, а не в рамках выполнения запроса вложенных под-обработчиков.
При определении нескольких секций inner_hits в под-обработчиках:
- Каждая секция
inner_hitsдолжна иметь уникальное имя - Имена должны быть уникальными для всех под-обработчиков в запросе поиска
© 2023-2025 Elasticsearch
As of September 2024, Elasticsearch is available under a choice of three licenses: the Server Side Public License (SSPL), the Elastic License, or the AGPLv3 (OSI approved).
Elasticsearch and the Elasticsearch logo are trademarks of Elasticsearch B.V., registered in the U.S. and in other countries.
https://www.elastic.co/guide/en/elasticsearch/reference/8.17/rrf.html