一、RAG 的基础过程
1.1 知识库构建
1.1.1 数据加载
数据加载,将需要作为 RAG 文档的原始文件加载为 Document。 https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/dataParse.py
1.1.2 数据清洗
数据清洗,去除文档中的无用内容(如连续的空格,横线分隔符等),统一编码格式、修正错误信息、删除无用的垃圾内容,为文本分块做准备。https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/dataParse.py
1.1.3 文本分块
直接将整篇文档作为 RAG 单位,粒度太大,检索精度不高(如果直接向量化的话,那么得到的向量很难代表文档的全部含义);同时 LLM 的上下文窗口是有限的,不可能把大段大段的文档一股脑交给 LLM 检索,同时时间效率、token 消耗也需要我们精简文本,尽量只提供少的、有用的内容
常用的分块方法:
1.1.3.1 固定大小分块
每块分块的大小是固定的。同时,为了避免语义的割裂,所以不同的分块之间会有一部分的重复区间,例如
Plain
0 ---- 450 || 451 ---- 500 || 501 ---- 950
| ------- chunk1 --------- |
| ---------- chunk2 --------- |
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/fixedSizeChunk.py
1.1.3.2 句子分块
一个句子中含有很多标点符号,所以天然就将句子分为很多部分,所以可以通过这些标点符号,将文档分为若干个部分。具体的切分可以通过正则表达式完成,比如:
Python
sents = [s.strip() **for** s in re.split(r"(?<=[。!?.!?])\s*", text) **if** s.strip()]
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/sentenceChunk.py
1.1.3.3 语义分块
我们可以通过嵌入模型完成分块的工作。嵌入模型本职工作是做文本向量化,但是通过 llama_index.core.node_parser 提供的 SemanticSplitterNodeParser,可以自动完成语义分块
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/semanticChunk.py
BAAI/bge-m3 可以生成 1024 维的向量,通过下面的方式可以下载到本地,使用时自动加载完成文本向量化
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/download.py
1.1.3.4 递归分块
递归分块有点类似优化版本的句子分块(句子分块因为严格按照标点符号切分,所以很容易出现文本块过长或者过短问题),但是没有 "句子" 这个限制了,按照主题、段落、标点等依次进行划分,只要没有达到 chunk 大小限制,就不断用下一个符号切分。在 llama_index 中,可以使用 llama_index.core.node_parser.TokenTextSplitter 实现
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/recursiveChunk.py
1.1.3.5 结构分块
结构分块依照文档内置结构进行块的划分,更加注重一个块的完整性,而不是注重语义的切分。Markdown 就是最适合结构分块的文本格式,比如 LangChain 中直接就提供了 MarkdownTextSplitter
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/structureChunk.py
1.1.3.6 大模型分块
将分块的任务直接丢给 LLM 执行,通过 Prompt 约束 LLM 的切分行为,这种方式效率要慢很多,相比不借助模型的方法,并且很消耗 token,切分的质量拒绝于提示词和 LLM 能力
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/llmChunk.py
1.1.4 向量嵌入
1.1.4.1 向量的比较方式
将文本切分为大小合适的块后,就可以进行向量嵌入了(文本 -> 向量),有以下几种比较方式:
-
余弦相似度 ,计算两个向量的余弦相似度,取值范围为
[-1, 1],语义越相近,余弦相似度越大 -
欧式距离 ,将向量看作高维空间中的两个点,计算两个点之间的距离,距离越小,语义越相近。不过在高维文本下,文档区分度很小
-
点积距离 ,既兼顾长度,又兼顾夹脚。不过点积距离未归一化不能随便使用(归一化即单位化,归一化后点积 = 余弦相似度)
1.1.4.2 向量数据库
得到文本向量之后,需要一个存储、检索的结构。传统的关系型数据库如 MySQL,很难完成高维向量的存储、检索
Milvus 是工业最主流的数据库之一,速度、精度均衡。
| 传统关系型数据库 | 向量数据库 | |
|---|---|---|
| 存储对象 | 字符串、数字、表格 | 高维向量 |
| 查询方式 | 精确匹配、模糊匹配 | 近似相似度检索 |
| 使用场景 | 订单、用户 | RAG、图文检索 |
| 高维性能 | 极慢 | 内置索引算法(常使用 ANN,比如分层导航小世界) |
Milvus 向量数据库的基本操作:https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/milvus.py
Milvus-Lite 是专门的轻量、嵌入式版本,随 Python 启动、关闭,数据直接保存在(.db 文件,类似 SQLite),也可以通过 Docker 部署
概念对应
| 类比 | |
|---|---|
| Database | 一个 SQLite文件 |
| Collection | 表 |
| Insert | 写入一行 |
| Search | 近似最相邻检索 |
| Delete | 删除一行 |
或者可以使用 llama_index.core.VectorStoreIndex,默认是内存级存储向量
1.2 数据检索
构建完了知识库,就构建完了 RAG 的数据基座构建。完成数据检索的大体思路:用户丢来一个查询,将这个查询也分块、向量化,从向量数据库中检索出所有文本分块,随后一起丢给 LLM 生成
https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/vectorSearch.py
1.3 提示词增强
用户的提示词往往是模糊不清的,并且内容单薄。我们 RAG 检索出的文档也需要放到我们最后给 LLM 的提示词中。
1.3.1 提示词模板化
一个最基本的 RAG 提示词模板例如:https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/prompts.py
其中包括:
-
角色设定:基于文档回答,不要瞎编
-
上下文占位符:存放 RAG 检索出的内容
-
用户问题:动态填入用户当前问题
-
输出格式要求、内容要求
1.3.2 上下文压缩
检索出的内容,是可能存在无关冗余内容、重复内容(车轱辘话)、片段过长,会浪费 Token、处理时间
可以交给 LLM 来做,提示词 https://gitee.com/wangs-joyful-home/rag/blob/master/BaseRag/prompts.py
1.3.3 过滤
检索出的内容可能分数较低(相似度较低),需要过滤掉低相似度的内容,一半是低于 0.75 的直接扔掉
二、RAG 的优化
2.1 知识库构建优化
2.1.1 分块优化
2.1.1.1 放大检索
一般有两种方式:父子分块、句子窗口
2.1.1.1.1 父子分块
提前构建文本内容,包含两个部分:子块,短小,用于在数据库中完成检索;父块,对应到子块,当子块被检索出来,父块作为上下文、更详细的内容一起被检索出来,父块通常是子块的段落、篇章等。返回给 LLM 的是我们的父块内容
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/chunking/parentChildChunk.py
2.1.1.1.2 句子窗口
入库的内容依然是切分出的句子本身(或者不一定是严格的一个单句)。但是维护一个结构保存所有句子序列,检索出一个句子,打包返回这个句子前面和后面的若干个句子,保证上下文的连贯性
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/chunking/sentenceWindowChunk.py
2.1.1.2 总结摘要
放大检索的优化方式,是将原文中的一小段丢到数据库中用来检索,返回给 LLM的是原文的完整部分,即局部上下文或预置父块;而总结摘要,是将大文本块的简短摘要放到数据库中进行检索,摘要来自人工编写或者 LLM 生成。
这种方式,相对放大检索,粒度更粗(因为摘要势必会丢失文档内容),但是制作成本更低(使用 LLM 生成摘要的话)
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/chunking/summaryRetrievalChunk.py
2.1.1.3 添加元数据
这里的元数据,可以是文档内容的标题、标签这种概括性的内容,具体体现为 chunk 对应的 metadata。先维护一个结构,保存所有的标签;拿到 query 后,先不直接去向量数据库做语义检索,优先拿到 query 出现的标签,然后通过这些标签去拿到向量数据库所有含有这些标签的向量,然后再在这些向量中做语义查询。
相对于直接做语义近似检索,速度更加快
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/chunking/metadataRetrievalChunk.py
2.1.2 结构化管理
2.1.2.1 层级索引
MySQL 对于海量数据的索引方式是 B+ 树,10 亿级数据理论上只需要3次磁盘 IO,优化的思想就是:稀疏索引加速检索,稠密索引存储数据。层级索引也是采用这种思想(不是方式一样,只是思想类似),磁盘 IO 对应做一次近似最相邻搜索,稀疏索引是粒度更粗的文本,越往下粒度越细,最后是粒度最细的稠密索引,也就是我们真正需要提供给 LLM 的内容。每一层都做向量索引,然后使用 metadata 过滤的方式,继续往下进行检索
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/chunking/levelIndexChunk.py
2.1.2.2 知识图谱
知识图谱特别适合用来解决人物关系问题,包含两个元素:entity、relation。entity 在这里起到顶点的作用,relation 起到连接顶点的边的作用。
知识图谱的优势:能够处理跨章节、跨文档的问题,并且由于 entity 之间的关系是写死的,很难进行瞎编;知识图谱的劣势是维护、制作关系图成本高,并且一旦改动一个点,可能造成很多点牵连改动
2.2 检索前优化
2.2.1 基础预处理
主要是提高 query 质量,包括以下几种:
-
错别字修正
-
停用词去除,删除"的"、"了"、""、"吧"这些对语义毫无贡献的词去掉,减少噪音
-
关键词提取,提取出关键词
2.2.2 意图识别
在使用 query 去检索前,尝试通过语义检索识别出 query 的意图,比如寻求方法、预测、对比等,并在后续带有针对性的对文档检索,例如:"C++ 和 Python 谁的速度更快?",除了检索 C++、Python 的文档,也识别到"对比"的意图,因此额外检索两者对比类文章,并在回答时使用对比结构回答
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/intendRecognition.py
2.2.3 查询扩写
用户的 query,就像用户需求一样,往往只是简单、模糊的一句话;想要交给向量数据库高召回率的检索,就像用户需求转化为软件需求,我们可以对查询进行修改,后续使用变体查询。查询扩写是一种方式,包括两种思路:多路查询、子查询
2.2.3.1 多路查询
简单来说,多路查询的思路是把 query 丢给 LLM 进行同义描述,最后使用这些所有 query 进行向量检索。这样,理论上可以查询到更多的文档,提高召回率
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/multiQueryExpansion.py
2.2.3.2 子查询
子查询是将一个问题,丢给 LLM 进行拆解,分成若干个原问题的小问题,使用所有子问题检索,最后将结果进行合并。尤其适用于用户问题包含若干个方面、难以一次检索完成向量查询的情况
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/subQueryExpansion.py
2.2.3.3 验证链
上面的两种变体查询还有一个问题:LLM 虽然说基于原 query 改写、分解,但是可能会出现幻觉问题,所以可以在生成变体查询后额外加一轮验证,去除:问题含有幻觉、冗余问题等
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/verificationChainExpand.py
2.2.4 查询转换
查询扩写是查询转换的一种具体方式,下面还有两种更高级的操作方法
2.2.4.1 HyDE(Hypethetical Document Embedding,假设文档嵌入)
在检索专业、官方文档的情况下,可能出现这种情况:文档中含有大量的专业术语、官方名词,但是用户查询不含有这些内容,或者术语相当模糊,导致 RAG 召回率很低。从这点优化,我们可以直接让 LLM 自己根据用户查询预先生成一个仿官方文档(用词、语气等),用这篇文档作为用户查询,去进行向量检索
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/hyde.py
2.2.4.2 后退提示
所谓后退,就是将用户 query,"后退"为更基本、更笼统、去除细节(时间、地点、任务等)的问题,使用这种"后退"过的问题进行检索,并和原问题检索的到的向量进行去重合并,将最后的结果交给 LLM
后退提示,主要弥补了细分文档缺失问题,扩展检索边界,大幅提高文档召回率
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/step_back.py
2.2.5 查询路由
查询路由是在构建数据库时,将一类向量放到一个数据库中;得到用户请求中,通过适合的方式,路由到指定的数据库中进行向量查找,而不是全量的语义比较
2.2.5.1 元数据路由
给每个向量数据库打上若干个标签优化知识库构建时使用的添加元数据,从 query 中提取出符合的标签,然后再到符合要求的数据库中进行具体检索
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/pre_retrieval/metadataRoute.py
2.2.5.2 语义路由
可以通过 LLM 或者嵌入模型来进行路由,优点是不会受死卡关键字的影响,缺点是效率相对较低、成本更高
2.3 检索处理优化
2.3.1 稀疏检索
稀疏检索是只在字面上进行检索,大体思路:对文档进行分词,建立倒排索引(词 -> 文档);对 query 使用同样的方式分词,查询倒排索引,获得哪些文档含有 query 中含有的词,并根据词语出现次数累计这些文档的得分。BM25 就是常用的算法
BM25 检索器需要自己指定分词规则,比如下面通过正则表达式指定
Python
TOKEN_PATTERN = r"(?u)[a-zA-Z0-9]+|[\u4e00-\u9fff]"
**def** **build_retriever**(nodes: list[TextNode], top_k:int=5)-> BM25Retriever:
**return** BM25Retriever(
nodes=nodes,
similarity_top_k=top_k,
skip_stemming=True, # 关闭词干提取
token_pattern=TOKEN_PATTERN
)
优点是简单速度快,缺点是不考虑语义
2.3.2 稠密检索
这里的稠密,就是使用嵌入模型,通过语义相似度给文档进行匹配打分,这种方式相比稀疏检索,更能体现语义上的影响
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/retrieval/vectorRetriever.py
2.2.3 混合检索
稀疏检索可以强硬匹配关键词,稠密检索可以通过语义进行匹配,更常用的检索方式是稀疏索引和稠密索引结合的方式。
但是这里还有一个问题:稀疏索引分数相比稠密检索会高很多(稠密检索 [-1, 1]),所以直接相加不行
RRF 是这种问题的一种解决方式:score = 1.0 / (rank + k + 1),rank 是排名(每一路的排名),k 为平滑系数(工业界常取 60)
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/retrieval/hybridRetriever.py
2.4 检索后处理
2.4.1 结果过滤
检索之后的结果,即使有各种优化,也不意味着适合直接交给 LLM 生成。可以通过下面的方式进行过滤
-
相似度过滤 ,除了压根没有被检索出来的向量,剩下的向量有一部分相似度较低,共享度也很低,所以可以直接把这些向量去掉。一般相似度低于 0.6 的认为无关低价值,可以通过 llama_index.core.postprocessor.SimilarityPostprocessor
-
文本过滤 ,通过正则,对字符串在字面上进行处理,比如:
-
去除掉过长、过短的片段
-
乱码内容、过长的空格
-
广告、不当内容或者用词
-
-
元数据过滤 ,检索出的文档可以携带元数据(metadata),可以通过业务规则进行过滤,比如失效、权限、来源、类型等
-
语义过滤 ,前面相似度过滤,是对检索出来的内容进行一个门槛的过滤;语义过滤,是在通过嵌入模型进行一轮轻量的检查,一般相似度低于0.55 的不保留
2.4.2 去重
包含两个层级
-
去掉内容一模一样的文档
-
去掉内容高度相似的文档
2.4.2.1 精确去重
可以采取两种方式,对于每个文档都可以在 metadata 之类的字段中存放一个 ID,可以通过这个 ID 进行去重;或者通过 md5 的方式对文本形成摘要,如果摘要相同,那就可以直接认为是相通的文档,进行去重
2.4.2.2 语义去重
可以通过嵌入模型计算语义相似度,大于 0.85 的可以认为高度相似,直接丢弃一个
2.4.3 重排序
2.4.3.1 基于规则排序
这些规则,指的就是通过人工预设的规则进行关键词匹配等对检索出的所有文档进行打分,之后依据这些分对文档进行排序
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/post_retrieval/ruleRerank.py
2.4.3.2 基于交叉编码器排序
使用专用的语义匹配模型,将 query 和单条文档片段进行深度匹配,依据此打分。速度适中,没有额外的推理过程
专用的语义匹配模型,可以使用 BAAI/bge-reraker-base(https://www.modelscope.cn/models/BAAI/bge-reranker-base)
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/post_retrieval/modelRerank.py
2.4.3.3 基于 LLM 排序
可以直接把所有文档片段全部丢给 LLM,编写适合的提示词完成重排序。这种方式最简单,并且质量可能比交叉编码器还好,但是效率、成本都比前面两种低
https://gitee.com/wangs-joyful-home/rag/blob/master/PlusRag/post_retrieval/llmRerank.py
2.5 生成后处理
生成之后,还可能存在一些问题,比如
-
幻觉检测,可以把 LLM 返回的回答进行切分,和检索出的文档进行语义匹配,剔出来幻觉内容
-
安全检测,可能会处于幻觉、断章取义输出,所以需要检测危险内容
-
格式控制
-
引用标注