Compare View

switch
from
...
to
 
Commits (2)
docs/搜索API对接指南.md
@@ -136,7 +136,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -136,7 +136,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
136 136
137 #### 1. 精确匹配过滤器 (filters) 137 #### 1. 精确匹配过滤器 (filters)
138 138
139 -用于精确匹配或多值匹配(OR 逻辑)。 139 +用于精确匹配或多值匹配。对于普通字段,数组表示 OR 逻辑(匹配任意一个值);对于 specifications 字段,按维度分组处理(见下文)。
140 140
141 **格式**: 141 **格式**:
142 ```json 142 ```json
@@ -181,7 +181,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -181,7 +181,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \
181 ``` 181 ```
182 查询规格名称为"color"且值为"white"的商品。 182 查询规格名称为"color"且值为"white"的商品。
183 183
184 -**多个规格过滤(OR 逻辑)**: 184 +**多个规格过滤(按维度分组)**:
185 ```json 185 ```json
186 { 186 {
187 "filters": { 187 "filters": {
@@ -192,7 +192,26 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -192,7 +192,26 @@ curl -X POST "http://120.76.41.98:6002/search/" \
192 } 192 }
193 } 193 }
194 ``` 194 ```
195 -查询满足任意一个规格的商品(color=white **或** size=256GB)。 195 +查询同时满足所有规格的商品(color=white **且** size=256GB)。
  196 +
  197 +**相同维度的多个值(OR 逻辑)**:
  198 +```json
  199 +{
  200 + "filters": {
  201 + "specifications": [
  202 + {"name": "size", "value": "3"},
  203 + {"name": "size", "value": "4"},
  204 + {"name": "size", "value": "5"},
  205 + {"name": "color", "value": "green"}
  206 + ]
  207 + }
  208 +}
  209 +```
  210 +查询满足 (size=3 **或** size=4 **或** size=5) **且** color=green 的商品。
  211 +
  212 +**过滤逻辑说明**:
  213 +- **不同维度**(不同的 `name`)之间是 **AND** 关系(求交集)
  214 +- **相同维度**(相同的 `name`)的多个值之间是 **OR** 关系(求并集)
196 215
197 **常用过滤字段**: 216 **常用过滤字段**:
198 - `category_name`: 类目名称 217 - `category_name`: 类目名称
@@ -707,9 +726,9 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -707,9 +726,9 @@ curl -X POST "http://120.76.41.98:6002/search/" \
707 } 726 }
708 ``` 727 ```
709 728
710 -### 场景7:多个规格过滤(OR逻辑 729 +### 场景7:多个规格过滤(不同维度AND,相同维度OR
711 730
712 -**需求**: 搜索"手机",筛选color为"white"size为"256GB"的商品 731 +**需求**: 搜索"手机",筛选color为"white"size为"256GB"的商品
713 732
714 ```json 733 ```json
715 { 734 {
@@ -725,6 +744,24 @@ curl -X POST "http://120.76.41.98:6002/search/" \ @@ -725,6 +744,24 @@ curl -X POST "http://120.76.41.98:6002/search/" \
725 } 744 }
726 ``` 745 ```
727 746
  747 +**需求**: 搜索"手机",筛选size为"3"、"4"或"5",且color为"green"的商品
  748 +
  749 +```json
  750 +{
  751 + "query": "手机",
  752 + "size": 20,
  753 + "language": "zh",
  754 + "filters": {
  755 + "specifications": [
  756 + {"name": "size", "value": "3"},
  757 + {"name": "size", "value": "4"},
  758 + {"name": "size", "value": "5"},
  759 + {"name": "color", "value": "green"}
  760 + ]
  761 + }
  762 +}
  763 +```
  764 +
728 ### 场景8:规格分面搜索 765 ### 场景8:规格分面搜索
729 766
730 **需求**: 搜索"手机",获取所有规格的分面统计 767 **需求**: 搜索"手机",获取所有规格的分面统计
docs/搜索API速查表.md
@@ -41,7 +41,7 @@ POST /search/ @@ -41,7 +41,7 @@ POST /search/
41 } 41 }
42 ``` 42 ```
43 43
44 -**多个规格(OR)**: 44 +**多个规格(按维度分组)**:
45 ```bash 45 ```bash
46 { 46 {
47 "filters": { 47 "filters": {
@@ -52,6 +52,7 @@ POST /search/ @@ -52,6 +52,7 @@ POST /search/
52 } 52 }
53 } 53 }
54 ``` 54 ```
  55 +说明:不同维度(不同name)是AND关系,相同维度(相同name)的多个值是OR关系。
55 56
56 --- 57 ---
57 58
docs/系统设计文档.md
@@ -480,7 +480,8 @@ laptop AND (gaming OR professional) ANDNOT cheap @@ -480,7 +480,8 @@ laptop AND (gaming OR professional) ANDNOT cheap
480 - 范围过滤:`{"min_price": {"gte": 50, "lte": 200}}` 480 - 范围过滤:`{"min_price": {"gte": 50, "lte": 200}}`
481 - **Specifications嵌套过滤**: 481 - **Specifications嵌套过滤**:
482 - 单个规格:`{"specifications": {"name": "color", "value": "white"}}` 482 - 单个规格:`{"specifications": {"name": "color", "value": "white"}}`
483 - - 多个规格(OR):`{"specifications": [{"name": "color", "value": "white"}, {"name": "size", "value": "256GB"}]}` 483 + - 多个规格:`{"specifications": [{"name": "color", "value": "white"}, {"name": "size", "value": "256GB"}]}`
  484 + - 过滤逻辑:不同维度(不同name)是AND关系,相同维度(相同name)的多个值是OR关系
484 - 使用ES的`nested`查询实现 485 - 使用ES的`nested`查询实现
485 - **text_recall**: 文本相关性召回 486 - **text_recall**: 文本相关性召回
486 - 同时搜索中英文字段(`title_zh/en`, `brief_zh/en`, `description_zh/en`, `vendor_zh/en`, `category_path_zh/en`, `category_name_zh/en`, `tags`) 487 - 同时搜索中英文字段(`title_zh/en`, `brief_zh/en`, `description_zh/en`, `vendor_zh/en`, `category_path_zh/en`, `category_name_zh/en`, `tags`)
@@ -593,7 +594,7 @@ ranking: @@ -593,7 +594,7 @@ ranking:
593 - ✅ 语义搜索(KNN 检索) 594 - ✅ 语义搜索(KNN 检索)
594 - ✅ 相关性排序(BM25 + 向量相似度) 595 - ✅ 相关性排序(BM25 + 向量相似度)
595 - ✅ 结果聚合(Faceted Search) 596 - ✅ 结果聚合(Faceted Search)
596 -- ✅ Specifications嵌套过滤(单个和多个规格,OR逻辑 597 +- ✅ Specifications嵌套过滤(单个和多个规格,按维度分组:不同维度AND,相同维度OR
597 - ✅ Specifications嵌套分面(所有规格名称和指定规格名称) 598 - ✅ Specifications嵌套分面(所有规格名称和指定规格名称)
598 - ✅ SKU筛选(按维度过滤,应用层实现) 599 - ✅ SKU筛选(按维度过滤,应用层实现)
599 600
@@ -784,7 +785,7 @@ class RangeFilter(BaseModel): @@ -784,7 +785,7 @@ class RangeFilter(BaseModel):
784 } 785 }
785 ``` 786 ```
786 787
787 -**多个规格过滤(OR逻辑)**: 788 +**多个规格过滤(按维度分组)**:
788 ```json 789 ```json
789 { 790 {
790 "specifications": [ 791 "specifications": [
@@ -799,7 +800,7 @@ class RangeFilter(BaseModel): @@ -799,7 +800,7 @@ class RangeFilter(BaseModel):
799 2. Searcher 层:透传 `filters` 字典 800 2. Searcher 层:透传 `filters` 字典
800 3. ES Query Builder:检测 `specifications` 键,构建ES `nested` 查询 801 3. ES Query Builder:检测 `specifications` 键,构建ES `nested` 查询
801 - 单个规格:构建单个 `nested` 查询 802 - 单个规格:构建单个 `nested` 查询
802 - - 多个规格:构建多个 `nested` 查询,使用 `should` 组合(OR逻辑) 803 + - 多个规格:按 name 维度分组,相同维度内使用 `should` 组合(OR逻辑),不同维度之间使用 `must` 组合(AND逻辑)
803 4. 输出:ES nested 查询(`nested.path=specifications` + `bool.must=[term(name), term(value)]`) 804 4. 输出:ES nested 查询(`nested.path=specifications` + `bool.must=[term(name), term(value)]`)
804 805
805 #### 8.3.4 响应 Facets 数据流 806 #### 8.3.4 响应 Facets 数据流
docs/索引字段说明v2.md
@@ -132,7 +132,7 @@ @@ -132,7 +132,7 @@
132 } 132 }
133 ``` 133 ```
134 134
135 -**多个规格过滤(OR逻辑)**: 135 +**多个规格过滤(按维度分组)**:
136 ```json 136 ```json
137 { 137 {
138 "query": "手机", 138 "query": "手机",
@@ -144,21 +144,57 @@ @@ -144,21 +144,57 @@
144 } 144 }
145 } 145 }
146 ``` 146 ```
  147 +说明:不同维度(不同name)是AND关系,相同维度(相同name)的多个值是OR关系。
  148 +
  149 +**示例:相同维度的多个值(OR)**:
  150 +```json
  151 +{
  152 + "query": "手机",
  153 + "filters": {
  154 + "specifications": [
  155 + {"name": "size", "value": "3"},
  156 + {"name": "size", "value": "4"},
  157 + {"name": "size", "value": "5"},
  158 + {"name": "color", "value": "green"}
  159 + ]
  160 + }
  161 +}
  162 +```
  163 +生成查询:(size=3 OR size=4 OR size=5) AND color=green
147 164
148 **ES 查询结构**(后端自动生成): 165 **ES 查询结构**(后端自动生成):
149 ```json 166 ```json
150 { 167 {
151 - "nested": {  
152 - "path": "specifications",  
153 - "query": {  
154 - "bool": {  
155 - "must": [  
156 - { "term": { "specifications.name": "color" } },  
157 - { "term": { "specifications.value": "white" } }  
158 - ] 168 + "filter": [
  169 + {
  170 + "nested": {
  171 + "path": "specifications",
  172 + "query": {
  173 + "bool": {
  174 + "should": [
  175 + {"bool": {"must": [{"term": {"specifications.name": "size"}}, {"term": {"specifications.value": "3"}}]}},
  176 + {"bool": {"must": [{"term": {"specifications.name": "size"}}, {"term": {"specifications.value": "4"}}]}},
  177 + {"bool": {"must": [{"term": {"specifications.name": "size"}}, {"term": {"specifications.value": "5"}}]}}
  178 + ],
  179 + "minimum_should_match": 1
  180 + }
  181 + }
  182 + }
  183 + },
  184 + {
  185 + "nested": {
  186 + "path": "specifications",
  187 + "query": {
  188 + "bool": {
  189 + "must": [
  190 + {"term": {"specifications.name": "color"}},
  191 + {"term": {"specifications.value": "green"}}
  192 + ]
  193 + }
  194 + }
159 } 195 }
160 } 196 }
161 - } 197 + ]
162 } 198 }
163 ``` 199 ```
164 200
frontend/base.html deleted
@@ -1,89 +0,0 @@ @@ -1,89 +0,0 @@
1 -<!DOCTYPE html>  
2 -<html lang="zh-CN">  
3 -<head>  
4 - <meta charset="UTF-8">  
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">  
6 - <title>店匠通用搜索 - Base Configuration</title>  
7 - <link rel="stylesheet" href="/static/css/style.css">  
8 -</head>  
9 -<body>  
10 - <div class="page-container">  
11 - <!-- Header -->  
12 - <header class="top-header">  
13 - <div class="header-left">  
14 - <span class="logo">Shoplazza Base Search</span>  
15 - <span class="product-count" id="productCount">0 products found</span>  
16 - </div>  
17 - <div class="header-right">  
18 - <button class="fold-btn" onclick="toggleFilters()">Fold</button>  
19 - </div>  
20 - </header>  
21 -  
22 - <!-- Search Bar -->  
23 - <div class="search-bar">  
24 - <input type="text" id="searchInput" placeholder="输入搜索关键词... (支持中文、英文)"  
25 - onkeypress="handleKeyPress(event)">  
26 - <button onclick="performSearch()" class="search-btn">搜索</button>  
27 - </div>  
28 -  
29 - <!-- Filter Section -->  
30 - <div class="filter-section" id="filterSection">  
31 - <!-- Category Filter -->  
32 - <div class="filter-row">  
33 - <div class="filter-label">Categories:</div>  
34 - <div class="filter-tags" id="categoryTags"></div>  
35 - </div>  
36 -  
37 - <!-- Vendor Filter -->  
38 - <div class="filter-row">  
39 - <div class="filter-label">Vendor:</div>  
40 - <div class="filter-tags" id="brandTags"></div>  
41 - </div>  
42 -  
43 - <!-- Tags Filter -->  
44 - <div class="filter-row">  
45 - <div class="filter-label">Tags:</div>  
46 - <div class="filter-tags" id="supplierTags"></div>  
47 - </div>  
48 -  
49 - <!-- Price Range Filter -->  
50 - <div class="filter-row">  
51 - <div class="filter-label">Price Range:</div>  
52 - <div class="filter-tags" id="priceTags"></div>  
53 - </div>  
54 -  
55 - <!-- Clear Filters Button -->  
56 - <div class="filter-row">  
57 - <button id="clearFiltersBtn" onclick="clearAllFilters()" class="clear-filters-btn" style="display: none;">  
58 - Clear All Filters  
59 - </button>  
60 - </div>  
61 - </div>  
62 -  
63 - <!-- Results Section -->  
64 - <div class="results-section">  
65 - <div class="product-grid" id="productGrid">  
66 - <div class="welcome-message">  
67 - <h2>Welcome to Shoplazza Base Search</h2>  
68 - <p>Enter keywords to search for products</p>  
69 - </div>  
70 - </div>  
71 - </div>  
72 -  
73 - <!-- Loading Indicator -->  
74 - <div id="loading" style="display: none; text-align: center; padding: 20px;">  
75 - <div class="spinner"></div>  
76 - <p>Searching...</p>  
77 - </div>  
78 -  
79 - <!-- Debug Info (collapsible) -->  
80 - <div class="debug-section" id="debugSection" style="display: none;">  
81 - <button onclick="toggleDebug()" class="debug-toggle">Toggle Debug Info</button>  
82 - <div id="debugInfo" style="display: none;"></div>  
83 - </div>  
84 - </div>  
85 -  
86 - <script src="/static/js/app_base.js"></script>  
87 -</body>  
88 -</html>  
89 -  
frontend/static/js/app.js
@@ -608,7 +608,7 @@ function displayDebugInfo(data) { @@ -608,7 +608,7 @@ function displayDebugInfo(data) {
608 // ES Query 608 // ES Query
609 if (debugInfo.es_query) { 609 if (debugInfo.es_query) {
610 html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Query DSL:</strong>'; 610 html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Query DSL:</strong>';
611 - html += `<pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 400px;">${escapeHtml(JSON.stringify(debugInfo.es_query, null, 2))}</pre>`; 611 + html += `<pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 400px;">${escapeHtml(customStringify(debugInfo.es_query))}</pre>`;
612 html += '</div>'; 612 html += '</div>';
613 } 613 }
614 614
@@ -616,6 +616,19 @@ function displayDebugInfo(data) { @@ -616,6 +616,19 @@ function displayDebugInfo(data) {
616 debugInfoDiv.innerHTML = html; 616 debugInfoDiv.innerHTML = html;
617 } 617 }
618 618
  619 +// Custom JSON stringify that compresses numeric arrays (like embeddings) to single line
  620 +function customStringify(obj) {
  621 + return JSON.stringify(obj, (key, value) => {
  622 + if (Array.isArray(value)) {
  623 + // Only collapse arrays that contain numbers (like embeddings)
  624 + if (value.every(item => typeof item === 'number')) {
  625 + return JSON.stringify(value);
  626 + }
  627 + }
  628 + return value;
  629 + }, 2).replace(/"\[/g, '[').replace(/\]"/g, ']');
  630 +}
  631 +
619 // Helper functions 632 // Helper functions
620 function escapeHtml(text) { 633 function escapeHtml(text) {
621 if (!text) return ''; 634 if (!text) return '';
frontend/static/js/app_base.js deleted
@@ -1,590 +0,0 @@ @@ -1,590 +0,0 @@
1 -// SearchEngine Frontend - Modern UI (Multi-Tenant)  
2 -  
3 -const API_BASE_URL = 'http://localhost:6002';  
4 -document.getElementById('apiUrl').textContent = API_BASE_URL;  
5 -  
6 -// Get tenant ID from input  
7 -function getTenantId() {  
8 - const tenantInput = document.getElementById('tenantInput');  
9 - if (tenantInput) {  
10 - return tenantInput.value.trim();  
11 - }  
12 - return '1'; // Default fallback  
13 -}  
14 -  
15 -// State Management  
16 -let state = {  
17 - query: '',  
18 - currentPage: 1,  
19 - pageSize: 20,  
20 - totalResults: 0,  
21 - filters: {},  
22 - rangeFilters: {},  
23 - sortBy: '',  
24 - sortOrder: 'desc',  
25 - facets: null,  
26 - lastSearchData: null,  
27 - debug: true // Always enable debug mode for test frontend  
28 -};  
29 -  
30 -// Initialize  
31 -document.addEventListener('DOMContentLoaded', function() {  
32 - console.log('SearchEngine loaded');  
33 - console.log('Debug mode: always enabled (test frontend)');  
34 -  
35 - document.getElementById('searchInput').focus();  
36 -});  
37 -  
38 -// Keyboard handler  
39 -function handleKeyPress(event) {  
40 - if (event.key === 'Enter') {  
41 - performSearch();  
42 - }  
43 -}  
44 -  
45 -// Toggle filters visibility  
46 -function toggleFilters() {  
47 - const filterSection = document.getElementById('filterSection');  
48 - filterSection.classList.toggle('hidden');  
49 -}  
50 -  
51 -// Perform search  
52 -async function performSearch(page = 1) {  
53 - const query = document.getElementById('searchInput').value.trim();  
54 - const tenantId = getTenantId();  
55 -  
56 - if (!query) {  
57 - alert('Please enter search keywords');  
58 - return;  
59 - }  
60 -  
61 - if (!tenantId) {  
62 - alert('Please enter tenant ID');  
63 - return;  
64 - }  
65 -  
66 - state.query = query;  
67 - state.currentPage = page;  
68 - state.pageSize = parseInt(document.getElementById('resultSize').value);  
69 -  
70 - const from = (page - 1) * state.pageSize;  
71 -  
72 - // Define facets (简化配置)  
73 - const facets = [  
74 - {  
75 - "field": "category.keyword",  
76 - "size": 15,  
77 - "type": "terms"  
78 - },  
79 - {  
80 - "field": "vendor.keyword",  
81 - "size": 15,  
82 - "type": "terms"  
83 - },  
84 - {  
85 - "field": "tags.keyword",  
86 - "size": 10,  
87 - "type": "terms"  
88 - },  
89 - {  
90 - "field": "min_price",  
91 - "type": "range",  
92 - "ranges": [  
93 - {"key": "0-50", "to": 50},  
94 - {"key": "50-100", "from": 50, "to": 100},  
95 - {"key": "100-200", "from": 100, "to": 200},  
96 - {"key": "200+", "from": 200}  
97 - ]  
98 - }  
99 - ];  
100 -  
101 - // Show loading  
102 - document.getElementById('loading').style.display = 'block';  
103 - document.getElementById('productGrid').innerHTML = '';  
104 -  
105 - try {  
106 - const response = await fetch(`${API_BASE_URL}/search/`, {  
107 - method: 'POST',  
108 - headers: {  
109 - 'Content-Type': 'application/json',  
110 - 'X-Tenant-ID': tenantId,  
111 - },  
112 - body: JSON.stringify({  
113 - query: query,  
114 - size: state.pageSize,  
115 - from: from,  
116 - filters: Object.keys(state.filters).length > 0 ? state.filters : null,  
117 - range_filters: Object.keys(state.rangeFilters).length > 0 ? state.rangeFilters : null,  
118 - facets: facets,  
119 - sort_by: state.sortBy || null,  
120 - sort_order: state.sortOrder,  
121 - debug: state.debug  
122 - })  
123 - });  
124 -  
125 - if (!response.ok) {  
126 - throw new Error(`HTTP ${response.status}: ${response.statusText}`);  
127 - }  
128 -  
129 - const data = await response.json();  
130 - state.lastSearchData = data;  
131 - state.totalResults = data.total;  
132 - state.facets = data.facets;  
133 -  
134 - displayResults(data);  
135 - displayFacets(data.facets);  
136 - displayPagination();  
137 - displayDebugInfo(data);  
138 - updateProductCount(data.total);  
139 - updateClearFiltersButton();  
140 -  
141 - } catch (error) {  
142 - console.error('Search error:', error);  
143 - document.getElementById('productGrid').innerHTML = `  
144 - <div class="error-message">  
145 - <strong>Search Error:</strong> ${error.message}  
146 - <br><br>  
147 - <small>Please ensure backend service is running (${API_BASE_URL})</small>  
148 - </div>  
149 - `;  
150 - } finally {  
151 - document.getElementById('loading').style.display = 'none';  
152 - }  
153 -}  
154 -  
155 -// Display results in grid  
156 -function displayResults(data) {  
157 - const grid = document.getElementById('productGrid');  
158 -  
159 - if (!data.results || data.results.length === 0) {  
160 - grid.innerHTML = `  
161 - <div class="no-results" style="grid-column: 1 / -1;">  
162 - <h3>No Results Found</h3>  
163 - <p>Try different keywords or filters</p>  
164 - </div>  
165 - `;  
166 - return;  
167 - }  
168 -  
169 - let html = '';  
170 -  
171 - data.results.forEach((spu) => {  
172 - const score = spu.relevance_score;  
173 - html += `  
174 - <div class="product-card">  
175 - <div class="product-image-wrapper">  
176 - ${spu.image_url ? `  
177 - <img src="${escapeHtml(spu.image_url)}"  
178 - alt="${escapeHtml(spu.title)}"  
179 - class="product-image"  
180 - onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22%3E%3Crect fill=%22%23f0f0f0%22 width=%22100%22 height=%22100%22/%3E%3Ctext x=%2250%25%22 y=%2250%25%22 font-size=%2214%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23999%22%3ENo Image%3C/text%3E%3C/svg%3E'">  
181 - ` : `  
182 - <div style="color: #ccc; font-size: 14px;">No Image</div>  
183 - `}  
184 - </div>  
185 -  
186 - <div class="product-price">  
187 - ${spu.price ? `$${spu.price.toFixed(2)}` : 'N/A'}${spu.compare_at_price && spu.compare_at_price > spu.price ? `<span style="text-decoration: line-through; color: #999; font-size: 0.9em; margin-left: 8px;">$${spu.compare_at_price.toFixed(2)}</span>` : ''}  
188 - </div>  
189 -  
190 - <div class="product-stock">  
191 - ${spu.in_stock ? '<span style="color: green;">In Stock</span>' : '<span style="color: red;">Out of Stock</span>'}  
192 - ${spu.skus && spu.skus.length > 0 ? `<span style="color: #666; font-size: 0.9em;">(${spu.skus.length} skus)</span>` : ''}  
193 - </div>  
194 -  
195 - <div class="product-title">  
196 - ${escapeHtml(spu.title || 'N/A')}  
197 - </div>  
198 -  
199 - <div class="product-meta">${spu.vendor ? escapeHtml(spu.vendor) : ''}${spu.category ? ' | ' + escapeHtml(spu.category) : ''} ${spu.tags ? `  
200 - <div class="product-tags">  
201 - Tags: ${escapeHtml(spu.tags)}  
202 - </div>  
203 - ` : ''}  
204 - </div>  
205 - `;  
206 - });  
207 -  
208 - grid.innerHTML = html;  
209 -}  
210 -  
211 -// Display facets as filter tags (重构版 - 标准化格式)  
212 -function displayFacets(facets) {  
213 - if (!facets) return;  
214 -  
215 - facets.forEach(facet => {  
216 - // 根据字段名找到对应的容器  
217 - let containerId = null;  
218 - let maxDisplay = 10;  
219 -  
220 - if (facet.field === 'category.keyword') {  
221 - containerId = 'categoryTags';  
222 - maxDisplay = 10;  
223 - } else if (facet.field === 'vendor.keyword') {  
224 - containerId = 'brandTags';  
225 - maxDisplay = 10;  
226 - } else if (facet.field === 'tags.keyword') {  
227 - containerId = 'supplierTags';  
228 - maxDisplay = 8;  
229 - }  
230 -  
231 - if (!containerId) return;  
232 -  
233 - const container = document.getElementById(containerId);  
234 - if (!container) return;  
235 -  
236 - let html = '';  
237 -  
238 - // 渲染分面值  
239 - facet.values.slice(0, maxDisplay).forEach(facetValue => {  
240 - const value = facetValue.value;  
241 - const count = facetValue.count;  
242 - const selected = facetValue.selected;  
243 -  
244 - html += `  
245 - <span class="filter-tag ${selected ? 'active' : ''}"  
246 - onclick="toggleFilter('${escapeAttr(facet.field)}', '${escapeAttr(value)}')">  
247 - ${escapeHtml(value)} (${count})  
248 - </span>  
249 - `;  
250 - });  
251 -  
252 - container.innerHTML = html;  
253 - });  
254 -}  
255 -  
256 -// Toggle filter  
257 -function toggleFilter(field, value) {  
258 - if (!state.filters[field]) {  
259 - state.filters[field] = [];  
260 - }  
261 -  
262 - const index = state.filters[field].indexOf(value);  
263 - if (index > -1) {  
264 - state.filters[field].splice(index, 1);  
265 - if (state.filters[field].length === 0) {  
266 - delete state.filters[field];  
267 - }  
268 - } else {  
269 - state.filters[field].push(value);  
270 - }  
271 -  
272 - performSearch(1); // Reset to page 1  
273 -}  
274 -  
275 -// Handle price filter (重构版 - 使用 rangeFilters)  
276 -function handlePriceFilter(value) {  
277 - if (!value) {  
278 - delete state.rangeFilters.price;  
279 - } else {  
280 - const priceRanges = {  
281 - '0-50': { lt: 50 },  
282 - '50-100': { gte: 50, lt: 100 },  
283 - '100-200': { gte: 100, lt: 200 },  
284 - '200+': { gte: 200 }  
285 - };  
286 -  
287 - if (priceRanges[value]) {  
288 - state.rangeFilters.price = priceRanges[value];  
289 - }  
290 - }  
291 -  
292 - performSearch(1);  
293 -}  
294 -  
295 -// Handle time filter (重构版 - 使用 rangeFilters)  
296 -function handleTimeFilter(value) {  
297 - if (!value) {  
298 - delete state.rangeFilters.create_time;  
299 - } else {  
300 - const now = new Date();  
301 - let fromDate;  
302 -  
303 - switch(value) {  
304 - case 'today':  
305 - fromDate = new Date(now.setHours(0, 0, 0, 0));  
306 - break;  
307 - case 'week':  
308 - fromDate = new Date(now.setDate(now.getDate() - 7));  
309 - break;  
310 - case 'month':  
311 - fromDate = new Date(now.setMonth(now.getMonth() - 1));  
312 - break;  
313 - case '3months':  
314 - fromDate = new Date(now.setMonth(now.getMonth() - 3));  
315 - break;  
316 - case '6months':  
317 - fromDate = new Date(now.setMonth(now.getMonth() - 6));  
318 - break;  
319 - }  
320 -  
321 - if (fromDate) {  
322 - state.rangeFilters.create_time = {  
323 - gte: fromDate.toISOString()  
324 - };  
325 - }  
326 - }  
327 -  
328 - performSearch(1);  
329 -}  
330 -  
331 -// Clear all filters  
332 -function clearAllFilters() {  
333 - state.filters = {};  
334 - state.rangeFilters = {};  
335 - document.getElementById('priceFilter').value = '';  
336 - document.getElementById('timeFilter').value = '';  
337 - performSearch(1);  
338 -}  
339 -  
340 -// Update clear filters button visibility  
341 -function updateClearFiltersButton() {  
342 - const btn = document.getElementById('clearFiltersBtn');  
343 - if (Object.keys(state.filters).length > 0 || Object.keys(state.rangeFilters).length > 0) {  
344 - btn.style.display = 'inline-block';  
345 - } else {  
346 - btn.style.display = 'none';  
347 - }  
348 -}  
349 -  
350 -// Update product count  
351 -function updateProductCount(total) {  
352 - document.getElementById('productCount').textContent = `${total.toLocaleString()} SPUs found`;  
353 -}  
354 -  
355 -// Sort functions  
356 -function setSortByDefault() {  
357 - // Remove active from all buttons and arrows  
358 - document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));  
359 - document.querySelectorAll('.arrow-up, .arrow-down').forEach(a => a.classList.remove('active'));  
360 -  
361 - // Set default button active  
362 - const defaultBtn = document.querySelector('.sort-btn[data-sort=""]');  
363 - if (defaultBtn) defaultBtn.classList.add('active');  
364 -  
365 - state.sortBy = '';  
366 - state.sortOrder = 'desc';  
367 -  
368 - performSearch(1);  
369 -}  
370 -  
371 -function sortByField(field, order) {  
372 - state.sortBy = field;  
373 - state.sortOrder = order;  
374 -  
375 - // Remove active from all buttons (but keep "By default" if no sort)  
376 - document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));  
377 -  
378 - // Remove active from all arrows  
379 - document.querySelectorAll('.arrow-up, .arrow-down').forEach(a => a.classList.remove('active'));  
380 -  
381 - // Add active to clicked arrow  
382 - const activeArrow = document.querySelector(`.arrow-up[data-field="${field}"][data-order="${order}"], .arrow-down[data-field="${field}"][data-order="${order}"]`);  
383 - if (activeArrow) {  
384 - activeArrow.classList.add('active');  
385 - }  
386 -  
387 - performSearch(state.currentPage);  
388 -}  
389 -  
390 -// Pagination  
391 -function displayPagination() {  
392 - const paginationDiv = document.getElementById('pagination');  
393 -  
394 - if (state.totalResults <= state.pageSize) {  
395 - paginationDiv.style.display = 'none';  
396 - return;  
397 - }  
398 -  
399 - paginationDiv.style.display = 'flex';  
400 -  
401 - const totalPages = Math.ceil(state.totalResults / state.pageSize);  
402 - const currentPage = state.currentPage;  
403 -  
404 - let html = `  
405 - <button class="page-btn" onclick="goToPage(${currentPage - 1})"  
406 - ${currentPage === 1 ? 'disabled' : ''}>  
407 - ← Previous  
408 - </button>  
409 - `;  
410 -  
411 - // Page numbers  
412 - const maxVisible = 5;  
413 - let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));  
414 - let endPage = Math.min(totalPages, startPage + maxVisible - 1);  
415 -  
416 - if (endPage - startPage < maxVisible - 1) {  
417 - startPage = Math.max(1, endPage - maxVisible + 1);  
418 - }  
419 -  
420 - if (startPage > 1) {  
421 - html += `<button class="page-btn" onclick="goToPage(1)">1</button>`;  
422 - if (startPage > 2) {  
423 - html += `<span class="page-info">...</span>`;  
424 - }  
425 - }  
426 -  
427 - for (let i = startPage; i <= endPage; i++) {  
428 - html += `  
429 - <button class="page-btn ${i === currentPage ? 'active' : ''}"  
430 - onclick="goToPage(${i})">  
431 - ${i}  
432 - </button>  
433 - `;  
434 - }  
435 -  
436 - if (endPage < totalPages) {  
437 - if (endPage < totalPages - 1) {  
438 - html += `<span class="page-info">...</span>`;  
439 - }  
440 - html += `<button class="page-btn" onclick="goToPage(${totalPages})">${totalPages}</button>`;  
441 - }  
442 -  
443 - html += `  
444 - <button class="page-btn" onclick="goToPage(${currentPage + 1})"  
445 - ${currentPage === totalPages ? 'disabled' : ''}>  
446 - Next →  
447 - </button>  
448 - `;  
449 -  
450 - html += `  
451 - <span class="page-info">  
452 - Page ${currentPage} of ${totalPages} (${state.totalResults.toLocaleString()} results)  
453 - </span>  
454 - `;  
455 -  
456 - paginationDiv.innerHTML = html;  
457 -}  
458 -  
459 -function goToPage(page) {  
460 - const totalPages = Math.ceil(state.totalResults / state.pageSize);  
461 - if (page < 1 || page > totalPages) return;  
462 -  
463 - performSearch(page);  
464 -  
465 - // Scroll to top  
466 - window.scrollTo({ top: 0, behavior: 'smooth' });  
467 -}  
468 -  
469 -// Display debug info  
470 -function displayDebugInfo(data) {  
471 - const debugInfoDiv = document.getElementById('debugInfo');  
472 -  
473 - if (!state.debug || !data.debug_info) {  
474 - // If debug mode is off or no debug info, show basic query info  
475 - if (data.query_info) {  
476 - let html = '<div style="padding: 10px;">';  
477 - html += `<div><strong>original_query:</strong> ${escapeHtml(data.query_info.original_query || 'N/A')}</div>`;  
478 - html += `<div><strong>detected_language:</strong> ${getLanguageName(data.query_info.detected_language)}</div>`;  
479 - html += '</div>';  
480 - debugInfoDiv.innerHTML = html;  
481 - } else {  
482 - debugInfoDiv.innerHTML = '';  
483 - }  
484 - return;  
485 - }  
486 -  
487 - // Display comprehensive debug info when debug mode is on  
488 - const debugInfo = data.debug_info;  
489 - let html = '<div style="padding: 10px; font-family: monospace; font-size: 12px;">';  
490 -  
491 - // Query Analysis  
492 - if (debugInfo.query_analysis) {  
493 - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Query Analysis:</strong>';  
494 - html += `<div>original_query: ${escapeHtml(debugInfo.query_analysis.original_query || 'N/A')}</div>`;  
495 - html += `<div>normalized_query: ${escapeHtml(debugInfo.query_analysis.normalized_query || 'N/A')}</div>`;  
496 - html += `<div>rewritten_query: ${escapeHtml(debugInfo.query_analysis.rewritten_query || 'N/A')}</div>`;  
497 - html += `<div>detected_language: ${getLanguageName(debugInfo.query_analysis.detected_language)}</div>`;  
498 - html += `<div>domain: ${escapeHtml(debugInfo.query_analysis.domain || 'default')}</div>`;  
499 - html += `<div>is_simple_query: ${debugInfo.query_analysis.is_simple_query ? 'yes' : 'no'}</div>`;  
500 -  
501 - if (debugInfo.query_analysis.translations && Object.keys(debugInfo.query_analysis.translations).length > 0) {  
502 - html += '<div>translations: ';  
503 - for (const [lang, translation] of Object.entries(debugInfo.query_analysis.translations)) {  
504 - if (translation) {  
505 - html += `${getLanguageName(lang)}: ${escapeHtml(translation)}; `;  
506 - }  
507 - }  
508 - html += '</div>';  
509 - }  
510 -  
511 - if (debugInfo.query_analysis.boolean_ast) {  
512 - html += `<div>boolean_ast: ${escapeHtml(debugInfo.query_analysis.boolean_ast)}</div>`;  
513 - }  
514 -  
515 - html += `<div>has_vector: ${debugInfo.query_analysis.has_vector ? 'enabled' : 'disabled'}</div>`;  
516 - html += '</div>';  
517 - }  
518 -  
519 - // Feature Flags  
520 - if (debugInfo.feature_flags) {  
521 - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Feature Flags:</strong>';  
522 - html += `<div>translation_enabled: ${debugInfo.feature_flags.translation_enabled ? 'enabled' : 'disabled'}</div>`;  
523 - html += `<div>embedding_enabled: ${debugInfo.feature_flags.embedding_enabled ? 'enabled' : 'disabled'}</div>`;  
524 - html += `<div>rerank_enabled: ${debugInfo.feature_flags.rerank_enabled ? 'enabled' : 'disabled'}</div>`;  
525 - html += '</div>';  
526 - }  
527 -  
528 - // ES Response  
529 - if (debugInfo.es_response) {  
530 - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Response:</strong>';  
531 - html += `<div>took_ms: ${debugInfo.es_response.took_ms}ms</div>`;  
532 - html += `<div>total_hits: ${debugInfo.es_response.total_hits}</div>`;  
533 - html += `<div>max_score: ${debugInfo.es_response.max_score?.toFixed(3) || 0}</div>`;  
534 - html += '</div>';  
535 - }  
536 -  
537 - // Stage Timings  
538 - if (debugInfo.stage_timings) {  
539 - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">Stage Timings:</strong>';  
540 - for (const [stage, duration] of Object.entries(debugInfo.stage_timings)) {  
541 - html += `<div>${stage}: ${duration.toFixed(2)}ms</div>`;  
542 - }  
543 - html += '</div>';  
544 - }  
545 -  
546 - // ES Query  
547 - if (debugInfo.es_query) {  
548 - html += '<div style="margin-bottom: 15px;"><strong style="font-size: 14px;">ES Query DSL:</strong>';  
549 - html += `<pre style="background: #f5f5f5; padding: 10px; overflow: auto; max-height: 400px;">${escapeHtml(JSON.stringify(debugInfo.es_query, null, 2))}</pre>`;  
550 - html += '</div>';  
551 - }  
552 -  
553 - html += '</div>';  
554 - debugInfoDiv.innerHTML = html;  
555 -}  
556 -  
557 -// Helper functions  
558 -function escapeHtml(text) {  
559 - if (!text) return '';  
560 - const div = document.createElement('div');  
561 - div.textContent = text;  
562 - return div.innerHTML;  
563 -}  
564 -  
565 -function escapeAttr(text) {  
566 - if (!text) return '';  
567 - return text.replace(/'/g, "\\'").replace(/"/g, '&quot;');  
568 -}  
569 -  
570 -function formatDate(dateStr) {  
571 - if (!dateStr) return '';  
572 - try {  
573 - const date = new Date(dateStr);  
574 - return date.toLocaleDateString('zh-CN');  
575 - } catch {  
576 - return dateStr;  
577 - }  
578 -}  
579 -  
580 -function getLanguageName(code) {  
581 - const names = {  
582 - 'zh': '中文',  
583 - 'en': 'English',  
584 - 'ru': 'Русский',  
585 - 'ar': 'العربية',  
586 - 'ja': '日本語',  
587 - 'unknown': 'Unknown'  
588 - };  
589 - return names[code] || code;  
590 -}  
search/es_query_builder.py
@@ -373,33 +373,58 @@ class ESQueryBuilder: @@ -373,33 +373,58 @@ class ESQueryBuilder:
373 } 373 }
374 }) 374 })
375 elif isinstance(value, list): 375 elif isinstance(value, list):
376 - # 多个规格过滤(OR逻辑):[{"name": "color", "value": "green"}, ...]  
377 - should_clauses = [] 376 + # 多个规格过滤:按 name 分组,相同维度 OR,不同维度 AND
  377 + # 例如:[{"name": "size", "value": "3"}, {"name": "size", "value": "4"}, {"name": "color", "value": "green"}]
  378 + # 应该生成:(size=3 OR size=4) AND color=green
  379 + from collections import defaultdict
  380 + specs_by_name = defaultdict(list)
378 for spec in value: 381 for spec in value:
379 if isinstance(spec, dict): 382 if isinstance(spec, dict):
380 name = spec.get("name") 383 name = spec.get("name")
381 spec_value = spec.get("value") 384 spec_value = spec.get("value")
382 if name and spec_value: 385 if name and spec_value:
383 - should_clauses.append({  
384 - "nested": {  
385 - "path": "specifications",  
386 - "query": {  
387 - "bool": {  
388 - "must": [  
389 - {"term": {"specifications.name": name}},  
390 - {"term": {"specifications.value": spec_value}}  
391 - ]  
392 - } 386 + specs_by_name[name].append(spec_value)
  387 +
  388 + # 为每个 name 维度生成一个过滤子句
  389 + for name, values in specs_by_name.items():
  390 + if len(values) == 1:
  391 + # 单个值,直接生成 term 查询
  392 + filter_clauses.append({
  393 + "nested": {
  394 + "path": "specifications",
  395 + "query": {
  396 + "bool": {
  397 + "must": [
  398 + {"term": {"specifications.name": name}},
  399 + {"term": {"specifications.value": values[0]}}
  400 + ]
393 } 401 }
394 } 402 }
  403 + }
  404 + })
  405 + else:
  406 + # 多个值,使用 should (OR) 连接
  407 + should_clauses = []
  408 + for spec_value in values:
  409 + should_clauses.append({
  410 + "bool": {
  411 + "must": [
  412 + {"term": {"specifications.name": name}},
  413 + {"term": {"specifications.value": spec_value}}
  414 + ]
  415 + }
395 }) 416 })
396 - if should_clauses:  
397 - filter_clauses.append({  
398 - "bool": {  
399 - "should": should_clauses,  
400 - "minimum_should_match": 1  
401 - }  
402 - }) 417 + filter_clauses.append({
  418 + "nested": {
  419 + "path": "specifications",
  420 + "query": {
  421 + "bool": {
  422 + "should": should_clauses,
  423 + "minimum_should_match": 1
  424 + }
  425 + }
  426 + }
  427 + })
403 continue 428 continue
404 429
405 # 普通字段过滤 430 # 普通字段过滤