7. Aggregations over a result set
← FTS exampleswhat this does
Folds an aggregation tree over the documents a query matched and returns bucket counts and metrics instead of hits - "what is in this result set" rather than "what ranks highest in it". The values come from per-document records you write separately, so you can group by an attribute that was never part of the indexed text.
when to use it
- Faceted navigation - the colour, brand and price-band counts shown beside a result list.
- Dashboards over a filtered slice: revenue, distinct values, min / max of a numeric attribute.
- Any question whose answer is a number about the whole match set rather than a page of documents.
write the values first
An aggregation reads per-document records, not the indexed text. Postings written by Example 1 carry no attribute values, so give each document a facets map first. Re-posting the same doc_id replaces its record.
# Attach the values the aggregation will fold over. "text" is the stored
# text highlighting reads; "facets" maps a facet field to its values.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/description/doc" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"doc_id": "p001",
"text": "Over-ear headphones with active noise cancellation",
"facets": { "color": ["red"], "price": ["249"] }
}' the request
Bucket every matching document by color and sum price inside each bucket. Omit search entirely and the tree runs over every document indexed under the field.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/fts/shop.products/_aggs" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"field": "description",
"search": { "query": { "bool": {
"must": [{ "match": { "field": "description", "query": "headphones" } }],
"must_not": [{ "term": { "field": "description", "value": "refurbished" } }]
} } },
"aggs": {
"by_color": {
"terms": { "field": "color" },
"aggs": { "revenue": { "sum": { "field": "price" } } }
}
}
}'import requests
# _aggs hangs off the SCHEMA, not the field. "field" in the body names the
# FTS field whose per-document records supply the values to aggregate.
resp = requests.post(
f"https://{OC_HOST}/v1/tenants/{OC_TENANT}/fts/shop.products/_aggs",
headers={"Authorization": f"Bearer {OC_TOKEN}"},
json={
"field": "description",
"search": {"query": {"bool": {
"must": [{"match": {"field": "description", "query": "headphones"}}],
"must_not": [{"term": {"field": "description", "value": "refurbished"}}],
}}},
"aggs": {
"by_color": {
"terms": {"field": "color"},
"aggs": {"revenue": {"sum": {"field": "price"}}},
}
},
},
)
result = resp.json()
for b in result["aggs"]["by_color"]["buckets"]:
print(b["key"], b["doc_count"], b["aggs"]["revenue"]["value"])// _aggs hangs off the schema; "field" names the FTS field whose
// per-document records supply the values to aggregate.
const res = await fetch(
`https://${OC_HOST}/v1/tenants/${OC_TENANT}/fts/shop.products/_aggs`,
{
method: "POST",
headers: {
Authorization: `Bearer ${OC_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
field: "description",
search: { query: { bool: {
must: [{ match: { field: "description", query: "headphones" } }],
must_not: [{ term: { field: "description", value: "refurbished" } }],
} } },
aggs: {
by_color: {
terms: { field: "color" },
aggs: { revenue: { sum: { field: "price" } } },
},
},
}),
},
);
const result = await res.json();
for (const b of result.aggs.by_color.buckets) {
console.log(b.key, b.doc_count, b.aggs.revenue.value);
}// _aggs hangs off the schema; "field" names the FTS field whose
// per-document records supply the values to aggregate.
body, _ := json.Marshal(map[string]any{
"field": "description",
"search": map[string]any{"query": map[string]any{"bool": map[string]any{
"must": []any{map[string]any{
"match": map[string]any{"field": "description", "query": "headphones"},
}},
"must_not": []any{map[string]any{
"term": map[string]any{"field": "description", "value": "refurbished"},
}},
}}},
"aggs": map[string]any{
"by_color": map[string]any{
"terms": map[string]any{"field": "color"},
"aggs": map[string]any{"revenue": map[string]any{"sum": map[string]any{"field": "price"}}},
},
},
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://"+ocHost+"/v1/tenants/"+ocTenant+"/fts/shop.products/_aggs",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+ocToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)what you get back
{
"doc_count": 2,
"aggs": {
"by_color": {
"kind": "buckets",
"buckets": [
{
"key": "red",
"doc_count": 2,
"aggs": {
"revenue": { "kind": "value", "value": 349.0 }
}
}
],
"sum_other_doc_count": 0
}
}
} doc_count is the size of the set the tree was evaluated over. Every result is tagged with kind - buckets, value, stats, cardinality and so on - so a client can dispatch on the shape without knowing which aggregation produced it.
how it works
- Two different "field" keys. The top-level
fieldnames the FTS field whose per-document records hold the values. Thefieldinside each aggregation names the facet being bucketed. They are rarely the same string. - Buckets cover the whole match set. Ranked search stops at a top-k; an aggregation cannot, or it would answer a different question with a number that looks right. The executor is asked for every match, so a
termscount counts all of them. - Too many matches is a refusal, not a sample. A match set above the hit ceiling (
OC_FTS_MAX_HITS, default 10,000) returns 400 naming the limit rather than quietly aggregating a prefix. - Trees nest. Each bucket carries its own
aggsmap, up toMAX_AGG_DEPTH= 8 levels.termsbuckets default to count-descending. - Facet values are stored as strings and coerced to numbers by the numeric aggregations, which is why
"249"sums correctly.
common mistakes
- Aggregating a field that has no per-document records. Indexing text writes postings only. Without a
/docwrite carryingfacets, there is nothing to fold and the bucket lists come back empty. - Sending an empty
aggsobject. Refused with 400 - an empty tree would return an object that reads like a result. - A pipeline aggregation at the top level.
cumulative_sumand its siblings act on a parent bucket list; at the root there is none, so the request is refused rather than answered with zeroes. - Expecting
top_kto bound the cost. It is ignored here by design. If the query matches too much, narrow the query - the endpoint will not hand back a prefix. - Putting
doc_values_fieldin thesearchblock. Refused: the aggregatedfieldis already the value source, and the two could only disagree.