Hierarchical Category Facets

A flat brand facet is a list; a category facet is a tree, and trees need a different indexing strategy, a different aggregation, and a different interaction model. Getting any of the three wrong produces the familiar failures — counts that do not add up, a facet that cannot be drilled into, breadcrumbs that lose state on refresh. This page builds one that behaves. It sits under faceted navigation and filtering in search frontend and UX patterns.

Prerequisites

  1. A category taxonomy with a stable path for every node — names alone are ambiguous once two branches share a leaf name.
  2. Control over the mapping, since the indexing shape is the whole technique.
  3. URL-encoded filter state, per the pattern in the parent guide, so drilling in and backing out survive navigation.
  4. A decision about whether a document belongs to one category or several, because it changes the aggregation.

Diagnosis: the naive shapes and why they fail

Storing the leaf category alone — "Tools" — makes it impossible to aggregate at a higher level: you cannot count everything under “Home & Garden” without knowing which leaves belong to it, and that mapping lives outside the index.

Storing a nested object per level — {"l1": "Home", "l2": "Garden", "l3": "Tools"} — supports per-level aggregation but breaks as soon as depth varies, which it always does. A three-level product and a five-level product need different field sets, and queries have to know the depth in advance.

The shape that works is a path array: every ancestor path stored as its own keyword term.

{
  "category_paths": [
    "Home",
    "Home > Garden",
    "Home > Garden > Tools"
  ]
}

Every level is a single term, so a terms aggregation with a prefix filter counts any level, and drilling in is a filter on one more specific string. Depth is irrelevant because each path is independent.

Three ways to index a category hierarchy Leaf-only indexing cannot aggregate upward, per-level fields break on variable depth, and a path array supports both. leaf only "Tools" cannot roll up field per level l1, l2, l3… breaks on variable depth path array every ancestor as a term any level, any depth the path array costs a few extra terms per document and removes every structural limitation which is an unusually one-sided trade
Three shapes with very different capabilities. The extra storage of the path array is trivial next to what it makes possible.

Solution Steps

1. Generate the path array at ingestion

# categories.py — one term per ancestor, generated once at write time
SEP = " > "

def category_paths(tree: list[str]) -> list[str]:
    """['Home','Garden','Tools'] -> ['Home', 'Home > Garden', 'Home > Garden > Tools']"""
    return [SEP.join(tree[: i + 1]) for i in range(len(tree))]

def document_categories(product) -> list[str]:
    paths = []
    for tree in product["category_trees"]:      # a product may sit in several branches
        paths.extend(category_paths(tree))
    return sorted(set(paths))

2. Map the field as a plain keyword

{ "category_paths": { "type": "keyword" } }

No analyzer, no normalizer. The path is an identifier, and normalising it would merge categories that differ only in case — which for a taxonomy is a bug rather than a convenience.

3. Aggregate one level at a time

The facet shows the children of the current node, which is a terms aggregation restricted to paths one level deeper than the current selection.

{
  "query": { "bool": { "filter": [
    { "term": { "category_paths": "Home > Garden" } }
  ]}},
  "aggs": {
    "children": {
      "terms": {
        "field": "category_paths",
        "include": "Home > Garden > [^>]*",
        "size": 20
      }
    }
  }
}

The include regex is what restricts the buckets to the immediate children rather than every descendant, and it is the piece most implementations miss — producing a facet that lists grandchildren alongside children.

4. Render breadcrumbs from the selected path

The selected path already contains the full ancestry, so breadcrumbs need no additional lookup.

// breadcrumbs.js — the filter value is the breadcrumb trail
function breadcrumbs(selectedPath) {
  const parts = selectedPath.split(" > ");
  return parts.map((label, i) => ({
    label,
    path: parts.slice(0, i + 1).join(" > "),   // each crumb is a valid filter value
  }));
}

5. Encode the selection in the URL

/search?q=spade&category=Home%20%3E%20Garden%20%3E%20Tools

One parameter carries the entire position in the tree, which makes back, refresh, sharing and bookmarking work without any additional state.

6. Cap the depth you index and display

MAX_DEPTH = 5

def category_paths(tree: list[str]) -> list[str]:
    tree = tree[:MAX_DEPTH]                       # deeper levels are not facetable
    return [SEP.join(tree[: i + 1]) for i in range(len(tree))]

Taxonomies grow deeper than anyone intends, and a ten-level path produces ten terms per document for levels no interface will ever show. Capping at four or five keeps the term count bounded and the facet usable.

Verification

Confirm the path array is generated correctly:

curl -s 'localhost:9200/products/_doc/sku-4471?filter_path=_source.category_paths' | jq
# { "_source": { "category_paths": [
#     "Home", "Home > Garden", "Home > Garden > Tools" ] } }

Confirm the children aggregation returns exactly one level:

curl -s 'localhost:9200/products/_search' -H 'Content-Type: application/json' -d '{
  "size": 0,
  "query": {"bool":{"filter":[{"term":{"category_paths":"Home > Garden"}}]}},
  "aggs": {"children":{"terms":{"field":"category_paths","include":"Home > Garden > [^>]*"}}}
}' | jq -r '.aggregations.children.buckets[] | "\(.doc_count)\t\(.key)"'
# 812   Home > Garden > Tools
# 431   Home > Garden > Plants
# => no grandchildren in the list

Confirm counts are consistent between a parent and the sum of its children:

python3 verify_facets.py --root "Home > Garden"
# parent count:        1243
# sum of children:     1243
# => equal, so no product sits in the parent without a child (or the discrepancy is intended)

The interaction model matters as much as the data

Two interaction patterns are common and they suit different catalogs, so the choice should be deliberate rather than inherited from whichever component library was available.

Drill-down replaces the facet contents with the children of the selected node and adds a breadcrumb. It keeps the sidebar compact regardless of taxonomy size and is the right choice for deep or large taxonomies. Its cost is that siblings disappear on selection, so switching between two categories at the same level requires backing out first.

Expand-in-place shows the tree with the selected branch expanded and siblings still visible. It makes lateral movement easy and becomes unusable past a few dozen visible nodes, which happens quickly on a real catalog. It suits shallow taxonomies with a small number of top-level categories.

A workable hybrid, and the pattern most large catalogs converge on, is to expand in place for the first two levels and drill down below that. The top of the tree is small enough to display and is where lateral comparison actually happens; the depths are where compactness matters.

Whichever model is chosen, the counts must reflect the current query — a facet showing catalog-wide totals rather than counts within the active search is a promise the result page will not keep, and it is the single most common complaint about category facets.

Drill-down compared with expand-in-place Drill-down stays compact at any depth but hides siblings, while expand-in-place keeps siblings visible and grows unusable on large taxonomies. drill-down compact at any depth siblings hidden on select expand in place easy lateral movement unusable past ~30 nodes most large catalogs expand the first two levels and drill down below that
Two models with opposite strengths. The hybrid works because lateral comparison happens near the top of a taxonomy and compactness matters near the bottom.

Common Pitfalls

Parent counts that do not equal the sum of children

This is usually correct rather than broken, and the cause is products assigned directly to a parent category with no child. The interface has to decide what to do about them — an “other” bucket, or promoting them into a child — and doing nothing produces a facet where the arithmetic visibly fails and users lose confidence in every count on the page.

Using a separator that appears in category names

A path built with > breaks the moment a category is named “Rock > Pop” or contains the separator for any other reason, because the path can no longer be split unambiguously. Choose a separator that cannot occur in a name — a control character, or a rarely used sequence — and validate category names against it at ingestion rather than discovering the collision in production.

Aggregating on an analysed field

If category_paths is mapped as text rather than keyword, the aggregation buckets individual words rather than whole paths, producing a facet listing “Home”, “Garden” and “Tools” as separate entries with wildly wrong counts. The symptom is distinctive once seen and confusing the first time.

Operational notes

Validate the taxonomy at ingestion rather than trusting it. A cycle in the tree, a node with two parents, or a name containing the separator all produce path arrays that look plausible and behave strangely, and catching them at the boundary is far cheaper than diagnosing the resulting facet weeks later.

Regenerate paths whenever the taxonomy changes, and treat a taxonomy change as a reindex rather than an update. Renaming a mid-level category changes every descendant path, so a partial update leaves documents carrying paths that no longer exist — which manifests as facet entries that return no results when clicked.

Keep the taxonomy itself in one place and generate both the index paths and the interface’s navigation from it. Two independent representations of the same tree drift, and the drift shows up as a category visible in navigation that the facet has never heard of, or vice versa. Generating both from one source removes the entire class of inconsistency.

One taxonomy source feeding both index and interface A single taxonomy definition generates the indexed path arrays and the navigation tree, preventing drift between them. taxonomy source indexed path arrays navigation tree two hand-maintained copies drift within a quarter; one source cannot
The taxonomy is shared state between search and navigation. Generating both sides from one definition is what keeps them agreeing.

A last note on scale: the path array grows the term count per document by the taxonomy depth, and on a catalog where products belong to several branches it multiplies again. At five levels and three branches that is fifteen terms per document, which is entirely manageable — but it is worth measuring rather than assuming, because a taxonomy that has grown to twelve levels with unrestricted multi-assignment produces a very different number.