Amazon’s left sidebar: “Brand: Nike, Adidas, New Balance. Price: Under $50, $50-$100. Size: 8, 9, 10, 11.” Each filter is a facet. Selecting one narrows results and updates the counts on all other facets. Faceted search is not just filtering — it’s computing counts for all possible filter values simultaneously, on the filtered result set.

The Count Problem#

The hard part is not filtering: “show me Nikes under $50” is a simple index query. The hard part is: “given my current filters, how many Adidas are there? How many are size 10? How many are between $50 and $100?” All of these counts must be recomputed every time the filter changes.

A naive approach: for each facet value, run a separate COUNT query. 10 brands, 5 price ranges, 8 sizes = 23 separate queries. Multiplied across millions of concurrent users, this is untenable.

Facet Aggregation in Elasticsearch#

Elasticsearch’s aggregation framework computes all facet counts in a single query pass. One query: “filter by category=shoes AND brand=Nike, then aggregate by size, price range, and color.” Elasticsearch scans the matching documents once and accumulates counts for all requested facets simultaneously.

graph TD A[Query: category=shoes, brand=Nike] --> B[Elasticsearch: filter + multi-aggregation] B --> C[Result set: 4200 matching products] B --> D[Size aggregation: size 9 = 890, size 10 = 1100, size 11 = 820] B --> E[Price aggregation: under $100 = 2100, $100-200 = 1800] B --> F[Color aggregation: white = 1500, black = 1900] C --> G[Return paginated results + all facet counts] D --> G E --> G F --> G style A fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style B fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style C fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style D fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style E fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style F fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style G fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff

Inverted Index for Facets#

Under the hood, each facet value is a term in the inverted index. “size:10” is a term. “brand:Nike” is a term. Combining filters is a bitmap AND of the posting lists. Counting results per facet value is a population count on the resulting bitmap. This is fast because bitwise AND and popcount are cheap CPU operations.

Caching Facet Counts#

Facet counts for popular filter combinations (category=shoes, no other filters) are heavily cached. The most common entry point query (just category filter, no attributes selected) can be fully cached. As users add filters, the combinations explode and caching becomes less effective, but by that point the result set is small enough that the query is fast anyway.

Approximate Counts#

For very large result sets, exact facet counts are expensive. Elasticsearch supports approximate counts for aggregations: use cardinality estimation (HyperLogLog) when exact counts aren’t needed. For a result set of 10 million products, showing “1,247,832 Nikes” vs “approximately 1.2M Nikes” rarely matters to the user. Approximate counting saves significant computation.

At Oracle#

Oracle Commerce had faceted navigation for B2B parts catalogs. The challenge was hierarchical facets: a part belongs to category > subcategory > part type, and counts needed to roll up through the hierarchy. We precomputed rollup counts nightly for the top 3 hierarchy levels. Dynamic counts (leaf-level filtering) ran live against Elasticsearch. The precomputed layer handled 90% of queries from the browse flow; Elasticsearch handled the 10% with deep filter combinations.

What I’m Learning#

Faceted search is computationally expensive because it requires computing aggregates over filtered sets simultaneously. Elasticsearch’s aggregation framework is the standard solution. Caching the most common filter combinations and using approximate counts for large sets keeps it fast in production.

What’s the most complex faceted search you’ve built, and where did performance break down first?