Агрегации вместе с поиском и фильтрами

В предыдущих статьях мы запускали агрегации по всему индексу. На практике это редкость: обычно нужно сначала что-то сузить — по тексту запроса, по категории, по дате — и уже по этой выборке считать статистику. Разберём, как запрос влияет на агрегации, зачем нужен post_filter и как реализовать фасетную навигацию.


Как запрос сужает выборку для агрегаций

Когда в тело поиска добавляется "query", ES сначала находит все документы, удовлетворяющие этому запросу, а потом применяет агрегации только к этой отфильтрованной выборке. Это логично: если ищете ноутбуки, статистика по ценам и брендам должна быть про ноутбуки, а не про весь каталог.

GET /products/_search
{
  "size": 0,
  "query": {
    "match": { "name": "ноутбук" }
  },
  "aggs": {
    "по_бренду": {
      "terms": { "field": "brand", "size": 10 }
    },
    "диапазон_цен": {
      "stats": { "field": "price" }
    }
  }
}

"size": 0 говорит ES: «документы мне не нужны, верни только агрегации». match отберёт документы с «ноутбук», а terms и stats посчитают по ним бренды и ценовую статистику.

flowchart TD A[Все документы индекса] -->|query| B[Отфильтрованные документы] B --> C[Агрегации считаются здесь] B -->|post_filter| D[hits.hits — результаты поиска] C --> E[Бакеты и метрики в ответе] style C fill:#d4edda,stroke:#28a745 style D fill:#cce5ff,stroke:#004085 style A fill:#f8f9fa,stroke:#6c757d
flowchart TD
    A[Все документы индекса] -->|query| B[Отфильтрованные документы]
    B --> C[Агрегации считаются здесь]
    B -->|post_filter| D[hits.hits — результаты поиска]
    C --> E[Бакеты и метрики в ответе]
    style C fill:#d4edda,stroke:#28a745
    style D fill:#cce5ff,stroke:#004085
    style A fill:#f8f9fa,stroke:#6c757d
Запрос сужает выборку для агрегаций; post_filter влияет только на хиты, уже после подсчёта агрегаций
Проверь себя
В запросе есть `"query": { "term": { "category": "электроника" } }` и рядом агрегация `terms` по полю `brand`. По каким документам будет считаться агрегация — по всему индексу или только по электронике?

Фасетная навигация: в чём задача

Фасетная навигация — это боковые фильтры на странице поиска, которые вы видите на любом маркетплейсе: бренды с количеством товаров, диапазоны цен, рейтинги. Пользователь выбирает фасет (например, «Бренд: Apple»), список товаров обновляется, но счётчики у остальных брендов должны остаться, чтобы можно было переключиться.

Вот в чём ловушка: если применить выбранный фасет прямо в "query", агрегации тоже будут считаться только по Apple — и счётчики других брендов пропадут или окажутся неверными. Для этой задачи ES предоставляет post_filter.

post_filter: фильтр только для хитов

post_filter применяется после того, как агрегации уже посчитаны. Документы для агрегаций он не трогает — только отсекает хиты в hits.hits.

GET /products/_search
{
  "query": {
    "match": { "name": "ноутбук" }
  },
  "aggs": {
    "по_бренду": {
      "terms": { "field": "brand", "size": 10 }
    }
  },
  "post_filter": {
    "term": { "brand": "apple" }
  }
}

Последовательность шагов внутри ES:

1. "query" → находим все ноутбуки.

2. "aggs" → считаем количество по всем брендам среди найденных ноутбуков.

3. "post_filter" → в hits.hits попадают только Apple-ноутбуки.

Пользователь видит только Apple, а в боковой панели — корректные счётчики для всех брендов. Именно то, что нужно.

Проверь себя
Пользователь кликнул на фасет «Бренд: Samsung». Вы применяете `post_filter: { term: { brand: "samsung" } }`. Изменятся ли счётчики в агрегации `terms` по бренду — будет ли там только Samsung?

Агрегация filter: считать по подвыборке внутри агрегации

Иногда нужно посчитать метрику не по всей выборке запроса, а по её части — например, «средняя цена только среди товаров с рейтингом $\geq 4{,}5$». Для этого есть тип агрегации filter (не путайте с filter context в Query DSL — это другое):

GET /products/_search
{
  "size": 0,
  "query": {
    "match": { "name": "ноутбук" }
  },
  "aggs": {
    "топовые_товары": {
      "filter": { "range": { "rating": { "gte": 4.5 } } },
      "aggs": {
        "средняя_цена": { "avg": { "field": "price" } }
      }
    }
  }
}

Агрегация filter создаёт бакет из одной корзины: в него попадают только документы, прошедшие условие. Дочерняя avg работает уже по этой подвыборке, не трогая другие агрегации.


Агрегация global: вырваться из контекста запроса

Иногда нужен обратный эффект — показать глобальный показатель рядом с результатами поиска. Например: «средняя цена всех товаров в каталоге» рядом со «средней ценой найденных ноутбуков». Это даёт пользователю контекст.

Агрегация global игнорирует "query" и работает по всему индексу:

GET /products/_search
{
  "size": 0,
  "query": {
    "match": { "name": "ноутбук" }
  },
  "aggs": {
    "ср_цена_ноутбуков": {
      "avg": { "field": "price" }
    },
    "ср_цена_по_каталогу": {
      "global": {},
      "aggs": {
        "значение": { "avg": { "field": "price" } }
      }
    }
  }
}

"global": {} — пустой объект без параметров. Дочерняя avg видит все документы индекса вне зависимости от запроса.

Проверь себя
Агрегация `global` вложена в запрос, который ищет ноутбуки. Сколько документов она видит — только результаты запроса или весь индекс?

Полный пример: страница каталога с фасетами

Соберём всё вместе: полнотекстовый поиск, фасеты по бренду и ценовым диапазонам, применённый фасет только на хиты.

GET /products/_search
{
  "size": 12,
  "query": {
    "bool": {
      "must":   { "match": { "name": "ноутбук" } },
      "filter": { "term": { "in_stock": true } }
    }
  },
  "aggs": {
    "бренды": {
      "terms": { "field": "brand", "size": 20 }
    },
    "ценовые_диапазоны": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 50000,  "key": "до 50к" },
          { "from": 50000, "to": 100000, "key": "50–100к" },
          { "from": 100000, "key": "от 100к" }
        ]
      }
    }
  },
  "post_filter": {
    "term": { "brand": "lenovo" }
  }
}

Разберём по частям:

  • bool с must + filter — полнотекстовый поиск по «ноутбук» и фильтр по наличию. Агрегации работают по этой выборке.
  • "aggs" — считаем бренды и ценовые диапазоны для всех ноутбуков в наличии.
  • "post_filter" — в hits.hits попадут только Lenovo, но агрегации уже посчитаны до этого шага.

Пользователь видит ноутбуки Lenovo, а в боковой панели — счётчики по всем брендам и диапазонам цен.


Когда что применять

ЗадачаИнструмент
Сузить и хиты, и агрегацииquery / filter в bool
Фасет: сузить только хиты, агрегации — по всей выборкеpost_filter
Метрика по подмножеству внутри агрегацииагрегация filter
Считать по всему индексу вне текущего запросаагрегация global

Быстрое повторение
post_filter применяется на каком этапе обработки запроса: до расчёта агрегаций, одновременно или после?
Быстрое повторение
Почему нельзя просто добавить выбранный фасет в query для фасетной навигации?
Быстрое повторение
Когда в запросе есть query, по каким документам считаются агрегации — по всему индексу или только по найденным?

См. также