Files
group_fqcd_jr/app/core/retrieval_fusion.py
T
张胜宇 2a55269e20 feat(W34-W36): 客服双通道口径分离收口 + 会签 20/21/22 落地 + 演示启动器修复
W34 · 会签 20/21/22 三项落地(先立单、经授权、后动手)
- 会签 20(白名单外):runtime_config_service 新增 load_collection_routes() /
  collection_routes() / _first_collection_name(),首次消费既有 JSON 列 collection_routes;
  消费方 customer_service 走「配置优先、缺失回落代码常量」。零 DDL;该列当前全为 None
  ⇒ 实际走回落路径,行为与改动前一致。
- 会签 21(类 3 + 融合层):retrieval_fusion 新增 fuse_rrf() + RRF_K(排名融合,只吃名次
  不吃分数 ⇒ 异质分数不可能污染判定分,best_vector_score 仍只取向量路原始 cosine);
  knowledge_search_service::search() 新增 literal_parallel: bool = False(默认值使行为
  逐字等同现状)。工具层透传未做 —— 那需改 KnowledgeSearchInput 契约(extra="forbid"),
  超出本单范围。
- 会签 22(发布配置 + bootstrap):customer_service 新增 INTENT_MARKET_QUOTE 常量 +
  TREND_WHITELIST_INTENT_CANDIDATES(按优先级回落)+ _trend_whitelist_intent()
  (运行时自检 + 自动回落,强于「仅报错」)。发布配置 customer_service:market_quote
  (release 260,allowed_tools=['query_fund_trend'])已写入并回读校验(9 → 10 行)。
  刻意未加入 supported_intents:授权维度与判定维度解耦,不动判定分布。
- 阶段 0:customer_service_rules 新增 normalize_query() + QUERY_SYNONYMS + 等级代号大写
  (纯函数;同义表只收纯书写差异,语义类同义留待金标 A/B 后逐条加;调用方默认不启用)。

W35 · 判定口径与融合口径分离(修 A-01 / C-04 / I-02 / E-04 四条)
- _dual_route_output 返回值新增 vector_order(向量路原始 doc_id 顺序、去重);
- 新增 _vector_decision_hits() 据此还原「判定序列」(带向量分的 basic 补位块回补首位;
  无 vector_order / 空 / id 全对不上 ⇒ 返回 None 回落原分支);
- _answer_from_knowledge 的 score / gap 与原文直返的 best 改从向量路原始序列取。
  语义边界(刻意):_evidence_pack / _exit_clarify / _answer_from_evidence 仍吃融合序列
  —— 融合的收益只留在「给哪些块、什么顺序」,符合三层分数分离约束。

W36 · 选块口径归一(收口最后一条 E-01)
- 新增 _pack_order():order 命中的块排前,其余按原相对顺序追加在后;
- _evidence_pack 新增 order= 参数,三处遍历 hits → ordered,top 由 hits[0] → ordered[0];
- _answer_from_knowledge 传入 order=[judge 的 doc_id 序列];judge is None ⇒ None
  (开关关闭时逐字零改动)。order 只当排序键、不当过滤器 ⇒ 证据包成员集合不变。

演示环境与文档
- start.ps1 / demo.ps1 默认端口 8000 → 8099(与 README / docs/06,07,09,14,15,32 /
  tools/smoke_check.py / login_console.py 的全仓口径对齐;字节级定长替换,保住
  UTF-8 BOM + CRLF,字节数不变);
- portal/README.md 更正 fin_nav_history 过期口径(「0 行」→ 实测 2494 行 / 20 个产品 /
  nav_date 覆盖 2026-03-18—2026-09-13)。

测试(新增 3 个文件、补强 2 个)
- 新增 tests/unit/service/test_decision_scope_w35.py(10 条)、
  tests/unit/service/test_evidence_pack_order_w36.py(13 条)、
  tests/unit/service/test_customer_service_trend_chart_inv8.py(INV-8 字面级判定,
  纳入 pytest 门禁,此前只在 jsdom 脚本里覆盖);
- 补强 tests/unit/core/test_customer_service_rules.py(normalize_query 7 条)与
  tests/integration/test_customer_service_trend_chart_persistence.py(图内每个数字
  都必须在答复正文出现过,判定口径与 INV-8 单测一致)。

验证
- 全量 pytest:2642 passed / 3 skipped / 0 failed(基线 2629 + 新增 13);
- 55 条金标真实链路 A/B 四组:off / norm / dual / on 均 55/55 = 100%
  (改前 dual 92.7%、on 90.9%);M-4 事实正确率恒 100%、M-7—M-10 全 0;
- 红线四条守住:融合/精排层仍不持 Milvus 客户端(INV-1)、阈值一字未动、零 DDL;
- 三个实验开关 CS_DUAL_ROUTE / CS_RERANK / CS_QUERY_NORM 仍默认关闭。
2026-09-22 18:05:59 +08:00

294 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""检索融合:多路召回 → 加权评分 → 统一排序(**纯函数,不碰 Milvus、不碰判定分数**)。
## 为什么需要它
一次检索现在只有"一路向量 + 条件触发的字面通道",且字面通道被 `VECTOR_CONFIDENT_SCORE = 0.75`
挡住。要让两路**并行**并各司其职,就需要一个地方把它们的候选合起来 —— 那就是本模块。
## 🔴 最重要的约束:`fused_score` **不是**判定分数
判定用的阈值是 `HIGH_SCORE` / `MID_SCORE` / `MIN_GAP` / `E4_MIN_SCORE`(都在客服 Agent 里)。
`app/service/agent/implementations/customer_service.py` 里 `MIN_GAP` 的注释写着实测可选取值区间是
**`(0.0649, 0.0759)`**、两侧裕度**各约 0.005**。那个 `gap` 是用**向量 cosine 分**算的,
任何替换它的实现都会把金标题推出窄带(`M-1` 从 100% 掉下来)。
所以本模块严格区分两个分数:
| 字段 | 含义 | 谁用 |
|---|---|---|
| `best_vector_score` | 该块在**向量路**里的原始 cosine(没有则 `None`) | 🔴 **出口判定** |
| `fused_score` | 各路加权后的融合分 | **只用于候选集合的选择与排序** |
**消费方必须用前者做判定、后者做取舍**,两者不可互换。这条不是风格偏好,是回归门禁的硬要求。
## 融合规则(为什么这么定)
1. **同 `doc_id` 合并**,各路得分**加权求和**:`fused = Σ(w_i · s_i)`。
⇒ 两路都命中的条目得分更高 —— 这是"共识条目优先"的期望行为(与 RRF 的精神一致)。
权重之和约定为 `1.0`,这样 `fused_score` 仍落在 `[0, 1]` 区间、与向量分同量纲,便于人工核读。
2. **不引入新分数尺度**:本模块**不做** `1/(k+rank)` 之类的排名变换 —— 一期两路都是 cosine、
天然同尺度,加权即可(`W33` 方案 §3.2)。等二期引入**异质通道**(字面锚点给满分 `1.0`)时,
才需要切换成排名融合;那时本模块加一个 `rrf` 策略即可,**消费方接口不变**。
3. **保序稳定**:同分时按"先出现的路 → 路内原顺序"排,保证**可复算**(两次跑同一输入结果一致)。
4. **`degraded` 按"能否区分故障与没找到"合并**(沿用既有纪律):
- 全部路失败 ⇒ `degraded=True, reason="all_routes_failed"`
- 部分路失败但有结果 ⇒ `degraded=True, reason="partial_route_failure"`,**结果仍然返回**
- 部分路失败且**没有任何结果** ⇒ 同上(**不得**降级成「没找到」)
- 全部路成功 ⇒ `degraded=False`,结果为空是**正常的"没命中"**
## 边界
- **档位过滤不归本模块管**:送进来的候选必须**已经过 `visibility` 过滤**(由检索层按 `tiers` 做)。
本模块不得持有 Milvus 客户端、不得自行检索 —— 否则访客内容可经"融合"这一侧被读出来(`INV-1`)。
- 所有异常(缺字段、类型不对)一律**跳过该候选**而不是抛错:融合是**增益**,
它坏了不能把本来能答的问题变成答不出来。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class RetrievalRoute:
"""一路召回的结果。`hits` 是**工具返回值形态**的 dict 列表。"""
name: str
weight: float
hits: tuple[Any, ...] = ()
degraded: bool = False
reason: str = ""
@dataclass(frozen=True)
class FusedHit:
"""融合后的一条候选。**两个分数各有各的用途,消费方别混用。**"""
doc_id: str
payload: dict[str, Any]
#: 只用于"选哪些块、按什么顺序"。
fused_score: float
#: 🔴 用于出口判定的原始向量分(该块没出现在向量路时为 `None`)。
best_vector_score: float | None = None
#: 该块来自哪些路(长度 > 1 = 多路共识)。
routes: tuple[str, ...] = ()
#: 该块在各路里的原始分,便于核读与排查。
route_scores: dict[str, float] = field(default_factory=dict)
@dataclass(frozen=True)
class FusionOutcome:
hits: tuple[FusedHit, ...] = ()
degraded: bool = False
reason: str = ""
routes: tuple[str, ...] = ()
#: 向量路的固定名:只有它产出的分数允许被判定层使用(见模块头「两个分数」)。
VECTOR_ROUTE_NAME = "vector"
#: 参与比对的分数阈值:低于它视作噪声,不进融合(沿用 `E4_MIN_SCORE` 的量级太低,
#: 这里只挡"明显是无意义的 0 分")。
_MIN_USEFUL_SCORE = 0.0
def _score_of(hit: Any) -> float | None:
"""取一条命中的分数;取不到返回 `None`(该条仍可入库,只是不计分)。"""
if not isinstance(hit, dict):
return None
value = hit.get("score")
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value))
except (TypeError, ValueError):
return None
def _doc_id_of(hit: Any) -> str:
if not isinstance(hit, dict):
return ""
return str(hit.get("doc_id") or "")
def fuse(routes: list[RetrievalRoute] | tuple[RetrievalRoute, ...]) -> FusionOutcome:
"""把多路候选融合成一份排序结果。
`routes` 顺序即"路内原顺序"的优先级(同分时先出现的路在前)。
"""
# 传进来的每一路都视为**已尝试**:调用方只构造它真的跑过的路。
# 不按 `hits` 是否为空过滤 —— "跑成功但零命中"是一个**有意义的信号**
# (它把"部分失败"与"全部失败"区分开),过滤掉它就会把部分失败误报成全部失败。
usable = list(routes)
if not usable:
return FusionOutcome()
failed = [r for r in usable if r.degraded]
degraded = bool(failed)
if failed and len(failed) == len(usable):
reason = "all_routes_failed"
elif failed:
reason = "partial_route_failure"
else:
reason = ""
# doc_id → 累计;用 dict 保序(Python 3.7+ 保插入序)
acc: dict[str, dict[str, Any]] = {}
for route in usable:
weight = float(route.weight)
for hit in route.hits:
doc_id = _doc_id_of(hit)
if not doc_id:
continue # 没有 doc_id 的命中无法去重、也无法引用 ⇒ 丢弃
score = _score_of(hit)
if score is not None and score < _MIN_USEFUL_SCORE:
continue
entry = acc.get(doc_id)
if entry is None:
entry = {"payload": hit, "fused": 0.0, "routes": [], "scores": {},
"best_vector": None}
acc[doc_id] = entry
# 后出现的路不覆盖 payload:先出现者优先(保序稳定、可复算)
if score is not None:
entry["fused"] += weight * score
entry["scores"][route.name] = score
if route.name == VECTOR_ROUTE_NAME:
prev = entry["best_vector"]
entry["best_vector"] = score if prev is None else max(prev, score)
if route.name not in entry["routes"]:
entry["routes"].append(route.name)
fused_hits = [
FusedHit(
doc_id=doc_id,
payload=entry["payload"],
fused_score=float(entry["fused"]),
best_vector_score=entry["best_vector"],
routes=tuple(entry["routes"]),
route_scores=dict(entry["scores"]),
)
for doc_id, entry in acc.items()
]
# 融合分降序;同分保持插入序 ⇒ 可复算
fused_hits.sort(key=lambda item: item.fused_score, reverse=True)
return FusionOutcome(
hits=tuple(fused_hits),
degraded=degraded,
reason=reason,
routes=tuple(r.name for r in usable),
)
#: RRF(排名融合)的平滑常数。取 60 —— 原始论文与主流实现的通行值。
#: 它的作用是把"第 1 名 vs 第 2 名"的差距压到一个不至于过大的量级,
#: 使多路"共同命中但都不靠前"的条目有机会胜出(这正是融合想要的"共识优先")。
RRF_K = 60
def fuse_rrf(
routes: list[RetrievalRoute] | tuple[RetrievalRoute, ...],
*,
k: int = RRF_K,
) -> FusionOutcome:
"""排名融合(Reciprocal Rank Fusion):`score = Σ 1 / (k + rank_i)`。
## 什么时候用它(而不是 `fuse`)
一期两路都是 cosine、天然同尺度,加权求和即可(`fuse`)。
但引入**异质通道**后(例如字面锚点命中给满分 `1.0`),两路分数**不再是同一把尺子**:
直接加权会让字面路**压平** `gap`,而 `customer_service` 的 `MIN_GAP` 裕度只有 ±0.005。
此时改用 RRF —— 它**只吃名次、不吃分数**,异质分数被彻底丢弃 ⇒ 不可能污染判定分。
## 与 `fuse` 的共同点(消费方接口不变)
- 返回仍是 `FusionOutcome`,`FusedHit` 字段语义不变;
- 🔴 `best_vector_score` **仍然只取向量路的原始 cosine**,绝不由 RRF 分替代 ——
判定分与排序分的分离是本模块的第一约束(见模块头);
- `fused_score` 这里装的是 RRF 原始分(量级约 `1/k`),**只用于排序**;
- `degraded` 的三态合并口径与 `fuse` 逐字一致。
⚠️ 注意量纲差异:`fuse` 的 `fused_score` 落在 `[0, 1]`,RRF 的落在约 `(0, 2/k]`。
两者都**只用于排序**,因此可互替;但**不得**拿二者跨策略比较大小。
"""
usable = list(routes)
if not usable:
return FusionOutcome()
failed = [r for r in usable if r.degraded]
degraded = bool(failed)
if failed and len(failed) == len(usable):
reason = "all_routes_failed"
elif failed:
reason = "partial_route_failure"
else:
reason = ""
smooth = float(k) if k > 0 else 1.0
acc: dict[str, dict[str, Any]] = {}
for route in usable:
seen: set[str] = set()
for index, hit in enumerate(route.hits, start=1):
doc_id = _doc_id_of(hit)
if not doc_id or doc_id in seen:
continue # 同一路里重复出现的同一条,只计它最好的那次名次
seen.add(doc_id)
entry = acc.get(doc_id)
if entry is None:
entry = {"payload": hit, "rrf": 0.0, "routes": [], "best_vector": None}
acc[doc_id] = entry
# 后出现的路不覆盖 payload:先出现者优先(保序稳定、可复算)
entry["rrf"] += 1.0 / (smooth + index)
if route.name not in entry["routes"]:
entry["routes"].append(route.name)
if route.name == VECTOR_ROUTE_NAME:
score = _score_of(hit)
if score is not None:
prev = entry["best_vector"]
entry["best_vector"] = score if prev is None else max(prev, score)
fused_hits = [
FusedHit(
doc_id=doc_id,
payload=entry["payload"],
fused_score=float(entry["rrf"]),
best_vector_score=entry["best_vector"],
routes=tuple(entry["routes"]),
route_scores={}, # RRF 不保留各路原始分:它们已被刻意丢弃(异质尺度)
)
for doc_id, entry in acc.items()
]
# 融合分降序;同分保持插入序 ⇒ 可复算
fused_hits.sort(key=lambda item: item.fused_score, reverse=True)
return FusionOutcome(
hits=tuple(fused_hits),
degraded=degraded,
reason=reason,
routes=tuple(r.name for r in usable),
)
def payloads(outcome: FusionOutcome) -> list[dict[str, Any]]:
"""把融合结果还原成"工具返回值形态"的列表,供既有下游(证据包 / 展示)直接消费。
🔑 **`score` 字段写回的是 `best_vector_score`(有则写),不是 `fused_score`** ——
这是本模块唯一一处最容易写错的地方:下游的置信判定读的就是这个 `score`,
若把它换成融合分,`gap` 就会变,`MIN_GAP` 的 ±0.005 裕度立刻被打破。
融合分单独放在 `fused_score` 里,**只给排序用**。
"""
out: list[dict[str, Any]] = []
for hit in outcome.hits:
item = dict(hit.payload) if isinstance(hit.payload, dict) else {}
if hit.best_vector_score is not None:
item["score"] = round(hit.best_vector_score, 4)
else:
# 该块不在向量路里(例如纯字面命中)⇒ 不给一个"看起来像判定分"的数,
# 显式置 0.0 让下游按"无向量证据"处理,而不是拿融合分冒充。
item["score"] = 0.0
item["fused_score"] = round(hit.fused_score, 4)
item["routes"] = list(hit.routes)
out.append(item)
return out