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.
593 lines
20 KiB
Markdown
593 lines
20 KiB
Markdown
# 混合检索上线之后:为什么还要统一 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/` |
|