Релевантность и BM25: как считается _score

Каждый раз, когда ES возвращает результаты поиска, рядом с каждым хитом стоит число _score. Чем оно больше — тем «лучше» документ совпадает с запросом по мнению движка. Откуда берётся это число?

Что такое _score

_score — число с плавающей точкой, которое ES вычисляет динамически под конкретный запрос. Оно не хранится заранее: считается заново при каждом поиске.

Важно понять сразу: _score — это относительный рейтинг внутри одной выдачи. Оценка 4.2 при запросе «ноутбук» не означает того же, что 4.2 при запросе «купить ноутбук недорого». Числа имеют смысл только в сравнении друг с другом в рамках одного запроса.

С ES 5.0 алгоритм по умолчанию — BM25 (Okapi BM25). До этого использовался классический TF-IDF, но BM25 лучше работает на коротких полях и при повторных вхождениях термина.

Три фактора BM25

1. Частота термина (Term Frequency)

Если слово «поиск» встречается в документе 5 раз — он скорее всего о поиске. Но 100 вхождений не в 20 раз лучше 5 — отдача убывает. BM25 учитывает это через параметр насыщения $k_1$ (дефолт 1.2):

$$\text{TF-часть}(f) = \frac{f \cdot (k_1 + 1)}{f + k_1}$$

где $f$ — количество вхождений термина в документ. При $k_1 = 1.2$ разница между 1 и 5 вхождениями существенная, между 20 и 100 — почти нулевая.

2. Обратная частота документа (Inverse Document Frequency)

Слово «и» есть в каждом документе — оно ничего не говорит о релевантности. Слово «elasticsearch» — редкое и значимое. IDF штрафует частые слова и поощряет редкие:

$$\text{IDF}(t) = \ln\!\left(1 + \frac{N - n_t + 0.5}{n_t + 0.5}\right)$$

где $N$ — всего документов в индексе, $n_t$ — сколько из них содержат термин $t$. Чем реже термин в корпусе, тем выше его IDF.

3. Нормализация по длине поля

Документ из 5 слов с одним словом «поиск» — скорее всего, именно о поиске. Документ из 1000 слов с одним «поиском» мог упомянуть его случайно. BM25 делит оценку на нормализованную длину через параметр $b$ (дефолт 0.75):

$$\text{TF-norm}(f, |D|) = \frac{f \cdot (k_1 + 1)}{f + k_1 \cdot \!\left(1 - b + b \cdot \dfrac{|D|}{\overline{dl}}\right)}$$

где $|D|$ — длина конкретного документа в токенах, $\overline{dl}$ — средняя длина документа по индексу.

Итоговая формула

Финальный скор — сумма по всем терминам запроса:

$$\text{score}(D, Q) = \sum_{t \in Q} \operatorname{IDF}(t) \cdot \frac{f(t,D) \cdot (k_1 + 1)}{f(t,D) + k_1 \cdot \!\left(1 - b + b \cdot \dfrac{|D|}{\overline{dl}}\right)}$$

Не нужно запоминать её наизусть. Достаточно помнить три рычага: частота термина в документе, редкость термина в корпусе и длина поля.

Check yourself
Документ A содержит слово «сервер» 1 раз, документ B — 20 раз. Можно ли сказать, что вклад этого слова в _score документа B будет в 20 раз выше, чем у документа A?
flowchart TD Q["Запрос: 'elasticsearch поиск'"] --> T1["Термин 1: elasticsearch"] Q --> T2["Термин 2: поиск"] T1 --> IDF1["IDF — редкий в корпусе\nвысокое значение"] T1 --> TFN1["TF-norm — частота в документе\nс учётом длины поля"] T2 --> IDF2["IDF — частый в корпусе\nниже значение"] T2 --> TFN2["TF-norm — частота в документе\nс учётом длины поля"] IDF1 & TFN1 --> MUL1["× вклад термина 1"] IDF2 & TFN2 --> MUL2["× вклад термина 2"] MUL1 & MUL2 --> SUM["∑ сумма = _score"]
flowchart TD
    Q["Запрос: 'elasticsearch поиск'"] --> T1["Термин 1: elasticsearch"]
    Q --> T2["Термин 2: поиск"]
    T1 --> IDF1["IDF — редкий в корпусе\nвысокое значение"]
    T1 --> TFN1["TF-norm — частота в документе\nс учётом длины поля"]
    T2 --> IDF2["IDF — частый в корпусе\nниже значение"]
    T2 --> TFN2["TF-norm — частота в документе\nс учётом длины поля"]
    IDF1 & TFN1 --> MUL1["× вклад термина 1"]
    IDF2 & TFN2 --> MUL2["× вклад термина 2"]
    MUL1 & MUL2 --> SUM["∑ сумма = _score"]
Как BM25 собирает _score из вкладов отдельных терминов

Разбираем _score через explain

ES умеет «раскрывать» расчёт скора для каждого документа. Добавьте "explain": true в тело запроса:

GET /articles/_search
{
  "explain": true,
  "query": {
    "match": { "body": "elasticsearch поиск" }
  }
}

В каждом хите появится поле _explanation — дерево вычислений:

"_explanation": {
  "value": 3.847,
  "description": "sum of:",
  "details": [
    {
      "value": 2.4,
      "description": "weight(body:elasticsearch in doc#0)",
      "details": [
        {
          "value": 1.2,
          "description": "idf, computed as log(1 + (N-n+0.5)/(n+0.5))"
        },
        {
          "value": 2.0,
          "description": "tf, computed as freq/(freq+k1*(1-b+b*dl/avgdl))"
        }
      ]
    },
    {
      "value": 1.447,
      "description": "weight(body:поиск in doc#0)"
    }
  ]
}

Читать это удобнее в Kibana Dev Tools — там дерево раскрывается интерактивно. Для отладки конкретного документа есть отдельный endpoint:

GET /articles/_explain/1
{
  "query": {
    "match": { "body": "elasticsearch поиск" }
  }
}

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

Check yourself
Вы хотите понять, почему конкретный документ с id=42 получил неожиданно низкий _score по запросу match на поле title. Какой REST-запрос отправить?

Нюанс со статистикой на шардах

IDF считается по шарду, а не по всему индексу. Если документы неравномерно распределились, один и тот же запрос может дать чуть разные скоры на разных шардах. На маленьких индексах это заметно.

Два способа решить проблему:

  • Один шард ("number_of_shards": 1) — для небольших индексов, где важна точность ранжирования.
  • search_type=dfs_query_then_fetch — ES сначала собирает глобальную IDF-статистику со всех шардов, потом считает скор:
GET /articles/_search?search_type=dfs_query_then_fetch
{
  "query": { "match": { "body": "elasticsearch" } }
}

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

Check yourself
Почему два одинаковых документа на разных шардах одного индекса могут получить разный _score по одному и тому же запросу?

Параметры BM25: k1 и b

Параметры $k_1$ и $b$ настраиваются через кастомный similarity в настройках индекса:

PUT /articles
{
  "settings": {
    "similarity": {
      "my_bm25": {
        "type": "BM25",
        "k1": 1.5,
        "b":  0.3
      }
    }
  },
  "mappings": {
    "properties": {
      "body": {
        "type":       "text",
        "similarity": "my_bm25"
      }
    }
  }
}

Когда трогать $k_1$:

  • $k_1 = 0$ → частота термина не учитывается вообще (чистый IDF)
  • $k_1 = 1.2$ → дефолт, хорошо работает на большинстве задач
  • $k_1 > 2$ → больше веса у повторений; уместно для технической документации

Когда трогать $b$:

  • $b = 0$ → длина поля игнорируется полностью
  • $b = 0.75$ → дефолт
  • $b = 1$ → полная нормализация, жёстко штрафует длинные документы

На практике менять эти параметры стоит только после замеров с реальными запросами и реальными данными. Интуитивный тюнинг BM25 обычно не помогает — помогают данные.

Когда _score не нужен

В Query DSL: структура запроса и контекст query vs filter мы разбирали filter context. Напомним: всё, что попадает в filter внутри bool, скор не меняет и кешируется. Если нужно найти «все ноутбуки дешевле 50 000» без ранжирования — кладите условия в filter, а не в must.

Если вы сортируете по полю (sort: [{ "price": "asc" }]), ES по умолчанию не вычисляет _score вовсе — экономия ресурсов. Скор появляется только там, где нужен для ранжирования.

Quick recall
Как увидеть пошаговый расчёт `_score` для каждого результата в поиске?
Quick recall
Что происходит с рангом документа, когда слово встречается в нём 5 раз vs 100 раз, и почему?
Quick recall
Что находится в поле `_score` рядом с каждым результатом поиска в ES, и когда оно вычисляется?

См. также