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,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" |
docs/TODO.txt
| 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 |