Files
SuperBizAgent-java/mvp/engineering/rag/RAG-Hybrid质量分与后处理.md
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

20 KiB
Raw Permalink Blame History

混合检索上线之后:为什么还要统一 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 / 粗去重」变成了另一件事:

库内: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 之后,问题变成:

  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,于是:

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 字段名可不变):

  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 路很强」的好证据。

这次收口的核心就三句:

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/