Faceted Navigation: Aligning Mapping Design with Aggregations
AI generated
_doc
_index
Elasticsearch · OpenSearch · Mapping · E-Commerce Search
Faceted Navigation: Aligning Mapping Design with Aggregations
why attribute mapping decides facet performance

Faceted navigation lives on terms aggregations over product attributes, but those aggregations are only as fast as the mapping underneath them. Index attributes as an analyzed text type instead of keyword, and you get fragmented filter values, duplicate facets and aggregations that burn unnecessary memory and compute time on every category page.

18 min read keyword mapping · terms aggregation · eager_global_ordinals Elasticsearch 8.x · OpenSearch 2.x

1. Why mapping is the real facet lever

Faceted navigation, often called layered navigation in shop contexts, shows customers a set of filters alongside the product list with hit counts: brand, color, price range, material. Technically, each of these facets comes from a terms or range aggregation that runs in parallel to the actual product search within the same query. The quality of this faceted navigation, however, does not primarily depend on the aggregation syntax, but on the mapping of the underlying attributes.

A poorly chosen field mapping does not show up immediately. The query runs, the facet appears, but the values are fragmented: "Nike Air Max" on an analyzed text field turns into the two separate facet values "nike" and "air" and "max" instead of appearing as a single brand value. This pattern is the most common reason why faceted navigation in practice produces unusable filter lists, even though the aggregation query itself is syntactically correct.

The following sections show how mapping design and aggregations work together for performant faceted navigation: from the base rule of keyword over text, through global ordinals, to range facets for numeric attributes like price. Every example uses real mapping and aggregation syntax from an e-commerce context.

2. keyword instead of text: the base rule for facet fields

The most important mapping principle for faceted navigation is this: every attribute meant to be offered as a filter must be mapped as keyword, not as text. A text field goes through an analyzer at index time that splits the value into individual tokens, lowercases it and possibly removes stopwords. A terms aggregation on such a field builds facets from these tokens, not from the original attribute value, which fragments multi-word brand names or category labels into granular, customer-unusable filters.

A keyword field, on the other hand, is indexed unchanged and without analysis. "Nike Air Max" stays exactly "Nike Air Max" as a single aggregation bucket. For color values, size labels, brand names and similar categorical attributes, keyword is therefore practically always the right choice for facet mapping, regardless of whether the same attribute also plays a role in full-text search.


PUT /products
{
  "mappings": {
    "properties": {
      "brand":        { "type": "keyword" },
      "color":        { "type": "keyword" },
      "size":         { "type": "keyword" },
      "material":     { "type": "keyword" },
      "price":        { "type": "scaled_float", "scaling_factor": 100 },
      "name": {
        "type": "text",
        "analyzer": "standard"
      }
    }
  }
}

3. doc_values and the storage layer behind terms aggregations

Terms aggregations do not access the inverted index but a separate column-oriented data structure called doc_values. This structure is written automatically by default for every indexed field, unless it is explicitly disabled, and is organized so that Elasticsearch can directly ask, for an aggregation, "for every document, what value does field X have", instead of, as with a text search, "which documents contain value Y". This column-oriented organization is the actual reason why terms aggregations on keyword fields are so efficient for faceted navigation.

An important detail for mapping design: disabling doc_values on a field saves some disk space, but makes any aggregation on that field impossible. For attributes used exclusively for full-text search and never meant to serve as a facet, that can be a sensible optimization. For every attribute that is part of faceted navigation, doc_values must remain enabled, which is the default anyway.

4. Global ordinals and eager_global_ordinals

For keyword fields, Elasticsearch uses an optimization called global ordinals for terms aggregations: instead of comparing full text values on every aggregation, every unique value in a segment gets assigned a numeric id, and the aggregation then only works with these ids. Building this ordinal structure happens by default on the first aggregation request after a segment merge or refresh, which makes that first request noticeably slower than all subsequent ones.

For faceted navigation on heavily trafficked category pages, this one-time build overhead is undesirable, because it randomly hits individual users with slow response times right after a refresh invalidates the ordinals. The eager_global_ordinals mapping parameter solves this by triggering the build of the ordinal structure directly in the background after every refresh, instead of waiting for the next user request. This setting costs some extra indexing time but moves the build overhead entirely out of the user path.


PUT /products/_mapping
{
  "properties": {
    "brand": {
      "type": "keyword",
      "eager_global_ordinals": true
    },
    "color": {
      "type": "keyword",
      "eager_global_ordinals": true
    }
  }
}

5. Multi-fields: search and facet on the same attribute

Many attributes need to be both searchable and facetable, for example the product name or the category. Elasticsearch resolves this conflict with multi-fields: the main field is mapped as text for full-text search, while a sub-field named .keyword additionally stores the same value unanalyzed. Aggregations for faceted navigation then target that sub-field specifically, while search continues to run over the analyzed main field.

This pattern avoids duplicate data storage in the source document, because Elasticsearch automatically derives both representations from the same input value. It is important to consistently use the full path with the .keyword suffix in the aggregation request, otherwise the aggregation accidentally hits the analyzed text field and again produces fragmented facet values.


PUT /products/_mapping
{
  "properties": {
    "category_name": {
      "type": "text",
      "fields": {
        "keyword": { "type": "keyword" }
      }
    }
  }
}

GET /products/_search
{
  "size": 0,
  "aggs": {
    "categories": {
      "terms": { "field": "category_name.keyword", "size": 20 }
    }
  }
}

6. Numeric attributes: range facets instead of terms

For price or similar continuous numeric attributes, a terms aggregation is a poor fit, because every individual price potentially forms its own bucket, producing no meaningful filter list. For these cases, the range aggregation is the right choice within faceted navigation, because it defines fixed or dynamically computed price bands as buckets, for example "0 to 50 euros", "50 to 100 euros" and "over 100 euros".

Mapping numeric facet fields benefits from the scaled_float type over float, because scaled_float is stored internally as an integer with a fixed scaling factor, making it more compact and causing fewer rounding issues in aggregations than binary floating-point numbers. For price fields, a scaling_factor of 100 is common to represent cent amounts exactly.


GET /products/_search
{
  "size": 0,
  "aggs": {
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 50 },
          { "from": 50, "to": 100 },
          { "from": 100, "to": 250 },
          { "from": 250 }
        ]
      }
    }
  }
}

7. Filter aggregations for facets with an active filter

A common requirement in faceted navigation: when a customer already filters by color "red", other facets like brand should keep showing all available options, while the color facet itself should still show all colors including hit counts, not just "red". This requirement is solved with a post_filter combination or with a dedicated filter aggregation per facet, which specifically excludes that one facet's own active filter while still applying every other active filter.

In practice, a structure with a global aggregation as the root works well, under which a filter aggregation for each facet applies every filter except its own, followed by the actual terms or range aggregation. This pattern enables correct multi-select facets in a single query, without sending a separate request to the cluster for every facet.

Attribute type Wrong mapping Recommended mapping Effect
Brand, color, size text with analyzer keyword No fragmenting of multi-word values
Searchable name text only, no facet possible text with .keyword multi-field Search and facet on the same attribute
Price terms aggregation on float range aggregation on scaled_float Meaningful price bands instead of single values
Heavily used facet default ordinals, deferred build eager_global_ordinals: true Build overhead removed from the user path
Text-only attribute doc_values on by default, costs storage doc_values: false if no facet needed Save storage when never aggregated

8. Keeping cardinality and bucket size under control

The size setting of a terms aggregation determines how many facet values come back, but has a direct impact on accuracy on distributed indices with multiple shards. Since every shard determines its own top-N values independently and the coordinating node merges these partial results, values with genuinely high overall frequency can be incorrectly missing at a tight size setting, if they did not fall within the local top-N on individual shards. For critical faceted navigation facets like brand, a more generous size combined with the shard_size parameter makes sense, explicitly controlling how many candidates each shard returns before the merge.

Attributes with very high cardinality, such as individual SKU numbers or free-text tags, are fundamentally a poor fit for a facet, because a terms aggregation with thousands of buckets is neither meaningful for users nor performant for the cluster. For such fields, either a pre-computed categorization into coarser groups or completely dropping the facet is the better choice, regardless of the chosen mapping.

9. Common mistakes and debugging

The most common mistake with faceted navigation is a terms aggregation directly on an analyzed text field without the .keyword suffix. The result is facet values that look like individual words instead of complete attribute values, often already recognizable by the lowercasing, since the standard analyzer lowercases all values. A second common mistake is a missing reindex after a mapping change: changing an existing field from text to keyword only affects newly indexed documents, existing documents keep their old mapping until a full reindex.


// WRONG: aggregating directly on an analyzed text field
{
  "aggs": {
    "brands": { "terms": { "field": "brand_name" } }
  }
}
// Result: fragmented tokens like "nike", "air", "max" instead of one value

// RIGHT: aggregate on the keyword sub-field
{
  "aggs": {
    "brands": { "terms": { "field": "brand_name.keyword" } }
  }
}

// WRONG: mapping change without reindex, old documents keep old mapping
PUT /products/_mapping
{ "properties": { "brand_name": { "type": "keyword" } } }
// existing documents indexed before this change are unaffected

// RIGHT: mapping change followed by a full reindex
POST /_reindex
{
  "source": { "index": "products" },
  "dest": { "index": "products_v2" }
}

Mironsoft

Elasticsearch and OpenSearch consulting for search, analytics and dashboards

Faceted navigation that stays fast even with many attributes?

We design attribute mapping and aggregations so faceted navigation delivers correct filter values, accurate hit counts and fast response times, from mapping analysis to multi-select facets.

Mapping review

Reviewing existing attribute mappings for keyword and multi-field mistakes

Facet implementation

Terms and range aggregations for layered navigation in the shop

Performance tuning

eager_global_ordinals, shard_size and reindex strategy for large catalogs

10. Summary

Performant faceted navigation does not come from the aggregation query alone, it comes from the underlying mapping. keyword instead of text is the base rule for every filterable attribute, multi-fields resolve the conflict between search and facet on the same field, and range aggregations on scaled_float replace terms aggregations for continuous numeric values like price.

eager_global_ordinals moves the ordinal build overhead out of the user path, shard_size safeguards accuracy on distributed indices, and filter aggregations enable correct multi-select facets in a single query. Account for these building blocks in the mapping from the start, and you avoid later reindex cycles while delivering customers a faceted navigation with correct, unfragmented filter values.

Faceted navigation and mapping design, the essentials at a glance

Base rule keyword

Map every filter attribute as keyword, never as an analyzed text type, or values fragment into tokens.

Multi-fields

.keyword sub-field for facets, text main field for full-text search, both derived from the same value.

Numeric facets

range aggregation on scaled_float instead of terms aggregation for price and similar continuous values.

Performance

eager_global_ordinals for heavily used facets, shard_size for accuracy, disable doc_values selectively.

11. FAQ: Faceted Navigation and Mapping Design

1Why keyword instead of text for facets?
A text field is analyzed and split into tokens. Facets built from tokens instead of full values are unusable.
2Search and facet on the same attribute?
Multi-field with a .keyword sub-field for aggregations, text main field for search, both from the same value.
3Why no terms aggregation for price?
Every price would form its own bucket. A range aggregation with fixed bands is the right choice.
4What does eager_global_ordinals do?
Builds the ordinal structure after every refresh in the background, preventing slow first user requests.
5What happens with doc_values: false?
Aggregations on that field become impossible. Only useful for fields that will never need a facet.
6Why do some facet values go missing?
A value can narrowly miss the local top-N on individual shards. Increasing shard_size fixes it.
7Show all options with an active filter?
A filter aggregation per facet that excludes only its own filter while applying all others.
8Is a plain mapping change enough?
No, a full reindex is required since existing documents keep their old mapping.
9High cardinality suitable as a facet?
Generally not. Pre-computed categorization into coarser groups is usually the better solution.
10scaled_float instead of float for price?
More compact and fewer rounding issues in aggregations than binary floating-point numbers.