AI 工程基础体系 · 第 89/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

混合检索:BM25、向量、过滤、融合排序和权重调优

在 RAG(Retrieval-Augmented Generation,检索增强生成)系统中,生成模型并不直接“知道”企业内部文档。典型流程是先根据用户问题检索候选片段,再把候选片段作为上下文交给生成模型。RAG 原始论文将这一过程描述为“参数化记忆”和“外部非参数化记忆”的结合:模型参数保存通用能力,检索库保存可更新的知识(Retrieval-Augmented Generation Paper)。

检索质量决定了生成模型能看到什么。一个答案生成得很流畅,并不意味着检索正确;如果检索阶段漏掉了关键文档,生成模型通常只能基于错误或不完整的上下文进行推断。因此,生产级 RAG 很少只依赖一种检索方式,而是将:

  • BM25:基于词项匹配的稀疏检索;
  • 向量检索:基于语义表示相似度的稠密检索;
  • 过滤:基于元数据、租户、权限和时间等条件的硬约束;
  • 融合排序:合并不同检索器的候选结果;
  • 权重调优:根据离线评测和线上反馈确定融合策略;

放在同一个检索系统中设计。


一、先明确检索系统要优化什么

给定用户查询 qq,文档集合为:

D={d1,d2,,dn}D=\{d_1,d_2,\ldots,d_n\}

检索器的目标不是简单地找“最相似”的文档,而是在有限的上下文窗口、延迟和成本约束下,把与问题相关、用户有权访问、能够支持答案的文档排在前面。

可以把一个候选文档的有效性拆成几个条件:

Useful(d,q,u)=Relevant(d,q)Accessible(d,u)FreshEnough(d)\text{Useful}(d,q,u) = \text{Relevant}(d,q) \land \text{Accessible}(d,u) \land \text{FreshEnough}(d)

其中:

  • Relevant(d,q)\text{Relevant}(d,q):文档是否回答当前问题;
  • Accessible(d,u)\text{Accessible}(d,u):用户 uu 是否有权限访问;
  • FreshEnough(d)\text{FreshEnough}(d):文档是否满足时效要求。

这里的“相关”还可以细分为:

  1. 主题相关:文档讨论了用户询问的对象;
  2. 事实相关:文档包含问题所需的具体事实;
  3. 证据充分:文档能够支撑生成答案,而不仅仅是提到几个关键词;
  4. 粒度匹配:文档片段足够完整,没有因为切块而失去条件、例外或结论。

BM25 和向量检索主要解决第一类和第二类问题,但它们不能替代权限校验,也不能自动保证证据充分。过滤和后续重排序必须参与整个流程。


二、BM25:基于词项匹配的稀疏检索

2.1 BM25 解决什么问题

BM25 是一种经典的词项检索函数。它判断查询中的词是否出现在文档中,并根据以下因素计算相关性:

  • 查询词是否出现在文档中;
  • 该词在文档中出现了多少次;
  • 该词在整个语料库中是否稀有;
  • 文档是否过长;
  • 查询词频是否需要饱和。

“稀疏”意味着文档可以表示成一个高维词项空间中的稀疏向量:大多数词项的权重为零。BM25 不需要训练神经网络,因此具有可解释、成本低、对精确术语敏感等特性。

例如,用户查询:

PostgreSQL 16 logical replication slot

BM25 能够直接利用 PostgreSQLlogical replication slot 等词项。即使向量模型没有充分理解某个新版本术语,BM25 仍可能准确命中包含该字符串的文档。


2.2 BM25 的公式

常见的 BM25 形式为:

BM25(q,d)=tqIDF(t)f(t,d)(k1+1)f(t,d)+k1(1b+bdavgdl)\text{BM25}(q,d) = \sum_{t\in q} \text{IDF}(t) \cdot \frac{f(t,d)(k_1+1)} {f(t,d)+k_1\left(1-b+b\frac{|d|}{\text{avgdl}}\right)}

其中:

  • qq:查询;
  • dd:文档;
  • tt:查询中的词项;
  • f(t,d)f(t,d):词项 tt 在文档 dd 中出现的次数;
  • d|d|:文档长度;
  • avgdl\text{avgdl}:语料库中文档平均长度;
  • k1k_1:词频饱和参数;
  • bb:文档长度归一化参数;
  • IDF(t)\text{IDF}(t):词项逆文档频率。

一种常见的 IDF 定义为:

IDF(t)=ln(Ndf(t)+0.5df(t)+0.5)\text{IDF}(t) = \ln \left( \frac{N-\text{df}(t)+0.5} {\text{df}(t)+0.5} \right)

其中:

  • NN:文档总数;
  • df(t)\text{df}(t):包含词项 tt 的文档数。

IDF 的直觉是:一个词出现在所有文档中,就没有区分度;只出现在少数文档中,则更能说明文档与查询相关。


2.3 词频为什么要“饱和”

如果一个文档中出现一次“退款”,它可能已经说明文档与退款有关。出现 20 次并不意味着相关性是出现一次的 20 倍。因此 BM25 对词频使用饱和函数。

忽略长度归一化时,词频部分近似为:

g(f)=f(k1+1)f+k1g(f)=\frac{f(k_1+1)}{f+k_1}

k1=1.2k_1=1.2

  • f=1f=1 时,g(f)=1g(f)=1
  • f=2f=2 时,g(f)1.375g(f)\approx1.375
  • f=10f=10 时,g(f)1.964g(f)\approx1.964

词频从 1 增加到 2 有明显收益,从 2 增加到 10 的收益则逐渐变小。这避免了长文档因为重复出现同一术语而无限领先。


2.4 文档长度归一化

如果只按词频计分,长文档通常更容易包含查询词。BM25 使用:

1b+bdavgdl1-b+b\frac{|d|}{\text{avgdl}}

调整长度:

  • d=avgdl|d|=\text{avgdl} 时,长度因子为 1;
  • 当文档比平均长度长时,分母变大,得分降低;
  • 当文档比平均长度短时,分母变小,单位词频贡献变大。

参数 bb 越大,长度归一化越强。常见实现会使用 k1k_1bb 的默认值,但默认值并不适合所有语料:

  • FAQ 或短条款集合的文档长度差异小,bb 的影响有限;
  • 长篇技术文档与短代码片段混合时,长度归一化会明显影响结果;
  • 中文分词、英文 tokenization、代码符号切分方式变化后,文档长度的统计含义也会变化。

因此不能把 BM25 参数视为与数据无关的常量。


2.5 一个完整的 BM25 计算例子

假设语料库有 N=100N=100 篇文档,平均长度 avgdl=100\text{avgdl}=100。查询为:

退款 延迟

设置:

k1=1.2,b=0.75k_1=1.2,\qquad b=0.75

假设:

  • “退款”出现在 10 篇文档中;
  • “延迟”出现在 5 篇文档中;
  • 文档 d1d_1 长度为 100;
  • d1d_1 中“退款”出现 1 次,“延迟”出现 1 次。

两个词的 IDF 分别为:

IDF(退款)=ln10010+0.510+0.5=ln90.510.52.154\text{IDF}(\text{退款}) = \ln\frac{100-10+0.5}{10+0.5} = \ln\frac{90.5}{10.5} \approx2.154

IDF(延迟)=ln1005+0.55+0.5=ln95.55.52.854\text{IDF}(\text{延迟}) = \ln\frac{100-5+0.5}{5+0.5} = \ln\frac{95.5}{5.5} \approx2.854

由于 d1d_1 的长度等于平均长度:

1b+bd1avgdl=10.75+0.75×1=11-b+b\frac{|d_1|}{\text{avgdl}} = 1-0.75+0.75\times1=1

每个词的词频部分为:

1(1.2+1)1+1.2×1=2.22.2=1\frac{1(1.2+1)}{1+1.2\times1} = \frac{2.2}{2.2} =1

所以:

BM25(q,d1)=2.154+2.854=5.008\text{BM25}(q,d_1) = 2.154+2.854 = 5.008

再看文档 d2d_2

  • 长度为 120;
  • “退款”出现 2 次;
  • 不包含“延迟”。

长度因子为:

10.75+0.75120100=1.151-0.75+0.75\frac{120}{100} = 1.15

“退款”的词频部分为:

2(2.2)2+1.2×1.15=4.43.381.302\frac{2(2.2)} {2+1.2\times1.15} = \frac{4.4}{3.38} \approx1.302

因此:

BM25(q,d2)2.154×1.302=2.805\text{BM25}(q,d_2) \approx 2.154\times1.302 = 2.805

虽然 d2d_2 中“退款”出现了两次,但它缺少“延迟”,所以仍然低于同时包含两个查询词的 d1d_1

这个例子说明,BM25 的得分不是“匹配词数量”的简单计数,而是词项稀有度、词频饱和和长度归一化共同作用的结果。


2.6 BM25 的边界

BM25 的强项是精确匹配,但它不天然理解同义表达:

  • 查询是“如何取消订单”,文档写的是“撤销购买”;
  • 查询是“数据库连接池耗尽”,文档写的是“connection pool saturation”;
  • 查询使用新产品名,文档使用旧产品名。

除非分词器、同义词表或扩展查询处理了这些关系,否则 BM25 可能漏检。

BM25 还有几个容易被忽略的问题:

  1. 分词质量决定上限
    中文必须考虑分词、词典和专有名词;英文要处理大小写、词干化和连字符;代码则可能需要保留标识符和符号。

  2. 短词可能产生噪声
    “AI”“云”“网”等高频或歧义词可能贡献有限或带来大量候选。

  3. 拼写错误不容易处理
    PostgresPostgreSQL 是否等价,取决于索引和查询分析配置。

  4. 跨语言语义匹配能力弱
    中文查询和英文文档之间,BM25 通常需要翻译、别名或多语言分析器支持。

因此,BM25 不是向量检索的过渡技术,而是对精确术语、编号、版本号、错误码和实体名仍然非常重要的一类检索器。


三、向量检索:基于语义表示的稠密检索

3.1 向量检索的基本过程

向量检索使用 embedding 模型将查询和文档映射到同一个向量空间:

eq=fθ(q),ed=fθ(d)\mathbf{e}_q=f_{\theta}(q),\qquad \mathbf{e}_d=f_{\theta}(d)

其中:

  • fθf_{\theta}:embedding 模型;
  • eq\mathbf{e}_q:查询向量;
  • ed\mathbf{e}_d:文档向量。

检索时计算查询向量和文档向量之间的相似度。常用度量包括:

余弦相似度

cos(eq,ed)=eqedeqed\text{cos}(\mathbf{e}_q,\mathbf{e}_d) = \frac{\mathbf{e}_q\cdot\mathbf{e}_d} {\|\mathbf{e}_q\|\|\mathbf{e}_d\|}

它只关注方向,不关注向量长度。若向量已经归一化,则余弦相似度等于点积。

点积

dot(eq,ed)=eqed\text{dot}(\mathbf{e}_q,\mathbf{e}_d) = \mathbf{e}_q\cdot\mathbf{e}_d

适合某些训练目标,但向量长度可能影响结果。

欧氏距离

eqed2\|\mathbf{e}_q-\mathbf{e}_d\|_2

在向量已经归一化的条件下,欧氏距离与余弦相似度之间存在单调关系;未归一化时则不等价。

使用哪种距离必须与 embedding 模型的训练和向量数据库索引配置保持一致。不能先用点积建库,再在查询阶段把结果解释为余弦相似度。


3.2 向量检索为什么能召回同义表达

如果 embedding 模型在训练中见过大量语义相近的表达,它可能把:

  • “取消订阅”
  • “停止续费”
  • “关闭自动续费”

映射到相近区域。这样,即使文档和查询没有共享完全相同的词项,向量检索仍可能召回它。

不过,向量相似并不等于事实相关。一个关于“如何申请退款”的文档,可能与“退款是否需要审批”的问题语义相近,却不能直接回答审批规则。向量检索擅长语义召回,不保证精确事实匹配。


3.3 近似最近邻和召回率

对每个查询都与全部文档计算距离,复杂度大致为:

O(Nd)O(Nd)

其中 NN 是文档数,dd 是向量维度。大规模系统通常使用 ANN(Approximate Nearest Neighbor,近似最近邻)索引,例如图索引或倒排聚类索引。

ANN 用少量搜索代价换取可接受的召回率。这里有两个不同的“召回”概念:

  1. 检索任务的 Recall@k:前 kk 个结果中是否包含相关文档;
  2. ANN 索引召回率:近似索引找到的真实近邻比例。

如果 ANN 搜索参数过低,向量模型本身可能是正确的,但索引搜索没有找到应有的近邻。诊断时需要用精确暴力搜索作为基线,区分模型问题和 ANN 参数问题。


3.4 向量检索的失败模式

向量检索常见的失败表现包括:

  • 召回主题相近但版本错误的文档;
  • 把“如何配置”与“为什么失败”混为一类;
  • 对精确错误码、订单号、函数名召回不足;
  • 受文档切块影响,召回片段缺失前置条件;
  • embedding 模型对内部缩写、产品名和领域术语理解不足;
  • 查询和文档语言不同,模型的跨语言能力不足;
  • 文档更新后向量没有重建,导致语义索引陈旧。

因此,向量检索通常需要 BM25、元数据过滤和后续重排序共同约束。


四、BM25 与向量检索的互补关系

可以把两种检索器的能力抽象为:

场景 BM25 向量检索
错误码、订单号、版本号 可能弱
精确产品名和 API 名称 取决于模型
同义表达
口语化问题 中等 通常更强
代码和配置键 可能产生语义近似噪声
跨语言语义匹配 取决于多语言模型
新术语、内部缩写 需要词典 需要模型或微调支持
解释精确词项来源 清晰 较难解释

两者的结果集并不是简单的重复。对查询:

Redis ERR max number of clients reached 如何处理

BM25 可以通过错误字符串直接命中故障手册;向量检索可能召回“Redis 连接数配置”“连接池耗尽”“客户端限制”等语义相关文档。合并后,系统既能保留精确错误信息,又能覆盖问题的解释和处理方案。


五、过滤:不是排序,而是候选集合约束

5.1 过滤的形式化定义

过滤条件定义一个允许集合:

DF={dDF(d)=true}D_F=\{d\in D\mid F(d)=\text{true}\}

其中 F(d)F(d) 可以包含:

  • tenant_id = 当前租户
  • department in 用户部门集合
  • doc_type = policy
  • language = zh-CN
  • updated_at >= 某个时间
  • environment = production
  • classification <= 用户许可级别

理想情况下,检索应该在 DFD_F 上进行:

Retrieve(q,DF)\operatorname{Retrieve}(q,D_F)

而不是先在整个 DD 上取结果,再试图删除无权文档。


5.2 预过滤与后过滤

预过滤

先应用过滤条件,再执行 BM25 或向量检索:

用户身份
  -> 计算权限和元数据条件
  -> 在满足条件的文档集合中检索
  -> 融合排序
  -> 重排序

优点:

  • 不会把明显不符合条件的文档计入候选;
  • 对权限隔离更安全;
  • 当过滤条件选择性高时,可以显著减少搜索空间。

缺点:

  • 过滤条件复杂时,索引实现困难;
  • 允许集合太小或太碎时,ANN 搜索可能退化;
  • 某些系统的向量索引只支持有限的元数据过滤表达式。

后过滤

先检索,再过滤:

全库检索 top-K
  -> 删除不满足条件的文档
  -> 返回剩余结果

它实现简单,但有一个直接的数量问题:如果先取 10 条,其中 8 条因权限或部门条件被删除,最终可能只剩 2 条。扩大初始候选数可以缓解数量不足,但不能弥补候选集合本身没有覆盖目标文档的情况。

更严重的是,权限过滤不能依赖普通后过滤。如果无权限文档已经进入日志、缓存、调试输出、重排序模型或生成模型上下文,就可能造成信息泄露。权限条件应尽可能在检索前生效,并在返回前进行第二次防御性校验。


5.3 过滤选择性与检索质量

定义过滤选择性:

s=DFDs=\frac{|D_F|}{|D|}

ss 很高时,例如只过滤一个常见文档类型,预过滤和后过滤的差别可能不大。

ss 很低时,例如一个用户只能访问全库的 0.1%0.1\%,后过滤通常会产生:

  • 候选数量不足;
  • 相关文档排名被无权文档占用;
  • 需要过度扩大 KK,导致延迟和成本上升;
  • 融合时不同检索器对有效文档的覆盖不稳定。

因此,租户、用户组和 ACL 等高选择性条件,应成为索引分区或原生过滤条件的一部分,而不是事后清理。


5.4 过滤与 BM25、向量检索的交互

过滤不是只应用于某一个检索器。一个一致的流程应当是:

Cb=BM25(q,DF,Kb)C_b=\operatorname{BM25}(q,D_F,K_b)

Cv=Vector(q,DF,Kv)C_v=\operatorname{Vector}(q,D_F,K_v)

C=CbCvC=C_b\cup C_v

如果 BM25 使用了权限过滤,而向量检索没有使用,融合阶段仍然可能把无权文档带入候选。反过来也一样。

需要区分两种过滤:

  • 安全过滤:租户、ACL、密级,违反后会造成数据泄露;
  • 质量过滤:语言、文档类型、时间范围,违反后主要影响相关性。

安全过滤必须强制执行;质量过滤则可以在特定场景下作为可配置召回策略。


六、融合排序:如何合并两个不同的分数体系

BM25 分数和向量相似度通常不能直接相加。

原因是:

  • BM25 分数受查询词数量、文档长度和语料统计影响;
  • 向量相似度通常落在固定或近似固定区间;
  • 不同查询的 BM25 分数分布可能完全不同;
  • 不同 embedding 模型的相似度分布也不同。

例如一次查询的 BM25 分数可能是:

[8.0,5.0,2.0,0.0][8.0,5.0,2.0,0.0]

另一次查询可能是:

[1.2,1.1,0.9,0.8][1.2,1.1,0.9,0.8]

如果直接把它们与向量分数相加,权重的含义会随查询变化。


6.1 基于分数归一化的加权融合

一种简单方法是先把每个检索器的分数归一化,再计算加权和。

Min-Max 归一化为:

s^i(d)=si(d)min(si)max(si)min(si)\hat{s}_i(d) = \frac{s_i(d)-\min(s_i)} {\max(s_i)-\min(s_i)}

其中 sis_i 是第 ii 个检索器的原始分数。

两个检索器的融合分数为:

S(d)=αs^BM25(d)+(1α)s^vec(d)S(d) = \alpha \hat{s}_{\text{BM25}}(d) + (1-\alpha)\hat{s}_{\text{vec}}(d)

α[0,1]\alpha\in[0,1] 表示归一化后 BM25 的权重。

完整算例

四个文档的结果如下:

文档 BM25 分数 向量分数
A 8.0 0.4
B 5.0 1.0
C 2.0 0.7
D 0.0 0.1

BM25 的最小值为 0,最大值为 8,因此归一化后:

s^b=[1, 0.625, 0.25, 0]\hat{s}_{b}=[1,\ 0.625,\ 0.25,\ 0]

向量分数的最小值为 0.1,最大值为 1.0,因此:

s^v=[0.40.10.9,1.00.10.9,0.70.10.9,0]=[0.333, 1, 0.667, 0]\hat{s}_{v} = \left[ \frac{0.4-0.1}{0.9}, \frac{1.0-0.1}{0.9}, \frac{0.7-0.1}{0.9}, 0 \right] = [0.333,\ 1,\ 0.667,\ 0]

如果 α=0.5\alpha=0.5,则:

S(A)=0.5×1+0.5×0.333=0.667S(A)=0.5\times1+0.5\times0.333=0.667

S(B)=0.5×0.625+0.5×1=0.813S(B)=0.5\times0.625+0.5\times1=0.813

S(C)=0.5×0.25+0.5×0.667=0.458S(C)=0.5\times0.25+0.5\times0.667=0.458

S(D)=0S(D)=0

最终顺序为:

B>A>C>DB>A>C>D

BM25 单独排序是:

A>B>C>DA>B>C>D

向量单独排序是:

B>C>A>DB>C>A>D

融合后,B 获得了语义检索的优势,A 仍然保留了精确词项匹配的优势。

Min-Max 的风险

Min-Max 归一化依赖当前候选集合的极值。如果某个查询出现异常高分,其他文档的归一化分数会被压缩。候选数量变化也会改变极值,从而改变同一文档的融合分数。

因此,Min-Max 适合简单实验和分布稳定的场景,但不应未经验证地视为统一解决方案。生产系统也可能使用:

  • Z-score;
  • 百分位归一化;
  • 基于验证集估计的分布校准;
  • 对每个检索器输出 rank 而不是原始分数。

6.2 RRF:基于名次的融合

RRF(Reciprocal Rank Fusion)不直接比较分数,而是比较文档在各个结果列表中的名次:

RRF(d)=rR1k+rankr(d)\text{RRF}(d) = \sum_{r\in R} \frac{1}{k+\operatorname{rank}_r(d)}

其中:

  • RR:检索器集合;
  • rankr(d)\operatorname{rank}_r(d):文档 dd 在检索器 rr 中的名次,从 1 开始;
  • kk:平滑常数,常见实现会使用一个较大的正数。

继续使用上面的排序:

  • BM25:A、B、C、D;
  • 向量:B、C、A、D。

k=60k=60

RRF(A)=161+1630.03226\text{RRF}(A)=\frac1{61}+\frac1{63}\approx0.03226

RRF(B)=162+1610.03252\text{RRF}(B)=\frac1{62}+\frac1{61}\approx0.03252

RRF(C)=163+1620.03200\text{RRF}(C)=\frac1{63}+\frac1{62}\approx0.03200

RRF(D)=164+164=0.03125\text{RRF}(D)=\frac1{64}+\frac1{64}=0.03125

所以顺序为:

B>A>C>DB>A>C>D

RRF 的优点是避免了 BM25 和向量分数的量纲问题,并且对“多个检索器都认为重要”的文档较友好。它的缺点是丢失了原始分数的距离信息:

  • 第一名和第二名可能只差一点;
  • 也可能第一名远远领先第二名;
  • RRF 对这两种情况的处理相近。

RRF 适合作为稳健基线,尤其是在尚未建立可靠分数校准和标注数据时。


6.3 一个可执行的融合示例

下面的代码只依赖 Python 标准库,演示 Min-Max 加权融合和 RRF。它不执行 BM25 或向量搜索,而是假设两个检索器已经返回文档分数。

from typing import Dict, List, Tuple

bm25 = {
    "A": 8.0,
    "B": 5.0,
    "C": 2.0,
    "D": 0.0,
}

vector = {
    "A": 0.4,
    "B": 1.0,
    "C": 0.7,
    "D": 0.1,
}


def min_max(scores: Dict[str, float]) -> Dict[str, float]:
    values = list(scores.values())
    low, high = min(values), max(values)

    # 所有分数相同,说明该检索器没有区分度。
    # 此时统一返回 0,避免除零;也可以根据业务改为 1。
    if high == low:
        return {doc_id: 0.0 for doc_id in scores}

    return {
        doc_id: (score - low) / (high - low)
        for doc_id, score in scores.items()
    }


def weighted_fusion(
    bm25_scores: Dict[str, float],
    vector_scores: Dict[str, float],
    alpha: float = 0.5,
) -> List[Tuple[str, float]]:
    bm25_norm = min_max(bm25_scores)
    vector_norm = min_max(vector_scores)

    doc_ids = set(bm25_scores) | set(vector_scores)
    fused = {}

    for doc_id in doc_ids:
        b = bm25_norm.get(doc_id, 0.0)
        v = vector_norm.get(doc_id, 0.0)
        fused[doc_id] = alpha * b + (1 - alpha) * v

    return sorted(fused.items(), key=lambda x: (-x[1], x[0]))


def rrf(
    ranked_lists: List[List[str]],
    k: int = 60,
) -> List[Tuple[str, float]]:
    scores = {}

    for ranked_list in ranked_lists:
        for rank, doc_id in enumerate(ranked_list, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)

    return sorted(scores.items(), key=lambda x: (-x[1], x[0]))


print("weighted:", weighted_fusion(bm25, vector, alpha=0.5))
print("rrf:", rrf([
    ["A", "B", "C", "D"],
    ["B", "C", "A", "D"],
]))

预期输出的核心顺序是:

weighted: B, A, C, D
rrf: B, A, C, D

实际浮点数会包含小数误差。代码中对所有分数相同的情况进行了处理;如果某个检索器返回的候选只有一个,或者所有候选分数完全一致,Min-Max 归一化本身没有信息可以利用,不能人为制造排序差异。


6.4 融合不是重排序

融合排序和重排序是两个不同阶段。

融合排序

作用是合并多个初始检索器:

BM25 top 50
向量 top 50
        ↓
并集、去重、融合
        ↓
候选 top 50 或 top 100

重排序

通常使用更复杂的模型,对查询和候选文本进行联合判断。例如交叉编码器可以输入:

(q,d)(q,d)

直接预测相关性,而不是分别编码查询和文档。它的表达能力通常更强,但计算成本更高,因此只适合处理较小的候选集。

一个常见的三级结构是:

第一阶段:BM25 + 向量召回,扩大覆盖面
第二阶段:融合排序,形成候选集
第三阶段:交叉编码器或规则重排,优化前几名

不能把“调大 BM25 权重”当成“增加重排序能力”。权重只能改变已有候选的相对顺序,不能召回任何一个原本没有进入候选集合的文档。


七、权重调优:从经验参数到可验证的选择

7.1 权重到底影响什么

以:

S(d)=αs^b(d)+(1α)s^v(d)S(d)=\alpha \hat{s}_b(d)+(1-\alpha)\hat{s}_v(d)

为例,α\alpha 越大,系统越偏向 BM25;越小,越偏向向量检索。

但它不是“BM25 可信度为 0.7、向量可信度为 0.3”的概率解释。它只表示在当前分数校准方法下,两种信号对最终排序的相对贡献。

权重是否合理,取决于:

  • 查询类型;
  • 语料领域;
  • 分词质量;
  • embedding 模型;
  • 文档切块策略;
  • 过滤后的集合规模;
  • 评测指标;
  • 下游生成模型对上下文的使用方式。

7.2 建立可调优的数据集

调权重前需要一组查询—文档标注数据。最简单的格式是:

query_id query doc_id relevance
q1 如何取消自动续费 d10 3
q1 退款审批规则 d11 0
q1 订阅管理说明 d12 2

相关性等级需要先定义,例如:

  • 3:直接、完整回答问题;
  • 2:部分回答或提供重要背景;
  • 1:主题相关但不能支撑答案;
  • 0:无关或不应使用。

如果系统带权限,还必须在评测样本中记录用户身份或权限条件。否则,离线评测可能把用户无权访问的文档当作“正确答案”,导致调优结果与生产安全策略冲突。


7.3 选择评测指标

Recall@K

Recall@K=前 K 个结果中的相关文档数所有相关文档数\text{Recall@K} = \frac{\text{前 K 个结果中的相关文档数}} {\text{所有相关文档数}}

它适合评估第一阶段召回是否漏掉证据。

Precision@K

Precision@K=前 K 个结果中的相关文档数K\text{Precision@K} = \frac{\text{前 K 个结果中的相关文档数}}{K}

它关注候选中噪声多少。

MRR

对于每个查询只关心第一个相关结果:

MRR=1QqQ1rankq\text{MRR} = \frac1{|Q|} \sum_{q\in Q} \frac1{\operatorname{rank}_q}

适合 FAQ、单事实问答等“尽快找到一个正确文档”的场景。

nDCG@K

当相关性有多个等级时,nDCG 比二元命中更合适:

DCG@K=i=1K2reli1log2(i+1)\text{DCG@K} = \sum_{i=1}^{K} \frac{2^{rel_i}-1}{\log_2(i+1)}

再除以理想排序下的 DCG,得到 nDCG。它会同时考虑相关性等级和排名位置。

对 RAG 来说,通常至少要同时观察:

  • 召回 Recall@K;
  • 排序 nDCG@K;
  • 最终上下文中的证据覆盖率;
  • 生成答案的事实正确性;
  • 引用是否真正支持答案;
  • 延迟和成本。

只看最终生成答案,无法区分是检索失败还是生成模型使用上下文失败。


7.4 网格搜索一个融合权重

如果使用单一全局权重,可以先做简单网格搜索:

α{0,0.1,0.2,,1.0}\alpha\in\{0,0.1,0.2,\ldots,1.0\}

每个候选 α\alpha 都在同一个验证集上计算 nDCG@10 或 Recall@50,选择验证集表现最好的参数,再在独立测试集上确认。

伪代码如下:

best_alpha = None
best_score = float("-inf")

for alpha in [i / 10 for i in range(11)]:
    rankings = []
    for query in validation_queries:
        bm25_results = retrieve_bm25(query, top_k=50)
        vector_results = retrieve_vector(query, top_k=50)

        candidates = fuse(
            bm25_results,
            vector_results,
            alpha=alpha,
        )
        rankings.append(candidates)

    score = ndcg_at_k(rankings, validation_labels, k=10)

    if score > best_score:
        best_score = score
        best_alpha = alpha

这里的关键不是搜索算法,而是评测流程必须固定:

  • 相同的查询集合;
  • 相同的权限过滤;
  • 相同的候选数量;
  • 相同的去重规则;
  • 相同的 chunk 版本;
  • 相同的文档新鲜度。

如果调权重时 BM25 使用了全库,而测试时使用了租户过滤,结果没有可比性。


7.5 为什么全局权重常常不够

不同查询需要不同的检索偏好。

精确查询

例如:

HTTP 429 rate_limit_exceeded

这类查询包含错误码和固定字符串,BM25 往往更可靠。

解释型查询

例如:

为什么批处理任务会在高峰期变慢

这类查询可能需要向量检索覆盖“资源争用”“吞吐下降”“队列堆积”等不同表达。

混合查询

例如:

Kafka 3.6 consumer group rebalance 原因

既有精确版本和组件名,又有语义问题,两个检索器都重要。

因此可以使用查询分类或查询特征进行动态调权:

α(q)=g(query features)\alpha(q)=g(\text{query features})

特征可以包括:

  • 查询中是否包含数字、版本号、错误码;
  • 查询长度;
  • 是否包含代码符号;
  • 是否命中内部术语词典;
  • BM25 和向量结果的分数间隔;
  • 两个检索器结果的重合率。

但动态调权也会增加调试难度。第一步通常应先建立可靠的全局基线,再确认分片调权确实带来稳定收益。


7.6 从权重融合到学习排序

当标注数据足够多时,可以训练 Learning to Rank(学习排序)模型。对每个候选文档构造特征:

x(q,d)=[sBM25,svector,rankBM25,rankvector,title match,freshness,metadata match]x(q,d)= [ s_{\text{BM25}}, s_{\text{vector}}, \operatorname{rank}_{\text{BM25}}, \operatorname{rank}_{\text{vector}}, \text{title match}, \text{freshness}, \text{metadata match} ]

然后学习:

y^=hϕ(x(q,d))\hat{y}=h_{\phi}(x(q,d))

模型可以是逻辑回归、梯度提升树或更复杂的排序模型。需要注意:

  • 训练标签必须来自真实相关性,而不能只来自现有检索器;
  • 训练、验证、测试要按查询划分,避免同一问题模板泄漏;
  • 权限特征不能被用来推断或绕过权限;
  • 文档新鲜度必须按照上线时可获得的信息构造,避免时间穿越;
  • 排序模型上线后要监控特征分布漂移。

学习排序并不会消除 BM25 和向量检索的召回边界。如果相关文档没有进入候选集,排序模型无法恢复它。


八、一个生产级混合检索数据流

混合检索通常包含离线索引和在线查询两条路径。

flowchart TD
    A[原始文档] --> B[解析与清洗]
    B --> C[切块]
    C --> D[生成元数据与 ACL]
    D --> E1[建立 BM25 倒排索引]
    D --> E2[生成 Embedding]
    E2 --> E3[建立向量索引]

    Q[用户查询] --> Q1[身份认证与权限解析]
    Q1 --> Q2[查询分析与过滤条件构造]
    Q2 --> R1[BM25 检索]
    Q2 --> R2[向量检索]
    R1 --> F[候选并集与去重]
    R2 --> F
    F --> F2[融合排序]
    F2 --> R3[可选重排序]
    R3 --> V[最终 ACL 校验与去重]
    V --> Ctx[上下文压缩与拼接]
    Ctx --> G[生成模型]
    G --> O[答案与引用]

8.1 离线索引阶段

原始文档进入索引前通常要经过:

  1. 解析 PDF、HTML、Markdown、Office 或数据库记录;
  2. 去除导航栏、页脚、重复模板等噪声;
  3. 按标题、段落、表格或代码结构切块;
  4. 为每个 chunk 保存 doc_id、标题、来源、版本、时间、租户和 ACL;
  5. 使用同一份规范化文本建立 BM25 索引;
  6. 使用 embedding 模型生成向量;
  7. 将文本索引、向量索引和元数据索引建立可关联关系。

doc_id 必须稳定。若每次重建向量后生成新的随机 ID,BM25 结果和向量结果就难以去重,文档更新、删除和权限撤销也会变得复杂。


8.2 文档更新与索引一致性

文档内容更新时,至少涉及三类状态:

源文档版本 V2
    -> chunk 版本 V2
    -> BM25 索引 V2
    -> 向量索引 V2
    -> 元数据/ACL V2

如果 BM25 已更新而向量仍是旧版本,融合结果可能同时包含新旧片段。如果 ACL 先撤销但向量索引尚未删除,在线路径必须依靠实时权限校验阻止旧向量结果返回。

常见的状态处理方式是:

  • 用文档版本号标记每个 chunk;
  • 先生成新版本索引;
  • 完成校验后切换索引别名或版本指针;
  • 删除旧版本;
  • 对权限变更使用更短的同步路径,必要时在查询时读取最新 ACL。

不要只依赖“最终一致”而忽略撤权场景。内容更新延迟主要影响新鲜度,权限更新延迟则可能影响安全性。


8.3 在线查询的并发

BM25 和向量检索通常可以并发执行:

bm25_future = executor.submit(
    retrieve_bm25, query, filters, 50
)
vector_future = executor.submit(
    retrieve_vector, query_vector, filters, 50
)

bm25_results = bm25_future.result(timeout=0.2)
vector_results = vector_future.result(timeout=0.2)

并发执行可以降低总延迟,但会引入故障路径:

  • BM25 超时,向量结果仍可降级返回;
  • 向量服务不可用,BM25 可以承担精确检索;
  • 两者都失败时,应返回明确的检索失败状态,而不是让生成模型假装有证据;
  • 一个检索器返回空结果,融合器必须处理,而不能因除零或空列表崩溃;
  • 任何一个服务的超时都不能无限拖延生成请求。

降级策略应与查询类型相关。对于错误码和 API 名称查询,BM25 失败的影响可能比向量检索失败更大;对于自然语言解释问题,情况可能相反。


九、候选集合、去重和文档粒度

9.1 为什么不能直接把两个 top-k 拼接

如果 BM25 和向量检索都返回同一文档的不同 chunk,直接拼接会造成:

  • 同一文档占据多个上下文位置;
  • 其他来源被挤出;
  • 生成模型重复看到近似内容;
  • token 成本增加;
  • 评测时看似命中多个结果,实际证据覆盖没有增加。

通常需要先按稳定键去重,例如:

key=(doc_id,chunk_id)\text{key}=(\text{doc\_id},\text{chunk\_id})

如果要限制同一文档最多出现 mm 个片段,则可以在融合后增加多样性约束:

#{di:doc_id=x}m\#\{d_i:\text{doc\_id}=x\}\le m

不过,完全按文档去重也可能损失证据。一个长文档的两个不同片段可能分别包含定义和例外条件。因此去重粒度应根据文档结构决定。


9.2 Chunk 大小与混合检索的关系

切块过小:

  • BM25 可能命中关键词,但片段缺乏上下文;
  • 向量表示不稳定;
  • 条件、例外和结论可能被分到不同片段。

切块过大:

  • BM25 的长度归一化可能降低精确词项的相对贡献;
  • 向量表示混入多个主题;
  • 单个候选消耗更多上下文窗口;
  • 重排序和生成成本上升。

应把“召回单位”和“展示单位”分开考虑:

  • 检索可以按较小 chunk 找到证据;
  • 返回上下文时补充父标题、相邻段落或表格上下文;
  • 引用仍然指向稳定的文档和段落位置。

十、过滤、权限与 RAG 安全边界

权限不是一个普通排序特征。下面两个文档即使语义和关键词都高度匹配,也不能因为得分高而返回:

d1: 财务部门的薪酬调整方案
d2: 全员可见的薪酬政策摘要

如果当前用户无权访问 d1d_1,则 d1d_1 必须从允许集合中删除,而不是给它一个很大的负分。原因是“排序后隐藏”容易在其他环节泄露:

  • 检索日志记录了标题;
  • 调试接口返回了候选;
  • 重排序服务收到完整文本;
  • 缓存键没有包含用户权限;
  • 生成上下文中残留被过滤的片段;
  • 引用生成器根据候选 ID 暴露文档存在性。

生产系统至少需要:

  1. 身份认证后构造用户权限上下文;
  2. 检索阶段执行租户和 ACL 过滤;
  3. 融合和重排序只处理允许集合;
  4. 输出前再次校验权限;
  5. 缓存必须绑定租户、用户组或权限版本;
  6. 文档删除和撤权要有可验证的传播路径;
  7. 审计日志记录“使用了哪个版本的权限快照”。

如果权限条件变化频繁,最好将权限过滤设计为检索基础设施的一等能力,而不是在 RAG 应用代码里散落多个 if 判断。


十一、权重调优之外的关键参数

权重不是唯一影响结果的参数。至少要同时调试以下变量:

11.1 两个检索器的初始 top-k

如果最终只需要 10 个上下文片段,BM25 和向量检索各取 10 个,候选覆盖可能不足。常见做法是先扩大候选:

BM25 top-50
向量 top-50
合并后最多 100 个候选
重排序后保留 top-10

但候选越大,重排序延迟和成本越高。应通过 Recall@K 曲线寻找边际收益,而不是无限增加 K。

11.2 过滤后的有效候选数

过滤会使初始 top-k 中一部分结果失效。系统应监控:

  • 过滤前候选数;
  • 过滤后候选数;
  • 每个检索器的有效候选数;
  • 最终可用于生成的片段数。

如果过滤后经常低于目标数量,问题可能不是融合权重,而是索引分区、权限建模或文档覆盖不足。

11.3 召回与重排序的比例

重排序模型越强,候选越多通常越有利;但候选过多会增加成本。可以评估:

有效收益=ΔnDCGλ1Δ延迟λ2Δ成本\text{有效收益} = \Delta\text{nDCG} - \lambda_1\Delta\text{延迟} - \lambda_2\Delta\text{成本}

其中 λ1,λ2\lambda_1,\lambda_2 是业务对延迟和成本的权衡系数。

11.4 新鲜度和版本

对政策、价格、接口文档等时效性强的资料,相关性不只取决于文本匹配。可以在融合或重排序中加入新鲜度,但要避免新文档无条件压过权威旧文档。

例如:

S(d)=S(d)+βFreshness(d)S'(d)=S(d)+\beta\cdot \text{Freshness}(d)

这里的 β\beta 需要通过标注数据确定。对于法律、审计和历史记录,旧文档可能正是问题所需的证据,不能简单地按时间排序。


十二、常见误解与反例

误解一:向量检索已经包含 BM25 的全部能力

反例是精确标识符查询:

ERR_AUTH_0017

如果 embedding 模型没有把这个错误码与正确文档建立稳定语义关系,向量检索可能返回“认证失败”“权限不足”等相似主题,但没有包含 ERR_AUTH_0017 的具体处理步骤。BM25 对该字符串的直接匹配更可靠。

误解二:BM25 找不到同义词,所以没有价值

反例是 API 和版本查询:

Python 3.12 asyncio.TaskGroup

此时用户需要精确的版本和符号,语义相似但版本不同的文档可能造成严重误导。BM25 能够帮助系统优先保留包含确切术语的文档。

误解三:把 BM25 分数和向量分数直接相加即可

假设 BM25 分数范围是 0 到 20,向量相似度范围是 0.7 到 0.9:

S(d)=sb(d)+sv(d)S(d)=s_b(d)+s_v(d)

即使向量检索在某个查询上区分度很高,它对最终分数的影响也可能只占很小比例。反过来,某些向量实现的点积分数可能远大于 BM25。没有校准的加法实际上是在让分数尺度决定权重。

误解四:融合后再过滤可以节省实现工作

反例是多租户知识库。无权文档可能在融合、缓存或日志阶段已经暴露。安全过滤必须在候选形成前尽可能执行,并在最终输出前再次检查。

误解五:权重调优能解决所有召回问题

如果 BM25 的分词器把内部产品名拆错,或者 embedding 模型没有覆盖领域术语,调节 α\alpha 不能创造正确的候选。应先检查:

  • 原文是否被正确解析;
  • chunk 是否包含完整证据;
  • 术语是否被正确分词;
  • embedding 是否成功生成;
  • ANN 是否达到预期召回;
  • 过滤是否误删;
  • 文档版本是否正确。

十三、诊断方法:从“答案错了”回溯到检索链路

一个可操作的诊断顺序如下。

第一步:确认正确文档是否在允许集合中

先检查权限、租户、文档类型和时间过滤。如果正确文档被过滤掉,继续调 BM25 权重没有意义。

第二步:检查 BM25 是否召回

观察:

  • 查询分析后的 token;
  • 词项是否进入倒排索引;
  • 关键术语的 document frequency;
  • 目标文档的原始 BM25 分数;
  • 是否因文档长度或分词导致得分异常。

第三步:检查向量检索是否召回

观察:

  • 查询和文档是否使用同一 embedding 模型;
  • 相似度度量是否一致;
  • 向量是否为空或维度错误;
  • ANN 搜索参数;
  • 精确搜索与 ANN 搜索的差异;
  • 目标文档与查询的相似度。

第四步:检查融合前后的名次

保存每个候选的:

doc_id
bm25_rank
bm25_score
vector_rank
vector_score
normalized_scores
fused_score
filter_status
rerank_score

如果正确文档在两个召回器中都存在,但融合后被挤出,问题属于归一化、权重、去重或重排序。如果它只在一个召回器中存在,则应分析该检索器的覆盖能力。

第五步:检查生成上下文

即使正确 chunk 排在前面,也可能在上下文拼接时被:

  • token 截断;
  • 去重规则删除;
  • 压缩模型错误摘要;
  • 相邻片段排序打乱;
  • 引用映射错误。

检索评测和生成评测必须分开记录,不能只看最终答案。


十四、评测应覆盖查询类型和生产约束

一个可靠的评测集不能只有“普通自然语言问题”。至少应分层包含:

  • 精确错误码;
  • 产品名和版本号;
  • API、函数和配置键;
  • 同义改写;
  • 跨语言查询;
  • 多条件过滤;
  • 权限边界;
  • 时效性问题;
  • 多跳问题;
  • 无答案问题。

对每类查询分别报告指标,才能知道权重变化究竟改善了什么。例如,一个全局权重可能提高自然语言问答的 nDCG,却降低错误码查询的 Recall@5。

还应做消融实验:

仅 BM25
仅向量
BM25 + 向量,RRF
BM25 + 向量,加权融合
混合召回 + 重排序
混合召回 + 重排序 + 新鲜度

如果加入某个组件后整体指标上升,但权限过滤查询下降,就不能只看平均值。生产系统应优先保证安全边界,再在允许集合内优化相关性。


十五、成本、延迟和容量取舍

混合检索的成本来自多个部分:

Cost=Embedding+存储+查询+重排序+生成\text{Cost} = \text{Embedding} + \text{存储} + \text{查询} + \text{重排序} + \text{生成}

其中:

  • 文档入库和更新需要生成 embedding;
  • 向量存储会增加内存和磁盘占用;
  • BM25 倒排索引也需要存储词项和位置等信息;
  • 两个检索器并发查询会增加服务资源;
  • 候选越多,重排序和上下文处理越贵;
  • 上下文越长,生成模型的 token 成本和延迟越高。

不能只通过增加 top-k 来弥补召回问题。更合理的顺序通常是:

  1. 先确认文档解析和切块正确;
  2. 确认过滤没有误删;
  3. 评估单独 BM25 和单独向量的召回上限;
  4. 通过融合提高候选覆盖;
  5. 用重排序改善前几名;
  6. 最后在延迟和成本约束下确定 K 和上下文长度。

OpenAI 的 Retrieval Guide 也将语义搜索、向量存储和基于属性的过滤作为检索系统的重要组成部分;具体能力和参数应以所使用服务的当前文档和版本为准,不能把一个产品的过滤语法直接套用到另一个向量数据库。


十六、一个可落地的基线方案

在没有大量标注数据时,可以采用以下可解释基线:

1. 文档按结构切块,保存稳定 doc_id、chunk_id、版本和 ACL
2. 同一规范化文本同时建立 BM25 索引和向量索引
3. 查询时先构造租户、ACL 和业务元数据过滤条件
4. BM25 检索 top-50
5. 向量检索 top-50
6. 对两个结果集按稳定 chunk_id 去重
7. 使用 RRF 作为初始融合方法
8. 对融合后的 top-50 使用交叉编码器或业务重排序
9. 再执行一次权限校验和文档版本校验
10. 选择 top-5 到 top-10 片段进入生成上下文
11. 记录每个阶段的分数、名次、过滤和版本信息

选择 RRF 作为起点,是因为它不要求立即解决两个分数空间的校准问题。积累了可靠的查询—文档相关性标注后,再比较:

  • RRF;
  • 归一化加权融合;
  • 查询分片权重;
  • 学习排序;
  • 融合后重排序。

最终方案不应由某一个搜索案例决定,而应由分层评测、权限约束、延迟预算和成本预算共同决定。

混合检索的核心不是“同时调用 BM25 和向量数据库”,而是让不同信号在正确的候选集合上协同工作:BM25 负责精确词项,向量负责语义覆盖,过滤负责合法边界,融合负责统一排序,权重调优负责用数据决定取舍,重排序和生成阶段则分别负责提高证据优先级与答案表达质量。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。