【实践分享】如何手搓一个RAG

【实践分享】如何手搓一个RAG 前言 1.之前有看到佬友分享使用dify的悲伤经历,还有搭建知识库踩过的坑,在下面我也评论了一下,佬友希望我开贴细说一下,今天写在这里。 2.项目是24年的,但是我也没什么很深厚的理论基础,毕竟23年GPT才开启第一波开源;然后我真正手搓项目是在24年,所以这个分享可...
【实践分享】如何手搓一个RAG
【实践分享】如何手搓一个RAG

【实践分享】如何手搓一个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 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文