Commit 69881ecbacf6ca440552327e33d42e923679c9ec

Authored by tangwang
1 parent 8140e942

相关性调参、enrich内容解析优化

api/routes/indexer.py
@@ -449,7 +449,7 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]], languages: @@ -449,7 +449,7 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]], languages:
449 同步执行内容理解:调用 product_enrich.analyze_products,按语言批量跑 LLM, 449 同步执行内容理解:调用 product_enrich.analyze_products,按语言批量跑 LLM,
450 再聚合成每 SPU 的 qanchors、semantic_attributes、tags。供 run_in_executor 调用。 450 再聚合成每 SPU 的 qanchors、semantic_attributes、tags。供 run_in_executor 调用。
451 """ 451 """
452 - from indexer.product_enrich import analyze_products 452 + from indexer.product_enrich import analyze_products, split_multi_value_field
453 453
454 llm_langs = list(dict.fromkeys(languages)) or ["en"] 454 llm_langs = list(dict.fromkeys(languages)) or ["en"]
455 455
@@ -510,10 +510,7 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]], languages: @@ -510,10 +510,7 @@ def _run_enrich_content(tenant_id: str, items: List[Dict[str, str]], languages:
510 raw = row.get(name) 510 raw = row.get(name)
511 if not raw: 511 if not raw:
512 continue 512 continue
513 - for part in re.split(r"[,;|/\n\t]+", str(raw)):  
514 - value = part.strip()  
515 - if not value:  
516 - continue 513 + for value in split_multi_value_field(str(raw)):
517 rec["semantic_attributes"].append({"lang": lang, "name": name, "value": value}) 514 rec["semantic_attributes"].append({"lang": lang, "name": name, "value": value})
518 if name == "tags": 515 if name == "tags":
519 rec["tags"].append(value) 516 rec["tags"].append(value)
config/config.yaml
@@ -75,16 +75,15 @@ es_settings: @@ -75,16 +75,15 @@ es_settings:
75 # 若需要按某个语言单独调权,也可以加显式 key(例如 title.de: 3.2)。 75 # 若需要按某个语言单独调权,也可以加显式 key(例如 title.de: 3.2)。
76 field_boosts: 76 field_boosts:
77 title: 3.0 77 title: 3.0
  78 + qanchors: 2.5
  79 + tags: 2.0
  80 + category_name_text: 2.0
  81 + category_path: 2.0
78 brief: 1.5 82 brief: 1.5
79 - description: 1.0  
80 - qanchors: 1.5  
81 - vendor: 1.5  
82 - category_path: 1.5  
83 - category_name_text: 1.5  
84 - tags: 1.0  
85 - option1_values: 0.6  
86 - option2_values: 0.4  
87 - option3_values: 0.4 83 + description: 1.5
  84 + option1_values: 1.5
  85 + option2_values: 1.5
  86 + option3_values: 1.5
88 87
89 # Query Configuration(查询配置) 88 # Query Configuration(查询配置)
90 query_config: 89 query_config:
@@ -122,11 +121,11 @@ query_config: @@ -122,11 +121,11 @@ query_config:
122 search_fields: 121 search_fields:
123 multilingual_fields: 122 multilingual_fields:
124 - "title" 123 - "title"
125 - - "brief"  
126 - - "description"  
127 - - "vendor" 124 + - "qanchors"
128 - "category_path" 125 - "category_path"
129 - "category_name_text" 126 - "category_name_text"
  127 + - "brief"
  128 + - "description"
130 shared_fields: 129 shared_fields:
131 - "tags" 130 - "tags"
132 - "option1_values" 131 - "option1_values"
@@ -135,15 +134,14 @@ query_config: @@ -135,15 +134,14 @@ query_config:
135 core_multilingual_fields: 134 core_multilingual_fields:
136 - "title" 135 - "title"
137 - "brief" 136 - "brief"
138 - - "vendor"  
139 - "category_name_text" 137 - "category_name_text"
140 138
141 # 统一文本召回策略(主查询 + 翻译查询) 139 # 统一文本召回策略(主查询 + 翻译查询)
142 text_query_strategy: 140 text_query_strategy:
143 base_minimum_should_match: "75%" 141 base_minimum_should_match: "75%"
144 translation_minimum_should_match: "75%" 142 translation_minimum_should_match: "75%"
145 - translation_boost: 0.6  
146 - tie_breaker_base_query: 0.9 143 + translation_boost: 0.75
  144 + tie_breaker_base_query: 0.5
147 145
148 # Embedding字段名称 146 # Embedding字段名称
149 text_embedding_field: "title_embedding" 147 text_embedding_field: "title_embedding"
1 1
  2 +
  3 +
  4 +本地部署一个7b Q4量化的大模型
  5 +es需要licence的两个功能,如果费用低,开通下licence,或者改es源码定制开发下,支持 rank.rrf,reranker
  6 +
  7 +
  8 +
2 把knn跟文本相关性的融合方式修改为 "rank": {"rrf": {} }需要licence,可以帮我修改源码支持吗? 9 把knn跟文本相关性的融合方式修改为 "rank": {"rrf": {} }需要licence,可以帮我修改源码支持吗?
3 10
4 knn_boost: 2.0 11 knn_boost: 2.0
@@ -13,8 +20,6 @@ @@ -13,8 +20,6 @@
13 } 20 }
14 21
15 22
16 -  
17 -  
18 "image_embedding": { 23 "image_embedding": {
19 "type": "nested", 24 "type": "nested",
20 "properties": { 25 "properties": {
@@ -58,34 +63,70 @@ image_embedding改为,一个spu有多个sku向量,每个向量内部properti @@ -58,34 +63,70 @@ image_embedding改为,一个spu有多个sku向量,每个向量内部properti
58 2. ES支持reranker pipline? 63 2. ES支持reranker pipline?
59 64
60 65
61 -@reranker/backends/qwen3_vllm.py 单次 generate 前有进程内锁,同一进程里不会并行多路 vLLM 推理,这个锁有必要吗?是否会影响性能?是否能够打开,使得性能更好?比如这个场景,我一次请求 400 条,分成每64个一个batch,基于我现在的gpu配置,可以再提高并发度吗?  
62 -测试了,让每个批次都并发地进行,耗时没有变化 66 +
  67 +
  68 +
63 69
64 增加款式意图识别模块 70 增加款式意图识别模块
65 71
66 -意图类型: 颜色,尺(目前只需要支持这两种) 72 +意图类型: 颜色,尺(目前只需要支持这两种)
67 73
68 意图召回层: 74 意图召回层:
69 每种意图,有一个召回词集合 75 每种意图,有一个召回词集合
70 对query(包括原始query、各种翻译query 都做匹配) 76 对query(包括原始query、各种翻译query 都做匹配)
71 77
72 -意图识别层:  
73 -如果召回 判断有款式需求, 78 +以颜色意图为例:
  79 +有一个词表,每一行 都逗号分割,互为同义词,行内第一个为标准化词
  80 +query匹配了其中任何一个词,都认为,具有颜色意图
  81 +匹配规则: 用细粒度、粗粒度分词,看是否有在词表中的。原始query分词、和每种翻译的分词,都要用。
  82 +
  83 +意图判断: 暂时留空,直接返回true。目前没有模型,即只要召回了(词表匹配了),即认为有该维度款式需求。
  84 +
  85 +
  86 +
  87 +意图使用:
  88 +
  89 +我们第一阶段,使用 参与ES提权。
  90 +
  91 +一、参与ES提权
  92 +
  93 +
  94 +二、参与reranker
74 95
75 96
76 -是否有:  
77 -颜色需求  
78 -尺码需求  
79 如果有: 先做sku筛选,然后把最优的拼接到名称中,参与reranker。 97 如果有: 先做sku筛选,然后把最优的拼接到名称中,参与reranker。
80 98
81 99
82 现在在reranker、分页之后、做填充的时候,已经有做sku的筛选。 100 现在在reranker、分页之后、做填充的时候,已经有做sku的筛选。
83 需要优化: 101 需要优化:
84 现在是,先做包含的判断,找到第一个 option_value被query包含的,则直接认为匹配。改为 102 现在是,先做包含的判断,找到第一个 option_value被query包含的,则直接认为匹配。改为
85 -1. 第一轮:遍历完,如果有且仅有一个才这样。  
86 -2. 第二轮:如果有多个,跳到3。如果没有,对每个词都走泛化词表进行匹配。 103 +1. 第一轮:遍历完,如果有且仅有一个被query包含,那么认为匹配。
  104 +2. 第二轮:如果有多个符合(被query包含),跳到3。如果没有,对每个词都走泛化词表进行匹配。
87 3. 第三轮:如果有多个,那么对这多个,走embedding相关性取最高的。如果一个也没有,则对所有的走embedding相关性取最高的 105 3. 第三轮:如果有多个,那么对这多个,走embedding相关性取最高的。如果一个也没有,则对所有的走embedding相关性取最高的
88 -这个sku筛选也需要提取为一个独立的模块 106 +这个sku筛选也需要提取为一个独立的模块。
  107 +
  108 +
  109 +另外:现在是reranker、分页之后做sku筛选,要改为:
  110 +1. 有款式意图的时候,才做sku筛选
  111 +2. sku筛选的时机,改为在reranker之前,对所有内容做sku筛选,然后
  112 +3. 从仅 option1 扩展到多个维度,识别的意图,包含意图的维度名(color)和维度名的泛化词list(color、颜色、colour、olors、、、、),遍历option1_name,option2_name,option3_name,看哪个能匹配上意图的维度名list,哪个匹配上了,则在这个维度筛选。
  113 +4. Rerank doc (有款式意图的时候)要带上属性后缀,拼接到title后面。在调用 run_rerank 前,对每条 hit 生成「用于重排的 doc 文本」(标题 + 可选后缀)
  114 +5. TODO : 还有一个问题。 目前,sku只返回一个维度(店铺主维度。默认应该是option1,不是所有维度的sku信息都返回的。所以,如果有款式意图,但是主维度是颜色,那么拿不到全的款式sku)
  115 +
  116 +
  117 +
  118 +
  119 +当前项目功能已经较多,但是有清晰的框架,请务必基于现有框架进行改造,不要进行补丁式的修改,避免代码逻辑分叉。
  120 +
  121 +请一步一步来,先设计意图识别模块,仔细思考需求,意图识别模块需要提供哪些内容,用于返回数据接口的定义,深度思考,定义一个合理的接口后,再给出合理的模块设计。
  122 +
  123 +
  124 +
  125 +
  126 +
  127 +
  128 +
  129 +
89 130
90 131
91 132
@@ -398,6 +439,31 @@ embeddings/image_encoder.py:requests.post(..., timeout=self.timeout_sec) @@ -398,6 +439,31 @@ embeddings/image_encoder.py:requests.post(..., timeout=self.timeout_sec)
398 439
399 440
400 441
  442 +
  443 +
  444 +
  445 +
  446 +
  447 +
  448 +
  449 +多reranker:
  450 +
  451 +改 reranker 服务,一次请求返回多路分
  452 +服务启动时 加载多个 backend(或按请求懒加载),/rerank 响应扩展为例如
  453 +scores: [...](兼容主后端)+ scores_by_backend: { "bge": [...], "qwen3_vllm": [...] }。
  454 +搜索侧解析多路分,再融合或只透传 debug。
  455 +优点:搜索侧仍只调一个 URL。缺点:单进程多大模型 显存压力很大;
  456 +
  457 +融合层要注意的一点
  458 +fuse_scores_and_resort 目前只消费 一条 rerank_scores 序列,并写入 _rerank_score
  459 +多 backend 之后需要rerank_scores 都参与融合
  460 +
  461 +
  462 +
  463 +
  464 +
  465 +
  466 +
401 product_enrich : Partial Mode : done 467 product_enrich : Partial Mode : done
402 https://help.aliyun.com/zh/model-studio/partial-mode?spm=a2c4g.11186623.help-menu-2400256.d_0_3_0_7.74a630119Ct6zR 468 https://help.aliyun.com/zh/model-studio/partial-mode?spm=a2c4g.11186623.help-menu-2400256.d_0_3_0_7.74a630119Ct6zR
403 需在messages 数组中将最后一条消息的 role 设置为 assistant,并在其 content 中提供前缀,在此消息中设置参数 "partial": true。messages格式如下: 469 需在messages 数组中将最后一条消息的 role 设置为 assistant,并在其 content 中提供前缀,在此消息中设置参数 "partial": true。messages格式如下:
indexer/document_transformer.py
@@ -13,7 +13,7 @@ import numpy as np @@ -13,7 +13,7 @@ import numpy as np
13 import logging 13 import logging
14 import re 14 import re
15 from typing import Dict, Any, Optional, List 15 from typing import Dict, Any, Optional, List
16 -from indexer.product_enrich import analyze_products 16 +from indexer.product_enrich import analyze_products, split_multi_value_field
17 17
18 logger = logging.getLogger(__name__) 18 logger = logging.getLogger(__name__)
19 19
@@ -121,7 +121,7 @@ class SPUDocumentTransformer: @@ -121,7 +121,7 @@ class SPUDocumentTransformer:
121 # Tags 121 # Tags
122 if pd.notna(spu_row.get('tags')): 122 if pd.notna(spu_row.get('tags')):
123 tags_str = str(spu_row['tags']) 123 tags_str = str(spu_row['tags'])
124 - doc['tags'] = [tag.strip() for tag in tags_str.split(',') if tag.strip()] 124 + doc['tags'] = split_multi_value_field(tags_str)
125 125
126 # Category相关字段 126 # Category相关字段
127 self._fill_category_fields(doc, spu_row) 127 self._fill_category_fields(doc, spu_row)
@@ -282,11 +282,7 @@ class SPUDocumentTransformer: @@ -282,11 +282,7 @@ class SPUDocumentTransformer:
282 raw = row.get(name) 282 raw = row.get(name)
283 if not raw: 283 if not raw:
284 continue 284 continue
285 - parts = re.split(r"[,;|/\n\t]+", str(raw))  
286 - for part in parts:  
287 - value = part.strip()  
288 - if not value:  
289 - continue 285 + for value in split_multi_value_field(str(raw)):
290 semantic_list.append({"lang": lang, "name": name, "value": value}) 286 semantic_list.append({"lang": lang, "name": name, "value": value})
291 287
292 if qanchors_obj: 288 if qanchors_obj:
@@ -703,11 +699,7 @@ class SPUDocumentTransformer: @@ -703,11 +699,7 @@ class SPUDocumentTransformer:
703 raw = row.get(name) 699 raw = row.get(name)
704 if not raw: 700 if not raw:
705 continue 701 continue
706 - parts = re.split(r"[,;|/\n\t]+", str(raw))  
707 - for part in parts:  
708 - value = part.strip()  
709 - if not value:  
710 - continue 702 + for value in split_multi_value_field(str(raw)):
711 semantic_list.append( 703 semantic_list.append(
712 { 704 {
713 "lang": lang, 705 "lang": lang,
indexer/product_enrich.py
@@ -144,6 +144,20 @@ if _missing_prompt_langs: @@ -144,6 +144,20 @@ if _missing_prompt_langs:
144 ) 144 )
145 145
146 146
  147 +# 多值字段分隔:英文逗号、中文逗号、顿号,及历史约定的 ; | / 与空白
  148 +_MULTI_VALUE_FIELD_SPLIT_RE = re.compile(r"[,、,;|/\n\t]+")
  149 +
  150 +
  151 +def split_multi_value_field(text: Optional[str]) -> List[str]:
  152 + """将 LLM/业务中的多值字符串拆成短语列表(strip 后去空)。"""
  153 + if text is None:
  154 + return []
  155 + s = str(text).strip()
  156 + if not s:
  157 + return []
  158 + return [p.strip() for p in _MULTI_VALUE_FIELD_SPLIT_RE.split(s) if p.strip()]
  159 +
  160 +
147 def _normalize_space(text: str) -> str: 161 def _normalize_space(text: str) -> str:
148 return re.sub(r"\s+", " ", (text or "").strip()) 162 return re.sub(r"\s+", " ", (text or "").strip())
149 163
search/es_query_builder.py
@@ -38,6 +38,10 @@ class ESQueryBuilder: @@ -38,6 +38,10 @@ class ESQueryBuilder:
38 translation_boost: float = 0.4, 38 translation_boost: float = 0.4,
39 tie_breaker_base_query: float = 0.9, 39 tie_breaker_base_query: float = 0.9,
40 mixed_script_merged_field_boost_scale: float = 0.6, 40 mixed_script_merged_field_boost_scale: float = 0.6,
  41 + phrase_match_base_fields: Optional[Tuple[str, ...]] = None,
  42 + phrase_match_slop: int = 2,
  43 + phrase_match_tie_breaker: float = 0.4,
  44 + phrase_match_boost: float = 3.0,
41 ): 45 ):
42 """ 46 """
43 Initialize query builder. 47 Initialize query builder.
@@ -73,6 +77,10 @@ class ESQueryBuilder: @@ -73,6 +77,10 @@ class ESQueryBuilder:
73 self.translation_boost = float(translation_boost) 77 self.translation_boost = float(translation_boost)
74 self.tie_breaker_base_query = float(tie_breaker_base_query) 78 self.tie_breaker_base_query = float(tie_breaker_base_query)
75 self.mixed_script_merged_field_boost_scale = float(mixed_script_merged_field_boost_scale) 79 self.mixed_script_merged_field_boost_scale = float(mixed_script_merged_field_boost_scale)
  80 + self.phrase_match_base_fields = tuple(phrase_match_base_fields or ("title", "qanchors"))
  81 + self.phrase_match_slop = int(phrase_match_slop)
  82 + self.phrase_match_tie_breaker = float(phrase_match_tie_breaker)
  83 + self.phrase_match_boost = float(phrase_match_boost)
76 84
77 def _apply_source_filter(self, es_query: Dict[str, Any]) -> None: 85 def _apply_source_filter(self, es_query: Dict[str, Any]) -> None:
78 """ 86 """
@@ -453,6 +461,49 @@ class ESQueryBuilder: @@ -453,6 +461,49 @@ class ESQueryBuilder:
453 """Format (field_path, boost) pairs for Elasticsearch multi_match ``fields``.""" 461 """Format (field_path, boost) pairs for Elasticsearch multi_match ``fields``."""
454 return [self._format_field_with_boost(path, boost) for path, boost in specs] 462 return [self._format_field_with_boost(path, boost) for path, boost in specs]
455 463
  464 + def _build_phrase_match_fields(self, language: str) -> List[str]:
  465 + """Fields for phrase multi_match: base names × ``.{lang}`` with ``field_boosts``."""
  466 + lang = (language or "").strip().lower()
  467 + if not lang:
  468 + return []
  469 + out: List[str] = []
  470 + for base in self.phrase_match_base_fields:
  471 + path = f"{base}.{lang}"
  472 + boost = self._get_field_boost(base, lang)
  473 + out.append(self._format_field_with_boost(path, boost))
  474 + return out
  475 +
  476 + def _append_phrase_should_clause(
  477 + self,
  478 + should_clauses: List[Dict[str, Any]],
  479 + lang: str,
  480 + lang_query: str,
  481 + clause_name: str,
  482 + is_source: bool,
  483 + ) -> None:
  484 + text = (lang_query or "").strip()
  485 + if not text:
  486 + return
  487 + phrase_fields = self._build_phrase_match_fields(lang)
  488 + if not phrase_fields:
  489 + return
  490 + boost = (
  491 + self.phrase_match_boost
  492 + if is_source
  493 + else self.phrase_match_boost * float(self.translation_boost)
  494 + )
  495 + should_clauses.append({
  496 + "multi_match": {
  497 + "_name": f"{clause_name}_phrase",
  498 + "query": lang_query,
  499 + "type": "phrase",
  500 + "fields": phrase_fields,
  501 + "slop": self.phrase_match_slop,
  502 + "tie_breaker": self.phrase_match_tie_breaker,
  503 + "boost": boost,
  504 + }
  505 + })
  506 +
456 def _merge_supplemental_lang_field_specs( 507 def _merge_supplemental_lang_field_specs(
457 self, 508 self,
458 specs: List[MatchFieldSpec], 509 specs: List[MatchFieldSpec],
@@ -590,6 +641,9 @@ class ESQueryBuilder: @@ -590,6 +641,9 @@ class ESQueryBuilder:
590 should_clauses.append({ 641 should_clauses.append({
591 "multi_match": clause["multi_match"] 642 "multi_match": clause["multi_match"]
592 }) 643 })
  644 + self._append_phrase_should_clause(
  645 + should_clauses, lang, lang_query, clause_name, is_source
  646 + )
593 647
594 if base_query_text: 648 if base_query_text:
595 append_clause(source_lang, base_query_text, "base_query", True) 649 append_clause(source_lang, base_query_text, "base_query", True)
@@ -606,7 +660,7 @@ class ESQueryBuilder: @@ -606,7 +660,7 @@ class ESQueryBuilder:
606 # Fallback to a simple query when language fields cannot be resolved. 660 # Fallback to a simple query when language fields cannot be resolved.
607 if not should_clauses: 661 if not should_clauses:
608 fallback_fields = self.match_fields or ["title.en^1.0"] 662 fallback_fields = self.match_fields or ["title.en^1.0"]
609 - return { 663 + fallback_lexical = {
610 "multi_match": { 664 "multi_match": {
611 "_name": "base_query_fallback", 665 "_name": "base_query_fallback",
612 "query": query_text, 666 "query": query_text,
@@ -615,6 +669,22 @@ class ESQueryBuilder: @@ -615,6 +669,22 @@ class ESQueryBuilder:
615 "tie_breaker": self.tie_breaker_base_query, 669 "tie_breaker": self.tie_breaker_base_query,
616 } 670 }
617 } 671 }
  672 + fb_should: List[Dict[str, Any]] = [fallback_lexical]
  673 + self._append_phrase_should_clause(
  674 + fb_should,
  675 + self.default_language,
  676 + query_text,
  677 + "base_query_fallback",
  678 + True,
  679 + )
  680 + if len(fb_should) == 1:
  681 + return fallback_lexical
  682 + return {
  683 + "bool": {
  684 + "should": fb_should,
  685 + "minimum_should_match": 1,
  686 + }
  687 + }
618 688
619 # Return bool query with should clauses 689 # Return bool query with should clauses
620 if len(should_clauses) == 1: 690 if len(should_clauses) == 1:
suggestion/builder.py
@@ -147,7 +147,7 @@ class SuggestionIndexBuilder: @@ -147,7 +147,7 @@ class SuggestionIndexBuilder:
147 raw = str(value).strip() 147 raw = str(value).strip()
148 if not raw: 148 if not raw:
149 return [] 149 return []
150 - parts = re.split(r"[,;|/\n\t]+", raw) 150 + parts = re.split(r"[,、,;|/\n\t]+", raw)
151 out = [p.strip() for p in parts if p and p.strip()] 151 out = [p.strip() for p in parts if p and p.strip()]
152 if not out: 152 if not out:
153 return [raw] 153 return [raw]
@@ -162,7 +162,7 @@ class SuggestionIndexBuilder: @@ -162,7 +162,7 @@ class SuggestionIndexBuilder:
162 s = str(raw).strip() 162 s = str(raw).strip()
163 if not s: 163 if not s:
164 return [] 164 return []
165 - parts = re.split(r"[,;|/\n\t]+", s) 165 + parts = re.split(r"[,、,;|/\n\t]+", s)
166 out = [p.strip() for p in parts if p and p.strip()] 166 out = [p.strip() for p in parts if p and p.strip()]
167 return out if out else [s] 167 return out if out else [s]
168 168