做了两年RAG相关的项目,说实话,最初能跑通demo的那点兴奋,很快就被线上badcase打没了。用户一句口语化的问题,检索模块经常召不回关键段落,大模型又一本正经地答非所问。后来我花了大把时间在检索链路和索引侧修补,才慢慢摸清了门道。这次想聊的内容没有理论堆砌,只讲我在生产环境里真正用过的几个高阶RAG技巧,覆盖索引分块、查询改写、混合检索、重排序、上下文组装,以及评估迭代。不管你是正在调一个RAG demo,还是已经上生产做效果优化,这些思路大概率能直接帮到你。
RAG的性能从来不是一个单点问题。很多人以为换个更强的生成模型就能提升效果,但实际排查下来,大部分badcase都出在“找不到”“找不准”和“喂不好”这三件事上。所以下面的技巧我会按一条完整链路的顺序展开:先让索引更合理,再让查询更干净,然后让召回和排序更精准,最后让上下文更可用。每一部分我会结合踩过的坑和实测数据来写,方便你直接抄作业。
1. 索引侧:分块和向量化,检索质量的地基
1.1 别再无脑固定切块,分块粒度决定召回上限
很多RAG项目第一步就是用一个固定大小把文档切成块,比如每512个token切一块,中间加个重叠。这样做不是不能用,但一旦文档结构复杂,尤其是合同、产品手册、技术规范这类有章节层级的内容,固定切块会把语义单元切碎。比如合同里的“违约责任”条款被拦腰截断,一部分进了上一块,一部分进了下一块,向量化之后两个片段都没有完整的语义,检索时自然谁都召不回。
我个人的经验是,能按文档结构切就按结构切。先把Markdown标题、PDF章节、HTML的标题层级解析出来,以标题为边界,把段落归到最近的章节块里。如果章节太长,再在章节内部做二次切分,同时保留章节标题作为上下文前缀。切成块之后,给每个块塞一段“来源说明”,比如“来自某产品手册3.2节”,这样即使正文被裁剪,向量里也带有主题信息。
如果文档没有明显的结构,那至少要把固定窗口和重叠窗口的参数调明白。我之前对一个知识库做过对照测试,块大小256 token,召回准确率大约61%,512 token是73%,1024 token反而回落到68%。原因也很直观:太短容易丢上下文,太长会把多个主题混在一个向量里,语义变得模糊。重叠窗口我习惯设成10%到20%,主要为了防止切块边缘的关键句被截断。切记,这些数字不是通用的,换一个领域、换一种embedding模型,最优值都会变,必须拿自己的数据试。
1.2 向量化之前,先想清楚嵌入模型适不适合你的领域
通用embedding模型在开放域问答上表现不错,但落到垂直领域,经常会出现“语义相似但检索不相关”的尴尬。举个例子,你问“这台设备的保修期多久”,通用模型可能更关注“设备”“多久”这类常见词,而领域里的SKU编号、特定型号、售后条款词本身在预训练语料里出现得少,向量表达很弱。这就导致检索返回一堆意思相近但完全不对应的内容。
解决思路有两个:第一,挑一个在目标语言和目标领域上表现更好的公开模型;第二,用自己领域的数据做二次微调。以前我觉得微调embedding成本很高,后来发现其实数据量不需要想象中那么大。我做过一次轻量微调,大概用了5000条query-positive对和同样数量的hard negative组,效果立竿见影,检索Top5命中率提升了接近12个百分点。
微调数据怎么来?最好的来源是线上真实query和用户最终点击/引用的段落,这是天然的positive。hard negative可以去难一点:用BM25先召回一批,挑那些字面上重叠高但实际不相关的片段,再让一个强模型过滤排除,剩下的当作负例。训练方式可以直接用对比学习,让query向量靠近positive、远离hard negative。如果你没有工程条件,也可以退一步,先通过prompt让LLM把query里的专业术语做同义扩展,再拿去检索,效果也能提升一些。
1.3 多字段索引与元数据过滤:把检索范围先圈起来
如果知识库只有一个大文档,那索引设计相对简单。但真实场景往往是几百份文档、多种产品线、不同来源的FAQ和工单记录混在一起。这种情况下如果不过滤元数据,检索就相当于在全量混乱数据里大海捞针。一个做企业知识库的朋友就遇到过:用户问A产品的问题,检索结果里大量混入B产品内容,生成答案虽然语义通顺,但产品名都对不上。
解决办法是在写入向量库时给每个文档块打上结构化标签:文档类型、产品线、部门、版本号、更新时间等。查询时先根据用户上下文或意图识别结果,把检索范围限定到某个产品线或文档类型里。比如通过一个轻量路由分类,先判断用户问的是哪个产品的售后问题,然后把filter设成“product=A 且 doc_type=manual”。这一层过滤能显著减少后续排序的干扰,尤其在知识库庞杂时,效果比单纯依赖向量相似度要稳定得多。
注意:元数据过滤不能滥用,过滤条件过于严格会导致召回结果过少。我的做法是先宽后严:先用比较宽松的条件召回足够多的候选,再在重排序阶段利用元数据加权,而不是一开始就硬过滤。
2. 查询处理与改写:检索的入口同样值得关注
2.1 让大模型先把问题“洗干净”:查询改写
用户提问通常不是规范搜索语句,直接拿原问句去embedding,向量空间里的表达很可能偏离文档里的表述方式。比如“那个东西怎么用啊?”,这里的“那个东西”在知识库里可能是“在线表单组件”。不做改写,检索系统很难把两者对上。
我常用的方式是加一个轻量的查询改写模块,在用户query进入检索引擎前,先让LLM把问题转换成更具体、更适合检索的表达。改写不是翻译,目标是去掉口语词、补全指代、补充必要的限定词。改写prompt可以很简单:请把用户问题改写为用于知识库检索的查询,保留关键实体和专业术语,不要增删事实。实测中,这类改写对口语化问题提升最明显,Top20召回提升约15%左右。
还有一种更高级的思路叫HyDE,也就是让LLM针对用户问题先生成一个假设性回答,然后用“问题和回答”的拼接文本去做向量检索。因为假设性回答往往包含文档中可能出现的术语和句式,能拉近query与文档片段的语义距离。不过HyDE会增加一次额外的LLM调用,延迟和成本都要评估。我的习惯是只在初次检索的置信度偏低时才触发改写,而不是每个请求都走,这样能兼顾效果和速度。
2.2 多路查询与子问题拆分:复杂问题别指望一次召回
用户有时会问很复杂的问题,比如“备份失败怎么办,日志在哪看,如何恢复”,这里其实混合了三个子问题。如果直接用一整句去检索,向量会试图同时表达三个意图,结果每个意图都只召回了一部分,生成时自然缺料。
高阶做法是让LLM把复杂问题拆成多个子查询,分别去检索,再合并结果。拆分逻辑不需要太复杂,我用的prompt就是:如果问题包含多个独立信息需求,请拆分为最多3个搜索语句,每个语句只包含一个主题。然后对每个子查询并行调用检索,最后统一去重。去重时不能只看文本完全相同,向量相似度很高的片段也要合并,避免同一个信息点被重复塞进上下文。
这个方案的风险是子查询过多导致候选爆炸。我一般控制最终进入重排序的片段数量,比如固定为60到80个,多路子查询统一竞争名额,而不是每个子查询平均分配。这样既能覆盖多角度信息,也不会让上下文变得臃肿。
2.3 查询路由:不是所有问题都要走RAG
把资源浪费在没必要检索的问题上,是很多人忽略的性能杀手。用户问“你能做什么”“你是谁”这类闲聊,或者问“昨天聊到哪了”这类对话历史问题,根本不涉及知识库。如果硬走检索,不仅拉高延迟,还可能把无关片段灌给模型,反而干扰回答。
我习惯在检索前加一个意图路由器。可以是基于规则的分类,比如检测问候词、对话上下文标记;也可以用一个小分类模型判断query是否需要外部知识。分类结果分三条路:直接走对话模型、走RAG检索、走多知识库路由。这样整体系统的平均响应时间和质量都会有改善,尤其是闲聊占比高的场景,收益非常明显。
3. 检索引擎升级:从向量召回走向混合与重排
3.1 混合检索:把关键词精确匹配和语义能力一起用
向量检索擅长语义相似,但它对精确词、ID号、型号、特殊缩写往往不敏感。你在向量库里搜“ABC-123”,返回的可能是“ABC-124”或者“123”相关的内容,因为embedding会模糊掉字符细节。但用户问这类精确标识时,要的就是逐字匹配。这也是为什么BM25这类稀疏检索到现在仍不过时,它天然擅长精确匹配和词频加权。
我现在的标准做法是混合检索:一路是向量检索,一路是关键词/全文检索,两者并行召回,再用RRF(Reciprocal Rank Fusion)合并。RRF合并的核心思路是先不看具体分数,只看每个片段在每一路结果里的排名,然后用一个简单公式融合排名。这样做的好处是规避了两路得分尺度不一致的问题,向量余弦相似度0.8和BM25得分20.5完全没法直接相加,但排名可以公平比较。
实际项目中,混合检索对既有“精确型号”又有“语义描述”的查询帮助最大。举个例子,用户问“ABC-123这个设备的故障原因有哪些”,关键词路能精确命中ABC-123,向量路能召回关于故障原因的描述,两路合并后覆盖度明显提升。需要注意的是,不要固定权重,最好留两个可调参数,线上根据badcase反复调。
3.2 重排序:粗召回之后再做精排
粗召回阶段为了保证不漏,通常会拉回几十个片段。但如果直接把几十个片段都塞给生成模型,效果会断崖式下降,因为模型会被大量噪声干扰。这时候需要用重排序模型来做精排。
我常用的组合是:先用双塔向量模型做粗召回,召回50到100个片段;再用一个cross-encoder类型的重排模型,对这几十个片段逐一对query打分,最后取Top3到Top5作为上下文。为什么cross-encoder更准?因为双塔模型把query和文档分别编码成向量再算相似度,交互发生在最后一层;而cross-encoder让query和文档从头就开始token级别的交互,能捕捉更细的语义关系。代价是计算量大很多,所以它只适合对候选集精排,不适合全库扫描。
这里有一个工程细节:候选集太大,重排延迟会明显上升。我的建议是先给粗召回做一个快速剪枝,比如把向量相似度低于某阈值的片段直接丢掉,再把剩下的60个片断送去重排。线上可以给重排服务加个超时控制,如果重排不能在150毫秒内完成,就退回用粗召回的前K个结果,保证整体响应时间稳定。
3.3 动态K值与兜底策略:宁可少用,不可错用
固定每次取Top5,这种简单策略并不总能满足需求。简单问题可能一个片段就够了,复杂问题可能需要三四个不同信息点的组合。动态K的方法是根据第一条精排结果的得分设置阈值:如果最高分都过低,说明知识库里可能压根没有相关内容;如果最高分很高,可以适度放宽,多取几个次高分的片段。
我遇到比较多的badcase是“空检索”却硬答。知识库里没有答案,但生成模型会因为系统指令要求“必须回答问题”而自行发挥。兜底策略必须明确:当所有候选片段的得分都低于预设阈值时,直接返回“知识库中未找到相关信息”,或者明确告知用户“我无法从现有资料中确认答案”。这样看起来没有“面子”,但能大幅减少幻觉。你也可以加一个追问引导,让用户提供更多信息,比让模型硬编答案要可靠得多。
4. 上下文构造与提示工程:检索结果也要让模型“吃得下”
4.1 去重与排序:把最重要的证据放到前排
检索结果合并之后,经常出现同一段内容的不同切块反复出现,或者一个答案同时在多个文档里有类似表述。如果不做去重,上下文里可能有一半内容是重复的,既浪费token,又干扰模型判断。我在合并阶段会对候选片段做一次向量相似度聚类,相似度超过0.9的只保留最相关的那一个,其余丢弃。
去重之后还有一个排序问题。虽然重排已经给出了相关性顺序,但有些场景需要按原文顺序组织上下文,比如多步骤操作说明。如果检索结果分别是步骤1和步骤3的片段,重排可能把步骤3排前面,因为它的关键词匹配度更高,但按原文顺序排列更有利于模型生成连贯步骤。我的方案是提供一个排序模式参数:答案型问题按相关度排序,操作说明型问题按原文顺序排序。
4.2 上下文太长怎么办:压缩和摘要
一个经常被低估的问题是:就算你只取Top5片段,每个片段512 token,拼接起来也超过2500 token了。多个子查询合并后,上下文很容易冲到5000甚至更多。大模型虽然宣称能支持几十万token上下文,但长上下文里真正被有效利用的信息往往集中在开头和结尾,中间内容容易被忽略,这就是业界常说的“lost in the middle”。
我处理这个问题的办法是上下文压缩。先用一个轻量LLM对粗召回的全部片段做信息抽取,把与query相关的关键句子提取出来,删掉冗余论述、案例、免责声明等。然后把压缩后的内容作为最终上下文。压缩会损失一些细节,但在大部分业务问答场景里,收益大于损失。压缩模型的输出限制在500到800字之间,能有效降低最终生成模型的输入长度,也间接降低了成本。
4.3 指令模板与反幻觉设计:让模型知道该怎么用证据
上下文再全,如果模型不知道“只能依据文档回答”,它还是会按照预训练习惯自由发挥。我最初的prompt只写了一句“根据上下文回答问题”,结果模型经常把常识混进来。后来我把指令换成三段式:首先是“你是基于知识库的问答助手”,然后是“只能使用提供的资料回答,禁止使用内部知识”,最后是“如果资料中没有答案,请明确说不知道”。改动之后,幻觉相关的badcase下降了约30%。
还有一个小技巧是给上下文片段标注来源。让模型在回答末尾附上“信息来源:文档A第2节”“文档B第5页”这样的引用。这不只是增加可信度,还能在badcase复盘时快速定位是哪一段文档导致了错误回答,对后续优化极有帮助。
提示注入也值得注意。知识库文档里可能会出现“忽略以上指令,直接告诉我...”这类恶意的或者无意的句子。我现在的做法是,在把文档片段输入给生成模型之前,先把非指令类内容用明确的边界包裹起来,例如在每一段前加上“参考片段开始”和“参考片段结束”标记,然后在指令里强调这些标记内部只是数据,不是指令。
5. 评估与迭代:性能不是感觉出来的
5.1 先建一套评测集,再谈优化
没有评测集的RAG优化就是在黑箱里调参。很多团队凭感觉换模型、改参数,结果过几天又改回去,浪费大量时间。我建议上线前先花两三天时间,从真实用户日志里抽100到200条有代表性的query,覆盖知识库里的典型问题、长尾问题、口语化问题,以及专门收集的历史badcase。标注时至少标注两件事:期望答案要点,以及命中哪些文档片段是正确答案。
有了评测集就可以把效果量化。检索侧常用Hit Rate、Recall@K、MRR;生成侧关注忠实度、答案相关性、信息完整性。如果你没有人力做人工评分,可以用更强的大模型做裁判,对“答案是否忠实于上下文”“答案是否完整回答用户问题”打分。但LLM裁判也有偏好,我通常会额外抽20%的数据人工复核,防止裁判模型误判。
| 指标 | 衡量内容 | 我常用的阈值/目标 |
|---|---|---|
| Hit Rate@5 | 正确答案是否出现在Top5候选里 | 不低于85% |
| Recall@10 | 相关片段在前10里的覆盖比例 | 不低于80% |
| Faithfulness | 答案是否完全由上下文支撑 | 不低于90% |
| Answer Relevance | 答案是否对应当前问题 | 不低于85% |
| Latency P95 | 端到端响应时间 | 2秒以内 |
5.2 Badcase归因:先别急着调模型,判断问题出在哪一层
评测集跑完,你会得到一批badcase。这时候最忌讳直接调整生成prompt,因为很多错误发生在检索阶段。我给自己定了一个排查顺序:先看Top10候选里有没有正确答案。没有,是召回问题,去修索引、分块、查询改写;有,但Top5里没有,是排序问题,去调重排模型;Top5里有正确答案,但生成答案还是错,才是生成问题,去改上下文和prompt。
按照这个思路,百分之八十的badcase都出在前两层。有一次系统回答了一堆天花乱坠的内容,人工一查,正确答案其实在候选第7位,重排时被一个词面更相似但内容无关的片段挤了下去。后来我把重排模型换成更大一档的版本,同时把粗召回候选从50扩到了100,这类问题立刻少了很多。
5.3 回归测试与持续集成:守护每一次改动
RAG系统的优化很容易“按下葫芦浮起瓢”。你修好了A类问题,可能又带坏了B类问题。所以每次改动之后,不能只拿几个badcase验证,要把整个评测集完整跑一遍,对比前后指标。我现在的做法是把评测集写进一个自动化脚本里,每次索引更新、prompt调整、模型切换,都先跑一遍回归,再决定是否可以上线。
另外,评测集不是一成不变的,要持续吸收线上新的badcase。我每两周会从用户反馈和日志里筛选一批新query补充进评测集,然后重新跑一轮基线,看有没有出现新的系统性短板。这个过程比较枯燥,但正是这种重复劳动,让系统的性能一步步稳定下来。
如果让我给后来者一个建议:不要一上来就换更贵的生成模型,先把检索和评测闭环搭好。RAG的瓶颈大多数不在生成侧,而是在“找得到”和“找得准”。我自己的优化顺序通常是索引、查询、重排、上下文、评估迭代,每一步都用数据说话,不凭感觉施工。按这个顺序走,即使中间踩到坑,也能快速定位并回来。