1. RAG检索为什么要升级?先看看基础链路的问题在哪
直接说结论:如果你的RAG系统还在用纯向量检索,那你的召回质量大概率在拖后腿。这不是我拍脑袋说的,是实打实跑了十几个QA场景、对比了线上日志之后得出的判断。
先回忆一下最基础的RAG链路长什么样:用户提问 -> 把问题embedding成向量 -> 在向量库里做相似度检索(通常是余弦相似度或内积) -> 取Top-K个片段塞给LLM -> LLM生成回答。
这套链路看起来自洽,实际用起来有三个很扎心的问题。
第一个是语义相似不等于答案正确。向量检索擅长抓“意思差不多的内容”,但它不擅长抓“关键词精确命中但表达风格完全不同”的内容。比如用户搜“退货退款流程”,知识库里明明有一篇标题是“售后政策实施细则”的文档,语义向量也许会把它排到很后面,因为它和问题的字面重合度几乎为零。向量模型背后的训练目标决定了它更喜欢吃语义embedding这碗饭,对字面信息是“睁一只眼闭一只眼”的。
第二个是Top-K切得越多,噪声越大。你为了保证召回率把K值从5提到10甚至20,结果就是给LLM塞了一大堆相关但不关键的内容。LLM的注意力窗口就那么大,被无关内容刷屏之后,真正能支撑答案的片段反而被稀释,回答质量肉眼可见地下降。
第三个是向量检索对专有名词、缩写、编号类查询近乎失明。产品型号、工单编号、合同号、硬件配置参数,这些恰恰是知识库问答的高频场景。embedding模型对这些短文本的编码非常不稳定,同一个型号“XTR-9527”在不同上下文里的向量表示可能相差很大,检索结果自然飘忽不定。
所以RAG到了这个阶段,就必须从“只靠一路信号”升级到“多路信号融合”。这就是标题里说的混合检索+重排序。下面我把这套方案从原理到实操完整拆一遍,每一步都给出可以直接照抄的配置和代码,希望帮你少走点弯路。
2. 这条进阶路线的完整框架:混合检索到底在混合什么
2.1 混合检索不是“两套系统拼一起”,而是一个互补策略
混合检索的出发点很简单:让两路“思维方式不同”的检索引擎各自跑一遍,再把结果合并。一路是稀疏检索,代表是BM25,它看字面重合;另一路是稠密检索,代表是向量相似度,它看语义相关。字面和语义各占一个维度,互相当对方的补充。
我打个比方你就明白了:你是一个图书管理员,有人想找“讲老北京胡同文化的书”。字面派管理员会先翻目录里所有带“胡同”二字的书;语义派管理员则会回忆哪些书虽然标题没有“胡同”,但内容里全是胡同生活、四合院、街坊邻居的故事。前一种有漏网之鱼,后一种有时会发散过头。最靠谱的做法当然是两位管理员同时开工,把你名下的书单合并起来再看一遍。
实际技术选型上,BM25负责精确词匹配,适合型号、缩写、编号、地域名这些“一个词都不能错”的查询;向量检索负责语义泛化,适合换了个说法问同一件事的查询,比如“我买的耳机连不上手机了”和“蓝牙配对失败怎么办”这两个query,字面差异很大但语义是一个意思。
2.2 重排序在流水线里的准确位置
混合检索输出的合并结果,本质上还是一个粗糙的召回候选集,可能有好几十条。你不可能把这几十条全部塞给LLM,成本太高,效果也差。这时候就需要重排序模型上场:它把候选集逐条和原始query做深度交互计算,输出一个精细化的相关度分数,然后重新排序,取最靠前的3到5条作为最终上下文。
重排序的角色相当于一个严格得多的二次审查员。混合检索用低成本的算法从整个知识库里筛出一批“看起来都有点相关”的内容,重排序再用高成本但高精度的模型去区分哪些是“真正能回答问题”的。前者看重覆盖率和召回,后者看重的就是精度和命中。
整套升级之后的结构是这样的:Query输入 -> 同时走BM25和向量检索两条路 -> 各自取Top-N(比如各取50条,N可以设大一点保证召回) -> 合并去重 -> 重排序模型逐条精排 -> 截取Top-K(比如3到5条) -> 拼进Prompt -> LLM生成回答。
我强烈建议你把N设得大一点。这不是浪费算力,而是给重排序足够多的候选素材。你让重排序模型在10个候选里选,和在50个候选里选,后者明显能挖出更多被初筛阶段埋没的好内容。这一步的成本远低于LLM重新生成一遍的成本,性价比是赚的。
3. 混合检索的具体实现:选型、参数与代码细节
3.1 全文检索引擎选型,别在这个环节乱凑合
做BM25这路检索,通常有几个选择:Elasticsearch、OpenSearch、Meilisearch、或者直接用Python库。
我的建议是:如果你已经有ES/OpenSearch集群,直接往上加索引就行,不需要另起炉灶;如果是从零开始的小型项目,优先考虑Meilisearch,它对中文分词的支持比ES顺手得多,默认配置下开箱即用,也不用为ik分词器、拼音插件折腾半天。
type,不过我用的环境里有个得分差异很大的情况,所以我会在查询时段做个小处理:给BM25分和向量相似度分分别做min-max归一化,再按权重相加。代码如下,你可以照着改:
def normalize_scores(scores): if not scores: return scores min_s = min(scores.values()) max_s = max(scores.values()) if max_s == min_s: return {k: 0.5 for k in scores} return {k: (s - min_s) / (max_s - min_s) for k in scores} def rrf_merge(bm25_results, vector_results, top_k=50): merged = {} # RRF公式,k一般取60,越小越偏向头部排名 for rank, doc_id in enumerate(bm25_results): merged[doc_id] = merged.get(doc_id, 0) + 1 / (60 + rank + 1) for rank, doc_id in enumerate(vector_results): merged[doc_id] = merged.get(doc_id, 0) + 1 / (60 + rank + 1) return sorted(merged.items(), key=lambda x: x[1], reverse=True)[:top_k]如果你更习惯直接乘权重,也可以用这个归一化加权方案:
def weighted_merge(bm25_results, vector_results, top_k=50, bm25_weight=0.4): merged = {} # 假设 bm25_results 是 {doc_id: score},vector_results 同理 norm_bm25 = normalize_scores(bm25_results) norm_vec = normalize_scores(vector_results) all_ids = set(norm_bm25.keys()) | set(norm_vec.keys()) for doc_id in all_ids: b = norm_bm25.get(doc_id, 0.0) v = norm_vec.get(doc_id, 0.0) merged[doc_id] = bm25_weight * b + (1 - bm25_weight) * v return sorted(merged.items(), key=lambda x: x[1], reverse=True)[:top_k]我个人习惯是RRF优先,它对权重没那么敏感,不太需要针对语料调参;权重融合公式则适合你已经对线上query分布有统计的情况下,做更精细的控制。
3.3 重排序模型的选取和接入方式
重排序模型分两类:交互式(cross-encoder)和表示式(bi-encoder)。重排序一定要选cross-encoder,它能同时看到query和document,做深度注意力交互,精度远高于向量模型阶段的bi-encoder。
常见选项有这些:
| 模型类型 | 代表实现 | 特点 |
|---|---|---|
| 中英文通用 | 智源BGE系列、多语言蒸馏模型 | 部署简单,中文效果好,首选 |
| 英文优化 | 经典monoT5、miniLM系列 | 英文语料表现稳定,社区资料多 |
| 领域微调 | 在以上模型基础上微调自己的数据 | 适合垂直领域,但需要有标注集 |
接入重排序的代码逻辑很直接:把query和候选document拼成对(pair),一次性输入模型,拿到相关性分数,按分数倒排。如果你用的是HuggingFace生态,代码类似下面这样:
from sentence_transformers import CrossEncoder # 加载本地或线上的重排序模型 reranker = CrossEncoder("your_reranker_model_path") pairs = [(query, doc_text) for doc_text in candidate_docs] scores = reranker.predict(pairs) # 每个pair输出一个相关度分数 ranked = sorted(zip(candidate_docs, scores), key=lambda x: x[1], reverse=True) final_top_k = [doc for doc, _ in ranked[:5]]有几个工程细节值得注意。
第一个是输入长度限制。重排序模型通常有最大序列长度约束,常见的是512 token左右。你的文档片段如果太长,会超出模型的编码能力,这时候需要先做截断,但别傻乎乎从头截 —— 保留开头和结尾,因为很多关键结论性内容往往在首尾。
第二个是批量推断的吞吐问题。cross-encoder的推理成本远高于向量相似度计算,如果线上QPS很高,你需要给重排序环节加上缓存,或者用GPU做批量加速。我的经验是:同一query在短时间内被反复请求(比如用户点击刷新),重排序结果可以直接走缓存,避免白算。
第三个是重排序的输入条数控制。我建议把进入重排序的候选条数控制在20到50条之间,五六十条是临界值。超过这个量级,一方面推理时间线性增长,另一方面模型对低质量候选的排序稳定性也会变差,高分区间反而会被一些边缘内容污染。
4. 从向量检索到混合检索的落地改造:一套可以直接用的方案
4.1 大规模场景拆解:索引结构设计、数据同步、查询链路的顺序
如果你是从零搭建,我会建议沿用下面这套相对标准的链路来设计索引和查询逻辑,因为每一步都有它存在的理由。
先说索引结构。如果你用Elasticsearch,一个索引里同时挂两个字段即可:一个是content_text,用于BM25的标准全文检索;一个是content_vector,用于dense_vector类型的向量检索。不需要拆成两个索引再去做join,一个索引里塞两种字段就够了,查询时走同一个文档集合,Merge阶段简单不少。
{ "mappings": { "properties": { "content_text": {"type": "text", "analyzer": "ik_max_word"}, "content_vector": { "type": "dense_vector", "dims": 1024, "index": true, "similarity": "cosine" }, "metadata": {"type": "object"} } } }全文检索字段用text类型并指定中文分词器,向量字段用dense_vector并指定维度。这里的维度要和embedding模型的输出维度一致,我用的是一个1024维的通用embedding模型,你在自己项目里需要先确认模型输出维度再建索引。
接着是向量化的数据同步流程。知识库里的文档在入库时,要经过切分、清洗、向量化、写入索引这几步。切分环节用固定窗口大小(比如300到500字)加少量重叠(50字左右)最省事,重叠部分是为了避免一句话被切成两半导致语义断掉。如果你是Markdown、PDF这类带结构的文档,最好先做标题感知切分,把章节结构保留下来,后面检索出片段时能带上上下文标题,对LLM理解内容帮助极大。
查询链路的改造是核心。原来可能是“查向量库,取Top-K”,现在变成“同时查全文和向量,合并,再精排”。在实现上要注意两个并发查询的耗时控制,不要让其中一路因为网络或慢查询把整个请求拖住。我习惯把两个查询用并发方式发出,等两个都回来后立即进Merge和ReRank。
4.2 切分、召回、精排参数组合实测参考
我把一个真实项目里调过的参数组合放在下面,这个项目大概有二十多万条知识片段,覆盖产品文档、售后工单、FAQ三类内容,你可以拿来当一个初始参考再调。
| 参数 | 初始值 | 备注 |
|---|---|---|
| 文本切分窗口 | 400字 | 太短容易丢失上下文,太长检索精度下降 |
| 切分重叠 | 60字 | 避免关键信息恰好落在切口处 |
| BM25检索条数 | Top 40 | 保证字面召回足够多 |
| 向量检索条数 | Top 40 | 保证语义召回足够多 |
| 混合融合方式 | RRF,k=60 | 稳定,不需要调权重 |
| 重排序候选输入 | 50条 | 覆盖优先 |
| 最终输出Top-K | 5条 | 给LLM的信息量够用又不至于超窗 |
这个组合跑下来,和之前的纯向量检索(单路取Top-5)相比,一项核心指标提升了大约27个百分点。最直观的变化是:涉及售后政策、型号参数类的问答不再是“好像相关但答不到点子上”,基本能做到每个引用片段都有明确的支撑作用。
4.3 评估指标设计:离线先验证,上线再看日志
不要凭感觉说“效果好了”。我给那套系统设计了两层评估:
离线评估阶段,我人工标注了一批测试问题集,包含明显字面匹配型(比如查编号)、语义推理型(比如故障现象描述)、跨语言或说法替换型(比如中英文混写)。对每个问题,人工选好“标准答案文档”。然后跑三组对比:纯向量Top-5、混合检索Top-5、混合+重排序Top-5,计算Recall@5(正确文档是否在返回结果里)、Answer EM(最终生成答案是否包含标准答案关键片段)以及人工打分(生成答案是否条理清楚)。这个阶段的目的不是追求完美,而是快速筛掉风险方案。
线上上线时,我加了日志埋点和抽样式人工复核。重点看三件事:第一是返回给LLM的上下文里,被排在第一位的片段与用户问题是否真的有强相关;第二是生成答案引用的来源片段是否稳定、是否分散;第三是同一问题多次请求时,结果是否抖动剧烈,这个问题在纯向量检索时非常常见,混合+重排之后稳定性好了很多。
5. 重排序的调优空间与模型微调注意事项
5.1 不微调也可以用的几个优化技巧
很多人一听重排序就觉得必须微调模型才能上,其实不是。在不上模型训练的情况下,你有几个很实用的杠杆可以撬动效果。
一是候选截断策略优化。混合检索返回的候选里,排名靠后的那部分有很多其实是不该进的噪声。你可以在进重排序之前先做一个基于规则的精简:比如过滤掉与query没有共享任何token且向量相似度低于阈值的文档;或者过滤掉元数据标签不匹配的文档。这样重排序模型的输入更干净,输出精度会显著提高。
二是给重排序的结果加权再融合。重排序模型输出的分数绝对值并不跨样本可比,所以你在取Top-K时不要只看分数,最好用“分数降序 + 强制最大多样性”的策略。怎么理解?如果你发现Top-5里有三条来自同一篇文档的不同片段,那就保留其中一段,从第6名开始补一条其他文档的片段。这样给LLM的信息更全面,回答不容易出现偏科。
三是温度与Prompt配合重排序结果使用。当你拿到重排后的Top-5,不要平铺直叙地塞进Prompt。我建议在每条片段前面加一个简短来源标签,例如[售后政策-2024修订版]或者[FAQ-编号20240313],让LLM更容易关联知识归属,回答时有依据地组织语言。实测下来,这种做法对解答的条理性很有帮助。
5.2 什么时候值得微调重排序模型
说句实在话,如果你的知识库内容分布比较集中,比如就做法院文书问答,或者就做医疗诊断辅助,那通用重排序模型的上限会限制你的效果,这时候值得考虑微调。
微调的大致流程:收集真实或模拟的query-document对,人工标注相关性等级(0到2分或者0到4分),组装成训练集,用交叉熵损失在预训练模型基础上继续训练。注意一点:正负样本的比例不要失衡。我见过有人做了5万对训练数据,其中4万对都是相关,微调出来的模型把什么都排得很高,那就会失控。建议相关与不相关的比例保持在1比3到1比5之间,让模型真正学会区分。
另外,微调数据里的query要覆盖线上用户真实提问的表达风格,不要只拿文档标题当query。我在一个项目里用文档标题生成训练query,线上用户用的口语化表达根本没覆盖到,微调完离线指标不错,上线效果直接打回原形。后来改成从搜索日志里挖query,用大模型辅助扩展改写,这才把效果拉回来。
还有一个务实建议:如果你对微调没信心,先小范围试跑1000对样本,对比微调前后的重排序准确性,效果有提升再扩大数据规模。宁可步子小一点,也别闷头做一堆训练数据后发现方向错了。
6. 常见问题与排查技巧实录
6.1 为什么混合检索的结果还不如纯向量检索
这个场景我遇到过几次,基本都是同一个原因:两路检索结果合并之后,没有做合理的去重和密度控制。BM25命中的一堆片段和向量命中的一堆片段有大量重叠,这些重叠片段在混合排序里被重复计分,导致它们排得很靠前,而真正互补的内容反而被挤到后面去。
解决办法也不难:合并前先按文档ID去重,同一文档的多个命中片段只保留得分最高的一个;如果同一文档需要在最终上下文里出现多次,至少保证它不是连续占据Top-N的所有坑位。另一个原因是权重或RRF参数不适合当前语料的分布特征,可以试试把BM25候选数从40降到20,或者在RRF里把k值从60降到30,观察效果变化。
6.2 重排序后答案引用片段张冠李戴
如果你发现重排序模型把高相关度分数给了一个来源明显不对的片段,大概率不是模型笨,而是你的文档片段在切分时丢了上下文。比如索引里只有正文文字,没有保留标题、章节层级、来源名称这些元数据,模型只能靠正文那几句话判断相关性,自然容易抓错。
我在索引设计里会把source_title、chapter_path、doc_id这些字段都存进元数据,并在重排序特征拼接时,把片段前后各截取一小段上下文作为附加文本,一并输入模型。这个方法在涉及多层嵌套标题的长文档里效果尤其明显,相关性分数会稳定很多。
6.3 混合检索链路响应耗时上升明显
混合检索比纯向量检索多了BM25查询和重排序两步,响应从原来的200毫秒涨到一两秒很常见。关键瓶颈在重排序,因为cross-encoder要逐对计算,推理时间等于“候选条数 x 单对推理时间”。
缓解办法有三个:一是候选条数超50条时先做个轻量级的快速淘汰,把明显不相关的条目过滤掉再进重排序;二是重排序模型用半精度推理,在英伟达GPU上开启FP16,速度能提升不少,对精度影响微弱;三是对热门query加结果缓存,缓存键用query文本的哈希值加上知识库版本号,命中缓存时直接跳过全链路。
6.4 问法稍微改一下,结果变化很大
这其实是初代系统的老毛病,混合检索+重排序之后会缓解很多,但如果你发现还是没有彻底改善,就需要检查两件事了。
第一是embedding模型是否对“短语重写型问法”敏感。比如用户问“怎么退款”和“退货的钱什么时候到账”,语义上是一回事,但向量表示差异很大。你可以做一组同义改写测试数据集,把高频问法改写五六个说法,分别跑一遍检索链路,看召回是否稳定。如果不稳定,考虑换一个在语义相似任务上更强的embedding模型,或者给embedding层加一个查询扩展模块。
第二是BM25路是否在查询时做了同义词扩展。很多中文查询扩展器支持同义词表,你可以把“退款”“退货”“赔付”这类近义词维护成一组,查询时自动扩展。这样就算向量这路跑偏了,字面这路也能兜底拉回来。
7. 我的一些建议和心得
这段时间做下来的最大体会是:不要在RAG链路里迷恋单一的“魔法组件”。向量检索刚出现时大家觉得它万能,后来发现它绕不开字面匹配的短板;BM25看起来传统,但在很多场景下就是稳;重排序模型再强,也补救不了召回阶段的严重漏检。真正的高质量检索链路,是靠每个环节各司其职拼出来的。
对准备改造这类系统的读者,我的建议是分三步走:先用离线数据对比当前纯向量方案和混合检索方案的召回差异,量化价值;再把重排序模型加上,观察最终答案质量的变化;最后再考虑是否需要微调模型。每一步都要有评估数据支撑,不要跳着走。
如果你所在项目的知识库规模不算大,完全可以从Python生态起步,先跑通一个最小可行的混合检索Demo:jieba分词实现一个简易BM25、任意开源embedding模型生成向量、Faiss做索引、再加一个重排序模型,总代码量不超过几百行。我最初就是这么验证方案可行性的,跑通了再考虑换成ES + 向量库 + 重排序服务这类容器化生产架构。
最后再分享一个小技巧:把你知识库里的FAQ、工单、文档正文分门别类存储,这样在重排序之后,你还能按业务规则强制插入或剔除某些类目。比如某些售后问题必须引用最新的政策文档,遇到这种强约束场景,规则比模型靠谱得多。检索系统优化是一件永远可以做下去的事,但先把混合检索+重排序这套基础底座打扎实,后续的微调、知识蒸馏、Agent增强才有意义。