Релевантность и 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):
где $f$ — количество вхождений термина в документ. При $k_1 = 1.2$ разница между 1 и 5 вхождениями существенная, между 20 и 100 — почти нулевая.
2. Обратная частота документа (Inverse Document Frequency)
Слово «и» есть в каждом документе — оно ничего не говорит о релевантности. Слово «elasticsearch» — редкое и значимое. IDF штрафует частые слова и поощряет редкие:
где $N$ — всего документов в индексе, $n_t$ — сколько из них содержат термин $t$. Чем реже термин в корпусе, тем выше его IDF.
3. Нормализация по длине поля
Документ из 5 слов с одним словом «поиск» — скорее всего, именно о поиске. Документ из 1000 слов с одним «поиском» мог упомянуть его случайно. BM25 делит оценку на нормализованную длину через параметр $b$ (дефолт 0.75):
где $|D|$ — длина конкретного документа в токенах, $\overline{dl}$ — средняя длина документа по индексу.
Итоговая формула
Финальный скор — сумма по всем терминам запроса:
Не нужно запоминать её наизусть. Достаточно помнить три рычага: частота термина в документе, редкость термина в корпусе и длина поля.
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"]Разбираем _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, но без лишнего шума. Пользуйтесь этим, когда документ неожиданно высоко или низко в выдаче и хочется понять — почему.
Нюанс со статистикой на шардах
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 сходилась к глобальной — эффект становится пренебрежимо малым.
Параметры 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 вовсе — экономия ресурсов. Скор появляется только там, где нужен для ранжирования.
См. также
- Бустинг полей и function_score: управление ранжированием
- Query DSL: структура запроса и контекст query vs filter
- Полнотекстовые запросы: match, match_phrase, multi_match
- Составные запросы: bool и комбинирование условий
- Подсветка, сортировка и пагинация результатов
- Анатомия анализатора: char filters, токенизатор, token filters