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 + +``` + +## 技术实现细节 + +### 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