同一个知识库问题,关键词检索可能找到了正确的错误码,向量检索却找到了更接近用户说法的排障步骤。把两边结果合并后,下一步是直接交给模型,还是先重新比较问题与每个段落的关系?

这一篇接在从 embedding 到语义检索之后,把流程拆成三个可以分别检查的环节:候选是否覆盖答案、融合怎样排列候选、重排是否让真正有用的证据更靠前。最后用纯 Python 标准库实验验证排序与指标,不调用模型,也不把人工示例当成生产效果评测。

先分清三个不同的预算

记号 含义 增大它主要影响什么
W 每条召回列表进入融合的窗口 候选覆盖面、检索与融合成本
M 融合后送入重排的候选数 重排计算量、候选截断损失
K 最终返回或放入上下文的条数 阅读与生成负担、上下文预算

W、M、K 是三个独立决定。下文 RRF 的平滑常数记作 c,避免把它与返回条数 K 混淆。向量索引内部的近似搜索候选预算又是另一层参数,不能用这三个数代替。

实际系统应先由可信的服务端身份确定可访问范围,再让各召回分支在同一范围内查询。下面的代码接受已完成权限过滤的排名列表;它不是权限系统。把受限内容先交给外部重排服务,再在显示时过滤,不能补回已经发生的数据外发。

关键词与向量分数为什么不能直接相加

关键词检索常借助 BM25 这类方法利用词频、稀有程度和长度信息;向量检索比较表示之间的相似程度。两者的分数含义和尺度不同。Elastic 排名说明

可以设想查询“连接报错 ZX-417,重试还是重新登录”:关键词分支有机会精确抓住型号与错误码,语义分支有机会找到没有原样复述问题的处理说明。这是可能的互补,必须用自己的查询验证;分词、同义词扩展和 embedding 模型都会影响结果。

假设某条结果的关键词分数是 18,向量余弦相似度是 0.82,18+0.82 并没有天然的统计解释。按查询做归一化也会引入新问题:一个异常分数、一个很短的候选列表,都可能改变归一化后的间距。

RRF 提供一个容易检查的基线:只使用各路排名,不要求原始分数共享量纲。这会丢掉分数间距,既是便利,也是一种信息损失。

RRF 手算:来自几条列表和排第几名

对于文档 d,RRF 分数为:

score(d) = Σ 1 / (c + rank_i(d))。

排名从 1 开始;d 未出现在某条列表中,该列表贡献 0。c 是正的平滑常数。该方法来自 Cormack、Clarke 与 Büttcher 的研究;原论文实验采用 c=60。它是一个有出处的起点,不能替代当前任务的验证。RRF 原论文

使用本站构造的两张列表:关键词为 [A,B,C],向量为 [B,D,A]。令 c=60、W=3:

文档 关键词名次 向量名次 精确贡献 近似分数
A 1 3 1/61 + 1/63 0.032266
B 2 1 1/62 + 1/61 0.032522
C 3 未出现 1/63 0.015873
D 未出现 2 1/62 0.016129

融合结果为 B、A、D、C。B 两边都靠前,所以排第一。但这不证明 B 真正相关:如果两个检索器共享同一种偏差,一致意见仍可能出错。

当 c 较大,第一名与后面名次的单路贡献更接近;当 c 较小,靠前名次的优势更明显。改变 W 还会改变哪些文档能贡献分数,两者不能混为一谈。Elastic 的官方说明也分别定义了 rank_constant 与 rank_window_size;本文代码是教学实现,不是其 API 的逐项复刻。Elastic RRF 文档

同一路列表中一个 ID 只能出现一次。来自不同列表的同一 ID 则应合并贡献。文档版本、分块版本和 ID 规则必须统一,否则“同一段证据”可能被误当成不同对象。相邻分块内容相近也不等于 ID 重复;上下文去冗余需要单独处理。

重排读取的是查询与候选的关系

常见的双编码器分别编码查询和文档,便于预计算文档向量;Cross-Encoder 则将查询与候选文本一起处理,直接为这一对文本打分。后者常放在召回之后,以有限候选量控制计算成本。Sentence Transformers 检索与重排说明

这不是“再做一次 RRF”。RRF 汇总名次;文本重排模型重新读取内容。重排分数也不自动等于经过校准的相关概率,更不能直接证明答案受到证据支持。

本篇不下载重排模型,而是提供一组人工指定的演示分数:A=0.96、D=0.85、B=0.20。它们只用于验证“融合候选如何进入下一阶段”;没有任何真实模型性能含义。

步骤 结果 此时能做什么
RRF 融合 B、A、D、C 形成统一候选次序
M=3 截断 B、A、D C 已不在重排输入中
按演示分数重排 A、D、B 重新决定这三个候选的顺序
K=2 返回 A、D 用两条证据继续下游任务

如果 M=1,重排只得到 B,无论模型多强,也不能把缺席的 A、D 排进结果。这是流程约束,不是对任何具体模型的能力判断。

可运行的 Python 标准库实验

将下面整段保存为 Python 文件直接运行。排名 ID 使用非空字符串;每路禁止重复;非法预算和非有限重排分数会被拒绝。为了让手算中的精确同分不会受浮点累加顺序影响,教学代码用 Fraction 排序,输出时再转换为小数。生产规模可以使用经过测试的浮点实现,不宜直接把有理数计算当成低延迟方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import json
import math
from fractions import Fraction


def integer(value, name, minimum):
if type(value) is not int or value < minimum:
raise ValueError(name + " must be an integer >= " + str(minimum))


def ids(values):
if not isinstance(values, (list, tuple)):
raise ValueError("expected an ID list")
if any(not isinstance(v, str) or not v for v in values):
raise ValueError("IDs must be nonempty strings")
if len(set(values)) != len(values):
raise ValueError("duplicate ID within one list")
return list(values)


def rrf(rankings, c=60, window=100, top_k=10):
integer(c, "c", 1)
integer(window, "window", 1)
integer(top_k, "top_k", 0)
if not isinstance(rankings, (list, tuple)):
raise ValueError("expected a list of rankings")
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ids(ranking)[:window], 1):
scores[doc_id] = scores.get(doc_id, Fraction(0)) + Fraction(1, c + rank)
ordered = sorted(scores, key=lambda d: (-scores[d], d))[:top_k]
return [{"id": d, "score": float(scores[d])} for d in ordered]


def rerank(candidates, supplied_scores, top_k):
integer(top_k, "top_k", 0)
candidates = ids(candidates)
if not isinstance(supplied_scores, dict) or set(supplied_scores) != set(candidates):
raise ValueError("one score is required for exactly each candidate")
for score in supplied_scores.values():
if type(score) not in (int, float):
raise ValueError("invalid reranking score")
try:
finite = math.isfinite(score)
except OverflowError:
finite = False
if not finite:
raise ValueError("invalid reranking score")
return sorted(candidates, key=lambda d: (-supplied_scores[d], d))[:top_k]


def metrics(ranked, grades, k):
integer(k, "k", 0)
ranked = ids(ranked)
if not isinstance(grades, dict):
raise ValueError("grades must be a mapping")
for doc_id, grade in grades.items():
if not isinstance(doc_id, str) or not doc_id:
raise ValueError("invalid grade ID")
integer(grade, "grade", 0)
if grade > 3:
raise ValueError("grades must be between 0 and 3")
relevant = {d for d, grade in grades.items() if grade > 0}
if not relevant:
return {"recall": None, "rr": None, "ndcg": None}
selected = ranked[:k]
hits = sum(d in relevant for d in selected)
rr = next((1 / rank for rank, d in enumerate(selected, 1) if d in relevant), 0.0)
dcg = sum((2 ** grades.get(d, 0) - 1) / math.log2(rank + 1)
for rank, d in enumerate(selected, 1))
ideal = sorted(grades.values(), reverse=True)[:k]
idcg = sum((2 ** grade - 1) / math.log2(rank + 1)
for rank, grade in enumerate(ideal, 1))
return {"recall": hits / len(relevant), "rr": rr,
"ndcg": dcg / idcg if idcg else 0.0}


if __name__ == "__main__":
lexical = ["A", "B", "C"]
dense = ["B", "D", "A"]
fused = rrf([lexical, dense], c=60, window=3, top_k=4)
candidates = [row["id"] for row in fused[:3]]
demo_scores = {"A": 0.96, "B": 0.20, "D": 0.85}
final = rerank(candidates, demo_scores, top_k=2)
grades = {"A": 3, "B": 0, "C": 0, "D": 2}
systems = {"lexical": lexical, "dense": dense,
"rrf": [row["id"] for row in fused], "demo_rerank": final}
print(json.dumps({"fused": fused, "candidates": candidates, "final": final,
"at_2": {name: metrics(order, grades, 2)
for name, order in systems.items()}}, indent=2))

代码只实现融合、对已提供分数的排序和评测,没有实现 BM25、embedding 推理或 Cross-Encoder。demo_scores 与 grades 都是人工构造的,不能作为训练后模型的测试成绩。

排名总输入长度为 N、窗口内不同 ID 数为 U 时,融合最多累加 N 次、排序 O(U log U),另有有理数运算成本。窗口截断前仍验证完整输入,所以校验成本按完整列表计。实际部署还需要计入检索、模型推理、文本传输与分块长度带来的成本。

三个指标分别看什么

本例的标注是 A=3、D=2、其余为 0。Recall 把等级大于 0 的项目当作相关;nDCG 保留等级强弱。这里的评测单位是稳定的段落 ID,不能把文档级标签直接套在分块级结果上。

指标 本文约定 能回答的问题
Recall@K 前 K 条命中的相关 ID 数 / 全部已标相关 ID 数 已知证据覆盖了多少?
RR@K 首个相关结果名次的倒数,未命中为 0 第一条有用结果排得多靠前?
nDCG@K 增益为 2^grade−1,按 log2(rank+1) 折扣后除以理想值 更重要的证据是否排得更前?

多条查询的 RR 平均值才是 MRR。本文对没有任何正相关标注的查询返回 None,提示它们不进入这些相关性指标的普通均值;应另外评价无答案查询的拒答或误报情况。K=0 且有正相关标注时,三个结果都按 0 处理。

系统,K=2 返回前两条 Recall RR nDCG,约值
关键词列表 A、B 0.5 1 0.787
向量列表 B、D 0.5 0.5 0.213
RRF B、A 0.5 0.5 0.497
人工分数重排 A、D 1 1 1

这个手算里,RRF 的 nDCG 甚至低于关键词列表。因此“加上融合必然提升”已经被示例本身否定。最后一行的满分是人工安排出来的流程演示,不能用来证明重排模型有效。

离线比较怎样避免只看一个漂亮例子

先固定语料快照、分块规则、权限范围与标注单位,再按精确术语、同义改写、组合条件、无答案查询分别检查。参数选择使用开发集,最后报告独立测试集;不要边看测试集失败例边调参数,再把同一集合称作未见测试。

下面是实验设计建议,不是已经测得的结果:

比较组 保持不变 需要记录
关键词 / 向量 / RRF 同一查询、语料和权限范围 Recall@M、RR@K、nDCG@K
RRF / RRF+真实重排 同一前 M 个候选 排序收益、退化查询、延迟
不同 W 固定模型、M、K 候选并集覆盖与成本
不同 M 固定召回结果、K 截断损失与重排耗时

候选集合大小增加时,并集的覆盖可能提高,但融合后的固定前 M 名可能变化,最终指标并不保证单调提高。重排模型有输入长度限制时,还应记录候选截断:正确句子可能在段落尾部,模型实际看到的内容与检索返回的内容未必一致。

未标注文档在本代码中按 0 处理,只是一个显式约定;真实标注不完整时,“未标”不等于“已证实不相关”。报告要说明标注范围,否则不同系统发现的新结果可能受到不公平惩罚。

接回 RAG 的证据链

检索返回一段高分文本,仅表示它在当前排序规则下靠前。接下来仍要检查问题的时间范围、实体、限制条件,以及回答的每项主张是否由该段内容支持。可以接着读RAG 知识库评测,把检索覆盖与最终回答可信度分别记录。

一个实用的排障顺序是:先确认资料确实已入库且可访问,再查召回、融合截断、重排、上下文组织,最后查生成。保存各阶段 ID 和版本,才容易判断故障发生在哪里。

资料与验证范围

资料核对日期:2026-10-04。本站验证脚本会执行本文代码,用独立有理数参考计算核对融合排名,并检查窗口、同分、重复 ID、非法分数、指标边界和重排无法补回缺失候选的性质。验证证明实现符合本文约定,不代表已经完成真实知识库上的效果实验。