## qanchors 与 semantic_attributes 设计与索引逻辑说明 本文档详细说明: - **锚文本字段 `qanchors.{lang}` 的作用与来源** - **语义属性字段 `semantic_attributes` 的结构、用途与写入流程** - **多语言支持策略(zh / en / de / ru / fr)** - **索引阶段与 LLM 调用的集成方式** 本设计已默认开启,无需额外开关;在上游 LLM 不可用时会自动降级为“无锚点/语义属性”,不影响主索引流程。 --- ### 1. 字段设计概览 #### 1.1 `qanchors.{lang}`:面向查询的锚文本 - **Mapping 位置**:`mappings/search_products.json` 中的 `qanchors` 对象。 - **结构**(与 `title.{lang}` 一致): ```140:182:/home/tw/saas-search/mappings/search_products.json "qanchors": { "type": "object", "properties": { "zh": { "type": "text", "analyzer": "index_ik", "search_analyzer": "query_ik" }, "en": { "type": "text", "analyzer": "english" }, "de": { "type": "text", "analyzer": "german" }, "ru": { "type": "text", "analyzer": "russian" }, "fr": { "type": "text", "analyzer": "french" }, ... } } ``` - **语义**: 用于承载“更接近用户自然搜索行为”的词/短语(query-style anchors),包括: - 品类 + 细分类别表达; - 使用场景(通勤、约会、度假、office outfit 等); - 适用人群(年轻女性、plus size、teen boys 等); - 材质 / 关键属性 / 功能特点等。 - **使用场景**: - 主搜索:作为额外的全文字段参与 BM25 召回与打分(可在 `search/query_config.py` 中给一定权重); - Suggestion:`suggestion/builder.py` 会从 `qanchors.{lang}` 中拆分词条作为候选(`source="qanchor"`,权重大于 `title`)。 #### 1.2 `semantic_attributes`:面向过滤/分面的通用语义属性 - **Mapping 位置**:`mappings/search_products.json`,追加的 nested 字段。 - **结构**: ```1392:1410:/home/tw/saas-search/mappings/search_products.json "semantic_attributes": { "type": "nested", "properties": { "lang": { "type": "keyword" }, // 语言:zh / en / de / ru / fr "name": { "type": "keyword" }, // 维度名:usage_scene / target_audience / material / ... "value": { "type": "keyword" } // 维度值:通勤 / office / Baumwolle ... } } ``` - **语义**: - 将 LLM 输出的各维度信息统一规约到 `name/value/lang` 三元组; - 维度名稳定、值内容可变,便于后续扩展新的语义维度而不需要修改 mapping。 - **当前支持的维度名**(在 `document_transformer.py` 中固定列表): - `tags`:细分标签/风格标签; - `target_audience`:适用人群; - `usage_scene`:使用场景; - `season`:适用季节; - `key_attributes`:关键属性; - `material`:材质说明; - `features`:功能特点。 - **使用场景**: - 按语义维度过滤: - 例:只要“适用人群=年轻女性”的商品; - 例:`usage_scene` 包含 “office” 或 “通勤”。 - 按语义维度分面 / 展示筛选项: - 例:展示当前结果中所有 `usage_scene` 的分布,供前端勾选; - 例:展示所有 `material` 值 + 命中文档数。 --- ### 2. LLM 分析服务:`indexer/process_products.py` #### 2.1 入口函数:`analyze_products` - **文件**:`indexer/process_products.py` - **函数签名**: ```365:392:/home/tw/saas-search/indexer/process_products.py def analyze_products( products: List[Dict[str, str]], target_lang: str = "zh", batch_size: Optional[int] = None, ) -> List[Dict[str, Any]]: """ 库调用入口:根据输入+语言,返回锚文本及各维度信息。 Args: products: [{"id": "...", "title": "..."}] target_lang: 输出语言,需在 SUPPORTED_LANGS 内 batch_size: 批大小,默认使用全局 BATCH_SIZE """ ... ``` - **支持的输出语言**(在同文件中定义): ```54:62:/home/tw/saas-search/indexer/process_products.py LANG_LABELS: Dict[str, str] = { "zh": "中文", "en": "英文", "de": "德文", "ru": "俄文", "fr": "法文", } SUPPORTED_LANGS = set(LANG_LABELS.keys()) ``` - **返回结构**(每个商品一条记录): ```python { "id": "", "lang": "", "title_input": "<原始输入标题>", "title": "<目标语言的标题>", "category_path": "", "tags": "<逗号分隔的细分标签>", "target_audience": "<逗号分隔的适用人群>", "usage_scene": "<逗号分隔的使用场景>", "season": "<逗号分隔的适用季节>", "key_attributes": "<逗号分隔的关键属性>", "material": "<逗号分隔的材质说明>", "features": "<逗号分隔的功能特点>", "selling_points": "<一句话卖点>", "anchor_text": "<逗号分隔的锚文本短语>", # 若发生错误,还会附带: # "error": "<异常信息>" } ``` > 注意:表格中的多值字段(标签/场景/人群/材质等)约定为**使用逗号分隔**,后续索引端会统一按正则 `[,;|/\\n\\t]+` 再拆分为短语。 #### 2.2 Prompt 设计与语言控制 - Prompt 中会明确要求“**所有输出内容使用目标语言**”,并给出中英文示例: ```65:81:/home/tw/saas-search/indexer/process_products.py def create_prompt(products: List[Dict[str, str]], target_lang: str = "zh") -> str: """创建LLM提示词(根据目标语言输出)""" lang_label = LANG_LABELS.get(target_lang, "对应语言") prompt = f"""请对输入的每条商品标题,分析并提取以下信息,所有输出内容请使用{lang_label}: 1. 商品标题:将输入商品名称翻译为{lang_label} 2. 品类路径:从大类到细分品类,用">"分隔(例如:服装>女装>裤子>工装裤) 3. 细分标签:商品的风格、特点、功能等(例如:碎花,收腰,法式) 4. 适用人群:性别/年龄段等(例如:年轻女性) 5. 使用场景 6. 适用季节 7. 关键属性 8. 材质说明 9. 功能特点 10. 商品卖点:分析和提取一句话核心卖点,用于推荐理由 11. 锚文本:生成一组能够代表该商品、并可能被用户用于搜索的词语或短语。这些词语应覆盖用户需求的各个维度,如品类、细分标签、功能特性、需求场景等等。 """ ``` - 返回格式固定为 Markdown 表格,首行头为: ```89:91:/home/tw/saas-search/indexer/process_products.py | 序号 | 商品标题 | 品类路径 | 细分标签 | 适用人群 | 使用场景 | 适用季节 | 关键属性 | 材质说明 | 功能特点 | 商品卖点 | 锚文本 | |----|----|----|----|----|----|----|----|----|----|----|----| ``` `parse_markdown_table` 会按表格列顺序解析成字段。 --- ### 3. 索引阶段集成:`SPUDocumentTransformer._fill_llm_attributes` #### 3.1 调用时机 在 `SPUDocumentTransformer.transform_spu_to_doc(...)` 的末尾,在所有基础字段(多语言文本、类目、SKU/规格、价格、库存等)填充完成后,会调用: ```96:101:/home/tw/saas-search/indexer/document_transformer.py # 文本字段处理(翻译等) self._fill_text_fields(doc, spu_row, primary_lang) # 标题向量化 if self.enable_title_embedding and self.encoder: self._fill_title_embedding(doc) ... # 时间字段 ... # 基于 LLM 的锚文本与语义属性(默认开启,失败时仅记录日志) self._fill_llm_attributes(doc, spu_row) ``` 也就是说,**每个 SPU 文档默认会尝试补充 qanchors 与 semantic_attributes**。 #### 3.2 语言选择策略 在 `_fill_llm_attributes` 内部: ```148:164:/home/tw/saas-search/indexer/document_transformer.py try: index_langs = self.tenant_config.get("index_languages") or ["en", "zh"] except Exception: index_langs = ["en", "zh"] # 只在支持的语言集合内调用 llm_langs = [lang for lang in index_langs if lang in SUPPORTED_LANGS] if not llm_langs: return ``` - `tenant_config.index_languages` 决定该租户希望在索引中支持哪些语言; - 实际调用 LLM 的语言集合 = `index_languages ∩ SUPPORTED_LANGS`; - 当前 SUPPORTED_LANGS:`{"zh", "en", "de", "ru", "fr"}`。 这保证了: - 如果租户只索引 `zh`,就只跑中文; - 如果租户同时索引 `en` + `de`,就为这两种语言各跑一次 LLM; - 如果 `index_languages` 里包含暂不支持的语言(例如 `es`),会被自动忽略。 #### 3.3 调用 LLM 并写入字段 核心逻辑(简化描述): ```164:210:/home/tw/saas-search/indexer/document_transformer.py spu_id = str(spu_row.get("id") or "").strip() title = str(spu_row.get("title") or "").strip() if not spu_id or not title: return semantic_list = doc.get("semantic_attributes") or [] qanchors_obj = doc.get("qanchors") or {} dim_keys = [ "tags", "target_audience", "usage_scene", "season", "key_attributes", "material", "features", ] for lang in llm_langs: try: rows = analyze_products( products=[{"id": spu_id, "title": title}], target_lang=lang, batch_size=1, ) except Exception as e: logger.warning("LLM attribute fill failed for SPU %s, lang=%s: %s", spu_id, lang, e) continue if not rows: continue row = rows[0] or {} # qanchors.{lang} anchor_text = str(row.get("anchor_text") or "").strip() if anchor_text: qanchors_obj[lang] = anchor_text # 语义属性 for name in dim_keys: raw = row.get(name) if not raw: continue parts = re.split(r"[,;|/\n\t]+", str(raw)) for part in parts: value = part.strip() if not value: continue semantic_list.append( { "lang": lang, "name": name, "value": value, } ) if qanchors_obj: doc["qanchors"] = qanchors_obj if semantic_list: doc["semantic_attributes"] = semantic_list ``` 要点: - 每种语言**单独调用一次** `analyze_products`,传入同一 SPU 的原始标题; - 将返回的 `anchor_text` 直接写入 `qanchors.{lang}`,其内部仍是逗号分隔短语,后续 suggestion builder 会再拆分; - 对各维度字段(tags/usage_scene/...)用统一正则进行“松散拆词”,过滤空串后,以 `(lang,name,value)` 三元组追加到 nested 数组; - 如果某个维度在该语言下为空,则跳过,不写入任何条目。 #### 3.4 容错 & 降级策略 - 如果: - 没有 `title`; - 或者 `tenant_config.index_languages` 与 `SUPPORTED_LANGS` 没有交集; - 或 `DASHSCOPE_API_KEY` 未配置 / LLM 请求报错; - 则 `_fill_llm_attributes` 会在日志中输出 `warning`,**不会抛异常**,索引流程继续,只是该 SPU 在这一轮不会得到 `qanchors` / `semantic_attributes`。 这保证了整个索引服务在 LLM 不可用时表现为一个普通的“传统索引”,而不会中断。 --- ### 4. 查询与 Suggestion 中的使用建议 #### 4.1 主搜索(Search API) 在 `search/query_config.py` 或构建 ES 查询时,可以: - 将 `qanchors.{lang}` 作为额外的 `should` 字段参与匹配,并给一个略高的权重,例如: ```json { "multi_match": { "query": "", "fields": [ "title.zh^3.0", "brief.zh^1.5", "description.zh^1.0", "vendor.zh^1.5", "category_path.zh^1.5", "category_name_text.zh^1.5", "tags^1.0", "qanchors.zh^2.0" // 建议新增 ] } } ``` - 当用户做维度过滤时(例如“只看通勤场景 + 夏季 + 棉质”),可以在 filter 中增加 nested 查询: ```json { "nested": { "path": "semantic_attributes", "query": { "bool": { "must": [ { "term": { "semantic_attributes.lang": "zh" } }, { "term": { "semantic_attributes.name": "usage_scene" } }, { "term": { "semantic_attributes.value": "通勤" } } ] } } } } ``` 多个维度可以通过多个 nested 子句组合(AND/OR 逻辑与 `specifications` 的设计类似)。 #### 4.2 Suggestion(联想词) 现有 `suggestion/builder.py` 已经支持从 `qanchors.{lang}` 中提取候选: ```249:287:/home/tw/saas-search/suggestion/builder.py # Step 1: product title/qanchors hits = self._scan_products(tenant_id, batch_size=batch_size) ... title_obj = src.get("title") or {} qanchor_obj = src.get("qanchors") or {} ... for lang in index_languages: ... q_raw = None if isinstance(qanchor_obj, dict): q_raw = qanchor_obj.get(lang) for q_text in self._split_qanchors(q_raw): text_norm = self._normalize_text(q_text) if self._looks_noise(text_norm): continue key = (lang, text_norm) c = key_to_candidate.get(key) if c is None: c = SuggestionCandidate(text=q_text, text_norm=text_norm, lang=lang) key_to_candidate[key] = c c.add_product("qanchor", spu_id=spu_id, score=product_score + 0.6) ``` - `_split_qanchors` 使用与索引端一致的分隔符集合,确保: - 无论 LLM 用逗号、分号还是换行分隔,只要符合约定,都能被拆成单独候选词; - `add_product("qanchor", ...)` 会: - 将来源标记为 `qanchor`; - 在排序打分时,`qanchor` 命中会比纯 `title` 更有权重。 --- ### 5. 总结与扩展方向 1. **功能定位**: - `qanchors.{lang}`:更好地贴近用户真实查询词,用于召回与 suggestion; - `semantic_attributes`:以结构化形式承载 LLM 抽取的语义维度,用于 filter / facet。 2. **多语言对齐**: - 完全复用租户级 `index_languages` 配置; - 对每种语言单独生成锚文本与语义属性,不互相混用。 3. **默认开启 / 自动降级**: - 索引流程始终可用; - 当 LLM/配置异常时,只是“缺少增强特征”,不影响基础搜索能力。 4. **未来扩展**: - 可以在 `dim_keys` 中新增维度名(如 `style`, `benefit` 等),只要在 prompt 与解析逻辑中增加对应列即可; - 可以为 `semantic_attributes` 增加额外字段(如 `confidence`、`source`),用于更精细的控制(当前 mapping 为简单版)。 如需在查询层面增加基于 `semantic_attributes` 的统一 DSL(类似 `specifications` 的过滤/分面规则),推荐在 `docs/搜索API对接指南.md` 中新增一节,并在 `search/es_query_builder.py` 里封装构造逻辑,避免前端直接拼 nested 查询。