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 449 同步执行内容理解:调用 product_enrich.analyze_products,按语言批量跑 LLM,
450 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 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 510 raw = row.get(name)
511 511 if not raw:
512 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 514 rec["semantic_attributes"].append({"lang": lang, "name": name, "value": value})
518 515 if name == "tags":
519 516 rec["tags"].append(value)
... ...
config/config.yaml
... ... @@ -75,16 +75,15 @@ es_settings:
75 75 # 若需要按某个语言单独调权,也可以加显式 key(例如 title.de: 3.2)。
76 76 field_boosts:
77 77 title: 3.0
  78 + qanchors: 2.5
  79 + tags: 2.0
  80 + category_name_text: 2.0
  81 + category_path: 2.0
78 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 88 # Query Configuration(查询配置)
90 89 query_config:
... ... @@ -122,11 +121,11 @@ query_config:
122 121 search_fields:
123 122 multilingual_fields:
124 123 - "title"
125   - - "brief"
126   - - "description"
127   - - "vendor"
  124 + - "qanchors"
128 125 - "category_path"
129 126 - "category_name_text"
  127 + - "brief"
  128 + - "description"
130 129 shared_fields:
131 130 - "tags"
132 131 - "option1_values"
... ... @@ -135,15 +134,14 @@ query_config:
135 134 core_multilingual_fields:
136 135 - "title"
137 136 - "brief"
138   - - "vendor"
139 137 - "category_name_text"
140 138  
141 139 # 统一文本召回策略(主查询 + 翻译查询)
142 140 text_query_strategy:
143 141 base_minimum_should_match: "75%"
144 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 146 # Embedding字段名称
149 147 text_embedding_field: "title_embedding"
... ...
docs/TODO.txt
1 1  
  2 +
  3 +
  4 +本地部署一个7b Q4量化的大模型
  5 +es需要licence的两个功能,如果费用低,开通下licence,或者改es源码定制开发下,支持 rank.rrf,reranker
  6 +
  7 +
  8 +
2 9 把knn跟文本相关性的融合方式修改为 "rank": {"rrf": {} }需要licence,可以帮我修改源码支持吗?
3 10  
4 11 knn_boost: 2.0
... ... @@ -13,8 +20,6 @@
13 20 }
14 21  
15 22  
16   -
17   -
18 23 "image_embedding": {
19 24 "type": "nested",
20 25 "properties": {
... ... @@ -58,34 +63,70 @@ image_embedding改为,一个spu有多个sku向量,每个向量内部properti
58 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 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 97 如果有: 先做sku筛选,然后把最优的拼接到名称中,参与reranker。
80 98  
81 99  
82 100 现在在reranker、分页之后、做填充的时候,已经有做sku的筛选。
83 101 需要优化:
84 102 现在是,先做包含的判断,找到第一个 option_value被query包含的,则直接认为匹配。改为
85   -1. 第一轮:遍历完,如果有且仅有一个才这样。
86   -2. 第二轮:如果有多个,跳到3。如果没有,对每个词都走泛化词表进行匹配。
  103 +1. 第一轮:遍历完,如果有且仅有一个被query包含,那么认为匹配。
  104 +2. 第二轮:如果有多个符合(被query包含),跳到3。如果没有,对每个词都走泛化词表进行匹配。
87 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 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 467 product_enrich : Partial Mode : done
402 468 https://help.aliyun.com/zh/model-studio/partial-mode?spm=a2c4g.11186623.help-menu-2400256.d_0_3_0_7.74a630119Ct6zR
403 469 需在messages 数组中将最后一条消息的 role 设置为 assistant,并在其 content 中提供前缀,在此消息中设置参数 "partial": true。messages格式如下:
... ...
indexer/document_transformer.py
... ... @@ -13,7 +13,7 @@ import numpy as np
13 13 import logging
14 14 import re
15 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 18 logger = logging.getLogger(__name__)
19 19  
... ... @@ -121,7 +121,7 @@ class SPUDocumentTransformer:
121 121 # Tags
122 122 if pd.notna(spu_row.get('tags')):
123 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 126 # Category相关字段
127 127 self._fill_category_fields(doc, spu_row)
... ... @@ -282,11 +282,7 @@ class SPUDocumentTransformer:
282 282 raw = row.get(name)
283 283 if not raw:
284 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 286 semantic_list.append({"lang": lang, "name": name, "value": value})
291 287  
292 288 if qanchors_obj:
... ... @@ -703,11 +699,7 @@ class SPUDocumentTransformer:
703 699 raw = row.get(name)
704 700 if not raw:
705 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 703 semantic_list.append(
712 704 {
713 705 "lang": lang,
... ...
indexer/product_enrich.py
... ... @@ -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 161 def _normalize_space(text: str) -> str:
148 162 return re.sub(r"\s+", " ", (text or "").strip())
149 163  
... ...
search/es_query_builder.py
... ... @@ -38,6 +38,10 @@ class ESQueryBuilder:
38 38 translation_boost: float = 0.4,
39 39 tie_breaker_base_query: float = 0.9,
40 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 47 Initialize query builder.
... ... @@ -73,6 +77,10 @@ class ESQueryBuilder:
73 77 self.translation_boost = float(translation_boost)
74 78 self.tie_breaker_base_query = float(tie_breaker_base_query)
75 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 85 def _apply_source_filter(self, es_query: Dict[str, Any]) -> None:
78 86 """
... ... @@ -453,6 +461,49 @@ class ESQueryBuilder:
453 461 """Format (field_path, boost) pairs for Elasticsearch multi_match ``fields``."""
454 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 507 def _merge_supplemental_lang_field_specs(
457 508 self,
458 509 specs: List[MatchFieldSpec],
... ... @@ -590,6 +641,9 @@ class ESQueryBuilder:
590 641 should_clauses.append({
591 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 648 if base_query_text:
595 649 append_clause(source_lang, base_query_text, "base_query", True)
... ... @@ -606,7 +660,7 @@ class ESQueryBuilder:
606 660 # Fallback to a simple query when language fields cannot be resolved.
607 661 if not should_clauses:
608 662 fallback_fields = self.match_fields or ["title.en^1.0"]
609   - return {
  663 + fallback_lexical = {
610 664 "multi_match": {
611 665 "_name": "base_query_fallback",
612 666 "query": query_text,
... ... @@ -615,6 +669,22 @@ class ESQueryBuilder:
615 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 689 # Return bool query with should clauses
620 690 if len(should_clauses) == 1:
... ...
suggestion/builder.py
... ... @@ -147,7 +147,7 @@ class SuggestionIndexBuilder:
147 147 raw = str(value).strip()
148 148 if not raw:
149 149 return []
150   - parts = re.split(r"[,;|/\n\t]+", raw)
  150 + parts = re.split(r"[,、,;|/\n\t]+", raw)
151 151 out = [p.strip() for p in parts if p and p.strip()]
152 152 if not out:
153 153 return [raw]
... ... @@ -162,7 +162,7 @@ class SuggestionIndexBuilder:
162 162 s = str(raw).strip()
163 163 if not s:
164 164 return []
165   - parts = re.split(r"[,;|/\n\t]+", s)
  165 + parts = re.split(r"[,、,;|/\n\t]+", s)
166 166 out = [p.strip() for p in parts if p and p.strip()]
167 167 return out if out else [s]
168 168  
... ...