OriginChainDB docs
examples · fts · 7 / 9

7. Aggregations over a result set

← FTS examples

what 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.

POST /v1/tenants/:t/fts/:schema/_aggs
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" } } }
      }
    }
  }'

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 field names the FTS field whose per-document records hold the values. The field inside 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 terms count 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 aggs map, up to MAX_AGG_DEPTH = 8 levels. terms buckets 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 /doc write carrying facets, there is nothing to fold and the bucket lists come back empty.
  • Sending an empty aggs object. Refused with 400 - an empty tree would return an object that reads like a result.
  • A pipeline aggregation at the top level. cumulative_sum and 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_k to 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_field in the search block. Refused: the aggregated field is already the value source, and the two could only disagree.