The Elasticsearch top_hits aggregation is a metric aggregation that returns the top matching documents inside each parent bucket. It supports the same controls as a regular search - size, sort, _source, highlight, script_fields, and version - but applied per bucket. Use it for "the most recent N documents per category", "best-scoring hit per group", or "one sample per bucket" patterns that would otherwise need a second query.
Syntax
GET /articles/_search
{
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category.keyword", "size": 10 },
"aggs": {
"latest": {
"top_hits": {
"size": 3,
"sort": [{ "published_at": "desc" }],
"_source": { "includes": ["title", "author", "published_at"] }
}
}
}
}
}
}
top_hits must always sit inside a bucket aggregation. As a top-level aggregation it produces a single global bucket, which is rarely what you want.
Parameters
| Parameter | Default | Description |
|---|---|---|
size |
3 | Number of hits per bucket. Capped by index.max_inner_result_window (default 100). |
from |
0 | Pagination offset within a bucket. Subject to the same window cap. |
sort |
_score |
Any sort the search API supports - field, script, geo distance, or _score. |
_source |
true | Source filtering: false, true, or { "includes": [...], "excludes": [...] }. |
highlight |
- | Highlight configuration applied per returned hit. |
script_fields |
- | Computed fields per hit. |
stored_fields |
- | Stored fields to return per hit. |
version |
false | Include the _version field in each hit. |
seq_no_primary_term |
false | Include _seq_no and _primary_term per hit. |
explain |
false | Include scoring explanation. |
track_scores |
false | Compute _score even when sorting on a non-score field. |
Examples
Most recent document per host:
"aggs": {
"hosts": {
"terms": { "field": "host.keyword", "size": 50 },
"aggs": {
"latest_event": {
"top_hits": {
"size": 1,
"sort": [{ "@timestamp": "desc" }]
}
}
}
}
}
Highest-scoring 5 docs per author:
"aggs": {
"authors": {
"terms": { "field": "author.keyword", "size": 20 },
"aggs": {
"best_articles": {
"top_hits": {
"size": 5,
"sort": [{ "_score": "desc" }],
"highlight": { "fields": { "body": {} } }
}
}
}
}
}
Use top_hits as the "field collapse" alternative when collapsing is not available:
"aggs": {
"products": {
"terms": { "field": "product_id", "size": 100 },
"aggs": {
"top": {
"top_hits": {
"size": 1,
"_source": { "includes": ["title", "price"] }
}
}
}
}
}
Performance Notes
top_hits fetches stored fields and source for each returned document, so cost scales with size * number_of_buckets. A terms aggregation with 1000 buckets and top_hits.size: 10 fetches 10,000 documents. That is the same cost as a regular search of size: 10000, plus the bucket grouping work.
Sorting on a non-indexed or non-doc-value field forces a per-hit script computation. Always sort on indexed fields where possible. For "field collapse" patterns - one representative document per group - the dedicated collapse parameter on a search is faster than terms + top_hits and supports inner_hits for additional documents per group.
top_hits is bound by index.max_inner_result_window (default 100). Requests for larger sizes are rejected at parse time. top_hits inside high-cardinality terms aggregations is a common cause of slow searches and heap pressure. The manual triage - matching slow-log entries to bucket counts, deciding which patterns belong in collapse instead - is the loop Pulse runs continuously.
Common Mistakes
- Setting
sizeaboveindex.max_inner_result_window(100). Raise the setting only after measuring impact. - Using top_hits at the top level. It needs a parent bucket aggregation to be meaningful.
- Heavy
_sourcepayloads per hit when only a few fields are needed - always use_source.includes. - Picking top_hits instead of
collapsefor one-doc-per-group rendering.collapseis purpose-built and cheaper. - Sorting by a runtime field, forcing per-hit script evaluation.
Find Expensive top_hits Patterns with Pulse
Pulse is an AI DBA for Elasticsearch and OpenSearch that continuously profiles production query traffic. For top_hits aggregations specifically, Pulse:
- Identifies top_hits nested inside high-cardinality terms aggregations where total hit-retrieval cost is
size * number_of_bucketsand is dominating cluster latency - Flags top_hits requests whose effective per-bucket fetch crosses or pushes against
index.max_inner_result_window(default 100), plus heavy_sourcepayloads where only a few fields are needed - Detects "one document per group" patterns that would be faster as the dedicated
collapseparameter with optionalinner_hits - Spots sorts on runtime fields or non-doc-value fields that are forcing per-hit script evaluation
- Traces each slow top_hits aggregation back to the calling service via slow-log and APM correlation
- Recommends concrete fixes: switch to
collapsefor field-collapse use cases, trim_source.includes, sort on indexed or doc-value fields, cap parent bucketsize, and move exhaustive iteration to a composite aggregation with samples per page - Tracks latency and heap impact after the change ships
This converts the manual hit-retrieval cost triage loop into a continuous optimization workflow.
Frequently Asked Questions
Q: Can top_hits be used without a parent bucket aggregation?
A: It can, but it produces only one global "bucket". Use the regular search API for that case. top_hits earns its place when nested in a bucket aggregation like terms or date_histogram.
Q: What is the maximum size for top_hits?
A: index.max_inner_result_window, default 100. The setting is per index and can be raised, but very large per-bucket fetches are expensive. Reconsider the query if you need much more.
Q: How does top_hits compare to field collapsing?
A: collapse on a search is purpose-built for one document per group and supports a second-level inner_hits for additional documents per group. It is faster than terms + top_hits at high cardinality. top_hits wins when you also need other bucket-level metrics in the same response.
Q: Can I highlight matched terms inside top_hits results?
A: Yes. The highlight configuration inside top_hits behaves the same as on a regular search and produces a highlight field per returned hit.
Q: Can I paginate within a bucket using top_hits from?
A: Technically yes, subject to from + size <= index.max_inner_result_window. For exhaustive iteration across buckets, a composite aggregation is usually a better fit.
Q: How does top_hits interact with nested documents?
A: Wrap top_hits in a nested aggregation to retrieve top inner documents per parent. Sort and _source operate on the nested context inside that wrapper.
Q: How do I find which top_hits aggregations are causing slow searches and heap pressure?
A: Pulse profiles Elasticsearch and OpenSearch slow logs, isolates top_hits nested inside high-cardinality terms aggregations and "one-per-group" patterns that should use collapse instead, attributes each to the calling service, and recommends concrete _source.includes, collapse, and sort-field changes.
Related Reading
- Terms Aggregation: the most common parent for top_hits.
- Composite Aggregation: iterate every bucket exactly, paired with top_hits for samples.
- Date Histogram Aggregation: time-bucketed top hits.
- Max Aggregation: when you need only the highest value per bucket, not the whole document.
- Elasticsearch Query Language: the DSL top_hits runs inside.