Elasticsearch Faceted Search with Aggregations: Accurate Counters
Imagine a catalog with a million products. Without faceted search, the user pages through 200 pages. With it, they find the desired brand and price range in seconds. But counters lie if you don't configure post_filter and global aggregations. This article covers how to build correct faceted counters on Elasticsearch, avoiding typical mistakes.
We integrate Elasticsearch with faceted search in catalogs and e-commerce stores. These are filter counters: "Brand: Samsung (143), Apple (89)", "Price: up to 30,000 (234), 30–60,000 (145)". When a filter is selected, the product list narrows, and the counters of other values recalculate. In relational databases such operations are expensive — GROUP BY with COUNT(*) on a filtered set. In Elasticsearch, the task is solved with aggregations, often in a single request. We will assess your project and implement it turnkey in 2–4 working days.
How to Implement Correct Facet Counters
A typical request for a catalog: search query + active filters + aggregations for counting facets. Example of a basic request:
POST /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "laptop" } }
],
"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": "up to 30,000", "to": 30000 },
{ "key": "30–60,000", "from": 30000, "to": 60000 },
{ "key": "60–100,000", "from": 60000, "to": 100000 },
{ "key": "from 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
}
The Problem of Disappearing Counters When Selecting a Facet
If the user selects brand "Samsung" and adds it to filter, the by_brand aggregation counts only within the filtered set. Counters of other brands become zero — the user cannot see how many products are from other brands. The solution is Post Filter combined with Global Aggregation:
POST /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "laptop" } }
],
"filter": [
{ "term": { "is_active": true } }
]
}
},
"post_filter": {
"bool": {
"filter": [
{ "term": { "brand": "Samsung" } }
]
}
},
"aggs": {
"all_brands": {
"global": {},
"aggs": {
"filtered_brands": {
"filter": {
"bool": {
"must": [
{ "match": { "title": "laptop" } }
],
"filter": [
{ "term": { "is_active": true } }
]
}
},
"aggs": {
"brands": {
"terms": { "field": "brand", "size": 20 }
}
}
}
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "key": "up to 30,000", "to": 30000 },
{ "key": "30–60,000", "from": 30000, "to": 60000 },
{ "key": "from 60,000", "from": 60000 }
]
}
}
},
"size": 20
}
According to Elasticsearch documentation, post_filter is applied to results after aggregation. The global aggregation goes beyond the query context — it allows counting aggregates across all documents with additional filters.
How to Work with Nested Attributes?
If attributes (size, color, material) are stored as nested objects:
"mappings": {
"properties": {
"attributes": {
"type": "nested",
"properties": {
"name": { "type": "keyword" },
"value": { "type": "keyword" }
}
}
}
}
Aggregation over nested attributes:
"aggs": {
"attributes": {
"nested": { "path": "attributes" },
"aggs": {
"attribute_names": {
"terms": { "field": "attributes.name", "size": 50 },
"aggs": {
"attribute_values": {
"terms": { "field": "attributes.value", "size": 20 }
}
}
}
}
}
}
This is two-level nested aggregation: first group by attribute name ("Color", "Size"), within each group — values ("Black", "White").
Cardinality — Counting Unique Values
To display "Found 1,247 products from 89 brands", use cardinality aggregation with field brand and precision_threshold 100. This guarantees accuracy up to 100 unique values; above that, error margin is 1–6%.
Setting Up Faceted Search in 5 Steps
- Identify fields for filtering: brand, price, category, rating, attributes. Typical set is 5–10 fields.
- Configure mapping: use type
keywordfor key fields,nestedfor embedded. Ensure fields for aggregations are not analyzed. - Build a query with aggregations: include all necessary aggregations (terms, range, histogram, nested). For correct counters, use
post_filterandglobalaggregations. - Apply post_filter: separate filters from the main query. This preserves aggregation counters.
- Optimize performance: use
execution_hint: mapfor low-cardinality fields, caching for aggregations without search query, limit size to 20.
Aggregation Optimization
terms aggregation with a large size is an expensive operation: each shard returns top-N values, the coordinating node merges results. For high-cardinality fields (thousands of unique values), performance degrades. Optimization methods:
Optimization methods table
| Method | Description | When to Apply |
|---|---|---|
| execution_hint: map | For low-cardinality (<1000) works faster than ordinals | Status, category fields |
| Aggregation caching | Aggregations without search query are cached in shard request cache | Catalog pages without search |
| Limit size | For facets, top-20 values are enough; deep pagination unnecessary | Always |
Comparison of Aggregation Types
| Type | Example Use | Performance |
|---|---|---|
| Terms | Group by brand | Fast when cardinality <10k |
| Range | Price ranges | Very fast |
| Histogram | Rating with step 1 | Fast |
| Nested | Nested attributes | Medium (depends on number of objects) |
| Cardinality | Unique brands | Fast (HyperLogLog algorithm) |
Implementation on the Application Side
PHP class for building facet queries:
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;
}
}
What's Included in Turnkey Faceted Search Setup
- Designing mapping schemas and indices.
- Configuring all types of aggregations (terms, range, nested, cardinality).
- Implementing post_filter + global aggregations logic for correct counters.
- Performance optimization (execution_hint, caching).
- Writing server-side code for building queries and processing responses.
- Documentation, team training, post-implementation support.
Timelines
- Basic implementation (3–5 aggregation types) — 2 working days.
- Complex scenario (post_filter, global, nested) — 3–4 days.
- Optimization on real volumes (>5 million documents) — additional day.
Our team has 6+ years of experience with Elasticsearch and certified engineers. We have completed over 50 faceted search projects, guaranteeing counter accuracy and performance. Contact us for a consultation — we'll help you avoid expensive mistakes. The cost is calculated individually, and the savings from implementation can be significant. Get a complete solution with full documentation and support.







