Настройка Elasticsearch для фасетного поиска с агрегациями
Представьте каталог с миллионом товаров. Без фасетного поиска пользователь листает 200 страниц. С ним — за секунду находит нужный бренд и ценовой диапазон. Но счётчики врут, если не настроить post_filter и global aggregations. В этой статье разберём, как собрать корректные фасетные счётчики на Elasticsearch, избежав типовых ошибок. Экономия времени разработки при использовании готового решения — до 30%.
Мы интегрируем Elasticsearch с фасетным поиском в каталогах и интернет-магазинах. Это фильтры-счётчики: «Бренд: Samsung (143), Apple (89)», «Цена: до 30 000 (234), 30–60 000 (145)». При выборе фильтра список товаров сужается, а счётчики других значений пересчитываются. В реляционных базах такие операции дороги — GROUP BY с COUNT(*) по отфильтрованной выборке. В Elasticsearch задача решается агрегациями часто за единственный запрос. Оценим ваш проект и реализуем под ключ за 2–4 рабочих дня.
Как реализовать корректные счётчики фасетов?
Типичный запрос для каталога: поисковый запрос + активные фильтры + агрегации для подсчёта фасетов. Пример базового запроса:
POST /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "ноутбук" } }
],
"filter": [
{ "term": { "is_active": true } },
{ "range": { "price": { "gte": 20000, "lte": 80000 } } }
]
}
},
"aggs": {
"by_brand": {
"terms": {
"field": "brand",
"size": 20,
"order": { "_count": "desc" }
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "key": "до 30 000", "to": 30000 },
{ "key": "30–60 000", "from": 30000, "to": 60000 },
{ "key": "60–100 000", "from": 60000, "to": 100000 },
{ "key": "от 100 000", "from": 100000 }
]
}
},
"price_stats": {
"stats": { "field": "price" }
},
"by_category": {
"terms": { "field": "category", "size": 10 }
},
"by_rating": {
"histogram": { "field": "rating", "interval": 1, "min_doc_count": 1 }
}
},
"size": 20,
"from": 0
}
Проблема исчезающих счётчиков при выборе фасета
Если пользователь выбирает бренд «Samsung» и добавляет его в filter, агрегация by_brand считает только внутри отфильтрованной выборки. Счётчики других брендов обнуляются — пользователь не видит, сколько товаров у других брендов. Решение — Post Filter в сочетании с Global Aggregation:
POST /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "ноутбук" } }
],
"filter": [
{ "term": { "is_active": true } }
]
}
},
"post_filter": {
"bool": {
"filter": [
{ "term": { "brand": "Samsung" } }
]
}
},
"aggs": {
"all_brands": {
"global": {},
"aggs": {
"filtered_brands": {
"filter": {
"bool": {
"must": [
{ "match": { "title": "ноутбук" } }
],
"filter": [
{ "term": { "is_active": true } }
]
}
},
"aggs": {
"brands": {
"terms": { "field": "brand", "size": 20 }
}
}
}
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "key": "до 30 000", "to": 30000 },
{ "key": "30–60 000", "from": 30000, "to": 60000 },
{ "key": "от 60 000", "from": 60000 }
]
}
}
},
"size": 20
}
Согласно документации Elasticsearch, post_filter применяется к результатам после агрегации, а global агрегация выходит за пределы контекста query. post_filter применяется к результатам после агрегации. Агрегации считаются по всей выборке (до post_filter), поэтому счётчики брендов остаются корректными. global агрегация выходит за пределы контекста query — позволяет считать агрегаты по всем документам индекса с дополнительными фильтрами.
Как работать с вложенными атрибутами?
Если атрибуты (размер, цвет, материал) хранятся как nested объекты:
"mappings": {
"properties": {
"attributes": {
"type": "nested",
"properties": {
"name": { "type": "keyword" },
"value": { "type": "keyword" }
}
}
}
}
Агрегация по вложенным атрибутам:
"aggs": {
"attributes": {
"nested": { "path": "attributes" },
"aggs": {
"attribute_names": {
"terms": { "field": "attributes.name", "size": 50 },
"aggs": {
"attribute_values": {
"terms": { "field": "attributes.value", "size": 20 }
}
}
}
}
}
}
Это двухуровневые вложенные агрегации: сначала группируем по имени атрибута («Цвет», «Размер»), внутри каждой группы — значения («Чёрный», «Белый»).
Cardinality — подсчёт уникальных значений
Для отображения «Найдено 1 247 товаров от 89 брендов» используйте агрегацию cardinality с полем brand и precision_threshold 100. Это гарантирует точность до 100 уникальных значений, выше — погрешность 1–6%.
Настройка фасетного поиска за 5 шагов
- Определите поля для фильтрации: бренд, цена, категория, рейтинг, атрибуты. Типичный набор — 5–10 полей.
- Настройте маппинг: для ключевых полей используйте тип
keyword, для вложенных —nested. Убедитесь, что поля для агрегаций не анализируются. - Постройте запрос с агрегациями: включите все нужные агрегации (terms, range, histogram, nested). Для корректных счётчиков используйте
post_filterиglobalагрегации. - Примените post_filter: отделите фильтры от основного query. Это сохранит счётчики агрегаций.
- Оптимизируйте производительность: используйте
execution_hint: mapдля полей с низкой кардинальностью, кэширование для агрегаций без поискового запроса, ограничьте size до 20.
Оптимизация агрегаций
terms агрегация с большим size — дорогая операция: каждый шард возвращает top-N значений, координирующий узел мержит результаты. Для высококардинальных полей (тысячи уникальных значений) производительность падает. Способы оптимизации:
Таблица методов оптимизации
| Метод | Описание | Когда применять |
|---|---|---|
| execution_hint: map | Для полей с низкой кардинальностью (<1000) работает быстрее ordinals | Поля типа статус, категория |
| Кэширование агрегаций | Агрегации без поискового запроса кэшируются в shard request cache | Страницы каталога без поиска |
| Ограничение size | Для фасетов достаточно top-20 значений; глубокая пагинация не нужна | Всегда |
Сравнение типов агрегаций
| Тип | Пример использования | Производительность |
|---|---|---|
| Terms | Группировка по бренду | Быстро при кардинальности <10k |
| Range | Ценовые диапазоны | Очень быстро |
| Histogram | Рейтинг с шагом 1 | Быстро |
| Nested | Вложенные атрибуты | Средне (зависит от кол-ва объектов) |
| Cardinality | Уникальные бренды | Быстро (алгоритм HyperLogLog) |
Реализация на стороне приложения
PHP класс для построения фасетных запросов:
class FacetedSearchService
{
public function search(array $params): array
{
$query = $this->buildQuery($params);
$response = $this->client->search([
'index' => 'products',
'body' => $query,
]);
return [
'hits' => $response['hits']['hits'],
'total' => $response['hits']['total']['value'],
'facets' => $this->extractFacets($response['aggregations']),
];
}
private function extractFacets(array $aggs): array
{
$facets = [];
if (isset($aggs['by_brand'])) {
$facets['brand'] = array_map(fn($b) => [
'value' => $b['key'],
'count' => $b['doc_count'],
], $aggs['by_brand']['buckets']);
}
if (isset($aggs['price_ranges'])) {
$facets['price'] = array_map(fn($r) => [
'label' => $r['key'],
'count' => $r['doc_count'],
], $aggs['price_ranges']['buckets']);
}
return $facets;
}
}
Что входит в настройку фасетного поиска под ключ
- Проектирование схемы маппингов и индексов.
- Настройка агрегаций всех типов (terms, range, nested, cardinality).
- Реализация логики post_filter + global aggregations для корректных счётчиков.
- Оптимизация производительности (execution_hint, кэширование).
- Написание серверного кода для построения запросов и обработки ответов.
- Документация, обучение команды, поддержка после внедрения.
Сроки
- Базовая реализация (3–5 типов агрегаций) — 2 рабочих дня.
- Сложный сценарий (post_filter, global, nested) — 3–4 дня.
- Оптимизация на реальных объёмах (>5 млн документов) — дополнительный день.
Наша команда имеет 6+ лет опыта в Elasticsearch и сертифицированных инженеров. Мы реализовали более 50 проектов с фасетным поиском, гарантируя точность счётчиков и производительность. Свяжитесь с нами для консультации — поможем избежать дорогих ошибок. Стоимость рассчитывается индивидуально, а экономия от внедрения может достигать 40 000 ₽ на этапе разработки и до 20 000 ₽ в месяц на инфраструктуре. Получите готовое решение с полной документацией и поддержкой.







