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







