Course contents

Catalog facets

Make results and facet counts consistent under multi-select filters.

6 sectionsElasticsearch 9.4.3Not read yet

What you will learn

  • What a count means
  • query, post_filter and global
  • Products versus values

Make results and facet counts consistent under multi-select filters.

course-products examples use the teaching mapping and product from chapter 5, “Mapping and field types”. Exercise walkthroughs below use the trainer’s separate public dataset.

Chapter map
  • What a count means
  • query, post_filter and global
  • Products versus values

Make results and facet counts consistent under multi-select filters.

The course targets Elasticsearch 9.4.3. Exercise examples use the public products dataset; other requests illustrate separate teaching indices and may require setup or privileges unavailable in the trainer. Responses are shortened fragments; scores may vary. Check deployment and license requirements for advanced features.

01 / 06

What a count means

A brand count may mean products in the current result set or products available after selecting that brand. These are different UI contracts. Independent facets commonly apply all active constraints except their own selection. Multiple brands often combine with OR, while brand and size combine with AND. Define the rule explicitly.

Check your understanding
02 / 06

query, post_filter and global

query restricts hits and aggregations. post_filter affects hits after aggregation calculation, useful when counts need a wider scope. global escapes query scope, so mandatory access constraints must be reapplied within it. Multiple independent facets often need separate filter aggregations with different active constraints.

Check your understanding
03 / 06

Products versus values

A multivalued document may contribute to several buckets, so their counts need not sum to unique products. Nested facets initially count nested documents; reverse_nested returns to root products. Do not present offer counts as product counts. Test with a product containing two qualifying offers.

Hits are limited to Sony; brand counts cover all matching headphones.

POST /course-products/_search
{
  "query": {
    "match": {
      "name": "headphones"
    }
  },
  "aggs": {
    "brands": {
      "terms": {
        "field": "brand"
      }
    }
  },
  "post_filter": {
    "term": {
      "brand": "Sony"
    }
  }
}

How to read this result

Hits are limited to Sony; brand counts cover all matching headphones.

Check your understanding
04 / 06

Catalog reasoning example

A category contains Sony:5 and Bose:3. Selecting Sony gives five hits. Computing brand facets over those hits hides Bose. If users can add Bose, count brands within the category without the active brand selection, while retaining price and stock. With several facets, each scope omits only its own selection; one post_filter does not automatically implement every independent facet rule.

Check your understanding
05 / 06

Aggregate in-stock products

A filter aggregation creates a single bucket containing documents that pass its query. Only its child aggregations inherit that restriction. In this example total hits still includes every product, but out_of_stock contains unavailable products and its average describes only those products.

A top-level query would restrict all aggregations and the search result itself. Choose the scope from the business question: one local comparison needs a filter bucket; one shared restriction for everything belongs in query. filter buckets expose doc_count, which helps verify that a metric used the intended population.

Try a different scenario

POST /products/_search
{
  "size": 0,
  "aggs": {
    "out_of_stock": {
      "filter": {
        "term": {
          "in_stock": false
        }
      },
      "aggs": {
        "average_price": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}
Elasticsearch response
Shortened response · JSON
{
  "hits": {
    "total": {
      "value": 300,
      "relation": "eq"
    },
    "hits": []
  },
  "aggregations": {
    "out_of_stock": {
      "doc_count": 100,
      "average_price": {
        "value": 724.3
      }
    }
  }
}

How to read this result

Compare hits.total.value with out_of_stock.doc_count. They describe different populations because the restriction is local to the aggregation.

Apply this in exercise 24
06 / 06

Conditional metrics within groups

Keep parent counts and local eligibility separate. The top-level query selects statuses active or preorder in this example. A brands bucket counts every matching document of that brand. Its eligible child then requires stock AND (rating at least 4.5 OR no indexed rating). The inner minimum_should_match makes the rating alternatives mandatory.

Write missing rating as bool.must_not containing exists. Keep stock outside the rating alternatives, so an unrated but unavailable item does not qualify. The same missing-value logic treats null as unindexed in our fixed mapping. A zero rating exists and fails the threshold; it must not pass the missing branch.

Place average_price, min_price and max_price inside eligible.aggs. If a brand has active or preorder products but none are eligible, its parent bucket remains with a positive doc_count, eligible.doc_count is zero, and all three metrics are null. Moving the eligibility filter into query would erase this distinction and answer a different question.

Try a different scenario

POST /products/_search
{
  "size": 0,
  "query": {
    "terms": {
      "status": [
        "active",
        "preorder"
      ]
    }
  },
  "aggs": {
    "brands": {
      "terms": {
        "field": "brand.keyword",
        "size": 20
      },
      "aggs": {
        "eligible": {
          "filter": {
            "bool": {
              "filter": [
                {
                  "term": {
                    "in_stock": true
                  }
                }
              ],
              "should": [
                {
                  "range": {
                    "rating": {
                      "gte": 4.5
                    }
                  }
                },
                {
                  "bool": {
                    "must_not": [
                      {
                        "exists": {
                          "field": "rating"
                        }
                      }
                    ]
                  }
                }
              ],
              "minimum_should_match": 1
            }
          },
          "aggs": {
            "average_price": {
              "avg": {
                "field": "price"
              }
            },
            "min_price": {
              "min": {
                "field": "price"
              }
            },
            "max_price": {
              "max": {
                "field": "price"
              }
            }
          }
        }
      }
    }
  }
}
Elasticsearch response
Shortened response · JSON
{
  "hits": {
    "total": {
      "value": 200,
      "relation": "eq"
    },
    "hits": []
  },
  "aggregations": {
    "brands": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Apple",
          "doc_count": 50,
          "eligible": {
            "doc_count": 19,
            "min_price": {
              "value": 49
            },
            "average_price": {
              "value": 864.8421052631579
            },
            "max_price": {
              "value": 1299
            }
          }
        },
        {
          "key": "Logitech",
          "doc_count": 50,
          "eligible": {
            "doc_count": 15,
            "min_price": {
              "value": 99
            },
            "average_price": {
              "value": 919.7333333333333
            },
            "max_price": {
              "value": 2499
            }
          }
        },
        {
          "key": "Sony",
          "doc_count": 50,
          "eligible": {
            "doc_count": 0,
            "min_price": {
              "value": null
            },
            "average_price": {
              "value": null
            },
            "max_price": {
              "value": null
            }
          }
        },
        {
          "key": "apple",
          "doc_count": 50,
          "eligible": {
            "doc_count": 0,
            "min_price": {
              "value": null
            },
            "average_price": {
              "value": null
            },
            "max_price": {
              "value": null
            }
          }
        }
      ]
    }
  }
}

How to read this result

Compare each brand doc_count with eligible.doc_count. They can differ. In this fixed dataset some brand buckets have no eligible products, showing the null metric case explicitly.

Apply this in exercise 30

Check your understanding

Answer before expanding each explanation.

1. Why can bucket counts exceed total?

One multivalued document can enter multiple groups.

2. What does post_filter affect?

Hits, not the aggregation scope already calculated.

3. Why is global risky in multi-tenant search?

It escapes query scope, so tenant constraints must be reapplied.

4. What does reverse_nested restore?

The root-document scope from a nested context.

5. How should selected brands combine?

Usually OR within the facet, under an explicit UI contract.

Key takeaways

  • One multivalued document can enter multiple groups.
  • Hits, not the aggregation scope already calculated.
  • It escapes query scope, so tenant constraints must be reapplied.
  • The root-document scope from a nested context.
  • Usually OR within the facet, under an explicit UI contract.

Official reference

Ready to put this into practice?

Confirm reading to save your place. Reading and exercise progress are tracked separately in this browser using your guest cookie.