Вложенные агрегации и аналитические сценарии

В статье Метрические и бакетные агрегации мы разобрали два базовых класса: 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]
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]
Под-агрегация avg применяется отдельно к документам каждого бакета terms
Проверь себя
Вы пишете `terms` по полю `status`. Где именно в JSON нужно поставить ключ `"aggs"`, чтобы добавить под-агрегацию? Попробуйте набросать структуру в уме, прежде чем читать ответ.

Сценарий 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 считаются параллельно, не последовательно.

Проверь себя
Вы добавили `stats` и `cardinality` как две параллельные под-агрегации одного `terms`-бакета. Как ES их считает — последовательно (сначала одна, потом другая) или параллельно за один проход?

Сценарий 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_histogramtermsavg

Идём глубже: в каждом месяце, по каждой категории — средняя цена. Это уже трёхуровневая структура:

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 в корне запроса, если документы вам не нужны.
Проверь себя
У вас `date_histogram` на 24 месяца, внутри `terms` с `size: 15`, внутри ещё `avg`. Перемножьте в уме: сколько `avg`-вычислений может выполнить ES?

Сортировка бакетов по значению под-агрегации

По умолчанию terms сортирует бакеты по убыванию doc_count. Хотите показать категории с наибольшей средней ценой первыми — сошлитесь на имя под-агрегации в параметре "order":

GET /orders/_search
{
  "size": 0,
  "aggs": {
    "по_категории": {
      "terms": {
        "field": "category",
        "size": 10,
        "order": { "средняя_цена": "desc" }
      },
      "aggs": {
        "средняя_цена": { "avg": { "field": "price" } }
      }
    }
  }
}

Имя "средняя_цена" в "order" должно точно совпасть с именем под-агрегации — ES не проверяет это заранее, и опечатка приводит к неожиданному порядку без явной ошибки.



Быстрое повторение
Паттерн terms с вложенной avg (разделить по категориям и средняя цена для каждой) — какому SQL оператору он соответствует?
Быстрое повторение
Когда в terms-агрегаторе используется "order": { "средняя_цена": "desc" }, на что ссылается параметр — на имя под-агрегации или на поле документа?
Быстрое повторение
Как ES применяет под-агрегацию к бакетам — одновременно ко всем документам индекса или независимо для каждого бакета?

См. также