Бустинг полей и function_score: управление ранжированием

В статье о BM25 мы разобрали, откуда берётся _score. Но BM25 знает только о тексте: частоте слов, их редкости и длине поля. Он не знает, что заголовок важнее описания, что свежая статья лучше трёхлетней или что материал с миллионом просмотров полезнее нулевого. Для этого есть три инструмента: boost в теле запроса, синтаксис ^N в multi_match и function_score.

Boost на уровне запроса

boost — это умножающий коэффициент для отдельного условия. Добавьте его прямо к любому запросу:

GET /articles/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": {
              "query": "elasticsearch",
              "boost": 3.0
            }
          }
        },
        {
          "match": {
            "body": {
              "query": "elasticsearch"
            }
          }
        }
      ]
    }
  }
}

BM25-скор из ветки title умножается на 3.0, из body остаётся как есть. Документ, в заголовке которого встречается «elasticsearch», окажется выше того, где это слово только в теле.

Если вы используете multi_match — boost на поле пишется через ^:

GET /articles/_search
{
  "query": {
    "multi_match": {
      "query": "elasticsearch поиск",
      "fields": ["title^3", "summary^1.5", "body^1"]
    }
  }
}

"title^3" — то же, что boost: 3.0 на поле title. Компактный способ расставить приоритеты по полям.

Проверь себя
В запросе multi_match с полями `["title^4", "body^1"]` — во сколько раз совпадение в поле title «весит» больше, чем в body?

function_score: полный контроль над скором

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

Структура:

GET /articles/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "body": "elasticsearch" } },
      "functions": [],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  }
}

Два ключевых параметра:

  • score_mode — как объединить результаты нескольких функций друг с другом (multiply, sum, avg, first, max, min)
  • boost_mode — как соединить итог функций с оригинальным BM25-скором. При "multiply" (дефолт): $\text{score}_{\text{final}} = \text{score}_{\text{BM25}} \times f(\text{functions})$
flowchart TD Q[Запрос: match или bool] --> BM[BM25 score для каждого документа] F1[Функция 1: gauss] --> SM{score_mode: multiply} F2[Функция 2: field_value_factor] --> SM SM --> CM[Объединённый результат функций] BM --> BM2{boost_mode: multiply} CM --> BM2 BM2 --> R[_score финальный]
flowchart TD
    Q[Запрос: match или bool] --> BM[BM25 score для каждого документа]
    F1[Функция 1: gauss] --> SM{score_mode: multiply}
    F2[Функция 2: field_value_factor] --> SM
    SM --> CM[Объединённый результат функций]
    BM --> BM2{boost_mode: multiply}
    CM --> BM2
    BM2 --> R[_score финальный]
Как function_score соединяет BM25-скор с результатами функций

weight: простой множитель для категории документов

Самая простая функция — weight. Если документ попадает под filter, его скор умножается на заданный вес; остальные не затрагиваются:

GET /articles/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "body": "поиск" } },
      "functions": [
        {
          "filter": { "term": { "type": "tutorial" } },
          "weight": 2.0
        },
        {
          "filter": { "term": { "type": "release-notes" } },
          "weight": 0.3
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

Туториалы поднимаются вверх, release notes уходят вниз — без какой-либо аналитики текста.

Проверь себя
Вы хотите, чтобы документы с `featured: true` ранжировались вдвое выше, не меняя порядок остальных. Какую функцию использовать в function_score и что именно указать?

field_value_factor: учёт популярности

Если у документа есть числовой сигнал качества — просмотры, рейтинг, лайки — его учитывают через field_value_factor:

GET /articles/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "body": "elasticsearch" } },
      "functions": [
        {
          "field_value_factor": {
            "field":    "views",
            "factor":   0.1,
            "modifier": "log1p",
            "missing":  1
          }
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

Параметры:

  • field — числовое поле документа
  • factor — на что умножить значение поля перед modifier: $v = \text{factor} \times \text{field}$
  • modifier — нелинейное преобразование над $v$. log1p вычисляет $\ln(1 + v)$ и сглаживает разрыв между статьёй с тысячей и с миллионом просмотров
  • missing — значение по умолчанию, если поле отсутствует

Почему именно log1p? Без него статья с $1\,000\,000$ просмотров получит в $1\,000$ раз больший буст, чем статья с $1\,000$, и текстовая релевантность исчезнет. С log1p: $\ln(1 + 1\,000\,000) \approx 13.8$, а $\ln(1 + 1\,000) \approx 6.9$ — разница в $2$ раза вместо $1\,000$.

Decay-функции: учёт свежести

Для свежести или «близости» (дата публикации, геопозиция) — функции затухания: gauss, linear, exp. Чем дальше документ от «идеальной» точки, тем ниже его множитель.

GET /articles/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "body": "elasticsearch" } },
      "functions": [
        {
          "gauss": {
            "published_at": {
              "origin": "now",
              "scale":  "30d",
              "offset": "7d",
              "decay":  0.5
            }
          }
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

Параметры:

ПараметрСмысл
origin«Идеальная» точка; документы у origin получают множитель 1.0
offsetЗона без штрафа. В пределах offset от origin — множитель 1.0
scaleРасстояние за пределами offset-зоны, на котором множитель равен decay
decayЦелевой множитель на дистанции scale. При 0.5 — половина веса

Форма кривой у трёх функций разная:

  • gauss — плавный колокол, наиболее популярный вариант
  • linear — прямая линия, жёстко обрезается в ноль на краю
  • exp — экспоненциальный спад: быстрый штраф рядом с offset, медленный вдали
Графики трёх функций затухания: gauss, linear и exp
Проверь себя
Вы задали gauss с origin: "now", offset: "3d", scale: "14d", decay: 0.5. Какой множитель получит статья, опубликованная ровно 17 дней назад?

Комбинируем: BM25 + свежесть + популярность

Несколько функций в одном function_score работают в два шага: score_mode объединяет их между собой, boost_mode применяет итог к BM25-скору.

GET /articles/_search
{
  "query": {
    "function_score": {
      "query": { "match": { "body": "elasticsearch поиск" } },
      "functions": [
        {
          "gauss": {
            "published_at": {
              "origin": "now",
              "scale":  "30d",
              "offset": "7d",
              "decay":  0.5
            }
          }
        },
        {
          "field_value_factor": {
            "field":    "views",
            "factor":   0.1,
            "modifier": "log1p",
            "missing":  1
          }
        }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  }
}

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

$$\text{score}_{\text{final}} = \text{score}_{\text{BM25}} \times \text{gauss}(\text{published\_at}) \times \ln(1 + 0.1 \times \text{views})$$

Это типичная архитектура продуктового поиска: текстовая релевантность как фундамент, сигналы качества — как поправочные коэффициенты.

Несколько практических замечаний

Добавляйте "explain": true при отладке. Из предыдущей статьи вы уже знаете, как читать _explanation — для function_score она также покажет, какая функция и сколько добавила к скору.

Не ставьте boost больше 10–15 без измерений на реальных данных. Экстремальные значения убивают текстовую релевантность: документ с нужной категорией, но нерелевантным текстом всплывёт наверх.

function_score не кешируется — в отличие от условий в filter context. На горячих путях держите тяжёлые фильтры в bool.filter, а function_score применяйте там, где действительно нужен кастомный скоринг.

Полностью произвольная логикаscript_score с Painless-скриптом. Очень гибко, но медленнее встроенных функций.

Быстрое повторение
Как работает `function_score` в Elasticsearch?
Быстрое повторение
Как использовать параметр `boost` в условии `match` внутри `bool` запроса?
Быстрое повторение
Почему BM25 недостаточно для качественного ранжирования в поисковом приложении?

См. также