☰
RAG检索优化实战:从查询改写、切分到混合检索与重排
2026/10/1 12:28:26 网站建设 项目流程

1. 检索结果答非所问,问题到底出在链路的哪一环

做RAG项目的人,十个里有八个在第一次上线后都会遇到同一个尴尬:用户问了一个明明知识库里有的问题,系统却答得驴唇不对马嘴,或者干脆答"根据现有资料无法回答"。这时候大多数人的第一反应是去调大模型参数、换更强的LLM,但折腾一圈发现效果没变。我踩过这个坑之后才明白,RAG答非所问,九成以上的根因不在生成端,而在检索端。

1.1 先搞清楚RAG这条链路到底有几个环节会出错

一个标准的RAG流程,从用户输入到最终输出,中间要经过这么几道关:查询理解与改写、向量化(Embedding)、向量检索(或混合检索)、结果重排(Rerank)、上下文组装、最后才是LLM生成。任何一环出问题,最终表现都是"答得不对",但病因完全不同。

我习惯用一个类比来解释:RAG就像你去图书馆找资料写报告。查询改写是你跟管理员描述你要什么;Embedding是把你的描述翻译成图书分类号;向量检索是按分类号去书架上拿书;Rerank是把拿回来的一堆书按相关度排序;上下文组装是决定把哪几本书的哪几页摊在桌上;LLM生成是你读完这些资料后动笔写。如果最后报告写得烂,可能是管理员理解错了你的需求,也可能是分类号翻译错了,还可能是书架上根本没那本书,未必是写报告的人文笔不行。

所以排查RAG问题的第一步,永远是把链路拆开,逐段验证,而不是一上来就怀疑LLM。

1.2 用"检索命中率"这个指标定位问题区间

我一般会先算一个粗指标:Hit Rate(命中率)。做法很简单,准备一批测试问题,每个问题人工标注出知识库里对应的正确文档片段(chunk),然后跑检索,看Top-K结果里有没有包含这个正确片段。如果Top-5的命中率低于70%,那问题基本锁定在检索端,跟LLM没关系。

这里有个实操细节:测试集一定要覆盖不同类型的问法。我见过太多人只用"文档里原句改个问号"来测试,命中率当然高,一上线遇到口语化提问就崩。我的做法是每个知识点准备三种问法——原文式、口语式、以及需要跨文档推理的复合式,这样测出来的命中率才有参考价值。

# 一个极简的命中率测试脚本骨架 test_cases = [ {"query": "RAG的检索增强具体增强在哪", "gold_chunk_id": "doc_12_chunk_3"}, {"query": "为什么我的知识库答不出问题", "gold_chunk_id": "doc_07_chunk_1"}, # ... 覆盖多种问法 ] hit = 0 for case in test_cases: results = retriever.search(case["query"], top_k=5) retrieved_ids = [r.chunk_id for r in results] if case["gold_chunk_id"] in retrieved_ids: hit += 1 print(f"Hit Rate@5: {hit / len(test_cases):.2%}")

跑完这个脚本,如果命中率低,就继续往下拆:是Embedding模型不行,还是chunk切得不对,还是查询本身没被正确理解。定位到具体环节,再对症下药,比盲目换模型高效十倍。

1.3 查询改写:被最多人忽略的第一道关

很多RAG系统的查询改写环节是空的,用户问什么就直接拿什么去检索。这在知识库文档措辞和用户提问措辞高度一致时没问题,但现实中两者往往差得很远。用户问"这个功能怎么收费",文档里写的是"计费策略说明",字面重叠度极低,向量检索很容易漏掉。

我的经验是,查询改写至少要处理三件事:一是把口语化表达转成更接近文档的书面表达;二是把指代消解掉("它""这个"要还原成具体名词);三是把复合问题拆成多个子查询分别检索。第三点尤其重要,用户一句"对比A方案和B方案的优缺点",直接检索往往两边都拿不全,拆成"A方案的优缺点"和"B方案的优缺点"两次检索再合并,效果好得多。

提示:查询改写可以用小模型做,也可以用规则+LLM混合。我实测下来,用同一个小参数量的LLM做改写,成本可控,效果比纯规则好很多。但要注意改写不能过度,把用户原意改跑偏了反而更糟,所以改写后的查询和原查询建议都保留,做多路检索。

2. 知识切分不当,再好的模型也救不回来

如果说检索是RAG的命脉,那chunk切分就是命脉的命脉。我见过太多项目,模型选的是顶配,向量库也是主流方案,但因为切分策略粗糙,整个系统效果就是上不去。切分这件事看起来简单,实际上坑最多,也最考验对业务数据的理解。

2.1 固定长度切分的三个致命伤

新手最常用的切分方式是"按固定字符数切,加一点重叠"。这个方法上手快,但有几个绕不开的问题。

第一个是语义截断。一个完整的论述被从中间切开,前半段在chunk A,后半段在chunk B,检索时只召回A,LLM看到的就是半句话,自然答不全。第二个是标题与正文分离。很多文档是"标题+正文"结构,固定切分很容易把标题单独切成一个chunk,正文切到另一个chunk,结果标题那个chunk信息量极低却可能被召回,浪费上下文窗口。第三个是表格和列表被切碎。表格一旦跨chunk,行列对应关系就丢了,LLM读到的是一堆错位的数字。

我一般的做法是按文档结构切分优先,固定长度兜底。具体来说,先按标题层级(H1/H2/H3)切,每个最小标题下的内容作为一个语义单元;如果某个单元还是太长,再在单元内部按段落切;段落还长,才用固定长度切并加重叠。这样能最大程度保住语义完整性。

2.2 重叠窗口设多少,得看你的文档类型

重叠(overlap)是为了缓解截断问题,让相邻chunk有部分内容重复,检索时至少能捞到完整语义的一部分。但重叠设多少,很多人是拍脑袋定的。

我的经验值是这样的:技术文档、法律条文这类逻辑严密的,重叠设chunk长度的15%到20%,因为一句话的完整意思经常跨段;新闻、博客这类段落独立的,重叠设5%到10%就够了,设太多反而引入噪声。另外,重叠不是越大越好,重叠过大意味着存储和检索成本上升,而且容易召回大量重复内容,挤占上下文窗口。

这里有个容易被忽略的点:重叠部分在组装上下文时要去重。我见过系统把重叠内容原样塞给LLM,结果LLM看到重复信息,输出里也跟着重复,体验很差。组装上下文时做个简单的去重或标记,能明显改善输出质量。

2.3 元数据是切分时最该顺手做的事

切分的时候,除了文本内容,一定要把元数据一起存进去。什么是元数据?就是这段chunk来自哪个文档、哪个章节、第几页、什么类型(正文/表格/代码)。这些信息在检索和生成阶段都有大用。

检索阶段,元数据可以用来做过滤。比如用户问的是"2024年的政策",你可以先用元数据把年份不等于2024的chunk过滤掉,再在剩下的里面做向量检索,精度立刻提升。生成阶段,元数据可以用来做引用标注,让LLM回答时能说清楚"根据《XX文档》第3章",用户信任度完全不一样。

# chunk元数据的建议字段 chunk_metadata = { "doc_id": "policy_2024_001", "doc_title": "2024年度服务政策说明", "section": "第三章 计费规则", "page": 12, "content_type": "text", # text / table / code "updated_at": "2024-06-01" }

注意:元数据字段不要贪多,够用就行。字段太多会增加存储和检索的复杂度,而且很多字段实际用不上。我一般保留文档标识、章节、类型、时间这四类,基本覆盖了绝大多数过滤和引用需求。

3. 向量检索不灵时,混合检索和重排该怎么上

向量检索是RAG的默认方案,但它不是万能的。向量检索擅长语义相似,不擅长精确匹配。用户问一个具体的编号、人名、专有名词,向量检索经常召回一堆语义相近但就是没有那个关键词的chunk。这时候就需要混合检索和重排来补位。

3.1 什么时候该上混合检索

判断标准很简单:如果你的知识库里存在大量专有名词、编号、代码、缩写,就该上混合检索。纯向量检索对这些"精确token"不敏感,而关键词检索(BM25这类)恰好擅长这个。

混合检索的做法是把向量检索和关键词检索的结果融合。融合方式有两种主流:一种是加权求和,给两路结果各算一个分数,按权重相加;另一种是倒数排名融合(RRF),不看具体分数,只看排名,把两路排名做倒数相加。我实测下来,RRF更稳,因为它不依赖两路分数的量纲对齐,省去了调权重的麻烦。

# RRF融合的简化实现 def rrf_fusion(vector_results, keyword_results, k=60): scores = {} for rank, doc in enumerate(vector_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) for rank, doc in enumerate(keyword_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)

那个k值一般取60,是经验值,不用太纠结,在50到70之间效果差别不大。

3.2 重排模型是性价比最高的一步优化

如果只能做一项优化来提升RAG效果,我会选加重排(Rerank)。原因很简单:检索阶段为了不漏,通常会召回Top-20甚至Top-50,但真正能塞进LLM上下文窗口的可能只有Top-5。这中间的筛选,用重排模型来做,比单纯靠向量相似度排序准得多。

重排模型(比如各种Cross-Encoder架构的)会把查询和每个候选chunk拼在一起打分,虽然慢,但精度高。我的做法是检索召回Top-30,重排后取Top-5,这个组合在大多数场景下效果和成本的平衡最好。

这里有个坑要提醒:重排模型和Embedding模型最好配套选。有些重排模型是针对特定Embedding训练的,混搭可能效果打折。如果不想折腾,选同一家或同一系列的产品,省心。

3.3 检索参数不是调一次就完事

Top-K取多少、相似度阈值设多少,这些参数没有一劳永逸的值,得根据你的知识库规模和问题类型动态调。知识库大、问题宽泛,K可以大一点;知识库小、问题具体,K小一点反而准。

我一般会做一个小规模的参数扫描:固定测试集,把K从3扫到20,看命中率和最终答案质量的变化曲线,找到拐点。相似度阈值也是类似,设太高会漏召回,设太低会引入噪声,扫一遍找到平衡点。这个过程花不了多少时间,但收益很实在。

参数常见取值范围调大后的影响调小后的影响
Top-K3 ~ 20召回全但噪声多精准但易漏
相似度阈值0.5 ~ 0.8噪声少但可能漏召回全但噪声多
重排后保留数3 ~ 8上下文丰富但可能超窗精简但可能缺信息
重叠比例5% ~ 20%语义完整但冗余精简但易截断

4. 上下文塞不下、塞不对,生成质量照样崩

检索做对了,chunk也切好了,结果LLM还是答不好,这时候问题往往出在上下文组装这一环。很多人以为把检索到的chunk一股脑拼起来丢给LLM就行,实际上这里面的讲究不比检索少。

4.1 上下文窗口不是越大越好

现在很多模型支持很长的上下文,于是有人就把Top-20的chunk全塞进去。结果呢?LLM在长上下文里"迷失"了,关键信息淹没在大量无关内容中,反而答不准。这就是所谓的"lost in the middle"现象——模型对上下文中间部分的信息利用效率明显低于开头和结尾。

我的做法是宁精勿多。检索召回可以宽,但最终塞给LLM的一定是经过重排精选的Top-3到Top-5。如果确实需要更多信息,宁可分多轮对话,也不要一次性堆进去。另外,把最相关的chunk放在上下文的最前面或最后面,中间放次要的,能稍微缓解中间迷失的问题。

4.2 上下文里要带上"来源感"

LLM生成时,如果上下文里明确标注了每段内容的来源和结构,输出质量会明显提升。我一般会在组装时给每个chunk加上类似这样的标记:

[来源:2024年度服务政策说明 - 第三章 计费规则] 按使用量阶梯计费,前1000次免费,超出部分每次0.01元。 [来源结束]

这样做有两个好处:一是LLM知道这段内容的边界在哪,不会把不同chunk的内容混着编;二是生成时可以直接引用来源,用户看到"根据《XX》第三章"会觉得靠谱。引用标注这个功能,对提升用户信任度的作用被严重低估了。

4.3 提示词模板要留"不知道"的出口

这是我在实际项目里踩过的最大的坑之一。早期我的提示词是"请根据以下资料回答问题",结果LLM遇到资料里没有的问题,也会硬编一个答案出来,也就是幻觉。后来我改成明确告诉它"如果资料中没有相关信息,请直接说无法回答,不要编造",幻觉率立刻降下来。

提示词模板里,"不知道"的出口必须显式写出来,而且要写得强硬一点。我常用的模板结构是这样的:

你是一个严谨的问答助手。请严格根据下面提供的资料回答问题。 规则: 1. 只使用资料中明确提到的信息,不要引入外部知识。 2. 如果资料中没有足够信息回答问题,直接回复"根据现有资料无法回答该问题"。 3. 回答时标注信息来源。 资料: {context} 问题:{question}

提示:规则里的第2条,措辞越明确越好。"无法回答"比"可能无法回答"效果好,"直接回复"比"可以回复"效果好。别小看这几个字的差别,实测对幻觉率有影响。

5. 知识库更新后效果变差,增量维护的坑怎么绕

RAG系统上线不是终点,知识库是要持续更新的。而更新往往是效果回退的高发期。我见过系统上线时好好的,运营往里加了一批新文档,结果老问题的回答质量反而下降了。这背后的原因,值得单独拎出来讲。

5.1 新旧文档的Embedding分布漂移

不同时间、不同来源的文档,用同一个Embedding模型编码后,向量分布可能不一致。新文档如果措辞风格和老文档差很多,检索时容易出现"新文档霸屏"或"老文档被挤掉"的情况。这不是模型坏了,而是数据分布变了。

我的应对办法是定期做全量重编码,而不是只对新文档做增量编码。全量重编码成本高一点,但能保证所有向量在同一"坐标系"下,检索一致性更好。如果知识库特别大,至少也要定期抽样检查新旧文档的检索表现,发现漂移及时处理。

5.2 删除和修改比新增更容易出问题

新增文档相对简单,麻烦的是删除和修改。如果一篇文档被删了,但它的向量还留在库里,检索时就会召回已经不存在的内容,LLM据此生成的答案就是过时的。修改同理,改了正文但没更新向量,检索到的还是旧内容。

所以知识库维护必须做到文档和向量严格同步。我的做法是给每个文档一个唯一ID,文档的任何变更(增删改)都触发对应向量的同步操作。删除时按doc_id批量删向量,修改时先删旧向量再插新向量。这个逻辑听起来简单,但实际做的时候很容易漏,建议写成统一的更新入口,所有变更都走这个入口,避免散落各处。

5.3 用回归测试守住效果底线

知识库每次更新后,我都会跑一遍回归测试集——就是前面提到的那个带标注的测试问题集。看命中率和答案质量有没有明显下降。如果下降了,就回滚这次更新,排查原因。

这个测试集要持续维护,每次发现新的bad case就补进去。时间长了,它就变成了你系统的"体检表",任何改动的影响都能第一时间发现。我现在的习惯是,没有回归测试通过,知识库更新不上线,这条规矩帮我避免了好几次线上事故。

更新类型常见风险应对措施
新增文档分布漂移、霸屏定期全量重编码
删除文档残留向量被召回按doc_id同步删除
修改文档旧向量未更新先删后插,统一入口
批量导入格式不一致导入前做格式校验

6. 几个我反复验证过的实操心得

聊完这五类问题,再分享几个贯穿始终的经验,都是我在多个项目里反复验证过的。

第一,先跑通最小闭环,再谈优化。很多人一上来就纠结用哪个向量库、哪个重排模型,结果链路都没跑通。我的建议是先用最简单的方案(固定切分+基础向量检索+现成LLM)跑通端到端,拿到第一版效果数据,再针对瓶颈逐项优化。没有基线,优化就是盲人摸象。

第二,bad case比指标更重要。Hit Rate、召回率这些指标能告诉你"大概怎么样",但真正指导优化的是一个个具体的bad case。我习惯建一个bad case库,每次发现答得不好的问题就记下来,分析根因,归类。时间长了你会发现,问题往往集中在少数几类,解决这几类,整体效果就上一个大台阶。

第三,别迷信"最强模型"。Embedding模型、重排模型、LLM,都不是越大越好。小模型在特定领域微调后,效果可能超过通用大模型,而且成本和延迟都更友好。我做过对比,在垂直领域知识库上,一个中等规模的Embedding模型配合好的切分策略,检索效果不输顶级大模型,但成本低一个数量级。

第四,把评估做成常态。RAG系统的效果会随着知识库变化、用户提问分布变化而波动,一次评估说明不了问题。我现在的做法是每周自动跑一次回归测试,生成效果报告,有异常就排查。这个习惯让系统长期保持稳定,也让我对每次改动的效果心里有数。

最后说个我自己的体会:RAG这件事,工程细节的权重远大于模型选型。同样一套模型,切分策略、检索参数、上下文组装方式不同,效果能差出一倍。所以别总想着换模型,先把链路上每个环节的细节抠到位,效果自然就上来了。

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

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

立即咨询