Назад в блог

Релевантность — не прибыль: как мы научили Elasticsearch работать на маржу

Мы провели A/B-тест. Контроль — поиск сортировал по текстовой релевантности. Вариант — по функции, которая учитывала маржу, остаток и скорость оборота. Кликабельность — почти одинаковая. Выручка с поискового сеанса — на 12% выше в варианте с бизнес-ранжированием.

Elasticsearch это поддерживает из коробки. Его просто никто так не настраивает.

Не потому что сложно. Потому что нужно понять бизнес-логику раньше, чем лезть в запросы.

Проблема: релевантный поиск ≠ прибыльный поиск

Стандартный поиск оптимизирует одно: насколько точно документ соответствует запросу. BM25, TF-IDF — всё это про слова. Пользователь ввёл «кроссовки Nike» — получил список, где «кроссовки Nike» встречается в названии и описании чаще всего.

Но бизнес хочет другого. Он хочет сначала показать кроссовки, которые:

  • есть на складе (а не «под заказ», на который терпения у покупателя нет);
  • дают большую маржу (а не те, что пришли на распродажу с минимальной наценкой);
  • хорошо продаются (высокая оборачиваемость — меньше замороженных средств).

Текстовая релевантность это не знает. И не должна знать — это не её задача.

Задача инженера — соединить оба мира.

Что такое function_score

function_score — это обёртка над обычным запросом в Elasticsearch. Берёт релевантность как основу, добавляет числовой буст из полей документа или скриптов, комбинирует по заданной формуле.

Базовая структура:

{
  "query": {
    "function_score": {
      "query": { "match": { "name": "кроссовки nike" } },
      "functions": [
        {
          "field_value_factor": {
            "field": "margin_pct",
            "factor": 0.3,
            "modifier": "sqrt",
            "missing": 0
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

boost_mode: multiply — итоговый скор = текстовая релевантность × буст. Это важно: если товар вообще нерелевантен, никакая маржа его в топ не вытащит.

Три бизнес-сигнала, которые стоят внимания

На проекте с 28k SKU мы протестировали несколько комбинаций. Итог — три сигнала дают стабильный прирост при умеренном риске для UX.

Первый — маржа. Числовое поле margin_pct (маржинальность в процентах). Передаём в индекс при индексации из 1С. Использовали field_value_factor с modifier: log1p — это сглаживает разрыв между товарами с маржой 5% и 50%.

Второй — остаток на складе. Поле stock_qty. Применяли не как непрерывный сигнал, а через gauss decay: товары с нулевым остатком получают штраф, с остатком 1-3 единицы — небольшой буст, с >10 — максимальный. Поиск не убирает позиции с нулём полностью, просто опускает ниже.

Третий — скорость продаж. Поле orders_30d — количество заказов за последние 30 дней. Быстро оборачиваемый товар означает меньше замороженных средств на складе. Работает как косвенный сигнал спроса и качества карточки.

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

"functions": [
  {
    "field_value_factor": {
      "field": "margin_pct",
      "factor": 0.2,
      "modifier": "log1p"
    }
  },
  {
    "gauss": {
      "stock_qty": {
        "origin": 20,
        "scale": 15,
        "offset": 5,
        "decay": 0.5
      }
    }
  },
  {
    "field_value_factor": {
      "field": "orders_30d",
      "factor": 0.3,
      "modifier": "sqrt",
      "missing": 0
    }
  }
],
"score_mode": "sum"

Веса (factor) подбирали через A/B: начинали с равных, затем снижали weight у маржи после того, как заметили, что дорогие товары с низким спросом поднимаются слишком высоко.

Как передавать бизнес-данные в индекс

Это решается ещё на этапе архитектуры индексации. Два пути:

Денормализация при индексации. Когда товар индексируется, скрипт получает из 1С/Bitrix значения маржи, остатка, заказов — и записывает их прямо в документ ES. Простая синхронизация, понятная схема. Проблема — данные стареют. Остаток меняется при каждой продаже, а полная переиндексация 28k SKU занимает время.

Частичное обновление (update API). При изменении остатка в 1С Bitrix-агент через webhook вызывает POST /<index>/_update/<id> только с полями stock_qty и orders_30d. Остальное не трогает. Так данные актуальны почти в реальном времени.

Мы используем второй вариант для остатков (меняются часто) и первый для маржи (пересчитывается раз в день при синхронизации 1С).

Decay-функции: как не убить UX ради маржи

Главный риск — жёсткий буст по марже поднимает в топ товары, которые пользователю неинтересны. «Скатерть с самой высокой наценкой» — не то, что ожидаешь, набирая «кроссовки Nike».

Держим два правила без исключений.

Первое: boost_mode: multiply, а не replace. Буст умножает релевантность, а не заменяет её. Нерелевантный документ с нулевым скором остаётся нулём — сколько бы маржи у него ни было.

Второе: нормировать веса функций так, чтобы их суммарный вклад не превышал 40-50% итогового скора. Остальное — чистая текстовая релевантность. Грубое правило, но держит баланс: поиск остаётся «умным», а не «торговым».

Если перекос виден — декейл (gauss с жёстким decay) лучше, чем прямая field_value_factor. Он плавно снижает буст при удалении от «идеального» значения, а не обрезает резко.

A/B-тест: как убедиться, что буститинг работает

На 28k SKU A/B-тест поискового ранжирования становится статистически значимым быстро — при трафике от 500 поисковых сессий в день недели достаточно.

Метрики, которые мы отслеживали:

  • CTR первого результата — не должен резко падать (если падает, буст слишком агрессивный).
  • Выручка / поисковая сессия — главный показатель.
  • Средний чек — иногда растёт, потому что товары с высокой маржой стоят дороже.
  • Доля OOS (out-of-stock) в топ-5 результатов — должна снижаться после добавления gauss по остаткам.

В нашем тесте через 2 недели: выручка/сессия +12%, доля OOS в топ-5 упала с 18% до 4%, CTR первого результата снизился на 1.3 процентных пункта (допустимо — кликов стало чуть меньше, но с каждого покупают больше).

Фиксировали через Kibana: в каждый запрос добавляли named query с маркером группы ("_name": "boosted" / "_name": "control"), логировали клики и конверсии.

Что не работает

Честно — три ограничения, которые нужно знать до внедрения.

Маржа в 1С и маржа в реальности. В 1С нередко видна плановая маржа, не фактическая (после скидок, доставки, возвратов). Если буститься по такой марже, в топ вылезают товары с формально высокой наценкой, которые в реальности убыточны.

Сезонный товар ломает сигнал orders_30d. Зимняя куртка в октябре имеет высокий orders_30d, в апреле — ноль. Без временных весов или сезонного флага буститинг работает против бизнеса в межсезонье.

Каталог с длинным хвостом. Если 80% SKU — товары с нулевыми продажами за 30 дней (новинки, нишевые позиции), orders_30d как сигнал почти не работает. Нужны fallback-стратегии: например, буст по created_at для новинок.

Бизнес-буститинг — не серебряная пуля. Это дополнительный слой логики поверх поиска, который требует поддержки и пересмотра весов минимум раз в квартал.

Итог

Elasticsearch умеет ранжировать по бизнес-метрикам. function_score с field_value_factor и gauss decay — стандартный инструмент, документация на него отличная. Сложность не техническая, а предметная: нужно понять, какие метрики реально влияют на бизнес, как их передать в индекс и как взвесить их против текстовой релевантности.

В нашем случае это заняло 3 дня на первую версию и ещё 2 недели A/B-теста. Прирост +12% к выручке с поиска — при каталоге с ежедневным трафиком это деньги, которые видны на следующей неделе.

Релевантность — хорошо. Прибыль — лучше.


Похожие материалы: Elasticsearch — это не «база со скоростью». Это UX-инструмент и Три тысячи результатов — тоже сломанный поиск.