Вложенные агрегации и аналитические сценарии
В статье Метрические и бакетные агрегации мы разобрали два базовых класса: metric-агрегации считают числа, bucket-агрегации делят документы на корзины. Теперь — самое интересное: внутрь каждого бакета можно вложить другие агрегации. Это и называется под-агрегацией (sub-aggregation).
Логика простая: любой bucket-агрегатор принимает необязательный ключ "aggs" прямо внутри своего блока. ES применяет вложенные агрегации к каждому бакету независимо — как будто для каждой корзины выполняется отдельный подзапрос по её документам.
Базовый синтаксис вложения
Вот скелет запроса. "aggs" внутри агрегатора — единственное, что нужно добавить:
GET /orders/_search
{
"size": 0,
"aggs": {
"РОДИТЕЛЬСКИЙ_БАКЕТ": {
"terms": { "field": "category", "size": 10 },
"aggs": {
"ПОД_АГРЕГАЦИЯ": {
"avg": { "field": "price" }
}
}
}
}
}Ключ "aggs" здесь — точно такой же, как на верхнем уровне запроса. Разница только в том, что вложенный "aggs" видит не весь индекс, а только документы, попавшие в конкретный бакет.
flowchart TD
A[Все документы индекса] --> B[terms: по_категории]
B --> C[Бакет: электроника]
B --> D[Бакет: одежда]
B --> E[Бакет: книги]
C --> F[avg средняя_цена = 3500.0]
D --> G[avg средняя_цена = 1200.0]
E --> H[avg средняя_цена = 850.0]Сценарий 1: средняя цена по категориям
Классика аналитики — аналог SQL GROUP BY category + AVG(price). Разбиваем заказы по категории и тут же считаем среднее:
GET /orders/_search
{
"size": 0,
"aggs": {
"по_категории": {
"terms": { "field": "category", "size": 10 },
"aggs": {
"средняя_цена": { "avg": { "field": "price" } }
}
}
}
}ES добавит поле "средняя_цена" к каждому бакету в ответе:
{
"aggregations": {
"по_категории": {
"buckets": [
{ "key": "электроника", "doc_count": 520, "средняя_цена": { "value": 3500.0 } },
{ "key": "одежда", "doc_count": 310, "средняя_цена": { "value": 1200.0 } },
{ "key": "книги", "doc_count": 180, "средняя_цена": { "value": 850.0 } }
]
}
}
}Один запрос — полная картина по всем категориям сразу.
Сценарий 2: несколько под-агрегаций у одного бакета
У одного бакета может быть несколько независимых под-агрегаций — просто перечислите их в "aggs" рядом:
GET /orders/_search
{
"size": 0,
"aggs": {
"по_категории": {
"terms": { "field": "category", "size": 10 },
"aggs": {
"статистика": { "stats": { "field": "price" } },
"уник_клиенты": { "cardinality": { "field": "customer_id" } }
}
}
}
}ES вычислит обе под-агрегации для каждой категории за один проход по данным:
{
"key": "электроника",
"doc_count": 520,
"статистика": {
"count": 520, "min": 299.0, "max": 99999.0,
"avg": 3500.0, "sum": 1820000.0
},
"уник_клиенты": { "value": 214 }
}Обратите внимание: stats и cardinality считаются параллельно, не последовательно.
Сценарий 3: динамика по времени — date_histogram + terms
«Какие категории были самыми популярными в каждом месяце?» — двойное вложение: date_histogram снаружи, terms внутри:
GET /orders/_search
{
"size": 0,
"aggs": {
"по_месяцам": {
"date_histogram": {
"field": "created_at",
"calendar_interval": "month",
"format": "yyyy-MM"
},
"aggs": {
"топ_категории": {
"terms": { "field": "category", "size": 5 }
}
}
}
}
}Каждый месячный бакет получит свой terms-срез с топ-5 категориями:
{
"key_as_string": "2024-03",
"doc_count": 412,
"топ_категории": {
"buckets": [
{ "key": "электроника", "doc_count": 180 },
{ "key": "одежда", "doc_count": 142 }
]
}
}Три уровня: date_histogram → terms → avg
Идём глубже: в каждом месяце, по каждой категории — средняя цена. Это уже трёхуровневая структура:
GET /orders/_search
{
"size": 0,
"aggs": {
"по_месяцам": {
"date_histogram": {
"field": "created_at",
"calendar_interval": "month",
"format": "yyyy-MM"
},
"aggs": {
"по_категориям": {
"terms": { "field": "category", "size": 5 },
"aggs": {
"средняя_цена": { "avg": { "field": "price" } }
}
}
}
}
}
}Ответ трёхуровневый: месяц → категория → метрика. Мощно — но именно здесь важно следить за нагрузкой.
Производительность: когда вложение становится дорогим
Каждый уровень вложенности перемножает объём вычислений. Если date_histogram даёт $N_1$ бакетов, внутренний terms — $N_2$, а третий уровень — $N_3$, ES выполнит до $N_1 \times N_2 \times N_3$ мини-агрегаций. При $12$ месяцах, $10$ категориях и $50$ городах: $12 \times 10 \times 50 = 6000$ вычислительных блоков.
Практические ориентиры:
- Держите
"size"уtermsминимально необходимым — не ставьте $100$, если нужен топ-5. - Ограничивайте глубину вложенности двумя–тремя уровнями.
- Не забывайте
"size": 0в корне запроса, если документы вам не нужны.
Сортировка бакетов по значению под-агрегации
По умолчанию terms сортирует бакеты по убыванию doc_count. Хотите показать категории с наибольшей средней ценой первыми — сошлитесь на имя под-агрегации в параметре "order":
GET /orders/_search
{
"size": 0,
"aggs": {
"по_категории": {
"terms": {
"field": "category",
"size": 10,
"order": { "средняя_цена": "desc" }
},
"aggs": {
"средняя_цена": { "avg": { "field": "price" } }
}
}
}
}Имя "средняя_цена" в "order" должно точно совпасть с именем под-агрегации — ES не проверяет это заранее, и опечатка приводит к неожиданному порядку без явной ошибки.
См. также
- Метрические и бакетные агрегации — базовые классы, из которых строятся вложения
- Агрегации вместе с поиском и фильтрами — как ограничить выборку документов перед агрегацией и строить фасеты
- Типы полей: text, keyword, числа и даты — почему bucket-агрегации требуют
keyword, а неtext - Query DSL: структура запроса и контекст query vs filter — filter context, который работает рядом с агрегациями
- Маппинг: явный, динамический и шаблоны индексов — как объявить поля, чтобы агрегации работали корректно