# Фасетный поиск с активными фильтрами

Manticore Search 25.12.0 добавляет режимы strict, auto и max для фасетов, чтобы фильтры интернет-магазина могли показывать выбранные, доступные и недоступные значения без ручной сборки отдельных запросов.

Фасеты в интернет-магазине кажутся простыми до первого выбранного фильтра.

В каталоге это часть навигации. Выбранный цвет не должен исчезать из списка. Соседние цвета лучше оставить доступными: пользователь может переключиться на них или расширить выбор. А варианты без товаров полезнее сразу показать как недоступные. Внутри одного фасета обычно работает `OR`: красный или синий. Между разными фасетами - `AND`: бренд, цвет и размер одновременно.

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

Если каждый фасет наследует все фильтры из основного запроса, он быстро схлопывается до уже выбранных значений. Если для каждого фасета фильтры приходится пересобирать вручную, появляются отдельные ветки запросов для цвета, размера, бренда, наличия, продавца и остальных атрибутов.

В Manticore Search 25.12.0 появился `facet_filter_mode`, который переносит это поведение в API фасетов. Теперь в запросе можно описать, как ведёт себя панель фильтров магазина, а приложению не нужно вручную собирать почти одинаковые запросы для каждого фасета.

## Что нужно фасетам в интернет-магазине

Одного ответа на вопрос "сколько товаров у этого значения?" для панели фильтров мало. Ей нужны статусы, с которыми можно прямо работать в интерфейсе:

- выбранные значения остаются видимыми и их можно быстро снять;
- соседние значения того же фасета остаются доступными для выбора, потому что они расширяют фильтр через `IN (...)`;
- значения в других фасетах показывают, даст ли их выбор хотя бы один товар;
- количества остаются предсказуемыми: интерфейс понимает, к чему относится число - к текущей выдаче или к более широкой выборке;
- поиск внутри длинных списков брендов, категории, диапазоны цен, SEO-правила и аналитика запросов никуда не исчезают; это отдельная часть дизайна поиска.

`facet_filter_mode` берёт на себя самую частую часть этой задачи: как пересчитать фасеты после того, как пользователь уже выбрал несколько фильтров.

## Что изменилось

У фасетов теперь есть три режима наследования фильтров:

| Режим | Что делает |
| --- | --- |
| `strict` | Применяет к фасету все фильтры основного запроса. Это старое поведение; оно используется по умолчанию. |
| `auto` | Применяет все фильтры, кроме фильтров по этому же фасету, и добавляет `status`: выбранные значения получают `selected`, соседние значения - `available`. |
| `max` | Считает бакеты по широкой базовой выборке и добавляет `status`, чтобы отделить выбранные, доступные и недоступные значения. |

Есть и ручное управление:

- `ALL FILTERS` - применить к фасету все фильтры;
- `FILTERS color_id, size_id` - применить только перечисленные фильтры;
- `EXCLUDE FILTERS color_id` - применить все фильтры, кроме перечисленных;
- `ZEROES` - начиная с Manticore Search 27.3.0, в SQL-режиме `max` сохранить бакеты из широкой области `max`, даже если в текущем фасете их `count(*)` равен `0`.

В JSON API те же опции доступны через `facet_filter_mode`, `mode`, `filters`, `exclude_filters` и `zeroes: true`.

Если коротко: `strict` отвечает на вопрос "что есть в текущей выдаче?", `auto` - "что будет, если поменять значение именно этого фильтра?", а `max` показывает широкий список бакетов и добавляет в ответ поле `status`: `selected`, `available` или `unavailable`.

## Минимальный пример

Возьмём маленький каталог. В нём есть бренд, цвет, размер и SKU:

```sql
CREATE TABLE products(
  title text,
  brand_id int,
  color_id int,
  color_name string,
  size_id int,
  size_name string,
  sku string
);

INSERT INTO products(id,title,brand_id,color_id,color_name,size_id,size_name,sku) VALUES
(1,'p1',7,1,'red',10,'small','sku1'),
(2,'p2',7,1,'red',20,'large','sku2'),
(3,'p3',7,2,'blue',10,'small','sku3'),
(4,'p4',7,3,'green',30,'xlarge','sku4'),
(5,'p5',8,1,'red',10,'small','sku5'),
(6,'p6',8,4,'black',20,'large','sku6'),
(7,'p7',9,5,'white',10,'small','sku7');
```

Пользователь выбрал:

```sql
brand_id=7 AND color_id=1 AND size_id=10
```

При этих условиях в выдаче один товар: `p1`. Посмотрим, что происходит с фасетами.

## strict: старое поведение

Без дополнительных опций Manticore использует `strict`: каждый фасет получает все фильтры из основного запроса.

```sql
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
FACET color_id ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC;
```

Ответ:

```text
+----------+----------+
| color_id | count(*) |
+----------+----------+
|        1 |        1 |
+----------+----------+
+---------+----------+
| size_id | count(*) |
+---------+----------+
|      10 |        1 |
+---------+----------+
```

Для SQL это честный ответ. Для магазина он часто слишком узкий. Пользователь видит только уже выбранные `color_id=1` и `size_id=10`. Других вариантов как будто нет, хотя в данных есть товар того же бренда и размера, но другого цвета (`color_id=2`), и товар того же бренда и цвета, но другого размера (`size_id=20`).

## auto: фасет игнорирует свой фильтр

Режим `auto` оставляет все фильтры, кроме фильтра по тому фасету, который сейчас считается.

```sql
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='auto'
FACET color_id ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC;
```

Ответ:

```text
+----------+----------+-----------+
| color_id | count(*) | status    |
+----------+----------+-----------+
|        1 |        1 | selected  |
|        2 |        1 | available |
+----------+----------+-----------+
+---------+----------+-----------+
| size_id | count(*) | status    |
+---------+----------+-----------+
|      10 |        1 | selected  |
|      20 |        1 | available |
+---------+----------+-----------+
```

Что произошло:

- `FACET color_id` применил `brand_id=7 AND size_id=10`, но не `color_id=1`;
- `FACET size_id` применил `brand_id=7 AND color_id=1`, но не `size_id=10`.

Так интерфейс получает альтернативные значения без отдельного запроса для каждого фасета. Выбранные бакеты помечены как `selected`, а соседние значения того же фасета - как `available`: их можно добавить к текущему фильтру как расширение через `IN (...)`.

## max: широкий список бакетов и статус

`auto` показывает только значения из текущей области фильтров для конкретного фасета. `max` идёт шире: он считает бакеты по базовой выборке и отдельно помечает их статус для интерфейса.

```sql
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET color_id ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC;
```

Ответ:

```text
+----------+----------+-----------+
| color_id | count(*) | status    |
+----------+----------+-----------+
|        1 |        3 | selected  |
|        2 |        1 | available |
|        3 |        1 | available |
|        4 |        1 | available |
|        5 |        1 | available |
+----------+----------+-----------+
+---------+----------+-----------+
| size_id | count(*) | status    |
+---------+----------+-----------+
|      10 |        4 | selected  |
|      20 |        2 | available |
|      30 |        1 | available |
+---------+----------+-----------+
```

`status` приходит прямо из Manticore:

- `selected` - значение уже есть в фильтре по этому же фасету;
- `available` - значение можно выбрать; для соседних значений того же фасета это означает расширение фильтра через `IN (...)`;
- `unavailable` - значение есть в широком наборе для фасета, но его выбор не даст документов при текущих фильтрах.

В этом примере `color_id=1` встречается в трёх товарах всего каталога, поэтому в `max` у него количество `3`. Он помечен как `selected`, потому что уже участвует в фильтре. Остальные цвета помечены как `available`: если пользователь выберет один из них, фильтр по цвету станет шире, например `color_id IN (1,2)`. Недоступные значения появляются там, где широкий бакет не может дать документов при текущем наборе фильтров; такой случай есть ниже в примере с `sku`.

## Как вручную задать область действия фильтров

Глобальный `facet_filter_mode` закрывает большинство обычных случаев, но иногда разные фасеты должны вести себя по-разному. Например, цвет оставить строго в рамках всех фильтров, размер считать по `max`, SKU считать только по цвету и размеру, а бренд считать без фильтра по цвету.

```sql
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET color_id ALL FILTERS ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC
FACET sku FILTERS color_id, size_id ORDER BY sku ASC
FACET brand_id EXCLUDE FILTERS color_id ORDER BY brand_id ASC;
```

Ответ:

```text
+----------+----------+-----------+
| color_id | count(*) | status    |
+----------+----------+-----------+
|        1 |        1 | selected  |
+----------+----------+-----------+
+---------+----------+-----------+
| size_id | count(*) | status    |
+---------+----------+-----------+
|      10 |        4 | selected  |
|      20 |        2 | available |
|      30 |        1 | available |
+---------+----------+-----------+
+------+----------+-------------+
| sku  | count(*) | status      |
+------+----------+-------------+
| sku1 |        1 | available   |
| sku5 |        1 | unavailable |
+------+----------+-------------+
+----------+----------+----------+
| brand_id | count(*) | status   |
+----------+----------+----------+
|        7 |        2 | selected |
+----------+----------+----------+
```

Как читать этот запрос:

- `FACET color_id ALL FILTERS` применяет все фильтры и возвращает только выбранный цвет;
- `FACET size_id` наследует режим `max`, заданный на уровне запроса;
- `FACET sku FILTERS color_id, size_id` применяет только фильтры по цвету и размеру;
- `FACET brand_id EXCLUDE FILTERS color_id` применяет все фильтры, кроме цвета.

Такой режим нужен, когда в одной панели фильтров есть обычные и технические фасеты, а часть значений нужно считать по особым правилам.

## ZEROES: нулевые бакеты в max

`ZEROES` нужен, когда количество должно оставаться строгим, но список значений - широким. Он работает вместе с `max`: через `OPTION facet_filter_mode='max'` или через `MODE max` у конкретного фасета.

Без `ZEROES` фасет с `ALL FILTERS` показывает только бакет, который прошёл все фильтры:

```sql
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET size_id ALL FILTERS ORDER BY size_id ASC;
```

Ответ:

```text
+---------+----------+----------+
| size_id | count(*) | status   |
+---------+----------+----------+
|      10 |        1 | selected |
+---------+----------+----------+
```

Если добавить `ZEROES`, Manticore оставит те же видимые количества, но вернёт остальные бакеты из широкой области `max` с нулевым количеством:

```sql
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET size_id ALL FILTERS ZEROES ORDER BY size_id ASC;
```

Ответ:

```text
+---------+----------+-----------+
| size_id | count(*) | status    |
+---------+----------+-----------+
|      10 |        1 | selected  |
|      20 |        0 | available |
|      30 |        0 | available |
+---------+----------+-----------+
```

Так интерфейс может показать `large` и `xlarge` рядом с выбранным `small`: число относится к текущей строгой выдаче, а `status` показывает, что эти значения всё ещё можно выбрать как расширение фильтра.

## То же через JSON API

SQL здесь короче и лучше показывает саму механику, но JSON API поддерживает тот же подход:

```json
POST /search
{
  "table": "products",
  "limit": 0,
  "query": {
    "bool": {
      "must": [
        { "equals": { "brand_id": 7 } },
        { "equals": { "color_id": 1 } },
        { "equals": { "size_id": 10 } }
      ]
    }
  },
  "facet_filter_mode": "max",
  "aggs": {
    "colors": {
      "terms": { "field": "color_id", "size": 10 },
      "sort": [ { "color_id": { "order": "asc" } } ]
    },
    "sizes": {
      "terms": { "field": "size_id", "size": 10 },
      "sort": [ { "size_id": { "order": "asc" } } ]
    }
  }
}
```

Ответ:

```json
{
  "took": 0,
  "timed_out": false,
  "hits": {
    "total": 1,
    "total_relation": "eq",
    "hits": []
  },
  "aggregations": {
    "colors": {
      "buckets": [
        { "key": 1, "doc_count": 3, "status": "selected" },
        { "key": 2, "doc_count": 1, "status": "available" },
        { "key": 3, "doc_count": 1, "status": "available" },
        { "key": 4, "doc_count": 1, "status": "available" },
        { "key": 5, "doc_count": 1, "status": "available" }
      ]
    },
    "sizes": {
      "buckets": [
        { "key": 10, "doc_count": 4, "status": "selected" },
        { "key": 20, "doc_count": 2, "status": "available" },
        { "key": 30, "doc_count": 1, "status": "available" }
      ]
    }
  }
}
```

В JSON-агрегациях `mode`, `filters`, `exclude_filters` и `zeroes` можно задавать и для каждой отдельной агрегации.

## Чем это отличается от Meilisearch, Elasticsearch и OpenSearch

Мы проверили тот же сценарий на Manticore, Meilisearch, Elasticsearch и OpenSearch: активны фильтры `brand_id=7`, `color_id=1`, `size_id=10`; выдача остаётся строгой, а фасеты должны показать варианты, которые появляются при исключении собственного фильтра.

| Движок | Исключение собственного фильтра в одном запросе | Нативный статус бакета | Что остаётся приложению |
| --- | --- | --- | --- |
| Manticore | Да, через `auto`/`max` | Да, в `auto`/`max`; `unavailable` только в `max` | Отрисовка интерфейса, SEO, аналитика и нестандартные правила. |
| Meilisearch | Нет в таком виде | Нет | Делать дополнительные запросы и собирать фасеты на стороне приложения. |
| OpenSearch | Да, но вручную через `global` + `filter`-агрегации | Нет | Дублировать ветки фильтров и самому считать `status`. |
| Elasticsearch | Да, но вручную через `global` + `filter`-агрегации | Нет | То же: поддерживать отдельные ветки агрегации и клиентскую логику. |

В Meilisearch базовые фасеты использовать легко, но запрос вида:

```json
{
  "filter": ["brand_id = 7", "color_id = 1", "size_id = 10"],
  "facets": ["color_id", "size_id"]
}
```

вернёт количества для уже отфильтрованной выдачи. В нашем наборе данных это только `color_id=1` и `size_id=10`. Альтернативные `color_id=2` и `size_id=20` в таком ответе не появляются. Если они нужны в интерфейсе, придётся делать дополнительные запросы и объединять результаты в приложении.

В Elasticsearch и OpenSearch похожую механику можно собрать в одном запросе, но для каждого фасета нужно явно задавать отдельную ветку агрегации. Для `color` нужно оставить `brand_id` и `size_id`, для `size` - `brand_id` и `color_id`, и так далее. Это работает, но запрос быстро разрастается, а статус бакетов всё равно вычисляется на стороне приложения.

Главная разница в том, где живёт эта логика. В Manticore она задаётся прямо в API фасетов; в других системах её чаще собирают из нескольких похожих деревьев фильтров.

## Производительность и ограничения

`strict` остаётся режимом по умолчанию и сохраняет старое поведение. Если вам нужны фасеты только внутри текущей выдачи, ничего менять не нужно.

`auto` обычно лучше подходит для фильтров интернет-магазина: он показывает альтернативные значения внутри каждого фасета, помечает выбранные значения как `selected`, а соседние - как `available`.

`max` стоит включать, когда интерфейсу нужны списки значений шире текущей выдачи и недоступные варианты. Этот режим дороже: Manticore считает бакеты в широкой области, а затем отдельно определяет их `status`. На больших данных и при большом количестве фасетов это стоит учитывать.

Ограничения тоже есть:

- локальное переписывание фильтров для фасета поддерживает только фильтры по атрибутам, объединённые через `AND`;
- сложные деревья `AND`/`OR` для отдельных фасетов автоматически не переписываются;
- уже выбранные значения сейчас надёжно сопоставляются только для явных фильтров по значениям вроде `=` и `IN`;
- для диапазонов вроде цены такой статус пока не считается автоматически.

Для цены, скидок и рейтингов лучше явно задать отдельные диапазоны. Например, оставить числовое поле для сортировки и слайдера, а для фасета добавить `price_band` или `discount_band`. Так интерфейс сможет показывать понятные статусы для готовых диапазонов.

SEO для фасетных URL, A/B-тесты, правила мерчандайзинга и аналитика запросов остаются задачами приложения или платформы вокруг поиска.

На практике начните с `auto`, если вам нужны альтернативные значения, и переходите на `max`, когда интерфейс должен показывать широкий список бакетов со статусами `selected`, `available` и `unavailable`.

## Что почитать дальше

Если вам нужен вводный разбор фасетного поиска в Manticore, начните со старой статьи [Faceted search](/blog/faceted-search/). В ней разобраны базовые `FACET`, сортировка, лимиты и фасеты по выражениям.

Подробности по `FACET` есть в [документации](https://manual.manticoresearch.com/Searching/Faceted_search).

Для интерактивного знакомства с фасетами можно пройти курс по [Manticore Faceting](https://play.manticoresearch.com/faceting/).
