跳到主要内容
RAG 翻车,问题出在"检索"这个词上

RAG 翻车,问题出在"检索"这个词上

图南
图南

· 阅读约 9 分钟

你有没有过这种体验:RAG 系统搭好了,demo 里样样都好,一上真实流量就翻车。第一反应永远是换更强的模型、换更贵的 embedding,折腾一整圈,该翻车还是翻车。dev.to 上那篇被转了一整轮的文章里有个数字:RAG 失败的时候,73% 的情况栽在检索阶段,不是生成阶段。这个数字最大的价值,是帮我们把锅从生成侧摘掉了——问题不是模型不会答,是压根没把该答的东西捞上来。

但"检索"这个词的粒度太粗了,粗到把五六道完全不同的工序糊成了同一个词。说人话就是:检索不是"查一下"这个动作,是一条流水线——分块、嵌入、召回、重排、组装,每道工序都有各自独立的翻车姿势,而且姿势完全不同。把检索当一个单点去优化,这个思路本身错配。这篇讲完你会得到的那个心智模型是:RAG 的成功率,是整条流水线里每一道工序失配率乘起来的结果。

先看第一道工序,分块。你去翻任何一份 RAG 教程,分块那一章永远是最短的——"块大小 1000 字符,重叠 100,完事"。但你可以试试把一个代码函数从中间切开:函数签名和 docstring 在上面那块,函数体在下面那块。下面那块以一个没头没尾的 if 开头,没有任何外围上下文。embedding 给它算出来的向量,可能跟"Python 判空的写法"这类查询在语义上靠得很近——但它实际是某个业务逻辑的异常分支,跟查询八竿子打不着。分块分的是文本的边界,切掉的却是语义的边界。语义边界一断,检索这关在起跑线上就输了,后面怎么调都补不回来。

所以分块的质量标准不是"大小均匀",而是"每一块能不能独立回答它可能被问到的问题"。这俩目标骨子里就打架:均匀分块要的是整齐,语义分块要的是自洽。结构感知分块先按文档自己的骨架切——标题、段落、表格、代码块——再处理超出容量的长块;语义分块更激进:先切成很细的碎片,再用嵌入相似度把相邻的碎块粘回一个"自含的话题单元"。这条思路的尽头,是让"一块"在意义上闭合,不是在字符数上闭合。

再往下是嵌入。这里有个便宜得不像话的操作,很多系统偏不做:把标题、摘要、章节路径这些上下文一起并进向量。原理不复杂——一段文字脱离上下文之后,语义是糊的。"它在这里返回 false",这句话脱离函数名,没人知道它在说什么。嵌入的时候把上下文焊进去,相当于给每个分块配了一段自我介绍,让它在向量空间里站稳位置。这就是上下文检索的核心:不是查询的时候再去找上下文,是嵌入的时候就让上下文跟分块一起出场。顺带一句,元数据别丢:来源、时间戳、章节路径留全。后面组装阶段要引用来源,评估阶段要追根溯源,元数据一缺,这两件事全无从谈起。

下一道工序,大概是整条线里被低估得最狠的:混合检索。"向量搜索包打天下"这个默认假设,是被教程教育出来的。但有一类查询吃的根本是精确匹配——比如一个 SKU、一个错误代码、一个产品型号。这类查询,语义上接近再准也没用:"SKU-7742"和"SKU-7743"在向量空间里可能挤得像近亲,但它们是两个不同的东西,查不到就是查不到。这里最容易被搞混的是:不是向量搜索做错了,是它被派去干了一件结构上就不适合它的活。精确匹配和近似匹配是两种匹配机制,你没法让一个近似机制从结构上把精确问题做对。

主流解法是混合搜索:BM25 拿关键词硬匹配,向量拿语义做近似,各出一份排名,再用 RRF 合成一份。RRF 一点花活都没有,不比分数,只比名次,把每个候选在两份排名里的倒数名次加起来。它聪明在绕开了一个坑:BM25 的分数和余弦相似度的分数,量纲完全不是一回事,直接相加等于把厘米和公斤做加法。而名次是跨系统可比的东西。这几年 BEIR、MTEB 上的结果反复验证同一件事:几乎没有哪种单独方法,能稳定赢过"BM25 加稠密嵌入加 RRF"这个组合。注意"几乎"这两个字——有人在自己小测试集上测完,宣布"单走向量就够了",这种结论基本是把自己数据集的简单,当成了方法的强大。

在往重排走之前,先把一扇门关上:查询侧还有一堆把戏——查询重写、HyDE、step-back prompting、问题分解。它们解决的是另一类失配:用户问法和文档写法对不上。这些很有用,但本质上是给流水线入口加的前置处理,不是流水线本身的工序。这条留到下次展开。

重排序。为什么召回之后还要单独做一道重排?机制上很直白:粗粒度召回的目标是"别漏",候选池必然放得宽,排名靠前不代表排名对。重排序器一般用交叉编码器,把查询和每个候选文档拼在一起,完整过一遍模型——贵,但它是在真实文本上做细粒度的相关性判断,不是隔着向量近似猜。打个比方:初筛是图书管理员按主题给你搬来二十本书,重排才是他一本本翻开目录和序言,挑出最对题的五本放你桌上。这个类比到这儿基本站得住,但有一个地方对不上:管理员是靠理解内容挑书,交叉编码器靠的仍然是统计相关性——只不过它是在"完整读过 query 和文档"这个条件下算的,比双编码器的近似,多了一个精度数量级。

工程配方通常是这样一个漏斗:混合检索召回 20 个候选,重排序收窄到 5 个,最终只把 3 到 5 个分块塞给大模型。重排序的提升幅度,在困难数据集上很吓人:MRR 普遍能拉高 5 到 15 个百分点,某些推理密集型基准上 nDCG@10 从 0.13 涨到 0.40。0.13 到 0.40 这种涨幅,已经不是一个"调优"能形容的量级,它等于把"基本靠猜"变成了"基本能看"。反过来说,也常有人跑完重排序回来报告"没效果",一怒之下把这道工序砍了——如果你手头的数据集简单到不管怎么排前五个都对,重排序当然没效果。这不是方法的问题,是测试集没有暴露差异的能力。

再往下来到组装。这一步有个被高估得很厉害的东西:context window。窗口只是物理上装得下,不代表模型会一视同仁地使用。超长上下文里注意力会被稀释,分块塞得越多,中间位置被有效注意到的概率就越低——这是位置偏差决定的,窗口物理上限管不着。所以组装有几条反直觉的规则:最强的分块放开头和结尾;分块总数控制在个位数;还有一条,要求模型在回答时引用来源。引用动作本身不改变答案,但它会逼着模型在生成时把相关分块重新拉进注意力范围——机制层面的一道保险丝。这个错位,上次拆 context window 的时候留了个尾巴,这回顺手填上了。

这几道工序全走对,系统也未必是对的。评论区有读者提过一句:检索到匹配的上下文,和"据此生成的声明当前为真且可执行",是两件事。检索管道负责的是"相关"——可"相关"不等于没过期,不等于支撑结论,更不等于该被执行。举个具体的坑:一份文档内容完全没错,但已经废弃了。很多系统把"废弃"留到查询时才去推断,于是过期文档一直被检索出来、一直被当成答案。正确做法是:文档的废弃状态在写入时就记进元数据,跟着文档一起被索引——在检索环节内部就把废弃的挡掉,而不是查询时靠猜。

再往下,跟检索质量无关但更致命的一层:执行授权。上下文完全正确,代理也可能拿着正确的上下文,去执行一个不该执行的操作。检索正确性和执行授权是两个独立的失败面——你把前者调到满分,后者照样翻车。一个是"答得对不对",一个是"做得该不该",这两件事一旦混着算,是系统设计里最贵的失误。agentic RAG 这类东西不是没用——多跳检索、高风险任务都靠它——但在分块、混合搜索、重排序这个地基打稳之前上 agentic,等于在漏水的管道上装智能阀门,阀门再聪明,水还是从裂缝里漏。

评估也跟着拆开:检索那一侧,用召回率、排名指标来量;答案是不是忠实于检索到的上下文,那是另一套东西,groundedness 这类指标管。两套指标混在一张表里出报告,什么都看不出来——三道不同的门,不可能被一个分数同时度量。

生产里还有一个大概率遇到的事:线上流量里六到八成的查询是重复的,或者近似重复。精确匹配的缓存可以直接上,收益稳得发指;语义缓存麻烦很多——阈值松一点点,语义相近但本质不同的查询就会拿到错答案;阈值紧,缓存又基本闲置。做不做语义缓存,取决于你的查询多样性和对答案新鲜度的容忍度,这个权重没有标准答案。这条留到下次展开,正好把缓存命中率怎么算一起讲了。

说到底,RAG 不是"查一下再喂给 LLM"的动作,是一条分块、嵌入、混合检索、重排序、组装逐层叠起来的流水线。73% 的失败发生在检索阶段,但"检索阶段"不是一个可以单独修好的零件箱,而是一整条每一道工序都会失配的流水线。就算整条流水线全走对,也还有三扇门拦着:检索到相关资料,答案当前为真,操作该被执行——任何一扇没开,系统都过不去。

有了这层心智模型,下次再看到"我们升级了 embedding,RAG 效果大幅提升"这种宣传,你自己就能判断:改的是流水线里的哪道工序,还是只把管道里的水换了个牌子。这两种改法,成本不一样,效果也不会一样。