Commit ca91352aa97b0a95479a570f0278d840f5709b20
1 parent
f7d3cf70
更新文档
1. 搜索API对接指南.md 在“精确匹配过滤器”部分添加了 specifications 嵌套过滤说明 支持单个规格过滤和多个规格过滤(OR 逻辑) 在“分面配置”部分完善了 specifications 分面说明 添加了两种分面模式:所有规格名称和指定规格名称 在“常见场景示例”部分添加了场景5-8,包含规格过滤和分面的完整示例 2. 搜索API速查表.md 在“精确匹配过滤”部分添加了 specifications 过滤的快速参考 在“分面搜索”部分添加了 specifications 分面的快速参考 更新了完整示例,包含 specifications 的使用 3. Search-API-Examples.md 在“过滤器使用”部分添加了示例4-6,展示 specifications 过滤 在“分面搜索”部分添加了示例2-3,展示 specifications 分面 更新了 Python 和 JavaScript 完整示例,包含 specifications 的使用 在“常见使用场景”部分添加了场景2.1,展示带规格过滤的搜索结果页 4. 索引字段说明v2.md 更新了 specifications 字段的查询示例,包含 API 格式和 ES 查询结构 添加了两种分面模式的说明和示例 更新了“分面字段”说明,明确支持指定规格名称的分面 5. 补充参数 参数说明:sku_filter_dimension 是可选参数,用于按指定维度过滤每个SPU下的SKU 支持的维度: 直接选项字段:option1、option2、option3 规格名称:通过 option1_name、option2_name、option3_name 匹配(如 color、size)
Showing
8 changed files
with
560 additions
and
176 deletions
Show diff stats
api/models.py
| ... | ... | @@ -145,6 +145,12 @@ class SearchRequest(BaseModel): |
| 145 | 145 | highlight: bool = Field(False, description="是否高亮搜索关键词(暂不实现)") |
| 146 | 146 | debug: bool = Field(False, description="是否返回调试信息") |
| 147 | 147 | |
| 148 | + # SKU筛选参数 | |
| 149 | + sku_filter_dimension: Optional[str] = Field( | |
| 150 | + None, | |
| 151 | + description="子SKU筛选维度(店铺配置)。指定后,每个SPU下的SKU将按该维度分组,每组选择第一个SKU返回。例如:'color'表示按颜色分组,每种颜色选一款。支持的值:'option1'、'option2'、'option3'或specifications中的name(如'color'、'size')" | |
| 152 | + ) | |
| 153 | + | |
| 148 | 154 | # 个性化参数(预留) |
| 149 | 155 | user_id: Optional[str] = Field(None, description="用户ID,用于个性化搜索和推荐") |
| 150 | 156 | session_id: Optional[str] = Field(None, description="会话ID,用于搜索分析") | ... | ... |
api/result_formatter.py
| ... | ... | @@ -13,7 +13,8 @@ class ResultFormatter: |
| 13 | 13 | def format_search_results( |
| 14 | 14 | es_hits: List[Dict[str, Any]], |
| 15 | 15 | max_score: float = 1.0, |
| 16 | - language: str = "zh" | |
| 16 | + language: str = "zh", | |
| 17 | + sku_filter_dimension: Optional[str] = None | |
| 17 | 18 | ) -> List[SpuResult]: |
| 18 | 19 | """ |
| 19 | 20 | Convert ES hits to SpuResult list. |
| ... | ... | @@ -72,11 +73,29 @@ class ResultFormatter: |
| 72 | 73 | price=sku_entry.get('price'), |
| 73 | 74 | compare_at_price=sku_entry.get('compare_at_price'), |
| 74 | 75 | sku=sku_entry.get('sku'), |
| 76 | + sku_code=sku_entry.get('sku_code'), | |
| 75 | 77 | stock=sku_entry.get('stock', 0), |
| 78 | + weight=sku_entry.get('weight'), | |
| 79 | + weight_unit=sku_entry.get('weight_unit'), | |
| 80 | + option1_value=sku_entry.get('option1_value'), | |
| 81 | + option2_value=sku_entry.get('option2_value'), | |
| 82 | + option3_value=sku_entry.get('option3_value'), | |
| 83 | + image_src=sku_entry.get('image_src'), | |
| 76 | 84 | options=sku_entry.get('options') |
| 77 | 85 | ) |
| 78 | 86 | skus.append(sku) |
| 79 | 87 | |
| 88 | + # Apply SKU filtering if dimension is specified | |
| 89 | + if sku_filter_dimension and skus: | |
| 90 | + skus = ResultFormatter._filter_skus_by_dimension( | |
| 91 | + skus, | |
| 92 | + sku_filter_dimension, | |
| 93 | + source.get('option1_name'), | |
| 94 | + source.get('option2_name'), | |
| 95 | + source.get('option3_name'), | |
| 96 | + source.get('specifications', []) | |
| 97 | + ) | |
| 98 | + | |
| 80 | 99 | # Determine in_stock (any sku has stock > 0) |
| 81 | 100 | in_stock = any(sku.stock > 0 for sku in skus) if skus else True |
| 82 | 101 | |
| ... | ... | @@ -119,6 +138,81 @@ class ResultFormatter: |
| 119 | 138 | return results |
| 120 | 139 | |
| 121 | 140 | @staticmethod |
| 141 | + def _filter_skus_by_dimension( | |
| 142 | + skus: List[SkuResult], | |
| 143 | + dimension: str, | |
| 144 | + option1_name: Optional[str] = None, | |
| 145 | + option2_name: Optional[str] = None, | |
| 146 | + option3_name: Optional[str] = None, | |
| 147 | + specifications: Optional[List[Dict[str, Any]]] = None | |
| 148 | + ) -> List[SkuResult]: | |
| 149 | + """ | |
| 150 | + Filter SKUs by dimension, keeping only one SKU per dimension value. | |
| 151 | + | |
| 152 | + Args: | |
| 153 | + skus: List of SKU results to filter | |
| 154 | + dimension: Filter dimension, can be: | |
| 155 | + - 'option1', 'option2', 'option3': Direct option field | |
| 156 | + - A specification name (e.g., 'color', 'size'): Match by option name | |
| 157 | + option1_name: Name of option1 (e.g., 'color') | |
| 158 | + option2_name: Name of option2 (e.g., 'size') | |
| 159 | + option3_name: Name of option3 | |
| 160 | + specifications: List of specifications (for reference) | |
| 161 | + | |
| 162 | + Returns: | |
| 163 | + Filtered list of SKUs (one per dimension value) | |
| 164 | + """ | |
| 165 | + if not skus: | |
| 166 | + return skus | |
| 167 | + | |
| 168 | + # Determine which field to use for filtering | |
| 169 | + filter_field = None | |
| 170 | + | |
| 171 | + # Direct option field (option1, option2, option3) | |
| 172 | + if dimension.lower() == 'option1': | |
| 173 | + filter_field = 'option1_value' | |
| 174 | + elif dimension.lower() == 'option2': | |
| 175 | + filter_field = 'option2_value' | |
| 176 | + elif dimension.lower() == 'option3': | |
| 177 | + filter_field = 'option3_value' | |
| 178 | + else: | |
| 179 | + # Try to match by option name | |
| 180 | + dimension_lower = dimension.lower() | |
| 181 | + if option1_name and option1_name.lower() == dimension_lower: | |
| 182 | + filter_field = 'option1_value' | |
| 183 | + elif option2_name and option2_name.lower() == dimension_lower: | |
| 184 | + filter_field = 'option2_value' | |
| 185 | + elif option3_name and option3_name.lower() == dimension_lower: | |
| 186 | + filter_field = 'option3_value' | |
| 187 | + | |
| 188 | + # If no matching field found, return all SKUs (no filtering) | |
| 189 | + if not filter_field: | |
| 190 | + return skus | |
| 191 | + | |
| 192 | + # Group SKUs by dimension value and select first one from each group | |
| 193 | + dimension_groups: Dict[str, SkuResult] = {} | |
| 194 | + | |
| 195 | + for sku in skus: | |
| 196 | + # Get dimension value from the determined field | |
| 197 | + dimension_value = None | |
| 198 | + if filter_field == 'option1_value': | |
| 199 | + dimension_value = sku.option1_value | |
| 200 | + elif filter_field == 'option2_value': | |
| 201 | + dimension_value = sku.option2_value | |
| 202 | + elif filter_field == 'option3_value': | |
| 203 | + dimension_value = sku.option3_value | |
| 204 | + | |
| 205 | + # Use empty string as key for None values | |
| 206 | + key = str(dimension_value) if dimension_value is not None else '' | |
| 207 | + | |
| 208 | + # Keep first SKU for each dimension value | |
| 209 | + if key not in dimension_groups: | |
| 210 | + dimension_groups[key] = sku | |
| 211 | + | |
| 212 | + # Return filtered SKUs (one per dimension value) | |
| 213 | + return list(dimension_groups.values()) | |
| 214 | + | |
| 215 | + @staticmethod | |
| 122 | 216 | def format_facets( |
| 123 | 217 | es_aggregations: Dict[str, Any], |
| 124 | 218 | facet_configs: Optional[List[Any]] = None | ... | ... |
api/routes/search.py
| ... | ... | @@ -96,6 +96,7 @@ async def search(request: SearchRequest, http_request: Request): |
| 96 | 96 | sort_order=request.sort_order, |
| 97 | 97 | debug=request.debug, |
| 98 | 98 | language=request.language, |
| 99 | + sku_filter_dimension=request.sku_filter_dimension, | |
| 99 | 100 | ) |
| 100 | 101 | |
| 101 | 102 | # Include performance summary in response |
| ... | ... | @@ -291,7 +292,8 @@ async def instant_search( |
| 291 | 292 | query=q, |
| 292 | 293 | tenant_id=tenant_id, |
| 293 | 294 | size=size, |
| 294 | - from_=0 | |
| 295 | + from_=0, | |
| 296 | + sku_filter_dimension=None # Instant search doesn't support SKU filtering | |
| 295 | 297 | ) |
| 296 | 298 | |
| 297 | 299 | return SearchResponse( | ... | ... |
docs/搜索API对接指南.md
| ... | ... | @@ -105,6 +105,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 105 | 105 | "sort_by": "string", |
| 106 | 106 | "sort_order": "desc", |
| 107 | 107 | "min_score": 0.0, |
| 108 | + "sku_filter_dimension": "string", | |
| 108 | 109 | "debug": false, |
| 109 | 110 | "user_id": "string", |
| 110 | 111 | "session_id": "string" |
| ... | ... | @@ -127,6 +128,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 127 | 128 | | `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) | |
| 128 | 129 | | `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) | |
| 129 | 130 | | `min_score` | float | N | null | 最小相关性分数阈值 | |
| 131 | +| `sku_filter_dimension` | string | N | null | 子SKU筛选维度(店铺配置)。指定后,每个SPU下的SKU将按该维度分组,每组选择第一个SKU返回。支持的值:`option1`、`option2`、`option3` 或 specifications 中的 name(如 `color`、`size`)。详见下文说明 | | |
| 130 | 132 | | `debug` | boolean | N | false | 是否返回调试信息 | |
| 131 | 133 | | `user_id` | string | N | null | 用户ID(用于个性化,预留) | |
| 132 | 134 | | `session_id` | string | N | null | 会话ID(用于分析,预留) | |
| ... | ... | @@ -326,6 +328,51 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 326 | 328 | - `type`: 分面类型,`terms`(分组统计)或 `range`(范围统计) |
| 327 | 329 | - `ranges`: 范围定义(仅当 type='range' 时需要) |
| 328 | 330 | |
| 331 | +### SKU筛选维度 (sku_filter_dimension) | |
| 332 | + | |
| 333 | +**功能说明**: | |
| 334 | +`sku_filter_dimension` 用于控制每个SPU下返回的SKU数量。当指定此参数后,系统会按指定维度对SKU进行分组,每个分组只返回第一个SKU(从简实现,选择该维度下的第一款)。 | |
| 335 | + | |
| 336 | +**使用场景**: | |
| 337 | +- 店铺配置了SKU筛选维度(如 `color`),希望每个SPU下每种颜色只显示一个SKU | |
| 338 | +- 减少前端展示的SKU数量,提升页面加载性能 | |
| 339 | +- 避免展示过多重复的SKU选项 | |
| 340 | + | |
| 341 | +**支持的维度值**: | |
| 342 | +1. **直接选项字段**: `option1`、`option2`、`option3` | |
| 343 | + - 直接使用对应的 `option1_value`、`option2_value`、`option3_value` 字段进行分组 | |
| 344 | + | |
| 345 | +2. **规格名称**: 通过 `option1_name`、`option2_name`、`option3_name` 匹配 | |
| 346 | + - 例如:如果 `option1_name` 为 `"color"`,则可以使用 `sku_filter_dimension: "color"` 来按颜色分组 | |
| 347 | + | |
| 348 | +**示例**: | |
| 349 | + | |
| 350 | +**按颜色筛选(假设 option1_name = "color")**: | |
| 351 | +```json | |
| 352 | +{ | |
| 353 | + "query": "芭比娃娃", | |
| 354 | + "sku_filter_dimension": "color" | |
| 355 | +} | |
| 356 | +``` | |
| 357 | + | |
| 358 | +**按选项1筛选**: | |
| 359 | +```json | |
| 360 | +{ | |
| 361 | + "query": "芭比娃娃", | |
| 362 | + "sku_filter_dimension": "option1" | |
| 363 | +} | |
| 364 | +``` | |
| 365 | + | |
| 366 | +**按选项2筛选**: | |
| 367 | +```json | |
| 368 | +{ | |
| 369 | + "query": "芭比娃娃", | |
| 370 | + "sku_filter_dimension": "option2" | |
| 371 | +} | |
| 372 | +``` | |
| 373 | + | |
| 374 | +--- | |
| 375 | + | |
| 329 | 376 | ### 布尔表达式语法 |
| 330 | 377 | |
| 331 | 378 | 搜索查询支持布尔表达式,提供更灵活的搜索能力。 |
| ... | ... | @@ -467,7 +514,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 467 | 514 | | `results[].spu_id` | string | SPU ID | |
| 468 | 515 | | `results[].title` | string | 商品标题 | |
| 469 | 516 | | `results[].price` | float | 价格(min_price) | |
| 470 | -| `results[].skus` | array | SKU列表 | | |
| 517 | +| `results[].skus` | array | SKU列表(如果指定了`sku_filter_dimension`,则按维度过滤后的SKU) | | |
| 471 | 518 | | `results[].relevance_score` | float | 相关性分数 | |
| 472 | 519 | | `total` | integer | 匹配的总文档数 | |
| 473 | 520 | | `max_score` | float | 最高相关性分数 | |
| ... | ... | @@ -548,7 +595,24 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 548 | 595 | } |
| 549 | 596 | ``` |
| 550 | 597 | |
| 551 | -### 场景2:带筛选的商品搜索 | |
| 598 | +### 场景2:SKU筛选(按维度过滤) | |
| 599 | + | |
| 600 | +**需求**: 搜索"芭比娃娃",每个SPU下按颜色筛选,每种颜色只显示一个SKU | |
| 601 | + | |
| 602 | +```json | |
| 603 | +{ | |
| 604 | + "query": "芭比娃娃", | |
| 605 | + "size": 20, | |
| 606 | + "sku_filter_dimension": "color" | |
| 607 | +} | |
| 608 | +``` | |
| 609 | + | |
| 610 | +**说明**: | |
| 611 | +- 如果 `option1_name` 为 `"color"`,则使用 `sku_filter_dimension: "color"` 可以按颜色分组 | |
| 612 | +- 每个SPU下,每种颜色只会返回第一个SKU | |
| 613 | +- 如果维度不匹配,返回所有SKU(不进行过滤) | |
| 614 | + | |
| 615 | +### 场景3:带筛选的商品搜索 | |
| 552 | 616 | |
| 553 | 617 | **需求**: 搜索"玩具",筛选类目为"益智玩具",价格在50-200之间 |
| 554 | 618 | |
| ... | ... | @@ -569,7 +633,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 569 | 633 | } |
| 570 | 634 | ``` |
| 571 | 635 | |
| 572 | -### 场景3:带分面的商品搜索 | |
| 636 | +### 场景4:带分面的商品搜索 | |
| 573 | 637 | |
| 574 | 638 | **需求**: 搜索"玩具",获取类目和品牌的分面统计,用于构建筛选器 |
| 575 | 639 | |
| ... | ... | @@ -586,7 +650,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 586 | 650 | } |
| 587 | 651 | ``` |
| 588 | 652 | |
| 589 | -### 场景4:多条件组合搜索 | |
| 653 | +### 场景5:多条件组合搜索 | |
| 590 | 654 | |
| 591 | 655 | **需求**: 搜索"手机",筛选多个品牌,价格范围,并获取分面统计 |
| 592 | 656 | |
| ... | ... | @@ -626,7 +690,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 626 | 690 | } |
| 627 | 691 | ``` |
| 628 | 692 | |
| 629 | -### 场景5:规格过滤搜索 | |
| 693 | +### 场景6:规格过滤搜索 | |
| 630 | 694 | |
| 631 | 695 | **需求**: 搜索"手机",筛选color为"white"的商品 |
| 632 | 696 | |
| ... | ... | @@ -644,7 +708,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 644 | 708 | } |
| 645 | 709 | ``` |
| 646 | 710 | |
| 647 | -### 场景6:多个规格过滤(OR逻辑) | |
| 711 | +### 场景7:多个规格过滤(OR逻辑) | |
| 648 | 712 | |
| 649 | 713 | **需求**: 搜索"手机",筛选color为"white"或size为"256GB"的商品 |
| 650 | 714 | |
| ... | ... | @@ -662,7 +726,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 662 | 726 | } |
| 663 | 727 | ``` |
| 664 | 728 | |
| 665 | -### 场景7:规格分面搜索 | |
| 729 | +### 场景8:规格分面搜索 | |
| 666 | 730 | |
| 667 | 731 | **需求**: 搜索"手机",获取所有规格的分面统计 |
| 668 | 732 | |
| ... | ... | @@ -686,7 +750,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 686 | 750 | } |
| 687 | 751 | ``` |
| 688 | 752 | |
| 689 | -### 场景8:组合过滤和分面 | |
| 753 | +### 场景9:组合过滤和分面 | |
| 690 | 754 | |
| 691 | 755 | **需求**: 搜索"手机",筛选类目和规格,并获取对应的分面统计 |
| 692 | 756 | |
| ... | ... | @@ -711,7 +775,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 711 | 775 | } |
| 712 | 776 | ``` |
| 713 | 777 | |
| 714 | -### 场景9:布尔表达式搜索 | |
| 778 | +### 场景10:布尔表达式搜索 | |
| 715 | 779 | |
| 716 | 780 | **需求**: 搜索包含"手机"和"智能"的商品,排除"二手" |
| 717 | 781 | |
| ... | ... | @@ -722,7 +786,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ |
| 722 | 786 | } |
| 723 | 787 | ``` |
| 724 | 788 | |
| 725 | -### 场景10:分页查询 | |
| 789 | +### 场景11:分页查询 | |
| 726 | 790 | |
| 727 | 791 | **需求**: 获取第2页结果(每页20条) |
| 728 | 792 | ... | ... |
docs/搜索API速查表.md
| ... | ... | @@ -121,6 +121,37 @@ POST /search/ |
| 121 | 121 | |
| 122 | 122 | --- |
| 123 | 123 | |
| 124 | +## SKU筛选维度 | |
| 125 | + | |
| 126 | +**功能**: 按指定维度对每个SPU下的SKU进行分组,每组只返回第一个SKU。 | |
| 127 | + | |
| 128 | +```bash | |
| 129 | +{ | |
| 130 | + "query": "芭比娃娃", | |
| 131 | + "sku_filter_dimension": "color" // 按颜色筛选(假设option1_name="color") | |
| 132 | +} | |
| 133 | +``` | |
| 134 | + | |
| 135 | +**支持的维度值**: | |
| 136 | +- `option1`, `option2`, `option3`: 直接使用选项字段 | |
| 137 | +- 规格名称(如 `color`, `size`): 通过 `option1_name`、`option2_name`、`option3_name` 匹配 | |
| 138 | + | |
| 139 | +**示例**: | |
| 140 | +```bash | |
| 141 | +// 按选项1筛选 | |
| 142 | +{"sku_filter_dimension": "option1"} | |
| 143 | + | |
| 144 | +// 按颜色筛选(如果option1_name="color") | |
| 145 | +{"sku_filter_dimension": "color"} | |
| 146 | + | |
| 147 | +// 按尺寸筛选(如果option2_name="size") | |
| 148 | +{"sku_filter_dimension": "size"} | |
| 149 | +``` | |
| 150 | + | |
| 151 | +**性能说明**: 在应用层过滤,不影响ES查询性能,只对返回结果进行过滤。 | |
| 152 | + | |
| 153 | +--- | |
| 154 | + | |
| 124 | 155 | ## 排序 |
| 125 | 156 | |
| 126 | 157 | ```bash |
| ... | ... | @@ -180,7 +211,8 @@ Headers: X-Tenant-ID: 2 |
| 180 | 211 | "specifications.size" |
| 181 | 212 | ], |
| 182 | 213 | "sort_by": "min_price", |
| 183 | - "sort_order": "asc" | |
| 214 | + "sort_order": "asc", | |
| 215 | + "sku_filter_dimension": "color" // 可选:按颜色筛选SKU | |
| 184 | 216 | } |
| 185 | 217 | ``` |
| 186 | 218 | |
| ... | ... | @@ -211,7 +243,7 @@ Headers: X-Tenant-ID: 2 |
| 211 | 243 | "specifications": [ |
| 212 | 244 | {"sku_id": "sku_001", "name": "color", "value": "white"} |
| 213 | 245 | ], |
| 214 | - "skus": [...], | |
| 246 | + "skus": [...], // 如果指定了sku_filter_dimension,则返回过滤后的SKU(每个维度值一个) | |
| 215 | 247 | "relevance_score": 8.5 |
| 216 | 248 | } |
| 217 | 249 | ], | ... | ... |
docs/系统设计文档.md
| ... | ... | @@ -31,124 +31,154 @@ |
| 31 | 31 | { |
| 32 | 32 | "tenant_id": "1", |
| 33 | 33 | "spu_id": "123", |
| 34 | - "title": "蓝牙耳机", | |
| 34 | + "title_zh": "蓝牙耳机", | |
| 35 | + "title_en": "Bluetooth Headphones", | |
| 36 | + "brief_zh": "高品质蓝牙耳机", | |
| 37 | + "brief_en": "High-quality Bluetooth headphones", | |
| 38 | + "category_name": "电子产品", | |
| 39 | + "category_path_zh": "电子产品/音频设备/耳机", | |
| 40 | + "category_path_en": "Electronics/Audio/Headphones", | |
| 41 | + "category1_name": "电子产品", | |
| 42 | + "category2_name": "音频设备", | |
| 43 | + "category3_name": "耳机", | |
| 44 | + "vendor_zh": "品牌A", | |
| 45 | + "vendor_en": "Brand A", | |
| 46 | + "min_price": 199.99, | |
| 47 | + "max_price": 299.99, | |
| 48 | + "option1_name": "color", | |
| 49 | + "option2_name": "size", | |
| 50 | + "specifications": [ | |
| 51 | + { | |
| 52 | + "sku_id": "456", | |
| 53 | + "name": "color", | |
| 54 | + "value": "black" | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "sku_id": "456", | |
| 58 | + "name": "size", | |
| 59 | + "value": "large" | |
| 60 | + } | |
| 61 | + ], | |
| 35 | 62 | "skus": [ |
| 36 | 63 | { |
| 37 | 64 | "sku_id": "456", |
| 38 | - "title": "黑色", | |
| 39 | 65 | "price": 199.99, |
| 40 | - "sku": "SKU-123-1", | |
| 41 | - "stock": 50 | |
| 66 | + "compare_at_price": 249.99, | |
| 67 | + "sku_code": "SKU-123-1", | |
| 68 | + "stock": 50, | |
| 69 | + "weight": 0.2, | |
| 70 | + "weight_unit": "kg", | |
| 71 | + "option1_value": "black", | |
| 72 | + "option2_value": "large", | |
| 73 | + "option3_value": null, | |
| 74 | + "image_src": "https://example.com/image.jpg" | |
| 42 | 75 | } |
| 43 | 76 | ], |
| 44 | - "min_price": 199.99, | |
| 45 | - "max_price": 299.99 | |
| 77 | + "title_embedding": [0.1, 0.2, ...], // 1024维向量 | |
| 78 | + "image_embedding": [ | |
| 79 | + { | |
| 80 | + "vector": [0.1, 0.2, ...], // 1024维向量 | |
| 81 | + "url": "https://example.com/image.jpg" | |
| 82 | + } | |
| 83 | + ] | |
| 46 | 84 | } |
| 47 | 85 | ``` |
| 48 | 86 | |
| 49 | -### 1.3 配置化方案 | |
| 87 | +### 1.3 索引结构简化方案 | |
| 50 | 88 | |
| 51 | -**配置分离原则**: | |
| 52 | -- **搜索配置**:只包含ES字段定义、查询域、排序规则等搜索相关配置 | |
| 53 | -- **数据源配置**:不在搜索配置中,由Pipeline层(脚本)决定 | |
| 54 | -- **数据导入流程**:写死的脚本,不依赖配置 | |
| 89 | +**简化原则**: | |
| 90 | +- **硬编码映射**:ES mapping 结构直接定义在 JSON 文件中(`mappings/search_products.json`) | |
| 91 | +- **统一索引结构**:所有租户共享相同的索引结构,通过 `tenant_id` 隔离数据 | |
| 92 | +- **数据源统一**:所有租户使用相同的 MySQL 表结构(店匠标准表) | |
| 93 | +- **查询配置硬编码**:查询相关配置(字段 boost、查询域等)硬编码在 `search/query_config.py` | |
| 55 | 94 | |
| 56 | -统一通过配置文件定义: | |
| 57 | -1. ES 字段定义(字段类型、分析器、boost等) | |
| 58 | -2. ES mapping 结构生成 | |
| 59 | -3. 查询域配置(indexes) | |
| 60 | -4. 排序和打分配置(function_score) | |
| 95 | +**索引结构特点**: | |
| 96 | +1. **多语言字段**:所有文本字段支持中英文(`title_zh/en`, `brief_zh/en`, `description_zh/en`, `vendor_zh/en`, `category_path_zh/en`, `category_name_zh/en`) | |
| 97 | +2. **嵌套字段**: | |
| 98 | + - `skus`: SKU 嵌套数组(包含价格、库存、选项值等) | |
| 99 | + - `specifications`: 规格嵌套数组(包含 name、value、sku_id) | |
| 100 | + - `image_embedding`: 图片向量嵌套数组 | |
| 101 | +3. **扁平化字段**:`sku_prices`, `sku_weights`, `total_inventory` 等用于过滤和排序 | |
| 102 | +4. **向量字段**:`title_embedding`(1024维)用于语义搜索 | |
| 61 | 103 | |
| 62 | -**注意**:配置中**不包含**以下内容: | |
| 63 | -- `mysql_config` - MySQL数据库配置 | |
| 64 | -- `main_table` / `extension_table` - 数据表配置 | |
| 65 | -- `source_table` / `source_column` - 字段数据源映射 | |
| 104 | +**实现文件**: | |
| 105 | +- `mappings/search_products.json` - ES mapping 定义(硬编码) | |
| 106 | +- `search/query_config.py` - 查询配置(硬编码) | |
| 107 | +- `indexer/mapping_generator.py` - 加载 JSON mapping 并创建索引 | |
| 66 | 108 | |
| 67 | 109 | --- |
| 68 | 110 | |
| 69 | -## 2. 配置系统实现 | |
| 70 | - | |
| 71 | -### 2.1 应用结构配置(字段定义) | |
| 72 | - | |
| 73 | -**配置文件位置**:`config/schema/{tenant_id}_config.yaml` | |
| 74 | - | |
| 75 | -**配置内容**:定义了 ES 的输入数据有哪些字段、关联 MySQL 的哪些字段。 | |
| 76 | - | |
| 77 | -**实现情况**: | |
| 78 | - | |
| 79 | -#### 字段类型支持 | |
| 80 | -- **TEXT**:文本字段,支持多语言分析器 | |
| 81 | -- **KEYWORD**:关键词字段,用于精确匹配和聚合 | |
| 82 | -- **TEXT_EMBEDDING**:文本向量字段(1024维,dot_product相似度) | |
| 83 | -- **IMAGE_EMBEDDING**:图片向量字段(1024维,dot_product相似度) | |
| 84 | -- **INT/LONG**:整数类型 | |
| 85 | -- **FLOAT/DOUBLE**:浮点数类型 | |
| 86 | -- **DATE**:日期类型 | |
| 87 | -- **BOOLEAN**:布尔类型 | |
| 88 | - | |
| 89 | -#### 分析器支持 | |
| 90 | -- **chinese_ecommerce**:中文电商分词器(index_ansj/query_ansj) | |
| 91 | -- **english**:英文分析器 | |
| 92 | -- **russian**:俄文分析器 | |
| 93 | -- **arabic**:阿拉伯文分析器 | |
| 94 | -- **spanish**:西班牙文分析器 | |
| 95 | -- **japanese**:日文分析器 | |
| 96 | -- **standard**:标准分析器 | |
| 97 | -- **keyword**:关键词分析器 | |
| 98 | - | |
| 99 | -#### 字段配置示例(Base配置) | |
| 100 | - | |
| 101 | -```yaml | |
| 102 | -fields: | |
| 103 | - # 租户隔离字段(必需) | |
| 104 | - - name: "tenant_id" | |
| 105 | - type: "KEYWORD" | |
| 106 | - required: true | |
| 107 | - index: true | |
| 108 | - store: true | |
| 109 | - | |
| 110 | - # 商品标识字段 | |
| 111 | - - name: "spu_id" | |
| 112 | - type: "KEYWORD" | |
| 113 | - required: true | |
| 114 | - index: true | |
| 115 | - store: true | |
| 116 | - | |
| 117 | - # 文本搜索字段 | |
| 118 | - - name: "title" | |
| 119 | - type: "TEXT" | |
| 120 | - analyzer: "chinese_ecommerce" | |
| 121 | - boost: 3.0 | |
| 122 | - index: true | |
| 123 | - store: true | |
| 124 | - | |
| 125 | - - name: "seo_keywords" | |
| 126 | - type: "TEXT" | |
| 127 | - analyzer: "chinese_ecommerce" | |
| 128 | - boost: 2.0 | |
| 129 | - index: true | |
| 130 | - store: true | |
| 131 | - | |
| 132 | - # 嵌套skus字段 | |
| 133 | - - name: "skus" | |
| 134 | - type: "JSON" | |
| 135 | - nested: true | |
| 136 | - nested_properties: | |
| 137 | - sku_id: | |
| 138 | - type: "keyword" | |
| 139 | - price: | |
| 140 | - type: "float" | |
| 141 | - sku: | |
| 142 | - type: "keyword" | |
| 143 | -``` | |
| 144 | - | |
| 145 | -**注意**:配置中**不包含**`source_table`和`source_column`,数据源映射由Pipeline层决定。 | |
| 111 | +## 2. 索引结构实现 | |
| 112 | + | |
| 113 | +### 2.1 硬编码映射方案 | |
| 114 | + | |
| 115 | +**实现方式**: | |
| 116 | +- ES mapping 直接定义在 `mappings/search_products.json` 文件中 | |
| 117 | +- 所有租户共享相同的索引结构 | |
| 118 | +- 查询配置硬编码在 `search/query_config.py` | |
| 119 | + | |
| 120 | +**索引字段结构**: | |
| 121 | + | |
| 122 | +#### 基础字段 | |
| 123 | +- `tenant_id` (keyword): 租户ID,用于数据隔离 | |
| 124 | +- `spu_id` (keyword): SPU唯一标识 | |
| 125 | +- `create_time`, `update_time` (date): 创建和更新时间 | |
| 126 | + | |
| 127 | +#### 多语言文本字段 | |
| 128 | +- `title_zh/en` (text): 标题(中英文) | |
| 129 | +- `brief_zh/en` (text): 短描述(中英文) | |
| 130 | +- `description_zh/en` (text): 详细描述(中英文) | |
| 131 | +- `vendor_zh/en` (text): 供应商/品牌(中英文) | |
| 132 | +- `category_path_zh/en` (text): 类目路径(中英文) | |
| 133 | +- `category_name_zh/en` (text): 类目名称(中英文) | |
| 134 | + | |
| 135 | +**分析器配置**: | |
| 136 | +- 中文字段:`hanlp_index`(索引时)/ `hanlp_standard`(查询时) | |
| 137 | +- 英文字段:`english` | |
| 138 | +- `vendor` 字段包含 `keyword` 子字段(normalizer: lowercase) | |
| 139 | + | |
| 140 | +#### 分类字段 | |
| 141 | +- `category_id` (keyword): 类目ID | |
| 142 | +- `category_name` (keyword): 类目名称 | |
| 143 | +- `category_level` (integer): 类目层级 | |
| 144 | +- `category1_name`, `category2_name`, `category3_name` (keyword): 多级类目名称 | |
| 145 | + | |
| 146 | +#### 规格字段(Specifications) | |
| 147 | +- `specifications` (nested): 规格嵌套数组 | |
| 148 | + - `sku_id` (keyword): SKU ID | |
| 149 | + - `name` (keyword): 规格名称(如 "color", "size") | |
| 150 | + - `value` (keyword): 规格值(如 "white", "256GB") | |
| 151 | + | |
| 152 | +**用途**: | |
| 153 | +- 支持按规格过滤:`{"specifications": {"name": "color", "value": "white"}}` | |
| 154 | +- 支持规格分面:`["specifications"]` 或 `["specifications.color"]` | |
| 155 | + | |
| 156 | +#### SKU嵌套字段 | |
| 157 | +- `skus` (nested): SKU嵌套数组 | |
| 158 | + - `sku_id`, `price`, `compare_at_price`, `sku_code` | |
| 159 | + - `stock`, `weight`, `weight_unit` | |
| 160 | + - `option1_value`, `option2_value`, `option3_value` | |
| 161 | + - `image_src` (index: false) | |
| 162 | + | |
| 163 | +#### 选项名称字段 | |
| 164 | +- `option1_name`, `option2_name`, `option3_name` (keyword): 选项名称(如 "color", "size") | |
| 165 | + | |
| 166 | +#### 扁平化字段 | |
| 167 | +- `min_price`, `max_price`, `compare_at_price` (float): 价格字段 | |
| 168 | +- `sku_prices` (float[]): 所有SKU价格数组 | |
| 169 | +- `sku_weights` (long[]): 所有SKU重量数组 | |
| 170 | +- `total_inventory` (long): 总库存 | |
| 171 | + | |
| 172 | +#### 向量字段 | |
| 173 | +- `title_embedding` (dense_vector, 1024维): 标题向量,用于语义搜索 | |
| 174 | +- `image_embedding` (nested): 图片向量数组 | |
| 175 | + - `vector` (dense_vector, 1024维) | |
| 176 | + - `url` (text) | |
| 146 | 177 | |
| 147 | 178 | **实现模块**: |
| 148 | -- `config/config_loader.py` - 配置加载器 | |
| 149 | -- `config/field_types.py` - 字段类型定义 | |
| 150 | -- `indexer/mapping_generator.py` - ES mapping 生成器 | |
| 151 | -- `indexer/data_transformer.py` - 数据转换器 | |
| 179 | +- `mappings/search_products.json` - ES mapping 定义 | |
| 180 | +- `indexer/mapping_generator.py` - 加载 JSON mapping 并创建索引 | |
| 181 | +- `search/query_config.py` - 查询配置(字段 boost、查询域等) | |
| 152 | 182 | |
| 153 | 183 | ### 2.2 索引结构配置(查询域配置) |
| 154 | 184 | |
| ... | ... | @@ -217,6 +247,7 @@ indexes: |
| 217 | 247 | **实现模块**: |
| 218 | 248 | - `search/es_query_builder.py` - ES 查询构建器(单层架构) |
| 219 | 249 | - `query/query_parser.py` - 查询解析器(支持语言检测和翻译) |
| 250 | +- `search/query_config.py` - 查询配置(字段 boost、查询域等) | |
| 220 | 251 | |
| 221 | 252 | --- |
| 222 | 253 | |
| ... | ... | @@ -233,26 +264,47 @@ indexes: |
| 233 | 264 | |
| 234 | 265 | ### 3.2 数据导入方式 |
| 235 | 266 | |
| 236 | -**Pipeline层决定数据源**: | |
| 237 | -- 数据导入流程是写死的脚本,不依赖配置 | |
| 238 | -- 配置只关注ES搜索相关的内容 | |
| 239 | -- 数据源映射逻辑写死在转换器代码中 | |
| 267 | +**数据源统一**: | |
| 268 | +- 所有租户使用相同的MySQL表结构(店匠标准表) | |
| 269 | +- 数据转换逻辑写死在转换器代码中 | |
| 270 | +- 索引结构硬编码,不依赖配置 | |
| 240 | 271 | |
| 241 | -#### Base配置数据导入(店匠通用) | |
| 272 | +#### 数据导入流程(店匠通用) | |
| 242 | 273 | |
| 243 | 274 | **脚本**:`scripts/ingest_shoplazza.py` |
| 244 | 275 | |
| 245 | 276 | **数据流程**: |
| 246 | -1. **数据加载**:从MySQL读取`shoplazza_product_spu`和`shoplazza_product_sku`表 | |
| 277 | +1. **数据加载**: | |
| 278 | + - 从MySQL读取`shoplazza_product_spu`表(SPU数据) | |
| 279 | + - 从MySQL读取`shoplazza_product_sku`表(SKU数据) | |
| 280 | + - 从MySQL读取`shoplazza_product_option`表(选项定义) | |
| 281 | + | |
| 247 | 282 | 2. **数据转换**(`indexer/spu_transformer.py`): |
| 248 | 283 | - 按`spu_id`和`tenant_id`关联SPU和SKU数据 |
| 249 | - - 将SKU数据聚合为嵌套的`skus`数组 | |
| 250 | - - 计算扁平化价格字段(`min_price`, `max_price`, `compare_at_price`) | |
| 251 | - - 字段映射(写死在代码中,不依赖配置) | |
| 284 | + - **多语言字段映射**: | |
| 285 | + - MySQL的`title` → ES的`title_zh`(英文字段设为空) | |
| 286 | + - 其他文本字段类似处理 | |
| 287 | + - **分类字段映射**: | |
| 288 | + - 从SPU表的`category_path`解析多级类目(`category1_name`, `category2_name`, `category3_name`) | |
| 289 | + - 映射`category_id`, `category_name`, `category_level` | |
| 290 | + - **规格字段构建**(`specifications`): | |
| 291 | + - 从`shoplazza_product_option`表获取选项名称(`name`) | |
| 292 | + - 从SKU的`option1/2/3`字段获取选项值(`value`) | |
| 293 | + - 构建嵌套数组:`[{"sku_id": "...", "name": "color", "value": "white"}, ...]` | |
| 294 | + - **选项名称映射**: | |
| 295 | + - 从`shoplazza_product_option`表获取`option1_name`, `option2_name`, `option3_name` | |
| 296 | + - **SKU嵌套数组构建**: | |
| 297 | + - 包含所有SKU字段(价格、库存、选项值、图片等) | |
| 298 | + - **扁平化字段计算**: | |
| 299 | + - `min_price`, `max_price`: 从所有SKU价格计算 | |
| 300 | + - `sku_prices`: 所有SKU价格数组 | |
| 301 | + - `total_inventory`: SKU库存总和 | |
| 252 | 302 | - 注入`tenant_id`字段 |
| 303 | + | |
| 253 | 304 | 3. **索引创建**: |
| 254 | - - 根据配置生成ES mapping | |
| 305 | + - 从`mappings/search_products.json`加载ES mapping | |
| 255 | 306 | - 创建或更新`search_products`索引 |
| 307 | + | |
| 256 | 308 | 4. **批量入库**: |
| 257 | 309 | - 批量写入ES(默认每批500条) |
| 258 | 310 | - 错误处理和重试机制 |
| ... | ... | @@ -424,12 +476,19 @@ laptop AND (gaming OR professional) ANDNOT cheap |
| 424 | 476 | 2. **查询构建**(简化架构): |
| 425 | 477 | - **结构**: `filters AND (text_recall OR embedding_recall)` |
| 426 | 478 | - **filters**: 前端传递的过滤条件(永远起作用,放在 `filter` 中) |
| 479 | + - 普通字段过滤:`{"category_name": "手机"}` | |
| 480 | + - 范围过滤:`{"min_price": {"gte": 50, "lte": 200}}` | |
| 481 | + - **Specifications嵌套过滤**: | |
| 482 | + - 单个规格:`{"specifications": {"name": "color", "value": "white"}}` | |
| 483 | + - 多个规格(OR):`{"specifications": [{"name": "color", "value": "white"}, {"name": "size", "value": "256GB"}]}` | |
| 484 | + - 使用ES的`nested`查询实现 | |
| 427 | 485 | - **text_recall**: 文本相关性召回 |
| 428 | 486 | - 同时搜索中英文字段(`title_zh/en`, `brief_zh/en`, `description_zh/en`, `vendor_zh/en`, `category_path_zh/en`, `category_name_zh/en`, `tags`) |
| 429 | 487 | - 使用 `multi_match` 查询,支持字段 boost |
| 488 | + - 中文字段使用中文分词器,英文字段使用英文分析器 | |
| 430 | 489 | - **embedding_recall**: 向量召回(KNN) |
| 431 | 490 | - 使用 `title_embedding` 字段进行 KNN 搜索 |
| 432 | - - ES 自动与文本召回合并 | |
| 491 | + - ES 自动与文本召回合并(OR逻辑) | |
| 433 | 492 | - **function_score**: 包装召回部分,支持提权字段(新鲜度、销量等) |
| 434 | 493 | |
| 435 | 494 | #### 查询结构示例 |
| ... | ... | @@ -530,10 +589,13 @@ ranking: |
| 530 | 589 | |
| 531 | 590 | ### 6.4 搜索功能 |
| 532 | 591 | - ✅ 布尔表达式解析(AND, OR, RANK, ANDNOT, 括号) |
| 533 | -- ✅ 多语言查询构建(语言路由、字段映射) | |
| 592 | +- ✅ 多语言查询构建(同时搜索中英文字段) | |
| 534 | 593 | - ✅ 语义搜索(KNN 检索) |
| 535 | 594 | - ✅ 相关性排序(BM25 + 向量相似度) |
| 536 | 595 | - ✅ 结果聚合(Faceted Search) |
| 596 | +- ✅ Specifications嵌套过滤(单个和多个规格,OR逻辑) | |
| 597 | +- ✅ Specifications嵌套分面(所有规格名称和指定规格名称) | |
| 598 | +- ✅ SKU筛选(按维度过滤,应用层实现) | |
| 537 | 599 | |
| 538 | 600 | ### 6.5 API 服务 |
| 539 | 601 | - ✅ RESTful API(FastAPI) |
| ... | ... | @@ -542,12 +604,16 @@ ranking: |
| 542 | 604 | - ✅ 前端界面(HTML + JavaScript) |
| 543 | 605 | - ✅ 租户隔离(tenant_id过滤) |
| 544 | 606 | |
| 545 | -### 6.6 Base配置(店匠通用) | |
| 607 | +### 6.6 索引结构(店匠通用) | |
| 546 | 608 | - ✅ SPU级别索引结构 |
| 547 | -- ✅ 嵌套skus字段 | |
| 609 | +- ✅ 多语言字段支持(中英文) | |
| 610 | +- ✅ 嵌套字段(skus, specifications, image_embedding) | |
| 611 | +- ✅ 规格字段(specifications)支持过滤和分面 | |
| 612 | +- ✅ 扁平化字段(价格、库存等)用于过滤和排序 | |
| 548 | 613 | - ✅ 统一索引(search_products) |
| 549 | 614 | - ✅ 租户隔离(tenant_id) |
| 550 | -- ✅ 配置简化(移除MySQL相关配置) | |
| 615 | +- ✅ 硬编码映射(mappings/search_products.json) | |
| 616 | +- ✅ 硬编码查询配置(search/query_config.py) | |
| 551 | 617 | |
| 552 | 618 | --- |
| 553 | 619 | |
| ... | ... | @@ -636,25 +702,30 @@ Elasticsearch |
| 636 | 702 | |
| 637 | 703 | - **简单模式**:字符串列表(字段名),使用默认配置 |
| 638 | 704 | ```json |
| 639 | - ["category.keyword", "vendor.keyword"] | |
| 705 | + ["category1_name", "category2_name", "specifications"] | |
| 640 | 706 | ``` |
| 641 | 707 | |
| 708 | +- **Specifications分面**: | |
| 709 | + - 所有规格名称:`"specifications"` - 返回所有name及其value列表 | |
| 710 | + - 指定规格名称:`"specifications.color"` - 只返回指定name的value列表 | |
| 711 | + | |
| 642 | 712 | - **高级模式**:FacetConfig 对象列表,支持自定义配置 |
| 643 | 713 | ```json |
| 644 | 714 | [ |
| 645 | 715 | { |
| 646 | - "field": "category.keyword", | |
| 716 | + "field": "category1_name", | |
| 647 | 717 | "size": 15, |
| 648 | 718 | "type": "terms" |
| 649 | 719 | }, |
| 650 | 720 | { |
| 651 | - "field": "price", | |
| 721 | + "field": "min_price", | |
| 652 | 722 | "type": "range", |
| 653 | 723 | "ranges": [ |
| 654 | 724 | {"key": "0-50", "to": 50}, |
| 655 | 725 | {"key": "50-100", "from": 50, "to": 100} |
| 656 | 726 | ] |
| 657 | - } | |
| 727 | + }, | |
| 728 | + "specifications.color" // 指定规格名称的分面 | |
| 658 | 729 | ] |
| 659 | 730 | ``` |
| 660 | 731 | |
| ... | ... | @@ -662,7 +733,10 @@ Elasticsearch |
| 662 | 733 | 1. API 层:接收 `List[Union[str, FacetConfig]]` |
| 663 | 734 | 2. Searcher 层:透传,不做转换 |
| 664 | 735 | 3. ES Query Builder:只接受 `str` 或 `FacetConfig`,自动处理两种格式 |
| 665 | -4. 输出:转换为 ES 聚合查询 | |
| 736 | + - 检测 `"specifications"` 或 `"specifications.{name}"` 格式 | |
| 737 | + - 构建对应的嵌套聚合查询 | |
| 738 | +4. 输出:转换为 ES 聚合查询(包括specifications嵌套聚合) | |
| 739 | +5. Result Formatter:格式化ES聚合结果,处理specifications嵌套结构 | |
| 666 | 740 | |
| 667 | 741 | #### 8.3.3 Range Filters 数据流 |
| 668 | 742 | |
| ... | ... | @@ -680,8 +754,8 @@ class RangeFilter(BaseModel): |
| 680 | 754 | **示例**: |
| 681 | 755 | ```json |
| 682 | 756 | { |
| 683 | - "price": {"gte": 50, "lte": 200}, | |
| 684 | - "created_at": {"gte": "2023-01-01T00:00:00Z"} | |
| 757 | + "min_price": {"gte": 50, "lte": 200}, | |
| 758 | + "create_time": {"gte": "2023-01-01T00:00:00Z"} | |
| 685 | 759 | } |
| 686 | 760 | ``` |
| 687 | 761 | |
| ... | ... | @@ -696,6 +770,38 @@ class RangeFilter(BaseModel): |
| 696 | 770 | - 类型支持:支持数值(float)和日期时间字符串(ISO 格式) |
| 697 | 771 | - 统一约定:所有范围过滤都使用 RangeFilter 模型 |
| 698 | 772 | |
| 773 | +#### 8.3.3.1 Specifications 过滤数据流 | |
| 774 | + | |
| 775 | +**输入格式**:`Dict[str, Union[Dict[str, str], List[Dict[str, str]]]]` | |
| 776 | + | |
| 777 | +**单个规格过滤**: | |
| 778 | +```json | |
| 779 | +{ | |
| 780 | + "specifications": { | |
| 781 | + "name": "color", | |
| 782 | + "value": "white" | |
| 783 | + } | |
| 784 | +} | |
| 785 | +``` | |
| 786 | + | |
| 787 | +**多个规格过滤(OR逻辑)**: | |
| 788 | +```json | |
| 789 | +{ | |
| 790 | + "specifications": [ | |
| 791 | + {"name": "color", "value": "white"}, | |
| 792 | + {"name": "size", "value": "256GB"} | |
| 793 | + ] | |
| 794 | +} | |
| 795 | +``` | |
| 796 | + | |
| 797 | +**数据流**: | |
| 798 | +1. API 层:接收 `filters` 字典,检测 `specifications` 键 | |
| 799 | +2. Searcher 层:透传 `filters` 字典 | |
| 800 | +3. ES Query Builder:检测 `specifications` 键,构建ES `nested` 查询 | |
| 801 | + - 单个规格:构建单个 `nested` 查询 | |
| 802 | + - 多个规格:构建多个 `nested` 查询,使用 `should` 组合(OR逻辑) | |
| 803 | +4. 输出:ES nested 查询(`nested.path=specifications` + `bool.must=[term(name), term(value)]`) | |
| 804 | + | |
| 699 | 805 | #### 8.3.4 响应 Facets 数据流 |
| 700 | 806 | |
| 701 | 807 | **输出格式**:`List[FacetResult]` |
| ... | ... | @@ -711,16 +817,63 @@ class FacetResult(BaseModel): |
| 711 | 817 | ``` |
| 712 | 818 | |
| 713 | 819 | **数据流**: |
| 714 | -1. ES Response:返回聚合结果(字典格式) | |
| 715 | -2. Searcher 层:构建 `List[FacetResult]` 对象 | |
| 716 | -3. API 层:直接返回 `List[FacetResult]`(Pydantic 自动序列化为 JSON) | |
| 820 | +1. ES Response:返回聚合结果(字典格式,包括specifications嵌套聚合) | |
| 821 | +2. Result Formatter:格式化ES聚合结果 | |
| 822 | + - 处理普通terms聚合 | |
| 823 | + - 处理range聚合 | |
| 824 | + - **处理specifications嵌套聚合**: | |
| 825 | + - 所有规格名称:解析 `by_name` 聚合结构 | |
| 826 | + - 指定规格名称:解析 `filter_by_name` 聚合结构 | |
| 827 | +3. Searcher 层:构建 `List[FacetResult]` 对象 | |
| 828 | +4. API 层:直接返回 `List[FacetResult]`(Pydantic 自动序列化为 JSON) | |
| 717 | 829 | |
| 718 | 830 | **优势**: |
| 719 | 831 | - 类型安全:使用 Pydantic 模型确保数据结构一致性 |
| 720 | 832 | - 自动序列化:模型自动转换为 JSON,无需手动处理 |
| 721 | 833 | - 统一约定:所有响应都使用标准化的 Pydantic 模型 |
| 722 | 834 | |
| 723 | -#### 8.3.5 统一约定的好处 | |
| 835 | +#### 8.3.5 SKU筛选数据流 | |
| 836 | + | |
| 837 | +**输入格式**:`Optional[str]` | |
| 838 | + | |
| 839 | +**支持的维度值**: | |
| 840 | +- `option1`, `option2`, `option3`: 直接使用选项字段 | |
| 841 | +- 规格名称(如 `color`, `size`): 通过 `option1_name`、`option2_name`、`option3_name` 匹配 | |
| 842 | + | |
| 843 | +**示例**: | |
| 844 | +```json | |
| 845 | +{ | |
| 846 | + "query": "手机", | |
| 847 | + "sku_filter_dimension": "color" | |
| 848 | +} | |
| 849 | +``` | |
| 850 | + | |
| 851 | +**数据流**: | |
| 852 | +1. API 层:接收 `sku_filter_dimension` 字符串参数 | |
| 853 | +2. Searcher 层:透传到 Result Formatter | |
| 854 | +3. Result Formatter:在格式化结果时,按指定维度对SKU进行分组 | |
| 855 | + - 如果维度是 `option1/2/3`,直接使用对应的 `option1_value/2/3` 字段 | |
| 856 | + - 如果维度是规格名称,通过 `option1_name/2/3` 匹配找到对应的 `option1_value/2/3` | |
| 857 | + - 每个分组选择第一个SKU返回 | |
| 858 | +4. 输出:过滤后的SKU列表(每个维度值一个SKU) | |
| 859 | + | |
| 860 | +**工作原理**: | |
| 861 | +1. 系统从ES返回所有SKU(不改变ES查询,保持性能) | |
| 862 | +2. 在结果格式化阶段,按指定维度对SKU进行分组 | |
| 863 | +3. 每个分组选择第一个SKU返回 | |
| 864 | +4. 如果维度不匹配或未找到,返回所有SKU(不进行过滤) | |
| 865 | + | |
| 866 | +**性能说明**: | |
| 867 | +- ✅ **推荐方案**: 在应用层过滤(当前实现) | |
| 868 | + - ES查询简单,不需要nested查询和join | |
| 869 | + - 只对返回的结果(通常10-20个SPU)进行过滤,数据量小 | |
| 870 | + - 实现简单,性能开销小 | |
| 871 | +- ❌ **不推荐**: 在ES查询时过滤 | |
| 872 | + - 需要nested查询和join,性能开销大 | |
| 873 | + - 实现复杂 | |
| 874 | + - 只对返回的结果需要过滤,不需要在ES层面过滤 | |
| 875 | + | |
| 876 | +#### 8.3.6 统一约定的好处 | |
| 724 | 877 | |
| 725 | 878 | 1. **类型安全**:使用 Pydantic 模型提供运行时类型检查和验证 |
| 726 | 879 | 2. **代码一致性**:所有层使用相同的数据模型,减少转换错误 |
| ... | ... | @@ -729,14 +882,14 @@ class FacetResult(BaseModel): |
| 729 | 882 | 5. **数据验证**:自动验证输入数据,减少错误处理代码 |
| 730 | 883 | |
| 731 | 884 | **实现模块**: |
| 732 | -- `api/models.py` - 所有 Pydantic 模型定义 | |
| 733 | -- `api/result_formatter.py` - 结果格式化器(ES 响应 → Pydantic 模型) | |
| 734 | -- `search/es_query_builder.py` - ES 查询构建器(Pydantic 模型 → ES 查询) | |
| 885 | +- `api/models.py` - 所有 Pydantic 模型定义(包括 `SearchRequest`, `FacetConfig`, `RangeFilter` 等) | |
| 886 | +- `api/result_formatter.py` - 结果格式化器(ES 响应 → Pydantic 模型,包括specifications分面处理和SKU筛选) | |
| 887 | +- `search/es_query_builder.py` - ES 查询构建器(Pydantic 模型 → ES 查询,包括specifications过滤和分面) | |
| 735 | 888 | |
| 736 | -## 9. 配置文件示例 | |
| 889 | +## 9. 索引结构文件 | |
| 737 | 890 | |
| 738 | -**Base配置**(店匠通用):`config/schema/base/config.yaml` | |
| 891 | +**硬编码映射**(店匠通用):`mappings/search_products.json` | |
| 739 | 892 | |
| 740 | -**其他客户配置**:`config/schema/tenant1/config.yaml` | |
| 893 | +**查询配置**(硬编码):`search/query_config.py` | |
| 741 | 894 | |
| 742 | 895 | --- | ... | ... |
docs/设计文档.md renamed to docs/系统设计文档v1.md
| ... | ... | @@ -24,7 +24,7 @@ |
| 24 | 24 | - 所有客户共享同一个Elasticsearch索引:`search_products` |
| 25 | 25 | - 索引粒度:SPU级别(每个文档代表一个SPU) |
| 26 | 26 | - 数据隔离:通过`tenant_id`字段实现租户隔离 |
| 27 | -- 嵌套结构:每个SPU文档包含嵌套的`skus`数组(SKU变体) | |
| 27 | +- 嵌套结构:每个SPU文档包含嵌套的`skus`数组 | |
| 28 | 28 | |
| 29 | 29 | **索引文档结构**: |
| 30 | 30 | ```json |
| ... | ... | @@ -215,7 +215,7 @@ indexes: |
| 215 | 215 | 4. 组合多个语言查询的结果,提高召回率 |
| 216 | 216 | |
| 217 | 217 | **实现模块**: |
| 218 | -- `search/multilang_query_builder.py` - 多语言查询构建器 | |
| 218 | +- `search/es_query_builder.py` - ES 查询构建器(单层架构) | |
| 219 | 219 | - `query/query_parser.py` - 查询解析器(支持语言检测和翻译) |
| 220 | 220 | |
| 221 | 221 | --- |
| ... | ... | @@ -345,7 +345,7 @@ query_config: |
| 345 | 345 | |
| 346 | 346 | #### 工作流程 |
| 347 | 347 | ``` |
| 348 | -查询输入 → 语言检测 → 确定目标语言 → 翻译 → 多语言查询构建 | |
| 348 | +查询输入 → 语言检测 → 翻译 → 查询构建(filters and (text_recall or embedding_recall)) | |
| 349 | 349 | ``` |
| 350 | 350 | |
| 351 | 351 | #### 实现模块 |
| ... | ... | @@ -421,31 +421,57 @@ laptop AND (gaming OR professional) ANDNOT cheap |
| 421 | 421 | - 提取域(如 `title:查询` → 域=`title`,查询=`查询`) |
| 422 | 422 | - 检测查询语言 |
| 423 | 423 | - 生成翻译 |
| 424 | -2. **多语言查询构建**: | |
| 425 | - - 如果域有 `language_field_mapping`: | |
| 426 | - - 使用检测到的语言查询对应字段(boost * 1.5) | |
| 427 | - - 使用翻译后的查询搜索其他语言字段(boost * 1.0) | |
| 428 | - - 如果域没有 `language_field_mapping`: | |
| 429 | - - 使用所有字段进行搜索 | |
| 430 | -3. **查询组合**: | |
| 431 | - - 多个语言查询组合为 `should` 子句 | |
| 432 | - - 提高召回率 | |
| 433 | - | |
| 434 | -#### 示例 | |
| 435 | -``` | |
| 436 | -查询: "芭比娃娃" | |
| 437 | -域: default | |
| 438 | -检测语言: zh | |
| 439 | - | |
| 440 | -生成的查询: | |
| 441 | -- 中文查询 "芭比娃娃" → 搜索 name, categoryName, brandName (boost * 1.5) | |
| 442 | -- 英文翻译 "Barbie doll" → 搜索 enSpuName (boost * 1.0) | |
| 443 | -- 俄文翻译 "Кукла Барби" → 搜索 ruSkuName (boost * 1.0) | |
| 424 | +2. **查询构建**(简化架构): | |
| 425 | + - **结构**: `filters AND (text_recall OR embedding_recall)` | |
| 426 | + - **filters**: 前端传递的过滤条件(永远起作用,放在 `filter` 中) | |
| 427 | + - **text_recall**: 文本相关性召回 | |
| 428 | + - 同时搜索中英文字段(`title_zh/en`, `brief_zh/en`, `description_zh/en`, `vendor_zh/en`, `category_path_zh/en`, `category_name_zh/en`, `tags`) | |
| 429 | + - 使用 `multi_match` 查询,支持字段 boost | |
| 430 | + - **embedding_recall**: 向量召回(KNN) | |
| 431 | + - 使用 `title_embedding` 字段进行 KNN 搜索 | |
| 432 | + - ES 自动与文本召回合并 | |
| 433 | + - **function_score**: 包装召回部分,支持提权字段(新鲜度、销量等) | |
| 434 | + | |
| 435 | +#### 查询结构示例 | |
| 436 | +```json | |
| 437 | +{ | |
| 438 | + "query": { | |
| 439 | + "bool": { | |
| 440 | + "must": [ | |
| 441 | + { | |
| 442 | + "function_score": { | |
| 443 | + "query": { | |
| 444 | + "multi_match": { | |
| 445 | + "query": "手机", | |
| 446 | + "fields": [ | |
| 447 | + "title_zh^3.0", "title_en^3.0", | |
| 448 | + "brief_zh^1.5", "brief_en^1.5", | |
| 449 | + ... | |
| 450 | + ] | |
| 451 | + } | |
| 452 | + }, | |
| 453 | + "functions": [...] | |
| 454 | + } | |
| 455 | + } | |
| 456 | + ], | |
| 457 | + "filter": [ | |
| 458 | + {"term": {"tenant_id": "2"}}, | |
| 459 | + {"term": {"category_name": "手机"}} | |
| 460 | + ] | |
| 461 | + } | |
| 462 | + }, | |
| 463 | + "knn": { | |
| 464 | + "field": "title_embedding", | |
| 465 | + "query_vector": [...], | |
| 466 | + "k": 50, | |
| 467 | + "boost": 0.2 | |
| 468 | + } | |
| 469 | +} | |
| 444 | 470 | ``` |
| 445 | 471 | |
| 446 | 472 | #### 实现模块 |
| 447 | -- `search/multilang_query_builder.py` - 多语言查询构建器 | |
| 448 | -- `search/searcher.py` - 搜索器(使用多语言构建器) | |
| 473 | +- `search/es_query_builder.py` - ES 查询构建器(单层架构,`build_query` 方法) | |
| 474 | +- `search/searcher.py` - 搜索器(使用 `ESQueryBuilder`) | |
| 449 | 475 | |
| 450 | 476 | ### 5.3 相关性计算(Ranking) |
| 451 | 477 | |
| ... | ... | @@ -610,14 +636,14 @@ Elasticsearch |
| 610 | 636 | |
| 611 | 637 | - **简单模式**:字符串列表(字段名),使用默认配置 |
| 612 | 638 | ```json |
| 613 | - ["categoryName_keyword", "brandName_keyword"] | |
| 639 | + ["category.keyword", "vendor.keyword"] | |
| 614 | 640 | ``` |
| 615 | 641 | |
| 616 | 642 | - **高级模式**:FacetConfig 对象列表,支持自定义配置 |
| 617 | 643 | ```json |
| 618 | 644 | [ |
| 619 | 645 | { |
| 620 | - "field": "categoryName_keyword", | |
| 646 | + "field": "category.keyword", | |
| 621 | 647 | "size": 15, |
| 622 | 648 | "type": "terms" |
| 623 | 649 | }, | ... | ... |
search/searcher.py
| ... | ... | @@ -135,6 +135,7 @@ class Searcher: |
| 135 | 135 | sort_order: Optional[str] = "desc", |
| 136 | 136 | debug: bool = False, |
| 137 | 137 | language: str = "zh", |
| 138 | + sku_filter_dimension: Optional[str] = None, | |
| 138 | 139 | ) -> SearchResult: |
| 139 | 140 | """ |
| 140 | 141 | Execute search query (外部友好格式). |
| ... | ... | @@ -376,7 +377,8 @@ class Searcher: |
| 376 | 377 | formatted_results = ResultFormatter.format_search_results( |
| 377 | 378 | es_hits, |
| 378 | 379 | max_score, |
| 379 | - language=language | |
| 380 | + language=language, | |
| 381 | + sku_filter_dimension=sku_filter_dimension | |
| 380 | 382 | ) |
| 381 | 383 | |
| 382 | 384 | # Format facets |
| ... | ... | @@ -542,7 +544,12 @@ class Searcher: |
| 542 | 544 | max_score = es_response.get('hits', {}).get('max_score') or 0.0 |
| 543 | 545 | |
| 544 | 546 | # Format results using ResultFormatter |
| 545 | - formatted_results = ResultFormatter.format_search_results(es_hits, max_score) | |
| 547 | + formatted_results = ResultFormatter.format_search_results( | |
| 548 | + es_hits, | |
| 549 | + max_score, | |
| 550 | + language="zh", # Default language for image search | |
| 551 | + sku_filter_dimension=None # Image search doesn't support SKU filtering | |
| 552 | + ) | |
| 546 | 553 | |
| 547 | 554 | return SearchResult( |
| 548 | 555 | results=formatted_results, | ... | ... |