☰
别再把余弦分和 BM25 分相加了:一次混合检索的工程实践
2026/10/27 8:12:27 网站建设 项目流程

企业知识库里最让人抓狂的一类问题:你明明记得原文里有个编号,搜出来的一堆段落却都在「聊同一个话题」,偏偏没有那一句。

这不是模型不聪明,而是单一检索方式的盲区。BizBuddy 最近给知识库补上了「向量 + BM25」混合检索,这篇文章把原理、实现和踩过的坑一次讲清。

一、一个典型的检索尴尬

假设知识库里有一份运维手册,里面写着:

出现 E1023 错误时,先检查网关的超时配置。

用户提问:「E1023 怎么处理?」

纯向量检索把提问变成一个语义向量,再去库里找「意思相近」的段落。问题是,「错误码怎么处理」这个语义,和手册里「故障排查」「异常处理」「告警响应」几段都很像;真正含E1023的那一段,反而可能排在第四、第五位。

反过来,如果只用关键词检索(BM25),搜「E1023」能一击命中,但用户换一种说法——「网关一直超时怎么办」——只要原文没出现「超时」之外的词,就可能什么都搜不到。

这就是两种检索的天然分工:

  • 稠密向量擅长「换一种说法也能找到」,对同义、口语、跨语言友好;
  • BM25擅长「这个编号、这个型号、这个条款号,必须原样命中」。

一个偏语义,一个偏精确。企业知识库的真实提问,往往两者都需要。

二、BM25 到底在算什么

BM25 不是「数关键词出现几次」这么简单,它主要看三件事:

  1. 词频(TF):这个词在文档里出现得越多,越相关——但收益会递减,出现 20 次不会比 10 次相关一倍;
  2. 逆文档频率(IDF):越少见的词越值钱。「的」「是」这种到处都有的词几乎不加分,E1023这种独家编号权重很高;
  3. 文档长度归一化:长文档天然更容易命中词,所以要压一压,避免「篇幅大」冒充「相关」。

经典形式大致是:

score(D, Q) = Σ IDF(qi) · [ f(qi, D) · (k1 + 1) ] / [ f(qi, D) + k1 · (1 - b + b · |D| / avgdl) ]

k1控制词频饱和速度,b控制长度惩罚力度,行业常见取值是k1=1.2、b=0.75。BizBuddy 首版不暴露这两个参数,走 Milvus 的默认行为,先把「能用、可降级」做扎实。

一句话概括:BM25 给「精确词」很高的确定性,向量给「同义表达」很高的召回率。

三、为什么不用「分数加权」,而用 RRF

最直觉的融合方式是:向量分 0.82、BM25 分 12.6,加起来排序。但这俩分数根本不是一个量纲:

  • 余弦相似度大致落在[0,1],且分布集中在高位;
  • BM25 分没有上界,随语料规模、词频、文档长度变化。

直接相加,等于拿「摄氏度」加「华氏度」。归一化能缓解,但两路分布的尾部差异依然会打架。

所以我们首版用RRF(Reciprocal Rank Fusion,倒数排名融合):不看分数,只看名次。

RRF(d) = Σ 1 / (k + rank_i(d))

第 1 名得1/(k+1),第 2 名得1/(k+2),以此类推;同一文档在两路都出现,就把两路得分相加。k是平滑常数,默认 60,作用是压住「第一名通吃」的极端情况。

举个直观的例子,查询E1023 怎么处理:

文档向量排名BM25 排名RRF 分(k=60)
A121/61 + 1/62 ≈ 0.0325
D411/64 + 1/61 ≈ 0.0320
B2—1/62 ≈ 0.0161
C3—1/63 ≈ 0.0159
E—31/63 ≈ 0.0159

注意 A 和 D:单看任意一路都不是第一,但两路都认可它们,于是排到了最前。B 虽然是向量第一名,但 BM25 没召回它,只能排在后面。

这正是 RRF 的价值:它奖励「两种检索方式都认同」的结果,而不是被某一路的高分绑架。

首版我们刻意固定为两路等权,没有暴露 BM25 权重。理由很朴素:多一个旋钮,就多一类「调了之后为什么变差」的问题;等权 RRF 已经能覆盖大部分场景,等评测数据更充分再谈加权。

四、BizBuddy 里的实际链路

一次混合检索,按这个顺序走:

查询 → 稠密向量召回(带相似度阈值) → BM25 全文召回(默认 30 条) → RRF 融合(k=60) → 可选 rerank → 截断 topK

几个实现细节值得说明:

1. 按知识库开关,默认不开

知识库表新增retrieval_mode,取值vector(默认)或hybrid。不是所有库都值得上混合检索:纯口语问答、语义为主的场景,纯向量更便宜也更简单。默认值保持vector,是对成本和延迟的尊重。

2. 用 Milvus 原生 BM25,不额外调模型

BM25 的稀疏向量由 Milvus 在服务端根据正文自动生成,平台侧不需要再调用一个 embedding 模型。集合 Schema 里给content字段开启中文分词(type=chinese),再挂一个BM25Function,把正文映射到sparse字段。写入时只写正文,检索时直接传查询文本。

这一点很关键:混合检索没有引入第二个模型供应商,也没有多一次外部调用。

3. 独立集合,不动既有 Schema

每个知识库的 BM25 索引放在独立集合<collection>_bm25里,字段包括id(切片主键)、doc_id、chunk_index、content、sparse。

为什么不塞进原来的向量集合?因为老集合已经在线跑着,改 Schema 意味着迁移风险。独立集合可以按需创建、按库删除,代价是正文多存一份——这是明确的存储成本,换来的是零迁移。

4. 索引有生命周期,状态可见

bm25_index_status记录四态:none/building/ready/failed。

  • 新文档向量化时,如果知识库是hybrid,同一批切片会顺带写入 BM25;
  • 从vector切到hybrid时,异步触发一次全量重建:先清空集合,再按已向量化的文档分页写入;
  • 重建用单线程串行执行,同一知识库重复提交会被跳过,避免并发重建互相踩踏;
  • 页面提供手动重建入口,失败可以重试。

五、降级,比「成功路径」更重要

检索是问答链路的前置步骤,它不能成为新的故障点。

所以我们把降级写进了每一层:

  • BM25 集合不存在:检索返回空,直接走纯向量;
  • BM25 检索抛异常:捕获后打一条 warn 日志——「BM25 检索失败,本次降级为纯向量检索」——本次请求继续;
  • BM25 写入失败:文档向量化照常完成,只把索引状态置为failed,不阻断入库;
  • 重建失败:状态置failed,抛业务异常提示,但既有向量检索不受影响;
  • 文档删除 / 重新向量化:按doc_id清理对应的 BM25 切片,避免「向量删了、关键词还在」的脏命中。

一个诚实的边界:重建期间,BM25 集合可能为空或只有部分文档。这时混合检索不会报错,但会退化成「向量为主、关键词偶尔补位」。状态字段就是给运维看的——看到building,就知道现在不是混合检索的最佳状态。

六、分数怎么展示,别把它当置信度

RRF 分本身很小(第 1 名也只有 1/61 左右),直接展示没有意义。融合完成后,我们对结果做一次min-max 归一化到[0,1],只为页面展示和跨知识库合并排序。

这里必须说清楚两件事:

  1. 归一化不改顺序,只改显示;
  2. 它不是概率,也不是置信度。同一个0.83,在不同查询、不同知识库、开没开重排的情况下,含义并不相同。

多知识库检索时,平台会按score合并排序。因为各自做过归一化,这个排序「近似可比」,但不要拿它做严格的跨库质量判断。

七、什么时候该开 hybrid

基于目前的实现和语料特点,我们的建议很明确:

适合开:

  • 术语密集:产品型号、错误码、法规条款、工单编号、人名地名;
  • 长文档为主:手册、制度、合同,精确词是主要入口;
  • 用户提问经常「原样复制」文档里的词。

可以先不开:

  • 纯口语问答、客服话术类知识;
  • 对延迟和存储极度敏感;
  • 文档量很小,纯向量已经够准。

混合检索的代价不是「多一次模型调用」,而是多一次向量库检索、多一份正文存储、多一套索引维护。它值得开,但不值得默认全开。

八、评测:我们有哪些数字,哪些还没有

BizBuddy 有一套随仓库开源的 RAG 评测脚本(e2e/http/eval/eval-rag.mjs),56 条用例、10 篇语料,按事实 / 术语 / 同义词 / 口语化 / 跨文档分类统计。

目前可引用的基线是纯向量 + 可选重排:

配置hit@1hit@5recall@5
纯向量(不重排)67.9%89.3%96.4%
加重排75.0%98.2%98.2%

需要坦白的是:混合检索还没有纳入这套 56 用例的 A/B 报告。我们完成了功能、回归和双栈验证,但没有拿它去和纯向量做同口径对比,所以这篇文章不写「提升了多少个百分点」。

这不是保守,是基本纪律:没有同口径数字,就不编造收益。

下一步很明确——把vector/hybrid做成评测脚本的一个参数,用同一批用例跑出可复现的对比,再决定默认值要不要调整。

九、踩过的坑,给同样在做的人

1. 中文不分词,BM25 基本等于废的

默认分词器会把一整段中文当成一个长 token,E1023和「错误码」都切不出来。必须显式启用中文分词,否则关键词召回形同虚设。

2. 索引不是免费的

BM25 集合要再存一份正文。文档量大时,这是实打实的存储开销;删库、删文档、重新向量化都必须同步清理,否则会留下「幽灵切片」。

3. 重建窗口必须有降级

先 drop 再重建,中间一定有一段「索引不存在」的时间。如果这时检索直接报错,用户看到的就是「知识库坏了」。正确做法是:把「没有索引」当成正常状态,降级而不是失败。

4. 两路分数不能直接比

RRF 之所以稳,就是因为它绕开了量纲问题。但融合后的归一化分只适合展示,不要拿它反推「这条到底有多相关」。

5. 别急着做加权融合

等权 RRF 已经能吃到大部分收益。权重、阈值、k值这些旋钮,等有了稳定的评测集再调,不然很容易把「随机波动」当成「调优成果」。

十、把它放回平台里看

混合检索在 BizBuddy 里不是一个独立服务,而是知识库的一个配置项,最终仍然通过search_knowledge_base工具进入智能体链路。这意味着它天然继承平台已有的能力:

  • 权限:按公开 / 本部门 / 仅本人过滤,停用库不参与;
  • 安全:入库前内容审核,命中片段进上下文前带不可信边界标记;
  • 审计:每次工具调用留痕,能回溯引用了哪些切片;
  • 可运维:索引状态、手动重建、失败降级都有明确入口。

整条链路仍然是Java + 标准协议,没有 Python sidecar,也没有多引入一个模型依赖。

写在最后

混合检索不是「更高级的检索」,而是承认单一方法的盲区,然后用工程手段把两种能力拼起来。

真正难的从来不是「调一个 BM25 接口」,而是:

  • 索引什么时候建、失败怎么办;
  • 重建期间用户能不能继续用;
  • 分数怎么展示才不误导;
  • 什么时候该开、什么时候不该开。

这些问题的答案,最终都落在代码和运维界面里,而不是论文里。

BizBuddy 是一个全量开源的企业级智能体平台(MIT License),知识库混合检索、重排、上下文装配与评测脚本都在仓库里:

https://gitee.com/zl3624/biz-buddy

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询