diff --git a/api/models.py b/api/models.py
index e592be4..48183ce 100644
--- a/api/models.py
+++ b/api/models.py
@@ -34,6 +34,10 @@ class FacetConfig(BaseModel):
field: str = Field(..., description="分面字段名")
size: int = Field(10, ge=1, le=100, description="返回的分面值数量")
type: Literal["terms", "range"] = Field("terms", description="分面类型")
+ multi_select: bool = Field(
+ True,
+ description="是否支持多选(disjunctive faceting)。启用后,选中该分面的过滤器时,仍会显示其他可选项"
+ )
ranges: Optional[List[Dict[str, Any]]] = Field(
None,
description="范围分面的范围定义(仅当 type='range' 时需要)"
diff --git a/api/result_formatter.py b/api/result_formatter.py
index 6fdf947..7770cf2 100644
--- a/api/result_formatter.py
+++ b/api/result_formatter.py
@@ -220,24 +220,40 @@ class ResultFormatter:
@staticmethod
def format_facets(
es_aggregations: Dict[str, Any],
- facet_configs: Optional[List[Any]] = None
+ facet_configs: Optional[List[Any]] = None,
+ current_filters: Optional[Dict[str, Any]] = None
) -> List[FacetResult]:
"""
- Format ES aggregations to FacetResult list.
+ Format ES aggregations to FacetResult list with selected state.
支持:
1. 普通terms聚合
2. range聚合
3. specifications嵌套聚合(按name分组,然后按value聚合)
+ 4. 标记selected状态(基于current_filters)
Args:
es_aggregations: ES aggregations response
facet_configs: Facet configurations (optional)
+ current_filters: Current applied filters (used to mark selected values)
Returns:
- List of FacetResult objects
+ List of FacetResult objects with selected states
"""
facets = []
+
+ # Build a set of selected values for specifications
+ selected_specs = set()
+ if current_filters and 'specifications' in current_filters:
+ specs = current_filters['specifications']
+ if isinstance(specs, list):
+ # [{"name": "颜色", "value": "白色"}, ...]
+ for spec in specs:
+ if isinstance(spec, dict):
+ selected_specs.add((spec.get('name'), spec.get('value')))
+ elif isinstance(specs, dict):
+ # {"name": "颜色", "value": "白色"}
+ selected_specs.add((specs.get('name'), specs.get('value')))
for field_name, agg_data in es_aggregations.items():
display_field = field_name[:-6] if field_name.endswith("_facet") else field_name
@@ -254,11 +270,13 @@ class ResultFormatter:
values = []
if 'buckets' in value_counts:
for value_bucket in value_counts['buckets']:
+ # Check if this spec value is selected
+ is_selected = (name, value_bucket['key']) in selected_specs
value = FacetValue(
value=value_bucket['key'],
label=str(value_bucket['key']),
count=value_bucket['doc_count'],
- selected=False
+ selected=is_selected
)
values.append(value)
@@ -288,11 +306,13 @@ class ResultFormatter:
values = []
if 'buckets' in value_counts and value_counts['buckets']:
for value_bucket in value_counts['buckets']:
+ # Check if this spec value is selected
+ is_selected = (name, value_bucket['key']) in selected_specs
value = FacetValue(
value=value_bucket['key'],
label=str(value_bucket['key']),
count=value_bucket['doc_count'],
- selected=False
+ selected=is_selected
)
values.append(value)
@@ -311,11 +331,20 @@ class ResultFormatter:
if 'buckets' in agg_data:
values = []
for bucket in agg_data['buckets']:
+ # Check if this value is selected in current filters
+ is_selected = False
+ if current_filters and display_field in current_filters:
+ filter_value = current_filters[display_field]
+ if isinstance(filter_value, list):
+ is_selected = bucket['key'] in filter_value
+ else:
+ is_selected = bucket['key'] == filter_value
+
value = FacetValue(
value=bucket['key'],
label=bucket.get('key_as_string', str(bucket['key'])),
count=bucket['doc_count'],
- selected=False
+ selected=is_selected
)
values.append(value)
@@ -333,11 +362,20 @@ class ResultFormatter:
values = []
for bucket in agg_data['buckets']:
range_key = bucket.get('key', '')
+ # Check if this range is selected
+ is_selected = False
+ if current_filters and display_field in current_filters:
+ filter_value = current_filters[display_field]
+ if isinstance(filter_value, list):
+ is_selected = range_key in filter_value
+ else:
+ is_selected = range_key == filter_value
+
value = FacetValue(
value=range_key,
label=range_key,
count=bucket['doc_count'],
- selected=False
+ selected=is_selected
)
values.append(value)
diff --git a/docs/multi_select_faceting.md b/docs/multi_select_faceting.md
new file mode 100644
index 0000000..ea57de8
--- /dev/null
+++ b/docs/multi_select_faceting.md
@@ -0,0 +1,399 @@
+# Multi-Select Faceting 功能说明
+
+## 概述
+
+Multi-Select Faceting(多选分面)是业界标准的 Faceted Search 功能,允许用户在选中某个分面筛选项后,仍然能看到该分面的其他可选项,提供更好的探索式搜索体验。
+
+## 功能特性
+
+### 1. 两种 Faceting 模式
+
+#### 标准模式(Conjunctive Faceting)
+- **设置**: `multi_select: false`(默认)
+- **行为**: 选中某个分面值后,该分面只显示选中的值
+- **适用场景**: 层级下钻、逐步精炼
+- **ES 实现**: 过滤器应用在 `query.bool.filter`
+
+#### Multi-Select 模式(Disjunctive Faceting)
+- **设置**: `multi_select: true`
+- **行为**: 选中某个分面值后,该分面仍显示所有可选项
+- **适用场景**: 颜色、品牌、尺码等可切换属性
+- **ES 实现**: 过滤器应用在 `post_filter`
+
+### 2. Selected 状态标记
+
+所有 facet 值都包含 `selected` 字段,标记当前是否被选中:
+- `selected: true` - 当前筛选项已被选中
+- `selected: false` - 当前筛选项未被选中
+
+## 使用示例
+
+### 示例 1: 标准 Category Faceting
+
+```json
+{
+ "query": "T恤",
+ "filters": {
+ "category1_name": "服装"
+ },
+ "facets": [
+ {
+ "field": "category1_name",
+ "size": 10,
+ "type": "terms",
+ "multi_select": false
+ }
+ ]
+}
+```
+
+**响应**:
+```json
+{
+ "results": [...],
+ "facets": [
+ {
+ "field": "category1_name",
+ "values": [
+ {"value": "服装", "count": 150, "selected": true}
+ ]
+ }
+ ]
+}
+```
+
+### 示例 2: Multi-Select Brand Faceting
+
+```json
+{
+ "query": "手机",
+ "filters": {
+ "brand_name": "苹果"
+ },
+ "facets": [
+ {
+ "field": "brand_name",
+ "size": 10,
+ "type": "terms",
+ "multi_select": true
+ }
+ ]
+}
+```
+
+**响应**:
+```json
+{
+ "results": [...只包含苹果手机...],
+ "facets": [
+ {
+ "field": "brand_name",
+ "values": [
+ {"value": "苹果", "count": 150, "selected": true},
+ {"value": "华为", "count": 120, "selected": false},
+ {"value": "小米", "count": 98, "selected": false}
+ ]
+ }
+ ]
+}
+```
+
+### 示例 3: Specifications Multi-Select
+
+```json
+{
+ "query": "衬衫",
+ "filters": {
+ "specifications": {
+ "name": "颜色",
+ "value": "白色"
+ }
+ },
+ "facets": [
+ {
+ "field": "specifications.颜色",
+ "size": 10,
+ "type": "terms",
+ "multi_select": true
+ },
+ {
+ "field": "specifications.尺码",
+ "size": 10,
+ "type": "terms",
+ "multi_select": false
+ }
+ ]
+}
+```
+
+**响应**:
+```json
+{
+ "results": [...只包含白色衬衫...],
+ "facets": [
+ {
+ "field": "specifications.颜色",
+ "label": "颜色",
+ "values": [
+ {"value": "白色", "count": 50, "selected": true},
+ {"value": "蓝色", "count": 35, "selected": false},
+ {"value": "黑色", "count": 28, "selected": false}
+ ]
+ },
+ {
+ "field": "specifications.尺码",
+ "label": "尺码",
+ "values": [
+ {"value": "M", "count": 20, "selected": false},
+ {"value": "L", "count": 18, "selected": false},
+ {"value": "XL", "count": 12, "selected": false}
+ ]
+ }
+ ]
+}
+```
+
+注意:尺码分面(`multi_select: false`)的统计是基于白色衬衫的。
+
+### 示例 4: 混合多个 Multi-Select Facets
+
+```json
+{
+ "query": "*",
+ "filters": {
+ "category1_name": "玩具",
+ "specifications": [
+ {"name": "颜色", "value": "红色"},
+ {"name": "材质", "value": "塑料"}
+ ]
+ },
+ "facets": [
+ {
+ "field": "category1_name",
+ "size": 10,
+ "multi_select": true
+ },
+ {
+ "field": "specifications.颜色",
+ "size": 10,
+ "multi_select": true
+ },
+ {
+ "field": "specifications.材质",
+ "size": 10,
+ "multi_select": true
+ },
+ {
+ "field": "specifications.年龄段",
+ "size": 10,
+ "multi_select": false
+ }
+ ]
+}
+```
+
+**行为说明**:
+- `category1_name`: 显示所有类目选项(玩具被标记为 selected)
+- `specifications.颜色`: 显示所有颜色选项(红色被标记为 selected)
+- `specifications.材质`: 显示所有材质选项(塑料被标记为 selected)
+- `specifications.年龄段`: 只显示符合当前过滤条件的年龄段选项
+
+## 前端集成建议
+
+### React 示例
+
+```jsx
+function FacetComponent({ facet }) {
+ return (
+
+
{facet.label}
+ {facet.values.map(value => (
+
+ ))}
+
+ );
+}
+```
+
+### Vue 示例
+
+```vue
+
+
+
{{ facet.label }}
+
+
+
+```
+
+## 技术实现细节
+
+### Elasticsearch Query 结构
+
+**Multi-Select Faceting** 使用 `post_filter` 实现:
+
+```json
+{
+ "query": {
+ "bool": {
+ "must": [...],
+ "filter": [
+ // 只包含 multi_select=false 的过滤器
+ {"term": {"category2_name": "短袖T恤"}}
+ ]
+ }
+ },
+ "post_filter": {
+ "bool": {
+ "filter": [
+ // 包含 multi_select=true 的过滤器
+ {"term": {"brand_name": "苹果"}},
+ {
+ "nested": {
+ "path": "specifications",
+ "query": {
+ "bool": {
+ "must": [
+ {"term": {"specifications.name": "颜色"}},
+ {"term": {"specifications.value": "白色"}}
+ ]
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ "aggs": {
+ // 所有聚合都基于 query 的结果(不受 post_filter 影响)
+ "brand_name_facet": {...},
+ "specifications_颜色_facet": {...}
+ }
+}
+```
+
+**关键点**:
+- `query.bool.filter`: 影响结果和聚合
+- `post_filter`: 只影响结果,不影响聚合
+- 聚合统计基于 `query` 的结果,因此 multi-select facet 可以显示多个选项
+
+## 最佳实践
+
+### 1. 何时使用 Multi-Select
+
+| Facet 类型 | 推荐模式 | 原因 |
+|-----------|---------|------|
+| 颜色 | `multi_select: true` | 用户需要切换颜色 |
+| 品牌 | `multi_select: true` | 用户需要比较不同品牌 |
+| 尺码 | `multi_select: true` | 用户需要查看其他尺码 |
+| 类目 | `multi_select: false` | 层级下钻 |
+| 价格区间 | `multi_select: false` | 互斥选择 |
+| 是否有货 | `multi_select: false` | 布尔值筛选 |
+
+### 2. 性能考虑
+
+- **过多 Multi-Select**: 会增加 ES 查询复杂度
+- **建议**: 最多 3-5 个 multi-select facets
+- **优化**: 对于不常用的属性使用标准模式
+
+### 3. UI 设计建议
+
+- **Multi-Select Facets**: 使用复选框(Checkbox)
+- **Standard Facets**: 使用单选框(Radio)或链接
+- **Selected 状态**: 使用不同颜色或图标标识
+
+## API 变更说明
+
+### 新增字段
+
+**FacetConfig**:
+```json
+{
+ "field": "brand_name",
+ "size": 10,
+ "type": "terms",
+ "multi_select": true // 新增字段
+}
+```
+
+**FacetValue**:
+```json
+{
+ "value": "苹果",
+ "count": 150,
+ "selected": true // 现在由后端返回真实状态
+}
+```
+
+### 兼容性
+
+- `multi_select` 默认为 `false`,保持向后兼容
+- 旧版 API 调用仍然有效(使用标准模式)
+
+## 测试验证
+
+运行测试脚本:
+```bash
+python test_multi_select_facet.py
+```
+
+测试覆盖:
+1. ✓ 标准 Faceting (multi_select=false)
+2. ✓ Multi-Select Faceting (multi_select=true)
+3. ✓ Specifications Multi-Select
+4. ✓ ES Query 结构验证
+
+## 故障排查
+
+### 问题 1: Multi-Select 不生效
+
+**症状**: 设置了 `multi_select: true`,但仍然只返回一个值
+
+**检查**:
+1. 确认 `multi_select` 字段在请求中正确设置
+2. 检查 ES query 是否包含 `post_filter`(开启 `debug: true`)
+3. 验证 Elasticsearch 版本支持 `post_filter`
+
+### 问题 2: Selected 标记不正确
+
+**症状**: `selected` 字段没有正确标记
+
+**检查**:
+1. 确认 `filters` 中的字段名与 facet 字段名一致
+2. 对于 specifications,检查 `name` 和 `value` 是否匹配
+3. 检查 `filters` 的值类型(字符串、数组等)
+
+### 问题 3: 性能问题
+
+**症状**: 启用 Multi-Select 后查询变慢
+
+**优化**:
+1. 减少 multi-select facets 数量
+2. 降低 facet `size` 参数
+3. 考虑使用缓存
+4. 为常用字段建立索引
+
+## 参考资料
+
+- [Elasticsearch Post Filter](https://www.elastic.co/guide/en/elasticsearch/reference/current/filter-search-results.html#post-filter)
+- [Algolia Disjunctive Faceting](https://www.algolia.com/doc/guides/managing-results/refine-results/faceting/#conjunctive-and-disjunctive-facets)
+- [Amazon Product Search](https://www.amazon.com) - 业界最佳实践示例
+
diff --git a/docs/搜索API对接指南.md b/docs/搜索API对接指南.md
index 3291e16..a28df7e 100644
--- a/docs/搜索API对接指南.md
+++ b/docs/搜索API对接指南.md
@@ -275,17 +275,20 @@ curl -X POST "http://120.76.41.98:6002/search/" \
{
"field": "category1_name",
"size": 15,
- "type": "terms"
+ "type": "terms",
+ "multi_select": false
},
{
- "field": "category2_name",
+ "field": "brand_name",
"size": 10,
- "type": "terms"
+ "type": "terms",
+ "multi_select": true
},
{
"field": "specifications.color",
"size": 20,
- "type": "terms"
+ "type": "terms",
+ "multi_select": true
},
{
"field": "min_price",
@@ -301,6 +304,62 @@ curl -X POST "http://120.76.41.98:6002/search/" \
}
```
+**Facet 字段说明**:
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| `field` | string | 是 | - | 分面字段名 |
+| `size` | int | 否 | 10 | 返回的分面值数量(1-100) |
+| `type` | string | 否 | "terms" | 分面类型:`terms`(词条聚合)或 `range`(范围聚合) |
+| `multi_select` | bool | 否 | false | **是否支持多选模式**(见下文详细说明) |
+| `ranges` | array | 否 | null | 范围配置(仅 `type="range"` 时需要) |
+
+#### 🆕 Multi-Select Faceting(多选分面)
+
+**重要特性**: `multi_select` 字段控制分面的行为模式。
+
+##### 标准模式 (multi_select: false)
+- **行为**: 选中某个分面值后,该分面只显示选中的值
+- **适用场景**: 层级类目、互斥选择
+- **示例**: 类目下钻(玩具 > 娃娃 > 芭比)
+
+```json
+{
+ "filters": {"category1_name": "玩具"},
+ "facets": [
+ {"field": "category1_name", "size": 10, "multi_select": false}
+ ]
+}
+```
+**响应**: 只返回 "玩具" 一个选项
+
+##### Multi-Select 模式 (multi_select: true) ⭐
+- **行为**: 选中某个分面值后,该分面仍显示所有可选项
+- **适用场景**: 颜色、品牌、尺码等可切换属性
+- **示例**: 选择了"红色"后,仍能看到"蓝色"、"绿色"等选项
+
+```json
+{
+ "filters": {
+ "specifications": {"name": "颜色", "value": "红色"}
+ },
+ "facets": [
+ {"field": "specifications.颜色", "size": 10, "multi_select": true}
+ ]
+}
+```
+**响应**: 返回所有颜色选项,"红色" 被标记为 `selected: true`
+
+##### 推荐配置
+
+| 分面类型 | multi_select | 原因 |
+|---------|-------------|------|
+| 颜色 | `true` | 用户需要切换颜色 |
+| 品牌 | `true` | 用户需要比较品牌 |
+| 尺码 | `true` | 用户需要查看其他尺码 |
+| 类目 | `false` | 层级下钻 |
+| 价格区间 | `false` | 互斥选择 |
+
**规格分面说明**:
`specifications` 是嵌套字段,支持两种分面模式:
@@ -326,12 +385,14 @@ curl -X POST "http://120.76.41.98:6002/search/" \
{
"field": "specifications.color",
"size": 20,
- "type": "terms"
+ "type": "terms",
+ "multi_select": true
},
{
"field": "specifications.size",
"size": 15,
- "type": "terms"
+ "type": "terms",
+ "multi_select": true
}
]
}
@@ -347,8 +408,9 @@ curl -X POST "http://120.76.41.98:6002/search/" \
"label": "color",
"type": "terms",
"values": [
- {"value": "white", "count": 50, "selected": false},
- {"value": "black", "count": 30, "selected": false}
+ {"value": "white", "count": 50, "selected": true}, // ✓ selected 字段由后端标记
+ {"value": "black", "count": 30, "selected": false},
+ {"value": "red", "count": 20, "selected": false}
]
},
{
diff --git a/search/es_query_builder.py b/search/es_query_builder.py
index c096a16..0fe1e66 100644
--- a/search/es_query_builder.py
+++ b/search/es_query_builder.py
@@ -44,6 +44,54 @@ class ESQueryBuilder:
self.source_fields = source_fields
self.function_score_config = function_score_config
+ def _split_filters_for_faceting(
+ self,
+ filters: Optional[Dict[str, Any]],
+ facet_configs: Optional[List[Any]]
+ ) -> tuple:
+ """
+ Split filters into conjunctive (query) and disjunctive (post_filter) based on facet configs.
+
+ Disjunctive filters (multi-select facets):
+ - Applied via post_filter (affects results but not aggregations)
+ - Allows showing other options in the same facet even when filtered
+
+ Conjunctive filters (standard facets):
+ - Applied in query.bool.filter (affects both results and aggregations)
+ - Standard drill-down behavior
+
+ Args:
+ filters: All filters from request
+ facet_configs: Facet configurations with multi_select flags
+
+ Returns:
+ (conjunctive_filters, disjunctive_filters)
+ """
+ if not filters or not facet_configs:
+ return filters or {}, {}
+
+ # Get fields that support multi-select
+ multi_select_fields = set()
+ for fc in facet_configs:
+ if getattr(fc, 'multi_select', False):
+ # Handle specifications.xxx format
+ if fc.field.startswith('specifications.'):
+ multi_select_fields.add('specifications')
+ else:
+ multi_select_fields.add(fc.field)
+
+ # Split filters
+ conjunctive = {}
+ disjunctive = {}
+
+ for field, value in filters.items():
+ if field in multi_select_fields:
+ disjunctive[field] = value
+ else:
+ conjunctive[field] = value
+
+ return conjunctive, disjunctive
+
def build_query(
self,
query_text: str,
@@ -51,6 +99,7 @@ class ESQueryBuilder:
query_node: Optional[QueryNode] = None,
filters: Optional[Dict[str, Any]] = None,
range_filters: Optional[Dict[str, Any]] = None,
+ facet_configs: Optional[List[Any]] = None,
size: int = 10,
from_: int = 0,
enable_knn: bool = True,
@@ -59,10 +108,11 @@ class ESQueryBuilder:
min_score: Optional[float] = None
) -> Dict[str, Any]:
"""
- Build complete ES query (简化版).
+ Build complete ES query with post_filter support for multi-select faceting.
- 结构:filters and (text_recall or embedding_recall)
- - filters: 前端传递的过滤条件永远起作用
+ 结构:filters and (text_recall or embedding_recall) + post_filter
+ - conjunctive_filters: 应用在 query.bool.filter(影响结果和聚合)
+ - disjunctive_filters: 应用在 post_filter(只影响结果,不影响聚合)
- text_recall: 文本相关性召回(中英文字段都用)
- embedding_recall: 向量召回(KNN)
- function_score: 包装召回部分,支持提权字段
@@ -71,8 +121,9 @@ class ESQueryBuilder:
query_text: Query text for BM25 matching
query_vector: Query embedding for KNN search
query_node: Parsed boolean expression tree
- filters: Exact match filters (always applied)
- range_filters: Range filters for numeric fields (always applied)
+ filters: Exact match filters
+ range_filters: Range filters for numeric fields (always applied in query)
+ facet_configs: Facet configurations (used to identify multi-select facets)
size: Number of results
from_: Offset for pagination
enable_knn: Whether to use KNN search
@@ -110,8 +161,13 @@ class ESQueryBuilder:
# Embedding recall (KNN - separate from query, handled below)
has_embedding = enable_knn and query_vector is not None and self.text_embedding_field
- # 2. Build filter clauses (always applied)
- filter_clauses = self._build_filters(filters, range_filters)
+ # 2. Split filters for multi-select faceting
+ conjunctive_filters, disjunctive_filters = self._split_filters_for_faceting(
+ filters, facet_configs
+ )
+
+ # Build filter clauses for query (conjunctive filters + range filters)
+ filter_clauses = self._build_filters(conjunctive_filters, range_filters)
# 3. Build main query structure: filters and recall
if recall_clauses:
@@ -162,7 +218,18 @@ class ESQueryBuilder:
}
es_query["knn"] = knn_clause
- # 5. Add minimum score filter
+ # 5. Add post_filter for disjunctive (multi-select) filters
+ if disjunctive_filters:
+ post_filter_clauses = self._build_filters(disjunctive_filters, None)
+ if post_filter_clauses:
+ if len(post_filter_clauses) == 1:
+ es_query["post_filter"] = post_filter_clauses[0]
+ else:
+ es_query["post_filter"] = {
+ "bool": {"filter": post_filter_clauses}
+ }
+
+ # 6. Add minimum score filter
if min_score is not None:
es_query["min_score"] = min_score
diff --git a/search/searcher.py b/search/searcher.py
index 434f136..1c72800 100644
--- a/search/searcher.py
+++ b/search/searcher.py
@@ -275,6 +275,7 @@ class Searcher:
query_node=query_node,
filters=filters,
range_filters=range_filters,
+ facet_configs=facets,
size=size,
from_=from_,
enable_knn=enable_embedding and parsed_query.query_vector is not None,
@@ -381,7 +382,8 @@ class Searcher:
if facets:
standardized_facets = ResultFormatter.format_facets(
es_response.get('aggregations', {}),
- facets
+ facets,
+ filters
)
# Generate suggestions and related searches
diff --git a/test_multi_select_facet.py b/test_multi_select_facet.py
new file mode 100644
index 0000000..6bde77a
--- /dev/null
+++ b/test_multi_select_facet.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+"""
+测试 Multi-Select Faceting 和 Selected 标记功能
+
+验证:
+1. multi_select=False 时,选中某个 facet 值后,该 facet 只返回一个值(标准模式)
+2. multi_select=True 时,选中某个 facet 值后,该 facet 仍返回多个值(disjunctive 模式)
+3. selected 字段正确标记
+"""
+
+import requests
+import json
+
+API_URL = "http://localhost:8000/api/search"
+TENANT_ID = "test_tenant"
+
+def test_standard_faceting():
+ """测试标准 faceting(multi_select=False)"""
+ print("\n" + "="*80)
+ print("测试 1: 标准 Faceting (multi_select=False)")
+ print("="*80)
+
+ # 选择 category1_name = "玩具"
+ payload = {
+ "query": "*",
+ "size": 5,
+ "filters": {
+ "category1_name": "玩具"
+ },
+ "facets": [
+ {
+ "field": "category1_name",
+ "size": 10,
+ "type": "terms",
+ "multi_select": False # 标准模式
+ }
+ ]
+ }
+
+ headers = {"X-Tenant-ID": TENANT_ID}
+
+ try:
+ response = requests.post(API_URL, json=payload, headers=headers)
+ response.raise_for_status()
+ result = response.json()
+
+ print(f"\n✓ 请求成功 (HTTP {response.status_code})")
+ print(f" 总结果数: {result.get('total', 0)}")
+
+ if result.get('facets'):
+ for facet in result['facets']:
+ if facet['field'] == 'category1_name':
+ print(f"\n Facet: {facet['field']}")
+ print(f" Values 数量: {len(facet['values'])}")
+ for val in facet['values']:
+ selected_mark = "✓" if val.get('selected') else " "
+ print(f" [{selected_mark}] {val['value']}: {val['count']}")
+
+ # 验证
+ if len(facet['values']) == 1:
+ print("\n ✓ 验证通过: multi_select=False 时,只返回选中的值")
+ else:
+ print("\n ⚠ 警告: multi_select=False 时应只返回一个值")
+
+ selected_values = [v['value'] for v in facet['values'] if v.get('selected')]
+ if selected_values == ['玩具']:
+ print(" ✓ 验证通过: selected 字段正确标记")
+ else:
+ print(f" ✗ 错误: selected 标记不正确,期望 ['玩具'],实际 {selected_values}")
+ else:
+ print("\n ⚠ 警告: 没有返回 facets")
+
+ except requests.exceptions.RequestException as e:
+ print(f"\n✗ 请求失败: {e}")
+ except Exception as e:
+ print(f"\n✗ 错误: {e}")
+
+
+def test_multi_select_faceting():
+ """测试 Multi-Select Faceting (multi_select=True)"""
+ print("\n" + "="*80)
+ print("测试 2: Multi-Select Faceting (multi_select=True)")
+ print("="*80)
+
+ # 选择 category1_name = "玩具"
+ payload = {
+ "query": "*",
+ "size": 5,
+ "filters": {
+ "category1_name": "玩具"
+ },
+ "facets": [
+ {
+ "field": "category1_name",
+ "size": 10,
+ "type": "terms",
+ "multi_select": True # Multi-select 模式
+ }
+ ]
+ }
+
+ headers = {"X-Tenant-ID": TENANT_ID}
+
+ try:
+ response = requests.post(API_URL, json=payload, headers=headers)
+ response.raise_for_status()
+ result = response.json()
+
+ print(f"\n✓ 请求成功 (HTTP {response.status_code})")
+ print(f" 总结果数: {result.get('total', 0)}")
+
+ if result.get('facets'):
+ for facet in result['facets']:
+ if facet['field'] == 'category1_name':
+ print(f"\n Facet: {facet['field']}")
+ print(f" Values 数量: {len(facet['values'])}")
+ for val in facet['values'][:5]: # 只显示前5个
+ selected_mark = "✓" if val.get('selected') else " "
+ print(f" [{selected_mark}] {val['value']}: {val['count']}")
+
+ # 验证
+ if len(facet['values']) > 1:
+ print(f"\n ✓ 验证通过: multi_select=True 时,返回多个值 ({len(facet['values'])} 个)")
+ else:
+ print("\n ✗ 错误: multi_select=True 时应返回多个值")
+
+ selected_values = [v['value'] for v in facet['values'] if v.get('selected')]
+ if selected_values == ['玩具']:
+ print(" ✓ 验证通过: selected 字段正确标记")
+ else:
+ print(f" ⚠ 警告: selected 标记可能不正确,期望 ['玩具'],实际 {selected_values}")
+ else:
+ print("\n ⚠ 警告: 没有返回 facets")
+
+ except requests.exceptions.RequestException as e:
+ print(f"\n✗ 请求失败: {e}")
+ except Exception as e:
+ print(f"\n✗ 错误: {e}")
+
+
+def test_specifications_multi_select():
+ """测试 Specifications 的 Multi-Select Faceting"""
+ print("\n" + "="*80)
+ print("测试 3: Specifications Multi-Select Faceting")
+ print("="*80)
+
+ # 选择 specifications.颜色 = "白色"
+ payload = {
+ "query": "*",
+ "size": 5,
+ "filters": {
+ "specifications": {
+ "name": "颜色",
+ "value": "白色"
+ }
+ },
+ "facets": [
+ {
+ "field": "specifications.颜色",
+ "size": 10,
+ "type": "terms",
+ "multi_select": True
+ },
+ {
+ "field": "specifications.尺寸",
+ "size": 10,
+ "type": "terms",
+ "multi_select": False
+ }
+ ]
+ }
+
+ headers = {"X-Tenant-ID": TENANT_ID}
+
+ try:
+ response = requests.post(API_URL, json=payload, headers=headers)
+ response.raise_for_status()
+ result = response.json()
+
+ print(f"\n✓ 请求成功 (HTTP {response.status_code})")
+ print(f" 总结果数: {result.get('total', 0)}")
+
+ if result.get('facets'):
+ for facet in result['facets']:
+ print(f"\n Facet: {facet['field']} (multi_select={facet.get('multi_select', 'N/A')})")
+ print(f" Values 数量: {len(facet['values'])}")
+ for val in facet['values'][:5]:
+ selected_mark = "✓" if val.get('selected') else " "
+ print(f" [{selected_mark}] {val['value']}: {val['count']}")
+
+ # 验证 specifications.颜色
+ if facet['field'] == 'specifications.颜色':
+ if len(facet['values']) > 1:
+ print(f" ✓ 验证通过: multi_select=True,返回多个颜色选项")
+ selected_values = [v['value'] for v in facet['values'] if v.get('selected')]
+ if '白色' in selected_values:
+ print(" ✓ 验证通过: '白色' 被正确标记为 selected")
+
+ # 验证 specifications.尺寸(基于白色商品的尺寸分布)
+ if facet['field'] == 'specifications.尺寸':
+ print(f" ℹ 尺寸分布基于已选的颜色过滤器")
+ else:
+ print("\n ⚠ 警告: 没有返回 facets")
+
+ except requests.exceptions.RequestException as e:
+ print(f"\n✗ 请求失败: {e}")
+ except Exception as e:
+ print(f"\n✗ 错误: {e}")
+
+
+def test_es_query_structure():
+ """测试 ES Query 结构(需要 debug=True)"""
+ print("\n" + "="*80)
+ print("测试 4: ES Query 结构验证 (debug=True)")
+ print("="*80)
+
+ payload = {
+ "query": "手机",
+ "size": 1,
+ "filters": {
+ "category1_name": "电子产品",
+ "specifications": {"name": "颜色", "value": "白色"}
+ },
+ "facets": [
+ {
+ "field": "category1_name",
+ "size": 5,
+ "multi_select": True
+ },
+ {
+ "field": "specifications.颜色",
+ "size": 5,
+ "multi_select": True
+ }
+ ],
+ "debug": True
+ }
+
+ headers = {"X-Tenant-ID": TENANT_ID}
+
+ try:
+ response = requests.post(API_URL, json=payload, headers=headers)
+ response.raise_for_status()
+ result = response.json()
+
+ print(f"\n✓ 请求成功")
+
+ if result.get('debug_info') and result['debug_info'].get('es_query'):
+ es_query = result['debug_info']['es_query']
+
+ # 检查 post_filter
+ if 'post_filter' in es_query:
+ print("\n ✓ ES Query 包含 post_filter:")
+ print(f" {json.dumps(es_query['post_filter'], indent=4, ensure_ascii=False)[:200]}...")
+ else:
+ print("\n ℹ ES Query 不包含 post_filter(可能没有 multi-select 过滤器)")
+
+ # 检查 query.bool.filter
+ if 'query' in es_query and 'bool' in es_query['query']:
+ filters = es_query['query']['bool'].get('filter', [])
+ print(f"\n ✓ Query filters 数量: {len(filters)}")
+
+ else:
+ print("\n ⚠ 警告: debug_info 中没有 es_query")
+
+ except requests.exceptions.RequestException as e:
+ print(f"\n✗ 请求失败: {e}")
+ except Exception as e:
+ print(f"\n✗ 错误: {e}")
+
+
+if __name__ == "__main__":
+ print("\n" + "="*80)
+ print("Multi-Select Faceting 功能测试")
+ print("="*80)
+ print(f"\nAPI URL: {API_URL}")
+ print(f"Tenant ID: {TENANT_ID}")
+
+ # 运行测试
+ test_standard_faceting()
+ test_multi_select_faceting()
+ test_specifications_multi_select()
+ test_es_query_structure()
+
+ print("\n" + "="*80)
+ print("测试完成")
+ print("="*80)
+
--
libgit2 0.21.2