Module 04: Aggregations and Fuzzy Search
Why this matters
A search results page is more than a ranked list. Look at any e-commerce or
log-analytics UI and you'll see two more things: facets ("Brand: Summit
(42), Pace (17)… Price: under $50 (9), $50-100 (23)…") that both summarize the
results and let users drill in, and typo tolerance, so a user who types
runing shoez still gets running shoes. Both are core to a search feature that
feels professional, and both are this module.
Facets come from aggregations — Elasticsearch's analytics engine, which groups and computes over your documents in the same request that returns hits. Aggregations are also how the entire log/metrics side of Elasticsearch works ("events per minute," "p99 latency by service"), so this is a doubly important tool. Typo tolerance comes from fuzzy matching, built on edit distance. And underpinning the performance of both is the query context vs filter context distinction from module 03 — revisited here from the angle that actually pays your latency bills: why filters are faster and cacheable, and why you should filter before you aggregate.
Concepts
Aggregations: buckets and metrics
An aggregation runs alongside a query and computes summaries over the matching documents. Two fundamental families:
- Metric aggregations compute a number over a set of documents:
avg,min,max,sum,stats(all of those at once),cardinality(approximate distinct count),percentiles(p50/p95/p99 — the log-analytics staple). "Average price of the matching products," "p99 request latency." - Bucket aggregations group documents into buckets, each with a
doc_count:terms(one bucket per distinct value — the engine behind faceted navigation),range/histogram(numeric buckets like price bands),date_histogram(time buckets — "events per hour," the backbone of every log dashboard). Buckets can be nested: inside eachtermsbucket you can run a metric (avg price per brand) or another bucket agg (price histogram per brand). This nesting is what makes aggregations expressive.
terms "by_brand" (bucket: one per distinct keyword value)
├─ "Summit" doc_count 3
│ └─ avg "avg_price" 142.6 (metric nested inside the bucket)
└─ "Pace" doc_count 2
└─ avg "avg_price" 82.2
A defining feature: you can ask for aggregations and search hits in one
request, or ask for only aggregations by setting "size": 0 (return no
document hits, just the computed buckets/metrics). For a facet sidebar you
almost always want size: 0 on the facet sub-requests — you don't need the
documents again, just the counts. Aggregations run over the documents matching
the query, which is the mechanism behind "these facet counts reflect my current
search/filters."
Two accuracy caveats worth knowing early: terms aggregation counts on a
sharded index are approximate by default (each shard returns its top N and
they're merged, which can slightly misreport counts for low-frequency terms —
tunable with shard_size), and cardinality is an approximate distinct
count (the HyperLogLog algorithm — fast and low-memory, trading a small error).
For a single-shard local index you won't see the terms approximation, but on a
real cluster you must know it exists. And terms aggregations require a
keyword/numeric field, never an analyzed text field — the exact module-01
lesson, now for aggregations.
Query context vs filter context — the performance angle
Module 03 introduced this split for relevance (filters don't affect
_score). Here's why it's also the biggest single latency lever you control.
When a clause runs in query context, Elasticsearch computes a relevance
_score for every matching document — real arithmetic (BM25) per doc, and the
result is inherently query-specific, so it can't be reused.
When a clause runs in filter context, there's no scoring — the answer for
each document is a plain yes/no. That yes/no set can be represented compactly
and, crucially, cached: Elasticsearch keeps a query cache of filter
results (as bitsets) keyed by the filter, so the second time you run
in_stock = true or category = "footwear" — which happens constantly, since
facet filters repeat across users — it's essentially free. Scored queries get
no such reuse.
The practical rules that fall out of this:
- Anything that is a hard yes/no condition belongs in
filter, notmust:term,terms,rangeon price/date,exists, geo bounds. Only the genuinely "how relevant" part (the user's full-textmatch) belongs in query context. - Filter first, then aggregate. Aggregations run over the query's matching set, so narrowing that set with cheap cached filters before aggregating makes the aggregation faster (fewer docs to bucket) — and keeps the facet counts consistent with what the user is looking at.
- A common pattern for facets: use
post_filterwhen you want the facet counts to reflect the broad search but the hits to reflect the user's selected facet — a subtlety you'll meet in the exercises and lean on in the capstone.
Internalize the mental cost model: a match costs scoring work every time; a
filter costs almost nothing the second time. Design queries so the
repeated, boolean parts are filters.
Fuzzy search: typo tolerance via edit distance
Users misspell. Fuzzy matching finds terms within a small edit distance
(Levenshtein distance) of the query term — the number of single-character
insertions, deletions, substitutions, or transpositions needed to turn one word
into another. runing → running is edit distance 1 (insert an n);
shoez → shoes is 1 (substitute z→s); from → form is 1 (a transposition).
Levenshtein edit distance = min single-char edits to turn one term into another:
runing → running insert 'n' distance 1
shoez → shoes substitute z→s distance 1
from → form transpose r/o distance 1
fuzziness: AUTO ─► allowed edits scale with term length (0 / 1 / 2)
You enable it with fuzziness:
fuzziness: 1or2— allow up to that many edits. Two is the practical maximum; beyond it, matches become noise (too many unrelated words are within 3 edits).fuzziness: "AUTO"— the recommended default: it scales the allowed edits by term length (0 edits for very short terms of 1-2 chars, 1 for 3-5 chars, 2 for longer). This avoids the trap where a 1-edit allowance on a 3-letter word matches half the dictionary.
Two ways to invoke it:
{ "match": { "name": { "query": "runing shoez", "fuzziness": "AUTO" } } } // fuzzy full-text
{ "fuzzy": { "brand": { "value": "sumit", "fuzziness": 1 } } } // fuzzy on a term
Prefer match with fuzziness for user-facing search boxes (it's analyzed
and multi-term); the raw fuzzy query is a term-level tool. Costs to respect:
fuzzy matching is more expensive than exact matching (the engine expands
your term into all the index terms within the edit distance and searches them
all), and it can hurt precision (match things you didn't mean). Two
mitigations: prefix_length (require the first N characters to match exactly —
big speedup and precision win, since typos in the first letter are rare) and
max_expansions (cap how many variant terms are considered). Fuzzy is a
scalpel, not a default-on switch for every field.
Fuzzy vs. the other "approximate" tools (don't confuse them)
fuzzy/fuzziness— typo tolerance via edit distance on whole terms. For misspellings.match_phrase_prefixand thecompletionsuggester — autocomplete (matching a prefix as the user types), a different problem you'll build in the capstone.runni→runningis prefix, not edit distance.- wildcard/regex (
run*) — pattern matching. Powerful but a performance trap (especially leading wildcards*run), covered as an anti-pattern in module 05. Don't reach for wildcard when you meant fuzzy or prefix.
Keeping these straight prevents the classic mistake of using a slow leading wildcard to do a job fuzzy or prefix matching does faster and better.
Command reference
| Aggregation / feature | DSL | Purpose |
|---|---|---|
terms |
{"terms":{"field":"brand"}} |
Facet counts per distinct value (keyword!) |
range |
{"range":{"field":"price","ranges":[...]}} |
Price bands, etc. |
histogram |
{"histogram":{"field":"price","interval":50}} |
Fixed-width numeric buckets |
date_histogram |
{"date_histogram":{"field":"created","calendar_interval":"month"}} |
Time buckets |
avg/min/max/sum/stats |
{"avg":{"field":"price"}} |
Metrics over the set |
cardinality |
{"cardinality":{"field":"sku"}} |
Approx distinct count |
percentiles |
{"percentiles":{"field":"latency"}} |
p50/p95/p99 |
| nested aggs | terms with sub-aggs |
Metric/bucket per bucket |
"size": 0 |
top-level | Aggregations only, no hits |
| filter context | bool.filter |
Cacheable yes/no; no scoring |
post_filter |
top-level | Filter hits after aggs computed |
| fuzzy full-text | match + "fuzziness":"AUTO" |
Typo-tolerant search box |
| fuzzy term | fuzzy + prefix_length/max_expansions |
Term-level typo tolerance |
Faceted search request — hits + facet counts + a metric, filtered — in curl:
curl "localhost:9200/products/_search?pretty" -H 'Content-Type: application/json' -d '{
"query": {
"bool": {
"must": [ { "match": { "name": "running" } } ],
"filter": [ { "range": { "price": { "lte": 200 } } } ]
}
},
"aggs": {
"by_brand": { "terms": { "field": "brand" } },
"price_bands":{ "range": { "field": "price",
"ranges": [ {"to":100}, {"from":100,"to":150}, {"from":150} ] } },
"avg_price": { "avg": { "field": "price" } }
}
}'
Fuzzy search box in curl:
curl "localhost:9200/products/_search?pretty" -H 'Content-Type: application/json' -d '{
"query": { "match": { "name": {
"query": "runing shoez", "fuzziness": "AUTO", "prefix_length": 1
}}}}'
Python — facets with size=0, and a nested aggregation:
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
r = es.search(index="products", size=0,
query={"bool": {"filter": [{"term": {"in_stock": True}}]}},
aggs={
"by_brand": {
"terms": {"field": "brand"},
"aggs": {"avg_price": {"avg": {"field": "price"}}} # metric per bucket
}
})
for b in r["aggregations"]["by_brand"]["buckets"]:
print(b["key"], b["doc_count"], round(b["avg_price"]["value"], 2))
Hands-on exercises
Rebuild a richer shop index so aggregations have something to chew on.
1. Seed a dataset with variety
curl -XDELETE "localhost:9200/shop"
curl -XPUT "localhost:9200/shop" -H 'Content-Type: application/json' -d '{
"mappings":{"properties":{
"name":{"type":"text"},"brand":{"type":"keyword"},
"category":{"type":"keyword"},"price":{"type":"scaled_float","scaling_factor":100},
"rating":{"type":"float"},"in_stock":{"type":"boolean"},
"created":{"type":"date"}}}}'
curl -XPOST "localhost:9200/shop/_bulk" -H 'Content-Type: application/json' -d '
{"index":{}}
{"name":"Trail Running Shoes","brand":"Summit","category":"footwear","price":129.99,"rating":4.6,"in_stock":true,"created":"2026-05-01"}
{"index":{}}
{"name":"Road Running Shoes","brand":"Pace","category":"footwear","price":149.50,"rating":4.2,"in_stock":true,"created":"2026-06-01"}
{"index":{}}
{"name":"Hiking Boots","brand":"Summit","category":"footwear","price":179.00,"rating":4.8,"in_stock":false,"created":"2026-03-01"}
{"index":{}}
{"name":"Running Socks","brand":"Pace","category":"apparel","price":14.99,"rating":4.0,"in_stock":true,"created":"2026-07-01"}
{"index":{}}
{"name":"Trail Jacket","brand":"Summit","category":"apparel","price":89.00,"rating":4.5,"in_stock":true,"created":"2026-04-01"}
'
curl -XPOST "localhost:9200/shop/_refresh"
2. A terms facet (the brand sidebar)
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{
"size": 0,
"aggs": { "by_brand": { "terms": { "field": "brand" } } }
}'
Expected: buckets Summit (3) and Pace (2), no document hits (because
size: 0). This is exactly the data behind a "Brand" facet.
3. Metric aggregations
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{
"size": 0,
"aggs": { "price_stats": { "stats": { "field": "price" } },
"distinct_brands": { "cardinality": { "field": "brand" } } }
}'
Expected: price_stats with min/max/avg/sum/count, and distinct_brands
count 2. Note cardinality is approximate (exact here at this tiny scale).
4. Nested aggregation: average price per category
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{
"size": 0,
"aggs": { "by_category": {
"terms": { "field": "category" },
"aggs": { "avg_price": { "avg": { "field": "price" } } }
}}}'
Expected: footwear and apparel buckets, each with its own avg_price
sub-metric. Nesting a metric inside a bucket = "this number, per group."
5. Range and date_histogram buckets
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{
"size": 0,
"aggs": {
"price_bands": { "range": { "field": "price",
"ranges": [ {"to":50}, {"from":50,"to":150}, {"from":150} ] } },
"per_month": { "date_histogram": { "field": "created", "calendar_interval": "month" } }
}
}'
Expected: three price bands with counts, and one bucket per calendar month
present in the data. date_histogram is the backbone of every time-series/log
dashboard you'll build in module 06.
6. Facets that respect the current search (filter + aggregate)
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{
"size": 0,
"query": { "bool": { "filter": [ { "term": { "in_stock": true } } ] } },
"aggs": { "by_brand": { "terms": { "field": "brand" } } }
}'
Expected: brand counts now reflect only in-stock products (Summit 2 — the boots are out of stock; Pace 2). The aggregation ran over the filtered set — which is why facet counts update as users apply filters.
7. Fuzzy search catches the typos
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{"query":{"match":{"name":"runing shoez"}}}'
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{"query":{"match":{"name":{"query":"runing shoez","fuzziness":"AUTO"}}}}'
Expected: the first (no fuzziness) returns 0 hits — runing and shoez
aren't terms in the index. The second, with fuzziness: "AUTO", finds the
running shoes: runing→running and shoez→shoes are each 1 edit. That's typo
tolerance in one flag.
8. See fuzzy's precision cost, then rein it in
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{"query":{"match":{"name":{"query":"boats","fuzziness":2}}}}'
Expected: with fuzziness: 2, boats may match Boots (edit distance 2:
a→o … actually boats→boots is 1 substitution) and potentially other
loosely-related terms — demonstrating over-matching. Add "prefix_length": 2
and observe the match set tighten (and the query speed up), because now the
first two characters must be exact. This is the precision/recall dial.
9. Diagnose and fix: the aggregation that errors, and the one that lies
Part A — this errors:
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{"size":0,"aggs":{"by_name":{"terms":{"field":"name"}}}}'
Part B — a colleague filters in the wrong place and gets facet counts that
don't match the hits. They put the in-stock condition in must and are
surprised the _score sorting changed and they lost caching. Explain both.
Answer
Part A errors with "Text fields are not optimised for … aggregations … set
fielddata=true … or use a keyword field instead." name is an analyzed
text field; terms aggregations need a keyword (or numeric) field so each
value is one indivisible bucket. Fix: aggregate on a keyword field/sub-field
(e.g. add name.raw and use by_name: {terms: {field: "name.raw"}}), or
aggregate on brand/category which are already keyword. (Enabling
fielddata is possible but memory-dangerous and almost never the right answer
— it's the module-01 text/keyword lesson resurfacing.)
Part B: an exact yes/no condition like in_stock=true in must runs in
query context, so (1) it contributes to _score — perturbing relevance
ranking they didn't intend — and (2) it can't use the query cache, so the
repeated filter isn't reused across requests. Move it to bool.filter: same
inclusion/exclusion, no scoring effect, and cacheable. This is the module-03
context split, now showing its performance teeth.
10. post_filter for facet UX
curl "localhost:9200/shop/_search?pretty" -H 'Content-Type: application/json' -d '{
"query": { "match": { "name": "running" } },
"aggs": { "by_brand": { "terms": { "field": "brand" } } },
"post_filter": { "term": { "brand": "Pace" } }
}'
Expected: the by_brand aggregation shows counts for all brands matching
"running" (so the user still sees "Summit (n)" as a clickable option), while the
returned hits are restricted to Pace. post_filter applies after
aggregations are computed — the standard trick for "keep showing me the other
facet options even though I've selected one."
Independent challenge
No queries given.
Task: Build the data for a complete faceted search results page for the
shop index (or a blog index of your own), in as few requests as sensible:
- Full-text, typo-tolerant search over product names (a user typing
jaketshould still find the Trail Jacket). - A results list restricted to in-stock products, where that restriction is placed so it neither affects relevance scoring nor forfeits caching.
- Facet counts for brand and for price bands (under 50 / 50-150 / 150+), reflecting the search terms.
- The average rating of the matching products.
- Sorted so the most relevant come first — but if the user instead chose "sort by price ascending," a variant query that pays no scoring cost.
Reach back to module 03 for the filter-vs-must placement in requirement 2 and
the no-score sort in requirement 5, and to module 01 for why the facet fields
in requirement 3 must be keyword.
Hint
One _search: bool with must = match name + fuzziness:"AUTO"
(requirements 1), filter = term in_stock=true (requirement 2 — filter
context). aggs = a terms on brand and a range on price
(requirement 3) plus an avg on rating (requirement 4). For requirement 5,
the default returns by _score; the price-ascending variant adds
"sort":[{"price":"asc"}], which makes _score null and skips scoring.
Common mistakes & troubleshooting
termsaggregation on atextfield. Errors (or, iffielddataenabled, is memory-dangerous and buckets by word). Aggregate onkeyword.- Putting boolean conditions in
mustinstead offilter. Costs scoring work and loses query-cache reuse, and can perturb relevance. Gate infilter. - Forgetting
size: 0for pure facet requests. You pay to fetch and score document hits you don't use. Setsize: 0when you only want aggregations. - Treating
terms/cardinalitycounts as exact on a multi-shard index. They're approximate by design; tuneshard_sizeor know the error exists. - Fuzzy on everything, always. It's slower and hurts precision. Use
fuzziness: "AUTO", addprefix_length, and don't fuzzy fields where exact matching is expected. - Confusing fuzzy, prefix/autocomplete, and wildcard. Edit distance (typos) ≠ prefix (as-you-type) ≠ wildcard (patterns, and slow). Pick the tool that matches the actual need; never use a leading wildcard for typo tolerance.
post_filtervsfilterconfusion.filternarrows both hits and aggregations;post_filternarrows only the hits, after aggs are computed. Usepost_filteronly for the "keep other facet options visible" UX.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What is the difference between a bucket aggregation and a metric aggregation? Give one example of each.
- Why does a
termsaggregation belong on akeywordfield and not on atextfield, and what goes wrong if you run it ontext? - Why do you set
size: 0on a request that only needs facet counts, and what does it save you? - Why are
termsandcardinalitycounts described as approximate on a multi-shard index, and what knob influences their accuracy? - What does
fuzziness: "AUTO"do, and why areprefix_lengthand not fuzzing every field important for precision and performance? - Distinguish fuzzy matching, prefix/autocomplete, and wildcard queries — what is each actually for, and why is a leading wildcard the wrong tool for typo tolerance?
- What is the difference between
filterandpost_filter, and when ispost_filterthe right choice?
Answers
- A bucket aggregation groups documents into buckets (e.g.
termsby brand,date_histogramby day,rangeby price band). A metric aggregation computes a number over a set of documents (e.g.avg,sum,max,cardinality). Buckets are the "group by," metrics are the "aggregate function," and metrics are usually nested inside buckets. termsneeds the verbatim value as a single token, which lives in doc-values on akeywordfield. On an analyzedtextfield it either errors (fielddata disabled by default) or, if fielddata is enabled, buckets by individual word and is memory-dangerous. Aggregate on the.keywordsub-field.size: 0tells Elasticsearch to return no document hits — only the aggregations. It saves the cost of fetching and scoring hit documents you were going to throw away, which is pure waste for a pure-facet request.- Each shard computes its own top terms / distinct-count estimate and the
coordinating node merges them, so a term that's just below the cutoff on
each shard can be under- or over-counted, and
cardinalityuses a probabilistic (HyperLogLog) estimate.shard_size(how many terms each shard returns before merging) trades accuracy for cost. fuzziness: "AUTO"allows an edit distance that scales with term length (short terms tolerate fewer typos, longer terms more), which is safer than a fixed distance.prefix_lengthrequires the first N characters to match exactly, which both improves precision and drastically cuts the number of terms considered; not fuzzing every field avoids the slowdown and precision loss on fields where exact matching is expected.- Fuzzy = tolerate typos via edit distance (
runing→running). Prefix/autocomplete = match the beginning of a term as the user types (run→running,runner). Wildcard = arbitrary patterns (r*n), and it's slow. A leading wildcard (*unning) can't use the index efficiently and doesn't model typos at all — use fuzzy for typo tolerance. filternarrows both the returned hits and the documents the aggregations see;post_filteris applied only to the hits after aggregations are computed. Usepost_filterfor the faceted-navigation UX where you want to filter the visible results by one facet while still showing the counts for the other facet options.
Cumulative review
Closed-book, mixing modules 00-04. Cover the answers; if a question from an earlier module stumps you, go redo that module's exercises rather than peeking.
- (00) Explain why an inverted index makes both a
matchsearch and atermsaggregation fundamentally different operations from a relational full-table scan. - (01) A field must support faceting (counts per value), full-text search, and alphabetical sorting. Give the exact mapping and say which query/agg uses which (sub-)field.
- (02) A
termquery for"Running"on atextfield returns nothing but amatchfor"running"works. Explain the mechanism in terms of analysis on both the index and query sides. - (03) State the difference between query context and filter context in both relevance terms and performance terms, and give one clause that belongs in each.
- (03+04) You want "products matching the user's words, in-stock only, brand
facet counts for all brands (not just in-stock), higher-rated ranked
higher." Name where each requirement goes:
must,filter,should,function_score, aggregation, orpost_filter— and why. - (04) Distinguish fuzzy matching, prefix/autocomplete, and wildcard search by the problem each solves, and name the one that's a performance anti-pattern and why.
- (00+04) Why are
termsaggregation counts approximate on a multi-shard index, connecting it back to what a shard actually is?
Answers
- Both operations exploit the inverted index / columnar structures rather than
reading every document:
matchjumps straight to the postings lists for its terms (cost scales with matching terms, not doc count), and atermsaggregation groups using per-field data structures (doc values) rather than scanning and parsing every row. A relational full scan reads and evaluates every row. {"type":"text","fields":{"raw":{"type":"keyword"}}}. Full-text search uses the basetextfield withmatch; faceting usesfield.raw(keyword) with atermsagg; alphabetical sorting usesfield.rawinsort.- On the index side the
textfield's analyzer lowercased/tokenized the value (stored termrunning);termdoesn't analyze the query, so it looks for the literalRunning(capital) which isn't in the index → 0 hits.matchanalyzes the query the same way (Running→running), so query and index terms line up. - Relevance: query context computes and contributes
_score; filter context is pure yes/no and contributes nothing to_score. Performance: query context does per-doc scoring (not reusable); filter context is cacheable (bitset query cache), so repeated filters are ~free.must/match→ query context;term in_stock/range price→ filter context. match(user's words) →must(scored). in-stock-only hits → but brand facet must show all brands, so the in-stock restriction goes inpost_filter(narrows hits only, after aggs) rather thanfilter; theby_brandtermsagg computes over the pre-post_filter set. Higher-rated ranked higher →function_scorewithfield_value_factoron rating. (If you wanted facet counts to also reflect in-stock, you'd usefilterinstead ofpost_filter— the choice depends on desired facet UX.)- Fuzzy = typo tolerance via edit distance on whole terms. Prefix/autocomplete
= matching the start of a term as the user types. Wildcard = pattern
matching (
run*), and leading wildcards (*run) are the anti-pattern because they can't use the inverted index efficiently and scan huge term sets. Use fuzzy/prefix instead where possible. - A shard is an independent Lucene index holding a subset of documents. A
termsagg asks each shard for its top-N terms and merges them; a term that's globally in the top-N but not top-N on some shard can be under-counted. Hence approximate counts, tunable withshard_size.
Further reading & sources
- Aggregations - the reference overview of bucket, metric, and pipeline aggregations.
- Terms aggregation - the facet workhorse, including the
shard_size/approximate-count caveats. - Cardinality aggregation - the HyperLogLog-based approximate distinct count.
- Fuzzy query - edit-distance matching,
fuzziness,prefix_length, andmax_expansions. - Common options: fuzziness - what
AUTOactually maps to by term length. - Filter aggregation and post_filter - how
post_filternarrows hits after aggregations for faceted-navigation UX.
Next
05-pagination-and-performance-optimization
— you can search, rank, facet, and tolerate typos. Next you'll make it fast
and scalable: why from/size deep pagination collapses at scale and what
search_after does instead, how to choose shard counts, loading data
efficiently with the bulk API, and the wildcard/other query anti-patterns that
quietly destroy latency.