diff --git a/config/__init__.py b/config/__init__.py index 87aec28..da439f7 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -4,6 +4,9 @@ from config.config_loader import ConfigLoader, ConfigurationError from config.loader import AppConfigLoader, get_app_config, reload_app_config from config.schema import ( AppConfig, + CoarseRankConfig, + CoarseRankFusionConfig, + FineRankConfig, FunctionScoreConfig, IndexConfig, QueryConfig, @@ -31,8 +34,11 @@ from config.utils import get_domain_fields, get_match_fields_for_index __all__ = [ "AppConfig", "AppConfigLoader", + "CoarseRankConfig", + "CoarseRankFusionConfig", "ConfigLoader", "ConfigurationError", + "FineRankConfig", "FunctionScoreConfig", "IndexConfig", "QueryConfig", diff --git a/config/config.yaml b/config/config.yaml index 2d5ce00..dfad00e 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -228,15 +228,40 @@ function_score: boost_mode: "multiply" functions: [] +# 粗排配置(仅融合 ES 文本/向量信号,不调用模型) +coarse_rank: + enabled: true + input_window: 700 + output_window: 240 + fusion: + text_bias: 0.1 + text_exponent: 0.35 + knn_text_weight: 1.0 + knn_image_weight: 1.0 + knn_tie_breaker: 0.1 + knn_bias: 0.6 + knn_exponent: 0.0 + +# 精排配置(轻量 reranker) +fine_rank: + enabled: true + input_window: 240 + output_window: 80 + timeout_sec: 10.0 + rerank_query_template: "{query}" + rerank_doc_template: "{title}" + service_profile: "fine" + # 重排配置(provider/URL 在 services.rerank) rerank: enabled: true - rerank_window: 400 + rerank_window: 80 timeout_sec: 15.0 weight_es: 0.4 weight_ai: 0.6 rerank_query_template: "{query}" rerank_doc_template: "{title}" + service_profile: "default" # 乘法融合:fused = Π (max(score,0) + bias) ** exponent(rerank / text / knn 三项) # 其中 knn_score 先做一层 dis_max: # max(knn_text_weight * text_knn, knn_image_weight * image_knn) @@ -244,6 +269,8 @@ rerank: fusion: rerank_bias: 0.00001 rerank_exponent: 1.0 + fine_bias: 0.00001 + fine_exponent: 1.0 text_bias: 0.1 text_exponent: 0.35 knn_text_weight: 1.0 @@ -399,6 +426,9 @@ services: http: base_url: "http://127.0.0.1:6007" service_url: "http://127.0.0.1:6007/rerank" + service_urls: + default: "http://127.0.0.1:6007/rerank" + fine: "http://127.0.0.1:6009/rerank" request: max_docs: 1000 normalize: true diff --git a/config/loader.py b/config/loader.py index 674c93c..3f10c66 100644 --- a/config/loader.py +++ b/config/loader.py @@ -27,10 +27,13 @@ except Exception: # pragma: no cover from config.schema import ( AppConfig, AssetsConfig, + CoarseRankConfig, + CoarseRankFusionConfig, ConfigMetadata, DatabaseSettings, ElasticsearchSettings, EmbeddingServiceConfig, + FineRankConfig, FunctionScoreConfig, IndexConfig, InfrastructureConfig, @@ -464,6 +467,11 @@ class AppConfigLoader: ) function_score_cfg = raw.get("function_score") if isinstance(raw.get("function_score"), dict) else {} + coarse_rank_cfg = raw.get("coarse_rank") if isinstance(raw.get("coarse_rank"), dict) else {} + coarse_fusion_raw = ( + coarse_rank_cfg.get("fusion") if isinstance(coarse_rank_cfg.get("fusion"), dict) else {} + ) + fine_rank_cfg = raw.get("fine_rank") if isinstance(raw.get("fine_rank"), dict) else {} rerank_cfg = raw.get("rerank") if isinstance(raw.get("rerank"), dict) else {} fusion_raw = rerank_cfg.get("fusion") if isinstance(rerank_cfg.get("fusion"), dict) else {} spu_cfg = raw.get("spu_config") if isinstance(raw.get("spu_config"), dict) else {} @@ -477,6 +485,33 @@ class AppConfigLoader: boost_mode=str(function_score_cfg.get("boost_mode") or "multiply"), functions=list(function_score_cfg.get("functions") or []), ), + coarse_rank=CoarseRankConfig( + enabled=bool(coarse_rank_cfg.get("enabled", True)), + input_window=int(coarse_rank_cfg.get("input_window", 700)), + output_window=int(coarse_rank_cfg.get("output_window", 240)), + fusion=CoarseRankFusionConfig( + text_bias=float(coarse_fusion_raw.get("text_bias", 0.1)), + text_exponent=float(coarse_fusion_raw.get("text_exponent", 0.35)), + knn_text_weight=float(coarse_fusion_raw.get("knn_text_weight", 1.0)), + knn_image_weight=float(coarse_fusion_raw.get("knn_image_weight", 1.0)), + knn_tie_breaker=float(coarse_fusion_raw.get("knn_tie_breaker", 0.0)), + knn_bias=float(coarse_fusion_raw.get("knn_bias", 0.6)), + knn_exponent=float(coarse_fusion_raw.get("knn_exponent", 0.2)), + ), + ), + fine_rank=FineRankConfig( + enabled=bool(fine_rank_cfg.get("enabled", True)), + input_window=int(fine_rank_cfg.get("input_window", 240)), + output_window=int(fine_rank_cfg.get("output_window", 80)), + timeout_sec=float(fine_rank_cfg.get("timeout_sec", 10.0)), + rerank_query_template=str(fine_rank_cfg.get("rerank_query_template") or "{query}"), + rerank_doc_template=str(fine_rank_cfg.get("rerank_doc_template") or "{title}"), + service_profile=( + str(v) + if (v := fine_rank_cfg.get("service_profile")) not in (None, "") + else "fine" + ), + ), rerank=RerankConfig( enabled=bool(rerank_cfg.get("enabled", True)), rerank_window=int(rerank_cfg.get("rerank_window", 384)), @@ -485,6 +520,11 @@ class AppConfigLoader: weight_ai=float(rerank_cfg.get("weight_ai", 0.6)), rerank_query_template=str(rerank_cfg.get("rerank_query_template") or "{query}"), rerank_doc_template=str(rerank_cfg.get("rerank_doc_template") or "{title}"), + service_profile=( + str(v) + if (v := rerank_cfg.get("service_profile")) not in (None, "") + else None + ), fusion=RerankFusionConfig( rerank_bias=float(fusion_raw.get("rerank_bias", 0.00001)), rerank_exponent=float(fusion_raw.get("rerank_exponent", 1.0)), @@ -495,6 +535,8 @@ class AppConfigLoader: knn_tie_breaker=float(fusion_raw.get("knn_tie_breaker", 0.0)), knn_bias=float(fusion_raw.get("knn_bias", 0.6)), knn_exponent=float(fusion_raw.get("knn_exponent", 0.2)), + fine_bias=float(fusion_raw.get("fine_bias", 0.00001)), + fine_exponent=float(fusion_raw.get("fine_exponent", 1.0)), ), ), spu_config=SPUConfig( diff --git a/config/schema.py b/config/schema.py index 226e9ea..2965b9c 100644 --- a/config/schema.py +++ b/config/schema.py @@ -117,6 +117,48 @@ class RerankFusionConfig: knn_tie_breaker: float = 0.0 knn_bias: float = 0.6 knn_exponent: float = 0.2 + fine_bias: float = 0.00001 + fine_exponent: float = 1.0 + + +@dataclass(frozen=True) +class CoarseRankFusionConfig: + """ + Multiplicative fusion without model score: + fused = (max(text, 0) + text_bias) ** text_exponent + * (max(knn, 0) + knn_bias) ** knn_exponent + """ + + text_bias: float = 0.1 + text_exponent: float = 0.35 + knn_text_weight: float = 1.0 + knn_image_weight: float = 1.0 + knn_tie_breaker: float = 0.0 + knn_bias: float = 0.6 + knn_exponent: float = 0.2 + + +@dataclass(frozen=True) +class CoarseRankConfig: + """Search-time coarse ranking configuration.""" + + enabled: bool = True + input_window: int = 700 + output_window: int = 240 + fusion: CoarseRankFusionConfig = field(default_factory=CoarseRankFusionConfig) + + +@dataclass(frozen=True) +class FineRankConfig: + """Search-time lightweight rerank configuration.""" + + enabled: bool = True + input_window: int = 240 + output_window: int = 80 + timeout_sec: float = 10.0 + rerank_query_template: str = "{query}" + rerank_doc_template: str = "{title}" + service_profile: Optional[str] = "fine" @dataclass(frozen=True) @@ -130,6 +172,7 @@ class RerankConfig: weight_ai: float = 0.6 rerank_query_template: str = "{query}" rerank_doc_template: str = "{title}" + service_profile: Optional[str] = None fusion: RerankFusionConfig = field(default_factory=RerankFusionConfig) @@ -141,6 +184,8 @@ class SearchConfig: indexes: List[IndexConfig] = field(default_factory=list) query_config: QueryConfig = field(default_factory=QueryConfig) function_score: FunctionScoreConfig = field(default_factory=FunctionScoreConfig) + coarse_rank: CoarseRankConfig = field(default_factory=CoarseRankConfig) + fine_rank: FineRankConfig = field(default_factory=FineRankConfig) rerank: RerankConfig = field(default_factory=RerankConfig) spu_config: SPUConfig = field(default_factory=SPUConfig) es_index_name: str = "search_products" diff --git a/config/services_config.py b/config/services_config.py index 092e04a..aa0bfd3 100644 --- a/config/services_config.py +++ b/config/services_config.py @@ -71,13 +71,20 @@ def get_rerank_backend_config() -> Tuple[str, Dict[str, Any]]: return cfg.backend, cfg.get_backend_config() -def get_rerank_base_url() -> str: +def get_rerank_base_url(profile: str | None = None) -> str: provider_cfg = get_app_config().services.rerank.get_provider_config() - base = provider_cfg.get("service_url") or provider_cfg.get("base_url") + base = None + profile_name = str(profile).strip() if profile else "" + if profile_name: + service_urls = provider_cfg.get("service_urls") + if isinstance(service_urls, dict): + base = service_urls.get(profile_name) + if not base: + base = provider_cfg.get("service_url") or provider_cfg.get("base_url") if not base: raise ValueError("Rerank service URL is not configured") return str(base).rstrip("/") -def get_rerank_service_url() -> str: - return get_rerank_base_url() +def get_rerank_service_url(profile: str | None = None) -> str: + return get_rerank_base_url(profile=profile) diff --git a/context/request_context.py b/context/request_context.py index de0a9c6..3533cb3 100644 --- a/context/request_context.py +++ b/context/request_context.py @@ -26,6 +26,8 @@ class RequestContextStage(Enum): # ES 按 ID 回源分页详情回填 ELASTICSEARCH_PAGE_FILL = "elasticsearch_page_fill" RESULT_PROCESSING = "result_processing" + COARSE_RANKING = "coarse_ranking" + FINE_RANKING = "fine_ranking" RERANKING = "reranking" # 款式意图 SKU 预筛选(StyleSkuSelector.prepare_hits) STYLE_SKU_PREPARE_HITS = "style_sku_prepare_hits" @@ -407,4 +409,4 @@ def clear_current_request_context() -> None: reset_request_log_context(tokens) delattr(threading.current_thread(), 'request_log_tokens') if hasattr(threading.current_thread(), 'request_context'): - delattr(threading.current_thread(), 'request_context') \ No newline at end of file + delattr(threading.current_thread(), 'request_context') diff --git a/docs/TODO-ES能力提升.md b/docs/TODO-ES能力提升.md deleted file mode 100644 index 1250b29..0000000 --- a/docs/TODO-ES能力提升.md +++ /dev/null @@ -1,70 +0,0 @@ -ES 付费版本 or 定制开发(建议先看下付费版本价格) -ES定制开发: -RRF / retrievers - -Elastic 的订阅矩阵里明确列了这些相关能力:Retrievers: linear, rule, RRF, text similarity re-ranker,以及 Reciprocal Rank Fusion (RRF) for hybrid search。 - -这类能力最有价值的点是: -它们把混合检索从“自己拼 DSL 和手搓打分”变成了官方支持的多阶段检索框架。重排:text similarity re-ranker / Elastic Rerank. text_similarity_reranker 用 NLP 模型对 top-k 结果按语义相似度重新排序;它可以用内置的 Elastic Rerank,也可以接 Cohere、Vertex AI,或者你自己上传的 text similarity 模型。 - -{ - "retriever": { - "rrf": { - "retrievers": [ - { "standard": { "query": { ... } } }, - { "knn": { ... } } - ] - } - } -} - - -加reranker: -text_similarity_reranker 用 NLP 模型对 top-k 结果按语义相似度重新排序;它可以用内置的 Elastic Rerank,也可以接 Cohere、Vertex AI,或者你自己上传的 text similarity 模型。 - -{ - "retriever": { - "text_similarity_reranker": { - "retriever": { - "rrf": { ... } - }, - ... - } - } -} - -{ - "retriever": { - "text_similarity_reranker": { - "retriever": { - "rrf": { - "retrievers": [ - { - "standard": { - "query": { - "...": "..." - } - } - }, - { - "knn": { - "...": "..." - } - } - ], - "rank_window_size": 100, - "rank_constant": 20 - } - }, - "field": "your_rerank_text_field", - "inference_text": "白色 oversized T-shirt", - "inference_id": ".rerank-v1-elasticsearch", - "rank_window_size": 50 - } - }, - "size": 20 -} - - - - diff --git a/docs/TODO-keywords限定-done.txt b/docs/TODO-keywords限定-done.txt deleted file mode 100644 index a68186e..0000000 --- a/docs/TODO-keywords限定-done.txt +++ /dev/null @@ -1,93 +0,0 @@ -@query/query_parser.py @scripts/es_debug_search.py -原始query、以及每一个翻译,都要有一个对应的keywords_query(token分词后,得到名词) -参考这段代码,获取每一个长度大于 1 的名词,然后用空格拼接起来,作为keywords_query -import hanlp -from typing import List, Tuple, Dict, Any - -class KeywordExtractor: - """ - 基于 HanLP 的名词关键词提取器 - """ - def __init__(self): - # 加载带位置信息的分词模型(细粒度) - self.tok = hanlp.load(hanlp.pretrained.tok.CTB9_TOK_ELECTRA_BASE_CRF) - self.tok.config.output_spans = True # 启用位置输出 - - # 加载词性标注模型 - self.pos_tag = hanlp.load(hanlp.pretrained.pos.CTB9_POS_ELECTRA_SMALL) - - def extract_keywords(self, query: str) -> str: - """ - 从查询中提取关键词(名词,长度 ≥ 2) - - Args: - query: 输入文本 - - Returns: - 拼接后的关键词字符串,非连续词之间自动插入空格 - """ - query = query.strip() - # 分词结果带位置:[[word, start, end], ...] - tok_result_with_position = self.tok(query) - tok_result = [x[0] for x in tok_result_with_position] - - # 词性标注 - pos_tag_result = list(zip(tok_result, self.pos_tag(tok_result))) - - # 需要忽略的词 - ignore_keywords = ['玩具'] - - keywords = [] - last_end_pos = 0 - - for (word, postag), (_, start_pos, end_pos) in zip(pos_tag_result, tok_result_with_position): - if len(word) >= 2 and postag.startswith('N'): - if word in ignore_keywords: - continue - # 如果当前词与上一个词在原文中不连续,插入空格 - if start_pos != last_end_pos and keywords: - keywords.append(" ") - keywords.append(word) - last_end_pos = end_pos - # 可选:打印调试信息 - # print(f'分词: {word} | 词性: {postag} | 起始: {start_pos} | 结束: {end_pos}') - - return "".join(keywords).strip() - - -最后,在组织检索表达式时,目前是每一个 query (base_query base_query_trans_en base_query_trans_zh 三种情况)。 会组成一个bool查询,以base_query为例: - "bool": { - "should": [ - { - "bool": { - "_name": "base_query", - "must": [ - { - "combined_fields": { -... - } - } - ], - "should": [ - { - "multi_match": { -... "type": "best_fields", -... - }, - { - "multi_match": { -... - "type": "phrase", -... - } - } - ] - } - }, - -base_query_trans_en base_query_trans_zh 也是同样 - -在这个布尔查询的must里面加一项:keywords,搜索的字段和combined_fields一样,命中比例要求50% - - -结合现有代码做出合理的设计,呈现简单清晰的数据接口,而不是打补丁 \ No newline at end of file diff --git a/docs/TODO-意图判断-done.md b/docs/TODO-意图判断-done.md deleted file mode 100644 index 91e82f5..0000000 --- a/docs/TODO-意图判断-done.md +++ /dev/null @@ -1,268 +0,0 @@ - - -增加款式意图识别模块。意图类型: 颜色,尺码(目前只需要支持这两种) - -一、 意图判断 -- 意图召回层: -每种意图,有一个召回词集合 -对query(包括原始query、各种翻译query 都做匹配) -- 以颜色意图为例: -有一个词表,每一行 都逗号分割,互为同义词,行内第一个为标准化词 -query匹配了其中任何一个词,都认为,具有颜色意图 -匹配规则: 用细粒度、粗粒度分词,看是否有在词表中的。原始query分词、和每种翻译的分词,都要用。 - -二、 意图使用: - 当前 SKU 置顶逻辑在「分页 + 详情回填」之后 -流程是:run_rerank → 按 from/size 切片 → page fill → _apply_sku_sorting_for_page_hits → ResultFormatter - 要改为: - 1. 有款式意图的时候,才做sku筛选 - 2. sku筛选的时机,改为在reranker之前,对所有内容(rerank输入的所有spus)做sku筛选 - 3. 从仅 option1 扩展到多个维度,识别的意图,包含意图的维度名(color)和维度名的泛化词list(color、颜色、colour、colors...),遍历spu的option1_name,option2_name,option3_name字段,看哪个能匹配上意图的维度名list,哪个匹配上了,则在这个维度筛选。 - 1. 比如匹配到option2_name,那么取每一个sku的option2_values。如果没匹配到任何一个,那么把三个属性值都用空格拼接起来。这个值要记录下来。有两个作用: - 1. 用来跟query匹配,看哪个更query相关性更高,以此进行最优sku筛选,把选出来的sku置顶,并替换spu的image_url - 2. 用来做rerank doc的title补充,从而参与rerank - 4. Rerank doc (有款式意图的时候)要带上属性后缀,拼接到title后面。在调用 run_rerank 前,对每条 hit 生成「用于重排的 doc 文本」(标题 + 可选后缀) - -- sku筛选的规则也要优化: -现在的逻辑是,先做包含的判断,找到第一个 option_value被query包含的,则直接认为匹配。没有匹配的再用embedding相似度。 -改为: - 1. 第一轮:遍历完,如果有且仅有一个被query包含,那么认为匹配。 - 2. 第二轮:如果有多个符合(被query包含),跳到3。如果没有,对每个词都走泛化词表进行匹配。 - 3. 第三轮:如果有多个,那么对这多个,走embedding相关性取最高的。如果一个也没有,则对所有的走embedding相关性取最高的 - 这个sku筛选也需要提取为一个独立的模块。 - -细节备注: -intent 考虑由 QueryParser 编排、具体实现拆成独立模块,主义好,现有的分词等基础设施的复用,缺失的英文分词可以补充。 -在重排窗口内,第一次 ES 查询会把 _source 裁成「重排模板需要的字段」,默认只有 title 等,不包含 skus / option*_name。因此,有意图的时候,需要给这一次的_source加上 skus / option*_name - -先仔细理解需求,查看代码,深度思考应该如何设计,和当前的系统较好的融合,给出统一的设计,可以根据需要适当改造当前的实现,降低整个系统的复杂度,提高模块化程度,而不是打补丁。修改后的最终状态应该是要足够简单、清晰、无冗余和分叉,模块间低耦合。多步思考确认最佳施工方案之后才进行代码修改。 - -5. TODO: 搜索接口里,results[].skus 不是全量子 SKU:由 sku_filter_dimension 控制在应用层按维度分组折叠,每个「维度取值组合」只保留一条 SKU(组内第一条)。请求未传该字段时,Pydantic 默认是 ["option1"],等价于只按 option1_value 去重;服务端不会读取店铺主题的「主展示维」,需调用方与装修配置对齐并传入正确维度。因此当用户有款式等更细粒度意图、而款式落在 option2/option3(或对应 option*_name)时,若仍用默认只按 option1(常见为颜色)折叠,同一颜色下多种款式只会出现一条代表 SKU,无法从返回的 skus 里拿到该颜色下的全部款式行。(若业务需要全量子款,需传包含对应维度的 sku_filter_dimension,或传 null/[] 跳过折叠——以当前 ResultFormatter 实现为准。) - - - - - -GPT5.4 主要思路: -我准备按这条线改,尽量把逻辑收束成两个新模块而不是继续堆在 Searcher 里: - -QueryParser 负责产出统一的“款式意图分析结果”,包含:命中的意图类型、标准化值、命中的 query 变体、分词结果、维度候选词。 -独立的 SKU 选择模块负责:维度解析、候选值抽取、三阶段匹配、选中 SKU 后的主图替换,以及生成 rerank 用的属性后缀。 -Searcher 只编排:解析 query -> 首次 ES 召回 -> 若有款式意图则对 rerank 窗口内全部 hits 做 SKU 预筛选并补充 rerank doc 文本 -> run_rerank -> 分页 -> page fill -> ResultFormatter。这样旧的页内 SKU 置顶逻辑可以下沉或删除,避免前后两个阶段重复做同一件事。我再补看一下你最近写的意图草稿文档,确认有没有现成词表示例或边界条件,避免我这边把词表格式和你预期做偏。 - - - - - -意图匹配的性能优化: -上面一版实现,性能上完全无法接受。因此进行了一轮策略简化 - -prompt -style_sku_prepare_hits阶段耗时太长。请根据需求,思考优化的方法,给出性能优化的方案。 -1. _select_by_embedding,有缓存吗,option_value的值是有限的,之前已经算过的,就不用再算了。不仅仅是embedding相似的结果,整个option_value的匹配结果,是有包含、还是没包含,相似度多少,都不用重新计算。比如之前已经有一个sku的某个属性值叫做“卡其色”,已经算出来是否文本匹配了,那么不需要再去做文本匹配。如果已经算出来向量的相似度,那么不需要再去取向量以及计算相似度。 -2. 匹配可以适当的优化: -匹配流程简化: -1)找到第一个文本匹配的,如果有直接匹配成功。不需要考虑匹配多个的情况。 -2)如果全部都没有匹配,那么进行embedding筛选。 - -匹配规则: -option_name的匹配,直接看规范化后的option_name是不是意图维度的泛化词之一(比如颜色、color、colour),如果没有匹配的,现在应该是把所有维度都算上,这样匹配成本和比较成本太高了,去掉这些逻辑,这种情况不需要加后缀、不需要选择sku。 -ption_value的匹配。意图检测的时候,有匹配的query中的命中的词,这个词被包含在属性值中,那么就算匹配。属性值被包含在query(包括翻译文本)中,也算匹配。提高匹配的覆盖率。 - -3. 这一阶段得到sku选择的结果即可(选中的sku的id,也可以为空值表示没找到匹配成功的,这种情况不需要拼接title后缀给重排输入),但是不用着急做image_url的替换和sku的置顶。等最后填充的时候判断有选中sku的时候直接做替换和置顶即可。 -请你思考如何进行设计,提高性能的时候不带来复杂度的提升,可以适当的重构以降低修改后的代码行数。 -@search/sku_intent_selector.py @query/style_intent.py - - -修改结果: -核心变化在 `search/sku_intent_selector.py` 和 `search/searcher.py`。 - -现在的策略是: -- `option_name` 只做规范化后 alias 精确匹配;任何意图维度没 resolve 到字段,就直接不选 SKU、不加 suffix。 -- `option_value` 文本匹配改成顺序扫描 SKU,找到第一个命中的就直接返回,不再收集多个候选再比较。 -- 文本匹配规则支持两种: - - query 命中的意图词被属性值包含 - - 属性值被 query/translation 包含 -- 只有当所有 SKU 都没有文本命中时,才做 embedding 选择。 -- `prepare_hits()` 现在只产出决策和 `_style_rerank_suffix`,不再提前改 `_source`;真正的 `image_url` 替换和 SKU 置顶统一后移到 `apply_precomputed_decisions()`。 -- 加了请求级缓存,避免同一个 `option_value`/`selection_text` 在一次请求里重复做文本判断、取向量和算相似度。 -- 顺手删掉了旧的 generalized match / fallback_text 路径,代码比之前更短也更直。 - -另外补了回归测试,覆盖了: -- 翻译文本命中后正确选 SKU -- 多个文本命中时取第一个 -- `option_name` 不命中 alias 时不做 SKU 选择 -- 无文本命中时走 embedding fallback - -验证过: -- `pytest tests/test_search_rerank_window.py -q` 通过 -- 变更文件 lint 无报错 - - ------------------------------- - - - ---- - -## 1. 现状(与需求的差距) - -**流水线**(`search/searcher.py`)大致是: - -1. `QueryParser.parse` → `ParsedQuery`(含 `translations`、`query_tokens` 等) -2. 组 ES 查询;若在重排窗口内,第一次查询把 `_source` 裁成「重排模板所需字段」(`_resolve_rerank_source_filter`) -3. ES 搜索 → `run_rerank`(`rerank_client.build_docs_from_hits` 用 `{title}` 等拼 doc) -4. 按 `from/size` 切片 → **page fill** 用 `ids` 查询把当前页 `_source` 补全 -5. `_apply_sku_sorting_for_page_hits`(仅 **option1**,先子串包含命中第一个,否则全量 option1 embedding) -6. `ResultFormatter`(`sku_filter_dimension` 只做**展示层**按维度折叠 SKU,与置顶逻辑独立) - - -**与需求冲突但必须一起解决的一点**:page fill 会用 ES 拉回来的 `_source`**整份覆盖**当前 hit(约 841–842 行)。若在 rerank **之前**只改内存里的 `skus` 顺序/`image_url`,**不**在 fill 后再处理一次,最终响应会被覆盖掉。因此「rerank 前对所有 window 内 hit 做 SKU 决策」和「用户看到的最终列表」之间,必须有一条**明确的数据契约**(见下文 §4)。 - ---- - -## 2. 模块划分(建议:`intent` + `sku_intent` 两层) - -避免继续在 `Searcher` 里堆方法,建议新建小包,职责清晰、由 `Searcher` 编排。 - -| 模块 | 职责 | -|------|------| -| **`query/intent/`**(或 `search/intent/`,二选一以「离谁更近」为准;更推荐 **`query/intent`**,因为输入完全是 query 侧事实) | 加载词表、**意图召回**、多 query 变体 + 粗细分词、输出结构化 **`IntentProfile`** | -| **`search/sku_intent/`**(或 `intent/sku_selection.py`) | 根据 `IntentProfile` 解析 **option1/2/3** 哪一维、生成每 SKU 的**匹配文本**、三轮匹配规则、embedding 批处理、对 `_source` 做 **promote + image_url** | -| **`search/rerank_client.py`(薄扩展)** | 支持「每条 hit 的 doc 文本」:模板扩展或 **显式传入 per-hit 字符串列表**,避免把业务塞进 format 字符串 | - -**`IntentProfile`(概念模型)建议包含**: - -- `active_intents: Set[Literal["color","size"]]`(可扩展) -- 每种意图:`canonical_terms`(命中行的标准词)、`matched_surface_forms`(可选,用于 debug) -- **维度别名**:如 color → `{"color","颜色","colour",...}`(配置或独立小词表) -- 原始用于匹配的 token 集合:每个 query 变体 ×(细粒度 | 粗粒度),便于日志与单测 - -**词表**: - -- **意图召回表**:每行逗号分隔同义词,首词标准化;颜色、尺码各一份(路径放 `config/` 或 `resources/intent/` + `config.yaml` 指路径)。 -- **SKU 第二轮「泛化」表**(对 **option 取值** 做同义扩展):与意图召回表分开,避免语义混在一起。 - ---- - -## 3. 意图判断(与 `QueryParser` 的衔接) - -需求:对 **原始 query + 各类翻译** 都做匹配;**细粒度 + 粗粒度** 分词。 - -现状: - -- `ParsedQuery` 里 **`query_tokens` 只对 rewritten 后的 `query_text` 跑了一次 HanLP**(`query_parser.py` 269–274 行附近),**没有**对 `original_query`、各 `translations` 的 token 缓存。 -- 已有 **`simple_tokenize_query`**(粗粒度)在 `query_parser.py`。 - -**建议**: - -- 在 **`IntentDetector.detect(parsed_query, tokenizer_fn)`** 内统一生成「query 变体列表」:至少包含 `original_query`、`query_normalized`、`rewritten_query`、`translations` 的值(与当前 `_build_sku_query_texts` 思路一致,但升级为**结构化**)。 -- 细粒度:复用 `QueryParser._get_query_tokens`(需把该方法暴露为公开 API 或注入同一 HanLP callable),对每个变体字符串调用。 -- 粗粒度:对每个变体调用 `simple_tokenize_query`。 -- 匹配逻辑:**任意变体 × 任意粒度** 的 token 落在「标准化 → 同义词闭包」上即视为命中该意图(与你描述的行内同义一致)。 - -**可选优化**:在 `parse()` 里顺带产出 `intent_profile`,减少一次遍历;但为控制 `QueryParser` 体积,更稳妥的是 **parse 之后**单独调 `IntentDetector`,依赖清晰。 - ---- - -## 4. 流水线改造(与 page fill 的契约) - -目标顺序变为: - -`ES(window)`(有意图时 `_source` 含 `skus` + `option*_name`) -→ **对每个 hit:SKU 决策 + 生成 rerank 用后缀/全文** -→ `run_rerank`(doc = 标题 + 后缀) -→ 切片 -→ page fill -→ **最终响应前再应用一次 SKU 决策(或与 prefetch 结果合并)** -→ `ResultFormatter` - -**为何最后还要一次?** 因为 page fill 会覆盖 `_source`,rerank 前内存里的 `skus` 顺序不能当作最终真相。 - -**推荐契约(降低复杂度)**: - -1. **Rerank 前**:对 window 内每个 hit 计算 `SkuIntentDecision`(至少包含:`option_slot` 1/2/3、`candidate_sku_index` 或 `sku_id`、`rerank_suffix` 字符串)。可挂在 hit 的**非 ES 字段**上,例如 `hit["_intent_sku"] = {...}`(或只存 `rerank_doc_text` 全文)。 -2. **`run_rerank`**:`build_docs_from_hits` 若发现 hit 上已有 `rerank_doc_text`(或 `style_suffix` + 模板),则优先使用,否则走原模板。 -3. **Page fill 之后**:对**当前页** hit 再调用**同一** `SkuIntentSelector.apply(source, parsed_query, intent_profile)`(或根据 `_id` 合并 prefetch 决策)。这样最终 `image_url` / SKU 顺序与 rerank 一致,且不被 fill 冲掉。 - -若担心算两次 embedding:**第一次**在 window 全量上算 query 向量 + option 向量;第二次仅对当前页且可带缓存(按 `embed_key` 去重),一般量很小。 - -**不在重排窗口内**:没有「rerank 前全 window」这一步;可在 **ResultFormatter 前**对当前页 `es_hits` 用同一 `SkuIntentSelector`(仅当有意图时),与「有意图才做 SKU 筛选」一致。 - ---- - -## 5. `_resolve_rerank_source_filter` 与 ES 字段 - -需求:有意图时预取需包含 `skus`、`option1_name`、`option2_name`、`option3_name`。 - -建议签名扩展为: - -`_resolve_rerank_source_filter(doc_template, intent_profile: Optional[IntentProfile])` - -- 若 `intent_profile` 非空且含 color/size(或任意「款式意图」),在 `includes` 中**合并**上述字段(并与模板解析出的 `title` 等取并集)。 -- 注意与全局 `source_fields` 的 tri-state 语义(`_apply_source_filter`)是否冲突:若租户配置 `_source` 白名单且不含 `skus`,需定义优先级——**建议**:「款式意图所需字段」作为**最低保证**合并进本次请求的 fetch includes,或在文档中写明限制。 - ---- - -## 6. 多维度 option 与「未匹配维度名」 - -需求逻辑可落到纯函数: - -1. 对每个意图类型,有 **维度别名集合**(如 color)。 -2. 依次与 `option1_name`、`option2_name`、`option3_name`(字符串,注意多语言:与 indexer 一致,可能是纯英文或中文)做 **casefold / 规范化** 后匹配别名表。 -3. 命中则该 SKU 行的匹配字段为 `option{k}_value`;用于 embedding key 时继续用 `name:value` 形式(沿用现有 `_sku_option1_embedding_key` 思路,泛化为 `option_slot`)。 -4. **若三个 name 都不匹配意图维度**:用 `option1_value`、`option2_value`、`option3_value` **空格拼接**成一条「兜底描述字符串」,供: - - 与 query 的包含/泛化/embedding 比较; - - 作为 `rerank_suffix` 的一部分(若你希望无明确维度时仍加强 rerank)。 - -**多意图同时存在**(如同时颜色+尺码):需要在产品层定规则,例如: - -- 只对「主意图」排序(配置优先级 color > size),或 -- 要求两个维度都满足的 SKU 优先,否则退化为单意图。 - -实现上可在 `SkuIntentSelector` 输入 `List[IntentType]` 与策略枚举,避免写死 if-else 散落。 - ---- - -## 7. 三轮 SKU 匹配规则(独立模块内) - -从当前「第一个包含就返回」改为: - -1. **第一轮**:统计「option 匹配文本被 **整条 query 文本** **包含**」的 SKU(或对每个 query 变体分别计,再合并——建议与你现有 `_build_sku_query_texts` 对齐);**若恰好 1 个** → 选中。 -2. **第二轮**:若 0 个,对每个 SKU 的候选词走 **取值泛化表**(同义词行),再跑包含判断;仍统计「多个 / 零个」。 -3. **第三轮**: - - 若 **多个** 满足包含(第一轮或第二轮)→ 仅在这多个上算 embedding,取相似度最高; - - 若 **仍 0 个** → 对 **全部** SKU 算 embedding,取最高。 - -实现上保持 **批量 encode**(与当前 `option1_values_to_encode` 去重逻辑类似),只是把「embed_key」从固定 option1 改为按 slot 动态生成。 - ---- - -## 8. `sku_filter_dimension`(API)与意图的关系 - -- **`sku_filter_dimension`**:客户端指定「结果里 SKU 列表如何按维度折叠」,在 `ResultFormatter._filter_skus_by_dimensions` 中实现。 -- **意图 SKU 置顶**:服务端根据 query 推断维度与取值,改顺序与主图。 - -建议约定: - -- **置顶 / 换图**仅在意图开启时执行; -- **`sku_filter_dimension` 仍只影响返回 SKU 条数结构**;若与意图维度冲突(例如意图命中 color,客户端只按 size 折叠),应用**文档说明优先级**:常见做法是 **先意图置顶,再 filter**(或相反,需在 PRD 写清)。 - -避免在 `ResultFormatter` 里再猜意图;意图结论由上游传入或在 Formatter 前已完成 `_source` 调整。 - ---- - -## 9. 配置与观测 - -- `config.yaml`:`intent.enabled`、`intent.lexicon_paths`、`intent.dimension_aliases`(或按类型分块)。 -- `RequestContext` / `debug`:写入 `intent_profile`、`sku_intent_decision`、rerank 用的 doc 摘要,便于与 `docs/TODO-意图判断.md` 对齐。 - ---- - -## 10. 小结 - -- **核心架构**:**`IntentDetector`(query 侧)** + **`SkuIntentSelector`(search 侧)** + **`run_rerank` 的 per-hit doc 覆盖** + **`_resolve_rerank_source_filter` 条件 includes**。 -- **必须处理 page fill 覆盖 `_source`**:rerank 前决策与 **fill 后再 apply 一次**(或等价合并策略),否则会出现「重排用了带后缀的 doc、返回结果却是未置顶 SKU」的不一致。 -- **与现有系统融合点**:`ParsedQuery` 变体列表、HanLP + `simple_tokenize_query`、`TextEmbeddingEncoder`、`ResultFormatter` / `sku_filter_dimension` 的边界清晰,避免把意图逻辑复制到 `api/` 层。 - -若你后续希望把「多意图优先级」或「rerank 后缀格式」定成唯一产品规则,可以在实现前写进同一份 spec,模块接口会很好稳定下来。 \ No newline at end of file diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 21311e0..0000000 --- a/docs/TODO.md +++ /dev/null @@ -1,596 +0,0 @@ -项目 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 deleted file mode 100644 index 96fce8e..0000000 --- a/docs/TODO.txt +++ /dev/null @@ -1,643 +0,0 @@ - - - -本地部署一个7b Q4量化的大模型 -es需要licence的两个功能,如果费用低,开通下licence,或者改es源码定制开发下,支持 rank.rrf,reranker - - - -把knn跟文本相关性的融合方式修改为 "rank": {"rrf": {} }需要licence,可以帮我修改源码支持吗? - - knn_boost: 2.0 - - -{ - "query": { ...全文检索... }, - "knn": { ...向量检索... }, - "rank": { - "rrf": {} - } -} - - -"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" - } - } -}, - - -外部需求: -1. 对推理能力要求很低、对耗时要求很高的大模型API(或者本地部署一个7b Q4量化的大模型),prompt大概30-50个token,首token响应要求500ms以内 -2. ES支持reranker pipline? - - - - - - -增加款式意图识别模块 - -意图类型: 颜色,尺码(目前只需要支持这两种) - -意图召回层: -每种意图,有一个召回词集合 -对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) - - -当前项目功能已经较多,但是有清晰的框架,请务必基于现有框架进行改造,不要进行补丁式的修改,避免代码逻辑分叉。 -请一步一步来,先设计意图识别模块,仔细思考需求,意图识别模块需要提供哪些内容,用于返回数据接口的定义,深度思考,定义一个合理的接口后,再给出合理的模块设计。 - - - - -文本相关性: -调研: -Princeton WordNet — 英文同义词底库 -Shopify Product Taxonomy — 电商品类标准 -Querqy — 电商搜索规则框架 -gensimpson/elasticsearch-synonyms — ES 同义词规则落地 - - -tags字段使用的优化: -现在是keyword,在搜索中,不太好使用(目前主要用于suggest)。 -可以考虑也拆分多语言,配合analyzer使用(和qanchors一样) - - - - - - - -是否需要: -当「源语言不在 index_languages」且「某些目标语言的翻译缺失」时,ES 里会额外加一层 用「原始 query 字符串」去撞缺失语种字段 - - - -先阅读文本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等等,除此之外,也请你思考合适的方案。 -成熟稳定、不带来复杂度、性能、稳定性方面的副作用,是最重要的。请先了解代码、需求,深度思考解决方案 - - - -配置体系的重构。 - -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 和重新设计文档中的简短状态块应用补丁。 - - - - -筛选SKU: 先只筛选第一个维度,但考虑到用户搜索词可能带了尺码,所以第二、三个维度也要考虑 - - -引入图片的相关性: -图片的向量最好做SKU维度,用 SPU 维度还是 SKU 维度? -1. SKU维度(主款式,option1维度),如果用户搜索“蓝色 T恤”,这种图片相关性会比较有价值。 -2. 我不考虑颜色的差异,其余的款式一般是大小之类的。这些图片,项链细粉到 SKU 维度,可能价值不大,性价比偏低 - - - -属性的筛选: -训练一个bert/transformer多分类模型,分类: 颜色、尺寸、材质 等等。但是要注意一些属性的值不规范、非常多,要考虑 是不是做规范化,如何规范化。 - - - - -无结果重查 -稀有语言,翻译可能超时(因为zh-en互译之外的翻译耗时更长) - - - - - - -检索相关性优化: -原始搜索词和翻译的词,都需要有对应的主干分析 -这个主干可以根据词性简单提取名词即可 -在搜索时,原始词和主干都成对地出现,原始词和trunk_keywords一起组成一个或查询。 -有一种方案是把原始词和主干词拼接起来。但是bm25要调tf系数。 - - - - -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架构的模型,有哪些性能优化方案,提高线上翻译服务的吞吐量、降低耗时,搜索相关的在线推理服务方案,找到高性能的服务化方法 - -cnclip的性能优化 - -rerank 性能优化 - - -超时 -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) - - - - - - - - - - - -多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的方式,但是一定会需要解决方案。 - - - - -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 - } -] -模型会以前缀内容为起点开始生成。 -支持 非思考模式。 - - - - - -融合打分(已完成,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 再做“最后一刀” - - - - -suggest 索引,现在是全量脚本,要交给金伟 - - - -翻译,增加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) - - - - - -Qwen3-Reranker-4B-GGUF -https://modelscope.cn/models/dengcao/Qwen3-Reranker-4B-GGUF/summary -1. 要确定选择哪种量化方式 -2. 确定提示词 - - - -reranker 补充:nvidia/llama-nemotron-rerank-1b-v2 -https://huggingface.co/nvidia/llama-nemotron-rerank-1b-v2 -后端推理也建议使用vLLM -注意搜索相关资料,挖掘我的特斯拉 T4 GPU 的性能,充分挖掘性能 -你有充足的自由度进行实验 -encoder架构。 -比较新。 -性能更好。 -亚马逊 电商搜索数据集比qwen-reranker-4b更好。 -支持vLLM。 - - - - - -查看翻译的缓存情况 - -向量的缓存 - - - - - - - - -AI - 生产 - MySQL -HOST:10.200.16.14 / localhost -端口:3316 -用户名:root -密码:qY8tgodLoA&KT#yQ - -AI - 生产 - Redis -HOST:10.200.16.14 / localhost -端口:6479 -密码:dxEkegEZ@C5SXWKv - - -远程登录方式: -# redis -redis-cli -h 43.166.252.75 -p 6479 - -# mysql 3个用户,都可以远程登录 -mysql -uroot -p'qY8tgodLoA&KT#yQ' -CREATE USER 'saas'@'%' IDENTIFIED BY '6dlpco6dVGuqzt^l'; -CREATE USER 'sa'@'%' IDENTIFIED BY 'C#HU!GPps7ck8tsM'; - - - -ES: -HOST:10.200.16.14 / localhost -端口:9200 -访问示例: -用户名密码:saas:4hOaLaf41y2VuI8y - - -安装 nvidia-container-toolkit (done) -https://mirrors.aliyun.com/github/releases/NVIDIA/nvidia-container-toolkit/ -https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/index.html - - -qwen3-embedding、qwen3-reranker (done) -选一个推理引擎,相比于我自己直接调 sentence-transformers,主要是多进程和负载均衡、连续批处理,比较有用 -当前结论:embedding 场景优先 TEI;vLLM 更偏向生成式与 rerank 场景。 - - - -混用 大模型 使用: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 diff --git a/docs/issue-2026-03-21-ES能力提升.md b/docs/issue-2026-03-21-ES能力提升.md new file mode 100644 index 0000000..1250b29 --- /dev/null +++ b/docs/issue-2026-03-21-ES能力提升.md @@ -0,0 +1,70 @@ +ES 付费版本 or 定制开发(建议先看下付费版本价格) +ES定制开发: +RRF / retrievers + +Elastic 的订阅矩阵里明确列了这些相关能力:Retrievers: linear, rule, RRF, text similarity re-ranker,以及 Reciprocal Rank Fusion (RRF) for hybrid search。 + +这类能力最有价值的点是: +它们把混合检索从“自己拼 DSL 和手搓打分”变成了官方支持的多阶段检索框架。重排:text similarity re-ranker / Elastic Rerank. text_similarity_reranker 用 NLP 模型对 top-k 结果按语义相似度重新排序;它可以用内置的 Elastic Rerank,也可以接 Cohere、Vertex AI,或者你自己上传的 text similarity 模型。 + +{ + "retriever": { + "rrf": { + "retrievers": [ + { "standard": { "query": { ... } } }, + { "knn": { ... } } + ] + } + } +} + + +加reranker: +text_similarity_reranker 用 NLP 模型对 top-k 结果按语义相似度重新排序;它可以用内置的 Elastic Rerank,也可以接 Cohere、Vertex AI,或者你自己上传的 text similarity 模型。 + +{ + "retriever": { + "text_similarity_reranker": { + "retriever": { + "rrf": { ... } + }, + ... + } + } +} + +{ + "retriever": { + "text_similarity_reranker": { + "retriever": { + "rrf": { + "retrievers": [ + { + "standard": { + "query": { + "...": "..." + } + } + }, + { + "knn": { + "...": "..." + } + } + ], + "rank_window_size": 100, + "rank_constant": 20 + } + }, + "field": "your_rerank_text_field", + "inference_text": "白色 oversized T-shirt", + "inference_id": ".rerank-v1-elasticsearch", + "rank_window_size": 50 + } + }, + "size": 20 +} + + + + diff --git a/docs/issue-2026-03-21-意图判断-done03-24.md b/docs/issue-2026-03-21-意图判断-done03-24.md new file mode 100644 index 0000000..91e82f5 --- /dev/null +++ b/docs/issue-2026-03-21-意图判断-done03-24.md @@ -0,0 +1,268 @@ + + +增加款式意图识别模块。意图类型: 颜色,尺码(目前只需要支持这两种) + +一、 意图判断 +- 意图召回层: +每种意图,有一个召回词集合 +对query(包括原始query、各种翻译query 都做匹配) +- 以颜色意图为例: +有一个词表,每一行 都逗号分割,互为同义词,行内第一个为标准化词 +query匹配了其中任何一个词,都认为,具有颜色意图 +匹配规则: 用细粒度、粗粒度分词,看是否有在词表中的。原始query分词、和每种翻译的分词,都要用。 + +二、 意图使用: + 当前 SKU 置顶逻辑在「分页 + 详情回填」之后 +流程是:run_rerank → 按 from/size 切片 → page fill → _apply_sku_sorting_for_page_hits → ResultFormatter + 要改为: + 1. 有款式意图的时候,才做sku筛选 + 2. sku筛选的时机,改为在reranker之前,对所有内容(rerank输入的所有spus)做sku筛选 + 3. 从仅 option1 扩展到多个维度,识别的意图,包含意图的维度名(color)和维度名的泛化词list(color、颜色、colour、colors...),遍历spu的option1_name,option2_name,option3_name字段,看哪个能匹配上意图的维度名list,哪个匹配上了,则在这个维度筛选。 + 1. 比如匹配到option2_name,那么取每一个sku的option2_values。如果没匹配到任何一个,那么把三个属性值都用空格拼接起来。这个值要记录下来。有两个作用: + 1. 用来跟query匹配,看哪个更query相关性更高,以此进行最优sku筛选,把选出来的sku置顶,并替换spu的image_url + 2. 用来做rerank doc的title补充,从而参与rerank + 4. Rerank doc (有款式意图的时候)要带上属性后缀,拼接到title后面。在调用 run_rerank 前,对每条 hit 生成「用于重排的 doc 文本」(标题 + 可选后缀) + +- sku筛选的规则也要优化: +现在的逻辑是,先做包含的判断,找到第一个 option_value被query包含的,则直接认为匹配。没有匹配的再用embedding相似度。 +改为: + 1. 第一轮:遍历完,如果有且仅有一个被query包含,那么认为匹配。 + 2. 第二轮:如果有多个符合(被query包含),跳到3。如果没有,对每个词都走泛化词表进行匹配。 + 3. 第三轮:如果有多个,那么对这多个,走embedding相关性取最高的。如果一个也没有,则对所有的走embedding相关性取最高的 + 这个sku筛选也需要提取为一个独立的模块。 + +细节备注: +intent 考虑由 QueryParser 编排、具体实现拆成独立模块,主义好,现有的分词等基础设施的复用,缺失的英文分词可以补充。 +在重排窗口内,第一次 ES 查询会把 _source 裁成「重排模板需要的字段」,默认只有 title 等,不包含 skus / option*_name。因此,有意图的时候,需要给这一次的_source加上 skus / option*_name + +先仔细理解需求,查看代码,深度思考应该如何设计,和当前的系统较好的融合,给出统一的设计,可以根据需要适当改造当前的实现,降低整个系统的复杂度,提高模块化程度,而不是打补丁。修改后的最终状态应该是要足够简单、清晰、无冗余和分叉,模块间低耦合。多步思考确认最佳施工方案之后才进行代码修改。 + +5. TODO: 搜索接口里,results[].skus 不是全量子 SKU:由 sku_filter_dimension 控制在应用层按维度分组折叠,每个「维度取值组合」只保留一条 SKU(组内第一条)。请求未传该字段时,Pydantic 默认是 ["option1"],等价于只按 option1_value 去重;服务端不会读取店铺主题的「主展示维」,需调用方与装修配置对齐并传入正确维度。因此当用户有款式等更细粒度意图、而款式落在 option2/option3(或对应 option*_name)时,若仍用默认只按 option1(常见为颜色)折叠,同一颜色下多种款式只会出现一条代表 SKU,无法从返回的 skus 里拿到该颜色下的全部款式行。(若业务需要全量子款,需传包含对应维度的 sku_filter_dimension,或传 null/[] 跳过折叠——以当前 ResultFormatter 实现为准。) + + + + + +GPT5.4 主要思路: +我准备按这条线改,尽量把逻辑收束成两个新模块而不是继续堆在 Searcher 里: + +QueryParser 负责产出统一的“款式意图分析结果”,包含:命中的意图类型、标准化值、命中的 query 变体、分词结果、维度候选词。 +独立的 SKU 选择模块负责:维度解析、候选值抽取、三阶段匹配、选中 SKU 后的主图替换,以及生成 rerank 用的属性后缀。 +Searcher 只编排:解析 query -> 首次 ES 召回 -> 若有款式意图则对 rerank 窗口内全部 hits 做 SKU 预筛选并补充 rerank doc 文本 -> run_rerank -> 分页 -> page fill -> ResultFormatter。这样旧的页内 SKU 置顶逻辑可以下沉或删除,避免前后两个阶段重复做同一件事。我再补看一下你最近写的意图草稿文档,确认有没有现成词表示例或边界条件,避免我这边把词表格式和你预期做偏。 + + + + + +意图匹配的性能优化: +上面一版实现,性能上完全无法接受。因此进行了一轮策略简化 + +prompt +style_sku_prepare_hits阶段耗时太长。请根据需求,思考优化的方法,给出性能优化的方案。 +1. _select_by_embedding,有缓存吗,option_value的值是有限的,之前已经算过的,就不用再算了。不仅仅是embedding相似的结果,整个option_value的匹配结果,是有包含、还是没包含,相似度多少,都不用重新计算。比如之前已经有一个sku的某个属性值叫做“卡其色”,已经算出来是否文本匹配了,那么不需要再去做文本匹配。如果已经算出来向量的相似度,那么不需要再去取向量以及计算相似度。 +2. 匹配可以适当的优化: +匹配流程简化: +1)找到第一个文本匹配的,如果有直接匹配成功。不需要考虑匹配多个的情况。 +2)如果全部都没有匹配,那么进行embedding筛选。 + +匹配规则: +option_name的匹配,直接看规范化后的option_name是不是意图维度的泛化词之一(比如颜色、color、colour),如果没有匹配的,现在应该是把所有维度都算上,这样匹配成本和比较成本太高了,去掉这些逻辑,这种情况不需要加后缀、不需要选择sku。 +ption_value的匹配。意图检测的时候,有匹配的query中的命中的词,这个词被包含在属性值中,那么就算匹配。属性值被包含在query(包括翻译文本)中,也算匹配。提高匹配的覆盖率。 + +3. 这一阶段得到sku选择的结果即可(选中的sku的id,也可以为空值表示没找到匹配成功的,这种情况不需要拼接title后缀给重排输入),但是不用着急做image_url的替换和sku的置顶。等最后填充的时候判断有选中sku的时候直接做替换和置顶即可。 +请你思考如何进行设计,提高性能的时候不带来复杂度的提升,可以适当的重构以降低修改后的代码行数。 +@search/sku_intent_selector.py @query/style_intent.py + + +修改结果: +核心变化在 `search/sku_intent_selector.py` 和 `search/searcher.py`。 + +现在的策略是: +- `option_name` 只做规范化后 alias 精确匹配;任何意图维度没 resolve 到字段,就直接不选 SKU、不加 suffix。 +- `option_value` 文本匹配改成顺序扫描 SKU,找到第一个命中的就直接返回,不再收集多个候选再比较。 +- 文本匹配规则支持两种: + - query 命中的意图词被属性值包含 + - 属性值被 query/translation 包含 +- 只有当所有 SKU 都没有文本命中时,才做 embedding 选择。 +- `prepare_hits()` 现在只产出决策和 `_style_rerank_suffix`,不再提前改 `_source`;真正的 `image_url` 替换和 SKU 置顶统一后移到 `apply_precomputed_decisions()`。 +- 加了请求级缓存,避免同一个 `option_value`/`selection_text` 在一次请求里重复做文本判断、取向量和算相似度。 +- 顺手删掉了旧的 generalized match / fallback_text 路径,代码比之前更短也更直。 + +另外补了回归测试,覆盖了: +- 翻译文本命中后正确选 SKU +- 多个文本命中时取第一个 +- `option_name` 不命中 alias 时不做 SKU 选择 +- 无文本命中时走 embedding fallback + +验证过: +- `pytest tests/test_search_rerank_window.py -q` 通过 +- 变更文件 lint 无报错 + + +------------------------------ + + + +--- + +## 1. 现状(与需求的差距) + +**流水线**(`search/searcher.py`)大致是: + +1. `QueryParser.parse` → `ParsedQuery`(含 `translations`、`query_tokens` 等) +2. 组 ES 查询;若在重排窗口内,第一次查询把 `_source` 裁成「重排模板所需字段」(`_resolve_rerank_source_filter`) +3. ES 搜索 → `run_rerank`(`rerank_client.build_docs_from_hits` 用 `{title}` 等拼 doc) +4. 按 `from/size` 切片 → **page fill** 用 `ids` 查询把当前页 `_source` 补全 +5. `_apply_sku_sorting_for_page_hits`(仅 **option1**,先子串包含命中第一个,否则全量 option1 embedding) +6. `ResultFormatter`(`sku_filter_dimension` 只做**展示层**按维度折叠 SKU,与置顶逻辑独立) + + +**与需求冲突但必须一起解决的一点**:page fill 会用 ES 拉回来的 `_source`**整份覆盖**当前 hit(约 841–842 行)。若在 rerank **之前**只改内存里的 `skus` 顺序/`image_url`,**不**在 fill 后再处理一次,最终响应会被覆盖掉。因此「rerank 前对所有 window 内 hit 做 SKU 决策」和「用户看到的最终列表」之间,必须有一条**明确的数据契约**(见下文 §4)。 + +--- + +## 2. 模块划分(建议:`intent` + `sku_intent` 两层) + +避免继续在 `Searcher` 里堆方法,建议新建小包,职责清晰、由 `Searcher` 编排。 + +| 模块 | 职责 | +|------|------| +| **`query/intent/`**(或 `search/intent/`,二选一以「离谁更近」为准;更推荐 **`query/intent`**,因为输入完全是 query 侧事实) | 加载词表、**意图召回**、多 query 变体 + 粗细分词、输出结构化 **`IntentProfile`** | +| **`search/sku_intent/`**(或 `intent/sku_selection.py`) | 根据 `IntentProfile` 解析 **option1/2/3** 哪一维、生成每 SKU 的**匹配文本**、三轮匹配规则、embedding 批处理、对 `_source` 做 **promote + image_url** | +| **`search/rerank_client.py`(薄扩展)** | 支持「每条 hit 的 doc 文本」:模板扩展或 **显式传入 per-hit 字符串列表**,避免把业务塞进 format 字符串 | + +**`IntentProfile`(概念模型)建议包含**: + +- `active_intents: Set[Literal["color","size"]]`(可扩展) +- 每种意图:`canonical_terms`(命中行的标准词)、`matched_surface_forms`(可选,用于 debug) +- **维度别名**:如 color → `{"color","颜色","colour",...}`(配置或独立小词表) +- 原始用于匹配的 token 集合:每个 query 变体 ×(细粒度 | 粗粒度),便于日志与单测 + +**词表**: + +- **意图召回表**:每行逗号分隔同义词,首词标准化;颜色、尺码各一份(路径放 `config/` 或 `resources/intent/` + `config.yaml` 指路径)。 +- **SKU 第二轮「泛化」表**(对 **option 取值** 做同义扩展):与意图召回表分开,避免语义混在一起。 + +--- + +## 3. 意图判断(与 `QueryParser` 的衔接) + +需求:对 **原始 query + 各类翻译** 都做匹配;**细粒度 + 粗粒度** 分词。 + +现状: + +- `ParsedQuery` 里 **`query_tokens` 只对 rewritten 后的 `query_text` 跑了一次 HanLP**(`query_parser.py` 269–274 行附近),**没有**对 `original_query`、各 `translations` 的 token 缓存。 +- 已有 **`simple_tokenize_query`**(粗粒度)在 `query_parser.py`。 + +**建议**: + +- 在 **`IntentDetector.detect(parsed_query, tokenizer_fn)`** 内统一生成「query 变体列表」:至少包含 `original_query`、`query_normalized`、`rewritten_query`、`translations` 的值(与当前 `_build_sku_query_texts` 思路一致,但升级为**结构化**)。 +- 细粒度:复用 `QueryParser._get_query_tokens`(需把该方法暴露为公开 API 或注入同一 HanLP callable),对每个变体字符串调用。 +- 粗粒度:对每个变体调用 `simple_tokenize_query`。 +- 匹配逻辑:**任意变体 × 任意粒度** 的 token 落在「标准化 → 同义词闭包」上即视为命中该意图(与你描述的行内同义一致)。 + +**可选优化**:在 `parse()` 里顺带产出 `intent_profile`,减少一次遍历;但为控制 `QueryParser` 体积,更稳妥的是 **parse 之后**单独调 `IntentDetector`,依赖清晰。 + +--- + +## 4. 流水线改造(与 page fill 的契约) + +目标顺序变为: + +`ES(window)`(有意图时 `_source` 含 `skus` + `option*_name`) +→ **对每个 hit:SKU 决策 + 生成 rerank 用后缀/全文** +→ `run_rerank`(doc = 标题 + 后缀) +→ 切片 +→ page fill +→ **最终响应前再应用一次 SKU 决策(或与 prefetch 结果合并)** +→ `ResultFormatter` + +**为何最后还要一次?** 因为 page fill 会覆盖 `_source`,rerank 前内存里的 `skus` 顺序不能当作最终真相。 + +**推荐契约(降低复杂度)**: + +1. **Rerank 前**:对 window 内每个 hit 计算 `SkuIntentDecision`(至少包含:`option_slot` 1/2/3、`candidate_sku_index` 或 `sku_id`、`rerank_suffix` 字符串)。可挂在 hit 的**非 ES 字段**上,例如 `hit["_intent_sku"] = {...}`(或只存 `rerank_doc_text` 全文)。 +2. **`run_rerank`**:`build_docs_from_hits` 若发现 hit 上已有 `rerank_doc_text`(或 `style_suffix` + 模板),则优先使用,否则走原模板。 +3. **Page fill 之后**:对**当前页** hit 再调用**同一** `SkuIntentSelector.apply(source, parsed_query, intent_profile)`(或根据 `_id` 合并 prefetch 决策)。这样最终 `image_url` / SKU 顺序与 rerank 一致,且不被 fill 冲掉。 + +若担心算两次 embedding:**第一次**在 window 全量上算 query 向量 + option 向量;第二次仅对当前页且可带缓存(按 `embed_key` 去重),一般量很小。 + +**不在重排窗口内**:没有「rerank 前全 window」这一步;可在 **ResultFormatter 前**对当前页 `es_hits` 用同一 `SkuIntentSelector`(仅当有意图时),与「有意图才做 SKU 筛选」一致。 + +--- + +## 5. `_resolve_rerank_source_filter` 与 ES 字段 + +需求:有意图时预取需包含 `skus`、`option1_name`、`option2_name`、`option3_name`。 + +建议签名扩展为: + +`_resolve_rerank_source_filter(doc_template, intent_profile: Optional[IntentProfile])` + +- 若 `intent_profile` 非空且含 color/size(或任意「款式意图」),在 `includes` 中**合并**上述字段(并与模板解析出的 `title` 等取并集)。 +- 注意与全局 `source_fields` 的 tri-state 语义(`_apply_source_filter`)是否冲突:若租户配置 `_source` 白名单且不含 `skus`,需定义优先级——**建议**:「款式意图所需字段」作为**最低保证**合并进本次请求的 fetch includes,或在文档中写明限制。 + +--- + +## 6. 多维度 option 与「未匹配维度名」 + +需求逻辑可落到纯函数: + +1. 对每个意图类型,有 **维度别名集合**(如 color)。 +2. 依次与 `option1_name`、`option2_name`、`option3_name`(字符串,注意多语言:与 indexer 一致,可能是纯英文或中文)做 **casefold / 规范化** 后匹配别名表。 +3. 命中则该 SKU 行的匹配字段为 `option{k}_value`;用于 embedding key 时继续用 `name:value` 形式(沿用现有 `_sku_option1_embedding_key` 思路,泛化为 `option_slot`)。 +4. **若三个 name 都不匹配意图维度**:用 `option1_value`、`option2_value`、`option3_value` **空格拼接**成一条「兜底描述字符串」,供: + - 与 query 的包含/泛化/embedding 比较; + - 作为 `rerank_suffix` 的一部分(若你希望无明确维度时仍加强 rerank)。 + +**多意图同时存在**(如同时颜色+尺码):需要在产品层定规则,例如: + +- 只对「主意图」排序(配置优先级 color > size),或 +- 要求两个维度都满足的 SKU 优先,否则退化为单意图。 + +实现上可在 `SkuIntentSelector` 输入 `List[IntentType]` 与策略枚举,避免写死 if-else 散落。 + +--- + +## 7. 三轮 SKU 匹配规则(独立模块内) + +从当前「第一个包含就返回」改为: + +1. **第一轮**:统计「option 匹配文本被 **整条 query 文本** **包含**」的 SKU(或对每个 query 变体分别计,再合并——建议与你现有 `_build_sku_query_texts` 对齐);**若恰好 1 个** → 选中。 +2. **第二轮**:若 0 个,对每个 SKU 的候选词走 **取值泛化表**(同义词行),再跑包含判断;仍统计「多个 / 零个」。 +3. **第三轮**: + - 若 **多个** 满足包含(第一轮或第二轮)→ 仅在这多个上算 embedding,取相似度最高; + - 若 **仍 0 个** → 对 **全部** SKU 算 embedding,取最高。 + +实现上保持 **批量 encode**(与当前 `option1_values_to_encode` 去重逻辑类似),只是把「embed_key」从固定 option1 改为按 slot 动态生成。 + +--- + +## 8. `sku_filter_dimension`(API)与意图的关系 + +- **`sku_filter_dimension`**:客户端指定「结果里 SKU 列表如何按维度折叠」,在 `ResultFormatter._filter_skus_by_dimensions` 中实现。 +- **意图 SKU 置顶**:服务端根据 query 推断维度与取值,改顺序与主图。 + +建议约定: + +- **置顶 / 换图**仅在意图开启时执行; +- **`sku_filter_dimension` 仍只影响返回 SKU 条数结构**;若与意图维度冲突(例如意图命中 color,客户端只按 size 折叠),应用**文档说明优先级**:常见做法是 **先意图置顶,再 filter**(或相反,需在 PRD 写清)。 + +避免在 `ResultFormatter` 里再猜意图;意图结论由上游传入或在 Formatter 前已完成 `_source` 调整。 + +--- + +## 9. 配置与观测 + +- `config.yaml`:`intent.enabled`、`intent.lexicon_paths`、`intent.dimension_aliases`(或按类型分块)。 +- `RequestContext` / `debug`:写入 `intent_profile`、`sku_intent_decision`、rerank 用的 doc 摘要,便于与 `docs/TODO-意图判断.md` 对齐。 + +--- + +## 10. 小结 + +- **核心架构**:**`IntentDetector`(query 侧)** + **`SkuIntentSelector`(search 侧)** + **`run_rerank` 的 per-hit doc 覆盖** + **`_resolve_rerank_source_filter` 条件 includes**。 +- **必须处理 page fill 覆盖 `_source`**:rerank 前决策与 **fill 后再 apply 一次**(或等价合并策略),否则会出现「重排用了带后缀的 doc、返回结果却是未置顶 SKU」的不一致。 +- **与现有系统融合点**:`ParsedQuery` 变体列表、HanLP + `simple_tokenize_query`、`TextEmbeddingEncoder`、`ResultFormatter` / `sku_filter_dimension` 的边界清晰,避免把意图逻辑复制到 `api/` 层。 + +若你后续希望把「多意图优先级」或「rerank 后缀格式」定成唯一产品规则,可以在实现前写进同一份 spec,模块接口会很好稳定下来。 \ No newline at end of file diff --git a/docs/issue-2026-03-26-ES文本搜索-补充多模态knn放入should-done-0327.md b/docs/issue-2026-03-26-ES文本搜索-补充多模态knn放入should-done-0327.md new file mode 100644 index 0000000..2e138fc --- /dev/null +++ b/docs/issue-2026-03-26-ES文本搜索-补充多模态knn放入should-done-0327.md @@ -0,0 +1,72 @@ +目前knn跟query里面是并列的层级,如下: +{ + "size": 400, + "from": 0, + "query": { + "bool": { + "must": [... + ], + } + }, + "knn": { + "field": "title_embedding", + "query_vector": [...], + "k": 120, + "num_candidates": 400, + "boost": 2, + "_name": "knn_query" + }, +其中query的结构是这样的: +"query": { + "bool": { + "should": [ + { + "bool": { + "_name": "base_query", +\# 原始query + } + }, + { + "bool": { + "_name": "base_query_trans_zh", +\# 翻译query。有可能是base_query_trans_en,也有可能两者都有 + "boost": 0.75 + } + } + ], + "minimum_should_match": 1 + } + }, +我想把knn放到should里面,和base_query、base_query_trans_zh并列。 +另外,现在过滤是在knn里面单独加了一遍: + "knn": { + "field": "title_embedding", + "query_vector": [...], + "k": 120, + "num_candidates": 400, + "boost": 2, + "_name": "knn_query", + "filter": { + "range": { + "min_price": { + "gte": 100, + "lt": 200 + } + } + } + } +现在不需要了。因为knn在query的内层了。共用过滤。 + +另外: +我需要再增加一个knn。 +需要参考文本embedding获得的逻辑, +通过 +curl -X POST "http://localhost:6008/embed/clip_text?normalize=true&priority=1" \ + -H "Content-Type: application/json" \ + -d '["纯棉短袖", "street tee"]' +(用 POST /embed/clip_text 生成多模态文本向量。和文本embedding获取方法类似。注意思考代码如何精简,不要冗余。) +得到文本的多模态embedding。 +然后在这里补充一个多模态embedding,寻找图片相似的结果,对应的商品图片字段为image_embedding.vector。 +重排融合:之前有knn的配置bias和exponential。现在,文本和图片的embedding相似需要融合,融合方式是dis_max,因此需要配置: +1)各自的权重和tie_breaker +2)整个向量方面的权重(bias和exponential) \ No newline at end of file diff --git a/docs/issue-2026-03-27-keywords限定-done-0327.txt b/docs/issue-2026-03-27-keywords限定-done-0327.txt new file mode 100644 index 0000000..a68186e --- /dev/null +++ b/docs/issue-2026-03-27-keywords限定-done-0327.txt @@ -0,0 +1,93 @@ +@query/query_parser.py @scripts/es_debug_search.py +原始query、以及每一个翻译,都要有一个对应的keywords_query(token分词后,得到名词) +参考这段代码,获取每一个长度大于 1 的名词,然后用空格拼接起来,作为keywords_query +import hanlp +from typing import List, Tuple, Dict, Any + +class KeywordExtractor: + """ + 基于 HanLP 的名词关键词提取器 + """ + def __init__(self): + # 加载带位置信息的分词模型(细粒度) + self.tok = hanlp.load(hanlp.pretrained.tok.CTB9_TOK_ELECTRA_BASE_CRF) + self.tok.config.output_spans = True # 启用位置输出 + + # 加载词性标注模型 + self.pos_tag = hanlp.load(hanlp.pretrained.pos.CTB9_POS_ELECTRA_SMALL) + + def extract_keywords(self, query: str) -> str: + """ + 从查询中提取关键词(名词,长度 ≥ 2) + + Args: + query: 输入文本 + + Returns: + 拼接后的关键词字符串,非连续词之间自动插入空格 + """ + query = query.strip() + # 分词结果带位置:[[word, start, end], ...] + tok_result_with_position = self.tok(query) + tok_result = [x[0] for x in tok_result_with_position] + + # 词性标注 + pos_tag_result = list(zip(tok_result, self.pos_tag(tok_result))) + + # 需要忽略的词 + ignore_keywords = ['玩具'] + + keywords = [] + last_end_pos = 0 + + for (word, postag), (_, start_pos, end_pos) in zip(pos_tag_result, tok_result_with_position): + if len(word) >= 2 and postag.startswith('N'): + if word in ignore_keywords: + continue + # 如果当前词与上一个词在原文中不连续,插入空格 + if start_pos != last_end_pos and keywords: + keywords.append(" ") + keywords.append(word) + last_end_pos = end_pos + # 可选:打印调试信息 + # print(f'分词: {word} | 词性: {postag} | 起始: {start_pos} | 结束: {end_pos}') + + return "".join(keywords).strip() + + +最后,在组织检索表达式时,目前是每一个 query (base_query base_query_trans_en base_query_trans_zh 三种情况)。 会组成一个bool查询,以base_query为例: + "bool": { + "should": [ + { + "bool": { + "_name": "base_query", + "must": [ + { + "combined_fields": { +... + } + } + ], + "should": [ + { + "multi_match": { +... "type": "best_fields", +... + }, + { + "multi_match": { +... + "type": "phrase", +... + } + } + ] + } + }, + +base_query_trans_en base_query_trans_zh 也是同样 + +在这个布尔查询的must里面加一项:keywords,搜索的字段和combined_fields一样,命中比例要求50% + + +结合现有代码做出合理的设计,呈现简单清晰的数据接口,而不是打补丁 \ No newline at end of file diff --git a/docs/issue.md b/docs/issue.md new file mode 100644 index 0000000..21311e0 --- /dev/null +++ b/docs/issue.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/issue.txt b/docs/issue.txt new file mode 100644 index 0000000..96fce8e --- /dev/null +++ b/docs/issue.txt @@ -0,0 +1,643 @@ + + + +本地部署一个7b Q4量化的大模型 +es需要licence的两个功能,如果费用低,开通下licence,或者改es源码定制开发下,支持 rank.rrf,reranker + + + +把knn跟文本相关性的融合方式修改为 "rank": {"rrf": {} }需要licence,可以帮我修改源码支持吗? + + knn_boost: 2.0 + + +{ + "query": { ...全文检索... }, + "knn": { ...向量检索... }, + "rank": { + "rrf": {} + } +} + + +"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" + } + } +}, + + +外部需求: +1. 对推理能力要求很低、对耗时要求很高的大模型API(或者本地部署一个7b Q4量化的大模型),prompt大概30-50个token,首token响应要求500ms以内 +2. ES支持reranker pipline? + + + + + + +增加款式意图识别模块 + +意图类型: 颜色,尺码(目前只需要支持这两种) + +意图召回层: +每种意图,有一个召回词集合 +对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) + + +当前项目功能已经较多,但是有清晰的框架,请务必基于现有框架进行改造,不要进行补丁式的修改,避免代码逻辑分叉。 +请一步一步来,先设计意图识别模块,仔细思考需求,意图识别模块需要提供哪些内容,用于返回数据接口的定义,深度思考,定义一个合理的接口后,再给出合理的模块设计。 + + + + +文本相关性: +调研: +Princeton WordNet — 英文同义词底库 +Shopify Product Taxonomy — 电商品类标准 +Querqy — 电商搜索规则框架 +gensimpson/elasticsearch-synonyms — ES 同义词规则落地 + + +tags字段使用的优化: +现在是keyword,在搜索中,不太好使用(目前主要用于suggest)。 +可以考虑也拆分多语言,配合analyzer使用(和qanchors一样) + + + + + + + +是否需要: +当「源语言不在 index_languages」且「某些目标语言的翻译缺失」时,ES 里会额外加一层 用「原始 query 字符串」去撞缺失语种字段 + + + +先阅读文本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等等,除此之外,也请你思考合适的方案。 +成熟稳定、不带来复杂度、性能、稳定性方面的副作用,是最重要的。请先了解代码、需求,深度思考解决方案 + + + +配置体系的重构。 + +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 和重新设计文档中的简短状态块应用补丁。 + + + + +筛选SKU: 先只筛选第一个维度,但考虑到用户搜索词可能带了尺码,所以第二、三个维度也要考虑 + + +引入图片的相关性: +图片的向量最好做SKU维度,用 SPU 维度还是 SKU 维度? +1. SKU维度(主款式,option1维度),如果用户搜索“蓝色 T恤”,这种图片相关性会比较有价值。 +2. 我不考虑颜色的差异,其余的款式一般是大小之类的。这些图片,项链细粉到 SKU 维度,可能价值不大,性价比偏低 + + + +属性的筛选: +训练一个bert/transformer多分类模型,分类: 颜色、尺寸、材质 等等。但是要注意一些属性的值不规范、非常多,要考虑 是不是做规范化,如何规范化。 + + + + +无结果重查 +稀有语言,翻译可能超时(因为zh-en互译之外的翻译耗时更长) + + + + + + +检索相关性优化: +原始搜索词和翻译的词,都需要有对应的主干分析 +这个主干可以根据词性简单提取名词即可 +在搜索时,原始词和主干都成对地出现,原始词和trunk_keywords一起组成一个或查询。 +有一种方案是把原始词和主干词拼接起来。但是bm25要调tf系数。 + + + + +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架构的模型,有哪些性能优化方案,提高线上翻译服务的吞吐量、降低耗时,搜索相关的在线推理服务方案,找到高性能的服务化方法 + +cnclip的性能优化 + +rerank 性能优化 + + +超时 +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) + + + + + + + + + + + +多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的方式,但是一定会需要解决方案。 + + + + +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 + } +] +模型会以前缀内容为起点开始生成。 +支持 非思考模式。 + + + + + +融合打分(已完成,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 再做“最后一刀” + + + + +suggest 索引,现在是全量脚本,要交给金伟 + + + +翻译,增加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) + + + + + +Qwen3-Reranker-4B-GGUF +https://modelscope.cn/models/dengcao/Qwen3-Reranker-4B-GGUF/summary +1. 要确定选择哪种量化方式 +2. 确定提示词 + + + +reranker 补充:nvidia/llama-nemotron-rerank-1b-v2 +https://huggingface.co/nvidia/llama-nemotron-rerank-1b-v2 +后端推理也建议使用vLLM +注意搜索相关资料,挖掘我的特斯拉 T4 GPU 的性能,充分挖掘性能 +你有充足的自由度进行实验 +encoder架构。 +比较新。 +性能更好。 +亚马逊 电商搜索数据集比qwen-reranker-4b更好。 +支持vLLM。 + + + + + +查看翻译的缓存情况 + +向量的缓存 + + + + + + + + +AI - 生产 - MySQL +HOST:10.200.16.14 / localhost +端口:3316 +用户名:root +密码:qY8tgodLoA&KT#yQ + +AI - 生产 - Redis +HOST:10.200.16.14 / localhost +端口:6479 +密码:dxEkegEZ@C5SXWKv + + +远程登录方式: +# redis +redis-cli -h 43.166.252.75 -p 6479 + +# mysql 3个用户,都可以远程登录 +mysql -uroot -p'qY8tgodLoA&KT#yQ' +CREATE USER 'saas'@'%' IDENTIFIED BY '6dlpco6dVGuqzt^l'; +CREATE USER 'sa'@'%' IDENTIFIED BY 'C#HU!GPps7ck8tsM'; + + + +ES: +HOST:10.200.16.14 / localhost +端口:9200 +访问示例: +用户名密码:saas:4hOaLaf41y2VuI8y + + +安装 nvidia-container-toolkit (done) +https://mirrors.aliyun.com/github/releases/NVIDIA/nvidia-container-toolkit/ +https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/index.html + + +qwen3-embedding、qwen3-reranker (done) +选一个推理引擎,相比于我自己直接调 sentence-transformers,主要是多进程和负载均衡、连续批处理,比较有用 +当前结论:embedding 场景优先 TEI;vLLM 更偏向生成式与 rerank 场景。 + + + +混用 大模型 使用: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 diff --git a/providers/rerank.py b/providers/rerank.py index 885b806..e7d9419 100644 --- a/providers/rerank.py +++ b/providers/rerank.py @@ -57,7 +57,7 @@ class HttpRerankProvider: return None, None -def create_rerank_provider() -> HttpRerankProvider: +def create_rerank_provider(service_profile: Optional[str] = None) -> HttpRerankProvider: """Create rerank provider from services config.""" cfg = get_rerank_config() provider = (cfg.provider or "http").strip().lower() @@ -65,5 +65,5 @@ def create_rerank_provider() -> HttpRerankProvider: if provider != "http": raise ValueError(f"Unsupported rerank provider: {provider}") - url = get_rerank_service_url() + url = get_rerank_service_url(profile=service_profile) return HttpRerankProvider(service_url=url) diff --git a/scripts/experiments/english_query_bucketing_demo.py b/scripts/experiments/english_query_bucketing_demo.py new file mode 100644 index 0000000..ca04673 --- /dev/null +++ b/scripts/experiments/english_query_bucketing_demo.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +""" +Offline experiment: English query bucketing (intersection / boost / drop). + +Scheme A: spaCy noun_chunks + head + lemma + rule buckets +Scheme B: spaCy NP candidates + KeyBERT rerank → intersection vs boost +Scheme C: YAKE + spaCy noun/POS filter + +Run (after deps): python scripts/experiments/english_query_bucketing_demo.py +Optional: pip install -r scripts/experiments/requirements_query_bucketing_experiments.txt +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + + +# --- shared ----------------------------------------------------------------- + +_POSSESSIVE_RE = re.compile(r"(['’]s)\b", re.IGNORECASE) + + +def normalize_query(s: str) -> str: + s = (s or "").strip() + s = _POSSESSIVE_RE.sub("", s) + return s + + +@dataclass +class BucketResult: + intersection_terms: List[str] = field(default_factory=list) + boost_terms: List[str] = field(default_factory=list) + drop_terms: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "intersection_terms": self.intersection_terms, + "boost_terms": self.boost_terms, + "drop_terms": self.drop_terms, + } + + +def _dedupe_preserve(seq: Sequence[str]) -> List[str]: + seen: Set[str] = set() + out: List[str] = [] + for x in seq: + k = x.strip().lower() + if not k or k in seen: + continue + seen.add(k) + out.append(x.strip()) + return out + + +# --- Scheme A: spaCy + rules ------------------------------------------------- + +WEAK_BOOST_ADJS = frozenset( + { + "best", + "good", + "great", + "new", + "free", + "cheap", + "top", + "fine", + "real", + } +) + +FUNCTIONAL_DEP = frozenset( + { + "det", + "aux", + "auxpass", + "prep", + "mark", + "expl", + "cc", + "punct", + "case", + } +) + +# Second pobj under list-like INTJ roots often encodes audience/size (boost, not must-match). +_DEMOGRAPHIC_NOUNS = frozenset( + { + "women", + "woman", + "men", + "man", + "kids", + "kid", + "boys", + "boy", + "girls", + "girl", + "baby", + "babies", + "toddler", + "adult", + "adults", + } +) + + +def _lemma_lower(t) -> str: + return ((t.lemma_ or t.text) or "").lower().strip() + + +def _surface_lower(t) -> str: + """Lowercased surface form (keeps plural 'headphones' vs lemma 'headphone').""" + return (t.text or "").lower().strip() + + +_PRICE_PREP_LEMMAS = frozenset({"under", "over", "below", "above", "within", "between", "near"}) + + +def bucket_scheme_a_spacy(query: str, nlp) -> BucketResult: + """ + Dependency-first bucketing: noun_chunks alone mis-parse verbal queries like + "noise cancelling headphones" (ROOT verb). Prefer dobj / ROOT product nouns, + purpose PP (for …), and brand INTJ/PROPN. + """ + import spacy # noqa: F401 + + # Do not strip possessives ('s) before spaCy: it changes the parse tree + # (e.g. "women's running shoes size 8" vs "women running shoes size 8"). + text = (query or "").strip() + doc = nlp(text) + intersection: Set[str] = set() + boost: Set[str] = set() + drop: Set[str] = set() + + stops = nlp.Defaults.stop_words | WEAK_BOOST_ADJS + + def mark_drop(t) -> None: + if not t.is_space and not t.is_punct: + drop.add(t.text.lower()) + + # --- Drops: function words / question words --- + for token in doc: + if token.is_space or token.is_punct: + continue + lem = _lemma_lower(token) + if token.pos_ in ("DET", "PRON", "AUX", "ADP", "PART", "SCONJ", "CCONJ"): + mark_drop(token) + continue + if token.dep_ in FUNCTIONAL_DEP: + mark_drop(token) + continue + if token.pos_ == "ADV" and lem in {"where", "how", "when", "why", "what", "which"}: + mark_drop(token) + continue + if token.text.lower() in ("'s", "’s"): + mark_drop(token) + continue + if lem in stops and token.pos_ != "PROPN": + mark_drop(token) + + pobj_heads_to_demote: Set[int] = set() + + # Purpose / context: "for airplane travel" → boost phrase; demote bare head from intersection + for token in doc: + if token.dep_ == "prep" and token.text.lower() == "for": + for c in token.children: + if c.dep_ == "pobj" and c.pos_ in ("NOUN", "PROPN"): + span = doc[c.left_edge.i : c.right_edge.i + 1] + phrase = span.text.strip().lower() + if phrase: + boost.add(phrase) + pobj_heads_to_demote.add(c.i) + + # Price / range: "under 500 dollars" → boost only + for token in doc: + if token.dep_ != "prep" or _lemma_lower(token) not in _PRICE_PREP_LEMMAS: + continue + for c in token.children: + if c.dep_ == "pobj" and c.pos_ in ("NOUN", "PROPN"): + span = doc[c.left_edge.i : c.right_edge.i + 1] + phrase = span.text.strip().lower() + if phrase: + boost.add(phrase) + pobj_heads_to_demote.add(c.i) + + # Direct object product nouns (handles "noise cancelling … headphones") + for token in doc: + if token.dep_ == "dobj" and token.pos_ in ("NOUN", "PROPN"): + if token.i in pobj_heads_to_demote: + continue + intersection.add(_surface_lower(token)) + + # Copular questions / definitions: "what is the best smartphone …" + for token in doc: + if token.dep_ != "nsubj" or token.pos_ not in ("NOUN", "PROPN"): + continue + h = token.head + if h.pos_ == "AUX" and h.dep_ == "ROOT": + intersection.add(_surface_lower(token)) + + # Verbal ROOT: modifiers left of dobj → boost phrase (e.g. "noise cancelling") + roots = [t for t in doc if t.dep_ == "ROOT"] + if roots and roots[0].pos_ == "VERB": + root_v = roots[0] + for t in doc: + if t.dep_ != "dobj" or t.pos_ not in ("NOUN", "PROPN"): + continue + if t.i in pobj_heads_to_demote: + continue + parts: List[str] = [] + for x in doc[: t.i]: + if x.is_punct or x.is_space: + continue + if x.pos_ in ("DET", "ADP", "PRON"): + continue + xl = _lemma_lower(x) + if xl in stops: + continue + parts.append(x.text.lower()) + if len(parts) >= 1: + boost.add(" ".join(parts)) + + # Brand / query lead: INTJ/PROPN ROOT (e.g. Nike …) + for token in doc: + if token.dep_ == "ROOT" and token.pos_ in ("INTJ", "PROPN"): + intersection.add(_surface_lower(token)) + if token.pos_ == "PROPN": + intersection.add(_surface_lower(token)) + + _DIMENSION_ROOTS = frozenset({"size", "width", "length", "height", "weight"}) + + # "women's running shoes size 8" → shoes ∩, "size 8" boost (not size alone) + for token in doc: + if token.dep_ != "ROOT" or token.pos_ != "NOUN": + continue + if _lemma_lower(token) not in _DIMENSION_ROOTS: + continue + for c in token.children: + if c.dep_ == "nsubj" and c.pos_ in ("NOUN", "PROPN"): + intersection.add(_surface_lower(c)) + for ch in c.children: + if ch.dep_ == "compound" and ch.pos_ in ("NOUN", "VERB", "ADJ"): + boost.add(_surface_lower(ch)) + # Only the dimension head + numbers (not full subtree: left_edge/right_edge is huge) + dim_parts = [token.text.lower()] + for ch in token.children: + if ch.dep_ == "nummod": + dim_parts.append(ch.text.lower()) + boost.add(" ".join(dim_parts)) + + # ROOT noun product (e.g. "plastic toy car") + for token in doc: + if token.dep_ == "ROOT" and token.pos_ in ("NOUN", "PROPN"): + if _lemma_lower(token) in _DIMENSION_ROOTS and any( + c.dep_ == "nsubj" and c.pos_ in ("NOUN", "PROPN") for c in token.children + ): + continue + intersection.add(_surface_lower(token)) + for c in token.children: + if c.dep_ == "compound" and c.pos_ == "NOUN": + boost.add(c.text.lower()) + if token.i - token.left_edge.i >= 1: + comps = [x.text.lower() for x in doc[token.left_edge.i : token.i] if x.dep_ == "compound"] + if len(comps) >= 2: + boost.add(" ".join(comps)) + + # List-like INTJ head with multiple pobj: first pobj = product head, rest often demographic + for token in doc: + if token.dep_ != "ROOT" or token.pos_ not in ("INTJ", "VERB", "NOUN"): + continue + pobjs = sorted( + [c for c in token.children if c.dep_ == "pobj" and c.pos_ in ("NOUN", "PROPN")], + key=lambda x: x.i, + ) + if len(pobjs) >= 2 and token.pos_ == "INTJ": + intersection.add(_surface_lower(pobjs[0])) + for extra in pobjs[1:]: + if _lemma_lower(extra) in _DEMOGRAPHIC_NOUNS: + boost.add(_surface_lower(extra)) + else: + intersection.add(_surface_lower(extra)) + elif len(pobjs) == 1 and token.pos_ == "INTJ": + intersection.add(_surface_lower(pobjs[0])) + + # amod under pobj (running → shoes) + for token in doc: + if token.dep_ == "amod" and token.head.pos_ in ("NOUN", "PROPN"): + if token.pos_ == "VERB": + boost.add(_surface_lower(token)) + elif token.pos_ == "ADJ": + boost.add(_lemma_lower(token)) + + # Genitive possessor (women's shoes → women boost) + for token in doc: + if token.dep_ == "poss" and token.head.pos_ in ("NOUN", "PROPN"): + boost.add(_surface_lower(token)) + + # noun_chunks fallback when no dobj/ROOT intersection yet + if not intersection: + for chunk in doc.noun_chunks: + head = chunk.root + if head.pos_ not in ("NOUN", "PROPN"): + continue + # Price / range: "under 500 dollars" → boost, not a product head + if head.dep_ == "pobj" and head.head.dep_ == "prep": + prep = head.head + if _lemma_lower(prep) in _PRICE_PREP_LEMMAS: + boost.add(chunk.text.strip().lower()) + continue + hl = _surface_lower(head) + if hl: + intersection.add(hl) + for t in chunk: + if t == head or t.pos_ != "PROPN": + continue + intersection.add(_surface_lower(t)) + for t in chunk: + if t == head: + continue + if t.pos_ == "ADJ" or (t.pos_ == "NOUN" and t.dep_ == "compound"): + boost.add(_lemma_lower(t)) + + # Remove demoted pobj heads from intersection (purpose / price clause) + for i in pobj_heads_to_demote: + t = doc[i] + intersection.discard(_lemma_lower(t)) + intersection.discard(_surface_lower(t)) + + boost -= intersection + boost = {b for b in boost if b.lower() not in stops and b.strip()} + + return BucketResult( + intersection_terms=_dedupe_preserve(sorted(intersection)), + boost_terms=_dedupe_preserve(sorted(boost)), + drop_terms=_dedupe_preserve(sorted(drop)), + ) + + +# --- Scheme B: spaCy candidates + KeyBERT ----------------------------------- + +def _spacy_np_candidates(doc) -> List[str]: + phrases: List[str] = [] + for chunk in doc.noun_chunks: + t = chunk.text.strip() + if len(t) < 2: + continue + root = chunk.root + if root.pos_ not in ("NOUN", "PROPN"): + continue + phrases.append(t) + return phrases + + +def bucket_scheme_b_keybert(query: str, nlp, kw_model) -> BucketResult: + text = (query or "").strip() + doc = nlp(text) + candidates = _spacy_np_candidates(doc) + if not candidates: + candidates = [text] + + # KeyBERT API: candidate_keywords=... (sentence-transformers backend) + try: + keywords = kw_model.extract_keywords( + text, + candidates=candidates, + top_n=min(8, max(4, len(candidates) + 2)), + ) + except TypeError: + keywords = kw_model.extract_keywords( + text, + candidate_keywords=candidates, + top_n=min(8, max(4, len(candidates) + 2)), + ) + ranked = [k[0].lower().strip() for k in (keywords or []) if k and k[0].strip()] + + intersection: List[str] = [] + boost: List[str] = [] + if ranked: + intersection.append(ranked[0]) + if len(ranked) > 1: + boost.extend(ranked[1:]) + # Add remaining spaCy heads not in lists + heads: List[str] = [] + for ch in doc.noun_chunks: + h = ch.root + if h.pos_ in ("NOUN", "PROPN"): + heads.append(_surface_lower(h)) + for h in heads: + if h and h not in intersection and h not in boost: + boost.append(h) + if not intersection and heads: + intersection.append(heads[0]) + boost = [x for x in boost if x != heads[0]] + + drop_tokens: Set[str] = set() + stops = nlp.Defaults.stop_words | WEAK_BOOST_ADJS + for token in doc: + if token.is_punct: + continue + lem = (token.lemma_ or token.text).lower() + if token.pos_ in ("DET", "ADP", "PART", "PRON", "AUX") or lem in stops: + drop_tokens.add(token.text.lower()) + + return BucketResult( + intersection_terms=_dedupe_preserve(intersection), + boost_terms=_dedupe_preserve(boost), + drop_terms=sorted(drop_tokens), + ) + + +# --- Scheme C: YAKE + noun filter -------------------------------------------- + +def bucket_scheme_c_yake(query: str, nlp, yake_extractor) -> BucketResult: + text = (query or "").strip() + doc = nlp(text) + + kws = yake_extractor.extract_keywords(text) # List[Tuple[str, float]] newest yake API may differ + + scored: List[Tuple[str, float]] = [] + if kws and isinstance(kws[0], (list, tuple)) and len(kws[0]) >= 2: + scored = [(str(a).strip(), float(b)) for a, b in kws] + else: + # older yake returns list of tuples (kw, score) + scored = [(str(x[0]).strip(), float(x[1])) for x in kws] + + boost: List[str] = [] + intersection: List[str] = [] + for phrase, _score in sorted(scored, key=lambda x: x[1]): # lower score = more important in YAKE + phrase = phrase.lower().strip() + if not phrase or len(phrase) < 2: + continue + sub = nlp(phrase) + keep = False + head_noun = False + for t in sub: + if t.is_punct or t.is_space: + continue + if t.pos_ in ("NOUN", "PROPN"): + keep = True + if t.dep_ == "ROOT" or t == sub[-1]: + head_noun = True + if not keep: + continue + # top 1–2 important → intersection (very small) + if len(intersection) < 2 and head_noun and len(phrase.split()) <= 2: + intersection.append(phrase) + else: + boost.append(phrase) + + drop: Set[str] = set() + stops = nlp.Defaults.stop_words | WEAK_BOOST_ADJS + for token in doc: + if token.is_punct: + continue + lem = (token.lemma_ or token.text).lower() + if token.pos_ in ("DET", "ADP", "PART", "PRON", "AUX") or lem in stops: + drop.add(token.text.lower()) + + return BucketResult( + intersection_terms=_dedupe_preserve(intersection), + boost_terms=_dedupe_preserve(boost), + drop_terms=sorted(drop), + ) + + +# --- CLI --------------------------------------------------------------------- + +DEFAULT_QUERIES = [ + "best noise cancelling headphones for airplane travel", + "nike running shoes women", + "plastic toy car", + "what is the best smartphone under 500 dollars", + "women's running shoes size 8", +] + + +def _load_spacy(): + import spacy + + try: + return spacy.load("en_core_web_sm") + except OSError: + print( + "Missing model: run: python -m spacy download en_core_web_sm", + file=sys.stderr, + ) + raise + + +def _load_keybert(): + from keybert import KeyBERT + + # small & fast for demo; swap for larger if needed + return KeyBERT(model="paraphrase-MiniLM-L6-v2") + + +def _load_yake(): + import yake + + return yake.KeywordExtractor( + lan="en", + n=3, + dedupLim=0.9, + top=20, + features=None, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="English query bucketing experiments") + parser.add_argument( + "--queries", + nargs="*", + default=DEFAULT_QUERIES, + help="Queries to run (default: built-in examples)", + ) + parser.add_argument( + "--scheme", + choices=("a", "b", "c", "all"), + default="all", + ) + args = parser.parse_args() + + nlp = _load_spacy() + kb = None + yk = None + if args.scheme in ("b", "all"): + kb = _load_keybert() + if args.scheme in ("c", "all"): + yk = _load_yake() + + for q in args.queries: + print("=" * 72) + print("QUERY:", q) + print("-" * 72) + if args.scheme in ("a", "all"): + ra = bucket_scheme_a_spacy(q, nlp) + print("A spaCy+rules:", json.dumps(ra.to_dict(), ensure_ascii=False)) + if args.scheme in ("b", "all") and kb is not None: + rb = bucket_scheme_b_keybert(q, nlp, kb) + print("B spaCy+KeyBERT:", json.dumps(rb.to_dict(), ensure_ascii=False)) + if args.scheme in ("c", "all") and yk is not None: + rc = bucket_scheme_c_yake(q, nlp, yk) + print("C YAKE+noun filter:", json.dumps(rc.to_dict(), ensure_ascii=False)) + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/experiments/requirements_query_bucketing_experiments.txt b/scripts/experiments/requirements_query_bucketing_experiments.txt new file mode 100644 index 0000000..5562c37 --- /dev/null +++ b/scripts/experiments/requirements_query_bucketing_experiments.txt @@ -0,0 +1,6 @@ +# Optional: English query bucketing experiments +# After install: python -m spacy download en_core_web_sm +spacy>=3.7.0 +keybert>=0.8.0 +sentence-transformers>=2.2.0 +yake>=0.4.8 diff --git a/scripts/temp_embed_tenant_image_urls.py b/scripts/temp_embed_tenant_image_urls.py new file mode 100644 index 0000000..61ff54f --- /dev/null +++ b/scripts/temp_embed_tenant_image_urls.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +临时脚本:从 ES 遍历指定租户的 image_url,批量调用图片 embedding 服务。 +5 进程并发,每请求最多 8 条 URL。日志打印到标准输出。 + +用法: + source activate.sh # 会加载 .env,提供 ES_HOST / ES_USERNAME / ES_PASSWORD + python scripts/temp_embed_tenant_image_urls.py + +未 source 时脚本也会尝试加载项目根目录 .env。 +""" + +from __future__ import annotations + +import json +import multiprocessing as mp +import os +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode + +import requests +from elasticsearch import Elasticsearch +from elasticsearch.helpers import scan + +# 未 source activate.sh 时仍可从项目根 .env 加载(与 ES_HOST / ES_USERNAME / ES_PASSWORD 一致) +try: + from dotenv import load_dotenv + + _ROOT = Path(__file__).resolve().parents[1] + load_dotenv(_ROOT / ".env") +except ImportError: + pass + +# --------------------------------------------------------------------------- +# 配置(可按需修改;默认与 .env 中 ES_* 一致,见 config/loader.py) +# --------------------------------------------------------------------------- + +# Elasticsearch(默认读环境变量:ES_HOST、ES_USERNAME、ES_PASSWORD) +ES_HOST: str = os.getenv("ES_HOST", "http://localhost:9200") +ES_USERNAME: Optional[str] = os.getenv("ES_USERNAME") or None +ES_PASSWORD: Optional[str] = os.getenv("ES_PASSWORD") or None +ES_INDEX: str = "search_products_tenant_163" + +# 租户(keyword 字段,字符串) +TENANT_ID: str = "163" + +# 图片 embedding 服务(与文档 7.1.2 一致) +EMBED_BASE_URL: str = "http://localhost:6008" +EMBED_PATH: str = "/embed/image" +EMBED_QUERY: Dict[str, Any] = { + "normalize": "true", + "priority": "1", # 与对接文档 curl 一致;批量离线可改为 "0" +} + +# 并发与批量 +WORKER_PROCESSES: int = 5 +URLS_PER_REQUEST: int = 8 + +# HTTP +REQUEST_TIMEOUT_SEC: float = 120.0 + +# ES scan(elasticsearch-py 8+/ES 9:`scan(..., query=...)` 会展开为 `client.search(**kwargs)`, +# 必须传与 Search API 一致的参数名,例如顶层 `query` = DSL 的 query 子句,不要用裸 `match_all`。) +SCROLL_CHUNK_SIZE: int = 500 + +# --------------------------------------------------------------------------- + + +@dataclass +class BatchResult: + batch_index: int + url_count: int + ok: bool + status_code: Optional[int] + elapsed_sec: float + error: Optional[str] = None + + +def _build_embed_url() -> str: + q = urlencode(EMBED_QUERY) + return f"{EMBED_BASE_URL.rstrip('/')}{EMBED_PATH}?{q}" + + +def _process_batch(payload: Tuple[int, List[str]]) -> BatchResult: + batch_index, urls = payload + if not urls: + return BatchResult(batch_index, 0, True, None, 0.0, None) + + url = _build_embed_url() + t0 = time.perf_counter() + try: + resp = requests.post( + url, + headers={"Content-Type": "application/json"}, + data=json.dumps(urls), + timeout=REQUEST_TIMEOUT_SEC, + ) + elapsed = time.perf_counter() - t0 + ok = resp.status_code == 200 + err: Optional[str] = None + if ok: + try: + body = resp.json() + if not isinstance(body, list) or len(body) != len(urls): + ok = False + err = f"response length mismatch or not list: got {type(body).__name__}" + except Exception as e: + ok = False + err = f"json decode: {e}" + else: + err = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" + + worker = mp.current_process().name + status = resp.status_code if resp else None + ms = elapsed * 1000.0 + if ok: + print( + f"[embed] worker={worker} batch={batch_index} urls={len(urls)} " + f"http={status} elapsed_ms={ms:.2f} ok", + flush=True, + ) + else: + print( + f"[embed] worker={worker} batch={batch_index} urls={len(urls)} " + f"http={status} elapsed_ms={ms:.2f} FAIL err={err}", + flush=True, + ) + return BatchResult(batch_index, len(urls), ok, status, elapsed, err) + except Exception as e: + elapsed = time.perf_counter() - t0 + worker = mp.current_process().name + print( + f"[embed] worker={worker} batch={batch_index} urls={len(urls)} " + f"http=None elapsed_ms={elapsed * 1000.0:.2f} FAIL err={e}", + flush=True, + ) + return BatchResult(batch_index, len(urls), False, None, elapsed, str(e)) + + +def _iter_image_urls(es: Elasticsearch) -> List[str]: + # 对应 search body: { "query": { "term": { "tenant_id": "..." } } } + search_kw: Dict[str, Any] = { + "query": {"term": {"tenant_id": TENANT_ID}}, + "source_includes": ["image_url"], + } + urls: List[str] = [] + for hit in scan( + es, + query=search_kw, + index=ES_INDEX, + size=SCROLL_CHUNK_SIZE, + ): + src = hit.get("_source") or {} + u = src.get("image_url") + if u is None: + continue + s = str(u).strip() + if not s: + continue + urls.append(s) + return urls + + +def main() -> int: + t_wall0 = time.perf_counter() + + auth = None + if ES_USERNAME and ES_PASSWORD: + auth = (ES_USERNAME, ES_PASSWORD) + + es = Elasticsearch([ES_HOST], basic_auth=auth) + if not es.ping(): + print("ERROR: Elasticsearch ping failed", file=sys.stderr) + return 1 + + print( + f"[main] ES={ES_HOST} basic_auth={'yes' if auth else 'no'} " + f"index={ES_INDEX} tenant_id={TENANT_ID} " + f"workers={WORKER_PROCESSES} urls_per_req={URLS_PER_REQUEST}", + flush=True, + ) + print(f"[main] embed_url={_build_embed_url()}", flush=True) + + t_fetch0 = time.perf_counter() + all_urls = _iter_image_urls(es) + fetch_elapsed = time.perf_counter() - t_fetch0 + print( + f"[main] collected image_url count={len(all_urls)} es_scan_elapsed_sec={fetch_elapsed:.3f}", + flush=True, + ) + + batches: List[List[str]] = [] + for i in range(0, len(all_urls), URLS_PER_REQUEST): + batches.append(all_urls[i : i + URLS_PER_REQUEST]) + + if not batches: + print("[main] no URLs to process; done.", flush=True) + return 0 + + tasks = [(idx, batch) for idx, batch in enumerate(batches)] + print(f"[main] batches={len(tasks)} (parallel processes={WORKER_PROCESSES})", flush=True) + + t_run0 = time.perf_counter() + total_urls = 0 + success_urls = 0 + failed_urls = 0 + ok_batches = 0 + fail_batches = 0 + sum_req_sec = 0.0 + + with mp.Pool(processes=WORKER_PROCESSES) as pool: + for res in pool.imap_unordered(_process_batch, tasks, chunksize=1): + total_urls += res.url_count + sum_req_sec += res.elapsed_sec + if res.ok: + ok_batches += 1 + success_urls += res.url_count + else: + fail_batches += 1 + failed_urls += res.url_count + + wall_total = time.perf_counter() - t_wall0 + run_elapsed = time.perf_counter() - t_run0 + + print("---------- summary ----------", flush=True) + print(f"tenant_id: {TENANT_ID}", flush=True) + print(f"total documents w/ url: {len(all_urls)}", flush=True) + print(f"total batches: {len(batches)}", flush=True) + print(f"batches succeeded: {ok_batches}", flush=True) + print(f"batches failed: {fail_batches}", flush=True) + print(f"urls (success path): {success_urls}", flush=True) + print(f"urls (failed path): {failed_urls}", flush=True) + print(f"ES scan elapsed (s): {fetch_elapsed:.3f}", flush=True) + print(f"embed phase wall (s): {run_elapsed:.3f}", flush=True) + print(f"sum request time (s): {sum_req_sec:.3f} (sequential sum, for reference)", flush=True) + print(f"total wall time (s): {wall_total:.3f}", flush=True) + print("-----------------------------", flush=True) + return 0 if fail_batches == 0 else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/search/rerank_client.py b/search/rerank_client.py index f6a1217..f0c7044 100644 --- a/search/rerank_client.py +++ b/search/rerank_client.py @@ -10,7 +10,7 @@ from typing import Dict, Any, List, Optional, Tuple import logging -from config.schema import RerankFusionConfig +from config.schema import CoarseRankFusionConfig, RerankFusionConfig from providers import create_rerank_provider logger = logging.getLogger(__name__) @@ -120,6 +120,7 @@ def call_rerank_service( docs: List[str], timeout_sec: float = DEFAULT_TIMEOUT_SEC, top_n: Optional[int] = None, + service_profile: Optional[str] = None, ) -> Tuple[Optional[List[float]], Optional[Dict[str, Any]]]: """ 调用重排服务 POST /rerank,返回分数列表与 meta。 @@ -128,7 +129,7 @@ def call_rerank_service( if not docs: return [], {} try: - client = create_rerank_provider() + client = create_rerank_provider(service_profile=service_profile) return client.rerank(query=query, docs=docs, timeout_sec=timeout_sec, top_n=top_n) except Exception as e: logger.warning("Rerank request failed: %s", e, exc_info=True) @@ -240,24 +241,105 @@ def _collect_text_score_components(matched_queries: Any, fallback_es_score: floa def _multiply_fusion_factors( rerank_score: float, + fine_score: Optional[float], text_score: float, knn_score: float, fusion: RerankFusionConfig, -) -> Tuple[float, float, float, float]: - """(rerank_factor, text_factor, knn_factor, fused_without_style_boost).""" +) -> Tuple[float, float, float, float, float]: + """(rerank_factor, fine_factor, text_factor, knn_factor, fused_without_style_boost).""" r = (max(rerank_score, 0.0) + fusion.rerank_bias) ** fusion.rerank_exponent + if fine_score is None: + f = 1.0 + else: + f = (max(fine_score, 0.0) + fusion.fine_bias) ** fusion.fine_exponent t = (max(text_score, 0.0) + fusion.text_bias) ** fusion.text_exponent k = (max(knn_score, 0.0) + fusion.knn_bias) ** fusion.knn_exponent - return r, t, k, r * t * k + return r, f, t, k, r * f * t * k + + +def _multiply_coarse_fusion_factors( + text_score: float, + knn_score: float, + fusion: CoarseRankFusionConfig, +) -> Tuple[float, float, float]: + text_factor = (max(text_score, 0.0) + fusion.text_bias) ** fusion.text_exponent + knn_factor = (max(knn_score, 0.0) + fusion.knn_bias) ** fusion.knn_exponent + return text_factor, knn_factor, text_factor * knn_factor def _has_selected_sku(hit: Dict[str, Any]) -> bool: return bool(str(hit.get("_style_rerank_suffix") or "").strip()) +def coarse_resort_hits( + es_hits: List[Dict[str, Any]], + fusion: Optional[CoarseRankFusionConfig] = None, + debug: bool = False, +) -> List[Dict[str, Any]]: + """Coarse rank with text/knn fusion only.""" + if not es_hits: + return [] + + f = fusion or CoarseRankFusionConfig() + coarse_debug: List[Dict[str, Any]] = [] if debug else [] + for hit in es_hits: + es_score = _to_score(hit.get("_score")) + matched_queries = hit.get("matched_queries") + knn_components = _collect_knn_score_components(matched_queries, f) + text_components = _collect_text_score_components(matched_queries, es_score) + text_score = text_components["text_score"] + knn_score = knn_components["knn_score"] + text_factor, knn_factor, coarse_score = _multiply_coarse_fusion_factors( + text_score=text_score, + knn_score=knn_score, + fusion=f, + ) + + hit["_text_score"] = text_score + hit["_knn_score"] = knn_score + hit["_text_knn_score"] = knn_components["text_knn_score"] + hit["_image_knn_score"] = knn_components["image_knn_score"] + hit["_coarse_score"] = coarse_score + + if debug: + coarse_debug.append( + { + "doc_id": hit.get("_id"), + "es_score": es_score, + "text_score": text_score, + "text_source_score": text_components["source_score"], + "text_translation_score": text_components["translation_score"], + "text_weighted_source_score": text_components["weighted_source_score"], + "text_weighted_translation_score": text_components["weighted_translation_score"], + "text_primary_score": text_components["primary_text_score"], + "text_support_score": text_components["support_text_score"], + "text_score_fallback_to_es": ( + text_score == es_score + and text_components["source_score"] <= 0.0 + and text_components["translation_score"] <= 0.0 + ), + "text_knn_score": knn_components["text_knn_score"], + "image_knn_score": knn_components["image_knn_score"], + "weighted_text_knn_score": knn_components["weighted_text_knn_score"], + "weighted_image_knn_score": knn_components["weighted_image_knn_score"], + "knn_primary_score": knn_components["primary_knn_score"], + "knn_support_score": knn_components["support_knn_score"], + "knn_score": knn_score, + "coarse_text_factor": text_factor, + "coarse_knn_factor": knn_factor, + "coarse_score": coarse_score, + "matched_queries": matched_queries, + } + ) + + es_hits.sort(key=lambda h: h.get("_coarse_score", h.get("_score", 0.0)), reverse=True) + return coarse_debug + + def fuse_scores_and_resort( es_hits: List[Dict[str, Any]], rerank_scores: List[float], + fine_scores: Optional[List[float]] = None, weight_es: float = DEFAULT_WEIGHT_ES, weight_ai: float = DEFAULT_WEIGHT_AI, fusion: Optional[RerankFusionConfig] = None, @@ -290,6 +372,8 @@ def fuse_scores_and_resort( n = len(es_hits) if n == 0 or len(rerank_scores) != n: return [] + if fine_scores is not None and len(fine_scores) != n: + fine_scores = None f = fusion or RerankFusionConfig() fused_debug: List[Dict[str, Any]] = [] if debug else [] @@ -297,13 +381,14 @@ def fuse_scores_and_resort( for idx, hit in enumerate(es_hits): es_score = _to_score(hit.get("_score")) rerank_score = _to_score(rerank_scores[idx]) + fine_score = _to_score(fine_scores[idx]) if fine_scores is not None else _to_score(hit.get("_fine_score")) matched_queries = hit.get("matched_queries") knn_components = _collect_knn_score_components(matched_queries, f) knn_score = knn_components["knn_score"] text_components = _collect_text_score_components(matched_queries, es_score) text_score = text_components["text_score"] - rerank_factor, text_factor, knn_factor, fused = _multiply_fusion_factors( - rerank_score, text_score, knn_score, f + rerank_factor, fine_factor, text_factor, knn_factor, fused = _multiply_fusion_factors( + rerank_score, fine_score if fine_scores is not None or "_fine_score" in hit else None, text_score, knn_score, f ) sku_selected = _has_selected_sku(hit) style_boost = style_intent_selected_sku_boost if sku_selected else 1.0 @@ -311,6 +396,7 @@ def fuse_scores_and_resort( hit["_original_score"] = hit.get("_score") hit["_rerank_score"] = rerank_score + hit["_fine_score"] = fine_score hit["_text_score"] = text_score hit["_knn_score"] = knn_score hit["_text_knn_score"] = knn_components["text_knn_score"] @@ -330,6 +416,7 @@ def fuse_scores_and_resort( "doc_id": hit.get("_id"), "es_score": es_score, "rerank_score": rerank_score, + "fine_score": fine_score, "text_score": text_score, "text_source_score": text_components["source_score"], "text_translation_score": text_components["translation_score"], @@ -350,6 +437,7 @@ def fuse_scores_and_resort( "knn_support_score": knn_components["support_knn_score"], "knn_score": knn_score, "rerank_factor": rerank_factor, + "fine_factor": fine_factor, "text_factor": text_factor, "knn_factor": knn_factor, "style_intent_selected_sku": sku_selected, @@ -381,6 +469,8 @@ def run_rerank( debug: bool = False, fusion: Optional[RerankFusionConfig] = None, style_intent_selected_sku_boost: float = 1.2, + fine_scores: Optional[List[float]] = None, + service_profile: Optional[str] = None, ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]], List[Dict[str, Any]]]: """ 完整重排流程:从 es_response 取 hits -> 构造 docs -> 调服务 -> 融合分数并重排 -> 更新 max_score。 @@ -404,6 +494,7 @@ def run_rerank( docs, timeout_sec=timeout_sec, top_n=top_n, + service_profile=service_profile, ) if scores is None or len(scores) != len(hits): @@ -412,6 +503,7 @@ def run_rerank( fused_debug = fuse_scores_and_resort( hits, scores, + fine_scores=fine_scores, weight_es=weight_es, weight_ai=weight_ai, fusion=fusion, @@ -427,3 +519,53 @@ def run_rerank( es_response["hits"]["max_score"] = top return es_response, meta, fused_debug + + +def run_lightweight_rerank( + query: str, + es_hits: List[Dict[str, Any]], + language: str = "zh", + timeout_sec: float = DEFAULT_TIMEOUT_SEC, + rerank_query_template: str = "{query}", + rerank_doc_template: str = "{title}", + top_n: Optional[int] = None, + debug: bool = False, + service_profile: Optional[str] = "fine", +) -> Tuple[Optional[List[float]], Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """Call lightweight reranker and attach scores to hits without final fusion.""" + if not es_hits: + return [], {}, [] + + query_text = str(rerank_query_template).format_map({"query": query}) + rerank_debug_rows: Optional[List[Dict[str, Any]]] = [] if debug else None + docs = build_docs_from_hits( + es_hits, + language=language, + doc_template=rerank_doc_template, + debug_rows=rerank_debug_rows, + ) + scores, meta = call_rerank_service( + query_text, + docs, + timeout_sec=timeout_sec, + top_n=top_n, + service_profile=service_profile, + ) + if scores is None or len(scores) != len(es_hits): + return None, None, [] + + debug_rows: List[Dict[str, Any]] = [] if debug else [] + for idx, hit in enumerate(es_hits): + fine_score = _to_score(scores[idx]) + hit["_fine_score"] = fine_score + if debug: + row: Dict[str, Any] = { + "doc_id": hit.get("_id"), + "fine_score": fine_score, + } + if rerank_debug_rows is not None and idx < len(rerank_debug_rows): + row["rerank_input"] = rerank_debug_rows[idx] + debug_rows.append(row) + + es_hits.sort(key=lambda h: h.get("_fine_score", 0.0), reverse=True) + return scores, meta, debug_rows diff --git a/search/searcher.py b/search/searcher.py index 81a4e04..e032504 100644 --- a/search/searcher.py +++ b/search/searcher.py @@ -251,6 +251,30 @@ class Searcher: return hits_by_id, int(resp.get("took", 0) or 0) @staticmethod + def _restore_hits_in_doc_order( + doc_ids: List[str], + hits_by_id: Dict[str, Dict[str, Any]], + ) -> List[Dict[str, Any]]: + ordered_hits: List[Dict[str, Any]] = [] + for doc_id in doc_ids: + hit = hits_by_id.get(str(doc_id)) + if hit is not None: + ordered_hits.append(hit) + return ordered_hits + + @staticmethod + def _merge_source_specs(*source_specs: Any) -> Optional[Dict[str, Any]]: + includes: set[str] = set() + for source_spec in source_specs: + if not isinstance(source_spec, dict): + continue + for field_name in source_spec.get("includes") or []: + includes.add(str(field_name)) + if not includes: + return None + return {"includes": sorted(includes)} + + @staticmethod def _has_style_intent(parsed_query: Optional[ParsedQuery]) -> bool: profile = getattr(parsed_query, "style_intent_profile", None) return bool(getattr(profile, "is_active", False)) @@ -327,20 +351,30 @@ class Searcher: index_langs = tenant_cfg.get("index_languages") or [] enable_translation = len(index_langs) > 0 enable_embedding = self.config.query_config.enable_text_embedding + coarse_cfg = self.config.coarse_rank + fine_cfg = self.config.fine_rank rc = self.config.rerank effective_query_template = rerank_query_template or rc.rerank_query_template effective_doc_template = rerank_doc_template or rc.rerank_doc_template + fine_query_template = fine_cfg.rerank_query_template or effective_query_template + fine_doc_template = fine_cfg.rerank_doc_template or effective_doc_template # 重排开关优先级:请求参数显式传值 > 服务端配置(默认开启) rerank_enabled_by_config = bool(rc.enabled) do_rerank = rerank_enabled_by_config if enable_rerank is None else bool(enable_rerank) rerank_window = rc.rerank_window + coarse_input_window = max(rerank_window, int(coarse_cfg.input_window)) + coarse_output_window = max(rerank_window, int(coarse_cfg.output_window)) + fine_input_window = max(rerank_window, int(fine_cfg.input_window)) + fine_output_window = max(rerank_window, int(fine_cfg.output_window)) # 若开启重排且请求范围在窗口内:从 ES 取前 rerank_window 条、重排后再按 from/size 分页;否则不重排,按原 from/size 查 ES in_rerank_window = do_rerank and (from_ + size) <= rerank_window es_fetch_from = 0 if in_rerank_window else from_ - es_fetch_size = rerank_window if in_rerank_window else size + es_fetch_size = coarse_input_window if in_rerank_window else size es_score_normalization_factor: Optional[float] = None initial_ranks_by_doc: Dict[str, int] = {} + coarse_debug_info: Optional[Dict[str, Any]] = None + fine_debug_info: Optional[Dict[str, Any]] = None rerank_debug_info: Optional[Dict[str, Any]] = None # Start timing @@ -367,12 +401,19 @@ class Searcher: 'enable_rerank_request': enable_rerank, 'rerank_query_template': effective_query_template, 'rerank_doc_template': effective_doc_template, + 'fine_query_template': fine_query_template, + 'fine_doc_template': fine_doc_template, 'filters': filters, 'range_filters': range_filters, 'facets': facets, 'enable_translation': enable_translation, 'enable_embedding': enable_embedding, 'enable_rerank': do_rerank, + 'coarse_input_window': coarse_input_window, + 'coarse_output_window': coarse_output_window, + 'fine_input_window': fine_input_window, + 'fine_output_window': fine_output_window, + 'rerank_window': rerank_window, 'min_score': min_score, 'sort_by': sort_by, 'sort_order': sort_order @@ -470,16 +511,12 @@ class Searcher: # Keep requested response _source semantics for the final response fill. response_source_spec = es_query.get("_source") - # In rerank window, first pass only fetches minimal fields required by rerank template. + # In multi-stage rank window, first pass only needs score signals for coarse rank. es_query_for_fetch = es_query rerank_prefetch_source = None if in_rerank_window: - rerank_prefetch_source = self._resolve_rerank_source_filter( - effective_doc_template, - parsed_query=parsed_query, - ) es_query_for_fetch = dict(es_query) - es_query_for_fetch["_source"] = rerank_prefetch_source + es_query_for_fetch["_source"] = False # Extract size and from from body for ES client parameters body_for_es = {k: v for k, v in es_query_for_fetch.items() if k not in ['size', 'from']} @@ -587,26 +624,131 @@ class Searcher: context.end_stage(RequestContextStage.ELASTICSEARCH_SEARCH_PRIMARY) style_intent_decisions: Dict[str, SkuSelectionDecision] = {} - if self._has_style_intent(parsed_query) and in_rerank_window: - style_intent_decisions = self._apply_style_intent_to_hits( - es_response.get("hits", {}).get("hits") or [], - parsed_query, - context=context, - ) - if style_intent_decisions: + if do_rerank and in_rerank_window: + from dataclasses import asdict + from config.services_config import get_rerank_service_url + from .rerank_client import coarse_resort_hits, run_lightweight_rerank, run_rerank + + rerank_query = parsed_query.text_for_rerank() if parsed_query else query + hits = es_response.get("hits", {}).get("hits") or [] + + context.start_stage(RequestContextStage.COARSE_RANKING) + try: + coarse_debug = coarse_resort_hits( + hits, + fusion=coarse_cfg.fusion, + debug=debug, + ) + hits = hits[:coarse_output_window] + es_response.setdefault("hits", {})["hits"] = hits + if debug: + coarse_debug_info = { + "docs_in": es_fetch_size, + "docs_out": len(hits), + "fusion": asdict(coarse_cfg.fusion), + } + context.store_intermediate_result("coarse_rank_scores", coarse_debug) context.logger.info( - "款式意图 SKU 预筛选完成 | hits=%s", - len(style_intent_decisions), + "粗排完成 | docs_in=%s | docs_out=%s", + es_fetch_size, + len(hits), extra={'reqid': context.reqid, 'uid': context.uid} ) + finally: + context.end_stage(RequestContextStage.COARSE_RANKING) + + ranking_source_spec = self._merge_source_specs( + self._resolve_rerank_source_filter( + fine_doc_template, + parsed_query=parsed_query, + ), + self._resolve_rerank_source_filter( + effective_doc_template, + parsed_query=parsed_query, + ), + ) + candidate_ids = [str(h.get("_id")) for h in hits if h.get("_id") is not None] + if candidate_ids: + details_by_id, fill_took = self._fetch_hits_by_ids( + index_name=index_name, + doc_ids=candidate_ids, + source_spec=ranking_source_spec, + ) + for hit in hits: + hid = hit.get("_id") + if hid is None: + continue + detail_hit = details_by_id.get(str(hid)) + if detail_hit is not None and "_source" in detail_hit: + hit["_source"] = detail_hit.get("_source") or {} + if fill_took: + es_response["took"] = int((es_response.get("took", 0) or 0) + fill_took) + + if self._has_style_intent(parsed_query): + style_intent_decisions = self._apply_style_intent_to_hits( + es_response.get("hits", {}).get("hits") or [], + parsed_query, + context=context, + ) + if style_intent_decisions: + context.logger.info( + "款式意图 SKU 预筛选完成 | hits=%s", + len(style_intent_decisions), + extra={'reqid': context.reqid, 'uid': context.uid} + ) + + fine_scores: Optional[List[float]] = None + hits = es_response.get("hits", {}).get("hits") or [] + if fine_cfg.enabled and hits: + context.start_stage(RequestContextStage.FINE_RANKING) + try: + fine_scores, fine_meta, fine_debug_rows = run_lightweight_rerank( + query=rerank_query, + es_hits=hits[:fine_input_window], + language=language, + timeout_sec=fine_cfg.timeout_sec, + rerank_query_template=fine_query_template, + rerank_doc_template=fine_doc_template, + top_n=fine_output_window, + debug=debug, + service_profile=fine_cfg.service_profile, + ) + if fine_scores is not None: + hits = hits[:fine_output_window] + es_response["hits"]["hits"] = hits + if debug: + fine_debug_info = { + "service_url": get_rerank_service_url(profile=fine_cfg.service_profile), + "query_template": fine_query_template, + "doc_template": fine_doc_template, + "query_text": str(fine_query_template).format_map({"query": rerank_query}), + "docs": len(hits), + "top_n": fine_output_window, + "meta": fine_meta, + } + context.store_intermediate_result("fine_rank_scores", fine_debug_rows) + context.logger.info( + "精排完成 | docs=%s | top_n=%s | meta=%s", + len(hits), + fine_output_window, + fine_meta, + extra={'reqid': context.reqid, 'uid': context.uid} + ) + except Exception as e: + context.add_warning(f"Fine rerank failed: {e}") + context.logger.warning( + f"调用精排服务失败 | error: {e}", + extra={'reqid': context.reqid, 'uid': context.uid}, + exc_info=True, + ) + finally: + context.end_stage(RequestContextStage.FINE_RANKING) - # Optional Step 4.5: AI reranking(仅当请求范围在重排窗口内时执行) - if do_rerank and in_rerank_window: context.start_stage(RequestContextStage.RERANKING) try: - from .rerank_client import run_rerank - - rerank_query = parsed_query.text_for_rerank() if parsed_query else query + final_hits = es_response.get("hits", {}).get("hits") or [] + final_input = final_hits[:rerank_window] + es_response["hits"]["hits"] = final_input es_response, rerank_meta, fused_debug = run_rerank( query=rerank_query, es_response=es_response, @@ -619,15 +761,15 @@ class Searcher: top_n=(from_ + size), debug=debug, fusion=rc.fusion, + fine_scores=fine_scores[:len(final_input)] if fine_scores is not None else None, + service_profile=rc.service_profile, style_intent_selected_sku_boost=self.config.query_config.style_intent_selected_sku_boost, ) if rerank_meta is not None: if debug: - from dataclasses import asdict - from config.services_config import get_rerank_service_url rerank_debug_info = { - "service_url": get_rerank_service_url(), + "service_url": get_rerank_service_url(profile=rc.service_profile), "query_template": effective_query_template, "doc_template": effective_doc_template, "query_text": str(effective_query_template).format_map({"query": rerank_query}), @@ -652,15 +794,17 @@ class Searcher: finally: context.end_stage(RequestContextStage.RERANKING) - # 当本次请求在重排窗口内时:已从 ES 取了 rerank_window 条并可能已重排,需按请求的 from/size 做分页切片 + # 当本次请求在重排窗口内时:已按多阶段排序产出前 rerank_window 条,需按请求的 from/size 做分页切片 if in_rerank_window: hits = es_response.get("hits", {}).get("hits") or [] sliced = hits[from_ : from_ + size] es_response.setdefault("hits", {})["hits"] = sliced if sliced: - # 对于启用重排的结果,优先使用 _fused_score 计算 max_score;否则退回原始 _score slice_max = max( - (h.get("_fused_score", h.get("_score", 0.0)) for h in sliced), + ( + h.get("_fused_score", h.get("_fine_score", h.get("_coarse_score", h.get("_score", 0.0)))) + for h in sliced + ), default=0.0, ) try: @@ -670,7 +814,6 @@ class Searcher: else: es_response["hits"]["max_score"] = 0.0 - # Page fill: fetch detailed fields only for final page hits. if sliced: if response_source_spec is False: for hit in sliced: @@ -754,6 +897,16 @@ class Searcher: if doc_id is None: continue rerank_debug_by_doc[str(doc_id)] = item + fine_debug_raw = context.get_intermediate_result('fine_rank_scores', None) + fine_debug_by_doc: Dict[str, Dict[str, Any]] = {} + if isinstance(fine_debug_raw, list): + for item in fine_debug_raw: + if not isinstance(item, dict): + continue + doc_id = item.get("doc_id") + if doc_id is None: + continue + fine_debug_by_doc[str(doc_id)] = item if self._has_style_intent(parsed_query): if style_intent_decisions: @@ -784,6 +937,9 @@ class Searcher: rerank_debug = None if doc_id is not None: rerank_debug = rerank_debug_by_doc.get(str(doc_id)) + fine_debug = None + if doc_id is not None: + fine_debug = fine_debug_by_doc.get(str(doc_id)) style_intent_debug = None if doc_id is not None and style_intent_decisions: decision = style_intent_decisions.get(str(doc_id)) @@ -823,6 +979,7 @@ class Searcher: debug_entry["doc_id"] = rerank_debug.get("doc_id") # 与 rerank_client 中字段保持一致,便于前端直接使用 debug_entry["rerank_score"] = rerank_debug.get("rerank_score") + debug_entry["fine_score"] = rerank_debug.get("fine_score") debug_entry["text_score"] = rerank_debug.get("text_score") debug_entry["text_source_score"] = rerank_debug.get("text_source_score") debug_entry["text_translation_score"] = rerank_debug.get("text_translation_score") @@ -833,11 +990,16 @@ class Searcher: debug_entry["text_score_fallback_to_es"] = rerank_debug.get("text_score_fallback_to_es") debug_entry["knn_score"] = rerank_debug.get("knn_score") debug_entry["rerank_factor"] = rerank_debug.get("rerank_factor") + debug_entry["fine_factor"] = rerank_debug.get("fine_factor") debug_entry["text_factor"] = rerank_debug.get("text_factor") debug_entry["knn_factor"] = rerank_debug.get("knn_factor") debug_entry["fused_score"] = rerank_debug.get("fused_score") debug_entry["rerank_input"] = rerank_debug.get("rerank_input") debug_entry["matched_queries"] = rerank_debug.get("matched_queries") + elif fine_debug: + debug_entry["doc_id"] = fine_debug.get("doc_id") + debug_entry["fine_score"] = fine_debug.get("fine_score") + debug_entry["rerank_input"] = fine_debug.get("rerank_input") if style_intent_debug: debug_entry["style_intent_sku"] = style_intent_debug @@ -908,6 +1070,8 @@ class Searcher: "shards": es_response.get('_shards', {}), "es_score_normalization_factor": es_score_normalization_factor, }, + "coarse_rank": coarse_debug_info, + "fine_rank": fine_debug_info, "rerank": rerank_debug_info, "feature_flags": context.metadata.get('feature_flags', {}), "stage_timings": { diff --git a/tests/queries.txt b/tests/queries.txt new file mode 100644 index 0000000..a91aad6 --- /dev/null +++ b/tests/queries.txt @@ -0,0 +1,43 @@ +白色oversized T-shirt +falda negra oficina +red fitted tee +黒いミディ丈スカート +黑色中长半身裙 +فستان أسود متوسط الطول +чёрное летнее платье +修身牛仔裤 +date night dress +vacation outfit dress +minimalist top +streetwear t-shirt +office casual blouse +街头风T恤 +宽松T恤 +复古印花T恤 +Y2K上衣 +情侣T恤 +美式复古T恤 +重磅棉T恤 +修身打底衫 +辣妹风短袖 +纯欲上衣 +正肩白T恤 +波西米亚花朵衬衫 +泡泡袖短袖 +扎染字母T恤 +T-shirt Dress +Crop Top +Lace Undershirt +Leopard Print Ripped T-shirt +Breton Stripe T-shirt +V-Neck Cotton T-shirt +Sweet & Cool Bow T-shirt +Vacation Style T-shirt +Commuter Casual Top +Minimalist Solid T-shirt +Band T-shirt +Athletic Gym T-shirt +Plus Size Loose T-shirt +Korean Style Slim T-shirt +Basic Layering Top + diff --git a/tests/test_search_rerank_window.py b/tests/test_search_rerank_window.py index f5ec64d..a44dc13 100644 --- a/tests/test_search_rerank_window.py +++ b/tests/test_search_rerank_window.py @@ -311,11 +311,18 @@ def test_searcher_reranks_top_window_by_default(monkeypatch): called: Dict[str, Any] = {"count": 0, "docs": 0} + def _fake_run_lightweight_rerank(**kwargs): + hits = kwargs["es_hits"] + for idx, hit in enumerate(hits): + hit["_fine_score"] = float(len(hits) - idx) + return [hit["_fine_score"] for hit in hits], {"stage": "fine"}, [] + def _fake_run_rerank(**kwargs): called["count"] += 1 called["docs"] = len(kwargs["es_response"]["hits"]["hits"]) return kwargs["es_response"], None, [] + monkeypatch.setattr("search.rerank_client.run_lightweight_rerank", _fake_run_lightweight_rerank) monkeypatch.setattr("search.rerank_client.run_rerank", _fake_run_rerank) result = searcher.search( @@ -328,17 +335,20 @@ def test_searcher_reranks_top_window_by_default(monkeypatch): ) assert called["count"] == 1 - # 应当对配置的 rerank_window 条文档做重排预取 - window = searcher.config.rerank.rerank_window - assert called["docs"] == window + assert called["docs"] == searcher.config.rerank.rerank_window assert es_client.calls[0]["from_"] == 0 - assert es_client.calls[0]["size"] == window + assert es_client.calls[0]["size"] == searcher.config.coarse_rank.input_window assert es_client.calls[0]["include_named_queries_score"] is True - assert es_client.calls[0]["body"]["_source"] == {"includes": ["title"]} - assert len(es_client.calls) == 2 - assert es_client.calls[1]["size"] == 10 + assert es_client.calls[0]["body"]["_source"] is False + assert len(es_client.calls) == 3 + assert es_client.calls[1]["size"] == max( + searcher.config.coarse_rank.output_window, + searcher.config.rerank.rerank_window, + ) assert es_client.calls[1]["from_"] == 0 - assert es_client.calls[1]["body"]["query"]["ids"]["values"] == [str(i) for i in range(20, 30)] + assert es_client.calls[2]["size"] == 10 + assert es_client.calls[2]["from_"] == 0 + assert es_client.calls[2]["body"]["query"]["ids"]["values"] == [str(i) for i in range(20, 30)] assert len(result.results) == 10 assert result.results[0].spu_id == "20" assert result.results[0].brief == "brief-20" @@ -353,6 +363,10 @@ def test_searcher_rerank_prefetch_source_follows_doc_template(monkeypatch): "search.searcher.get_tenant_config_loader", lambda: SimpleNamespace(get_tenant_config=lambda tenant_id: {"index_languages": ["en"]}), ) + monkeypatch.setattr( + "search.rerank_client.run_lightweight_rerank", + lambda **kwargs: ([1.0] * len(kwargs["es_hits"]), {"stage": "fine"}, []), + ) monkeypatch.setattr("search.rerank_client.run_rerank", lambda **kwargs: (kwargs["es_response"], None, [])) searcher.search( @@ -365,7 +379,8 @@ def test_searcher_rerank_prefetch_source_follows_doc_template(monkeypatch): rerank_doc_template="{title} {vendor} {brief}", ) - assert es_client.calls[0]["body"]["_source"] == {"includes": ["brief", "title", "vendor"]} + assert es_client.calls[0]["body"]["_source"] is False + assert es_client.calls[1]["body"]["_source"] == {"includes": ["brief", "title", "vendor"]} def test_searcher_rerank_prefetch_source_includes_sku_fields_when_style_intent_active(monkeypatch): @@ -378,6 +393,10 @@ def test_searcher_rerank_prefetch_source_includes_sku_fields_when_style_intent_a lambda: SimpleNamespace(get_tenant_config=lambda tenant_id: {"index_languages": ["en"]}), ) monkeypatch.setattr( + "search.rerank_client.run_lightweight_rerank", + lambda **kwargs: ([1.0] * len(kwargs["es_hits"]), {"stage": "fine"}, []), + ) + monkeypatch.setattr( "search.rerank_client.run_rerank", lambda **kwargs: (kwargs["es_response"], None, []), ) @@ -414,7 +433,8 @@ def test_searcher_rerank_prefetch_source_includes_sku_fields_when_style_intent_a enable_rerank=None, ) - assert es_client.calls[0]["body"]["_source"] == { + assert es_client.calls[0]["body"]["_source"] is False + assert es_client.calls[1]["body"]["_source"] == { "includes": ["option1_name", "option2_name", "option3_name", "skus", "title"] } -- libgit2 0.21.2