Релевантность — не прибыль: как мы научили 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-инструмент и Три тысячи результатов — тоже сломанный поиск.