Files
SuperBizAgent-java/mvp/engineering/rag/RAG-Hybrid质量分与后处理.md
T
zhuyongxin 584639fa2a docs(mvp): move engineering notes under mvp/engineering
Relocate RAG and diagnosis decision/E2E writeups from docs/ root into
mvp/engineering so architecture, issues, and engineering narrative stay
together. Update indexes and cross-links; leave docs/learning as legacy.
2026-07-29 10:49:45 +08:00

593 lines
20 KiB
Markdown
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.
# 混合检索上线之后:为什么还要统一 qualityScore,以及上一代后处理错在哪
**日期**:2026-07-28
**范围**:hybrid 检索后的分数语义、后处理排序、相关度闸门、scoreLabel 约定
**读者**:已经(或准备)上 dense+BM25+RRF,却发现「召回变了、质量判断还拧着」的工程同学
**关联实现**:
- `lookup_knowledge` 模块化链路
- `MilvusHybridKnowledgeStore`(dense / hybrid)
- `RetrievalScoreNormalizer` / `KnowledgeEvidencePostProcessor`
- OpenSpec / devflow:`rag-quality-score-unify`
- 前置讨论:`RAG排序-多路召回与RRF.md`
- 架构:`mvp/architecture/RAG知识检索架构.md` §6
---
## 1. 引言:融合排好了序,不等于质量链路闭环了
上一篇文章(《诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF》)回答的是:
> 候选太少时别急着上精排;跨路不要硬加原始分;优先 RRF;L0 只做导航。
那一轮讨论之后,工程上陆续落地了:
1. **chunk 级证据身份与去重**(`docId#chunkIndex`,同文档多片段可并存)
2. **真 hybrid**:Milvus 服务端 dense ANN + BM25 sparse + `hybridSearch` + `RRFRanker`
3. **单一知识后端**(`MilvusClientV2`),去掉 sdk/spring 多路由主路径
4. **mode 开关**:`hybrid` 线上主路径,`dense` 同库对照评测
主缺口从「假 hybrid / 粗去重」变成了另一件事:
```text
库内:RRF 已经决定谁先谁后
应用:后处理仍假装每条 score 都是 L2
再用 L0 关键词 contains 加分改序
```
于是出现一种很拧的现象:
- 检索层已经是 **混合检索的世界**
- 质量层还活在 **单路 dense + 规则 boost 的世界**
本文记录的,就是这次对「拧」的拆解、拍板与落地口径:
**统一 qualityScore,废止 hybrid 的 L2 伪装,去掉关键词 boost 改序。**
目标不是再推一套更复杂的模型,而是回答:
> hybrid 上线之后,排序权威和质量闸门到底听谁的?后处理还该不该拿关键词打分?
```mermaid
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` 时大致是:
```text
query
├─ dense ANN(query embedding) → vector / L2
└─ BM25 sparse(EmbeddedText) → sparse_vector / BM25
│
▼
Milvus hybridSearch + RRFRanker(k)
│
▼
融合后的 hit 列表(RRF 序)
```
```mermaid
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 分数侧:仍在「骗」后处理
后处理历史契约默认:
```text
score ≈ L2 距离(越小越好)
baseScore = 1 - clamp(L2) / maxL2Distance # 越大越好
再 + domain/entity/keyword boost
按 finalScore 重排
用 baseScore 定 relevance_level / 是否低质 retry
```
为了迁就这套契约,hybrid 路径做了补丁:
```text
hybrid 融合结果
-> 再跑一路 dense
-> 按 id 把 L2 回填到 score,label 改成 l2_distance
-> 仅 BM25 命中、dense 没命中:
score = maxL2Distance
label = bm25_only_no_dense
```
```mermaid
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 改主序
典型逻辑:
```text
baseScore = normalizeL2(score)
finalScore = baseScore
+ domain_match (+0.15)
+ entity_match (+0.20)
+ keyword_match (+0.10)
+ source_type (+0.05)
按 finalScore 降序
```
在 **还没有库内 BM25** 时,这套东西多少能补一点词面。
在 **已经 hybrid** 之后,问题变成:
1. **双重计分**
BM25 已经在 RRF 里投过票;后处理再用 contains 加分,等于词面再抬一次。
2. **contains 比 BM25 更糙**
无 IDF、无文档长度、短词子串误命中——正好制造「词频/词面高、相关度低却排前面」。
3. **冲掉 RRF 序**
花了 hybrid 买到的融合序,被 L0 词表二次改写。
4. **PRECISE 还绑 hint**
高质量还要 `hasHintSupport`(同样是 contains),把导航层信号抬成等级门槛。
结合上一篇文章的判断——**L0 / 关键词适合做提示,不适合当最终裁判**——hybrid 上线后,后处理 boost 改序已经从「可接受的轻启发式」滑向「明确的设计债」。
---
## 3. 问题清单:chunk 去重 + hybrid 之后,还剩什么
可以分成四层(本次主要收口前两层):
### 3.1 正确性 / 契约(本次主战场)
1. hybrid **没有**独立的融合分归一化,只有 L2 兼容补丁
2. 后处理关键词打分不合理,会抬升词面热、语义冷的片段
3. `scoreLabel` 语义混乱:`l2_distance` / `rrf_fused` / `bm25_only_*` 混用
4. `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,于是:
```text
score(L2) → normalizeL2 → baseScore → (+boost) → finalScore → 排序/等级
```
```mermaid
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 用的是:
```text
isLowQuality = 无可用证据 或 topSimilarity < referenceThreshold
```
`topSimilarity` 来自归一化后的质量分。
unfiltered 只是**再检一次**,尺子本来就该是统一 quality,而不是「专为降级准备的 L2」。
```mermaid
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` 怎么解释
```mermaid
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 检索回来带什么
建议最小契约:
```text
rank (originalRank) // 1 最好;hybrid = RRF 序;dense = ANN 序
score // 引擎主分;量纲由 label 解释
scoreLabel // dense | hybrid
rawScore? // 可选调试
denseDistance? // hybrid 可选:同 id 的 L2,仅供闸门
```
| label | score 含义 |
|-------|------------|
| `dense` | L2 距离(越小越好) |
| `hybrid` | 引擎融合分可放 raw/score;**排序不看其量纲** |
```mermaid
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 唯一归一化点
```text
qualityScore = toQualityScore(label, score, rank, batchSize, maxL2, denseDistance?)
// 输出统一:[0,1],越大越好
```
分支只允许出现在这里:
```text
dense → 1 - clamp(L2)/maxL2
hybrid → 优先 denseDistance 的 L2 归一化(绝对质量 / 闸门)
无 dense 时 rank 线性回退
```
```mermaid
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 再分叉业务
```text
candidates
→ 每条 toQualityScore(...) ← 唯一认 label 的地方
→ qualityScore + originalRank
→ 统一:按 rank 排序 / 去重 / 每文档 chunk 上限 / return-n
→ 统一:relevance_level、isLowQuality(只看 qualityScore)
```
```mermaid
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. 目标数据流(落地后)
```text
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
```
```mermaid
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 字段名可不变):
1. hybrid 下证据顺序更贴近 **RRF**,不再被 contains 抬到前面
2. 「词面很准、dense 略远」的命中,不再被默认打成低质占位
3. `relevance_level` / category unfiltered retry 的触发分布可能变化
4. hybrid 的 quality 是**本轮序数分**,跨 query 绝对值不可比;阈值可能需后续标定
5. 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. 实践清单:以后别再踩的坑
1. **不要**为了复用旧 `normalizeL2`,把 hybrid 结果伪装成 L2。
2. **不要**在已经 BM25 hybrid 之后,再用 L0 contains 大额加分改主序。
3. **不要**把 `bm25_only` 当成第三种检索模式。
4. **不要**把 hybrid 内部的 dense 子路,和 `mode=dense` 整次查询混为一谈。
5. **要**让 label 差异停在适配器;后处理只认 qualityScore + rank。
6. **要**用 dense mode 做召回对照,而不是第二套长期线上策略。
7. **要**接受:hybrid 序数 quality 与绝对阈值之间,需要观测后再调,而不是再发明一层伪装。
8. **下一步再考虑**精排模型——在契约掰直、有固定 query 回归集之后。
---
## 11. 结语
混合检索落地,解决的是「漏」和「跨路硬加分」里很大一块。
但若后处理仍活在 L2 + 关键词 boost 的旧世界,hybrid 买到的 RRF 序和质量信号会被悄悄改写,甚至惩罚「只在 BM25 路很强」的好证据。
这次收口的核心就三句:
```text
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 讨论 | `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/` |