【实践分享】如何手搓一个RAG
前言
1.之前有看到佬友分享使用dify的悲伤经历,还有搭建知识库踩过的坑,在下面我也评论了一下,佬友希望我开贴细说一下,今天写在这里。
2.项目是24年的,但是我也没什么很深厚的理论基础,毕竟23年GPT才开启第一波开源;然后我真正手搓项目是在24年,所以这个分享可能有点简陋;并不是我不愿意分享自己的知识,只是我的理论其实也很薄弱,拿的出手的只有一点实践。
关于RAG的回忆
23年,我们对大模型的应用还很薄弱,当时业界有两个分支:
1.用原生大模型,用不同的问法。(这个就是后来提示词工程和rag的原型)
2.微调大模型。(当时的主流方法我已经不记得了,我只记得我试过了没有)
因为严格意义上来说,我并不是一个底层的AI开发,我是属于AI应用开发。微调我实在是整不明白,其次,微调需要很多语料,当时的知识库并没有被很多公司接纳,我得到的语料其实就那么几十个文档。
衡量利弊之下,我选用了方案一。那么接下来有两个东西就很重要了:提示词、知识库。
上面说的这些其实都算免责申明了,很菜,别骂。
回忆结束,进入正题。
RAG流程
检索增强生成有一套比较简单的流程:
用户提问->文档检索->提示词拼接文档,发给大模型->大模型返回结果
但是我要讲的是一套稍微复杂一点的流程:
用户提问->问题重写->意图识别(可以不要)->文档检索->提示词拼接文档,发给大模型->大模型返回结果
这个我会在下面展开讲。
用户提问与提示词
后端接收到前端传来的用户提问。
我们当然不可能直接把用户的提问传给大模型,就说最简单的,我们在
messages=[
{"role": "system", "content": "You are a helpful assistant. Help me with my math homework!"}
]
这个json里面写的东西,其实就是我们的提示词。他是我们使用大模型的开始,也是大模型能够更精确回答用户问题的开始。
问题重写(连续对话的根本)
问题重写,顾名思义就是把用户的问题,进行重构。当时的大模型并没有那么长的上下文,不能像现在一样,把用户和大模型的对话全部发给大模型。而且,过长的报文带来的是过长的等待时间和非常差的用户反馈。
因此,问题重写就出现了。这样说可能不够明确,我给大家举个例子吧。
以下这个是很简单的对话:
messages=[
{"role": "system", "content": "You are a helpful assistant. Help me with my math homework!"},
{"role": "user", "content": "Hello!"}
]
但如果用户进行了十几轮的对话呢,就会变成下面这样。
messages=[
{"role": "system", "content": "You are a helpful assistant. Help me with my math homework!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hello!"},
]
我们不可能把这种message全部传给大模型的,当然,实际生产的情况更复杂,但无论如何,我们不可能把所有的上下文全部传给大模型。
那有人会问了,那我只传最新一次的不就好了?
是的,效率提升了,但是大模型连续对话的能力没有了。
所以我们要压缩上下文,在压缩上下文的同时,还需要最大程度地保证上下文是有意义的、能够讲明白用户问题的。
最简单的方式,就是把用户当前最新一句话,以及前几轮对话+我们预先写好的提示词,传给大模型,让他进行问题重写。然后用这个问题重写的结果,作为真正的用户提问。
这里举一个真实的例子:
user:Ame是谁?
assistant:Ame 是Dota2职业选手王淳煜的ID。
user:他在哪个队打过?
重写后:
user:Ame(王淳煜)在哪些Dota2战队效力过?
意图识别
应用场景:
如果知识库过多,或者你的机器人(现在叫智能体)功能比较多,你要识别用户是不是想进行知识问答。
实现方式:
这里的实现方式有很多种,最简单的是直接让大模型对用户的提问进行意图识别;难一点的就是让大模型进行意图识别并在代码中进行处理。
好处和坏处:
好处是我们这样之后,文本检索可能会更准,比如我们有30个知识库,先通过意图识别进行一次初筛,然后对对应的知识库进行检索。
坏处就是链路变长了,速度变慢了。
但是,经过我的实践发现,在多知识库、多工具、多业务入口时,这是不可缺少的一环,而且这一环最好和用户进行交互后完成。不然用户不仅会骂机器人笨,还会说机器人胡言乱语。
文档检索(重点)
接下来是文档检索,我们需要准备一个向量化模型、一个向量库。
知识库构建- 知识库
有人总是把知识库的构建说的很高大上啊,其实很简单,就是把文档向量化,然后存在向量库里面。
这就是知识库。知识库的质量就是文档的质量,知识库的效率就是向量库的查询效率。
- 文档分块
为什么我的知识库是依托答辩?
为什么我的问答机器人总是胡言乱语?
明明我把文档给他了,原文我都看得明明白白在这一页了,为什么他就是答不上来?
就是因为你分档分块有问题。
我一开始手搓的时候,用的是FAISS向量库,当时是500个字一个块。这就有很大的问题了,段落会被截断啊,明明是同一个章节的内容,他就是识别不来。
那么有人会说了,那我们把章节也加上去。那你加到几级标题呢,文档名称要不要加呢?
你先别急着回答,我也不会回答,因为这个要具体问题具体分析,需要结合实际情况调测。但我很明确告诉你,无脑加是不行的。
那么那些很吊的RAG,他们是怎么分块的呢?
我这里慢慢介绍大家优化分块的方式,首先第一个是分块重叠。
- 分块重叠(chunk overlap)
分块重叠是系统切分时让相邻 chunk 共享一部分内容。比如:
分块500字,重叠100字。
那么实际上我们得到的分块结果是:
第一块:0-500字。第二块:400-900字。第三块:800-1300字。
这样做的目的不是让每个分块变大,而是避免文本语义被切断。举个最简单的例子。
原文:
在日语里,ame通常写作「雨」,意思是“雨”。所以看到ame ga furu,
一般可以理解为“下雨”。但在下面这段电竞报道里,Ame不是日语单词,
而是Dota2职业选手王淳煜的ID。
他在这场比赛中使用幻想系一哥们完成收割,帮助队伍赢下团战。
如果没有分块重叠,那么切块的结果是
分块1:
在日语里,ame通常写作「雨」,意思是“雨”。所以看到ame ga furu,
一般可以理解为“下雨”。但在下面这段电竞报道里,Ame不是日语单词,
分块2:
而是Dota2职业选手王淳煜的ID。
他在这场比赛中使用幻想系一哥们完成收割,帮助队伍赢下团战。
你提问:ame是谁的ID?
大模型回答:ame是雨的ID,因为你命中分块1的概率更高。
加上分块重叠后,切块的结果是
分块1:
在日语里,ame通常写作「雨」,意思是“雨”。所以看到ame ga furu,
一般可以理解为“下雨”。但在下面这段电竞报道里,Ame不是日语单词,
分块2:
但在下面这段电竞报道里,Ame不是日语单词,
而是Dota2职业选手王淳煜的ID。
他在这场比赛中使用幻想系一哥们完成收割,帮助队伍赢下团战。
这时候的答案就完全不一样了。
那有人会说了,两段一起传不就好了?直接两个分块怼过去:
分块1:
在日语里,ame通常写作「雨」,意思是“雨”。所以看到ame ga furu,
一般可以理解为“下雨”。但在下面这段电竞报道里,Ame不是日语单词,
分块2:
而是Dota2职业选手王淳煜的ID。
他在这场比赛中使用幻想系一哥们完成收割,帮助队伍赢下团战。
是可以的,但是两段一起传属于另一个优化方向:知识库的检索之Top-K。我还没说到检索方面的优化,我现在在讲的是知识库构建方面的优化。
那么我们还有什么其他的优化方式呢?
- 语义分块
方法一:
这个比较无脑很好用,既然分块那么麻烦,我直接用大模型分块不就是了?
我的超级智慧告诉我,用大模型就完事了。
既然写代码那么麻烦,我交给codex不就是了。
放弃所有思考,agent代替大脑。
方法二:
老老实实搞点语义识别的边界,例如:标题、小节、段落等。
- 给分块补内容
这是前面说的给分块补内容,就是在分块里面加标题和信息来源。也是用刚刚的例子:
标题:Ame的不同含义
来源:电竞术语说明
正文:在Dota2语境中,Ame通常指职业选手王淳煜。
- 父子分块(超级常用)
你叫父子分块也好,你叫小块检索+大块返回也罢。反正就是这么个玩意,稍微说一下区别吧。
小块检索+大块返回:
向量化的时候,我只向量化其中100个字,但是我存在向量库里的结构,包括了前面的50个字,和后面的50个字。这样我实际检索出来的内容其实是200个字。这里我要换个例子,不能用之前那段话了:
标题:Ame的不同含义
Ame在日语中可以表示“雨”,写作「雨」,读作「あめ」。
例如ame ga furu,意思是“下雨”。
但在Dota2语境中,Ame通常指中国职业选手王淳煜的比赛ID。
他长期担任核心位选手,因稳定的后期能力被很多玩家熟知。
接下里小块切分:
分块1:
Ame在日语中可以表示“雨”,写作「雨」,读作「あめ」。
分块2:
ame ga furu 的意思是“下雨”。
分块3:
但在Dota2语境中,Ame通常指中国职业选手王淳煜的比赛ID。
分块4:
他长期担任核心位选手,因稳定的后期能力被很多玩家熟知。
检索时,用小块去匹配问题。
用户问:dota2里的ame是谁?命中分块3,但是实际上我们会传给大模型一整段。
大模型回答:dota2中ame是王淳煜的ID,他是核心位选手。同时,ame还在日语中表达“雨”。ame取这个名字为id可能表示了他对雨的喜爱。
这时候用户就会想了,我靠,这个大模型这么聪明啊,居然还有引申!
其实是我们传的。
父子分块:
对大章节进行语义汇总,作为父块,每一个不同部分作为子块,检索时用子块,更精确,回答时带上父块,上下文更完整。依旧举例:
父块:一整节,讲Ame的不同含义
子块1:Ame在日语中的含义
子块2:Ame在 Dota2中的含义
子块3:如何根据上下文区分Ame
一样的情况,这里我就不举例了。
知识库检索知识库的检索,简而言之就是把用户的问题向量化,然后去向量库里比对向量相似性,然后把相似性最高的拿出来,这段文本里的内容,就是用户问题的答案。
但是你懂的,生活总是艰难的,一如检索到正确的文本。
最最简单方法之向量相似度。
- 向量相似度
不管有的没的了,我直接:
select * from table where col like '%我的问题%'
当然,这个是写给懂sql但是不懂向量库的同学看的,原理不是这样,但实际情况差不多。
实际上向量相似度是一个数学概念,知识库会把每个分快转化为embedding向量(我很不喜欢说英文概念,但是embedding的中文我是真不常说)。然后把你的输入条件也转化为向量,最后通过余弦相似度、点积或者欧式举例,返回他们之间的距离或者相似度。
注意,向量相似度本身只是数学距离,它不等于真正理解语义;只是因为embedding 模型会尽量把语义接近的文本映射到相近位置。所以向量检索能做语义近似,但效果高度依赖embedding模型质量。
比如Dota2这个游戏,聪明的embedding会把这一整个当成一个名词,笨一点的分词结果为:D,o,t,a,2。结果显而易见,一个词被分成了5个字母,语义直接被切碎了。
- 语义相似度
两段话字面上不一定一样,但表达的意思很接近。
这个也是靠embedding模型实现的,同样的例子我甚至可以再说一遍,比如Dota2这个游戏,聪明的embedding会把这一整个当成一个名词,笨一点的分词结果为:D,o,t,a,2。结果显而易见,一个词被分成了5个字母,语义直接被切碎了。
那么有人会问了,那和上面的也没有区别啊。
是的,严格来说,向量相似度和语义相似度不是两个独立的检索方式。
在RAG里,我们通常是先用embedding模型把文本转成向量,再计算向量之间的距离。这个距离就是向量相似度。
而我们真正关心的是:两段文本的意思是不是有关来拿。这个叫语义相似度。
所以可以这么理解:
向量相似度是系统实际算出来的分数;语义相似度是我们希望这个分数能够代表的含义。
embedding模型越好,向量相似度就越接近真实的语义相似度。
- Top-K
中文翻译:“取前 K 个结果”。依然举例:
Top1:Ame是Dota2职业选手王淳煜
Top2:Ame在日语中表示雨
Top3:PSG.LGD战队介绍
Top4:Dota2职业选手列表
如果 K=3,就取前三个。
因为Top-K不是一种复杂算法,它更像一个参数:召回多少条候选内容。
K 太小,可能漏掉正确答案;K 太大,噪声会变多。
- 关键词检索(BM25)
顾名思义就是用专有名词、编号、术语、代码、产品名之类的东西,直接进行关键词匹配。
- 多路召回
其实就是在检索的时候,用很多种方式进行检索,得到不同的答案,然后合并、去重、重排,得到一个或几个最终的结果(这里也是可以给定参数的)。有点类似于异质集成学习。
直接上用法:
1.向量召回:找语义相近的内容
1.关键词召回:找包含Ame、Dota2、王淳煜的内容
1.元数据召回:限定domain=电竞、game=Dota2
1.标题召回:优先匹配文档标题、章节标题
1.问题改写召回:把问题改写成多个版本分别检索
2.用某种方法集成、返回给大模型
加个序号,代表前面4个是并行的。
- rerank重排序
王中王,文档检索真正的王。但是非常非常非常吃性能。
你不用这个,5s回答完毕,你用这个,30s回答完毕。
比如你得到了20条文档检索结果,重排序模型会把最适合回答问题的分块,放到最前面。
- 混合检索(常用)
我们要知道的是,数据库的检索方式并不是多选一的,而是可以一起使用的,
例如:向量负责语义、关键词负责精确匹配、rerank负责最终排序
这样我们得到的结果会更加精准。
- 图谱检索(基于知识图谱的检索)
适合实体关系很强的知识库。
比如一个东西有别名,或者说映射关系很明显的时候,会用到这个。
它在组织关系、产品依赖、法规条款、故障链路、人物关系里其实挺有用。
但是这个搭建成本太高,不是所有知识库都值得做。
提示词拼接模板,发给大模型
这里就结合业务、检索结果、写一段提示词,发给大模型。
大模型返回结果
有人会说了,大模型返回结果不是很简单的么,这个也算流程之一?
算了。实际运用的时候,你不仅希望大模型告诉你答案,你还希望他告诉你答案是哪里来的,所以返回的结果我们是要加工的,比如来自哪个段落、来自哪个文件。这些东西大模型是不知道的,需要你在手搓的时候,留个字段来存。
总结
纯手搓,里面可能有很多错别字,主要是答应了佬友要写,赶工出来的。先发后改。尽量做到不误人子弟吧。
3 个帖子 - 3 位参与者