From daa2690ba5559d5c749855fe500db9ae2dc3a4f4 Mon Sep 17 00:00:00 2001 From: tangwang Date: Fri, 27 Mar 2026 22:27:43 +0800 Subject: [PATCH] 漏斗参数调优&呈现优化 --- config/config.yaml | 28 ++++++++++++++++++++-------- config/env_config.py | 6 ++---- config/loader.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ config/schema.py | 27 ++++++++++++++++++++++++--- config/services_config.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++------------- docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt | 38 ++++++++++++++++++++++++++++++++++++++ docs/issue-2026-03-27-添加粗排精排-第一轮-done-0327.txt | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/static/js/app.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------ reranker/README.md | 2 +- reranker/config.py | 18 ++++++++++++++---- reranker/server.py | 10 ++++++---- scripts/lib/reranker_backend_env.sh | 2 +- scripts/service_ctl.sh | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- scripts/start_reranker.sh | 34 ++++++++++++++++++++++++++++++---- search/searcher.py | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- tests/test_search_rerank_window.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 765 insertions(+), 101 deletions(-) create mode 100644 docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt create mode 100644 docs/issue-2026-03-27-添加粗排精排-第一轮-done-0327.txt diff --git a/config/config.yaml b/config/config.yaml index dfad00e..d1e0a1a 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -421,19 +421,31 @@ services: normalize_embeddings: true rerank: provider: "http" - base_url: "http://127.0.0.1:6007" providers: 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" + instances: + default: + base_url: "http://127.0.0.1:6007" + service_url: "http://127.0.0.1:6007/rerank" + fine: + base_url: "http://127.0.0.1:6009" + service_url: "http://127.0.0.1:6009/rerank" request: max_docs: 1000 normalize: true - # 服务内后端(reranker 进程启动时读取) - backend: "qwen3_vllm_score" # bge | jina_reranker_v3 | qwen3_vllm | qwen3_vllm_score | qwen3_transformers | qwen3_transformers_packed | qwen3_gguf | qwen3_gguf_06b | dashscope_rerank + default_instance: "default" + # 命名实例:同一套 reranker 代码按实例名读取不同端口 / 后端 / runtime 目录。 + instances: + default: + host: "0.0.0.0" + port: 6007 + backend: "qwen3_vllm_score" + runtime_dir: "./.runtime/reranker/default" + fine: + host: "0.0.0.0" + port: 6009 + backend: "bge" + runtime_dir: "./.runtime/reranker/fine" backends: bge: model_name: "BAAI/bge-reranker-v2-m3" diff --git a/config/env_config.py b/config/env_config.py index 028c702..6264e4b 100644 --- a/config/env_config.py +++ b/config/env_config.py @@ -10,6 +10,7 @@ from __future__ import annotations from typing import Any, Dict from config.loader import get_app_config +from config.services_config import get_rerank_service_url def _app(): @@ -92,10 +93,7 @@ INDEXER_BASE_URL = ( ) EMBEDDING_TEXT_SERVICE_URL = _app().services.embedding.get_provider_config().get("text_base_url") EMBEDDING_IMAGE_SERVICE_URL = _app().services.embedding.get_provider_config().get("image_base_url") -RERANKER_SERVICE_URL = ( - _app().services.rerank.get_provider_config().get("service_url") - or _app().services.rerank.get_provider_config().get("base_url") -) +RERANKER_SERVICE_URL = get_rerank_service_url() def get_es_config() -> Dict[str, Any]: diff --git a/config/loader.py b/config/loader.py index 3f10c66..316cf0a 100644 --- a/config/loader.py +++ b/config/loader.py @@ -43,6 +43,7 @@ from config.schema import ( RerankConfig, RerankFusionConfig, RerankServiceConfig, + RerankServiceInstanceConfig, RuntimeConfig, SearchConfig, SecretsConfig, @@ -615,13 +616,61 @@ class AppConfigLoader: rerank_providers = dict(rerank_raw.get("providers") or {}) if rerank_provider not in rerank_providers: raise ConfigurationError(f"services.rerank.providers.{rerank_provider} must be configured") - rerank_backend = str(rerank_raw.get("backend") or "").strip().lower() rerank_backends = { str(key).strip().lower(): dict(value) for key, value in dict(rerank_raw.get("backends") or {}).items() } - if rerank_backend not in rerank_backends: - raise ConfigurationError(f"services.rerank.backends.{rerank_backend} must be configured") + default_instance = str(rerank_raw.get("default_instance") or "default").strip() or "default" + raw_instances = rerank_raw.get("instances") if isinstance(rerank_raw.get("instances"), dict) else {} + if not raw_instances: + legacy_backend = str(rerank_raw.get("backend") or "").strip().lower() + if legacy_backend not in rerank_backends: + raise ConfigurationError(f"services.rerank.backends.{legacy_backend} must be configured") + provider_cfg = dict(rerank_providers.get(rerank_provider) or {}) + raw_instances = { + default_instance: { + "host": "0.0.0.0", + "port": 6007, + "backend": legacy_backend, + "base_url": provider_cfg.get("base_url"), + "service_url": provider_cfg.get("service_url"), + } + } + rerank_instances = {} + for instance_name, instance_raw in raw_instances.items(): + if not isinstance(instance_raw, dict): + raise ConfigurationError(f"services.rerank.instances.{instance_name} must be a mapping") + normalized_instance_name = str(instance_name).strip() + backend_name = str(instance_raw.get("backend") or "").strip().lower() + if backend_name not in rerank_backends: + raise ConfigurationError( + f"services.rerank.instances.{normalized_instance_name}.backend must reference configured services.rerank.backends" + ) + port = int(instance_raw.get("port", 6007)) + rerank_instances[normalized_instance_name] = RerankServiceInstanceConfig( + host=str(instance_raw.get("host") or "0.0.0.0"), + port=port, + backend=backend_name, + runtime_dir=( + str(v) + if (v := instance_raw.get("runtime_dir")) not in (None, "") + else None + ), + base_url=( + str(v).rstrip("/") + if (v := instance_raw.get("base_url")) not in (None, "") + else None + ), + service_url=( + str(v).rstrip("/") + if (v := instance_raw.get("service_url")) not in (None, "") + else None + ), + ) + if default_instance not in rerank_instances: + raise ConfigurationError( + f"services.rerank.default_instance={default_instance!r} must exist in services.rerank.instances" + ) rerank_request = dict(rerank_raw.get("request") or {}) rerank_request.setdefault("max_docs", 1000) rerank_request.setdefault("normalize", True) @@ -629,7 +678,8 @@ class AppConfigLoader: rerank_config = RerankServiceConfig( provider=rerank_provider, providers=rerank_providers, - backend=rerank_backend, + default_instance=default_instance, + instances=rerank_instances, backends=rerank_backends, request=rerank_request, ) @@ -754,8 +804,19 @@ class AppConfigLoader: errors.append("services.embedding.providers..image_base_url is required") rerank_provider_cfg = app_config.services.rerank.get_provider_config() - if not rerank_provider_cfg.get("service_url") and not rerank_provider_cfg.get("base_url"): - errors.append("services.rerank.providers..service_url or base_url is required") + provider_instances = rerank_provider_cfg.get("instances") + if not isinstance(provider_instances, dict): + provider_instances = {} + for instance_name in app_config.services.rerank.instances: + instance_cfg = app_config.services.rerank.get_instance(instance_name) + provider_instance_cfg = provider_instances.get(instance_name) if isinstance(provider_instances, dict) else None + has_instance_url = False + if isinstance(provider_instance_cfg, dict): + has_instance_url = bool(provider_instance_cfg.get("service_url") or provider_instance_cfg.get("base_url")) + if not has_instance_url and not instance_cfg.service_url and not instance_cfg.base_url: + errors.append( + f"services.rerank instance {instance_name!r} must define service_url/base_url either under providers..instances or services.rerank.instances" + ) if errors: raise ConfigurationError("Configuration validation failed:\n" + "\n".join(f" - {err}" for err in errors)) diff --git a/config/schema.py b/config/schema.py index 2965b9c..6e5f61b 100644 --- a/config/schema.py +++ b/config/schema.py @@ -236,20 +236,41 @@ class EmbeddingServiceConfig: @dataclass(frozen=True) +class RerankServiceInstanceConfig: + """One named reranker service instance.""" + + host: str = "0.0.0.0" + port: int = 6007 + backend: str = "qwen3_vllm_score" + runtime_dir: Optional[str] = None + base_url: Optional[str] = None + service_url: Optional[str] = None + + +@dataclass(frozen=True) class RerankServiceConfig: """Reranker service configuration.""" provider: str providers: Dict[str, Any] - backend: str + default_instance: str + instances: Dict[str, RerankServiceInstanceConfig] backends: Dict[str, Dict[str, Any]] request: Dict[str, Any] def get_provider_config(self) -> Dict[str, Any]: return dict(self.providers.get(self.provider, {}) or {}) - def get_backend_config(self) -> Dict[str, Any]: - return dict(self.backends.get(self.backend, {}) or {}) + def get_instance(self, name: Optional[str] = None) -> RerankServiceInstanceConfig: + instance_name = str(name or self.default_instance).strip() or self.default_instance + instance = self.instances.get(instance_name) + if instance is None: + raise KeyError(f"Unknown rerank service instance: {instance_name!r}") + return instance + + def get_backend_config(self, name: Optional[str] = None) -> Dict[str, Any]: + instance = self.get_instance(name) + return dict(self.backends.get(instance.backend, {}) or {}) @dataclass(frozen=True) diff --git a/config/services_config.py b/config/services_config.py index aa0bfd3..d29cb33 100644 --- a/config/services_config.py +++ b/config/services_config.py @@ -11,7 +11,12 @@ import os from typing import Any, Dict, Tuple from config.loader import get_app_config -from config.schema import EmbeddingServiceConfig, RerankServiceConfig, TranslationServiceConfig +from config.schema import ( + EmbeddingServiceConfig, + RerankServiceConfig, + RerankServiceInstanceConfig, + TranslationServiceConfig, +) def get_translation_config() -> Dict[str, Any]: @@ -26,6 +31,16 @@ def get_rerank_config() -> RerankServiceConfig: return get_app_config().services.rerank +def get_rerank_instance_config(profile: str | None = None) -> RerankServiceInstanceConfig: + cfg = get_app_config().services.rerank + instance_name = str( + profile + or os.getenv("RERANK_INSTANCE") + or cfg.default_instance + ).strip() or cfg.default_instance + return cfg.get_instance(instance_name) + + def get_translation_base_url() -> str: return get_app_config().services.translation.endpoint @@ -60,31 +75,56 @@ def get_embedding_image_backend_config() -> Tuple[str, Dict[str, Any]]: return cfg.image_backend, cfg.get_image_backend_config() -def get_rerank_backend_config() -> Tuple[str, Dict[str, Any]]: +def get_rerank_backend_config(profile: str | None = None) -> Tuple[str, Dict[str, Any]]: cfg = get_app_config().services.rerank - backend = str(os.getenv("RERANK_BACKEND") or cfg.backend).strip() - if backend != cfg.backend: + instance = get_rerank_instance_config(profile) + backend = str(os.getenv("RERANK_BACKEND") or instance.backend).strip() + if backend != instance.backend: backend_cfg = cfg.backends.get(backend) if backend_cfg is None: raise ValueError(f"Unknown rerank backend override from RERANK_BACKEND: {backend!r}") return backend, dict(backend_cfg) - return cfg.backend, cfg.get_backend_config() + return instance.backend, cfg.get_backend_config(profile) def get_rerank_base_url(profile: str | None = None) -> str: - provider_cfg = get_app_config().services.rerank.get_provider_config() + cfg = get_app_config().services.rerank + instance = get_rerank_instance_config(profile) + provider_cfg = cfg.get_provider_config() + profile_name = str(profile or os.getenv("RERANK_INSTANCE") or cfg.default_instance).strip() or cfg.default_instance + 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) + provider_instances = provider_cfg.get("instances") + if isinstance(provider_instances, dict): + instance_provider_cfg = provider_instances.get(profile_name) + if isinstance(instance_provider_cfg, dict): + base = instance_provider_cfg.get("base_url") or instance_provider_cfg.get("service_url") + if not base: + base = instance.base_url or instance.service_url 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("/") + base = str(base).rstrip("/") + if base.endswith("/rerank"): + base = base[: -len("/rerank")] + return base def get_rerank_service_url(profile: str | None = None) -> str: - return get_rerank_base_url(profile=profile) + cfg = get_app_config().services.rerank + instance = get_rerank_instance_config(profile) + provider_cfg = cfg.get_provider_config() + profile_name = str(profile or os.getenv("RERANK_INSTANCE") or cfg.default_instance).strip() or cfg.default_instance + + service_url = None + provider_instances = provider_cfg.get("instances") + if isinstance(provider_instances, dict): + instance_provider_cfg = provider_instances.get(profile_name) + if isinstance(instance_provider_cfg, dict): + service_url = instance_provider_cfg.get("service_url") + if not service_url: + service_url = instance.service_url + if not service_url: + service_url = f"{get_rerank_base_url(profile=profile)}/rerank" + return str(service_url).rstrip("/") diff --git a/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt b/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt new file mode 100644 index 0000000..b681ac4 --- /dev/null +++ b/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt @@ -0,0 +1,38 @@ +这是上一轮检索效果优化的需求: +参考 +searcher.py +rerank_client.py +schema.py +es_query_builder.py +config.yaml +相关性检索优化说明.md + +在ES返回到rerank期间增加一轮粗排+一轮精排。 +1. ES召回,600 +2. 粗排:600->240。配置文件增加粗排相关配置,包括输入条数(配置为700,ES拉取的条数改为粗排输入条数),然后增加粗排的融合公式配置,参考现有的reranker融合公式即可、只是去掉其中的重排模型项。 +3. 现在的sku选择、为reranker生成title后缀这一套逻辑,是放在粗排后,因为精排也是一个reranker模型(只不过是一个轻量级的,bge-reranker),需要用这个title后缀。 +4. 精排:240-80,使用bge-reranker,但是,因为reranker只能选一个backend,考虑如何重构。现在,精排也是一个独立的进程、独立提供端口,服务。但是,因为跟重排逻辑是一致的(即使有部分不一致也应该分离不一致的点进行配置化),所以共用代码,只是根据需要启动两个实例,避免代码冗余。 +5. 重排:80,也是用当前的重排代码,调用单独的实例(即现在使用的实例),返回后,经过融合公式,到分页,也参考现在的融合公式,但是,加入一项精排模型打分。 +测试时,使用跟我同样的环境./scripts/service_ctl.sh reranker backend 重启相关服务进行测试 + +你已经完成了一般修改,已把三段排序链路接上了,主改动在 search/searcher.py、search/rerank_client.py、config/schema.py、config/loader.py、config/services_config.py 和 config/config.yaml。 + +现在链路是: +ES 拉取 coarse_rank.input_window 条 -> 粗排按 text/knn 融合裁到 coarse_rank.output_window -> 再做 SKU 选择和 title suffix -> 精排调用轻量 reranker 裁到 fine_rank.output_window -> 最终重排调用现有 reranker,并在最终融合里加入 fine_score。同时把 reranker client/provider 改成了按 service_profile 选不同 service_url,这样 fine/final 可以共用同一套服务代码,只起不同实例。 + +你是用了./.venv/bin/pytest -q tests/test_rerank_client.py tests/test_search_rerank_window.py 进行验证,结果是 18 passed。也按要求尝试了 ./scripts/service_ctl.sh restart reranker backend 做联调,但当前环境里只有 6007 的现有 reranker,配置里的 fine 端口 6009 还没对应实例,而且服务重启后进程状态不太稳定,没能完成完整线上链路验证。当前最关键的落地前置是把轻量 bge 实例补起来,再用同样脚本重启联调。 + +因此,需要回顾这个漏斗的代码,进行进一步的优化: +1. Fine Rank环节的信息都是N/A。fine 实例也需要在config.yaml中体现,并且需要被server_ctl.sh管理,参考现在是怎么管理reranker的。fine和reranker分别调用哪个模型(两者代码功能,但是只是启动不同的实例),请你深度思考后决定。这里是需要参考当前代码、根据实际需要(原来只有一个reranker、现在多了一个精排,也要调用一个重排模型,因此带来的必要的部分重构。 +2. Ranking Funnel、Fusion Factors、Signal Breakdown +这些是不是整合起来、按漏斗收集、整理信息、以及进行呈现比较好。 +ES 召回的环节,展示Matched Queries各项打分、ES的总分、norm后打分、排序位置,等等关键信息 +粗排:粗排融合公式的各项输入、重要中间结果和参数、最后得分,排序位置以及上升/下降了多少。等等关键信息。 +精排:同样例举关键的输入、中间过程、输出、排序和位置变化等。 +reranker:类似 + +因为涉及的环节较多,非常要注意的一个点是:不要每次修改都在原来的基础上,为实现目标而打补丁,应该观察一下所涉及的代码现在是怎么做的,务必注意如何适当的清理掉现有逻辑,该如何对其进行修改,来达到目的,以达到代码的精简,避免冗余、分叉。 +特别是1关于将现在的单独的reranker服务,在不增加代码复杂度的情况下,如何可以通过配置启动多个实例、各自可以配置选择的模型等相关参数、分别服务于精排和重排,是设计的重中之重。这里需要思考配置如何拆分,需要多读相关代码、思考最佳实践。 + + + diff --git a/docs/issue-2026-03-27-添加粗排精排-第一轮-done-0327.txt b/docs/issue-2026-03-27-添加粗排精排-第一轮-done-0327.txt new file mode 100644 index 0000000..030d63c --- /dev/null +++ b/docs/issue-2026-03-27-添加粗排精排-第一轮-done-0327.txt @@ -0,0 +1,136 @@ +我: +进行一轮检索效果优化: +参考 +[searcher.py](search/searcher.py) +[rerank_client.py](search/rerank_client.py) +[schema.py](config/schema.py) +[es_query_builder.py](search/es_query_builder.py) +[config.yaml](config/config.yaml) +[相关性检索优化说明.md](docs/相关性检索优化说明.md) + +在ES返回到rerank期间增加一轮粗排+一轮精排。 +1. ES召回,600 +2. 粗排:600->240。配置文件增加粗排相关配置,包括输入条数(配置为700,ES拉取的条数改为粗排输入条数),然后增加粗排的融合公式配置,参考现有的reranker融合公式即可、只是去掉其中的重排模型项。 +3. 现在的sku选择、为reranker生成title后缀这一套逻辑,是放在粗排后,因为精排也是一个reranker模型(只不过是一个轻量级的,bge-reranker),需要用这个title后缀。 +4. 精排:240-80,使用bge-reranker,但是,因为reranker只能选一个backend,考虑如何重构。现在,精排也是一个独立的进程、独立提供端口,服务。但是,因为跟重排逻辑是一致的(即使有部分不一致也应该分离不一致的点进行配置化),所以共用代码,只是根据需要启动两个实例,避免代码冗余。 +5. 重排:80,也是用当前的重排代码,调用单独的实例(即现在使用的实例),返回后,经过融合公式,到分页,也参考现在的融合公式,但是,加入一项精排模型打分。 +测试时,使用跟我同样的环境./scripts/service_ctl.sh reranker backend 重启相关服务进行测试 + + + + + +codex: +前端这轮我把调试展示重构成了“两层漏斗”: + +1. 整体漏斗 +在 [frontend/static/js/app.js](/data/saas-search/frontend/static/js/app.js) 里把原来散着的 `Query Analysis / Rerank / Stage Timings / ES Query` 重新组织成了: +- `Query Context` +- `Ranking Funnel` +- `Timing Breakdown` +- `Raw Payloads` + +其中 `Ranking Funnel` 现在按阶段卡片展示: +- `ES Recall` +- `Coarse Rank` +- `Fine Rank` +- `Final Rerank` +- `Page Return` + +每张卡都尽量只放调参最关键的信息:输入/输出条数、模板、service URL、top_n、fusion/meta。这样你看漏斗收缩、看每段服务表现、看融合参数会更直接。 + +2. 单条结果漏斗 +在结果卡片右侧的 debug 区,我改成了真正的单条漏斗卡片,按“分数结果”和“公式分解”分开看: +- 顶部先看 `Initial Rank / Final Rank / Rank Delta` +- 再看阶段分数:`ES / ES Norm / Coarse / Fine / Rerank / Fused` +- 再看融合因子:`coarse_text_factor / coarse_knn_factor / text_factor / knn_factor / fine_factor / rerank_factor` +- 再看信号拆解:`text_source / text_translation / text_primary / text_support / text_knn / image_knn` + +同时保留了可展开的: +- `Rerank Input` +- `Selected SKU` +- `Matched Queries` +- `Multilingual Fields` + +为了支撑前端,我顺手在 [search/searcher.py](/data/saas-search/search/searcher.py) 里补了单条 `coarse_score / coarse_text_factor / coarse_knn_factor` 到 `per_result`,这样单条卡片现在能把粗排也完整展示出来。 + +样式上在 [frontend/static/css/style.css](/data/saas-search/frontend/static/css/style.css) 加了阶段卡片、分数 pill、时延条形图和移动端适配。JS 语法我用 `node --check frontend/static/js/app.js` 过了,后端补充字段也用 `./.venv/bin/python -m py_compile search/searcher.py` 过了。 + +如果你愿意,我下一步可以继续把这个视图再往“调参工作台”方向推一层,比如再加: +- 漏斗阶段间的 drop rate +- 每条结果的 rank jump 高亮 +- 批量对比 top N 结果里某个因子分布 +- fusion 参数和结果联动的可视化摘要 + + + + + + + + +我: +1. Fine Rank环节的信息都是N/A,是没有配置吗。fine rank是使用bge-reranker,复用当前reranker模型的代码,但是需要单独起一个服务、单独加载一个模型。 +2. Ranking Funnel、Fusion Factors、Signal Breakdown +这些是不是整合起来、按漏斗收集、整理信息、以及进行呈现比较好。 +ES 召回的环节,展示Matched Queries各项打分、ES的总分、norm后打分、排序位置,等等关键信息 +粗排:粗排融合公式的各项输入、重要中间结果和参数、最后得分,排序位置以及上升/下降了多少。等等关键信息。 +精排:同样例举关键的输入、中间过程、输出、排序和位置变化等。 +reranker:类似 + +因为涉及的环节较多,非常要注意的一个点是:不要每次修改都在原来的基础上,为实现目标而打补丁,应该观察一下所涉及的代码现在是怎么做的,务必注意如何适当的清理掉现有逻辑,该如何对其进行修改,来达到目的,以达到代码的精简,避免冗余、分叉。 + + + + + + +codex: +`Fine Rank` 之前显示 `N/A`,根因不是前端单纯没写,而是后端原来只有“精排成功拿到分数”时才会填那块 debug;一旦 `fine` 服务没起、没配 `service_url`、调用失败,前端只能看到空值。现在我把这层补齐了: + +- 顶层 `debug_info.fine_rank` 现在会始终带 `status / service_profile / service_url / input_window / output_window / query_template / doc_template` +- 失败时会带 `error` +- 成功时再补 `docs / top_n / meta` +- 所以你现在能区分: + - `disabled` + - `pending` + - `ok` + - `failed_no_scores` + - `failed` + +也就是说,`fine rank` 本身仍然需要你按设计单独起一个 `bge-reranker` 实例,这一层代码已经按“独立服务 + 共用 reranker 代码”的方式接好了,但服务本身还是要单独启动。 + +这轮我也把调试展示做了结构化重构,不再继续在旧视图上打补丁。现在是按阶段组织信息: + +- 整体漏斗: + - `ES Recall` + - `Coarse Rank` + - `Fine Rank` + - `Final Rerank` + - `Page Return` +- 单条结果: + - 用统一的 `ranking_funnel` 结构来表达每个阶段的 + - `rank` + - `delta` + - `score` + - 关键中间量 + - 关键 factor + - 输入 payload + +具体改动在: +- [search/searcher.py](/data/saas-search/search/searcher.py) +- [frontend/static/js/app.js](/data/saas-search/frontend/static/js/app.js) +- [frontend/static/css/style.css](/data/saas-search/frontend/static/css/style.css) + +单条结果现在按 stage card 展示,而不是把 `Ranking Funnel / Fusion Factors / Signal Breakdown` 拆成三块平铺,这样你可以顺着 ES -> 粗排 -> 精排 -> rerank 看每段的输入、过程、输出和位次变化,更适合调融合公式。 + +检查我也跑过了: +- `node --check frontend/static/js/app.js` +- `./.venv/bin/python -m py_compile search/searcher.py` +- `./.venv/bin/pytest -q tests/test_search_rerank_window.py` + +都通过了。 + +下一步如果你要,我建议直接继续做两件事中的一个: +1. 把 `fine` 实例真正拉起来,然后我再把前端里 `status=failed/pending` 的场景跑成真实数据。 +2. 在当前漏斗视图上继续加“阶段 rank 分布 / delta 高亮 / topN 因子对比”,把它做成更像调参工作台。 \ No newline at end of file diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js index a18528f..55760ca 100644 --- a/frontend/static/js/app.js +++ b/frontend/static/js/app.js @@ -498,6 +498,12 @@ function renderJsonDetails(title, payload, open = false) { function buildProductDebugHtml({ debug, result, spuId, tenantId }) { const resultJson = customStringify(result); const rawUrl = `${API_BASE_URL}/search/es-doc/${encodeURIComponent(spuId)}?tenant_id=${encodeURIComponent(tenantId)}`; + const funnel = debug.ranking_funnel || {}; + const esStage = funnel.es_recall || {}; + const coarseStage = funnel.coarse_rank || {}; + const fineStage = funnel.fine_rank || {}; + const rerankStage = funnel.rerank || {}; + const finalPageStage = funnel.final_page || {}; const rankSummary = renderMetricList([ { label: 'Initial Rank', value: debug.initial_rank ?? 'N/A' }, @@ -507,33 +513,57 @@ function buildProductDebugHtml({ debug, result, spuId, tenantId }) { ]); const stageScores = renderScorePills([ - { label: 'ES', value: formatDebugNumber(debug.es_score), tone: 'tone-es' }, - { label: 'ES Norm', value: formatDebugNumber(debug.es_score_normalized), tone: 'tone-neutral' }, - { label: 'Coarse', value: formatDebugNumber(debug.coarse_score), tone: 'tone-coarse' }, - { label: 'Fine', value: formatDebugNumber(debug.fine_score), tone: 'tone-fine' }, - { label: 'Rerank', value: formatDebugNumber(debug.rerank_score), tone: 'tone-rerank' }, - { label: 'Fused', value: formatDebugNumber(debug.fused_score), tone: 'tone-final' }, + { label: 'ES', value: formatDebugNumber(esStage.score ?? debug.es_score), tone: 'tone-es' }, + { label: 'ES Norm', value: formatDebugNumber(esStage.normalized_score ?? debug.es_score_normalized), tone: 'tone-neutral' }, + { label: 'Coarse', value: formatDebugNumber(coarseStage.score ?? debug.coarse_score), tone: 'tone-coarse' }, + { label: 'Fine', value: formatDebugNumber(fineStage.score ?? debug.fine_score), tone: 'tone-fine' }, + { label: 'Rerank', value: formatDebugNumber(rerankStage.rerank_score ?? debug.rerank_score), tone: 'tone-rerank' }, + { label: 'Fused', value: formatDebugNumber(rerankStage.fused_score ?? debug.fused_score), tone: 'tone-final' }, ]); - const factorMetrics = renderMetricList([ - { label: 'coarse_text_factor', value: formatDebugNumber(debug.coarse_text_factor) }, - { label: 'coarse_knn_factor', value: formatDebugNumber(debug.coarse_knn_factor) }, - { label: 'text_factor', value: formatDebugNumber(debug.text_factor) }, - { label: 'knn_factor', value: formatDebugNumber(debug.knn_factor) }, - { label: 'fine_factor', value: formatDebugNumber(debug.fine_factor) }, - { label: 'rerank_factor', value: formatDebugNumber(debug.rerank_factor) }, - ]); - - const signalMetrics = renderMetricList([ - { label: 'text_score', value: formatDebugNumber(debug.text_score) }, - { label: 'text_source', value: formatDebugNumber(debug.text_source_score) }, - { label: 'text_translation', value: formatDebugNumber(debug.text_translation_score) }, - { label: 'text_primary', value: formatDebugNumber(debug.text_primary_score) }, - { label: 'text_support', value: formatDebugNumber(debug.text_support_score) }, - { label: 'knn_score', value: formatDebugNumber(debug.knn_score) }, - { label: 'text_knn', value: formatDebugNumber(debug.text_knn_score) }, - { label: 'image_knn', value: formatDebugNumber(debug.image_knn_score) }, - ]); + const stageGrid = ` +
+ ${buildStageCard('ES Recall', 'Matched queries and ES raw score', [ + { label: 'rank', value: esStage.rank ?? debug.initial_rank ?? 'N/A' }, + { label: 'es_score', value: formatDebugNumber(esStage.score ?? debug.es_score) }, + { label: 'es_norm', value: formatDebugNumber(esStage.normalized_score ?? debug.es_score_normalized) }, + ], renderJsonDetails('Matched Queries', esStage.matched_queries ?? debug.matched_queries, false))} + ${buildStageCard('Coarse Rank', 'Text + vector fusion', [ + { label: 'rank', value: coarseStage.rank ?? 'N/A' }, + { label: 'rank_change', value: coarseStage.rank_change ?? 'N/A' }, + { label: 'coarse_score', value: formatDebugNumber(coarseStage.score ?? debug.coarse_score) }, + { label: 'text_score', value: formatDebugNumber(coarseStage.text_score ?? debug.text_score) }, + { label: 'text_source', value: formatDebugNumber(coarseStage.signals?.text_source_score ?? debug.text_source_score) }, + { label: 'text_translation', value: formatDebugNumber(coarseStage.signals?.text_translation_score ?? debug.text_translation_score) }, + { label: 'text_primary', value: formatDebugNumber(coarseStage.signals?.text_primary_score ?? debug.text_primary_score) }, + { label: 'text_support', value: formatDebugNumber(coarseStage.signals?.text_support_score ?? debug.text_support_score) }, + { label: 'knn_score', value: formatDebugNumber(coarseStage.knn_score ?? debug.knn_score) }, + { label: 'text_knn', value: formatDebugNumber(coarseStage.signals?.text_knn_score ?? debug.text_knn_score) }, + { label: 'image_knn', value: formatDebugNumber(coarseStage.signals?.image_knn_score ?? debug.image_knn_score) }, + { label: 'text_factor', value: formatDebugNumber(coarseStage.text_factor ?? debug.coarse_text_factor) }, + { label: 'knn_factor', value: formatDebugNumber(coarseStage.knn_factor ?? debug.coarse_knn_factor) }, + ], renderJsonDetails('Coarse Signals', coarseStage.signals, false))} + ${buildStageCard('Fine Rank', 'Lightweight reranker output', [ + { label: 'rank', value: fineStage.rank ?? 'N/A' }, + { label: 'rank_change', value: fineStage.rank_change ?? 'N/A' }, + { label: 'fine_score', value: formatDebugNumber(fineStage.score ?? debug.fine_score) }, + ], renderJsonDetails('Fine Input', fineStage.rerank_input ?? debug.rerank_input, false))} + ${buildStageCard('Final Rerank', 'Heavy reranker + final fusion', [ + { label: 'rank', value: rerankStage.rank ?? finalPageStage.rank ?? debug.final_rank ?? 'N/A' }, + { label: 'rank_change', value: rerankStage.rank_change ?? finalPageStage.rank_change ?? 'N/A' }, + { label: 'rerank_score', value: formatDebugNumber(rerankStage.rerank_score ?? debug.rerank_score) }, + { label: 'text_score', value: formatDebugNumber(rerankStage.text_score ?? debug.text_score) }, + { label: 'knn_score', value: formatDebugNumber(rerankStage.knn_score ?? debug.knn_score) }, + { label: 'text_source', value: formatDebugNumber(rerankStage.signals?.text_source_score ?? debug.text_source_score) }, + { label: 'text_translation', value: formatDebugNumber(rerankStage.signals?.text_translation_score ?? debug.text_translation_score) }, + { label: 'fine_factor', value: formatDebugNumber(rerankStage.fine_factor ?? debug.fine_factor) }, + { label: 'rerank_factor', value: formatDebugNumber(rerankStage.rerank_factor ?? debug.rerank_factor) }, + { label: 'text_factor', value: formatDebugNumber(rerankStage.text_factor ?? debug.text_factor) }, + { label: 'knn_factor', value: formatDebugNumber(rerankStage.knn_factor ?? debug.knn_factor) }, + { label: 'fused_score', value: formatDebugNumber(rerankStage.fused_score ?? debug.fused_score) }, + ], renderJsonDetails('Rerank Signals', rerankStage.signals, false))} +
+ `; const titlePayload = {}; if (debug.title_multilingual) titlePayload.title = debug.title_multilingual; @@ -545,13 +575,8 @@ function buildProductDebugHtml({ debug, result, spuId, tenantId }) {
Ranking Funnel
${rankSummary} ${stageScores} -
Fusion Factors
- ${factorMetrics} -
Signal Breakdown
- ${signalMetrics} - ${renderJsonDetails('Rerank Input', debug.rerank_input, true)} + ${stageGrid} ${renderJsonDetails('Selected SKU', debug.style_intent_sku, true)} - ${renderJsonDetails('Matched Queries', debug.matched_queries, false)} ${renderJsonDetails('Multilingual Fields', titlePayload, false)}