# 启用过滤条件后的分面搜索

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 开始，即使当前分面中某些桶的 `count(*)` 为 `0`，也会在 SQL `max` 模式下保留来自更宽 `max` 范围的这些桶。

在 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 Search 会使用 `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 Search：

- `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 Search 会保留相同的可见计数，但会把宽 `max` 范围中的其余桶以 0 计数返回：

```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 Search、Meilisearch、Elasticsearch 和 OpenSearch 中测试了相同场景：`brand_id=7`、`color_id=1` 和 `size_id=10` 筛选条件处于激活状态；结果集保持严格，而分面需要显示在排除自身筛选条件后出现的选项。

| 引擎 | 是否可在一个查询中排除自身筛选条件 | 原生桶状态 | 应用层还需要做什么 |
| --- | --- | --- | --- |
| Manticore Search | 可以，通过 `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 Search 中，它直接配置在分面 API 里；在其他系统中，通常要由多个相似的筛选树拼装而成。

## 性能与限制

`strict` 仍然是默认值，并保留了之前的行为。如果你只需要当前结果集内的分面，则无需做任何修改。

`auto` 通常更适合电商筛选：它会在每个分面中显示替代值，把已选值标记为 `selected`，并把其他值标记为 `available`。

当界面需要比当前结果集更宽的值列表，并且需要显示不可用选项时，可以使用 `max`。这个模式的成本更高：Manticore Search 会在更宽的范围上计算桶，然后再单独确定它们的 `status`。在大数据集和大量分面的情况下，这一点值得考虑。

还有一些限制：

- 分面的本地筛选重写只支持由 `AND` 组合的属性筛选条件；
- 复杂的 `AND`/`OR` 树不会自动为单个分面重写；
- 目前只有显式值筛选，例如 `=` 和 `IN`，才能可靠地匹配已选值；
- 对于价格等范围，状态还没有自动计算。

对于价格、折扣和评分，最好显式定义单独的区间。例如，保留一个用于排序和滑块的数值字段，再为分面添加 `price_band` 或 `discount_band`。这样界面就可以为预定义区间显示清晰的状态。

分面 URL 的 SEO、A/B 测试、商品陈列规则和查询分析，仍然是搜索周边应用或平台的职责。

实践中，当你需要替代值时，先从 `auto` 开始；当界面需要带有 `selected`、`available` 和 `unavailable` 状态的宽桶列表时，再切换到 `max`。

## 延伸阅读

要了解 Manticore Search 中分面搜索的入门内容，请先阅读之前的 [Faceted search](/blog/faceted-search/) 文章。它介绍了基础 `FACET` 查询、排序、限制以及基于表达式的分面。

详情请参阅 [`FACET` 文档](https://manual.manticoresearch.com/Searching/Faceted_search)。

如果想进行交互式分面入门学习，可以参加 [Manticore Faceting](https://play.manticoresearch.com/faceting/) 课程。
