Elasticsearch relevance: 7 шагов, которые мы повторяем в каждом e-com

Автор: WebGoodPeople

Если у вас 1000+ SKU и встроенный поиск Битрикс/WordPress/Shopify — ваш поиск, с вероятностью 80%, работает так: пользователь вводит запрос, получает либо «ничего не найдено», либо выдачу, в которой первый товар — не то, что он искал. Он уходит. Конверсия через поиск — 15–25% от всех продаж, и она у вас утекает.

Эта статья — playbook из 7 шагов, которые мы применяем в каждом проекте с Elasticsearch. Порядок неслучайный: каждый шаг требует данных от предыдущего.

Вводные: почему именно Elasticsearch


Альтернативы:

  • Встроенный поиск Битрикс — работает на MySQL FULLTEXT, без типосовместимости, без синонимов, релевантность «как повезёт».
  • OpenSearch — форк ES 7.x от Amazon. На 95% совместим с нашими шагами.
  • Meilisearch, Typesense — хороши для небольших каталогов (<50k), но ограничены в кастомном скоринге.
  • Algolia — отличный продукт, но $500+/мес для среднего e-com.

Для каталогов 50k+ SKU с серьёзными требованиями к управляемости — ES или OpenSearch.

Шаг 1. Анализ текущих запросов и zero-result rate


Что делаем: собираем все поисковые запросы за последние 30 дней. Считаем долю запросов с нулевым результатом (zero_result_rate).

-- В Loki или ClickHouse
SELECT 
  query,
  count(*) as cnt,
  countIf(hits = 0) as zero_results,
  countIf(hits = 0) / count(*) as zero_rate
FROM search_logs
WHERE ts >= now() - interval 30 day
GROUP BY query
ORDER BY cnt DESC
LIMIT 200;


Что ищем: запросы из топ-200 по частоте с zero_rate > 5%. Это прямая потеря: пользователь искал, ничего не нашёл, ушёл.

Типичные находки: опечатки (плате вместо платье), синонимы (кроссы вместо кроссовки), иностранные варианты (sneakers на русскоязычном сайте).

Ожидаемый baseline: хорошо настроенный e-com — zero_rate < 3%. Типичный не настроенный — 15–25%.

Шаг 2. Log shipping поисковых запросов в отдельный индекс


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

{
  "ts": "2026-05-19T10:00:00Z",
  "user_id": "u42",
  "query": "красное платье",
  "hits": 24,
  "first_click_position": 3,
  "converted": false
}


Индекс называем search_queries. Retention — 90 дней (для аналитики достаточно).

Зачем: все последующие шаги без этого индекса — стрельба по фанерным мишеням.

Шаг 3. Синонимы (манаульный + ML-derived словарь)


Что делаем: создаём словарь синонимов. Два уровня:

Ручной (30–50 пар, быстро):

кроссовки, кроссы, sneakers
платье, плате, dress
свитер, джемпер


ML-derived (из логов): используем Word2Vec или fasttext на логе запросов, находим пары слов, которые часто встречаются в семантически близких контекстах.

Elasticsearch принимает это как:

{
  "settings": {
    "analysis": {
      "filter": {
        "ru_synonyms": {
          "type": "synonym",
          "synonyms_path": "analysis/synonyms-ru.txt"
        }
      },
      "analyzer": {
        "ru_analyzer": {
          "tokenizer": "standard",
          "filter": ["lowercase", "ru_synonyms", "russian_stemmer"]
        }
      }
    }
  }
}


Ожидаемый эффект: zero_rate падает на 30–50% на первой итерации.

Шаг 4. Field boosting (что важнее — название или описание)


Что делаем: при multi_match указываем веса полей:

{
  "query": {
    "multi_match": {
      "query": "красное платье",
      "fields": [
        "title^5",
        "brand^3",
        "categories^2",
        "description^1",
        "attributes.*^0.5"
      ],
      "type": "best_fields",
      "fuzziness": "AUTO"
    }
  }
}


Почему такие веса: в e-com пользователи ищут по названию товара в 70% случаев. Brand — сильный сигнал (если есть). Описание — слабее (переполнено ключевыми словами от SEO-команды).

Важно: эти веса — стартовые. Финальные — после шага 7 (A/B-тестирование).

Шаг 5. Fuzziness (типо-толерантность)


Что делаем: включаем fuzzy matching, но осторожно. "fuzziness": "AUTO" даёт:

  • 0 опечаток для слов до 3 символов
  • 1 опечатка для слов 3–5 символов
  • 2 опечатки для слов 6+ символов

Для русского языка — этого обычно достаточно. Для брендов и кодов SKU fuzziness лучше отключить (чтобы iPhone 15 не матчился с iPhone 16).

"query": {
  "bool": {
    "should": [
      {
        "multi_match": {
          "query": "{{query}}",
          "fields": ["title^5", "description^1"],
          "fuzziness": "AUTO"
        }
      },
      {
        "term": { "sku": "{{query}}" }  // exact match for SKU
      }
    ]
  }
}


Шаг 6. Function scoring — popularity, stock, recency


Что делаем: ранжируем не только по текстовой релевантности, но и по бизнес-сигналам.

{
  "query": {
    "function_score": {
      "query": { /* multi_match из шага 4 */ },
      "functions": [
        {
          "field_value_factor": {
            "field": "popularity_score",
            "modifier": "log1p",
            "missing": 1
          }
        },
        {
          "filter": { "term": { "in_stock": true }},
          "weight": 2
        },
        {
          "exp": {
            "created_at": {
              "origin": "now",
              "scale": "90d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  }
}


Что даёт каждая функция:

  • field_value_factor по popularity_score — популярные товары выше. popularity_score собирается отдельно (например, просмотры за 30 дней).
  • filter по in_stock — товары в наличии получают boost × 2. Отсутствующие — проваливаются.
  • exp decay по created_at — новинки получают бонус, затухающий за 90 дней.

Ожидаемый эффект: позиция first-click растёт с 4–5 до 1–2.

Шаг 7. A/B-тестирование релевантности через interleaving


Что делаем: сравниваем две версии скоринга через team-draft interleaving. Пользователю показывается смешанная выдача (половина из A, половина из B), мы считаем, с какой стороны больше кликов.

def interleave(results_a, results_b, n=20):
    out = []
    turn = random.choice(['A', 'B'])
    i_a, i_b = 0, 0
    while len(out) < n and (i_a < len(results_a) or i_b < len(results_b)):
        if turn == 'A' and i_a < len(results_a):
            out.append((results_a[i_a], 'A'))
            i_a += 1
            turn = 'B'
        elif i_b < len(results_b):
            out.append((results_b[i_b], 'B'))
            i_b += 1
            turn = 'A'
    return out


Почему именно interleaving: классический A/B требует больших выборок (4–6 недель на статзначимость). Interleaving — в 10 раз быстрее (2–3 дня).

Ожидаемый outcome после 7 шагов


На каталоге 50k+ SKU с нулевой настройкой релевантности:

  • zero_result_rate: с 20% до 2%
  • Средняя позиция first-click: с 5 до 1.5
  • Конверсия через поиск: +30–60%
  • Время поиска (p95): 20–60 мс на запрос (правильная настройка индексов ES даёт это даже на 500k SKU)

Что НЕ делаем


  • Не добавляем ML-based ranking на первой итерации. Переход на learning-to-rank имеет смысл после того, как базовая настройка работает.
  • Не оптимизируем 100+ полей одновременно. 5–7 ключевых полей × правильные веса — 80% выигрыша.
  • Не делаем "персонализацию" без данных. Персонализация работает с 100+ сессий на пользователя. У большинства e-com такой глубины нет.

Если хочется быстрее


48-часовой аудит поиска — мы смотрим ваш ES-индекс, даём конкретный query-шаблон и словарь синонимов под ваш каталог. Free, без обязательств.


webgoodpeople.com/api-audit

Расскажите нам о своем проекте

Наши офисы

  • Россия
    Россия, Санкт-Петербург, Рижская улица, 5, корп. 1 офис 402
    +7 (967) 555-90-32
  • Казахстан
    Алма-Ата
    +7 (707) 340-29-12