Настройка Elasticsearch для фасетного поиска (Aggregations)
Фасетный поиск — это фильтры-счётчики сбоку от результатов: «Бренд: Samsung (143), Apple (89), Lenovo (56)», «Цена: до 30 000 (234), 30–60 000 (145)». При выборе фасета список результатов сужается, остальные счётчики пересчитываются. В реляционных базах это дорогие GROUP BY с COUNT(*) по отфильтрованной выборке. В Elasticsearch это решается агрегациями, часто за единственный запрос.
Базовые агрегации для фасетов
Типичный запрос для каталога товаров: поисковый запрос + активные фильтры + агрегации для подсчёта фасетов.
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
}
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 брендов»:
"aggs": {
"unique_brands": {
"cardinality": {
"field": "brand",
"precision_threshold": 100
}
}
}
precision_threshold — точность HyperLogLog алгоритма. Значения до precision_threshold — точные. Выше — с погрешностью ~1–6%. Для UI достаточно 100.
Реализация на стороне приложения
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 агрегация с большим size — дорогая операция: каждый шард возвращает top-N значений, координирующий узел мержит результаты. Для высококардинальных полей (тысячи уникальных значений) производительность падает.
Способы оптимизации:
execution_hint: map — для полей с малым числом уникальных значений (< 1000) работает быстрее стандартного ordinals:
"terms": {
"field": "status",
"size": 10,
"execution_hint": "map"
}
Кэширование агрегаций — агрегации без поискового запроса (чистые фильтры) кэшируются в shard request cache. Для страниц каталога без поиска — работает быстро из кэша.
Глубокая пагинация — from + size дороже для агрегаций, чем для hits. Для фасетов пагинация обычно не нужна — показываем top-20 значений.
Сроки
Реализация базового фасетного поиска с 3–5 типами агрегаций — 2 рабочих дня. Сложный сценарий с post_filter, global aggregation и nested атрибутами — 3–4 дня. Оптимизация производительности агрегаций на реальных объёмах (>5 млн документов) — дополнительный день.







