# Facets and aggregations

Facets group the documents a query matches into buckets and count them: "Science fiction (31)",
"Under $20 (18)", "May (240)". Metrics reduce them to numbers: average price, highest latency. Both
see every matching document, not just the page of hits you asked for, and both come back in the same
response, from the same consistent view of the collection. Counts are exact.

You can describe them in the native request shown here, or in the language you already use:
Elasticsearch `aggs`, Solr facets, SQL `GROUP BY`, or LogQL metric queries. They all produce the same
kinds of buckets. See [Query languages](query-languages.md).

## Facets in a request

Add a `facets` object to any search request. Each key is a name you choose, and it comes back
unchanged in the response.

```bash
curl -X POST https://api.warpsearch.io/v1/collections/books/search \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "language": "lucene",
    "query": "title:dune",
    "limit": 5,
    "facets": {
      "by_genre": {"type": "terms", "field": "genre", "size": 10,
                   "metrics": {"avg_price": "avg(price)"},
                   "order": {"by": "avg_price", "desc": true}},
      "per_decade": {"type": "range", "field": "year", "start": 1900, "end": 2030, "interval": 10},
      "price_tiers": {"type": "query",
                      "buckets": {"cheap": "price:<10", "pricey": "price:>=10"}}
    },
    "metrics": {"max_price": "max(price)"}
  }'
```

```json
{
  "total": 42,
  "hits": [],
  "facets": {
    "by_genre": {
      "type": "terms",
      "field": "genre",
      "missing": 2,
      "buckets": [
        {"value": "scifi", "count": 31, "metrics": {"avg_price": 11.7}},
        {"value": "classic", "count": 9, "metrics": {"avg_price": 8.2}}
      ]
    }
  },
  "metrics": {"max_price": 24.5}
}
```

Hits arrive as usual, so a results page and its sidebar need one request. Set `limit` to `0` when you
only want the analytics.

## Facet types

| `type` | Groups by | Main options |
|---|---|---|
| `terms` | Each distinct value of a field. | `size` (number of buckets), `min_count`, `missing` (also count documents with no value), `order` |
| `range` | Fixed-width numeric buckets. | `field`, `start`, `end`, `interval` |
| `date_histogram` | Time buckets. | `interval` (a number, a duration such as `5m`, or `auto`) or `calendar_interval` (`day`, `week`, `month`, `quarter`, `year`), `time_zone`, `format` |
| `query` | Named buckets that you define with queries. | `buckets`: a map of bucket name to query |

Every facet also accepts `metrics` and `facets`, which run once per bucket. That is how you ask for
"the average price and the top publishers of every category" in one request: a `terms` facet with a
`metrics` object and a nested `facets` object.

```json
"by_category": {
  "type": "terms", "field": "category", "size": 10,
  "metrics": {"avg_price": "avg(price)"},
  "facets": {"publishers": {"type": "terms", "field": "publisher", "size": 5}}
}
```

Notes on `terms`:

- Use it on `keyword` and number fields. On a `text` field it returns individual analyzed words,
  not whole values, so facet on a `keyword` sub-field instead. See
  [Schemas and field types](schemas-and-fields.md).
- A multi-valued field counts a document in each of its values, never twice in the same bucket.
- `order` is by count by default. Set `{"by": "<metric name>", "desc": true}` to order by a metric
  of the facet, or use `bucket_order` (`key_desc`, `count_desc`, `count_asc`).
- Buckets you did not ask for are not returned, so `size` decides how many you see.

Each bucket in the response has a `value` and a `count`, plus `metrics` and `facets` when you asked
for them. A `range` or `date_histogram` bucket also has `from` and `to` (the lower bound is included,
the upper one is not), and a date bucket has `key_as_string`, the formatted lower bound.

## Metrics

A metric is `"function(field)"` or an object. They sit at the top of the request (one number over all
matches) or inside a facet (one number per bucket).

| Function | Result | Notes |
|---|---|---|
| `avg`, `sum`, `min`, `max` | A number | Missing values are skipped. An empty set returns `null`. |
| `count` | A number | Documents with a value. |
| `stats` | `{count, min, max, avg, sum}` | |
| `cardinality` | A number (`{value}` in Elasticsearch shape) | Approximate distinct count. Exact up to a threshold, then about 1.6% error. Not on `text` fields. |
| `percentiles` | A map keyed by percent, such as `"99.0"` | Approximate, from a t-digest. Numeric and date fields. |
| `percentile_ranks` | A map keyed by value | Approximate. Needs `values`. |

The string form uses the defaults. The three approximate functions take parameters in the object
form:

```json
"metrics": {
  "p": {"func": "percentiles", "field": "latency_ms", "percents": [50, 99], "keyed": false},
  "users": {"func": "cardinality", "field": "user_id", "precision_threshold": 1000},
  "slow": {"func": "percentile_ranks", "field": "latency_ms", "values": [500]}
}
```

`precision_threshold` is capped at 40000. `percents` default to 1, 5, 25, 50, 75, 95 and 99.
`compression` (20 to 1000) tunes accuracy. Metric names are free-form strings and come back unchanged.

> **Status by API:** `cardinality`, `percentiles` and `percentile_ranks` are available through the
> `/v1` `metrics` object and through Elasticsearch and OpenSearch aggregations. `stats` and the basic
> functions work everywhere.

## Date histograms

```json
"facets": {
  "per_day": {"type": "date_histogram", "field": "published",
              "calendar_interval": "day", "time_zone": "America/Denver"}
}
```

Calendar intervals follow the civil calendar: months vary in length and a daylight-saving day has 23
or 25 hours. Fixed intervals such as `5m` or `1h` are exact durations. By default the histogram runs
from the first non-empty bucket to the last, with empty buckets between. To widen it, set
`extended_start` and `extended_end`, and `trim_empty` to drop empty edge buckets outside them.
Giving the histogram explicit bounds, for example through a date range in the query or
`time_range`, saves the engine a pass to find them.

Limits: a histogram returns at most 5000 buckets, and at most 1024 when it has metrics or nested
facets. A wider span fails with `limit_exceeded`, and the fix is a bigger interval or a narrower
range.

## Faceted navigation

For a sidebar where users tick several values and still see their alternatives, count each facet
without its own selection:

- In the Elasticsearch language, put the user's choices in `post_filter`. It narrows the hits and
  leaves the aggregations untouched.
- In Solr, tag the filter query and exclude it in the facet: `fq={!tag=g}genre:scifi` with
  `facet.field={!ex=g}genre`.
- In the native request, send the selected values in `filters` for the hits and run a second
  request without them for the counts of the facet in question.

## Other languages

| You write | You get |
|---|---|
| Elasticsearch `aggs`: `terms`, `histogram`, `date_histogram`, `range`, `date_range`, `filters`, `filter`, `global`, `top_hits`, and the metrics `avg`, `sum`, `min`, `max`, `value_count`, `stats`, `extended_stats`, `cardinality`, `percentiles`, `percentile_ranks` | The same buckets, in Elasticsearch shape. |
| Solr `facet.field`, `facet.range`, `facet.query`, `facet.pivot`, `stats`, and the JSON Facet API | Solr-shaped facet results. |
| SQL `GROUP BY a, b`, `COUNT(*)`, `AVG`, `SUM`, `MIN`, `MAX`, `HAVING COUNT(*) >= n`, `SELECT DISTINCT col` | Nested terms facets. |
| LogQL `count_over_time`, `sum by (...)`, `avg_over_time` | A date histogram per series. |

Terms ordering by a `cardinality` or `percentiles` sub-aggregation, scripts, `terms` `include` and
`exclude`, and `hdr` percentiles are not supported. Unsupported pieces answer `400
unsupported_feature`, and approximations come back as `warnings`; see
[Limits and errors](limits-and-errors.md).

## Plan limits and cost

Your plan caps how many facets a request may carry and how many buckets each returns, in addition
to the fixed histogram limits above. A `size` of `-1` is capped at your plan's limit rather than
unbounded. Facets over a high-cardinality field with nested facets are the most expensive shape, so
filter first and keep `size` small. Bound date histograms with a time range; see
[Pricing](pricing.md#keeping-query-costs-down).

## Gotchas

- Counts are of documents, except on multi-valued fields, where each value is counted.
- A facet on a field that no document has returns no buckets, and `missing` counts the rest.
- Numeric `terms` facets enumerate distinct values. For bands of numbers, use `range`.
