How Faceted Search Actually Works in Elasticsearch (And Where It Breaks)

by Luca Cavallini Technical guides
How Faceted Search Actually Works in Elasticsearch (And Where It Breaks)

A facet is two questions, not one

A faceted search page asks two different questions at the same time. The first is the obvious one: which documents match the current search and the filters already applied. The second is asked once per available filter value: how many documents would match if this value were also selected. The checkbox that says "Red (12)" is the answer to the second question, computed while answering the first.

Most of what makes faceted search hard, in Elasticsearch or anywhere else, comes from that second question. Getting a list of matching documents is not difficult. Getting an honest count next to every filter value, for every filter, on every page load, without the response time exploding as the number of facets grows, is the actual engineering problem.

The building block: terms aggregation over a filtered set

Elasticsearch answers both questions with a single request. The query clause narrows the document set (full text match plus whatever filters are already active), and a set of aggregations attached to that same query computes, for each facet field, how many documents in that filtered set fall into each value. A terms aggregation on a color field returns a bucket per distinct value with its document count; a range or histogram aggregation does the same for numeric fields like price. All of this happens inside the same query execution, over the same filtered set, in one round trip.

This is the part that is easy to miss if you have only used SQL: an aggregation here is not a second query against the result of the first. It runs against the index's own doc values and postings during the same request, which is why adding twenty more facets to a page does not mean twenty more full scans of the matching documents. It means twenty more bucket computations over data the engine already had to touch to answer the query.

Filter context, not query context

The facets and the filters applied by clicking them should almost always live in filter context, not query context. A term filter for brand: acme does not need to compute a relevance score, it needs a yes or no answer, and Elasticsearch caches filter clauses far more aggressively than scored query clauses. Building a faceted search page entirely out of bool must clauses that all get scored is a common way to make it slower than it needs to be for no benefit: nobody sorts a category page by "relevance to the Red filter". Filters belong in filter, free text search belongs in must or a dedicated match query, and the split is not cosmetic, it changes what gets cached and what gets scored.

The multi-select trap

The one detail that breaks more faceted search implementations than any other is this: when you compute the count for a facet, you must exclude that facet's own filter from the document set you are counting against, but keep every other active filter. If you do not, every facet you have already filtered on will show a count equal to the number of results currently on screen, because you are counting the intersection with a condition that is already fully applied. Click "Red", and every other value under "Color" would then wrongly show the same number as the total, instead of how many red-and-that-value products actually exist.

The usual fix is to build one aggregation per facet field, each wrapped in its own filter aggregation that reapplies every active filter except the one for that specific field, all as siblings under the same top-level query. Concretely: the base query already carries the free text search and every filter that is not being counted right now, and each facet's aggregation adds back the other selected filters, minus its own. It is more aggregations per request, not more requests, and it is the part worth testing explicitly rather than trusting by eye, because a page can look completely correct in a demo with three products and silently show wrong counts once real data with real overlaps is loaded.

There is a second, related decision worth making on purpose: what a facet group means when several values under the same label are selected together. If a shop groups "Crimson", "Scarlet" and "Maroon" under a single "Red" filter, does selecting two of those sub-values mean "has this AND that" or "has this OR that"? A count that answers the AND question while the click applies the OR question (or the reverse) is the single most common cause of a filter that shows one number and returns a different one. The two need to be computed with exactly the same logic, which usually means treating the group as one unioned condition from the start rather than summing per-value counts that can overlap or double count.

Why this collapses without an aggregation engine, with a real number

The reason faceted search engines exist, instead of everyone just running SQL, shows up clearly once you try to get exact per-value counts on a relational database at scale. On a 200,000-product test catalog running PrestaShop's built-in, database-only search engine, with around 2,250 attribute and feature groups, computing an exact "how many results per value" count with one COUNT(DISTINCT ...) query per facet made a category page with one active filter take 43 seconds. Turning off exact counts for those facets (showing the list of values with no number attached) brought the same page down to just under 12 seconds, which shows how much of the original cost was pure counting overhead, not the search itself.

A related mistake, found during an internal code review of a faceted search module, was computing the range of a numeric facet (for a "Price" or "Weight" slider) by loading every matching value into application memory and calculating the minimum and maximum there instead of asking the database or the search engine to do it. On a catalog where that meant roughly two million rows, that single step took 11 seconds and 965 MB of memory per request, on some configurations enough to exhaust a shared hosting memory limit and return an error page instead of a slider. Pushing the same calculation into a database GROUP BY, or into a search engine's own range aggregation, turned it back into a normal, fast query, because it is exactly the kind of aggregate computation these systems are built to do in one pass over indexed data, not in application code after the fact.

Both numbers describe the same underlying mistake at different layers: asking "how many, for every possible value" once per value instead of once for all values together. Elasticsearch's aggregation framework exists specifically to answer that question in a single pass over already-indexed data. That is the actual value proposition, independent of full text relevance ranking, and it is why faceted navigation is one of the most common reasons teams adopt Elasticsearch even when their search itself does not need advanced relevance features.

A short checklist before you ship faceted search

  • Filters go in filter context, free text goes in query context. Do not score a checkbox.
  • Every facet's count aggregation must exclude that facet's own active filter while keeping every other one, or multi-select facets will lie the moment two filters are used together.
  • Decide up front, and test explicitly, whether a group of values under one label is AND or OR, and make the count and the click share the exact same logic.
  • Cap aggregation size and watch doc_count_error_upper_bound on high-cardinality fields; an uncapped terms aggregation returning thousands of buckets is its own performance problem, independent of the multi-select issue above.
  • Never compute a facet's numbers by loading raw rows into application memory. If you find yourself writing a loop over query results to build a count or a range, that loop belongs in the aggregation, not in your code.

When Elasticsearch is not the answer

None of this means every catalog needs Elasticsearch. A shop with a few thousand products and a handful of facets will rarely notice the difference a dedicated search engine makes, and running a cluster has its own operational cost: memory, disk, and someone who understands reindexing when the schema changes. The numbers above only start to matter once the facet count, the catalog size, or both grow large enough that naive counting turns into seconds instead of milliseconds. If you are not there yet, a well-optimised database query is a perfectly reasonable choice, and the checklist above still applies to it: the AND/OR question and the multi-select trap are the same problem regardless of which engine answers it.

In short

Faceted search is not one query, it is one query plus one aggregation per filter, and almost every real-world bug in it comes from breaking that model: scoring what should be a filter, counting a facet against its own already-applied condition, or answering the count and the click with two different definitions of a group. Elasticsearch's aggregation framework solves the scale problem cleanly, in a single pass over indexed data instead of one query per facet value, which is the concrete reason the 43-second and 11-second numbers above happened on a database-only engine and not on one built for this. We do this kind of work daily inside cll_essearch, our faceted search module for PrestaShop, across the native driver as well as Elasticsearch, OpenSearch and Meilisearch (see the installation guide), if you want to see the same logic applied to an online store rather than a raw index.

Elasticsearch Search & Faceted NavigationLightning-fast, typo-tolerant search and modern modal faceted navigation with the built-in engine, or powered…€99.99View product

Leave a comment

Comments are moderated before publication.

← Blog

Loading...
Back to top