Commit 69881ecbacf6ca440552327e33d42e923679c9ec
1 parent
8140e942
相关性调参、enrich内容解析优化
Showing
7 changed files
with
185 additions
and
48 deletions
Show diff stats
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 | ... | ... |