RAG 到底是怎么工作的,从分片到生成,一条链路拆到底

你想给公司搭一个客服机器人。

手上的资料是一本 200 页的产品手册,用户开口问的就是「你们的退货政策是什么」这种问题。答案全在手册里,模型只要照着答就行。

这种拿着自家资料回答问题的需求,绕不开一个技术,RAG。

这三个字母的全称是 Retrieval Augmented Generation,中文叫检索增强生成。名字听着挺唬人,事情本身不复杂,先从资料库里检索出相关内容,再让大模型基于这些内容生成回答。先检索,再生成,所以叫检索增强生成。

这篇我们就把这条链路拆到底。全文按时间线分成两半,用户提问之前发生了什么,提问之后又发生了什么。提问前讲分片和索引,提问后讲召回、重排、生成,收尾的时候我们再把整条链路完整重走一遍。

不过在拆之前,我们先干一件更实际的事。试试最省事的办法,看看它为什么不行。

第一次尝试

最省事的办法是什么,直接把手册塞给模型。

用户问「你们的退货政策是什么」,我们在发送问题的时候,把 200 页手册整个贴进 prompt 里,让模型自己从里面翻答案。

没错,这确实是个办法。

而且这两年模型的上下文窗口越做越大,200 页手册折成字数也就几十万字,不少模型从容量上讲是装得下的。但装得下不等于该这么干,这个方案在实际落地的时候会撞上三个问题。

第一个问题,窗口装不下是常态。

先补一句什么是上下文窗口。模型每次对话能记住的内容是有上限的,这个上限就是窗口,超出了,最早的内容就会被挤出去。200 页手册只是我们这个例子里的体量,真实的企业知识库往往不止一本手册,还有常见问题文档、历史工单记录、产品变更日志,资料只会越积越多,加在一起很容易把窗口顶穿。指望靠加长窗口解决问题,等于指望仓库永远够用。

第二个问题,成本高得离谱。

模型接口是按 token 计费的,token 可以粗略理解成按字算钱的计量单位,输入的每一个字都要花钱。用户只问了一句退货政策,我们却每次都把几十万字的手册重新发一遍。一天下来几万次提问,光输入就是一笔非常可观的账,而这些输入里绝大部分内容跟当次问题毫无关系。钱全部花在了每次都搬运一遍全书上。

第三个问题,速度慢到没法用。

模型处理 prompt 是要时间的,输入越长,第一个字蹦出来得越晚。用户在客服窗口里问一句退货政策,等的是几秒钟内弹出答案,而不是盯着加载圈看模型从头到尾消化 200 页手册。客服场景对响应速度极其敏感,慢一倍,体验就崩了,用户扭头就去转人工。

看来直接把整本文档丢给模型,是行不通的。

那你可能会想到另一条路,微调。把手册拿去训练模型,让知识直接长在模型的参数里,提问的时候什么都不用带。

这条路同样有两个硬伤。

一个是更新麻烦。手册改一版,模型就得重训一遍,训练是要花真金白银的,而企业文档偏偏是改动最频繁的东西,今天改退货天数,明天改例外清单,重训根本追不上。

一个是记不准。微调擅长让模型学会一种风格、一种格式,比如学会用客服的口吻说话,但它不擅长逐字记住精确条款。你让它把 200 页手册背下来,它只能背个大概,退货天数给你记成 14 天,这比不回答还危险。

所以知识的存放位置还是得放在模型外面,用的时候再查再发。

那我们换个思路。

用户问的是退货政策,手册里真正讲退货政策的,其实就一两页。我们是不是可以只把相关的那部分内容发给模型,其他 198 页完全不碰?

可以的。

窗口装不下的问题没了,因为每次只发几段。成本的问题没了,因为输入从几十万字缩到了几百字。速度的问题也没了,因为模型要消化的东西变少了。

三个问题一次全解掉。

现在只剩一个新问题,模型怎么知道手册里哪几段跟「退货政策」相关?

这就是 RAG 要干的全部事情。

全景图

我们先把 RAG 的全景图铺出来,心里有张地图,后面每一节都是在放大这张图的某一块。

来看这张图。

在线问答 提问之后

离线准备 提问之前

产品手册 200 页

分片

几百个小片段

Embedding 模型

片段向量

向量数据库

用户提问

Embedding 模型

问题向量

召回 top 10

重排 top 3

大模型

答案

100%

这张图分左右两半。

左半边是离线准备,发生在用户提问之前。手册先被切成小片段,片段经过 Embedding 模型变成向量,再连同原文一起存进向量数据库。这一套做完就静静躺在那,等着被查询。

右半边是在线问答,发生在用户提问之后。用户的问题同样过一遍 Embedding 模型变成向量,拿去向量数据库里查,先召回出一批候选片段,再重排精选出几个,拼进 prompt 发给大模型,大模型生成答案。

这种两段式的切分还有个工程上的好处。手册改版了,我们只需要重跑左半边,把新的片段和向量灌进库,右半边的问答代码一行都不用动。资料和逻辑分开,各自更新。

不过话说回来,这张图是一个过度简化的流程,我隐藏了很多实现细节。比如说是怎么切的、距离怎么算的、重排用什么模型,每一环里面学问都不少。下面我们就从第一环开始,一层一层拆。

拆的过程中我们会反复用到同一个例子,就是开头那个,200 页手册,用户问「你们的退货政策是什么」。全篇不换例子,跟着它走完一整条链路。

分片

第一环,分片。

为什么要分片呢?把手册整篇存进库里不行吗?

不行,原因有两个。

一个是检索的粒度问题。我们在生成阶段是要把片段塞给模型的,如果片段就是整篇手册,那塞给模型的还是半本书,等于绕了一圈又回到老路上。检索出来的东西必须足够小,小到模型一次只看几段真正相关的内容。

一个是匹配的精度问题。语义匹配是拿片段为单位去跟问题算相似度的,片段越长,里面混进的不相关内容就越多。一段 2000 字的内容里只有 100 字在讲退货,剩下 1900 字在讲保修,整段的向量被这些杂音稀释,跟问题的匹配反而变差。切小了,每一段讲的事更纯粹,匹配才更准。

那应该切多大呢?

经验值是每个片段几百字。当然这个数字并不是固定的,你可以切小一点也可以切大一点,具体是多少不是很重要,重要的是一个原则,语义完整。一个片段应该能独立成立,单拿出来也能看懂它在讲什么。切得太小,一句话孤零零的没有上下文。比如切出来一个片段只有「但定制品除外」六个字,单看这六个字,谁知道是在说退货还是在说保修。切得太大,又回到杂音稀释的问题。两头都是坑,几百字的片段是在两头之间取的平衡。

比数字更值得讲的是切法。因为分片这一环最容易踩的坑,就是按固定长度硬切,把一句话拦腰斩断。

我来给大家模拟一下这个过程。

手册第 87 页,售后服务章节,写着这么一句,「自签收之日起 7 日内可申请无理由退货,但定制品和已激活的软件除外」。

如果我们按每 100 字硬切,切的位置正好落在「但定制品」前面,这句话就被分成了两半。前半句进了片段 41,后半句进了片段 42。

用户问「你们的退货政策是什么」,召回回来的是片段 41。模型看到的退货政策是「7 日内可申请无理由退货」,例外条件那半句躺在另一个片段里,可能根本没被召回。

于是机器人一本正经地告诉用户,所有商品 7 天无理由退货。买定制品的用户听了直接下单,退的时候被拒,客诉就来了。

这种错最难查的地方在于,链路上每一环看起来都正常运转。分片切了,索引建了,召回返回了结果,生成也流畅通顺,没有一环报错。错的是最初那一刀。

怎么避免,常见的做法有两种,我们一个个看。

一种是按结构切。手册本身有章节、标题、段落,这些边界是写手册的人自己画好的语义边界,沿着它们切,切出来的片段天然语义完整。我们这个例子里的手册有清晰的章节结构,按小节切是最自然的做法。缺点是格式五花八门的资料不一定有干净的结构,遇到纯文本就得另想办法。

一种是加重叠。切的时候让相邻的片段头尾各重复一部分内容,比如片段 41 的结尾和片段 42 的开头有几十字是一样的。这样即使一句话被切断了,它至少完整地存在于其中一个片段里。重叠多少同样是个经验值,不固定。

来看分片这一环的示意。

相邻片段保留重叠

相邻片段保留重叠

手册第 87 页 售后服务章节

按标题和段落边界切分

片段 41 退货政策正文

片段 42 例外情况说明

片段 43 维修流程

100%

看这张图,第 87 页被切成了三个片段,各自讲一件独立的事,片段之间还有一小段重叠兜底。

到这里,200 页手册变成了几百个带重叠的小片段,每个片段讲一件独立的小事。分片就讲到这里,下面我们进入到索引。

索引

分完片,马上面对一个新问题。

这几百个片段躺在那,用户问「你们的退货政策是什么」的时候,我们怎么从里面找到讲退货的那几个?

你可能会想,用关键词搜索不就行了,搜「退货」两个字。

我们试试看。用户问的是「退货政策」,手册里那一节的标题叫「售后服务与退换货管理」,正文里用的词是「申请售后」「不支持无理由退货」。字面一比对,「退货」这个词在手册里出现的位置很散,物流章节讲退货地址,包装章节讲退货时保持吊牌完整,真正讲退货政策的那一节,标题里一个「退货」都没有。搜出来的结果一半是无关段落,真正的答案反而排不上号。

关键词匹配卡在什么地方,卡在用户问法和文档写法是两套词汇。用户怎么问我们控制不了,文档怎么写早就定了,要跨过这道坎,得靠语义层面的匹配,也就是 embedding。

embedding 这里我们一笔带过,因为它值得单独开一篇讲。我们只需要记住一个结论,把一段文本交给 Embedding 模型,它会返回一串数字,这串数字叫向量。语义相近的文本,向量在空间里的位置也相近,语义无关的文本,位置就远。

拿我们的例子过一遍。

「你们的退货政策是什么」和「自签收之日起 7 日内可申请无理由退货」,这两句话字面上重叠的字没几个,但它们都在讲退货这件事,向量距离就近。「今天天气不错」跟退货没有任何关系,向量距离就远。

也就是说,embedding 把「找文字相同」变成了「找意思相近」,正好治我们刚才那个两套词汇的病。

向量有了,存哪?向量数据库。

向量数据库可以简单理解成一个专门存向量、查向量的库。这里有个容易忽略的点,它存的从来不只是向量。

我们来看库里实际长什么样。为了方便演示,我只列三条,向量也只写头三个数,真实的向量动辄几百上千维。

片段编号 片段原文 向量 只列前三个数
41 自签收之日起 7 日内可申请无理由退货 0.31 0.07 0.55
42 定制品和已激活的软件不支持无理由退货 0.28 0.11 0.49
55 保修期内免费维修 顺丰到付寄回 0.05 0.34 0.12

看第二列和第三列,每个片段的原文和它的向量是绑在一起存的。这一点很关键,因为向量只能用来算距离,它自己变不回人话。查的时候靠向量定位,返回给下游用的是原文,缺了原文这一列,后面就没法把资料拼进 prompt 了。

好,把片段变成向量再连同原文存进向量数据库,这一整套动作就叫索引。

其实就是我们刚才聊的这个过程。

到这里,索引建完了。注意不管是分片还是索引,它们都发生在用户提问之前,属于要提前准备的步骤。建一次,可以服务之后无数次的提问,手册改版了就重新跑一遍,平时不用动它。工程上这叫把一次性成本和高频成本分开,建索引慢一点没关系,它不在用户的等待路径上,查询快才要命。

下面我们就来看看,用户提问之后发生了什么。

召回

提问之后的第一环,召回。

用户在对话框里敲下一句话,「你们的退货政策是什么」。

第一步,这句话要先过一遍 Embedding 模型,变成一个向量。这里有个细节要划重点,提问用的 Embedding 模型,必须和建索引时用的是同一个。不同模型产出的向量不在同一个空间里,你拿 A 模型的问题向量去查 B 模型建出来的库,算出来的距离是没有意义的。

第二步,拿着问题向量去向量数据库里查。

那向量数据库是怎么知道哪些片段跟问题最相关的呢?

靠算。库里每个片段的向量,都跟问题向量之间算一个相似度分数。这个分数怎么算,方法有好几种,目前比较流行的是余弦相似度、欧氏距离和点积,我这里大致说一下,它们都是在度量两串数字方向合不合、距离近不近,具体公式本篇不展开,记住「算距离」这个结论就够了。

我们来给几百个片段挨个打分。为了方便演示,还是只看刚才那几条。

「你们的退货政策是什么」这个问题向量,跟片段 41 退货政策正文算出来分数很高,跟片段 42 例外情况也算出来一个高分,跟片段 55 保修条款算出来一个中等分。保修跟退货在语义上确实不远,但它不是用户问的。

几百个片段算完分数,按分数从高到低排个序。

第三步,取前 k 个。这个动作叫 top-k,k 取 10 是常见做法。当然 10 这个数字并不固定,你可以取 5 也可以取 20,具体是多少不是很重要,量级对了就行。取太多,后面环节的负担重,取太少,容易漏掉分散在多处的相关信息。

我们来看召回这一环的示意。

你们的退货政策是什么

Embedding 模型

问题向量

向量数据库

逐个计算相似度分数

按分数从高到低排序

取出 top 10 片段

片段 41 片段 42 等十个候选

100%

跟着这张图从左往右走,一句话进去,十个片段出来。

走完这三步,问题向量在几百个片段里粗筛出了 10 个候选。讲退货政策正文的片段 41、讲例外情况的片段 42 大概率在列,同时也混进来一些分数不低但关系不大的,比如刚才那个保修片段,因为「退货」和「保修」的语义距离确实不远。

这里多说一句。召回是在线链路的第一环,它的耗时是直接算在用户等待时间里的,所以它必须快。向量数据库能在几百上千个片段里很快出结果,靠的是内部专门的索引结构,这块同样不展开,记住它快就行。

混进来没关系,这就是召回的定位,粗筛。粗筛的目标不是全对,是别漏。真正的把关在下一环。

重排

你可能会想,召回都取 top 10 了,直接挑分数前 3 的发给模型不就好了,为什么还要重排,同样的事情搞两遍干什么?

因为这两段用的计算逻辑不一样,各有各的长短。

召回那一环,问题是一个向量,片段是另一个向量,两边各自独立编码,再拿两个向量算距离。好处是快,片段的向量在建索引的时候就提前算好了,查询的时候只需要算一次问题向量,再做一堆距离比较,从几百个片段里筛出 10 个是很快的事。代价是不够准,把几百上千字压成一串数字,细节信息一定会丢,所以它只适合干粗筛。

重排这一环换了种算法。它把问题和片段拼在一起,整对送进一个专门的重排模型,让模型逐字看完这一对,直接输出一个相关度分数。注意它输出的不是一段话,就是一个分数。所以重排模型没法拿来做生成,它天生就是为排序这件事服务的。问题和片段摆在一起互相参照,「但定制品除外」这种转折关系、这种字面词汇对不上但意思对得上的情况,它都看得出来。准确率比向量距离高得多。代价是慢和贵,每个候选都要过一遍模型,几百个片段全拼进去算是不现实的。

所以两段配合,召回负责从几百个里快速粗筛出 10 个,重排负责对这 10 个精挑细选出 3 个。粗筛图快,精排图准。

你可以把它类比成公司筛人。公司招人收到一万份简历,HR 不会拉着每个候选人聊一小时,先快速扫简历,从一万份里挑出十个看起来对路的,再安排面试,一个一个仔细聊,从中挑三个发 offer。召回就是简历筛选,重排就是面试。

类比讲完,我们拉回技术主线,看这一环的示意。

向量相似度 粗筛

逐对精算打分

全部片段 几百个

召回 top 10

重排 top 3

拼进 prompt 发给大模型

100%

从几百个到 10 个,再从 10 个到 3 个,两轮漏斗下来,留下的是又快又准挑出来的三个片段。

那为什么是 3 个,不是 10 个全要?因为发给模型的每一段都要占 prompt、花 token、拖速度。我们前面试错的那一课还在,发给模型的东西要克制,够回答问题就行。

重排就讲到这里,下面我们进入到生成。

生成

链路的收尾一环,生成。

生成什么呢?那当然是生成答案了。

具体怎么生成,把重排出来的 3 个片段,加上用户的问题,再加上一段指令,拼成一个 prompt,发给大模型。

拼出来的 prompt 长什么样,我来给大家模拟一下,就像这样。

text
你是一个产品客服助手
请只根据下面的资料回答用户问题
如果资料里没有相关内容 请直接说手册中没有找到

资料一 自签收之日起 7 日内可申请无理由退货
资料二 定制品和已激活的软件不支持无理由退货
资料三 退货流程为在订单页提交申请 客服审核后寄回商品

用户问题 你们的退货政策是什么

看这三块。

最上面一块是指令,它干两件事。

一件是划范围,只根据资料回答。有了这句,模型的发挥空间就被框死在资料里,哪怕它训练时见过别家产品的退货政策,也不许拿出来用。至于大模型为什么天生爱自由发挥,那是我们上一篇聊过的话题,这篇不重复。

另一件是给退路,资料里没有就直说没找到。这句很值钱。没有它,当召回回来的片段跟问题没关系时,模型大概率会硬着头皮编一段。有了它,机器人至少会说「手册里没有找到相关内容」,把问题转人工,而不是张口就来。

中间一块是资料,重排精选出来的 3 个片段原样贴进来。上面这个例子里,贴的是退货政策正文、例外情况、退货流程,第 87 页的核心内容都在了。

最下面一块是用户的原始问题,一字不改。

多补一个实践细节,片段的摆放位置有讲究。模型对放在 prompt 开头和结尾的内容更上心,埋在中间的容易被略过,这是实践中反复出现的现象。所以重排排完序之后,相关度最高的片段一般放在资料的最前面,别让最重要的内容淹在中间。

三块拼好,发给大模型,看这一环的示意。

指令 只根据资料回答

prompt 拼装

重排后的 3 个片段

用户原始问题

大模型

基于退货政策片段生成的回答

100%

大模型拿到的是什么呢,是三段划好范围的资料加一个问题。它要做的不是回忆,而是阅读理解。答案就在资料里,组织成人话输出就行。

「你们的退货政策是什么」,机器人这时会告诉你,自签收之日起 7 日内可申请无理由退货,定制品和已激活的软件除外,流程是在订单页提交申请、客服审核后寄回商品。

信息完整,来源可靠。而且手册改版之后重新跑一遍索引,答案跟着就更新了,模型本身一个字都不用重训。

你回头看会发现,这套架构里大模型只负责收尾那一步。找资料这件事,交给了前面整条专门的检索链路。每个环节职责单一,出了问题也好定位,答案不准就去看召回的片段对不对,片段不对就去看分片切得怎么样,一层层往上查,总能找到病灶。

到这里,从分片到生成的整条链路就走完了。是不是很简单。

常见坑

链路走通和链路好用,中间还隔着一段距离。落地的时候坑不少,我们挑最常见的那个讲透,就是召回不准。

前面留过一个伏笔,用户问法和文档写法是两套词汇。

手册里那一节的标题叫「售后服务与退换货管理」,用户问的是「退货政策」。这种不对齐可以列出一串,用户说「能不能退款」,手册写「退回货款」,用户说「买的东西坏了怎么办」,手册写「质量问题售后申请流程」。embedding 能扛住一部分语义差距,但当问法跟写法差距大到一定程度,相关片段的相似度分数就会掉下去。更要命的是,手册里可能有别的段落频繁出现「退」和「货」这两个字,比如物流章节的「退货地址」,比如包装章节的「退换时请保持吊牌完整」,这些片段的分数反而被顶了上来。

结果就是,top 10 里可能根本没有讲退货政策的那一页。

这种情况下,链路上每一环都正常运转。分片没切坏,索引建了,召回返回了 10 个片段,重排认认真真排了序,生成也流畅通顺。答案还是错的,因为错发生在更上游,问题本身和文档对不上,后面的环节再努力,也只是在错误的候选里挑比较好的那个。

这个方向有一批进阶解法,我们指个路不展开。查询改写,在检索之前先让大模型把用户的问题改写一遍,改得更接近文档的措辞再去查,比如把「退货政策」改写成「售后服务与退换货管理条款」。混合检索,向量召回和关键词召回同时跑,两边的结果合并,各取所长。展开都是单独的篇幅,先把主链路吃透再看这些不迟。

除了召回不准,还有几个坑顺带提一句。

手册改版之后忘了重建索引,用户问的是新版政策,库里存的还是旧片段,答案永远是过期的。top-k 设得太小,退货政策的正文在片段 41,例外情况在片段 42,k 等于 1 就注定漏一个。分片的时候把表格撕开,表格的上半截在片段 41,下半截在片段 42,两边各看一半都是天书。

那怎么知道自己的系统踩没踩这些坑,靠肉眼看答案猜是猜不出来的。常见做法是提前攒一批真实用户会问的问题,人工标注每个问题应该命中哪些片段,然后拿这批题去跑链路,看召回回来的片段对不对。召回不准就先修召回,别急着换模型,大部分答案质量问题,顺着链路往上追两环,都能找到病根。

这些坑有一个共同点,锅都不在大模型,在链路的某一环。RAG 系统调优,大部分时间不是在调模型,是在修链路。

全流程重走

好,每一环都拆完了,我们把整条链路从头重走一遍。

整条链路分两段。

一段是提问前的准备。我们把 200 页产品手册做分片,沿着标题和段落的边界,切成几百个带重叠的小片段。然后我们把所有片段交给 Embedding 模型,让每个片段都得到一个对应的向量。接着我们把片段的原文和向量成对存进向量数据库。到这里,提问前的准备流程就结束了,这一段建一次,可以应答之后无数次提问。

再看看用户提问之后发生了什么。

用户问「你们的退货政策是什么」。我们先让这个问题过一遍同一个 Embedding 模型,得到问题向量。然后我们把问题向量传给向量数据库,让它逐个算相似度分数,从几百个片段里粗筛出分数最高的 10 个。接着我们把这 10 个片段连同问题一起交给重排模型,逐对精算打分,从中挑出相关程度最高的 3 个。然后我们把这 3 个片段拼进 prompt,配上只根据资料回答、没有就说没有的指令,一起发给大模型。

大模型基于退货政策的那几段资料,生成最终的答案,返回给用户。

到这里,RAG 的完整链路就算是走通了。如果你想系统地学习这些内容,从大模型原理一路讲到 Agent、RAG 和模型部署,可以看看这门0基础Agent开发课,220 个课时,从零基础一路讲到生产部署。

评论区

暂无评论,快来抢沙发吧