企业知识库里最让人抓狂的一类问题:你明明记得原文里有个编号,搜出来的一堆段落却都在「聊同一个话题」,偏偏没有那一句。
这不是模型不聪明,而是单一检索方式的盲区。BizBuddy 最近给知识库补上了「向量 + BM25」混合检索,这篇文章把原理、实现和踩过的坑一次讲清。
一、一个典型的检索尴尬
假设知识库里有一份运维手册,里面写着:
出现 E1023 错误时,先检查网关的超时配置。
用户提问:「E1023 怎么处理?」
纯向量检索把提问变成一个语义向量,再去库里找「意思相近」的段落。问题是,「错误码怎么处理」这个语义,和手册里「故障排查」「异常处理」「告警响应」几段都很像;真正含E1023的那一段,反而可能排在第四、第五位。
反过来,如果只用关键词检索(BM25),搜「E1023」能一击命中,但用户换一种说法——「网关一直超时怎么办」——只要原文没出现「超时」之外的词,就可能什么都搜不到。
这就是两种检索的天然分工:
- 稠密向量擅长「换一种说法也能找到」,对同义、口语、跨语言友好;
- BM25擅长「这个编号、这个型号、这个条款号,必须原样命中」。
一个偏语义,一个偏精确。企业知识库的真实提问,往往两者都需要。
二、BM25 到底在算什么
BM25 不是「数关键词出现几次」这么简单,它主要看三件事:
- 词频(TF):这个词在文档里出现得越多,越相关——但收益会递减,出现 20 次不会比 10 次相关一倍;
- 逆文档频率(IDF):越少见的词越值钱。「的」「是」这种到处都有的词几乎不加分,
E1023这种独家编号权重很高; - 文档长度归一化:长文档天然更容易命中词,所以要压一压,避免「篇幅大」冒充「相关」。
经典形式大致是:
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) |
|---|---|---|---|
| A | 1 | 2 | 1/61 + 1/62 ≈ 0.0325 |
| D | 4 | 1 | 1/64 + 1/61 ≈ 0.0320 |
| B | 2 | — | 1/62 ≈ 0.0161 |
| C | 3 | — | 1/63 ≈ 0.0159 |
| E | — | 3 | 1/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],只为页面展示和跨知识库合并排序。
这里必须说清楚两件事:
- 归一化不改顺序,只改显示;
- 它不是概率,也不是置信度。同一个
0.83,在不同查询、不同知识库、开没开重排的情况下,含义并不相同。
多知识库检索时,平台会按score合并排序。因为各自做过归一化,这个排序「近似可比」,但不要拿它做严格的跨库质量判断。
七、什么时候该开 hybrid
基于目前的实现和语料特点,我们的建议很明确:
适合开:
- 术语密集:产品型号、错误码、法规条款、工单编号、人名地名;
- 长文档为主:手册、制度、合同,精确词是主要入口;
- 用户提问经常「原样复制」文档里的词。
可以先不开:
- 纯口语问答、客服话术类知识;
- 对延迟和存储极度敏感;
- 文档量很小,纯向量已经够准。
混合检索的代价不是「多一次模型调用」,而是多一次向量库检索、多一份正文存储、多一套索引维护。它值得开,但不值得默认全开。
八、评测:我们有哪些数字,哪些还没有
BizBuddy 有一套随仓库开源的 RAG 评测脚本(e2e/http/eval/eval-rag.mjs),56 条用例、10 篇语料,按事实 / 术语 / 同义词 / 口语化 / 跨文档分类统计。
目前可引用的基线是纯向量 + 可选重排:
| 配置 | hit@1 | hit@5 | recall@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