Когда вы вводите «шоколадные конфеты» в поиск и видите список товаров — Elasticsearch не просто ищет эти два слова. Он вычисляет, насколько каждый документ подходит к запросу, и сортирует результаты по этой оценке. Разберём, как всё это устроено.
Два режима: «насколько подходит» и «подходит или нет»
Самое важное различие в Elasticsearch — запрос может работать в двух режимах:
Полнотекстовый поиск (query context) — Elasticsearch задаёт вопрос «насколько хорошо этот документ отвечает на запрос?» и вычисляет числовую оценку (_score). Документы сортируются по этой оценке — самые релевантные идут первыми.
Точный фильтр (filter context) — вопрос «подходит документ или нет?» Ответ — да или нет, без оценки. Такие фильтры кэшируются, поэтому работают быстрее.
Пример, где оба режима используются вместе:
{
"query": {
"bool": {
"must": [
{ "match": { "name": "шоколад" } }
],
"filter": [
{ "term": { "in_stock": true } },
{ "range": { "price": { "gte": 50, "lte": 500 } } }
]
}
}
}
match в must — полнотекстовый поиск, влияет на оценку. Фильтры в filter — точные условия, кэшируются.
Практическое правило: всё, что не для ранжирования, кладите в filter — это быстрее и не искажает оценку релевантности.
Виды запросов
match — поиск по тексту
Самый частый запрос для текстовых полей:
{ "match": { "name": "шоколадные конфеты" } }
Перед поиском текст проходит через анализатор (тот же, что и при индексации): слова разбиваются на термины, удаляются окончания. В результате ищутся документы, где встречается хотя бы один из полученных терминов.
Чтобы потребовать все термины:
{ "match": { "name": { "query": "шоколадные конфеты", "operator": "and" } } }
match_phrase — точная фраза
{ "match_phrase": { "name": "шоколадные конфеты" } }
Термины должны идти в нужном порядке и стоять рядом. Подходит для точных названий. Параметр slop позволяет разрешить небольшой разрыв между словами.
multi_match — поиск сразу по нескольким полям
{
"multi_match": {
"query": "шоколад",
"fields": ["name^3", "description^1", "tags^2"],
"type": "best_fields"
}
}
name^3 означает, что совпадение в поле name ценится в три раза дороже, чем в description. Удобно, когда заголовок важнее тела.
Режимы поиска:
best_fields— итоговая оценка = максимум оценок по отдельным полям. Подходит, когда слова чаще всего в одном поле.most_fields— сумма оценок. Подходит для случаев, когда одно и то же слово может быть в разных полях.cross_fields— рассматривает все поля как одно. Удобно для имени и фамилии, разнесённых по разным полям.
term и terms — точное значение
{ "term": { "category_id": 1 } }
{ "terms": { "category_id": [1, 2, 3] } }
term не анализирует текст, поэтому не подходит для текстовых полей — только для чисел, идентификаторов и keyword-полей. На текстовых полях результат будет неожиданным: Elasticsearch будет искать дословное значение в индексе, где хранятся уже обработанные термины.
range — диапазон
{ "range": { "price": { "gte": 50, "lte": 500 } } }
{ "range": { "created_at": { "gte": "now-7d/d", "lte": "now/d" } } }
Работает с числами и датами. Для дат поддерживается математика: now-7d — семь дней назад, /d — округлить до начала дня.
exists — поле заполнено
{ "exists": { "field": "image_url" } }
Находит документы, где поле присутствует и не равно null.
bool — основа всех сложных запросов
bool позволяет комбинировать запросы:
{
"bool": {
"must": [ ... ], // обязательно, влияет на оценку
"filter": [ ... ], // обязательно, без оценки
"should": [ ... ], // желательно, повышает оценку при совпадении
"must_not": [ ... ] // исключить
}
}
Пример: найти товары с «шоколадом» в названии, в категориях 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 } }
]
}
}
}
Товары в наличии получат более высокую оценку, но товары не в наличии тоже попадут в результаты.
Как Elasticsearch решает, кто первый: алгоритм BM25
Когда вы ищете «шоколад», почему один документ оказывается выше другого? Elasticsearch использует формулу BM25 (Best Matching 25) — стандартный алгоритм из теории информационного поиска.
Упрощённо, оценка зависит от трёх вещей:
- Частота термина — слово «шоколад» встречается в документе 5 раз? Это лучше, чем один раз. Но не в 5 раз лучше — отдача убывает.
- Редкость термина — «шоколад» встречается в 10% всех документов, а «и» — в 99%. Редкое слово несёт больше информации, значит его совпадение ценится дороже.
- Длина документа — короткое название «Шоколадные конфеты» с совпадением ценится выше, чем длинное описание с тем же словом.
В большинстве случаев настройки BM25 менять не нужно — алгоритм работает хорошо из коробки.
Управление оценкой: поднять важные документы
Иногда нужно, чтобы определённые документы оказывались выше не за счёт текстового совпадения, а по бизнес-логике: «новые товары важнее», «рекомендованные товары в топ».
Простой способ — boost для отдельных запросов:
"must": [
{ "match": { "name": { "query": "шоколад", "boost": 3 } } },
{ "match": { "description": { "query": "шоколад", "boost": 1 } } }
]
function_score — комбинированное ранжирование
Для более сложной логики есть function_score:
{
"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"
}
}
}
Здесь базовая оценка от текстового совпадения умножается на сумму трёх факторов: рекомендованный товар получает вес 2, популярные товары поднимаются через логарифм числа продаж, а свежие товары оцениваются выше через гауссово затухание (через 30 дней оценка падает вдвое).
Начинайте с одного-двух факторов — с function_score легко перемудрить.
Aggregations: фасеты и аналитика
Aggregations (агрегации) — это подсчёт статистики по найденным документам. Именно они строят фасеты каталога: «в каждой категории X товаров», «цена от Y до Z».
Запрос с агрегациями не требует возвращать сами документы — можно поставить size: 0 и получить только статистику:
{
"query": { "match": { "name": "шоколад" } },
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category_id", "size": 10 }
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 100 },
{ "from": 100, "to": 500 },
{ "from": 500 }
]
}
}
}
}
Результат:
{
"aggregations": {
"by_category": {
"buckets": [
{ "key": 1, "doc_count": 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 }
]
}
}
}
Агрегации можно вкладывать друг в друга — например, посчитать среднюю цену в каждой категории:
{
"aggs": {
"by_category": {
"terms": { "field": "category_id" },
"aggs": {
"avg_price": { "avg": { "field": "price" } }
}
}
}
}
Это один HTTP-запрос вместо нескольких запросов в базу данных.
Собираем всё вместе: запрос каталога
Реальный запрос для каталога товаров с поиском, фильтрами и фасетами:
{
"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 } }
}
}
fuzziness: AUTO позволяет находить документы даже при опечатках: для длинных слов допускаются отклонения до двух символов.
Пагинация: почему from плохо работает на глубоких страницах
Стандартная пагинация через from и size имеет ограничение. Запрос from: 10000, size: 20 вынуждает Elasticsearch обработать и отсортировать первые 10020 документов на каждом осколке, а потом выбросить 10000 из них. На больших отступах это заметно замедляется.
Для постраничного листания (особенно бесконечной прокрутки) лучше использовать search_after:
{
"size": 20,
"query": { "match": { "name": "шоколад" } },
"sort": [ { "_score": "desc" }, { "_id": "desc" } ],
"search_after": [0.78, "product-12345"]
}
search_after принимает значения из последнего документа предыдущей страницы и продолжает список с этого места. Прыгнуть сразу на страницу 50 нельзя — зато нет проблем с производительностью.
Коротко
- Запросы работают в двух режимах: query context вычисляет оценку релевантности, filter context просто фильтрует без оценки. Фильтры кэшируются и быстрее.
- Основные типы запросов:
matchдля текста,term/termsдля точных значений,rangeдля диапазонов. boolобъединяет запросы черезmust,filter,should,must_not.- Оценку релевантности считает BM25 — учитывает частоту слова в документе, редкость слова в индексе и длину документа.
- Поднять нужные документы можно через
boostилиfunction_score(для сложной логики). - Aggregations считают статистику по найденным документам — используются для фасетов каталога.
- Для глубокой пагинации используйте
search_afterвместо большогоfrom.
Что почитать дальше
- Fundamentals — как устроен индекс и как документы попадают в поиск.
- Клиенты и интеграция — как отправлять эти запросы из Java-приложения.
- Operations — производительность и управление индексами.