← назад к разделу

Elasticsearch предоставляет JSON-DSL для запросов: гибкий, мощный, и поначалу запутанный. Эта статья — практический проход по часто встречающимся запросам в каталоге товаров с пониманием того, что на самом деле делает scoring.

Все примеры — на индексе products со структурой из статьи про fundamentals.

Query context vs Filter context

Главное различие, которое нужно понять в первый день. Запрос ES может быть в двух контекстах:

  • Query context: «насколько хорошо документ подходит?» Вычисляется score (число), документы сортируются по score. Полнотекстовый поиск.
  • Filter context: «подходит документ или нет?» Boolean — да/нет, без score. Кэшируется. Точные фильтры (category_id, price > 100, in_stock = true).
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "шоколад" } }                  ← query context, влияет на score
      ],
      "filter": [
        { "term":  { "in_stock": true } },                  ← filter context, кэш
        { "range": { "price": { "gte": 50, "lte": 500 } } }
      ]
    }
  }
}

Правило: всё, что не для ранжирования — в filter context. Это даёт две выгоды:

  1. Скорость: filter context кэшируется на уровне node (для самых частых сочетаний).
  2. Корректность score: точные фильтры не «портят» score full-text-запроса.

Базовые query-типы

match — полнотекстовый поиск

{ "match": { "name": "шоколадные конфеты" } }

Запрос пройдёт через тот же analyzer, что и поле при индексации. После анализа ["шоколадн", "конфет"] → ищутся документы, где встречается хотя бы один из термов (OR-режим по умолчанию).

{ "match": { "name": { "query": "шоколадные конфеты", "operator": "and" } } }

С operator: and — все термы обязательны.

match_phrase — точная фраза

{ "match_phrase": { "name": "шоколадные конфеты" } }

Термы должны идти в порядке, рядом. Полезно для имён собственных, точных названий товаров.

С slop можно разрешить расстояние между термами: slop: 2 позволит «шоколадные [???] конфеты».

multi_match — поиск по нескольким полям

{
  "multi_match": {
    "query": "шоколад",
    "fields": ["name^3", "description^1", "tags^2"],
    "type": "best_fields"
  }
}

name^3 означает «boost field by 3» — попадания в name ценятся в 3 раза дороже, чем в description. Типичный паттерн каталога товаров.

Режимы:

  • best_fields — score = max(score per field). Лучше, когда термы скорее всего в одном поле.
  • most_fields — score = sum(score per field) / N. Лучше для синонимов.
  • cross_fields — анализирует так, будто все поля — одно. Для имени-отчества-фамилии.
  • phrase — match_phrase на каждом поле.

term, terms — точное совпадение

{ "term":  { "category_id": 1 } }
{ "terms": { "category_id": [1, 2, 3] } }
{ "term":  { "name.raw": "Конфеты" } }      ← exact на keyword sub-field

Не проходит через analyzer. На text-поле даст странные результаты (попытается найти exact term «Конфеты» в инвертированном индексе, где лежит уже стеммированное «конфет»). Использовать только на keyword-полях или числах.

range — диапазон

{ "range": { "price": { "gte": 50, "lte": 500 } } }
{ "range": { "created_at": { "gte": "now-7d/d", "lte": "now/d" } } }

Работает на числах, датах, IP. Date math (now-7d/d = «7 дней назад, округлено до начала дня») — мощная штука для time-based фильтров.

exists, prefix, wildcard, regexp

{ "exists":   { "field": "image_url" } }      ← поле существует и не null
{ "prefix":   { "name.raw": "Конф" } }
{ "wildcard": { "sku": "ABC*123" } }
{ "regexp":   { "sku": "ABC[0-9]{3}" } }

wildcard и regexp дорогие на больших индексах — лучше избегать. Для prefix search используйте edge_ngram-analyzer.

bool — комбинация

Главная workhorse-конструкция:

{
  "bool": {
    "must":     [ ... ],  ← AND, в query context (влияет на score)
    "filter":   [ ... ],  ← AND, в filter context (без score)
    "should":   [ ... ],  ← OR, повышает score при совпадении
    "must_not": [ ... ],  ← NOT, в filter context
    "minimum_should_match": 1
  }
}

Пример: «найти товары, в названии которых "шоколад", в категории 1 или 2, цена 50-500, и желательно в наличии»:

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "шоколад" } }
      ],
      "filter": [
        { "terms": { "category_id": [1, 2] } },
        { "range": { "price": { "gte": 50, "lte": 500 } } }
      ],
      "should": [
        { "term": { "in_stock": true } }                  ← boost для in-stock
      ]
    }
  }
}

Scoring: BM25

ES 5+ использует BM25 (Best Matching 25) — функцию из information retrieval. Упрощённо score документа D для запроса Q:

score(D, Q) = Σ for each term t in Q:
                IDF(t) × TF(t in D) × length_norm(D)
  • TF (term frequency) — сколько раз терм встречается в документе. Больше — выше score.
  • IDF (inverse document frequency) — насколько редкий терм в индексе. шоколад встречается в 5% документов → его вес выше, чем и (встречается в 99%).
  • Length norm — короткие документы выигрывают. Совпадение в коротком имени важнее, чем в длинном описании.

Параметры можно тюнить через similarity settings, но в 99% случаев дефолт работает.

Когда score не нужен — constant_score

Если вам не нужен ранжированный поиск (например, просто фильтр), используйте constant_score с filter — экономия CPU:

{
  "query": {
    "constant_score": {
      "filter": { "term": { "category_id": 1 } }
    }
  }
}

Все совпавшие документы получают score=1.0, не вычисляется BM25.

Boosting

Уже видели name^3 в multi_match. Полные способы поднимать важность:

"must": [
  { "match": { "name": { "query": "шоколад", "boost": 3 } } },
  { "match": { "description": { "query": "шоколад", "boost": 1 } } }
]

function_score — custom-ранжирование

Когда BM25 + boost полей недостаточно. Хочется «недавние товары выше», «популярные товары выше», «премиум выше»:

{
  "query": {
    "function_score": {
      "query": { "match": { "name": "шоколад" } },
      "functions": [
        {
          "filter": { "term": { "is_featured": true } },
          "weight": 2.0
        },
        {
          "field_value_factor": {
            "field": "popularity",
            "modifier": "log1p",
            "factor": 0.5
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale": "30d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

Это значит:

  • Базовый score от full-text-match.
  • Featured-товары умножаются на 2.
  • Популярность товара (число продаж) добавляется к score через log1p.
  • Возраст: товар созданный неделю назад имеет высокий score, через 30 дней — score падает вдвое (Gauss decay).
  • Итог: score = base * (2*featured + log_popularity + gauss_age).

function_score — мощный инструмент, но легко перетюнить. Начинайте с одного-двух фактора, добавляйте по мере необходимости.

Aggregations: фасеты, метрики, группировки

Запрос query возвращает документы. Aggregations считают группы и метрики на тех же документах.

Bucket aggregations

{
  "query": { "match": { "name": "шоколад" } },
  "aggs": {
    "by_category": {
      "terms": { "field": "category_id", "size": 10 }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 100 },
          { "from": 100, "to": 500 },
          { "from": 500 }
        ]
      }
    }
  },
  "size": 0   ← документы не нужны, только aggregations
}

Ответ:

{
  "aggregations": {
    "by_category": {
      "buckets": [
        { "key": 1, "doc_count": 50 },     ← в категории "Сладости" 50 товаров
        { "key": 2, "doc_count": 12 }
      ]
    },
    "price_ranges": {
      "buckets": [
        { "key": "*-100",     "doc_count": 30 },
        { "key": "100-500",   "doc_count": 25 },
        { "key": "500-*",     "doc_count": 7  }
      ]
    }
  }
}

Это классические фасеты для каталога товаров — «сколько товаров в каждой категории / в каждом ценовом диапазоне после применения фильтра».

Metric aggregations

{
  "aggs": {
    "avg_price":   { "avg":   { "field": "price" } },
    "max_price":   { "max":   { "field": "price" } },
    "unique_skus": { "cardinality": { "field": "sku" } }
  }
}

Nested aggregations

{
  "aggs": {
    "by_category": {
      "terms": { "field": "category_id" },
      "aggs": {
        "avg_price": { "avg": { "field": "price" } }
      }
    }
  }
}

«Средняя цена по каждой категории» — мощный аналитический запрос за один HTTP-вызов.

Типичный запрос каталога товаров

Собрав всё вместе — реальный production-запрос:

{
  "from": 0,
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "шоколадные конфеты",
            "fields": ["name^3", "description"],
            "type": "best_fields",
            "fuzziness": "AUTO"
          }
        }
      ],
      "filter": [
        { "terms": { "category_id": [1, 2] } },
        { "range": { "price": { "gte": 50, "lte": 500 } } },
        { "term":  { "in_stock": true } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "created_at": "desc" }
  ],
  "aggs": {
    "categories":  { "terms": { "field": "category_id", "size": 20 } },
    "price_ranges": { "histogram": { "field": "price", "interval": 100 } }
  }
}

Что здесь:

  • multi_match с fuzziness: AUTO — терпит опечатки (≤2 правки для длинных слов).
  • Фильтры в filter context — кэшируются.
  • Sort: сначала по релевантности, потом по новизне.
  • Aggregations для фасетов «слева» в UI.
  • Pagination через from + size.

Пагинация — from плохо масштабируется

from: 100000, size: 20 заставит ES прогнать первые 100020 документов через все шарды, отсортировать, выбросить 100000, вернуть 20. На больших offset'ах — медленно.

Альтернатива — search_after:

{
  "size": 20,
  "query": { ... },
  "sort": [ { "_score": "desc" }, { "_id": "desc" } ],
  "search_after": [0.78, "product-12345"]   ← курсор из последнего документа предыдущей страницы
}

Не позволяет «прыгать на страницу 50», но идеально для infinite scroll.

Что почитать дальше

  • Fundamentals — устройство индекса.
  • Spring Data Elasticsearch — как делать эти запросы из Java.
  • Operations — оптимизация производительности, ILM.
  • Elasticsearch Query DSL Reference.