# 混合检索上线之后:为什么还要统一 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
vector / L2] TXT --> BA[BM25 ANN
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
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/` |