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. Это даёт две выгоды:
- Скорость: filter context кэшируется на уровне node (для самых частых сочетаний).
- Корректность 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.