先聊一个很多人都踩过的坑:自建RAG问答系统,前期做原型验证时觉得效果还行,一上生产就发现用户根本不满意的场景——用户问“我想查下这个月的显卡采购合同有没有审批完”,向量检索只返回了一堆“显卡驱动安装指南”;反过来用户问“机器老是蓝屏怎么排查”,你如果只用BM25,它又会因为“蓝屏”这个词匹配不到“系统崩溃”的相关文档。这就是召回这条链路出了问题,而召回恰恰是整个企业级问答系统的命门。
这章我们就把这个问题彻底讲透。核心方案就是标题里那句话:混合检索,具体来说是dense(稠密向量检索) + BM25(稀疏关键词检索) + RRF(倒数排名融合)三者的组合。全文不涉及复杂的训练任务,你只需要有一个embedding模型、一套倒排索引和大约三十行融合代码,就能让问答系统的召回质量上一个台阶。适合正在搭建企业知识库问答、客服机器人或内部文档检索的同学参考,尤其是那种已经做了单纯向量检索但效果不稳定的项目,这章的内容可以直接拿来改。
1. 为什么企业级问答必须走“混合检索”这条路
1.1 单一向量检索解决不了的三个典型场景
先说我自己的理解,混合检索不是赶时髦,是补短板。业界常说的dense检索,优势在语义理解,但它在真正企业级数据面前有三处硬伤。
第一,精确标识符召回困难。企业内部文档里充斥着“INV-2024-0789”“RTX 4090”“CMS系统报错代码E1001”这类标识符和型号。这些字符串在语义上没有太多“含义”,embedding模型很容易把它们映射到模糊的向量区域。你向dense检索问“RTX 4090驱动安装失败”,它可能找到“显卡驱动更新日志”,但“C01-024故障代码处理”这种纯代码匹配几乎必然漏掉。而BM25做这种精确词语匹配几乎是天生的。
第二,生僻词与错别字的鲁棒性问题。embedding模型的词表里,很多专有名词、内部系统名根本没出现过。出现这种词时,dense检索会把它们拆成子词甚至字符级别,语义信号被稀释;而BM25对词项完全无感,只要两边字符串一致,它就能命中。反过来,错别字对BM25是致命打击,但对dense检索反而有一定容错,这正好互补。
第三,用户query的信息密度不均。真实用户提问往往一半是口语化的意图描述,一半是精确的事实约束,比如“最近那张采购显卡的合同弄好了没”。语义匹配负责理解“采购显卡的合同”,精确匹配负责锁定“合同”“显卡”这类强信号词。单用任何一路都是顾此失彼。
1.2 方案选型:dense + BM25 + RRF 为什么是性价比最高的组合
很多团队最开始都会纠结一个问题:要不要用learning to rank(LTR)模型统一排序?我的答案是:在企业内部场景,先从混合检索起步,把LTR放到后期迭代。
原因是LTR需要大量标注排序样本,需要训练和线上特征工程,成本一次拉满。而dense + BM25 + RRF这套组合,三个组件里两个是现成的,RRF只是个几十行的公式,没有训练、没有特征工程,只依赖每个检索器自己给出的排序位置。它不关心两个检索器的分数是否处于同一个量纲,也不需要你解释为什么BM25打了7.5分、向量相似度打了0.83。
这种设计非常契合企业知识问答的现状:数据量大但标注少,业务变化快,检索器随时可能替换。RRF把“融合”和“具体检索器”解耦了,你以后把BM25换成别的稀疏检索,或者把dense模型换成更大的模型,融合层不用动。
2. 三个核心组件的原理与公式细节
2.1 BM25:用词频和文档稀疏性做精确匹配
BM25本质上是个打分函数,核心思想还是词频——一个词在文档里出现越多,文档越相关;但同时又加了两层修正:文档长度越长,词频越不值钱;整个文档库里很少出现的词,一旦出现就应该获得更高权重。
常用的BM25公式是:
score(D, Q) = Σ idf(qi) × [ f(qi,D) × (k1 + 1) ] / [ f(qi,D) + k1 × (1 - b + b × dl / avgdl) ]
其中:
- f(qi, D) 表示词项 qi 在文档 D 中的出现次数;
- dl 是文档长度,avgdl 是文档库平均长度,所以 dl/avgdl 就是当前文档相对长度的“膨胀系数”;
- k1 控制词频饱和度,默认 1.2~2.0,值越大,词频增长带来的分数增长越慢;
- b 控制长度惩罚力度,默认 0.75,值越大,长文档被罚得越狠。
idf(qi) 一般取:
idf(qi) = ln(1 + (N - n(qi) + 0.5) / (n(qi) + 0.5))
N 是文档总数,n(qi) 是包含该词的文档数。你注意那个 +1 在 ln 里面,这个平滑项是为了防止含有大量文档的语料里 idf 出现负无穷。BM25不需要训练,没有神经网络参数,理解成“基于统计的启发式打分器”就行。
拿生活场景打比方:你去档案室查一份合同,管理员先看关键词“采购合同”出现在哪些档案里,词出现得多的先拿出来;但几个档案篇幅不一样,500页的合同书里出现10次“合同”,跟2页的备忘录里出现10次“合同”,含金量明显不同,所以要按长度归一化;最后“显卡”这种全公司文档里很少出现的词,一旦出现,基本就是你要找的东西了,而“的”“了”这种谁都有的大量词,几乎不提供信息量。BM25做的事情就是这三步的加权综合。
2.2 Dense:把语义变成向量空间中的距离
dense检索走的是另一条路:用embedding模型把query和文档分别编码成一个固定维度的向量,然后计算向量相似度,通常用余弦相似度或者内积。
这里模型选型直接决定效果上限。中文场景下我推荐几个经得起实测的:bge-large-zh-v1.5(1024维)、bge-m3(支持多语种和稀疏/稠密混合)、m3e-base、以及通义千问的text-embedding系列。英文或代码场景可以看E5系列、gte系列、voyage系列。维度上,256维到1024维都常见,维度越高信息承载越大,但存储和计算成本也在涨。企业内部如果只有几百GB文档,尽量选高维模型;如果数据上亿、还要做HNSW索引,就要认真权衡了。
距离度量我用余弦相似度最多,因为它对向量模长不敏感,适合embedding模型输出的未归一化向量。但如果你的模型输出已经做了归一化,内积和余弦在数学上是等价的,这时选哪个纯粹看向量库的索引实现效率。
这里必须点出一个很多初学者忽略的细节:embedding模型是有“偏见”的,它在训练语料里见过大量互联网文本,但未必见过你公司内部那种缩略语满天飞的文书。所以dense检索强在“大意的传达”,弱在“字面的锁死”。你问“显示屏闪烁”,它能想起“显示器画面跳动”,但你把“PRJ-20241008”这几个字符扔给它,它只能给你一堆莫名其妙但“语义相近”的结果。
2.3 RRF:为什么“排序的位置”比“分数”更能反映真实相关性
先直接给公式,RRF对每个文档d,把两个排序列表中的排名位置综合起来打分:
RRFscore(d) = Σ 1 / (k + rank(d))
k 是一个常数,默认经验值取 60。rank(d) 是文档 d 在某一路检索结果中的排名位置,从1开始计数。如果文档在一路中没出现,贡献就为0。
为什么这个简单的公式如此有效?关键在于它绕开了“分数对齐”这个棘手问题。
你想一下,如果直接做分数加权,比如 0.5×BM25分 + 0.5×余弦相似度,会遇到什么?BM25的分数范围可能从0到二十几,而余弦相似度集中在0.75到0.98,两路分数范围和分布完全不同,直接相加基本等于其中一路说了算。你需要先做Min-Max归一化,但归一化又会被离群值干扰,每次数据规模变化都要重新算参数。这个坑我见过太多团队踩了。
RRF只看相对顺序,不看绝对分数。就算BM25给文档打了 3.5 分,向量检索打了 0.82 分,融合时只用到“它在各自名单里排第几”。排名位置是所有检索器都能提供的信息,也是跨字母、跨规范、跨模型最稳定的信息。用生活话讲:两个评委给选手打分,一个严一个松,你没法直接比较分数;但你知道这个选手在严评委里排第二,在松评委里排第一,那综合下来一定差不了。RRF干的就是这种活。
而且RRF有个隐性好处,它能放大“多路都认可”的文档。如果一个文档在BM25里排第10,在dense里排第20,融合得分是 1/(60+10) + 1/(60+20) ≈ 0.0268;另一个文档只在dense里排第1,得分是 1/61 ≈ 0.0164。前者的融合分反而更高。这个特性对企业问答太重要了,因为真正需要的答案往往是既符合关键词、又符合语义的结果,也许在单路上都不是第一,但综合来看就是最靠谱的。
RRF还有两个变体常见于工程实践:加权RRF和带截断的RRF。加权RRF就是给每路乘一个权重系数,比如:
RRFscore(d) = Σ w_i / (k + rank_i(d))
不过我不建议一上来就调权重,容易过拟合,先把标准RRF跑起来,再用实验数据决定要不要加权。带截断的RRF是指每路只取前N个候选参与融合,因为排名太靠后的文档贡献趋近于0,还白白拖慢计算,后面实现部分我会说具体怎么设N。
3. 从零到一的工程落地:索引、召回与融合实现
3.1 数据准备与分词细节(中文场景是重灾区)
文本进索引前,先做归一化清洗。我的经验是至少做四件事:统一全角半角,统一大小写(除非你的业务对大小写敏感,比如区分错误码),统一特殊空格为普通空格,过滤掉控制字符。这是最不起眼但最能减少线上诡异bug的步骤。接口返回“ABC-123”和“abc-123”如果在索引里被当成两个词,BM25的IDF统计都会变得混乱。
中文分词是整个BM25链路的命门。英文天然按空格分词,中文没有。企业场景的核心难点是——通用分词器(比如jieba默认模式)会把“显卡采购合同”切成“显卡/采购/合同”,这没问题;但“RTX4090”可能被切成“RTX/4090”甚至拆得更碎,用户搜索“RTX 4090”时带了个空格,你的索引里全是“RTX4090”,匹配就挂了。这让我在真实项目里吃过亏,后来用的方法是维护一份“强制保留词表”,分词前先按词表做精确匹配替换,比如把“RTX 4090”先替换成一个不会拆分的占位符,分词后再替换回来。
如果不想维护词表,另一个折中方案是使用混合分词策略:对原文本做一次细粒度分词,同时做一次n-gram切分,ES索引里的文本字段和keyword字段各保存一种,查询时用multi-field分别匹配。覆盖率提高了,但索引体积也会涨,需要权衡。
清洗和分词完成后,数据要落成统一的文档结构。我建议至少包含这几个字段:
- doc_id:唯一文档ID,全局稳定
- content_text:清洗后的纯文本,BM25和向量化共用
- content_vector:embedding模型生成的dense向量
- bm25_field:analyzer处理后的文本字段,用于BM25查询
- metadata_json:来源、时间、权限等扩展属性
embedding生成时我建议一条文档切分后按chunk粒度生成,不要整篇长文档生成一个向量。企业级问答通常是chunk级别召回,长文档embedding会被大量无关段落稀释。切分策略可以参考前几章的思路,我这里只说一个上线经验:chunk大小和你模型的max_seq_len要匹配,bge系列一般支持512token左右,超过512token就必须截断或做重叠切分,重叠部分建议50~100token,否则跨段落的上下文会被切断。
3.2 索引结构与双路召回的服务设计
工程上你总要选一个承载底座。如果你的公司已经重度使用Elasticsearch,我强烈建议直接在ES 8.x上构建,因为它同时支持BM25(text字段原生就是BM25打分)和dense向量检索(dense_vector字段 + knn query),省了一套中间件运维。如果是从零开始,也可以考虑Milvus或Qdrant这类专业向量库,配合一个独立的倒排索引服务(或者直接上Elasticsearch负责稀疏部分)。
两种架构没绝对优劣,按团队底座选。不过我这里多嘴一句:当你的检索链路同时依赖多个存储组件时,“数据一致性”会成为日常痛点,你能接受文档在向量库里存在但在搜索引擎里缺失吗?很多架构选型最后都死在这种“两边都要etl”的复杂性上。所以我还是偏向描述一个ES 8.x主实现的版本,至少索引和查询都在一处,排查问题路径短。
ES索引的映射简化如下:
{ "mappings": { "properties": { "doc_id": { "type": "keyword" }, "content_text": { "type": "text", "analyzer": "ik_max_word" }, "content_vector": { "type": "dense_vector", "dims": 1024, "similarity": "cosine" }, "metadata_json": { "type": "object" } } } }你可以看到content_text字段指定成text + ik_max_word,中文分词是用IK分词器完成的,这也是国内用ES做中文检索的常见组合。
双路召回的请求设计,我一般拆成两步:
第一步,用户query先走轻量归一化流程,包括大小写、全半角、去掉无关标点,然后送入embedding模型得到query_vector。同时用同一个标准化后的文本作为BM25的query。
第二步,并行发起两个请求:
- BM25路:ES的multi_match查询,fields里指定content_text^2和metadata_json.keyword^1.5,importance可以按业务调优;
- dense路:ES的knn查询,指定query_vector和indexed filter条件。
从代码上是两个query,但线上服务层应当用Goroutine/CompletableFuture之类的并发方式同时跑,等两路结果集都回来后统一交给RRF融合器处理。这里最忌讳串行调用,一路超时一路阻塞,整个接口延时就翻倍了。
3.3 RRF融合的代码实现与参数默认值
RRF的代码实现真的不长,我直接给出核心函数:
def rrf_fuse( ranked_lists: list[list[str]], k: int = 60, top_n: int = 50, ) -> list[tuple[str, float]]: """RRF融合多路排序列表. Args: ranked_lists: 每路检索返回的有序doc_id列表,位置越靠前排名越高。 k: RRF平滑常数,经验值30~60。 top_n: 每路只取前top_n个参与融合。 Returns: 按融合分从高到低排序的(doc_id, score)列表。 """ scores: dict[str, float] = {} for one_ranked in ranked_lists: for rank, doc_id in enumerate(one_ranked[:top_n]): # rank从0开始,实际排名 position = rank + 1 scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1) ranked = sorted(scores.items(), key=lambda x: (-x[1], x[0])) return ranked这里有几个细节需要掰扯清楚:
k=60是论文和开源实现里最常见的默认值,含义是“排名领先的收益衰减速度”。k越小,第一名和第十名的差距被拉得越大,相当于更信任每路排序的头部;k越大,各排名之间权重差越平缓,尾部文档也有机会靠多路重叠弥补。我在实际项目里的经验是,先跑默认值,再在候选集上的评测集里用一个网格搜索(30/60/100)对比,选NDCG@10最优的那个。
top_n截断建议设在50~200之间。理由很简单,RRF在rank超过50之后贡献已经小于 1/(60+50) ≈ 0.009,对最终排序的影响微乎其微。把top_n从全部结果砍到100,能显著减少融合计算量,也不会牺牲效果。如果你只有两路检索,top_n=100足够了。
doc_id的稳定性。融合是按doc_id做key的,如果doc_id在BM25结果里是数字ID,在dense结果里是带前缀的字符串ID,两边对不上,融合结果会支离破碎。我见过有人从ES拿_source.doc_id,从向量库拿ID字段,结果一个是long一个是string,隐式类型转换还恰好转成了一样的数字才没暴露问题。这个一定要在数据接入层统一成string。
如果你不想自己写融合函数,ES 8.8+ 直接在search请求里支持rrf参数,可以在同一个查询里做BM25 + kNN + RRF。我自己偏好自研写一个轻量服务,因为能更方便地打日志、做A/B对比和缓存。
3.4 性能优化:并发、缓存、裁剪候选集
性能是企业级问答绕不开的坎。企业内部查询并发可能不高,但单次查询内容长度很大,文档数量可能千万级。我的优化经验按优先级排序:
第一,最强收益来自缓存。双路检索尤其是dense部分,embedding模型的推理耗时通常在5~30ms,加上向量库ANN检索,单次300ms甚至更快。但用户高频问题往往是同一批,比如“怎么申请报销”“年假规则”这类。在服务层做query级缓存,以标准化后的query文本做key,把最终的top20结果缓存5~15分钟,能干掉一半以上重复开销。
第二,并发执行是关键。BM25和dense分别是独立IO操作,如果不能并发而是串行,总耗时就是两者相加。我用Go、Java、Python按项目不同做过几种实现,无一例外都是并发路径性能更好。如果你用Python,注意线程和GIL问题,建议用asyncio把两个HTTP查询并发出去。
第三,ANN参数直接影响召回延迟。ES的knn查询有两个参数:k(返回最终k条近邻)和num_candidates(探索的候选数量)。num_candidates越大,召回越准但越慢。经验值是num_candidates = k * 10~20,起步设200~400比较稳。返回的top_k不一定要等于最终需要的候选数,你可以让dense路返回top100,而knn的k就设置为100。
第四,BM25方面限制查询字段数量。不要对超大metadata文本字段做match查询,那会让倒排链变长、评分计算量变大。文本检索尽量聚焦在content_text和几个业务关键词字段上。
4. 调参与效果评估:用数据说话
4.1 核心参数速查表(k1/b、k值、候选集)
不是把所有参数都调一遍才是专业,你要知道每个参数控制的到底是什么。我整理了一张速查表,适合贴在工位旁边:
| 参数 | 所属组件 | 默认经验值 | 调整方向与影响 |
|---|---|---|---|
| k1 | BM25 | 1.2~2.0 | 越大,词频对分数的贡献越迟钝;长文档高频词占优时适当调大 |
| b | BM25 | 0.75 | 越大,长文档被惩罚越重;如果文档长度均衡,可调小到0.5 |
| embedding维度 | dense | 256~1024 | 维度越高信息量越大,但存储和检索开销增长明显 |
| knn num_candidates | dense | k * 10~20 | 越大召回越好但越慢;不宜超过总文档数的10% |
| RRF k | 融合 | 60 | 越小越偏向每路排序头部;越小越依赖重叠信号 |
| top_n截断 | 融合 | 50~200 | 过小会丢失尾部候选的重叠信号;过大增加计算量且收益趋零 |
| chunk大小 | 前置处理 | 300~500 token | 过大导致语义稀释,过小导致上下文断裂 |
4.2 一次真实的调参实验记录
拿我们去年的一个项目举例,场景是某制造企业的设备运维知识库,语料规模大概230万条文本片段,用户query覆盖维修手册、故障代码、备件型号三类。初始版本单路dense,用户满意度卡在61%;上线BM25 + dense + RRF(k=60,top_n=50)之后,离线评测Hit@5从0.52提升到0.67,线上点击率提升到49%,最终满意度到了73%。
这个结果看上去简单,中间实际踩了很多坑。第一版我直接上默认参数,k=60,没有做query归一化,结果融合效果跟单路dense差不多,后来定位到主要问题在BM25那路的候选集质量太差——英文错误码被IK分词器拆成“RTX”和“4090”两个独立词,用户搜“RTX4090”就完全匹配不上了。针对性加入强制保留词表后,BM25召回质量才立起来。
然后是top_n实验。top_n=20的时候融合效果比top_n=50明显差,因为当时语料存在大量长尾表述,真正匹配的文档往往在某一路排到30名开外;设成200之后离线指标几乎没再涨,线上延迟反而增加了8ms左右,所以最终折中到100。
RRF k值我们也做了网格搜索,候选值30/60/100,结果60在NDCG@10上最优,但30和60的差异非常小,小于一个百分点。这让我对默认值60更放心了。
4.3 如何搭建评估集避免“拍脑袋调参”
没有评估集就没有调参资格。这是我做检索方案最想强调的一件事:任何参数调整都必须建立在同一个评测集上反复跑,否则你只是在凭感觉做实验。
评估集可以从线上日志里抽样构造。拿企业问答来说,搜集最近三个月用户真实query,让业务方或标注人员标注出每个query对应的标准答案doc_id,再构造一批有明确否定语义的query保证精确率评估有意义。评估集规模不用太大,300到500条就能看出参数变化带来的趋势差异。
离线指标我常用这几个:
- Recall@5:池化候选中前5个命中真实答案的比例;
- MRR(倒数排名):第一个正确答案的平均倒数排名,越高代表答案越靠前;
- NDCG@10:考虑排序位置的加权累计增益,适合评价多级相关性。
每次调整参数,固定同一份评估集,跑完对比NDCG@10和Recall@5两个指标就够了。如果NDCG@10上升但MRR下降,说明你融合结果整体更均衡但头部稳定性变差,需要斟酌线上到底偏重哪个维度。
5. 线上问题排查与踩坑实录
5.1 为什么fusion后效果不如单路
混合检索上线后效果反而变差,这可能是你遇到的最诡异问题。按我的排查顺序,九成情况出在这几处:
第一,候选集质量不达标。RRF只是融合器,不会无中生有。如果dense路或者BM25路单独跑出来的前100名里根本没有正确答案,融合结果自然也不可能凭空变好。这时候你首先要做的不是调RRF参数,而是拉出三路结果,人工核对每路候选集里正确答案的真实排名,确认是不是某一路召回本身出了bug。
第二,doc_id对齐出了问题。两个列表的doc_id如果使用了不同的命名空间,融合时看似都在,实际是两个不重叠的集合,RRF评分就退化成“谁在这路也出现谁就分高”,这会给结果引入大量噪声。检查方法很简单:线上日志里随机抽100条query,打印两路的doc_id列表,人工比对交集比例。正常的交集比例应当在20%~60%之间,太低就先修数据管道。
第三,RRF k值不适配。如果你的每路候选集顺序质量很差,排前面的文档大量是噪音,k=60仍然会放大头部噪音的贡献。这时候把k调大到120甚至200,让各路尾部重叠的“老实文档”也能翻身,往往有奇效。这个跟“头部越可信k越小”的规律正好相反,要结合实际判断。
5.2 中文分词影响BM25的常见错误
中文分词坑太多,我单列一个子节讲。
最常见的错误是使用standard analyzer索引中文。standard本质是按空格和标点切分,中文一句话变成一整块token,BM25计算时根本没法匹配“合同”和“显卡”这种中文词。典型症状:文档里明明写“显卡采购合同”,query搜“采购”返回结果,但搜“显卡采购”反而什么都搜不到。改用IK或分词器后迟钝立消。
第二个常见错误是分词粒度不统一。索引端用了ik_max_word(最细粒度),查询端用了ik_smart(粗粒度),两边词项不一致,一样会匹配失败。我的建议是两端必须使用同一分析器参数配置,或者在mapping里加一个sub-field分别存储两种粒度,查询时靠权重控制。
第三个坑是数字和字母粘连问题。IK分词对“RTX4090”“error1001”这类词的切分行为极其不稳定,不同版本表现还不一样。给这类词建keyword sub-field是最稳的方案。我现在的习惯是无论什么业务,都会在文档模型里加一个exact_keyword字段,专门存储全文的连续数字字母序列分词,用BM25匹配时优先匹配这个字段。
5.3 ES查询超时与数据一致性
线上最磨人的一类问题是ES查询偶发超时。排查下来有几种典型原因:
一是knn查询中的num_candidates设得过大。当文档总量到千万级,num_candidates=1000会让每轮ANN搜索的图遍历负载大幅上升。把num_candidates降到200,配合更大的k值做融合,效果指标几乎不变,延迟却能下降40%。
二是索引segment太大。ES的knn依赖HNSW图,segment合并策略会显著影响查询性能。如果是频繁写入的实时数据,建议调大refresh_interval到30s以上,减少segment重建频率。另外每天让doc_id一样的文档做一次update而不是重复新增,也能减少ES内部的文档版本垃圾。
三是BM25查询在超长文本上的评分开销。你把content_text整篇塞进text字段且没有限制indexed字数上限,高词频大文档的评分计算会拖慢查询。通常的做法是只索引前N个字符作为检索目标,或者干脆按chunk粒度而不是文档粒度建索引。
第四,数据一致性是分布式检索的隐形杀手。当检索和向量库是两个系统时,文档在ES里已更新但在向量库还是旧版本,线上就会出现“同一个doc_id两边结果说的不是一回事”的灵异现象。建议在doc_id之外再加一个version字段,每次写入递增;查询融合阶段如果发现两路同doc_id的version不一致,要以更新的版本为准,并在日志里记录mismatch次数。这个数字如果超过5%,你就该检查两边的同步管道了。
常见问题速查表
| 现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 精确型号搜不到 | 分词器拆词或大小写混用 | 打印ES _analyze结果 | 增加强制保留词表/添加exact_keyword字段 |
| 语义改写查不到 | dense模型能力不够 | 单测几组同义改写query | 换更强的embedding模型,或在query端做改写扩充 |
| 融合后头部效果变差 | RRF k太小+头部噪音 | 对比NDCG@10和MRR | 调大k到100+,或对每路候选集做置信度过滤 |
| BM25返回大量无关结果 | 未做query归一化 | 查看日志原query | 统一全半角大小写,过滤停用词 |
| 检索延迟超过1s | num_candidates过大/串行调用 | 分链路打点耗时 | 并发调用+调低num_candidates+增加缓存 |
| dense向量分页后不齐 | chunk策略不一致 | 对比索引前后文本 | 统一切分逻辑并对向量库做全量重建 |
最后再分享一个实操建议
如果让我只给一条经验,那就是:混合检索不是终点,但它是你把RAG地基打牢的必要条件。在你把RRF加上去的头一个月,我强烈建议每个检索请求都记录三份信息——BM25候选排名、dense候选排名、融合后最终排名。这样一旦线上出现badcase,你能立刻看出是哪一路召回的锅,还是融合逻辑的锅,而不是对着黑盒猜。
另外一个细节:RRF融合层还应该容忍单路检索服务临时不可用。我这边的线上实现里,如果dense路超时,可以选择只用BM25路的结果直接返回,而不是报错;只要监控里标记“降级模式”的次数,事后复盘就行。对问答系统而言,返回一个次优但相关的答案,永远比返回一个错误提示更符合企业用户的预期。