Гибридный поиск: retrievers и RRF

В предыдущих статьях разобрали два противоположных подхода: BM25 ищет по точным словам, а Векторный поиск: поле dense_vector и запрос kNN и ELSER и разреженные векторы для семантики — по смыслу. Каждый подход точен в одной ситуации и слеп в другой.

Гибридный поиск объединяет оба — и почти всегда выигрывает у каждого из них по отдельности.


Почему одного подхода мало

Возьмём каталог товаров.

Запрос: «iPhone 15 Pro» — точный артикул.

  • BM25 справится отлично: найдёт документы с этими словами.
  • Семантика может вернуть «Samsung Galaxy S24» как «похожий по смыслу» — это провал.

Запрос: «чехол защищает телефон при падении» — смысловой запрос.

  • BM25 буквально ищет слово «падении» — пропустит товары с описанием «ударопрочный» или «защита корпуса».
  • Семантика поймёт намерение и найдёт нужное.

Гибрид одновременно покрывает оба сценария.


Reciprocal Rank Fusion: слияние без общей шкалы

Главная проблема при объединении двух ранжированных списков — несовместимость баллов. _score от BM25 и косинусное сходство kNN живут на разных числовых шкалах: складывать их напрямую бессмысленно.

RRF не смотрит на баллы вообще. Он работает только с позициями:

$$\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}$$

Где:

  • $R$ — набор ранжированных списков (BM25, kNN и т. д.),
  • $\text{rank}_r(d)$ — позиция документа $d$ в списке $r$ (нумерация с 1),
  • $k$ — константа сглаживания, по умолчанию $k = 60$.

Числовой пример при $k = 60$ и двух списках:

ДокументРанг BM25Ранг kNNRRF-балл
A11$\frac{1}{61} + \frac{1}{61} \approx 0{,}033$
B24$\frac{1}{62} + \frac{1}{64} \approx 0{,}032$
C1$\frac{1}{61} \approx 0{,}016$
D2$\frac{1}{62} \approx 0{,}016$

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

Константа $k = 60$ взята из оригинальной статьи Cormack, Clarke и Buettcher (SIGIR 2009) и хорошо работает без дополнительного тюнинга.

Check yourself
Документ занял 3-е место в BM25 и 7-е место в kNN. Рассчитайте его RRF-балл при k=60. Теперь представьте другой документ, который занял 1-е место только в BM25. Чей балл выше и почему?
flowchart TD Q[Поисковый запрос] --> ST[standard retriever] Q --> KN[knn retriever] ST --> BL[Список BM25 — rank_window_size кандидатов] KN --> KL[Список kNN — rank_window_size кандидатов] BL --> RRF[RRF — суммирует 1 делить на k+rank по каждому документу] KL --> RRF RRF --> RES[Топ-size финальных результатов]
flowchart TD
    Q[Поисковый запрос] --> ST[standard retriever]
    Q --> KN[knn retriever]
    ST --> BL[Список BM25 — rank_window_size кандидатов]
    KN --> KL[Список kNN — rank_window_size кандидатов]
    BL --> RRF[RRF — суммирует 1 делить на k+rank по каждому документу]
    KL --> RRF
    RRF --> RES[Топ-size финальных результатов]
Как RRF объединяет два независимых ранжированных списка в один

Синтаксис retrievers (ES 8.14+, GA с 8.16)

Вместо привычного ключа query в запросе появляется retriever. Доступны три основных типа:

ТипЧто делает
standardОборачивает любой Query DSL запрос
knnkNN-поиск (те же параметры, что у поля knn в _search)
rrfОбъединяет несколько retrievers через RRF

Минимальная структура гибридного запроса:

GET /index/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        { "standard": { "query": { "match": { "..." : "..." } } } },
        { "knn": { "field": "embedding", "query_vector": ["..."], "k": 10, "num_candidates": 100 } }
      ],
      "rank_window_size": 50,
      "rank_constant": 60
    }
  },
  "size": 10
}

rank_window_size — сколько кандидатов запрашивает каждый sub-retriever перед финальным слиянием. Значение должно быть не меньше size. Для size: 10 рекомендуется ставить 50–100: больший пул кандидатов улучшает качество финального ранжирования.

Check yourself
В запросе задано size: 10 и rank_window_size: 50. У нас два retriever'а. Сколько кандидатов максимально попадёт на этап слияния RRF, и сколько из них вернётся в ответе?

Полный пример: BM25 + kNN

Создадим индекс с текстовым и векторным полями:

PUT /catalog
{
  "mappings": {
    "properties": {
      "title":       { "type": "text" },
      "description": { "type": "text" },
      "embedding": {
        "type":       "dense_vector",
        "dims":       384,
        "index":      true,
        "similarity": "cosine"
      }
    }
  }
}

Гибридный запрос (вектор запроса — заглушка-пример; в реальном проекте его генерирует embedding-модель перед отправкой запроса):

GET /catalog/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "multi_match": {
                "query":  "ударопрочный чехол смартфон",
                "fields": ["title^2", "description"]
              }
            }
          }
        },
        {
          "knn": {
            "field":          "embedding",
            "query_vector":   [0.12, -0.45, 0.33],
            "num_candidates": 100,
            "k":              10
          }
        }
      ],
      "rank_window_size": 100,
      "rank_constant":    60
    }
  },
  "size": 10
}

Вариант с semantic_text — без своих векторов

Если вы используете Поле semantic_text и inference-эндпоинты, векторизовать запрос вручную не нужно — semantic-запрос делает это сам. Оба retriever'а будут типа standard, потому что Query DSL уже содержит всю логику:

GET /articles/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "match": {
                "title": "наушники для путешествий"
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "semantic": {
                "field": "content",
                "query": "наушники для путешествий"
              }
            }
          }
        }
      ],
      "rank_window_size": 50
    }
  },
  "size": 5
}

knn-retriever здесь не нужен — semantic-запрос сам выполняет векторный поиск через ELSER внутри ES.


Общий фильтр через rrf

Когда одно и то же условие нужно применить сразу к обоим путям, filter на уровне rrf избавляет от повторений:

{
  "retriever": {
    "rrf": {
      "retrievers": [
        { "standard": { "query": { "match": { "title": "чехол" } } } },
        { "knn": { "field": "embedding", "query_vector": ["..."], "k": 10, "num_candidates": 100 } }
      ],
      "rank_window_size": 50,
      "filter": [
        { "term":  { "in_stock": true } },
        { "range": { "price": { "lte": 2000 } } }
      ]
    }
  },
  "size": 10
}

Фильтр применяется до слияния — оба списка кандидатов уже содержат только документы, прошедшие условие.


Когда гибрид оправдан

Гибридный поиск добавляет сложность: нужно хранить два поля, вычислять эмбеддинги при индексации и при каждом запросе. Это не всегда нужно.

Оставайтесь на BM25, если:

  • Пользователи ищут по артикулам, именам, кодам — точные термины важнее смысла.
  • Корпус маленький или хорошо структурированный.

Чистый семантический поиск, если:

  • Запросы — развёрнутые вопросы на естественном языке, ключевые слова непредсказуемы.
  • Нет требований к точным совпадениям.

Гибрид BM25 + kNN/ELSER, если:

  • Часть запросов — точные термины, часть — смысловые вопросы (типичная поисковая строка на сайте).
  • Нужна максимальная полнота охвата.
  • Качество поиска приоритетнее экономии ресурсов.

На практике для продуктовых каталогов, баз знаний и статейных порталов гибридный поиск почти всегда обгоняет любой из подходов по отдельности — именно поэтому он стал стандартной рекомендацией Elastic для production-сценариев.


Quick recall
Какой ключ в запросе используется для гибридного поиска вместо традиционного query?
Quick recall
В чём принципиальное отличие RRF от простого сложения баллов _score?
Quick recall
Почему BM25 один не справляется с запросом «чехол защищает телефон при падении»?

См. также

Sources

  1. RRF retriever | Elasticsearch Reference
  2. Elasticsearch retrievers: architecture, use-cases & implementation — Elasticsearch Labs
  3. Retrievers | Elasticsearch Guide [8.19] | Elastic
  4. Reciprocal rank fusion | Elasticsearch Reference