Налаштування 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 ₽ на місяць на інфраструктурі. Отримайте готове рішення з повною документацією та підтримкою.







