Persist provider reasoning and assistant text separately on agent_reasoning_audit (DeepSeekAssistantMessage path), extract diagnosis_run.conclusion, enrich RAG tool audit (step_id/query/qualityScore), gate empty mysql tools, drop devtools, and align MVP docs after live E2E verification.
20 KiB
混合检索上线之后:为什么还要统一 qualityScore,以及上一代后处理错在哪
日期:2026-07-28
范围:hybrid 检索后的分数语义、后处理排序、相关度闸门、scoreLabel 约定
读者:已经(或准备)上 dense+BM25+RRF,却发现「召回变了、质量判断还拧着」的工程同学
关联实现:
lookup_knowledge模块化链路MilvusHybridKnowledgeStore(dense / hybrid)RetrievalScoreNormalizer/KnowledgeEvidencePostProcessor- OpenSpec / devflow:
rag-quality-score-unify - 前置讨论:
docs/RAG排序-多路召回与RRF.md - 架构:
mvp/architecture/RAG知识检索架构.md§6
1. 引言:融合排好了序,不等于质量链路闭环了
上一篇文章(《诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF》)回答的是:
候选太少时别急着上精排;跨路不要硬加原始分;优先 RRF;L0 只做导航。
那一轮讨论之后,工程上陆续落地了:
- chunk 级证据身份与去重(
docId#chunkIndex,同文档多片段可并存) - 真 hybrid:Milvus 服务端 dense ANN + BM25 sparse +
hybridSearch+RRFRanker - 单一知识后端(
MilvusClientV2),去掉 sdk/spring 多路由主路径 - mode 开关:
hybrid线上主路径,dense同库对照评测
主缺口从「假 hybrid / 粗去重」变成了另一件事:
库内:RRF 已经决定谁先谁后
应用:后处理仍假装每条 score 都是 L2
再用 L0 关键词 contains 加分改序
于是出现一种很拧的现象:
- 检索层已经是 混合检索的世界
- 质量层还活在 单路 dense + 规则 boost 的世界
本文记录的,就是这次对「拧」的拆解、拍板与落地口径:
统一 qualityScore,废止 hybrid 的 L2 伪装,去掉关键词 boost 改序。
目标不是再推一套更复杂的模型,而是回答:
hybrid 上线之后,排序权威和质量闸门到底听谁的?后处理还该不该拿关键词打分?
flowchart TB
subgraph retrieval["检索层 · 已 hybrid"]
Q[query] --> D[dense ANN]
Q --> B[BM25 sparse]
D --> RRF[hybridSearch + RRF]
B --> RRF
RRF --> ORD[RRF 序]
end
subgraph post_old["后处理 · 仍 L2 世界"]
ORD --> FAKE[伪装 / 回填 L2]
FAKE --> BOOST[关键词 boost 改序]
BOOST --> GATE[阈值 / level / retry]
end
post_old --> PAIN[排序与闸门拧巴]
2. 上一代(hybrid 刚落地时)到底长什么样
2.1 检索侧:已经是真混合
mode=hybrid 时大致是:
query
├─ dense ANN(query embedding) → vector / L2
└─ BM25 sparse(EmbeddedText) → sparse_vector / BM25
│
▼
Milvus hybridSearch + RRFRanker(k)
│
▼
融合后的 hit 列表(RRF 序)
flowchart LR
Q[query] --> EMB[embedding]
Q --> TXT[raw text]
EMB --> DA[dense ANN<br/>vector / L2]
TXT --> BA[BM25 ANN<br/>sparse]
DA --> HS[Milvus hybridSearch]
BA --> HS
HS --> RR[RRFRanker]
RR --> HITS[有序 hits]
这比应用层 sparse-lite / 伪 hybrid 前进了一大步:词面与语义在库内融合,chunk 身份也不会在后处理被文档级折叠吞掉。
2.2 分数侧:仍在「骗」后处理
后处理历史契约默认:
score ≈ L2 距离(越小越好)
baseScore = 1 - clamp(L2) / maxL2Distance # 越大越好
再 + domain/entity/keyword boost
按 finalScore 重排
用 baseScore 定 relevance_level / 是否低质 retry
为了迁就这套契约,hybrid 路径做了补丁:
hybrid 融合结果
-> 再跑一路 dense
-> 按 id 把 L2 回填到 score,label 改成 l2_distance
-> 仅 BM25 命中、dense 没命中:
score = maxL2Distance
label = bm25_only_no_dense
flowchart TB
H[hybrid RRF 结果] --> P[并行 dense 探测]
P --> M{同 id 有 L2?}
M -->|是| O1[score=L2 · label=l2_distance]
M -->|否| O2[score=maxL2 · label=bm25_only]
O1 --> N[normalizeL2 + boost 重排]
O2 --> N
N --> BAD[BM25-only 好证据被当成最差]
意图是好的:让 normalizeL2 和 0.75/0.5 阈值「还能用」。
副作用也很清楚:
| 现象 | 后果 |
|---|---|
| RRF 决定顺序,L2 决定「好不好」 | 两套真理,互相打架 |
| BM25-only 好证据被标成最远 L2 | quality≈0,像低质,甚至触发 unfiltered retry |
bm25_only_no_dense 像第三种 label |
概念膨胀:mode 其实只有 dense/hybrid |
| 多打一路 dense 只为回填 | 延迟与复杂度,换来的是语义自洽的假象 |
一句话:
不是拿 RRF 分错误地套了 L2 公式,而是排序信 RRF,打分/闸门仍假装大家都是 L2。
2.3 后处理侧:关键词 boost 改主序
典型逻辑:
baseScore = normalizeL2(score)
finalScore = baseScore
+ domain_match (+0.15)
+ entity_match (+0.20)
+ keyword_match (+0.10)
+ source_type (+0.05)
按 finalScore 降序
在 还没有库内 BM25 时,这套东西多少能补一点词面。
在 已经 hybrid 之后,问题变成:
-
双重计分
BM25 已经在 RRF 里投过票;后处理再用 contains 加分,等于词面再抬一次。 -
contains 比 BM25 更糙
无 IDF、无文档长度、短词子串误命中——正好制造「词频/词面高、相关度低却排前面」。 -
冲掉 RRF 序
花了 hybrid 买到的融合序,被 L0 词表二次改写。 -
PRECISE 还绑 hint
高质量还要hasHintSupport(同样是 contains),把导航层信号抬成等级门槛。
结合上一篇文章的判断——L0 / 关键词适合做提示,不适合当最终裁判——hybrid 上线后,后处理 boost 改序已经从「可接受的轻启发式」滑向「明确的设计债」。
3. 问题清单:chunk 去重 + hybrid 之后,还剩什么
可以分成四层(本次主要收口前两层):
3.1 正确性 / 契约(本次主战场)
- hybrid 没有独立的融合分归一化,只有 L2 兼容补丁
- 后处理关键词打分不合理,会抬升词面热、语义冷的片段
scoreLabel语义混乱:l2_distance/rrf_fused/bm25_only_*混用mode=dense与 hybrid 内部 dense 子路概念易混(mode 是整次查询算法,不是「第三套库」)
3.2 质量上限(未在本 change 做完)
- 无固定 RAG 评测报表驱动阈值标定
- 无邻块扩展、真 query rewrite、cross-encoder 精排
- 中文 analyzer / 分词策略未产品化钉死
3.3 工程债(部分清理、部分保留)
- 应用层
LexicalRanker/ 自研RrfFusion可能仍像「还有 app-layer hybrid」 - Spring AI starter 仍可作 sidecar,但不是知识主路径(starter 至 2.0.0 仍无 BM25 hybrid)
- 写入先删后插,非强 upsert;Milvus / MySQL / L0 三方一致性靠流程
3.4 运维边界
- hybrid 依赖 BM25 Function + sparse index
- 全量 rebuild 受 embedding 与写入延迟约束
totalVectors一类统计可能仍不可信
本次 change(rag-quality-score-unify)有意只收口 3.1:
让 hybrid 的排序权威与质量闸门重新对齐,而不是同时上精排模型。
4. 关键澄清:L2 是什么,它是不是「后处理」本身
讨论中容易把「L2」和「后处理打分流程」混成一个词。需要拆开:
4.1 L2 是度量
L2 = 欧氏距离,dense ANN 常用 metric:
- 越小越相似
- 单位向量场景下可用
maxL2Distance≈2做上界 - 归一化相似度:
1 - clamp(L2) / maxL2
4.2 后处理是流水线
后处理消费的是「约定好的 score」,历史上假定它是 L2,于是:
score(L2) → normalizeL2 → baseScore → (+boost) → finalScore → 排序/等级
flowchart LR
subgraph metric["度量层"]
L2[L2 距离]
end
subgraph pipe["后处理流水线"]
N[normalize]
B[可选 boost]
S[排序 / 截断]
G[等级 / 闸门]
N --> B --> S --> G
end
L2 -.->|历史上假定输入是 L2| N
RRF[RRF 融合分] -.->|量纲不同 · 不能直接套| N
所以:
- L2 ≠ 后处理
- L2 = dense 路径的自然距离
- 后处理 = 把某种 score 变成 quality / 等级 / 截断结果的流程
hybrid 的问题是:流程还在,输入契约已经不再总是 L2。
4.3 category 降级也不是 L2 存在的唯一理由
filtered → unfiltered retry 用的是:
isLowQuality = 无可用证据 或 topSimilarity < referenceThreshold
topSimilarity 来自归一化后的质量分。
unfiltered 只是再检一次,尺子本来就该是统一 quality,而不是「专为降级准备的 L2」。
flowchart TB
F[带 category 的检索] --> Q{isLowQuality?}
Q -->|是| U[unfiltered retry]
Q -->|否| K[采用本次结果]
U --> M[合并/替换为 retry 结果]
K --> OUT[后处理出口]
M --> OUT
5. 设计拍板:统一成什么
5.1 两层概念,不要混
| 层 | 只有什么 | 不是什么 |
|---|---|---|
| 检索 mode | dense | hybrid |
不是三套库 |
| 一级 scoreLabel | dense | hybrid |
不是 bm25_only 第三种模式 |
mode:整次查询怎么跑(配置retrieval.search.mode)scoreLabel:这条 hit 的score怎么解释
flowchart TB
CFG[retrieval.search.mode] --> M1[dense 整次只跑 ANN]
CFG --> M2[hybrid 整次 dense+BM25+RRF]
M1 --> L1[scoreLabel=dense]
M2 --> L2[scoreLabel=hybrid]
L2 -.->|不是| L3[bm25_only 第三种 mode]
bm25_only_no_dense 不是第三种检索,只是旧链路里「这条 hybrid 命中没有 dense L2 可回填」的补丁标签。统一后应降级为历史别名(canonicalize → hybrid),不再一级发射。
5.2 检索回来带什么
建议最小契约:
rank (originalRank) // 1 最好;hybrid = RRF 序;dense = ANN 序
score // 引擎主分;量纲由 label 解释
scoreLabel // dense | hybrid
rawScore? // 可选调试
denseDistance? // hybrid 可选:同 id 的 L2,仅供闸门
| label | score 含义 |
|---|---|
dense |
L2 距离(越小越好) |
hybrid |
引擎融合分可放 raw/score;排序不看其量纲 |
flowchart LR
subgraph emit["Store 发射"]
LAB[scoreLabel<br/>dense | hybrid]
SCR[score / rawScore]
RNK[列表序 → originalRank]
DD[denseDistance? 仅 hybrid]
end
LAB --> NORM
SCR --> NORM
RNK --> SORT
DD --> NORM
NORM[toQualityScore] --> QS[qualityScore]
SORT[按 rank 排序] --> LIST[evidence 顺序]
QS --> GATE[level / isLowQuality]
5.3 唯一归一化点
qualityScore = toQualityScore(label, score, rank, batchSize, maxL2, denseDistance?)
// 输出统一:[0,1],越大越好
分支只允许出现在这里:
dense → 1 - clamp(L2)/maxL2
hybrid → 优先 denseDistance 的 L2 归一化(绝对质量 / 闸门)
无 dense 时 rank 线性回退
flowchart TB
IN[label + score + rank + denseDistance?] --> C{canonicalize label}
C -->|dense| L2[l2ToQuality score]
C -->|hybrid| H{denseDistance?}
H -->|有| L2H[l2ToQuality denseDistance]
H -->|无| RK[rankToQuality]
L2 --> OUT[qualityScore 0..1]
L2H --> OUT
RK --> OUT
演进说明: 切片 1 曾用纯 rank 做 hybrid quality;eval 发现 rank1 恒高会杀死 L0 filter fallback。
现行约定:排序仍纯 RRF;闸门可用 denseDistance 绝对质量,且 不得 再把主分/label 伪装成 L2。
5.4 后处理:统一流程,不要按 label 再分叉业务
candidates
→ 每条 toQualityScore(...) ← 唯一认 label 的地方
→ qualityScore + originalRank
→ 统一:按 rank 排序 / 去重 / 每文档 chunk 上限 / return-n
→ 统一:relevance_level、isLowQuality(只看 qualityScore)
flowchart TB
CAND[candidates] --> QS[toQualityScore 每条]
QS --> SORT[sort by originalRank ASC]
SORT --> DEDUP[evidenceKey 去重]
DEDUP --> CAP[max-chunks / return-n]
CAP --> REL[relevance_level]
CAP --> LOW[isLowQuality → filter retry]
CAP --> OUT[EvidenceBlocks]
可以记成:
Label 只活在进后处理之前的适配器里;后处理是 label-agnostic 的。
排序听 rank;闸门听 qualityScore(hybrid 可含 denseDistance)。
5.5 后处理还改不改?——要改,而且和归一化同一刀
后处理合理职责是 裁剪与装配,不是第二套检索:
| 保留 | 去掉或降级 |
|---|---|
| evidenceKey 去重 | domain/entity/keyword 加分改序 |
| max-chunks-per-document | contains 当相关度代理 |
| return-n | PRECISE 强制 hint support |
| excerpt 截断、EvidenceBlock | |
| 统一 qualityScore 闸门 |
L0 仍可:
- 导航:category filter(失败 unfiltered retry)
- 解释:
hitReasons记l0_keyword_overlap等(零分值)
词面该不该高:交给 BM25 子路 + RRF。
语义该不该近:交给 dense 子路(融合时已参与;dense-only mode 对照时单独看)。
5.6 mode=dense 还要不要
要,但定位清楚:
| 模式 | 定位 |
|---|---|
| hybrid | 线上主路径 / 默认 |
| dense | 同库对照、评测、排障——看「去掉 BM25+RRF 后差在哪」 |
注意:
- hybrid 入库数据完全适用于 dense 查询(每条都写了
vector) - hybrid 内部仍有 dense 子路——那是融合的一部分,≠
mode=dense - 对照时固定
retrieve-k/return-n/ filter / query 集,只切 mode - 优先比命中集合与排名;
relevance_level在 hybrid 下是序数 quality,慎作跨 mode 绝对值对比
6. 目标数据流(落地后)
VectorSearchService (mode=dense|hybrid)
-> hits{ originalRank, score, scoreLabel=dense|hybrid, rawScore?, denseDistance? }
-> KnowledgeDocumentRetriever / SearchPort
-> KnowledgeEvidencePostProcessor
qualityScore = RetrievalScoreNormalizer.toQualityScore(...)
sort by originalRank ASC
evidenceKey dedup / max-chunks / return-n
relevance_level & topSimilarity from qualityScore
L0 overlap → hitReasons only
-> ContextPack / Assembler / Projector
flowchart TB
Q[query] --> VSS[VectorSearchService]
VSS -->|mode=dense| SD[searchDense]
VSS -->|mode=hybrid| SH[searchHybrid + 可选 denseDistance]
SD --> PORT[KnowledgeSearchPort / Adapter]
SH --> PORT
PORT --> POST[KnowledgeEvidencePostProcessor]
POST --> PACK[ContextPacker]
POST --> ASM[LookupResultAssembler]
ASM --> PROJ[RagResultProjector]
PROJ --> AGENT[Agent 可见契约]
与上一代对比:
| 环节 | 上一代 | 现在 |
|---|---|---|
| hybrid score | 常被 L2 覆盖 | 保持融合侧;label=hybrid |
| BM25-only | maxL2 + bm25_only_* |
普通 hybrid hit,quality 看 rank |
| 归一化 | 一律当 L2 | 按 label 唯一转换 |
| 排序 | finalScore(含 boost) | originalRank |
| L0 关键词 | +分改序 | 仅解释 |
| 质量闸门 | baseScore(L2 兼容) | qualityScore |
7. 行为变化:必须说清楚的协议调整
这是有意的行为变化(对内检索质量语义;Agent ACI 字段名可不变):
- hybrid 下证据顺序更贴近 RRF,不再被 contains 抬到前面
- 「词面很准、dense 略远」的命中,不再被默认打成低质占位
relevance_level/ category unfiltered retry 的触发分布可能变化- hybrid 的 quality 是本轮序数分,跨 query 绝对值不可比;阈值可能需后续标定
- dense 对照模式:质量仍走 L2 归一化,行为更接近旧 dense 主路径
未改:
- Agent 可见字段结构(evidence 列表、relevance 枚举名等)
- Milvus hybrid schema / 不必为本次 rebuild
mode=dense开关本身
8. 和上一篇文章的衔接:阶段进度
对照 RAG排序-多路召回与RRF.md 的推进顺序:
| 阶段 | 内容 | 状态(截至 2026-07-28) |
|---|---|---|
| Phase 0 | chunk 去重、retrieve-k/return-n、身份 | 已落地 |
| Phase 1~2 | 多路 + RRF;真 BM25 hybrid | 已落地(库内 hybrid,非 app-layer 伪融合) |
| 分数职责分离 | 排序 vs 可用性/质量闸门 | 本次收口(qualityScore 统一) |
| 去掉 L0 当裁判 | 关键词不改主序 | 本次收口 |
| Phase 3 | 可插拔模型 Rerank | 未做(候选池与评测闭环仍优先) |
| 邻块 / query rewrite | 上下文与问句改写 | 未做 |
因此,本次文章不是推翻上一篇,而是补上上一篇写到「融合之后」却还没写完的半截:
融合解决「谁进来、谁先排」;
归一化与后处理决定「算不算够好、会不会被规则再次打乱」。
9. 实现锚点(便于对照代码)
| 组件 | 职责 |
|---|---|
RetrievalScoreLabels |
dense / hybrid + 旧别名 canonicalize |
RetrievalScoreNormalizer |
唯一 toQualityScore |
MilvusHybridKnowledgeStore |
发射 label;hybrid 不 L2 覆盖 |
VectorSearchService |
mode 路由;SearchResult 契约注释 |
KnowledgeEvidencePostProcessor |
rank 保序、去 boost 改序、quality 闸门 |
| 架构 §6.0 | mode 用途:hybrid 主路径 / dense 对照 |
验证(单测,非 live E2E):
- normalizer:L2 边界、rank 单调、别名
- post-process:rank 不被 keyword 打乱;caps/return-n
- lookup tool:保序;context pack 元数据仍在
已知未验证:真实 Milvus 联调对照、阈值标定。
10. 实践清单:以后别再踩的坑
- 不要为了复用旧
normalizeL2,把 hybrid 结果伪装成 L2。 - 不要在已经 BM25 hybrid 之后,再用 L0 contains 大额加分改主序。
- 不要把
bm25_only当成第三种检索模式。 - 不要把 hybrid 内部的 dense 子路,和
mode=dense整次查询混为一谈。 - 要让 label 差异停在适配器;后处理只认 qualityScore + rank。
- 要用 dense mode 做召回对照,而不是第二套长期线上策略。
- 要接受:hybrid 序数 quality 与绝对阈值之间,需要观测后再调,而不是再发明一层伪装。
- 下一步再考虑精排模型——在契约掰直、有固定 query 回归集之后。
11. 结语
混合检索落地,解决的是「漏」和「跨路硬加分」里很大一块。
但若后处理仍活在 L2 + 关键词 boost 的旧世界,hybrid 买到的 RRF 序和质量信号会被悄悄改写,甚至惩罚「只在 BM25 路很强」的好证据。
这次收口的核心就三句:
1. 一级 label 只有 dense / hybrid
2. 唯一 toQualityScore;后处理统一、保 rank
3. L0 关键词可以解释,不可以再当排序裁判
它不是 RAG 的终点,而是 hybrid 从「能跑」变成「分数语义自洽」的必要一步。
在此之后,评测闭环、阈值标定、邻块与精排,才有干净的基线可谈。
附录 A:术语
| 术语 | 含义 |
|---|---|
| L2 | 欧氏距离;dense ANN 常用;越小越相似 |
| RRF | Reciprocal Rank Fusion;用名次融合多路,不融合原始分 |
| scoreLabel | 一级分数语义:dense | hybrid |
| qualityScore | 归一化后的 0~1 质量分(越大越好),供等级与低质闸门 |
| originalRank | 检索返回名次;后处理排序权威 |
| mode=dense | 整次只跑 dense ANN(对照) |
| mode=hybrid | dense+BM25+RRF(主路径) |
| L0 | query hint / 可选 category filter;不作事实证据、不改主序 |
附录 B:相关材料
| 材料 | 路径 |
|---|---|
| 多路与 RRF 讨论 | docs/RAG排序-多路召回与RRF.md |
| 当前架构 | mvp/architecture/RAG知识检索架构.md |
| OpenSpec 归档 | openspec/changes/archive/2026-07-28-rag-quality-score-unify/ |
| 主规格 | openspec/specs/rag-retrieval-quality-score/spec.md |
| devflow | devflow/projects/2026-07-28-rag-quality-score-unify/ |