diff --git a/config/config.yaml b/config/config.yaml index fc4ae13..3ec837e 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -81,6 +81,7 @@ field_boosts: category_path: 2.0 brief: 1.5 description: 1.5 + vendor: 1.5 option1_values: 1.5 option2_values: 1.5 option3_values: 1.5 @@ -126,6 +127,7 @@ query_config: - "category_name_text" - "brief" - "description" + - "vendor" shared_fields: - "tags" - "option1_values" @@ -133,7 +135,7 @@ query_config: - "option3_values" core_multilingual_fields: - "title" - - "brief" + - "qanchors" - "category_name_text" # 统一文本召回策略(主查询 + 翻译查询) @@ -142,6 +144,15 @@ query_config: translation_minimum_should_match: "75%" translation_boost: 0.75 tie_breaker_base_query: 0.5 + best_fields_boost: 2.0 + best_fields: + title: 4.0 + qanchors: 3.0 + category_name_text: 2.0 + phrase_fields: + title: 5.0 + qanchors: 4.0 + phrase_match_boost: 3.0 # Embedding字段名称 text_embedding_field: "title_embedding" diff --git a/config/loader.py b/config/loader.py index 6e8ddb7..d2892f9 100644 --- a/config/loader.py +++ b/config/loader.py @@ -285,6 +285,16 @@ class AppConfigLoader: translation_minimum_should_match=str(text_strategy.get("translation_minimum_should_match", "70%")), translation_boost=float(text_strategy.get("translation_boost", 0.4)), tie_breaker_base_query=float(text_strategy.get("tie_breaker_base_query", 0.9)), + best_fields={ + str(field): float(boost) + for field, boost in dict(text_strategy.get("best_fields") or {}).items() + }, + best_fields_boost=float(text_strategy.get("best_fields_boost", 2.0)), + phrase_fields={ + str(field): float(boost) + for field, boost in dict(text_strategy.get("phrase_fields") or {}).items() + }, + phrase_match_boost=float(text_strategy.get("phrase_match_boost", 3.0)), zh_to_en_model=str(query_cfg.get("zh_to_en_model") or "opus-mt-zh-en"), en_to_zh_model=str(query_cfg.get("en_to_zh_model") or "opus-mt-en-zh"), default_translation_model=str( diff --git a/config/schema.py b/config/schema.py index 13e2ce0..a3427c6 100644 --- a/config/schema.py +++ b/config/schema.py @@ -55,6 +55,10 @@ class QueryConfig: translation_minimum_should_match: str = "70%" translation_boost: float = 0.4 tie_breaker_base_query: float = 0.9 + best_fields: Dict[str, float] = field(default_factory=dict) + best_fields_boost: float = 2.0 + phrase_fields: Dict[str, float] = field(default_factory=dict) + phrase_match_boost: float = 3.0 zh_to_en_model: str = "opus-mt-zh-en" en_to_zh_model: str = "opus-mt-en-zh" default_translation_model: str = "nllb-200-distilled-600m" diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..21311e0 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,596 @@ +项目 TODO 清单 + +2. 核心搜索功能优化 + +2.1 意图识别模块 + +- 增加款式意图识别模块 + +- 意图类型: 颜色,尺码(目前只需要支持这两种) + +- 意图召回层: +每种意图,有一个召回词集合 +对query(包括原始query、各种翻译query 都做匹配) + +- 以颜色意图为例: +有一个词表,每一行 都逗号分割,互为同义词,行内第一个为标准化词 +query匹配了其中任何一个词,都认为,具有颜色意图 +匹配规则: 用细粒度、粗粒度分词,看是否有在词表中的。原始query分词、和每种翻译的分词,都要用。 + +- 意图判断: + 暂时留空,直接返回true。目前没有模型,即只要召回了(词表匹配了),即认为有该维度款式需求。 + (以后考虑建设fasttext/bert系列多分类模型) + +- 意图使用: +我们第一阶段,使用 参与ES提权。 + +- 一、参与ES提权 + +- 二、参与reranker + +- 如果有: 先做sku筛选,然后把最优的拼接到名称中,参与reranker。 + + + +- 现在在reranker、分页之后、做填充的时候,已经有做sku的筛选。 +需要优化: +现在是,先做包含的判断,找到第一个 option_value被query包含的,则直接认为匹配。改为 + 1. 第一轮:遍历完,如果有且仅有一个被query包含,那么认为匹配。 + 2. 第二轮:如果有多个符合(被query包含),跳到3。如果没有,对每个词都走泛化词表进行匹配。 + 3. 第三轮:如果有多个,那么对这多个,走embedding相关性取最高的。如果一个也没有,则对所有的走embedding相关性取最高的 + 这个sku筛选也需要提取为一个独立的模块。 + +- 另外:现在是reranker、分页之后做sku筛选,要改为: + 1. 有款式意图的时候,才做sku筛选 + 2. sku筛选的时机,改为在reranker之前,对所有内容做sku筛选,然后 + 3. 从仅 option1 扩展到多个维度,识别的意图,包含意图的维度名(color)和维度名的泛化词list(color、颜色、colour、olors、、、、),遍历option1_name,option2_name,option3_name,看哪个能匹配上意图的维度名list,哪个匹配上了,则在这个维度筛选。 + 4. Rerank doc (有款式意图的时候)要带上属性后缀,拼接到title后面。在调用 run_rerank 前,对每条 hit 生成「用于重排的 doc 文本」(标题 + 可选后缀) + 5. TODO : 还有一个问题。 目前,sku只返回一个维度(店铺主维度。默认应该是option1,不是所有维度的sku信息都返回的。所以,如果有款式意图,但是主维度是颜色,那么拿不到全的款式sku) + +- 筛选SKU: 先只筛选第一个维度,但考虑到用户搜索词可能带了尺码,所以第二、三个维度也要考虑 + + 当前项目功能已经较多,但是有清晰的框架,请务必基于现有框架进行改造,不要进行补丁式的修改,避免代码逻辑分叉。 + 请一步一步来,先设计意图识别模块,仔细思考需求,意图识别模块需要提供哪些内容,用于返回数据接口的定义,深度思考,定义一个合理的接口后,再给出合理的模块设计。 + +2.3 向量检索与融合 + +- 把knn跟文本相关性的融合方式修改为 "rank": {"rrf": {} }需要licence,可以帮我修改源码支持吗? + + knn_boost: 2.0 + + { + "query": { ...全文检索... }, + "knn": { ...向量检索... }, + "rank": { + "rrf": {} + } + } + +- 融合打分(已完成,2026-03) + + 以下已经完成: + 1. fuse_scores_and_resort 已改为乘法融合,并通过 matched_queries 提取: + - base_query + - base_query_trans_* + - fallback_original_query_* + - knn_query + 2. 文本相关性大分不再依赖 phrase_query / keywords_query,这两类查询已清理。 + 3. 当前融合策略: + - text_score = primary(weighted_source, weighted_translation, weighted_fallback) + 0.25 * support + - fused_score = (rerank_score + 0.00001) * (text_score + 0.1) ** 0.35 * (knn_score + 0.6) ** 0.2 + 4. track_scores 与 include_named_queries_score 已接入,调试字段与评估方法已同步到: + - docs/相关性检索优化说明.md + - docs/搜索API对接指南.md + - docs/Usage-Guide.md + + 未完成的: + (归一化、次序融合?还乘法公式?) + RRF:先把多路召回稳妥融合 + linear + minmax:让你能精调 knn 和文本的权重 + reranker:对前面召回出来的 top-k 再做“最后一刀” + +2.4 文本相关性优化 + +- 调研: +Princeton WordNet — 英文同义词底库 +Shopify Product Taxonomy — 电商品类标准 +Querqy — 电商搜索规则框架 +gensimpson/elasticsearch-synonyms — ES 同义词规则落地 + +- tags字段使用的优化: +现在是keyword,在搜索中,不太好使用(目前主要用于suggest)。 +可以考虑也拆分多语言,配合analyzer使用(和qanchors一样) + +- 是否需要: +当「源语言不在 index_languages」且「某些目标语言的翻译缺失」时,ES 里会额外加一层 用「原始 query 字符串」去撞缺失语种字段 + +- 检索相关性优化: +原始搜索词和翻译的词,都需要有对应的主干分析 +这个主干可以根据词性简单提取名词即可 +在搜索时,原始词和主干都成对地出现,原始词和trunk_keywords一起组成一个或查询。 +有一种方案是把原始词和主干词拼接起来。但是bm25要调tf系数。 + +2.5 图片相关性与向量字段调整 + +- "image_embedding": { + "type": "nested", + "properties": { + "vector": { + "type": "dense_vector", + "dims": 1024, + "index": true, + "similarity": "dot_product", + "element_type": "bfloat16" + }, + "url": { + "type": "text" + } + } +}, +去掉 image_embedding_512 +image_embedding改为,一个spu有多个sku向量,每个向量内部properties: +除了vector url还应该包括,该图片是对应哪些sku +"image_embedding": { + "type": "nested", + "properties": { + "vector": { + "type": "dense_vector", + "dims": 1024, + "index": true, + "similarity": "dot_product", + "element_type": "bfloat16" + }, + "url": { + "type": "text" + } + } +}, + +- 引入图片的相关性: +图片的向量最好做SKU维度,用 SPU 维度还是 SKU 维度? + 1. SKU维度(主款式,option1维度),如果用户搜索“蓝色 T恤”,这种图片相关性会比较有价值。 + 2. 我不考虑颜色的差异,其余的款式一般是大小之类的。这些图片,embedding细分到 SKU 维度,可能价值不大,性价比偏低 + +- 属性的筛选: +训练一个bert/transformer多分类模型,分类: 颜色、尺寸、材质 等等。但是要注意一些属性的值不规范、非常多,要考虑 是不是做规范化,如何规范化。 + +2.6 无结果重查与翻译缺失处理 + +- 无结果重查 +稀有语言,翻译可能超时(因为zh-en互译之外的翻译耗时更长) + + +--- + +3. 模型与推理服务优化 + +3.1 大模型API与本地部署 + +- 外部需求: + 1. 对推理能力要求很低、对耗时要求很高的大模型API(或者本地部署一个7b Q4量化的大模型),prompt大概30-50个token,首token响应要求500ms以内 + 2. ES支持reranker pipline? + +- 本地部署一个7b Q4量化的大模型 + +3.2 Embedding服务优化 + +- 先阅读文本embedding相关的代码: +@embeddings/README.md @embeddings/server.py @docs/搜索API对接指南-07-微服务接口(Embedding-Reranker-Translation).md @embeddings/text_encoder.py +目前有TEXT_MAX_INFLIGHT / IMAGE_MAX_INFLIGHT 准入限制,超限返回过载状态码。 + +- 文本embedding服务,要支持 priority 查询参数,priority > 0:不计入上述 inflight、不会因准入被拒绝(图片embedding不需要支持,因为只有离线需要用到图片embedding) +priority == 0(默认,适合做索引之类的离线任务):仍走原有 TEXT_MAX_INFLIGHT / IMAGE_MAX_INFLIGHT 准入;超限返回过载状态码。 +priority > 0(或者==1)(适合在线请求):不会因准入被拒绝,但是仍然需要占用inflight,这样保证在线请求不被限制,并且在线请求很多的时候可以拒绝掉离线的请求。 + +- 除了限制规则的修改,更进一步的,也需要保证这种请求是优先处理的(priority=1的相比=0的更优先被处理)。 +关于技术方案,有Worker + 双队列、PriorityMutex等等,除此之外,也请你思考合适的方案。 +成熟稳定、不带来复杂度、性能、稳定性方面的副作用,是最重要的。请先了解代码、需求,深度思考解决方案 + +- 向量的缓存 + +3.3 Reranker优化 + +- 多reranker: +改 reranker 服务,一次请求返回多路分 +服务启动时 加载多个 backend(或按请求懒加载),/rerank 响应扩展为例如 +scores: [...](兼容主后端)+ scores_by_backend: { "bge": [...], "qwen3_vllm": [...] }。 +搜索侧解析多路分,再融合或只透传 debug。 +优点:搜索侧仍只调一个 URL。缺点:单进程多大模型 显存压力很大; + +- 融合层要注意的一点 +fuse_scores_and_resort 目前只消费 一条 rerank_scores 序列,并写入 _rerank_score +多 backend 之后需要rerank_scores 都参与融合 + +- 必要性: +见 qwen3-reranker和bge-m3的严重badcase +不一定是要多reranker的方式,但是一定会需要解决方案。 + +- reranker 补充:nvidia/llama-nemotron-rerank-1b-v2 +https://huggingface.co/nvidia/llama-nemotron-rerank-1b-v2 +后端推理也建议使用vLLM +注意搜索相关资料,挖掘我的特斯拉 T4 GPU 的性能,充分挖掘性能 +你有充足的自由度进行实验 +encoder架构。 +比较新。 +性能更好。 +亚马逊 电商搜索数据集比qwen-reranker-4b更好。 +支持vLLM。 + +- Qwen3-Reranker-4B-GGUF +https://modelscope.cn/models/dengcao/Qwen3-Reranker-4B-GGUF/summary + 1. 要确定选择哪种量化方式 + 2. 确定提示词 + +- qwen3-embedding、qwen3-reranker (done) +选一个推理引擎,相比于我自己直接调 sentence-transformers,主要是多进程和负载均衡、连续批处理,比较有用 +当前结论:embedding 场景优先 TEI;vLLM 更偏向生成式与 rerank 场景。 + +- rerank 性能优化 + +3.4 翻译模型优化 + +- 翻译,增加facebook/nllb-200-distilled-600M +https://blog.csdn.net/qq_42746084/article/details/154947534 +https://huggingface.co/facebook/nllb-200-distilled-600M + +- 店铺的语言:英语能占到80%,所以专门增加一个en-zh的 +https://huggingface.co/Helsinki-NLP/opus-mt-zh-en +https://huggingface.co/Helsinki-NLP/opus-mt-en-zh + +- opus-mt-zh-en + + from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + model_name = "./models/opus-mt-en-zh" + tokenizer = AutoTokenizer.from_pretrained(model_name) + model = AutoModelForSeq2SeqLM.from_pretrained(model_name) + data = 'test' + encoded = tokenizer([data], return_tensors="pt") + translation = model.generate(**encoded) + result = tokenizer.batch_decode(translation, skip_special_tokens=True)[0] + print(result) + +- nllb-200-distilled-600M性能优化 +已完成(2026-03) + - CTranslate2 迁移 + float16 转换 + - 扩展压测报告:perf_reports/20260318/translation_local_models_ct2/README.md + - T4 聚焦调优报告:perf_reports/20260318/translation_local_models_ct2_focus/README.md + - NLLB T4 商品标题专项报告:perf_reports/20260318/nllb_t4_product_names_ct2/README.md + - 当前结论: + - NLLB 在线默认推荐:ct2_inter_threads=4 + ct2_max_queued_batches=32 + ct2_batch_type=examples + ct2_decoding_length_mode=source(+8,min=32) + - opus-mt-zh-en 维持保守默认更稳 + - opus-mt-en-zh 如追求离线吞吐可继续做单独 profile + +- 请搜索nllb-200-distilled-600M这类seq2seq、transformer架构的模型,有哪些性能优化方案,提高线上翻译服务的吞吐量、降低耗时,搜索相关的在线推理服务方案,找到高性能的服务化方法 + +- 查看翻译的缓存情况 + +3.5 其他模型优化 + +- cnclip的性能优化 + + +--- + +4. 性能优化与超时配置 + +4.1 超时配置 + +- Query 分析阶段等待翻译/embedding 的硬超时 +配置文件位置:config/config.yaml +配置项:query_config.async_wait_timeout_ms: 80 +代码生效点:query/query_parser.py 使用该值换算成秒传给 wait(...) + +2. Embedding HTTP 调用超时(Text/Image) +不再使用任何环境变量覆盖(之前提到的 EMBEDDING_HTTP_TIMEOUT_SEC 已不采用) +配置文件位置:config/config.yaml +配置项:services.embedding.providers.http.timeout_sec(已在 YAML 里补了示例默认 60) +代码生效点: +embeddings/text_encoder.py:requests.post(..., timeout=self.timeout_sec) +embeddings/image_encoder.py:requests.post(..., timeout=self.timeout_sec) + +4.2 生成式服务优化(Partial Mode) + +- product_enrich : Partial Mode : done +https://help.aliyun.com/zh/model-studio/partial-mode?spm=a2c4g.11186623.help-menu-2400256.d_0_3_0_7.74a630119Ct6zR +需在messages 数组中将最后一条消息的 role 设置为 assistant,并在其 content 中提供前缀,在此消息中设置参数 "partial": true。messages格式如下: +[ + { + "role": "user", + "content": "请补全这个斐波那契函数,勿添加其它内容" + }, + { + "role": "assistant", + "content": "def calculate_fibonacci(n):\n if n <= 1:\n return n\n else:\n", + "partial": true + } +] +模型会以前缀内容为起点开始生成。 +支持 非思考模式。 + + +--- + +5. Elasticsearch相关 + +- es需要licence的两个功能,如果费用低,开通下licence,或者改es源码定制开发下,支持 rank.rrf,reranker + + { + "query": { ...全文检索... }, + "knn": { ...向量检索... }, + "rank": { + "rrf": {} + } + } + + +--- + +1. 配置体系重构 +Referring to @docs/config-system-review-and-redesign.md , most of the modifications have been completed. Could you conduct a review to check what else needs improvement in the configuration documentation system? Are there any outstanding issues? + +一、仍然存在大量通过环境变量获取配置的地方 +_SERVICE_KIND = (os.getenv("EMBEDDING_SERVICE_KIND", "all") or "all").strip().lower() +if _SERVICE_KIND not in {"all", "text", "image"}: + raise RuntimeError( + f"Invalid EMBEDDING_SERVICE_KIND={_SERVICE_KIND!r}; expected all, text, or image" + ) +_TEXT_ENABLED_BY_ENV = os.getenv("EMBEDDING_ENABLE_TEXT_MODEL", "true").lower() in ("1", "true", "yes") +_IMAGE_ENABLED_BY_ENV = os.getenv("EMBEDDING_ENABLE_IMAGE_MODEL", "true").lower() in ("1", "true", "yes") +open_text_model = _TEXT_ENABLED_BY_ENV and _SERVICE_KIND in {"all", "text"} +open_image_model = _IMAGE_ENABLED_BY_ENV and _SERVICE_KIND in {"all", "image"} + +_text_encode_lock = threading.Lock() +_image_encode_lock = threading.Lock() + +_TEXT_MICROBATCH_WINDOW_SEC = max( + 0.0, float(os.getenv("TEXT_MICROBATCH_WINDOW_MS", "4")) / 1000.0 +) +_TEXT_REQUEST_TIMEOUT_SEC = max( + 1.0, float(os.getenv("TEXT_REQUEST_TIMEOUT_SEC", "30")) +) +_TEXT_MAX_INFLIGHT = max(1, int(os.getenv("TEXT_MAX_INFLIGHT", "32"))) +_IMAGE_MAX_INFLIGHT = max(1, int(os.getenv("IMAGE_MAX_INFLIGHT", "1"))) +_OVERLOAD_STATUS_CODE = int(os.getenv("EMBEDDING_OVERLOAD_STATUS_CODE", "503")) +_LOG_PREVIEW_COUNT = max(1, int(os.getenv("EMBEDDING_LOG_PREVIEW_COUNT", "3"))) +_LOG_TEXT_PREVIEW_CHARS = max(32, int(os.getenv("EMBEDDING_LOG_TEXT_PREVIEW_CHARS", "120"))) +_LOG_IMAGE_PREVIEW_CHARS = max(32, int(os.getenv("EMBEDDING_LOG_IMAGE_PREVIEW_CHARS", "180"))) +_VECTOR_PREVIEW_DIMS = max(1, int(os.getenv("EMBEDDING_VECTOR_PREVIEW_DIMS", "6"))) +_CACHE_PREFIX = str(REDIS_CONFIG.get("embedding_cache_prefix", "embedding")).strip() or "embedding" + + + + + +还有这些写死的地址 @embedding/config.py + +self.TEI_BASE_URL = str(text_backend.get("base_url") or "http://127.0.0.1:8080") +self.TEI_TIMEOUT_SEC = int(text_backend.get("timeout_sec", 60)) + +self.USE_CLIP_AS_SERVICE = services.image_backend == "clip_as_service" +self.CLIP_AS_SERVICE_SERVER = str(image_backend.get("server") or "grpc://127.0.0.1:51000") + + + + +看起来似乎并没有完全遵循这些原则? +4. 重新设计的设计原则 +重新设计应遵循以下规则。 + +4.1 单一逻辑配置系统 +可以有多个文件,但不能有多个职责重叠的加载器。 +必须有一个加载器管道,能够生成一个类型化的 AppConfig 对象。 + +4.2 配置文件负责声明,解析代码负责解释,环境变量负责运行时注入 +职责应明确如下: +配置文件 +声明非敏感的目标行为和可部署的非敏感设置 +解析逻辑 +加载、合并、验证、规范化并暴露类型化的配置 +绝不发明隐藏的业务行为 +环境变量 +承载密钥和少量运行时/进程相关的值 +不随意地重新定义业务行为 + +4.3 整个系统采用单一的优先级规则 +除非明确豁免,否则每个配置类别都应遵循相同的合并模型。 + +4.4 业务行为不得有静默的隐式后备 +在启动时,如果必需的配置缺失或无效,应快速失败。 +不要静默地回退到诸如硬编码语言列表之类的遗留行为。 + +4.5 有效配置必须可观测 +每个服务都应能够展示: +配置版本或哈希值 +加载的源文件 +环境名称 +经过清理的有效配置 + +5. 推荐的目标设计 + +5.1 边界模型 +使用三个清晰的层级。 +层级 1:代码仓库管理的静态配置 +目的: +搜索行为 +租户行为 +提供商/后端注册表 +非敏感的服务拓扑默认值 +功能开关 +示例: +字段权重 +查询策略 +重排序融合参数 +租户语言方案 +翻译能力注册表 +嵌入后端选择默认值 + +层级 2:特定环境的层叠配置 +目的: +按环境区分的非敏感差异 +按环境区分的服务端点 +按环境区分的资源大小默认值 +开发/测试/生产环境的运维差异 +示例: +本地嵌入 URL 与生产环境嵌入 URL +开发环境重排序后端与生产环境重排序后端 +本地开发环境中较低的并发度 + +层级 3:环境变量 +目的: +密钥 +绑定主机/端口 +外部基础设施凭证 +容器编排器的最后一步注入 +示例: +ES_HOST, ES_USERNAME, ES_PASSWORD +DB_HOST, DB_USERNAME, DB_PASSWORD +REDIS_HOST, REDIS_PASSWORD +DASHSCOPE_API_KEY, DEEPL_AUTH_KEY +API_HOST, API_PORT, INDEXER_PORT, TRANSLATION_PORT +规则: +环境变量不应成为选择业务行为(如翻译模型、嵌入后端或租户语言策略)的常规途径 +如果允许对非敏感字段进行环境变量覆盖,则必须将其明确列出并记录为运维覆盖手段,而非隐藏的约定 + +5.2 统一的优先级 +推荐的优先级顺序: +代码中的模式(schema)默认值 +config/base.yaml +config/environments/.yaml +来自 config/tenants/ 的租户层叠配置 +针对明确允许的运行时键的环境变量 +仅适用于当前进程的命令行标志 +重要规则: +只能有一个模块实现此合并逻辑 +任何业务模块不得直接调用 os.getenv() 来获取配置 + + + +二、cursor(composer2)帮我审查的 + +## 配置文档系统审查 + +这是对配置文档系统的简要审查,对照 [`docs/config-system-review-and-redesign.md`](docs/config-system-review-and-redesign.md) 和当前代码库进行。 + +### 实现已匹配的部分 + +代码库已朝着重新设计的目标迈进了很大一步: + +- 单一类型化根节点:`AppConfig` 位于 [`config/schema.py`](config/schema.py),通过 [`config/loader.py`](config/loader.py) 和 [`get_app_config()`](config/loader.py) 加载。 +- 精简的 `services_config`:[`config/services_config.py`](config/services_config.py) 委托给 `get_app_config()`(解决了服务层的发现 A)。 +- 租户目录:[`config/tenant_config_loader.py`](config/tenant_config_loader.py) 使用 `get_app_config().tenants`。 +- 重写资产路径:`config/dictionaries/query_rewrite.dict` 已存在(解决了发现 E 中的文件名不匹配问题)。 +- 可观测性:[`GET /admin/config`](api/routes/admin.py) 返回经过脱敏处理的有效配置树;[`GET /admin/config/meta`](api/routes/admin.py) 暴露环境信息、`config_hash`、`loaded_files`、`deprecated_keys`(涵盖了 §5.10 的意图;文档中提议的是 `/admin/config/effective`,但实际实现位于 `/admin/config`)。 + +因此,"单一加载器 + 有效配置可见性" 的故事在代码中已基本实现;文档尚未完全跟上。 + +--- + +## 文档问题(影响最大) + +### 1. 管理 API 文档中关于 `/admin/config` 的描述错误 + +[`docs/搜索API对接指南.md`](docs/搜索API对接指南.md)(管理部分附近)和 [`docs/搜索API对接指南-06-管理接口(Admin).md`](docs/搜索API对接指南-06-管理接口(Admin).md) 仍将 `/admin/config` 描述为按租户的 JSON(包含 `tenant_id`、`es_index_name`、`supported_languages` 等字段)。实际实现返回的是 `AppConfig.sanitized_dict()`(完整的应用配置,敏感信息已脱敏),而不是租户摘要字段。 + +这些指南中还缺少: `GET /admin/config/meta`。 + +健康检查: 拆分指南中的示例包含了 [`HealthResponse`](api/models.py) 中不存在的字段(只有 `status` 和 `elasticsearch`)。 + +对于任何仅根据文档进行 API 集成的人来说,这是最明显的"未解决问题"。 + +### 2. 面向开发者的指南仍将 `services_config` 作为"配置解析器"的核心 + +[`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md) §5.2 仍说搜索配置由 `ConfigLoader` 加载,服务由 `config/services_config` "解析"。§6.2 仍将 `config/services_config.py` 列为主要的"解析入口"。[`docs/QUICKSTART.md`](docs/QUICKSTART.md) §3.1 仍说"配置解析:`config/services_config.py`"。 + +文档中准确的说法应该是:规范入口是 `config/loader.py` + `get_app_config()`;[`config/config_loader.py`](config/config_loader.py) 中的 `ConfigLoader` 包装了统一加载器;`services_config` 是现有调用点的兼容性外观。 + +### 3. 重新设计文档本身不是"活的"状态文档 + +[`docs/config-system-review-and-redesign.md`](docs/config-system-review-and-redesign.md) 读起来仍是纯粹的问题陈述 + 目标,没有简短的**"已实现 vs 剩余"**部分。这很容易让人假设什么都没做,或者重复工作。添加一个小附录(或一页 `config/README.md` —— 见下文)可以解决这个问题。 + +### 4. 缺少 `config/README.md`(§5.3 中推荐) + +仍然没有专门的 `config/README.md` 来描述:加载器入口点、高级优先级、字典存放位置、指向 `/admin/config` + `/admin/config/meta` 的链接,以及重新设计文档的链接。这是重新设计中明确的交付物,可以锚定"文档系统"。 + +### 5. 轻微的文档整洁问题 + +- [`docs/QUICKSTART.md`](docs/QUICKSTART.md) §1.9 环境变量项目后的行有一个多余字符:`---·`(可能是打字错误)。 +- [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md) §10 文档索引没有列出 `config-system-review-and-redesign.md` 或未来的 `config/README.md`。 + +--- + +## 重新设计目标与当前代码之间的差距(文档不应声称"已完成") + +这些影响文档的诚实度: + +| 主题 | 状态 | +|--------|--------| +| `config dump` CLI(§5.10) | `main.py` 中不存在;运维人员依赖 HTTP 或临时脚本。 | +| 隐藏的 `["en", "zh"]` 回退(阶段 3 / 发现 D) | 仍在 [`indexer/document_transformer.py`](indexer/document_transformer.py)、[`suggestion/builder.py`](suggestion/builder.py) 等中使用。 | +| 加载器外的 `os.getenv`(规则 1–2) | 仍在例如 [`embeddings/server.py`](embeddings/server.py)、[`reranker/server.py`](reranker/server.py)、[`api/app.py`](api/app.py) 中使用 —— 文档声称"仅加载器"将是夸大其词。 | +| 拆分 `base.yaml` / `environments/` / `tenants/*.yaml`(阶段 5) | 未采用;仍是单一的 [`config/config.yaml`](config/config.yaml)。 | +| 遗留租户标志(阶段 6 / 发现 H) | [`indexer/README.md`](indexer/README.md) 仍描述上游 MySQL 的 `translate_to_en` / `translate_to_zh`(这可能作为上游模式文档保留;需与 Python `tenant_config` 模型区分开来)。 | + +--- + +## 推荐的后续步骤(仅文档,按优先级排序) + +1. 修复管理 API 文档(合并指南 + `-06-` 拆分):`/admin/config` 的响应格式,添加 `/admin/config/meta`,使健康检查示例与 [`HealthResponse`](api/models.py) 一致。 +2. 更新 DEVELOPER_GUIDE §5–§6 和 QUICKSTART §1.9 / §3.1,将 `get_app_config()` / `loader.py` 描述为主要入口,将 `services_config` 描述为适配器。 +3. 添加 `config/README.md`(简短的操作 + 开发者入口)。 +4. 在 `config-system-review-and-redesign.md` 中添加带日期的实现状态表(已交付 vs 推迟的内容),使审查文档不与现实矛盾。 +5. DEVELOPER_GUIDE §9 检查清单:将"配置来自 `services_config`"替换为允许 `get_app_config()` 或精简适配器的语言,与 §6 保持一致。 + +如果需要,我可以在后续处理中为项目 1–3 和重新设计文档中的简短状态块应用补丁。 + +其他云API +1 +1)提供两个rerank云API_KEY给我:(优先级:高) +AWS Bedrock / Azure 两家云有提供的Cohere Rerank 3.5/4模型API,开通APIKEY +google云 Vertex AI Ranking API + +已经调研: +阿里云在美国地区没有提供任意reranker API +AWS Bedrock / Azure 两家云有提供Cohere Rerank 3.5 +google云Vertex AI Ranking API性能更好 + +以上两个APIKEY给我,我来测试性能和效果。 + + +2)寻找美国地区reranker API最佳实践(优先级:高) +效果要求:qwen3-reranker-4b(或者同等能力。可对比huggingface公开的评测指标)的API +性能要求:在我们的服务器上,一个请求内排序400条结果、耗时低于300ms +测试评估:基于电商领域商品搜索场景评估效果(我可以提供数据) +据我了解的Cohere Rerank可能达不到这个性能要求,可能可以考虑拆分为4个请求、每个100条,做到300ms以内可能可以。 +参考Cohere Rerank 3.5 benchmark: +https://docs.oracle.com/en-us/iaas/Content/generative-ai/benchmark-cohere-rerank-3-5.htm + + +3)提供谷歌翻译API的apikey (优先级:低) +给我apikey,我看下耗时,希望耗时P95低于80ms满足在线请求使用 +在线翻译的问题已经基本解决,这一块需求不是特别大。 + +2 +混用 大模型 使用:hunyuan-turbos-latest +混元 OpenAI 兼容接口相关调用示例:https://cloud.tencent.com/document/product/1729/111007 + +腾讯云 混元大模型 API_KEY:sk-mN2PiW2gp57B3ykxGs4QhvYxhPzXRZ2bcR5kPqadjboGYwiz + +hunyuan翻译:使用模型 hunyuan-translation +https://cloud.tencent.com/document/product/1729/113395#4.-.E7.A4.BA.E4.BE.8B + +谷歌翻译 基础版:https://docs.cloud.google.com/translate/docs/reference/rest/v2/translate + +阿里云 百炼模型 现在使用的apikey是国内的。 +各地域的 Base URL 和对应的 API Key 是绑定的。 + +现在使用了美国的服务器,使用了美国的地址,需要在 美国地域控制台页面(https://modelstudio.console.aliyun.com/us-east-1 )中创建或获取API_KEY: + +登录 百炼美国地域控制台:https://modelstudio.console.aliyun.com/us-east-1?spm=5176.2020520104.0.0.6b383a98WjpXff +在 API Key 管理 中创建或复制一个适用于美国地域的 Key + +搜索效果反馈: +做完一些短期优化后,需要做一些case驱动的优化。 +给到100条测试用例,每个搜索词,要记录请求ID、以及 希望排序靠前但是没有靠前的(比如希望出现在第一页但是没出现在第一页的)、以及未召回的商品ID(希望出现在前几页但是没翻到的) +6. 其他任务 + +- suggest 索引,现在是全量脚本,要交给金伟 \ No newline at end of file diff --git a/docs/TODO.txt b/docs/TODO.txt index 4f08e95..96fce8e 100644 --- a/docs/TODO.txt +++ b/docs/TODO.txt @@ -55,14 +55,6 @@ image_embedding改为,一个spu有多个sku向量,每个向量内部properti }, - - -tags字段使用的优化: -现在是keyword,在搜索中,不太好使用(目前主要用于suggest)。 -可以考虑也拆分多语言,配合analyzer使用(和qanchors一样) - - - 外部需求: 1. 对推理能力要求很低、对耗时要求很高的大模型API(或者本地部署一个7b Q4量化的大模型),prompt大概30-50个token,首token响应要求500ms以内 2. ES支持reranker pipline? @@ -86,7 +78,7 @@ query匹配了其中任何一个词,都认为,具有颜色意图 匹配规则: 用细粒度、粗粒度分词,看是否有在词表中的。原始query分词、和每种翻译的分词,都要用。 意图判断: 暂时留空,直接返回true。目前没有模型,即只要召回了(词表匹配了),即认为有该维度款式需求。 - +(以后考虑建设fasttext/bert系列多分类模型) 意图使用: @@ -119,17 +111,23 @@ query匹配了其中任何一个词,都认为,具有颜色意图 5. TODO : 还有一个问题。 目前,sku只返回一个维度(店铺主维度。默认应该是option1,不是所有维度的sku信息都返回的。所以,如果有款式意图,但是主维度是颜色,那么拿不到全的款式sku) - - 当前项目功能已经较多,但是有清晰的框架,请务必基于现有框架进行改造,不要进行补丁式的修改,避免代码逻辑分叉。 - 请一步一步来,先设计意图识别模块,仔细思考需求,意图识别模块需要提供哪些内容,用于返回数据接口的定义,深度思考,定义一个合理的接口后,再给出合理的模块设计。 +文本相关性: +调研: +Princeton WordNet — 英文同义词底库 +Shopify Product Taxonomy — 电商品类标准 +Querqy — 电商搜索规则框架 +gensimpson/elasticsearch-synonyms — ES 同义词规则落地 +tags字段使用的优化: +现在是keyword,在搜索中,不太好使用(目前主要用于suggest)。 +可以考虑也拆分多语言,配合analyzer使用(和qanchors一样) @@ -463,8 +461,9 @@ scores: [...](兼容主后端)+ scores_by_backend: { "bge": [...], "qwen3_vl fuse_scores_and_resort 目前只消费 一条 rerank_scores 序列,并写入 _rerank_score 多 backend 之后需要rerank_scores 都参与融合 - - +必要性: +见 qwen3-reranker和bge-m3的严重badcase +不一定是要多reranker的方式,但是一定会需要解决方案。 diff --git a/search/es_query_builder.py b/search/es_query_builder.py index f4d7f56..f1ce217 100644 --- a/search/es_query_builder.py +++ b/search/es_query_builder.py @@ -37,10 +37,13 @@ class ESQueryBuilder: translation_minimum_should_match: str = "70%", translation_boost: float = 0.4, tie_breaker_base_query: float = 0.9, + best_fields_boosts: Optional[Dict[str, float]] = None, + best_fields_clause_boost: float = 2.0, mixed_script_merged_field_boost_scale: float = 0.6, + phrase_field_boosts: Optional[Dict[str, float]] = None, phrase_match_base_fields: Optional[Tuple[str, ...]] = None, - phrase_match_slop: int = 2, - phrase_match_tie_breaker: float = 0.4, + phrase_match_slop: int = 0, + phrase_match_tie_breaker: float = 0.0, phrase_match_boost: float = 3.0, ): """ @@ -77,7 +80,26 @@ class ESQueryBuilder: self.translation_boost = float(translation_boost) self.tie_breaker_base_query = float(tie_breaker_base_query) self.mixed_script_merged_field_boost_scale = float(mixed_script_merged_field_boost_scale) - self.phrase_match_base_fields = tuple(phrase_match_base_fields or ("title", "qanchors")) + default_best_fields = { + base: self._get_field_boost(base) + for base in self.core_multilingual_fields + if base in self.multilingual_fields + } + self.best_fields_boosts = { + str(base): float(boost) + for base, boost in (best_fields_boosts or default_best_fields).items() + } + self.best_fields_clause_boost = float(best_fields_clause_boost) + default_phrase_base_fields = tuple(phrase_match_base_fields or ("title", "qanchors")) + default_phrase_fields = { + base: self._get_field_boost(base) + for base in default_phrase_base_fields + if base in self.multilingual_fields + } + self.phrase_field_boosts = { + str(base): float(boost) + for base, boost in (phrase_field_boosts or default_phrase_fields).items() + } self.phrase_match_slop = int(phrase_match_slop) self.phrase_match_tie_breaker = float(phrase_match_tie_breaker) self.phrase_match_boost = float(phrase_match_boost) @@ -399,27 +421,6 @@ class ESQueryBuilder: return functions - def _build_text_query(self, query_text: str) -> Dict[str, Any]: - """ - Build simple text matching query (BM25). - - Args: - query_text: Query text - - Returns: - ES query clause - """ - return { - "multi_match": { - "query": query_text, - "fields": self.match_fields, - "minimum_should_match": "67%", - "tie_breaker": 0.9, - "boost": 1.0, - "_name": "base_query" - } - } - def _format_field_with_boost(self, field_name: str, boost: float) -> str: if abs(float(boost) - 1.0) < 1e-9: return field_name @@ -435,70 +436,38 @@ class ESQueryBuilder: return float(self.field_boosts[base_field]) return 1.0 - def _build_match_field_specs(self, language: str) -> Tuple[List[MatchFieldSpec], List[MatchFieldSpec]]: + def _build_match_field_specs( + self, + language: str, + *, + multilingual_fields: Optional[List[str]] = None, + shared_fields: Optional[List[str]] = None, + boost_overrides: Optional[Dict[str, float]] = None, + ) -> List[MatchFieldSpec]: """ - Per-language match targets as (field_path, boost). Single source of truth before string formatting. - Returns (all_fields, core_fields); core_fields are for phrase/keyword strategies elsewhere. + Per-language match targets as (field_path, boost). Single source of truth before + formatting as Elasticsearch ``fields`` strings. """ lang = (language or "").strip().lower() - all_specs: List[MatchFieldSpec] = [] - core_specs: List[MatchFieldSpec] = [] - - for base in self.multilingual_fields: - field = f"{base}.{lang}" - all_specs.append((field, self._get_field_boost(base, lang))) - - for shared in self.shared_fields: - all_specs.append((shared, self._get_field_boost(shared, None))) + specs: List[MatchFieldSpec] = [] + text_fields = multilingual_fields if multilingual_fields is not None else self.multilingual_fields + term_fields = shared_fields if shared_fields is not None else self.shared_fields + overrides = boost_overrides or {} - for base in self.core_multilingual_fields: + for base in text_fields: field = f"{base}.{lang}" - core_specs.append((field, self._get_field_boost(base, lang))) + boost = float(overrides.get(base, self._get_field_boost(base, lang))) + specs.append((field, boost)) - return all_specs, core_specs + for shared in term_fields: + boost = float(overrides.get(shared, self._get_field_boost(shared, None))) + specs.append((shared, boost)) + return specs def _format_match_field_specs(self, specs: List[MatchFieldSpec]) -> List[str]: """Format (field_path, boost) pairs for Elasticsearch multi_match ``fields``.""" return [self._format_field_with_boost(path, boost) for path, boost in specs] - def _build_phrase_match_fields(self, language: str) -> List[str]: - """Fields for phrase multi_match: base names × ``.{lang}`` with ``field_boosts``.""" - lang = (language or "").strip().lower() - if not lang: - return [] - out: List[str] = [] - for base in self.phrase_match_base_fields: - path = f"{base}.{lang}" - boost = self._get_field_boost(base, lang) - out.append(self._format_field_with_boost(path, boost)) - return out - - def _append_phrase_should_clause( - self, - should_clauses: List[Dict[str, Any]], - lang: str, - lang_query: str, - clause_name: str - ) -> None: - text = (lang_query or "").strip() - if not text: - return - phrase_fields = self._build_phrase_match_fields(lang) - if not phrase_fields: - return - boost = self.phrase_match_boost - should_clauses.append({ - "multi_match": { - "_name": f"{clause_name}_phrase", - "query": lang_query, - "type": "phrase", - "fields": phrase_fields, - "slop": self.phrase_match_slop, - "tie_breaker": self.phrase_match_tie_breaker, - "boost": boost, - } - }) - def _merge_supplemental_lang_field_specs( self, specs: List[MatchFieldSpec], @@ -506,7 +475,7 @@ class ESQueryBuilder: ) -> List[MatchFieldSpec]: """Append supplemental-language columns; boosts multiplied by mixed_script scale.""" scale = float(self.mixed_script_merged_field_boost_scale) - extra_all, _ = self._build_match_field_specs(supplemental_lang) + extra_all = self._build_match_field_specs(supplemental_lang) seen = {path for path, _ in specs} out = list(specs) for path, boost in extra_all: @@ -543,6 +512,103 @@ class ESQueryBuilder: out = self._merge_supplemental_lang_field_specs(out, "zh") return out + def _build_best_fields_clause(self, language: str, query_text: str) -> Optional[Dict[str, Any]]: + specs = self._build_match_field_specs( + language, + multilingual_fields=list(self.best_fields_boosts), + shared_fields=[], + boost_overrides=self.best_fields_boosts, + ) + fields = self._format_match_field_specs(specs) + if not fields: + return None + return { + "multi_match": { + "query": query_text, + "type": "best_fields", + "fields": fields, + "boost": self.best_fields_clause_boost, + } + } + + def _build_phrase_clause(self, language: str, query_text: str) -> Optional[Dict[str, Any]]: + specs = self._build_match_field_specs( + language, + multilingual_fields=list(self.phrase_field_boosts), + shared_fields=[], + boost_overrides=self.phrase_field_boosts, + ) + fields = self._format_match_field_specs(specs) + if not fields: + return None + clause: Dict[str, Any] = { + "multi_match": { + "query": query_text, + "type": "phrase", + "fields": fields, + "boost": self.phrase_match_boost, + } + } + if self.phrase_match_slop > 0: + clause["multi_match"]["slop"] = self.phrase_match_slop + if self.phrase_match_tie_breaker > 0: + clause["multi_match"]["tie_breaker"] = self.phrase_match_tie_breaker + return clause + + def _build_lexical_language_clause( + self, + lang: str, + lang_query: str, + clause_name: str, + *, + is_source: bool, + contains_chinese: bool, + contains_english: bool, + index_languages: List[str], + ) -> Optional[Dict[str, Any]]: + all_specs = self._build_match_field_specs(lang) + expanded_specs = self._expand_match_field_specs_for_mixed_script( + lang, + all_specs, + contains_chinese, + contains_english, + index_languages, + is_source, + ) + combined_fields = self._format_match_field_specs(expanded_specs) + if not combined_fields: + return None + minimum_should_match = ( + self.base_minimum_should_match if is_source else self.translation_minimum_should_match + ) + should_clauses = [ + clause + for clause in ( + self._build_best_fields_clause(lang, lang_query), + self._build_phrase_clause(lang, lang_query), + ) + if clause + ] + clause: Dict[str, Any] = { + "bool": { + "_name": clause_name, + "must": [ + { + "combined_fields": { + "query": lang_query, + "fields": combined_fields, + "minimum_should_match": minimum_should_match, + } + } + ], + } + } + if should_clauses: + clause["bool"]["should"] = should_clauses + if not is_source: + clause["bool"]["boost"] = float(self.translation_boost) + return clause + def _get_embedding_field(self, language: str) -> str: """Get embedding field name for a language.""" # Currently using unified embedding field @@ -603,42 +669,18 @@ class ESQueryBuilder: def append_clause(lang: str, lang_query: str, clause_name: str, is_source: bool) -> None: nonlocal should_clauses - all_specs, _ = self._build_match_field_specs(lang) - expanded_specs = self._expand_match_field_specs_for_mixed_script( + clause = self._build_lexical_language_clause( lang, - all_specs, - contains_chinese, - contains_english, - normalized_index_languages, - is_source, + lang_query, + clause_name, + is_source=is_source, + contains_chinese=contains_chinese, + contains_english=contains_english, + index_languages=normalized_index_languages, ) - match_fields = self._format_match_field_specs(expanded_specs) - if not match_fields: + if not clause: return - minimum_should_match = ( - self.base_minimum_should_match if is_source else self.translation_minimum_should_match - ) - - clause = { - "multi_match": { - "_name": clause_name, - "fields": match_fields, - "minimum_should_match": minimum_should_match, - "query": lang_query, - "tie_breaker": self.tie_breaker_base_query, - } - } - # base_query: never set multi_match.boost (ES default 1.0). - # Translation clauses: single knob from config — translation_boost. - if not is_source: - tb = float(self.translation_boost) - clause["multi_match"]["boost"] = tb - should_clauses.append({ - "multi_match": clause["multi_match"] - }) - self._append_phrase_should_clause( - should_clauses, lang, lang_query, clause_name - ) + should_clauses.append(clause) if base_query_text: append_clause(source_lang, base_query_text, "base_query", True) @@ -661,24 +703,9 @@ class ESQueryBuilder: "query": query_text, "fields": fallback_fields, "minimum_should_match": self.base_minimum_should_match, - "tie_breaker": self.tie_breaker_base_query, - } - } - fb_should: List[Dict[str, Any]] = [fallback_lexical] - self._append_phrase_should_clause( - fb_should, - self.default_language, - query_text, - "base_query_fallback" - ) - if len(fb_should) == 1: - return fallback_lexical - return { - "bool": { - "should": fb_should, - "minimum_should_match": 1, } } + return fallback_lexical # Return bool query with should clauses if len(should_clauses) == 1: diff --git a/search/searcher.py b/search/searcher.py index 156f996..21bbadd 100644 --- a/search/searcher.py +++ b/search/searcher.py @@ -133,6 +133,10 @@ class Searcher: translation_minimum_should_match=self.config.query_config.translation_minimum_should_match, translation_boost=self.config.query_config.translation_boost, tie_breaker_base_query=self.config.query_config.tie_breaker_base_query, + best_fields_boosts=self.config.query_config.best_fields, + best_fields_clause_boost=self.config.query_config.best_fields_boost, + phrase_field_boosts=self.config.query_config.phrase_fields, + phrase_match_boost=self.config.query_config.phrase_match_boost, ) def _apply_source_filter(self, es_query: Dict[str, Any]) -> None: diff --git a/tests/test_es_query_builder.py b/tests/test_es_query_builder.py index e271fb9..bd30498 100644 --- a/tests/test_es_query_builder.py +++ b/tests/test_es_query_builder.py @@ -14,19 +14,19 @@ def _builder() -> ESQueryBuilder: ) -def _lexical_multi_match_fields(query_root: Dict[str, Any]) -> list: - """Fields from the non-phrase multi_match (bool.should or single clause).""" - if "multi_match" in query_root: - mm = query_root["multi_match"] - if mm.get("type") == "phrase": - raise AssertionError("root multi_match is phrase-only") - return mm["fields"] +def _lexical_clause(query_root: Dict[str, Any]) -> Dict[str, Any]: + """Return the first named lexical bool clause from query_root.""" + if "bool" in query_root and query_root["bool"].get("_name"): + return query_root["bool"] for clause in query_root.get("bool", {}).get("should", []): - mm = clause.get("multi_match") or {} - if mm.get("type") == "phrase": - continue - return mm["fields"] - raise AssertionError("no lexical multi_match in query_root") + clause_bool = clause.get("bool") or {} + if clause_bool.get("_name"): + return clause_bool + raise AssertionError("no lexical bool clause in query_root") + + +def _lexical_combined_fields(query_root: Dict[str, Any]) -> list: + return _lexical_clause(query_root)["must"][0]["combined_fields"]["fields"] def test_knn_prefilter_includes_range_filters(): @@ -96,14 +96,11 @@ def test_text_query_contains_only_base_and_translation_named_queries(): index_languages=["en", "zh", "fr"], ) should = q["query"]["bool"]["should"] - names = [clause["multi_match"]["_name"] for clause in should] + names = [clause["bool"]["_name"] for clause in should] - assert names == [ - "base_query", - "base_query_phrase", - "base_query_trans_zh", - "base_query_trans_zh_phrase", - ] + assert names == ["base_query", "base_query_trans_zh"] + base_should = q["query"]["bool"]["should"][0]["bool"]["should"] + assert [clause["multi_match"]["type"] for clause in base_should] == ["best_fields", "phrase"] def test_text_query_skips_duplicate_translation_same_as_base(): @@ -122,8 +119,8 @@ def test_text_query_skips_duplicate_translation_same_as_base(): ) root = q["query"] - assert root["bool"]["should"][0]["multi_match"]["_name"] == "base_query" - assert root["bool"]["should"][1]["multi_match"]["_name"] == "base_query_phrase" + assert root["bool"]["_name"] == "base_query" + assert [clause["multi_match"]["type"] for clause in root["bool"]["should"]] == ["best_fields", "phrase"] def test_mixed_script_merges_en_fields_into_zh_clause(): @@ -147,7 +144,7 @@ def test_mixed_script_merges_en_fields_into_zh_clause(): enable_knn=False, index_languages=["zh", "en"], ) - fields = _lexical_multi_match_fields(q["query"]) + fields = _lexical_combined_fields(q["query"]) bases = {f.split("^", 1)[0] for f in fields} assert "title.zh" in bases and "title.en" in bases assert "brief.zh" in bases and "brief.en" in bases @@ -177,7 +174,7 @@ def test_mixed_script_merges_zh_fields_into_en_clause(): enable_knn=False, index_languages=["zh", "en"], ) - fields = _lexical_multi_match_fields(q["query"]) + fields = _lexical_combined_fields(q["query"]) bases = {f.split("^", 1)[0] for f in fields} assert "title.en" in bases and "title.zh" in bases assert "title.zh^0.6" in fields @@ -205,7 +202,7 @@ def test_mixed_script_merged_fields_scale_configured_boosts(): enable_knn=False, index_languages=["zh", "en"], ) - fields = _lexical_multi_match_fields(q["query"]) + fields = _lexical_combined_fields(q["query"]) assert "title.zh^5.0" in fields assert "title.en^6.0" in fields # 10.0 * 0.6 @@ -231,7 +228,7 @@ def test_mixed_script_does_not_merge_en_when_not_in_index_languages(): enable_knn=False, index_languages=["zh"], ) - fields = _lexical_multi_match_fields(q["query"]) + fields = _lexical_combined_fields(q["query"]) bases = {f.split("^", 1)[0] for f in fields} assert "title.zh" in bases assert "title.en" not in bases diff --git a/tests/test_es_query_builder_text_recall_languages.py b/tests/test_es_query_builder_text_recall_languages.py index f685a3d..a115028 100644 --- a/tests/test_es_query_builder_text_recall_languages.py +++ b/tests/test_es_query_builder_text_recall_languages.py @@ -2,8 +2,8 @@ ES text recall: base_query (rewritten @ detected_language) + base_query_trans_*. Covers combinations of query language vs tenant index_languages, translations, -and mixed Chinese/English queries. Asserts multi_match _name, query text, and -target language fields (title.{lang}). +and mixed Chinese/English queries. Asserts named lexical clause boundaries, +combined_fields payloads, and per-language target fields (title.{lang}). """ from types import SimpleNamespace @@ -34,7 +34,7 @@ def _builder_multilingual_title_only( def _unwrap_inner_query(es_body: Dict[str, Any]) -> Dict[str, Any]: """Navigate bool.must / function_score wrappers to the text recall root.""" q = es_body.get("query") or {} - if "bool" in q and "must" in q["bool"] and q["bool"]["must"]: + if "bool" in q and not q["bool"].get("_name") and "must" in q["bool"] and q["bool"]["must"]: q = q["bool"]["must"][0] if "function_score" in q: q = q["function_score"]["query"] @@ -49,30 +49,45 @@ def _extract_multi_match_clauses(es_body: Dict[str, Any]) -> List[Dict[str, Any] return [c["multi_match"] for c in should if "multi_match" in c] +def _extract_named_lexical_clauses(es_body: Dict[str, Any]) -> List[Dict[str, Any]]: + inner = _unwrap_inner_query(es_body) + if "bool" in inner and inner["bool"].get("_name"): + return [inner["bool"]] + should = (inner.get("bool") or {}).get("should") or [] + return [c["bool"] for c in should if "bool" in c and c["bool"].get("_name")] + + def _clauses_index(es_body: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: - """Map _name -> multi_match dict.""" + """Map lexical clause _name -> bool query body.""" out: Dict[str, Dict[str, Any]] = {} - for mm in _extract_multi_match_clauses(es_body): - name = mm.get("_name") + for clause in _extract_named_lexical_clauses(es_body): + name = clause.get("_name") if name: - out[str(name)] = mm + out[str(name)] = clause return out -def _with_phrase(lexical_names: set[str]) -> set[str]: - """Each lexical recall clause has a companion ``*_phrase`` multi_match.""" - return lexical_names | {f"{n}_phrase" for n in lexical_names} +def _combined_fields_clause(clause: Dict[str, Any]) -> Dict[str, Any]: + return clause["must"][0]["combined_fields"] + +def _should_multi_matches(clause: Dict[str, Any]) -> List[Dict[str, Any]]: + return [item["multi_match"] for item in clause.get("should") or [] if "multi_match" in item] -def _title_fields(mm: Dict[str, Any]) -> List[str]: - fields = mm.get("fields") or [] + +def _should_multi_matches_by_type(clause: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + return {str(mm.get("type") or "best_fields"): mm for mm in _should_multi_matches(clause)} + + +def _title_fields(clause: Dict[str, Any]) -> List[str]: + fields = _combined_fields_clause(clause).get("fields") or [] return [f for f in fields if str(f).startswith("title.")] -def _has_title_lang(mm: Dict[str, Any], lang: str) -> bool: +def _has_title_lang(clause: Dict[str, Any], lang: str) -> bool: """True if any field is title.{lang} with optional ^boost suffix.""" prefix = f"title.{lang}" - for f in mm.get("fields") or []: + for f in _combined_fields_clause(clause).get("fields") or []: s = str(f) if s == prefix or s.startswith(prefix + "^"): return True @@ -119,10 +134,10 @@ def test_zh_query_index_zh_en_includes_base_zh_and_trans_en(): index_languages=["zh", "en"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_en"}) - assert idx["base_query"]["query"] == "连衣裙" + assert set(idx) == {"base_query", "base_query_trans_en"} + assert _combined_fields_clause(idx["base_query"])["query"] == "连衣裙" assert "title.zh" in _title_fields(idx["base_query"]) - assert idx["base_query_trans_en"]["query"] == "dress" + assert _combined_fields_clause(idx["base_query_trans_en"])["query"] == "dress" assert "title.en" in _title_fields(idx["base_query_trans_en"]) @@ -137,10 +152,10 @@ def test_en_query_index_zh_en_includes_base_en_and_trans_zh(): index_languages=["en", "zh"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_zh"}) - assert idx["base_query"]["query"] == "dress" + assert set(idx) == {"base_query", "base_query_trans_zh"} + assert _combined_fields_clause(idx["base_query"])["query"] == "dress" assert "title.en" in _title_fields(idx["base_query"]) - assert idx["base_query_trans_zh"]["query"] == "连衣裙" + assert _combined_fields_clause(idx["base_query_trans_zh"])["query"] == "连衣裙" assert "title.zh" in _title_fields(idx["base_query_trans_zh"]) @@ -155,13 +170,11 @@ def test_de_query_index_de_en_fr_includes_base_and_two_translations(): index_languages=["de", "en", "fr"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase( - {"base_query", "base_query_trans_en", "base_query_trans_fr"} - ) - assert idx["base_query"]["query"] == "kleid" + assert set(idx) == {"base_query", "base_query_trans_en", "base_query_trans_fr"} + assert _combined_fields_clause(idx["base_query"])["query"] == "kleid" assert "title.de" in _title_fields(idx["base_query"]) - assert idx["base_query_trans_en"]["query"] == "dress" - assert idx["base_query_trans_fr"]["query"] == "robe" + assert _combined_fields_clause(idx["base_query_trans_en"])["query"] == "dress" + assert _combined_fields_clause(idx["base_query_trans_fr"])["query"] == "robe" # --- 检测语言不在 index_languages:仍有 base(弱)+ 翻译(强) --- @@ -178,15 +191,13 @@ def test_de_query_index_only_en_zh_base_on_de_translations_on_target_fields(): index_languages=["en", "zh"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase( - {"base_query", "base_query_trans_en", "base_query_trans_zh"} - ) - assert idx["base_query"]["query"] == "schuh" + assert set(idx) == {"base_query", "base_query_trans_en", "base_query_trans_zh"} + assert _combined_fields_clause(idx["base_query"])["query"] == "schuh" assert "title.de" in _title_fields(idx["base_query"]) assert "boost" not in idx["base_query"] - assert idx["base_query_trans_en"]["query"] == "shoe" + assert _combined_fields_clause(idx["base_query_trans_en"])["query"] == "shoe" assert idx["base_query_trans_en"]["boost"] == qb.translation_boost - assert idx["base_query_trans_zh"]["query"] == "鞋" + assert _combined_fields_clause(idx["base_query_trans_zh"])["query"] == "鞋" assert idx["base_query_trans_zh"]["boost"] == qb.translation_boost @@ -206,10 +217,10 @@ def test_mixed_zh_primary_with_en_translation_merges_en_into_zh_base_clause(): contains_english=True, ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_en"}) - assert idx["base_query"]["query"] == "红色 dress" + assert set(idx) == {"base_query", "base_query_trans_en"} + assert _combined_fields_clause(idx["base_query"])["query"] == "红色 dress" assert _has_title_lang(idx["base_query"], "zh") and _has_title_lang(idx["base_query"], "en") - assert idx["base_query_trans_en"]["query"] == "red dress" + assert _combined_fields_clause(idx["base_query_trans_en"])["query"] == "red dress" assert _has_title_lang(idx["base_query_trans_en"], "en") @@ -226,10 +237,10 @@ def test_mixed_en_primary_with_zh_translation_merges_zh_into_en_base_clause(): contains_english=True, ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_zh"}) - assert idx["base_query"]["query"] == "nike 运动鞋" + assert set(idx) == {"base_query", "base_query_trans_zh"} + assert _combined_fields_clause(idx["base_query"])["query"] == "nike 运动鞋" assert _has_title_lang(idx["base_query"], "en") and _has_title_lang(idx["base_query"], "zh") - assert idx["base_query_trans_zh"]["query"] == "耐克运动鞋" + assert _combined_fields_clause(idx["base_query_trans_zh"])["query"] == "耐克运动鞋" def test_mixed_zh_query_index_zh_only_no_en_merge_in_base(): @@ -245,7 +256,7 @@ def test_mixed_zh_query_index_zh_only_no_en_merge_in_base(): contains_english=True, ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query"}) + assert set(idx) == {"base_query"} bases = {f.split("^", 1)[0] for f in _title_fields(idx["base_query"])} assert bases == {"title.zh"} @@ -264,7 +275,7 @@ def test_skips_translation_when_same_lang_and_same_text_as_base(): index_languages=["en", "zh"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_zh"}) + assert set(idx) == {"base_query", "base_query_trans_zh"} def test_keeps_translation_when_same_text_but_different_lang_than_base(): @@ -278,8 +289,8 @@ def test_keeps_translation_when_same_text_but_different_lang_than_base(): index_languages=["en", "zh"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_zh"}) - assert idx["base_query_trans_zh"]["query"] == "NIKE" + assert set(idx) == {"base_query", "base_query_trans_zh"} + assert _combined_fields_clause(idx["base_query_trans_zh"])["query"] == "NIKE" # --- 翻译 key 规范化、空翻译跳过 --- @@ -297,7 +308,7 @@ def test_translation_language_key_is_normalized_case_insensitive(): ) idx = _clauses_index(q) assert "base_query_trans_zh" in idx - assert idx["base_query_trans_zh"]["query"] == "连衣裙" + assert _combined_fields_clause(idx["base_query_trans_zh"])["query"] == "连衣裙" def test_empty_translation_value_is_skipped(): @@ -331,8 +342,10 @@ def test_empty_index_languages_treats_source_as_in_index_boosts(): idx = _clauses_index(q) assert "boost" not in idx["base_query"] assert idx["base_query_trans_en"]["boost"] == qb.translation_boost - assert idx["base_query_phrase"]["boost"] == qb.phrase_match_boost - assert idx["base_query_trans_en_phrase"]["boost"] == qb.phrase_match_boost + base_should = _should_multi_matches_by_type(idx["base_query"]) + trans_should = _should_multi_matches_by_type(idx["base_query_trans_en"]) + assert base_should["phrase"]["boost"] == qb.phrase_match_boost + assert trans_should["phrase"]["boost"] == qb.phrase_match_boost # --- 无翻译:仅 base_query --- @@ -349,7 +362,7 @@ def test_no_translations_only_base_query(): index_languages=["en", "zh"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query"}) + assert set(idx) == {"base_query"} # --- 与 KNN 同存时仍能解析文本子句(顶层 knn 不影响 query 内结构) --- @@ -373,7 +386,7 @@ def test_text_clauses_present_alongside_knn(): ) assert "knn" in q idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_zh"}) + assert set(idx) == {"base_query", "base_query_trans_zh"} def test_detected_language_unknown_falls_back_to_default_language(): @@ -393,8 +406,8 @@ def test_detected_language_unknown_falls_back_to_default_language(): index_languages=["en", "zh"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_zh"}) - assert idx["base_query"]["query"] == "shirt" + assert set(idx) == {"base_query", "base_query_trans_zh"} + assert _combined_fields_clause(idx["base_query"])["query"] == "shirt" assert _has_title_lang(idx["base_query"], "en") @@ -409,10 +422,10 @@ def test_ru_query_index_ru_en_includes_base_ru_and_trans_en(): index_languages=["ru", "en"], ) idx = _clauses_index(q) - assert set(idx) == _with_phrase({"base_query", "base_query_trans_en"}) - assert idx["base_query"]["query"] == "платье" + assert set(idx) == {"base_query", "base_query_trans_en"} + assert _combined_fields_clause(idx["base_query"])["query"] == "платье" assert _has_title_lang(idx["base_query"], "ru") - assert idx["base_query_trans_en"]["query"] == "dress" + assert _combined_fields_clause(idx["base_query_trans_en"])["query"] == "dress" def test_translation_for_lang_not_listed_in_index_languages_still_generates_clause(): @@ -431,7 +444,7 @@ def test_translation_for_lang_not_listed_in_index_languages_still_generates_clau ) idx = _clauses_index(q) assert "base_query_trans_de" in idx - assert idx["base_query_trans_de"]["query"] == "Kleid" + assert _combined_fields_clause(idx["base_query_trans_de"])["query"] == "Kleid" assert _has_title_lang(idx["base_query_trans_de"], "de") @@ -449,5 +462,5 @@ def test_mixed_detected_zh_rewrite_differs_from_query_text_uses_rewritten_in_bas contains_english=False, ) idx = _clauses_index(q) - assert idx["base_query"]["query"] == "红色连衣裙" - assert idx["base_query_trans_en"]["query"] == "red dress" + assert _combined_fields_clause(idx["base_query"])["query"] == "红色连衣裙" + assert _combined_fields_clause(idx["base_query_trans_en"])["query"] == "red dress" -- libgit2 0.21.2