1 向量数据库 1.1 向量数据库简介 向量数据库就是一种专门用于存储和处理向量数据的数据库系统,传统的关系型数据库通常不擅长处理向量数据,因为它们需要将数据映射为结构化的表格形式,而向量数据的维度较高、结构复杂,导致传统数据库存储和查询效率低下 ,所以向量数据库应运而生。
1.2 传统数据库与向量数据库的差异 传统数据库采用基于行的存储方式,传统数据库将数据存储为行记录,每一行包含多个字段,并且每个字段都有固定的列。传统数据库通常使用索引来提高查询性能,例如下方就是一个典型的传统数据库表格
这种方式在处理结构化数据时非常高效,但在处理非结构化或半结构化数据时效率低下。
向量数据库将数据以列形式存储,即每个列都有一个独立的存储空间,这使得向量数据库可以更加灵活地处理复杂的数据结构。向量数据库还可以进行列压缩(稀疏矩阵 ),以减少存储空间和提高数据的访问速度。 并且在向量数据库中,将数据表示为高维向量 ,其中每个向量对应于数据点 。这些向量之间的距离表示它们之间的相似性 。这种方式使得非结构化或半结构化数据的存储和检索变得更加高效。
以电影数据库为例,我们可以将每部电影表示为一个特征向量。假设我们使用四个特征来描述每部电影:动作、冒险、爱情、科幻 。每个特征都可以在0到1的范围内进行标准化,表示该电影在该特征上的强度。
例如,电影”阿凡达”的向量表示可以是 [0.9, 0.8, 0.2, 0.9]
,其中数字分别表示动作、冒险、爱情、科幻的特征强度。其他电影也可以用类似的方式表示。这些向量可以存储在向量数据库中,如下所示: 现在,如果我们想要查找与电影”阿凡达”相似的电影,我们可以计算向量之间的距离,找到最接近的向量,从而实现相似性匹配,而无需复杂的SQL查询。这就像使用地图找到两个地点之间的最短路径一样简单。
1.3 传统数据库与向量数据库优缺点
1.4 相似度搜索算法 1.4.1 余弦相似度与欧氏距离 在向量数据库中,支持通过多种方式来计算两个向量的相似度,例如:余弦相似度、欧式距离、曼哈顿距离、闵可夫斯基距离、汉明距离、Jaccard相似度 等多种。其中最常见的就是 余弦相似度 和 欧式距离。 例如下图,左侧就是 欧式距离,右侧就是 余弦相似度:
余弦相似度主要用于衡量向量在方向上的相似性,特别适用于文本、图像和高维空间中的向量。它不受向量长度的影响,只考虑方向的相似程度,余弦相似度的计算公式如下(计算两个向量夹角的余弦值,取值范围为[-1, 1]
):
欧式距离衡量向量之间的直线距离,得到的值可能很大,最小为 0,通常用于低维空间或需要考虑向量各个维度之间差异的情况。欧氏距离较小的向量被认为更相似,欧式距离的计算公式如下:
1.4.2 相似性搜索加速算法 在向量数据库中,数据按列进行存储,通常会将多个向量组织成一个 M×N 的矩阵,其中 M 是向量的维度(特征数),N 是向量的数量(数据库中的条目数),这个矩阵可以是稠密或者稀疏的,取决于向量的稀疏性和具体的存储优化策略。
这样计算相似性搜索时,本质上就变成了向量与 M×N 矩阵的每一行进行相似度计算,这里可以用到大量成熟的加速算法:
1. 矩阵分解方法 :
· SVD(奇异值分解) :可以通过奇异值分解将原始矩阵转换为更低秩的矩阵表示,从而减少计算量。 · PCA(主成分分析) :类似地,可以通过主成分分析将高维矩阵映射到低维空间,减少计算复杂度。
索引结构和近似算法 :
· LSH(局部敏感哈希) :LSH 可以在近似相似度匹配中加速计算,特别适用于高维稀疏向量的情况。 · ANN(近似最近邻)算法 :ANN 算法如KD-Tree、Ball-Tree等可以用来加速对最近邻搜索的计算,虽然主要用于向量空间,但也可以部分应用于相似度计算中。
GPU 加速 :使用图形处理单元(GPU)进行并行计算可以显著提高相似度计算的速度,尤其是对于大规模数据和高维度向量。
分布式计算 :由于行与行之间独立,所以可以很便捷地支持分布式计算每行与向量的相似度,从而加速整体计算过程。
向量数据库底层除了在算法层面上针对相似性搜索做了大量优化,在存储结构、索引机制等方面均做了大量的优化,这才使得向量数据库在处理高维数据和实现快速相似性搜索上展示出巨大的优势
1.5 向量数据库的配置和使用 按照部署方式和提供的服务类型进行划分,向量数据库可以划分成几种: 1. 本地文件向量数据库 :用户将向量数据存储到本地文件系统中,通过数据库查询的接口来检索向量数据,例如:Faiss 。 2. 本地部署 API 向量数据库 :这类数据库不仅允许本地部署,而且提供了方便的 API 接口,使用户可以通过网络请求来访问和查询向量数据,这类数据库通常提供了更复杂的功能和管理选项,例如:Milvus、Annoy、Weaviate 等。 3. 云端 API 向量数据库 :将向量数据存储在云端,通过 API 提供向量数据的访问和管理功能,例如:TCVectorDB、Pinecone 等。
1.5.1 Faiss 向量数据库 1.5.1.1 Faiss 基本使用 Faiss 是 Facebook 团队开源的向量检索工具,针对高维空间的海量数据,提供高效可靠的相似性检索方式,被广泛用于推荐系统、图片和视频搜索等业务。Faiss 支持 Linux、macOS 和 Windows 操作系统,在百万级向量的相似性检索表现中,Faiss 能实现 < 10ms 的响应(需牺牲搜索准确度)。 CPU环境下使用
GPU环境下使用并且已经安装了CUDA,则可以使用GPU版本
代码示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import dotenv from langchain_community.vectorstores import FAISS from langchain_huggingface import HuggingFaceEmbeddings dotenv.load_dotenv() embeddings = HuggingFaceEmbeddings( model_name="sentence-transformers/all-MiniLM-L12-v2" , cache_folder="../22-其他Embedding嵌入模型的配置与使用/embeddings/" ) db = FAISS.load_local("vector-store/" , embeddings, allow_dangerous_deserialization=True ) print (db.similarity_search_with_score("我养了一只猫,叫笨笨" ))
1.5.1.2 删除指定数据 在 Faiss 中,支持删除向量数据库中特定的数据,目前仅支持传入数据条目 id 进行删除,并不支持条件筛选(但是可以通过条件筛选找到符合的数据,然后提取 id 列表,然后批量删除)。
代码示例:
1 2 3 4 print ("删除前数量:" , db.index.ntotal)db.delete([db.index_to_docstore_id[0 ]]) print ("删除后数量:" , db.index.ntotal)
输出结果:
1.5.1.3 带过滤的相似性搜索 在绝大部分向量数据库中,除了存储向量数据,还支持存储对应的元数据,这里的元数据可以是文本原文、扩展信息、页码、归属文档id、作者、创建时间 等等任何自定义信息,一般在向量数据库中,会通过元数据来实现对数据的检索。
1 向量数据库记录 = 向量(vector)+元数据(metadata)+id
Faiss 原生并不支持过滤,所以在 LangChain 封装的 FAISS 中对过滤功能进行了相应的处理。首先获取比 k 更多的结果 fetch_k(默认为 20 条),然后先进行搜索,接下来再搜索得到的 fetch_k 条结果上进行过滤,得到 k 条结果,从而实现带过滤的相似性搜索。 而且 Faiss 的搜索都是针对 元数据 的,在 Faiss 中执行带过滤的相似性搜索非常简单,只需要在搜索时传递 filter 参数即可,filter 可以传递一个元数据字典,也可以接收一个函数(函数的参数为元数据字典,返回值为布尔值)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import dotenv from langchain_community.vectorstores import FAISS from langchain_huggingface import HuggingFaceEmbeddings dotenv.load_dotenv() embeddings = HuggingFaceEmbeddings( model_name="sentence-transformers/all-MiniLM-L12-v2" , cache_folder="../22-其他Embedding嵌入模型的配置与使用/embeddings/" ) metadatas: list = [ {"page" : 1 }, {"page" : 2 }, {"page" : 3 }, {"page" : 4 }, {"page" : 5 }, {"page" : 6 }, {"page" : 7 }, {"page" : 8 }, {"page" : 9 }, {"page" : 10 }, ] db = FAISS.from_texts([ "笨笨是一只很喜欢睡觉的猫咪" , "我喜欢在夜晚听音乐,这让我感到放松。" , "猫咪在窗台上打盹,看起来非常可爱。" , "学习新技能是每个人都应该追求的目标。" , "我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , "昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , "我的手机突然关机了,让我有些焦虑。" , "阅读是我每天都会做的事情,我觉得很充实。" , "他们一起计划了一次周末的野餐,希望天气能好。" , "我的狗喜欢追逐球,看起来非常开心。" , ], embeddings, metadatas) print (db.index_to_docstore_id) print (db.similarity_search_with_score("我养了一只猫,叫笨笨" , filter =lambda x: x["page" ] > 5 ))
1.5.2 Pinecone 向量数据库 1.5.2.1 Pinecone 配置 Pinecone 是一个托管的、云原生的向量数据库,具有极简的 API,并且无需在本地部署即可快速使用,Pinecone 服务提供商还为每个账户设置了足够的免费空间,在开发阶段,可以快速基于 Pinecone 快速开发 AI 应用 。 相关资料: 1. Pinecone 官网:https://www.pinecone.io/ 2. Pinecone 翻译文档:https://www.pinecone-io.com/ 3. langchain-pinecone 翻译文档:http://imooc-langchain.shortvar.com/docs/integrations/vectorstores/pinecone/ Pinecone 向量数据库的设计架构与 Faiss 差异较大,Pinecone 由于是一个面向商业端的向量数据库,在功能和概念上会更加丰富,有几个核心概念+架构图如下:
概念的解释如下: 1. 组织 :组织是使用相同结算方式的一个或者多个项目的集合,例如个人账号、公司账号等都算是一个组织。 2. 项目 :项目是用来管理向量数据库、索引、硬件资源等内容的整合,可以将不同的项目数据进行区分。 3. 索引 :索引是 Pinecone 中数据的最高组织单位,在索引中需要定义向量的存储维度、查询时使用的相似性指标,并且在 Pinecone 中支持两种类型的索引:无服务器索引(根据数据大小自动扩容)和 Pod 索引(预设空间/硬件)。 4. 命名空间 :命名空间是索引内的分区,用于将索引中的数据区分成不同的组,以便于在不同的组内存储不同的数据,例如知识库、记忆的数据可以存储到不同的组中,类似 Excel 中的 Sheet表。 5. 记录 :记录是数据的基本单位,一条记录涵盖了 ID、向量(values)、元数据(metadata) 等。
所以在 Pinecone 中使用向量数据库,要确保 组织、项目、索引、命名空间、记录 等内容均配置好才可以使用,并且由于 Pinecone 是云端向量数据库,使用时还需配置对应的 API 秘钥(可在注册好 Pinecone 后管理页面的 API Key 中设置)。 对于 Pinecone,LangChain 团队也封装了响应的包,安装命令:
1 pip install -U langchain-pinecone
然后在 .env 文件中配置对应的 API 秘钥,如下
1.5.2.2 Pinecone 使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import dotenv from langchain_pinecone import PineconeVectorStore from langchain_huggingface import HuggingFaceEmbeddings dotenv.load_dotenv() embeddings = HuggingFaceEmbeddings( model_name="sentence-transformers/all-MiniLM-L12-v2" , cache_folder="../22-其他Embedding嵌入模型的配置与使用/embeddings/" ) texts: list = [ "笨笨是一只很喜欢睡觉的猫咪" , "我喜欢在夜晚听音乐,这让我感到放松。" , "猫咪在窗台上打盹,看起来非常可爱。" , "学习新技能是每个人都应该追求的目标。" , "我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , "昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , "我的手机突然关机了,让我有些焦虑。" , "阅读是我每天都会做的事情,我觉得很充实。" , "他们一起计划了一次周末的野餐,希望天气能好。" , "我的狗喜欢追逐球,看起来非常开心。" , ] metadatas: list = [ {"page" : 1 }, {"page" : 2 }, {"page" : 3 }, {"page" : 4 }, {"page" : 5 }, {"page" : 6 , "account_id" : 1 }, {"page" : 7 }, {"page" : 8 }, {"page" : 9 }, {"page" : 10 }, ] db = PineconeVectorStore(index_name="llmops" , embedding=embeddings, namespace="dataset" , pinecone_api_key="pcsk_Qz5bt_JMBCg1A6oJPbnceUnhwYf6CA1M57kBTxgVTDda96FkwCECAAhwPYrUvyytinYE2" ) db.add_texts(texts, metadatas, namespace="dataset" ) query = "我养了一只猫,叫笨笨" print (db.similarity_search_with_relevance_scores(query))
1.5.3 Weaviate 向量数据库 1.5.3.1 Weaviate 介绍 Weaviate 是完全使用 Go 语言构建的开源向量数据库,提供了强大的数据存储和检索功能。并且 Weaviate 提供了多种部署方式,以满足不同用户和用例的需求,部署方式如下: 1. Weaviate 云 :使用 Weaviate 官方提供的云服务,支持数据复制、零停机更新、无缝扩容等功能,适用于评估、开发和生产场景。 2. Docker 部署 :使用 Docker 容器部署 Weaviate 向量数据库,适用于评估和开发等场景。 3. K8s 部署 :在 K8s 上部署 Weaviate 向量数据库,适用于开发和生产场景。 4. 嵌入式 Weaviate:基于本地文件的方式构建 Weaviate 向量数据库,适用于评估场景,不过嵌入式 Weaviate 只适用于 Linux、macOS 系统,在 Windows 下不支持。
Weaviate 和 Pinecone/TCVectorDB 一样,也存在着集合的概念,在 Weaviate 中集合类似传统关系型数据库中的表,负责管理一类数据/数据对象,要使用 Weaviate 的流程其实也非常简单: 1. 创建部署 Weaviate 数据库(使用 Weaviate 云、Docker 部署)。 2. 安装 Python 客户端/LangChain 集成包。 3. 连接 Weaviate(本地连接、云端连接)。 4. 创建数据集/集合(代码创建、可视化管理界面创建),在 Weaviate 中,集合的名字必须以大写字母开头,并且只能包含字母、数字和下划线,否则创建的时候会出错,和 Python 的类名规范几乎一致。 5. 添加数据/向量。 6. 相似性搜索/带过滤器的相似性搜索。
参考资料: 1. Weaviate 官网:https://weaviate.io/ 2. Weaviate 快速上手指南:https://weaviate.io/developers/weaviate/quickstart
LangChain Weaviate 集成包翻译文档:https://imooc-langchain.shortvar.com/docs/integrations/vectorstores/weaviate
1.5.3.2 Weaviate 向量数据库的使用 Docker 部署 Weaviate 向量数据库:
1 docker run -d --name weaviate-dev -p 8080:8080 -p 50051:50051 cr.weaviate.io/semitechnologies/weaviate:1.24.20
创建好 Weaviate 数据库服务后,接下来就可以安装 Python 客户端/LangChain 集成包,命令如下:
1 pip install -Uqq langchain-weaviate
如果使用的是 Weaviate 云服务,可以直接从可视化界面创建 Collection,亦或者在使用时 LangChain 自动检测对应的数据集是否存在,如果不存在则直接创建。 然后就可以考虑连接 Weaviate 服务了,Weaviate 框架针对不同的部署方式提供的不同的连接方法: 1. weaviate.connect_to_local():连接到本地的部署服务,需配置连接 URL、端口号。 2. weaviate.connect_to_wcs():连接到远程的 Weaviate 服务,需配置连接 URL、连接秘钥。
代码示例 :
1 2 3 4 import weaviateclient = weaviate.connect_to_local("192.168.2.120" , "8080" )
连接到远程的 Weaviate 服务代码如下
1 2 3 4 5 6 import weaviatefrom weaviate.auth import AuthApiKeyclient = weaviate.connect_to_wcs( cluster_url="https://2j9jgyhprd2yej3c3rwog.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("BAn9bGZdZbdGCmUyfdegQoKFctyMmxaQdDFb" ) )
创建好客户端后,接下来可以基于客户端创建 LangChain 向量数据库实例,在实例化 LangChain VectorDB 时,需要传递 client(客户端)、 index_name(集合名字)、text(原始文本的存储键)、embedding(文本嵌入模型),如下
1 2 3 4 5 6 7 8 9 10 11 12 import dotenvimport weaviatefrom langchain_openai import OpenAIEmbeddingsfrom langchain_weaviate import WeaviateVectorStoredotenv.load_dotenv() client = weaviate.connect_to_local("192.168.2.120" , "8080" ) embedding = OpenAIEmbeddings(model="text-embedding-3-small" ) db = WeaviateVectorStore(client=client, index_name="DatasetTest" , text_key="text" , embedding=embedding)
实例化 LangChain VectorDB 后,就可以像 Faiss、Pinecone、TCVectorDB 一样去使用了,例如执行新增数据后完成检索示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import dotenvimport weaviatefrom langchain_openai import OpenAIEmbeddingsfrom langchain_weaviate import WeaviateVectorStoredotenv.load_dotenv() client = weaviate.connect_to_local("192.168.2.120" , "8080" ) embedding = OpenAIEmbeddings(model="text-embedding-3-small" ) db = WeaviateVectorStore(client=client, index_name="dataset-test" , text_key="text" , embedding=embedding) ids = db.add_texts([ "笨笨是一只很喜欢睡觉的猫咪" , "我喜欢在夜晚听音乐,这让我感到放松。" , "猫咪在窗台上打盹,看起来非常可爱。" , "学习新技能是每个人都应该追求的目标。" , "我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , "昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , "我的手机突然关机了,让我有些焦虑。" , "阅读是我每天都会做的事情,我觉得很充实。" , "他们一起计划了一次周末的野餐,希望天气能好。" , "我的狗喜欢追逐球,看起来非常开心。" , ]) print (db.similarity_search_with_score("笨笨" ))
输出内容: [(Document(page_content=’笨笨是一只很喜欢睡觉的猫咪’), 0.699999988079071), (Document(page_content=’猫咪在窗台上打盹,看起来非常可爱。’), 0.2090398222208023), (Document(page_content=’我的狗喜欢追逐球,看起来非常开心。’), 0.19787956774234772), (Document(page_content=’我的手机突然关机了,让我有些焦虑。’), 0.11435992270708084)]
在 Weaviate 中,也支持带过滤器的相似性筛选,并且 LangChain Weaviate 社区包并没有对筛选过滤器进行二次封装,所以直接传递原生的 weaviate 过滤器即可,参考文档:https://weaviate.io/developers/weaviate/search/filters
例如需要检索 page 属性大于等于 5 的所有数据,可以构建一个 filters 后传递给检索方法,如下:
1 2 3 from weaviate.classes.query import Filterfilters = Filter.by_property("page" ).greater_or_equal(5 ) print (db.similarity_search_with_score("笨笨" , filters=filters))
输出结果如下:
1 [(Document(page_content='我的狗喜欢追逐球,看起来非常开心。' , metadata={'page' : 10.0, 'account_id' : None}), 0.699999988079071), (Document(page_content='我的手机突然关机了,让我有些焦虑。' , metadata={'page' : 7.0, 'account_id' : None}), 0.4045487940311432), (Document(page_content='昨晚我做了一个奇怪的梦,梦见自己在太空飞行。' , metadata={'page' : 6.0, 'account_id' : 1.0}), 0.318904846906662), (Document(page_content='我最喜欢的食物是意大利面,尤其是番茄酱的那种。' , metadata={'page' : 5.0, 'account_id' : None}), 0.2671944797039032)]
如果想获取 Weaviate 原始集合的实例,可以通过 db._collection
快速获得,从而去执行一些原始操作,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from weaviate.classes.query import MetadataQuerycollection = db._collection response = collection.query.near_text( query="a sweet German white wine" , limit=2 , target_vector="title_country" , return_metadata=MetadataQuery(distance=True ) ) for o in response.objects: print (o.properties) print (o.metadata.distance)
1.5.4 自定义向量数据库 向量数据库的发展非常迅猛,几乎间隔几天就有新的向量数据库发布,LangChain 不可能将所有向量数据库都进行集成,亦或者封装的包存在这一些 bug 或错误,这个时候就需要考虑创建自定义向量数据库,去实现特定的方法。
在 LangChain 实现自定义向量数据库的类有两种模式,一种是继承封装好的数据库类,一种是继承基类 VectorStore。前一种一般继承后重写部分方法进行扩展或者修复 bug,后面一种是对接新的向量数据库。
在 LangChain 中,继承 VectorStore 只需实现最基础的 3 个方法即可正常使用: 1. add_texts :将对应的数据添加到向量数据库中。 2. similarity_search :最基础的相似性搜索。 3. from_texts :从特定的文本列表、元数据列表中构建向量数据库。
其他方法因为使用频率并不高,VectorStore 并没有设置成虚拟方法,但是再没有实现的情况下,直接调用会报错,涵盖: 1. delete():删除向量数据库中的数据。 2. _select_relevance_score_fn()
:根据距离计算相似性得分函数。 3. similarity_search_with_score()
:携带得分的相似性搜索函数。 4. similarity_search_by_vector():传递向量进行相似性搜索。 5. max_marginal_relevance_search():最大边界相似性搜索。 6. max_marginal_relevance_search_by_vector():传递向量进行最大边界相关性搜索。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 import uuid from typing import List , Optional , Any , Iterable, Type import dotenv import numpy as np from langchain_core.documents import Document from langchain_core.embeddings import Embeddings from langchain_core.vectorstores import VectorStore from langchain_openai import OpenAIEmbeddings class MemoryVectorStore (VectorStore ): """基于内存+欧几里得距离的向量数据库""" store: dict = {} def __init__ (self, embedding: Embeddings ): self._embedding = embedding def add_texts (self, texts: Iterable[str ], metadatas: Optional [List [dict ]] = None , **kwargs: Any ) -> List [str ]: """将数据添加到向量数据库中""" if metadatas is not None and len (metadatas) != len (texts): raise ValueError("metadatas格式错误" ) embeddings = self._embedding.embed_documents(texts) ids = [str (uuid.uuid4()) for _ in texts] for idx, text in enumerate (texts): self.store[ids[idx]] = { "id" : ids[idx], "text" : text, "vector" : embeddings[idx], "metadata" : metadatas[idx] if metadatas is not None else {}, } return ids def similarity_search (self, query: str , k: int = 4 , **kwargs: Any ) -> List [Document]: """传入对应的query执行相似性搜索""" embedding = self._embedding.embed_query(query) result = [] for key, record in self.store.items(): distance = self._euclidean_distance(embedding, record["vector" ]) result.append({"distance" : distance, **record}) sorted_result = sorted (result, key=lambda x: x["distance" ]) result_k = sorted_result[:k] return [ Document(page_content=item["text" ], metadata={**item["metadata" ], "score" : item["distance" ]}) for item in result_k ] @classmethod def from_texts (cls: Type ["MemoryVectorStore" ], texts: List [str ], embedding: Embeddings, metadatas: Optional [List [dict ]] = None , **kwargs: Any ) -> "MemoryVectorStore" : """从文本和元数据中去构建向量数据库""" memory_vector_store = cls(embedding=embedding) memory_vector_store.add_texts(texts, metadatas, **kwargs) return memory_vector_store @classmethod def _euclidean_distance (cls, vec1: list , vec2: list ) -> float : """计算两个向量的欧几里得距离""" return np.linalg.norm(np.array(vec1) - np.array(vec2)) dotenv.load_dotenv() texts = [ "笨笨是一只很喜欢睡觉的猫咪" , "我喜欢在夜晚听音乐,这让我感到放松。" , "猫咪在窗台上打盹,看起来非常可爱。" , "学习新技能是每个人都应该追求的目标。" , "我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , "昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , "我的手机突然关机了,让我有些焦虑。" , "阅读是我每天都会做的事情,我觉得很充实。" , "他们一起计划了一次周末的野餐,希望天气能好。" , "我的狗喜欢追逐球,看起来非常开心。" , ] metadatas = [ {"page" : 1 }, {"page" : 2 }, {"page" : 3 }, {"page" : 4 }, {"page" : 5 }, {"page" : 6 , "account_id" : 1 }, {"page" : 7 }, {"page" : 8 }, {"page" : 9 }, {"page" : 10 }, ] embedding = OpenAIEmbeddings(model="text-embedding-3-small" ) db = MemoryVectorStore(embedding=embedding) ids = db.add_texts(texts, metadatas) print (ids) print (db.similarity_search("笨笨是谁?" ))
2 嵌入模型介绍和使用 2.1 嵌入模型介绍 要想使用向量数据库的相似性搜索,存储的数据必须是向量,那么如何将高维度的文字、图片、视频等非结构化数据转换成向量呢?这个时候就需要使用到 Embedding 嵌入模型了,例如下方就是 Embedding 嵌入模型的运行流程:
Embedding 模型是一种在机器学习和自然语言处理中广泛应用的技术,它旨在将高纬度的数据(如文字、图片、视频)映射到低纬度的空间。Embedding 向量是一个 N 维的实值向量,它将输入的数据表示成一个连续的数值空间中的点。这种嵌入可以是一个词、一个类别特征(如商品、电影、物品等)或时间序列特征等。 而且通过学习,Embedding 向量可以更准确地表示对应特征的内在含义,使几何距离相近的向量对应的物体有相近的含义 ,甚至对向量进行加减乘除算法都有意义! 一句话理解 Embedding:一种模型生成方法,可以将非结构化的数据,例如文本/图片/视频等数据映射成有意义的向量数据 。
目前生成 embedding 方法的模型有以下 4 类: 1. Word2Vec(词嵌入模型) :这个模型通过学习将单词转化为连续的向量表示,以便计算机更好地理解和处理文本。Word2Vec 模型基于两种主要算法 CBOW 和 Skip-gram。 2. Glove :一种用于自然语言处理的词嵌入模型,它与其他常见的词嵌入模型(如 Word2Vec 和 FastText)类似,可以将单词转化为连续的向量表示。GloVe 模型的原理是通过观察单词在语料库中的共现关系,学习得到单词之间的语义关系。具体来说,GloVe 模型将共现概率矩阵表示为两个词向量之间的点积和偏差的关系,然后通过迭代优化来训练得到最佳的词向量表示。GloVe 模型的优点是它能够在大规模语料库上进行有损压缩,得到较小维度的词向量,同时保持了单词之间的语义关系。这些词向量可以被用于多种自然语言处理任务,如词义相似度计算、情感分析、文本分类等。 3. FastText :一种基于词袋模型的词嵌入技术,与其他常见的词嵌入模型(如 Word2Vec 和 GloVe)不同之处在于,FastText考虑了单词的子词信息。其核心思想是将单词视为字符的 n-grams 的集合,在训练过程中,模型会同时学习单词级别和n-gram级别的表示。这样可以捕捉到单词内部的细粒度信息,从而更好地处理各种形态和变体的单词。 4. 大模型 Embeddings(重点) :和大模型相关的嵌入模型,如 OpenAI 官方发布的第二代模型:text-embedding-ada-002。它最长的输入是 8191 个tokens,输出的维度是 1536。
2.3 Embedding 的价值 1. 降维 :在许多实际问题中,原始数据的维度往往非常高。例如,在自然语言处理中,如果使用 Token 词表编码来表示词汇,其维度等于词汇表的大小,可能达到数十万甚至更高。通过 Embedding,我们可以将这些高维数据映射到一个低维空间,大大减少了模型的复杂度。 2. 捕捉语义信息 :Embedding 不仅仅是降维,更重要的是,它能够捕捉到数据的语义信息。例如,在词嵌入中,语义上相近的词在向量空间中也会相近。这意味着Embedding可以保留并利用原始数据的一些重要信息。 3. 适应性 : 与一些传统的特征提取方法相比,Embedding 是通过数据驱动的方式学习的。这意味着它能够自动适应数据的特性,而无需人工设计特征。 4. 泛化能力 :在实际问题中,我们经常需要处理一些在训练数据中没有出现过的数据。由于Embedding能够捕捉到数据的一些内在规律,因此对于这些未见过的数据,Embedding仍然能够给出合理的表示。 5. 可解释性 :尽管 Embedding 是高维的,但我们可以通过一些可视化工具(如t-SNE)来观察和理解 Embedding 的结构。这对于理解模型的行为,以及发现数据的一些潜在规律是非常有用的。
2.4 CacheBackEmbedding 组件 通过嵌入模型计算传递数据的向量需要昂贵的算力,对于重复的内容,Embeddings 计算的结果肯定是一致的,如果数据重复仍然二次计算,会导致效率非常低,而且增加无用功。
所以在 LangChain 中提供了一个叫 CacheBackEmbedding 的包装类,一般通过类方法 from_bytes_store 进行实例化,它接受以下参数: 1. underlying_embedder:用于嵌入的嵌入模型。 2. document_embedding_cache:用于缓存文档嵌入的任何存储库(ByteStore)。 3. batch_size:可选参数,默认为 None,在存储更新之间要嵌入的文档数量。 4. namespace:可选参数,默认为“”,用于文档缓存的命名空间。此命名空间用于避免与其他缓存发生冲突。例如,将其设置为所使用的嵌入模型的名称。 5. query_embedding_cache:可选默认为 None 或者不缓存,用于缓存查询/文本嵌入的 ByteStore,或这是为 True 以使用与 document_embedding_cache 相同的存储。
CacheBackEmbedding 默认不缓存 embed_query 生成的向量,如果要缓存,需要设置 query_embedding_cache 的值,另外请尽可能设置 namespace,以避免使用不同嵌入模型嵌入的相同文本发生冲突。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import dotenv import numpy as np from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore from langchain_openai import OpenAIEmbeddings from numpy.linalg import norm dotenv.load_dotenv() def cosine_similarity (vector1: list , vector2: list ) -> float : """计算传入两个向量的余弦相似度""" dot_product = np.dot(vector1, vector2) norm_vec1 = norm(vector1) norm_vec2 = norm(vector2) return dot_product / (norm_vec1 * norm_vec2) embeddings = OpenAIEmbeddings(model="text-embedding-3-small" ) embeddings_with_cache = CacheBackedEmbeddings.from_bytes_store( embeddings, LocalFileStore("./cache/" ), namespace=embeddings.model, query_embedding_cache=True , ) query_vector = embeddings_with_cache.embed_query("你好,我是慕小课,我喜欢打篮球" ) documents_vector = embeddings_with_cache.embed_documents([ "你好,我是慕小课,我喜欢打篮球" , "这个喜欢打篮球的人叫慕小课" , "求知若渴,虚心若愚" ]) print (query_vector) print (len (query_vector)) print ("============" ) print (len (documents_vector)) print ("vector1与vector2的余弦相似度:" , cosine_similarity(documents_vector[0 ], documents_vector[1 ])) print ("vector2与vector3的余弦相似度:" , cosine_similarity(documents_vector[0 ], documents_vector[2 ]))
2.5 HuggingFace Embedding 模型的配置和使用 2.5.1 HuggingFace 本地模型 在某些对数据保密要求极高的场合下,数据不允许传递到外网,这个时候就可以考虑使用本地的文本嵌入模型——Hugging Face 本地嵌入模型,安装 langchain-huggingface 与 sentence-transformers 包,命令如下:
1 pip install -U langchain-huggingface sentence-transformers
其中 langchain-huggingface 是 langchain 团队基于 huggingface 封装的第三方社区包,sentence-transformers 是一个用于生成和使用预训练的文本嵌入,基于 transformer 架构,也是目前使用量最大的本地文本嵌入模型。 配置好后,就可以像正常的文本嵌入模型一样使用了,示例
1 2 3 4 5 6 7 8 9 10 11 from langchain_huggingface import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="sentence-transformers/all-MiniLM-L12-v2" , cache_folder="./embeddings/" ) query_vector = embeddings.embed_query("你好,我是慕小课,我喜欢打篮球游泳" ) print (query_vector) print (len (query_vector))
2.5.2 HuggingFace远程嵌入模型 部分模型的文件比较大,如果只是短期内调试,可以考虑使用 HuggingFace 提供的远程嵌入模型,首先安装对应的依赖
1 pip install huggingface_hub
然后在 Hugging Face 官网(https://huggingface.co/ ) 的 setting 中添加对应的访问秘钥,并配置到 .env 文件中
1 HUGGINGFACEHUB_API_TOKEN=xxx
接下来就可以使用 HuggingFace 提供的推理服务,这样在本地服务器上就无需配置对应的文本嵌入模型了。
1 2 3 4 5 6 7 8 9 10 11 import dotenv from langchain_huggingface import HuggingFaceEndpointEmbeddings dotenv.load_dotenv() embeddings = HuggingFaceEndpointEmbeddings(model="sentence-transformers/all-MiniLM-L12-v2" ) query_vector = embeddings.embed_query("你好,我是慕小课,我喜欢打篮球游泳" ) print (query_vector) print (len (query_vector))
相关资料信息: 1. Hugging Face 官网:https://huggingface.co/ 2. HuggingFace 嵌入文档:https://python.langchain.com/v0.2/docs/integrations/text_embedding/sentence_transformers/ 3. HuggingFace 嵌入翻译文档:http://imooc-langchain.shortvar.com/docs/integrations/text_embedding/sentence_transformers/
3 文档加载器 3.1 Document 与文档加载器 Document 类是 LangChain 中的核心组件,这个类定义了一个文档对象的结构,涵盖了文本内容和相关的元数据,Document 也是文档加载器、文档分割器、向量数据库、检索器这几个组件之间交互传递的状态数据。 在 LangChain 中所有文档加载器的基类为 BaseLoader,封装了统一的 5 个方法: 1. load()/aload():加载和异步加载文档,返回的数据为文档列表。 2. load_and_split():传递分割器,加载并将大文档按照传入的分割器进行切割,返回的数据为分割后的文档列表。 3. lazy_load()/alazy_load():懒加载和异步懒加载文档,返回的是一个迭代器,适用于传递的数据源有多份文档的情况,例如文件夹加载器,可以每次获得最新的加载文档,不需要等到所有文档都加载完毕。
在 LangChain 中封装了上百种文档加载器,几乎所有的文件都可以使用这些加载器完成数据的读取,而不需要手动去封装
代码示例:
1 2 3 4 5 6 7 8 9 10 11 from langchain_community.document_loaders import TextLoader loader = TextLoader("./电商产品数据.txt" , encoding="utf-8" ) documents = loader.load() print (documents) print (len (documents)) print (documents[0 ].metadata)
3.2 内置文档加载器的使用技巧 LangChain 内置文档加载器文档:https://imooc-langchain.shortvar.com/docs/integrations/document_loaders/
3.2.1 Markdown 文档加载器 LangChain 中封装了一个 UnstructuredMarkdownLoader 对象,要使用这个加载器,必须安装 unstructured 包,安装命令
1 pip install unstructured
代码示例:
1 2 3 4 5 6 7 8 from langchain_community.document_loaders import UnstructuredMarkdownLoader loader = UnstructuredMarkdownLoader("./项目API资料.md" ) documents = loader.load() print (documents) print (len (documents)) print (documents[0 ].metadata)
3.2.2 Office 文档加载器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from langchain_community.document_loaders import ( UnstructuredPowerPointLoader, ) ppt_loader = UnstructuredPowerPointLoader("./章节介绍.pptx" ) documents = ppt_loader.load() print (documents) print (len (documents)) print (documents[0 ].metadata)
3.2.3 通用文档加载器 在实际的 LLM 应用开发中,由于数据的种类是无穷的,没办法单独为每一种数据配置一个加载器(也不现实),所以对于一些无法判断的数据类型或者想进行通用性文件加载,可以统一使用非结构化文件加载器 UnstructuredFileLoader 来实现对文件的加载。
UnstructuredFileLoader 是所有 UnstructuredXxxLoader 文档类的基类,其核心是将文档划分为元素,当传递一个文件时,库将读取文档,将其分割为多个部分,对这些部分进行分类,然后提取每个部分的文本,然后根据模式决定是否合并(single、paged、elements)。 一个 UnstructuredFileLoader 可以加载多种类型的文件,涵盖了:文本文件、PowerPoint 文件、HTML、PDF、图像、Markdown、Excel、Word 等
例如通过检测文件的扩展名来加载不同的文件加载器,对于没校验到的文件类型,才考虑使用 UnstructuredFileLoader,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 if file_extension in [".xlsx" , ".xls" ]: loader = UnstructuredExcelLoader(file_path) elif file_extension == ".pdf" : loader = UnstructuredPDFLoader(file_path) elif file_extension in [".md" , ".markdown" ]: loader = UnstructuredMarkdownLoader(file_path) elif file_extension in [".htm" , "html" ]: loader = UnstructuredHTMLLoader(file_path) elif file_extension in [".docx" , ".doc" ]: loader = UnstructuredWordDocumentLoader(file_path) elif file_extension == ".csv" : loader = UnstructuredCSVLoader(file_path) elif file_extension in [".ppt" , ".pptx" ]: loader = UnstructuredPowerPointLoader(file_path) elif file_extension == ".xml" : loader = UnstructuredXMLLoader(file_path) else : loader = UnstructuredFileLoader(file_path) if is_unstructured else TextLoader(file_path)
3.2.4 自定义文档加载器 代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 from typing import Iterator, AsyncIterator from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document class CustomDocumentLoader (BaseLoader ): """自定义文档加载器,将文本文件的每一行都解析成Document""" def __init__ (self, file_path: str ) -> None : self.file_path = file_path def lazy_load (self ) -> Iterator[Document]: with open (self.file_path, encoding="utf-8" ) as f: line_number = 0 for line in f: yield Document( page_content=line, metadata={"score" : self.file_path, "line_number" : line_number} ) line_number += 1 async def alazy_load (self ) -> AsyncIterator[Document]: import aiofiles async with aiofiles.open (self.file_path, encoding="utf-8" ) as f: line_number = 0 async for line in f: yield Document( page_content=line, metadata={"score" : self.file_path, "line_number" : line_number} ) line_number += 1 loader = CustomDocumentLoader("./喵喵.txt" ) documents = loader.load() print (documents) print (len (documents)) print (documents[0 ].metadata)
lazy_load() 方法的两个核心步骤就是:读取文件数据、将文件数据解析成Document,并且绝大部分文档加载器都有着两个核心步骤,而且 读取文件数据 这个步骤大家都大差不差。
就像 *.md、*.txt、*.py
这类文本文件,甚至是 *.pdf、*.doc
等这类非文本文件,都可以使用同一个 读取文件数据 步骤将文件读取为 二进制内容,然后在使用不同的解析逻辑来解析对应的二进制内容,所以很容易可以得出:
因此,在项目开发中,如果大量配置自定义文档解析器的话,将解析逻辑与加载逻辑分离,维护起来会更容易,而且也更容易复用相应的逻辑(具体使用哪种方式取决于开发)。
这样原先的 DocumentLoader 运行流程就变成了如下:
4 文本分割器 在 LangChain 中针对文档的转换也统一封装了一个基类 BaseDocumentTransformer,所有涉及到文档的转换的类均是该类的子类 ,将大块文档切割成 chunk 分块的文档分割器也是 BaseDocumentTransformer 的子类实现。
BaseDocumentTransformer 基类封装了两个方法: 1. transform_documents():抽象方法,传递文档列表,返回转换后的文档列表。 2. atransform_documents():转换文档列表函数的异步实现,如果没有实现,则会委托 transform_documents() 函数实现。
在 LangChain 中,文档转换组件分成了两类:文档分割器(使用频率高)、文档处理转换器(使用频率低,老版本写法)。
1 pip install -qU langchain-text-splitters
4.2 字符分割器 在文档分割器中,最简单的分割器就是——字符串分割器 ,这个组件会基于给定的字符串进行分割,默认为 \n\n,并且在分割时会尽可能保证数据的连续性。分割出来每一块的长度是通过字符数来衡量的,使用起来也非常简单,实例化 CharacterTextSplitter 需传递多个参数,信息如下:
1. separator:分隔符,默认为 \n\n
。 2. is_separator_regex:是否正则表达式,默认为 False。 3. chunk_size:每块文档的内容大小,默认为 4000。 4. chunk_overlap:块与块之间重叠的内容大小,默认为 200。 5. length_function:计算文本长度的函数,默认为 len。 6. keep_separator:是否将分隔符保留到分割的块中,默认为 False。 7. add_start_index:是否添加开始索引,默认为 False,如果是的话会在元数据中添加该切块的起点。 8. strip_whitespace:是否删除文档头尾的空白,默认为 True。
如果想将文档切割为不超过 500 字符,并且每块之间文本重叠 50 个字符,可以使用 CharacterTextSplitter 来实现,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from langchain_community.document_loaders import UnstructuredMarkdownLoader from langchain_text_splitters import CharacterTextSplitter loader = UnstructuredMarkdownLoader("./项目API文档.md" ) documents = loader.load() text_splitter = CharacterTextSplitter( separator="\n\n" , chunk_size=500 , chunk_overlap=50 , add_start_index=True , ) chunks = text_splitter.split_documents(documents) for chunk in chunks: print (f"块大小:{len (chunk.page_content)} , 元数据:{chunk.metadata} " ) print (len (chunks))
4.3 递归字符文本分割器 普通的字符文本分割器只能使用单个分隔符对文本内容进行划分,在划分的过程中,可能会出现文档块 过小 或者 过大 的情况,这会让 RAG 变得不可控,例如: 1. 文档块可能会变得非常大 ,极端的情况下某个块的内容长度可能就超过了 LLM 的上下文长度限制,这样这个文本块永远不会被引用到,相当于存储了数据,但是数据又丢失了。 2. 文档块可能会远远小于窗口大小 ,导致文档块的信息密度太低,块内容即使填充到 Prompt 中,LLM 也无法提取出有用的信息。
RecursiveCharacterTextSplitter,即递归字符串分割 ,这个分割器可以传递 一组分隔符 和 设定块内容大小,根据分隔符的优先顺序对文本进行预分割,然后将小块进行合并,将大块进行递归分割,直到获得所需块的大小,最终这些文档块的大小并不能完全相同,但是仍然会逼近指定长度。 RecursiveCharacterTextSplitter 的分隔符参数默认为 ["\n\n", "\n", " ", ""]
,即优先使用换两行的数据进行分割,然后在使用单个换行符,如果块内容还是太大,则使用空格,最后再拆分成单个字符。 所以如果使用默认参数,这个字符文本分割器最后得到的文档块长度一定不会超过预设的大小,但是仍然会有小概率出现远小于的情况(目前也没有很好的解决方案)。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from langchain_community.document_loaders import UnstructuredMarkdownLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = UnstructuredMarkdownLoader("./项目API文档.md" ) documents = loader.load() text_splitter = RecursiveCharacterTextSplitter( chunk_size=500 , chunk_overlap=50 , add_start_index=True , ) chunks = text_splitter.split_documents(documents) for chunk in chunks: print (f"块大小: {len (chunk.page_content)} , 元数据: {chunk.metadata} " )
4.4 语义文档分割器 语义相似性分割器,SemanticChunker 在使用上和其他的文档分割器存在一些差异,并且该类并没有继承 TextSplitter,实例化参数含义如下:
1. embeddings:文本嵌入模型,在该分类器底层使用向量的 余弦相似度 来识别语句之间的相似性。 2. buffer_size:文本缓冲区大小,默认为 1,即在计算相似性时,该文本会叠加前后各 1 条文本,如果不够则不叠加(例如第 1 条和最后 1 条)。 3. add_start_index:是否添加起点索引,默认为 False。 4. breakpoint_threshold_type:断点阈值类型,默认为 percentile 即百分位 5. breakpoint_threshold_amount:断点阈值金额/得分。 6. number_of_chunks:分割后的文档块个数,默认为 None。 7. sentence_split_regex:句子切割正则,默认为 (?<=[.?!])\s+
,即以英文的点、问号、感叹号切割语句,不同的文档需要传递不同的切割正则表达式。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import dotenv from langchain_community.document_loaders import UnstructuredFileLoader from langchain_experimental.text_splitter import SemanticChunker from langchain_openai import OpenAIEmbeddings dotenv.load_dotenv() loader = UnstructuredFileLoader("./科幻短篇.txt" ) text_splitter = SemanticChunker( embeddings=OpenAIEmbeddings(model="text-embedding-3-small" ), number_of_chunks=10 , add_start_index=True , sentence_split_regex=r"(?<=[。?!.?!])" ) documents = loader.load() chunks = text_splitter.split_documents(documents) for chunk in chunks: print (f"块大小: {len (chunk.page_content)} , 元数据: {chunk.metadata} " )
4.5 自定义文档分割器 代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 from typing import List import jieba.analyse from langchain_community.document_loaders import UnstructuredFileLoader from langchain_text_splitters import TextSplitter class CustomTextSplitter (TextSplitter ): """自定义文本分割器""" def __init__ (self, seperator: str , top_k: int = 10 , **kwargs ): """构造函数,传递分割器还有需要提取的关键词数,默认为10""" super ().__init__(**kwargs) self._seperator = seperator self._top_k = top_k def split_text (self, text: str ) -> List [str ]: """传递对应的文本执行分割并提取分割数据的关键词,组成文档列表返回""" split_texts = text.split(self._seperator) text_keywords = [] for split_text in split_texts: text_keywords.append(jieba.analyse.extract_tags(split_text, self._top_k)) return ["," .join(keywords) for keywords in text_keywords] loader = UnstructuredFileLoader("./科幻短篇.txt" ) text_splitter = CustomTextSplitter("\n\n" , 10 ) documents = loader.load() chunks = text_splitter.split_documents(documents) for chunk in chunks: print (chunk.page_content)
4.6 非分割类型的文档分割器 在 LangChain 中,还存在另一种非分割类型的文档转换器,这类转换器也是传递 文档列表 并返回 文档列表,一般是将某种文档按照需求转换成另外一种格式(例如:翻译文档、文档重排、HTML 转文本、文档元数据提取、文档转问答 等)
4.6.1 问答转换器 在 RAG 的外挂知识库中,向量存储知识库中使用的文档通常以叙述或对话格式存储。但是,绝大部分用户的查询都是问题格式,所以如果我们在对文档进行向量化之前先将其转换为 问答格式,可以在一定程度上增加检索相关文档的可能性,降低检索不相关文档的可能性。
这个技巧也是 RAG 应用开发中常见的一种优化策略,即将原始数据转换成 QA 数据后进行存储,除此之外,对于绝大部分 LLM 的微调,使用的也是 QA问答数据 也可以考虑使用该问答转换器进行转换。
在 LangChain 中封装了 Doctran 库并实现了 DoctranQATransformer 类可以快捷实现该功能,这个库底层使用 OpenAI 的函数回调来实现对问答数据的提取,首先安装该库
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import dotenv from doctran import Doctran from langchain_community.document_transformers import DoctranQATransformer from langchain_core.documents import Document _ = Doctran dotenv.load_dotenv() page_content = """机密文件 - 仅供内部使用 日期:2023年7月1日 主题:各种话题的更新和讨论 亲爱的团队, 希望这封邮件能找到你们一切安好。在这份文件中,我想向你们提供一些重要的更新,并讨论需要我们关注的各种话题。请将此处包含的信息视为高度机密。 安全和隐私措施 作为我们不断致力于确保客户数据安全和隐私的一部分,我们已在所有系统中实施了强有力的措施。我们要赞扬IT部门的John Doe(电子邮件:john.doe@example.com)在增强我们网络安全方面的勤奋工作。未来,我们提醒每个人严格遵守我们的数据保护政策和准则。此外,如果您发现任何潜在的安全风险或事件,请立即向我们专门的团队报告,联系邮箱为security@example.com。 人力资源更新和员工福利 最近,我们迎来了几位为各自部门做出重大贡献的新团队成员。我要表扬Jane Smith(社保号:049-45-5928)在客户服务方面的出色表现。Jane一直受到客户的积极反馈。此外,请记住我们的员工福利计划的开放报名期即将到来。如果您有任何问题或需要帮助,请联系我们的人力资源代表Michael Johnson(电话:418-492-3850,电子邮件:michael.johnson@example.com)。 营销倡议和活动 我们的营销团队一直在积极制定新策略,以提高品牌知名度并推动客户参与。我们要感谢Sarah Thompson(电话:415-555-1234)在管理我们的社交媒体平台方面的杰出努力。Sarah在过去一个月内成功将我们的关注者基数增加了20%。此外,请记住7月15日即将举行的产品发布活动。我们鼓励所有团队成员参加并支持我们公司的这一重要里程碑。 研发项目 在追求创新的过程中,我们的研发部门一直在为各种项目不懈努力。我要赞扬David Rodriguez(电子邮件:david.rodriguez@example.com)在项目负责人角色中的杰出工作。David对我们尖端技术的发展做出了重要贡献。此外,我们希望每个人在7月10日定期举行的研发头脑风暴会议上分享他们的想法和建议,以开展潜在的新项目。 请将此文档中的信息视为最机密,并确保不与未经授权的人员分享。如果您对讨论的话题有任何疑问或顾虑,请随时直接联系我。 感谢您的关注,让我们继续共同努力实现我们的目标。 此致, Jason Fan 联合创始人兼首席执行官 Psychic jason@psychic.dev""" documents = [Document(page_content=page_content)] qa_transformer = DoctranQATransformer(openai_api_model="gpt-3.5-turbo-16k" ) transformer_documents = qa_transformer.transform_documents(documents) for qa in transformer_documents[0 ].metadata.get("questions_and_answers" ): print ("问答数据:" , qa)
输出内容:
1 2 3 4 5 6 7 8 9 10 11 {'question' : '文件日期是什么?' , 'answer' : '2023年7月1日' } {'question' : '文件主题是什么?' , 'answer' : '各种话题的更新和讨论' } {'question' : '谁是IT部门的网络安全负责人?' , 'answer' : 'John Doe(电子邮件:john.doe@example.com)' } {'question' : '如果发现安全风险或事件,应该向谁报告?' , 'answer' : '专门的团队,联系邮箱为security@example.com' } {'question' : '谁在客户服务方面表现出色?' , 'answer' : 'Jane Smith(社保号:049-45-5928)' } {'question' : '员工福利计划的开放报名期是什么时候?' , 'answer' : '即将到来' } {'question' : '人力资源代表的联系信息是什么?' , 'answer' : 'Michael Johnson(电话:418-492-3850,电子邮件:michael.johnson@example.com)' } {'question' : '谁在管理社交媒体平台方面做出了杰出努力?' , 'answer' : 'Sarah Thompson(电话:415-555-1234)' } {'question' : '产品发布活动的日期是什么时候?' , 'answer' : '7月15日' } {'question' : '谁在研发部门担任项目负责人角色?' , 'answer' : 'David Rodriguez(电子邮件:david.rodriguez@example.com)' } {'question' : '研发头脑风暴会议的日期是什么时候?' , 'answer' : '7月10日' }
4.6.2 翻译转换器 在 RAG 应用开发中,将文档通过嵌入/向量的方式进行比较的好处在于能跨语言工作,例如:你好,世界!、Hello, World! 和 こんにちは、世界! 分别是 中英日 三国的语言,但是因为语义相近,所以在向量空间中的位置也是非常接近的。
当一个 RAG 应用需要跨语言工作时,一般有两种策略: 1. 在将文档切块并嵌入存储到向量数据库时,同时将文档翻译成多国语言并进行相同的操作。 2. 在进行检索操作时,将检索出来的文档执行翻译功能,然后使用翻译后的文档。 这两种策略都涉及到一个功能,就是 文档的翻译,或者是说将 文档 转换成另外一种形式的 文档,这类操作其实和 文档转换器 的作用一模一样,所以可以考虑使用该组件来实现这个功能,LangChain 中针对翻译的转换器就提供了不少,例如 Doctran。
5 文档检索器 5.1 带得分阈值的相似性搜索 在 LangChain 的相似性搜索中,无论结果多不匹配,只要向量数据库中存在数据,一定会查找出相应的结果,在 RAG 应用开发中,一般是将高相似文档插入到 Prompt 中,所以可以考虑添加一个 相似性得分阈值,超过该数值的部分才等同于有相似性。 在 similarity_search_with_relevance_scores() 函数中,可以传递 score_threshold 阈值参数,过滤低于该得分的文档。 例如没有添加阈值检索 我养了一只猫,叫笨笨,示例与输出如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import dotenv from langchain_community.vectorstores import FAISS from langchain_core.documents import Document from langchain_openai import OpenAIEmbeddings dotenv.load_dotenv() embedding = OpenAIEmbeddings(model="text-embedding-3-small" ) documents = [ Document(page_content="笨笨是一只很喜欢睡觉的猫咪" , metadata={"page" : 1 }), Document(page_content="我喜欢在夜晚听音乐,这让我感到放松。" , metadata={"page" : 2 }), Document(page_content="猫咪在窗台上打盹,看起来非常可爱。" , metadata={"page" : 3 }), Document(page_content="学习新技能是每个人都应该追求的目标。" , metadata={"page" : 4 }), Document(page_content="我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , metadata={"page" : 5 }), Document(page_content="昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , metadata={"page" : 6 }), Document(page_content="我的手机突然关机了,让我有些焦虑。" , metadata={"page" : 7 }), Document(page_content="阅读是我每天都会做的事情,我觉得很充实。" , metadata={"page" : 8 }), Document(page_content="他们一起计划了一次周末的野餐,希望天气能好。" , metadata={"page" : 9 }), Document(page_content="我的狗喜欢追逐球,看起来非常开心。" , metadata={"page" : 10 }), ] db = FAISS.from_documents(documents, embedding) print (db.similarity_search_with_relevance_scores("我养了一只猫,叫笨笨" , score_threshold=0.4 ))
5.2 as_retriever() 检索器 在 LangChain 中,VectorStore 可以通过 as_retriever() 方法转换成检索器,在 as_retriever() 中可以传递一下参数:
1. search_type:搜索类型,支持 similarity(基础相似性搜索)、similarity_score_threshold(携带相似性得分+阈值判断的相似性搜索)、mmr(最大边际相关性搜索)。 2. search_kwargs:其他键值对搜索参数,类型为字典,例如:k、filter、score_threshold、fetch_k、lambda_mult 等,当搜索类型配置为 similarity_score_threshold 后,必须添加 score_threshold 配置选项,否则会报错,参数的具体信息要看 search_type 类型对应的函数配合使用。
并且由于检索器是 Runnable 可运行组件,所以可以使用 Runnable 组件的所有功能(组件替换、参数配置、重试、回退、并行等)。
例如将向量数据库转换成 携带得分+阈值判断的相似性搜索,并设置得分阈值为0.5,数据条数为10条,代码示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import dotenv import weaviate from langchain_community.document_loaders import UnstructuredMarkdownLoader from langchain_openai import OpenAIEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() loader = UnstructuredMarkdownLoader("./项目API文档.md" ) text_splitter = RecursiveCharacterTextSplitter( separators=["\n\n" , "\n" , "。|!|?" , "\.\s|\!\s|\?\s" , ";|;\s" , ",|,\s" , " " , "" , ], is_separator_regex=True , chunk_size=500 , chunk_overlap=50 , add_start_index=True , ) documents = loader.load() chunks = text_splitter.split_documents(documents) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://eftofnujtxqcsa0sn272jw.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("21pzYy0orl2dxH9xCoZG1O2b0euDeKJNEbB0" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) db.add_documents(chunks) retriever = db.as_retriever( search_type="similarity_score_threshold" , search_kwargs={"k" : 10 , "score_threshold" : 0.5 }, ) documents = retriever.invoke("关于配置接口的信息有哪些" ) print (list (document.page_content[:50 ] for document in documents)) print (len (documents))
5.3 MMR 最大边际相关性 最大边际相关性(MMR,max_marginal_relevance_search)的基本思想是同时考量查询与文档的 相关度,以及文档之间的 相似度。相关度 确保返回结果对查询高度相关,相似度 则鼓励不同语义的文档被包含进结果集。具体来说,它计算每个候选文档与查询的 相关度,并减去与已经入选结果集的文档的最大 相似度,这样更不相似的文档会有更高分。
而在 LangChain 中MMR 的实现过程和 FAISS 的 带过滤器的相似性搜索 非常接近,同样也是先执行相似性搜索,并得到一个远大于 k 的结果列表,例如 fetch_k 条数据,然后对搜索得到的 fetch_k 条数据计算文档之间的相似度,通过加权得分找到最终的 k 条数据。
简单来说,MMR 就是在一大堆最相似的文档中查找最不相似的,从而保证 结果多样化。
执行一个 MMR 最大边际相似性搜索需要的参数为:搜索语句、k条搜索结果数据、fetch_k条中间数据、多样性系数(0代表最大多样性,1代表最小多样性),在 LangChain 中也是基于这个思想进行封装,max_marginal_relevance_search() 函数的参数如下:
1. query:搜索语句,类型为字符串,必填参数。 2. k:搜索的结果条数,类型为整型,默认为 4。 3. fetch_k:要传递给 MMR 算法的的文档数,默认为 20。 4. lambda_mult:函数系数,数值范围从0-1,底层计算得分 = lambda_mult *相关性 - (1 - lambda_mult)*相似性
,所以 0 代表最大多样性、1 代表最小多样性。 5. kwargs:其他传递给搜索方法的参数,例如 filter 等,这个参数使用和相似性搜索类似,具体取决于使用的向量数据库。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import dotenv import weaviate from langchain_community.document_loaders import UnstructuredMarkdownLoader from langchain_openai import OpenAIEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() loader = UnstructuredMarkdownLoader("./项目API文档.md" ) text_splitter = RecursiveCharacterTextSplitter( separators=["\n\n" , "\n" , "。|!|?" , "\.\s|\!\s|\?\s" , ";|;\s" , ",|,\s" , " " , "" , ], is_separator_regex=True , chunk_size=500 , chunk_overlap=50 , add_start_index=True , ) documents = loader.load() chunks = text_splitter.split_documents(documents) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://eftofnujtxqcsa0sn272jw.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("21pzYy0orl2dxH9xCoZG1O2b0euDeKJNEbB0" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) search_documents = db.max_marginal_relevance_search("关于应用配置的接口有哪些?" ) for document in search_documents: print (document.page_content[:100 ]) print ("===========" )
5.4 检索器组件 在 LangChain 中,传递一段 query 并返回与这段文本相关联文档的组件被称为 检索器,并且 LangChain 为所有检索器设计了一个基类——BaseRetriever,该类继承了 RunnableSerializable,所以该类是一个 Runnable 可运行组件,支持使用 Runnable 组件的所有配置,在 BaseRetriever 下封装了一些通用的方法,类图如下
其中 get_relevance_documents() 方法将在 0.3.0 版本开始被遗弃(老版本非 Runnable 写法),使用检索器的技巧也非常简单,按照特定的规则创建好检索器后(通过 as_retriever() 或者 构造函数),调用 invoke() 方法即可。
并且针对所有 向量数据库,LangChain 都配置了 as_retriever() 方法,便于快捷将向量数据库转换成检索器,不同的检索器传递的参数会有所差异,需要查看源码或者查看文档搭配使用,例如下方是一个向量数据库检索器的使用示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import dotenv import weaviate from langchain_core.runnables import ConfigurableField from langchain_openai import OpenAIEmbeddings from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://eftofnujtxqcsa0sn272jw.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("21pzYy0orl2dxH9xCoZG1O2b0euDeKJNEbB0" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = db.as_retriever( search_type="similarity_score_threshold" , search_kwargs={"k" : 10 , "score_threshold" : 0.5 }, ).configurable_fields( search_type=ConfigurableField(id ="db_search_type" ), search_kwargs=ConfigurableField(id ="db_search_kwargs" ), ) mmr_documents = retriever.with_config( configurable={ "db_search_type" : "mmr" , "db_search_kwargs" : { "k" : 4 , } } ).invoke("关于应用配置的接口有哪些?" ) print ("相似性搜索: " , mmr_documents) print ("内容长度:" , len (mmr_documents)) print (mmr_documents[0 ].page_content[:20 ]) print (mmr_documents[1 ].page_content[:20 ])
5.5 自定义检索器 在 LangChain 中实现自定义检索器的技巧其实非常简单,只需要继承 BaseRetriever 类,然后实现 _get_relevant_documents()
方法即可,从 query 到 list[document]
的逻辑全部都在这个函数内部实现,异步的方法也可以不需要实现,底层会委托同步方法来执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 from typing import List from langchain_core.callbacks import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_core.retrievers import BaseRetriever class CustomRetriever (BaseRetriever ): """自定义检索器""" documents: list [Document] k: int def _get_relevant_documents (self, query: str , *, run_manager: CallbackManagerForRetrieverRun ) -> List [Document]: """根据传入的query,获取相关联的文档列表""" matching_documents = [] for document in self.documents: if len (matching_documents) > self.k: return matching_documents if query.lower() in document.page_content.lower(): matching_documents.append(document) return matching_documents documents = [ Document(page_content="笨笨是一只很喜欢睡觉的猫咪" , metadata={"page" : 1 }), Document(page_content="我喜欢在夜晚听音乐,这让我感到放松。" , metadata={"page" : 2 }), Document(page_content="猫咪在窗台上打盹,看起来非常可爱。" , metadata={"page" : 3 }), Document(page_content="学习新技能是每个人都应该追求的目标。" , metadata={"page" : 4 }), Document(page_content="我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , metadata={"page" : 5 }), Document(page_content="昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , metadata={"page" : 6 }), Document(page_content="我的手机突然关机了,让我有些焦虑。" , metadata={"page" : 7 }), Document(page_content="阅读是我每天都会做的事情,我觉得很充实。" , metadata={"page" : 8 }), Document(page_content="他们一起计划了一次周末的野餐,希望天气能好。" , metadata={"page" : 9 }), Document(page_content="我的狗喜欢追逐球,看起来非常开心。" , metadata={"page" : 10 }), ] retriever = CustomRetriever(documents=documents, k=3 ) retriever_documents = retriever.invoke("猫" ) print (retriever_documents) print (len (retriever_documents))
6 RAG 优化策略 6.1 RAG 开发6个阶段优化策略 在 RAG 应用开发中,无论架构多复杂,接入了多少组件,使用了多少优化策略与特性,所有优化的最终目标都是 提升LLM生成内容的准确性,而对于 Transformer架构类型 的大模型来说,要实现这个目标,一般只需要 3 个步骤:
1. 传递更准确的内容:传递和提问准确性更高的内容,会让 LLM 能识别到关联的内容, 生成的内容准确性更高。 2. 让重要的内容更靠前:GPT 模型的注意力机制会让传递 Prompt 中更靠前的内容权重更高,越靠后权重越低。 3. 尽可能不传递不相关内容:缩短每个块的大小,尽可能让每个块只包含关联的内容,缩小不相关内容的比例。
看起来很简单,但是目前针对这 3 个步骤 N 多研究员提出了不少方案,比较遗憾的是,目前也没有一种统一的方案,不同的场合仍然需要考虑不同的方案结合才能实现相对好一点的效果,并不是所有场合都适合配置很复杂的优化策略。
在 RAG 应用开发中,使用的优化策略越多,单次响应成本越高,性能越差,需要合理使用。映射到 RAG 中,其实就是 切割合适的文档块、更准确的搜索语句、正确地排序文档、剔除重复无关的检索内容,所以在 RAG应用开发 中,想进行优化,可以针对 query(提问查询)、TextSplitter(文本分割器)、VectorStore(向量数据库)、Retriever(检索器)、Prompt(基础prompt编写) 这几个组件。
在完整的 LLM 应用流程中拆解 RAG 开发阶段并进行优化看起来相对繁琐,可以考虑单独将 RAG 开发阶段的流程拎出来,并针对性对每个阶段进行优化与调整,按照不同的功能模块,共可以划分成 6 个阶段:查询转换、路由、查询构建、索引、检索 和 生成。
在 RAG 开发的 6 个阶段中,不同的阶段拥有不同的优化策略,需要针对不同的应用进行特定性的优化,目前市面上常见的优化方案有:问题转换、多路召回、混合检索、搜索重排、动态路由、图查询、问题重建、自检索 等数十种优化策略,每种策略所在的阶段并不一致,效果也有差异,并且相互影响。 并且 RAG 优化和 LangChain 并没有关系,无论使用任何框架、任何编程语言,进行 RAG 开发时,掌握优化的思路才是最重要的! 将对应的优化策略整理到 RAG 运行流程中,优化策略与开发阶段对应图如下:
6.2 多查询重写策略 6.2.1 Muliti-Query 多查询策略 多查询策略 也被称为 子查询,是一种用于生成子问题的技术,其核心思想是在问答过程中,为了更好地理解和回答主问题,系统会自动生成并提出与主问题相关的子问题,这些子问题通常具有更具体的细节,可以帮助大语言模型更深入地理解主问题,从而进行更加准确的检索并提供正确的答案。 多查询策略 会从多个角度重写用户问题,为每个重写的问题执行检索,然后将检索到的文档列表进行合并后去重,返回唯一文档,该策略的运行流程非常简单,如下 在 LangChain 中,针对 多查询策略 封装了一个检索器 MultiQueryRetriever,该检索器可以通过构造函数亦或者 from_llm 类方法进行实例化,参数如下: 1. retriever:基础检索器,必填参数。 2. llm:大语言模型,用于将原始问题转换成多个问题,必填参数。 3. prompt:转换原始问题为多个问题的提示模板,非必填,已有默认值。 4. parser_key:解析键,该参数在未来将被抛弃,非必填,已弃用,新版本中保留参数,但没有任何使用的地方。 5. inclued_original:是否保留原始问题,默认为 False,如果设置为 True,则除了检索新问题,还会检索原始问题。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import dotenv import weaviate from langchain.retrievers import MultiQueryRetriever from langchain_openai import ChatOpenAI from langchain_openai import OpenAIEmbeddings from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://eftofnujtxqcsa0sn272jw.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("21pzYy0orl2dxH9xCoZG1O2b0euDeKJNEbB0" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = db.as_retriever(search_type="mmr" ) multi_query_retriever = MultiQueryRetriever.from_llm( retriever=retriever, llm=ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ), include_original=True , ) docs = multi_query_retriever.invoke("关于LLMOps应用配置的文档有哪些" ) print (docs) print (len (docs))
输出:
1 2 3 [Document(metadata={'source' : './项目API文档.md' , 'start_index' : 0.0}, page_content='LLMOps 项目 API 文档\n\n应用 API 接口统一以 JSON 格式返回,并且包含 3 个字段:code、data 和 message,分别代表业务状态码、业务数据和接口附加信息。\n\n业务状态码共有 6 种,其中只有 success(成功) 代表业务操作成功,其他 5 种状态均代表失败,并且失败时会附加相关的信息:fail(通用失败)、not_found(未找到)、unauthorized(未授权)、forbidden(无权限)和validate_error(数据验证失败)。\n\n接口示例:\n\njson { "code": "success", "data": { "redirect_url": "https://github.com/login/oauth/authorize?client_id=f69102c6b97d90d69768&redirect_uri=http%3A%2F%2Flocalhost%3A5001%2Foauth%2Fauthorize%2Fgithub&scope=user%3Aemail" }, "message": "" }' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 3042.0}, page_content='1.2 [todo]更新应用草稿配置信息\n\n接口说明:更新应用的草稿配置信息,涵盖:模型配置、长记忆模式等,该接口会查找该应用原始的草稿配置并进行更新,如果没有原始草稿配置,则创建一个新配置作为草稿配置。\n\n接口信息:授权+POST:/apps/:app_id/config\n\n接口参数:\n\n请求参数:\n\napp_id -> str:需要修改配置的应用 id。\n\nmodel_config -> json:模型配置信息。\n\ndialog_round -> int:携带上下文轮数,类型为非负整型。\n\nmemory_mode -> string:记忆类型,涵盖长记忆 long_term_memory 和 none 代表无。\n\n请求示例:\n\njson { "model_config": { "dialog_round": 10 }, "memory_mode": "long_term_memory" }\n\n响应示例:\n\njson { "code": "success", "data": {}, "message": "更新AI应用配置成功" }\n\n1.3 [todo]获取应用调试长记忆' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 5818.0}, page_content='json { "code": "success", "data": { "list": [ { "id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "conversation_id": "2d7d3e3f-95c9-4d9d-ba9c-9daaf09cc8a8", "query": "能详细讲解下LLM是什么吗?", "answer": "LLM 即 Large Language Model,大语言模型,是一种基于深度学习的自然语言处理模型,具有很高的语言理解和生成能力,能够处理各式各样的自然语言任务,例如文本生成、问答、翻译、摘要等。它通过在大量的文本数据上进行训练,学习到语言的模式、结构和语义知识' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 675.0}, page_content='json { "code": "success", "data": { "list": [ { "app_count": 0, "created_at": 1713105994, "description": "这是专门用来存储慕课LLMOps课程信息的知识库", "document_count": 13, "icon": "https://imooc-llmops-1257184990.cos.ap-guangzhou.myqcloud.com/2024/04/07/96b5e270-c54a-4424-aece-ff8a2b7e4331.png", "id": "c0759ca8-2d35-4480-83a8-1f41f29d1401", "name": "慕课LLMOps课程知识库", "updated_at": 1713106758, "word_count": 8850 } ], "paginator": { "current_page": 1, "page_size": 20, "total_page": 1, "total_record": 2 } }' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 2324.0}, page_content='json { "code": "success", "data": { "id": "5e7834dc-bbca-4ee5-9591-8f297f5acded", "name": "慕课LLMOps聊天机器人", "icon": "https://imooc-llmops-1257184990.cos.ap-guangzhou.myqcloud.com/2024/04/23/e4422149-4cf7-41b3-ad55-ca8d2caa8f13.png", "description": "这是一个慕课LLMOps的Agent应用", "published_app_config_id": null, "drafted_app_config_id": null, "debug_conversation_id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "published_app_config": null, "drafted_app_config": { "id": "755dc464-67cd-42ef-9c56-b7528b44e7c8"' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 2042.0}, page_content='dialog_round -> int:携带上下文轮数,类型为非负整型。\n\nmemory_mode -> string:记忆类型,涵盖长记忆 long_term_memory 和 none 代表无。\n\nstatus -> string:应用配置的状态,drafted 代表草稿、published 代表已发布配置。\n\nupdated_at -> int:应用配置的更新时间。\n\ncreated_at -> int:应用配置的创建时间。\n\nupdated_at -> int:应用的更新时间。\n\ncreated_at -> int:应用的创建时间。\n\n响应示例:' )] 6
6.2.2 核心执行逻辑 从 LangSmith 平台记录的运行流程,可以很清晰看到这个检索器会先调用大语言模型生成 3 条与原始问题相关的 子问题,然后再逐个使用传递的检索器检索 3 个子问题,得到对应的文档列表,最后再将所有文档列表进行合并去重,得到最终的文档。 在 MultiQueryRetriever 这个检索器中,预设了一段 prompt,用于将原始问题生成 3 个关联子问题,并使用 \n 分割得到具体问题。 这段 prompt 如下:
1 2 3 4 5 6 DEFAULT_QUERY_PROMPT = PromptTemplate( input_variables=["question" ], template="""You are an AI language model assistant. Your task is to generate 3 different versions of the given user question to retrieve relevant documents from a vector database. By generating multiple perspectives on the user question, your goal is to help the user overcome some of the limitations of distance-based similarity search. Provide these alternative questions separated by newlines. Original question: {question}""" , )
在 LangChain 中,所有预设的 prompt 绝大部分场景都是使用 OpenAI 的大语言模型进行调试的,所以效果会比较好,对于其他的模型,例如国内的模型,一般来说还需要将对应的提示换成 中文语言,所以可以考虑使用 ChatGPT 翻译原有的 prompt,更新后
1 2 3 4 5 6 7 8 9 10 multi_query_retriever = MultiQueryRetriever.from_llm( retriever=retriever, llm=ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ), prompt=ChatPromptTemplate.from_template( "你是一个AI语言模型助手。你的任务是生成给定用户问题的3个不同版本,以从向量数据库中检索相关文档。" "通过提供用户问题的多个视角,你的目标是帮助用户克服基于距离的相似性搜索的一些限制。" "请用换行符分隔这些替代问题。" "原始问题:{question}" ) )
基于中文 prompt 生成的问题列表如下
LLMOps应用配置的文档有哪些资源可供参考?
我可以在哪里找到关于LLMOps应用配置的文档?
有哪些文档可以帮助我了解LLMOps应用配置的相关信息?
对于该检索器,不同的模型生成的 query 格式可能并不一样,某些模型生成的多条 query 可能并不是按照 \n 进行分割,这个时候查询的效果可能不如原始问题,所以在使用该检索器时,一定要多次测试 prompt 的效果,或者设置 inclued_original 为 True,确保生成内容不符合规范时,仍然可以使用原始问题进行检索。 另外,在 MultiQueryRetriever 的底层进行合并去重时,并没有任何特别的,仅仅只做了循环遍历并记录唯一的文档而已,核心代码
1 2 3 def _unique_documents (documents: Sequence [Document] ) -> List [Document]: return [doc for i, doc in enumerate (documents) if doc not in documents[:i]]
多查询策略是最基础+最简单的 RAG 优化,不涉及到复杂的逻辑与算法,会稍微影响单次对话的耗时。 并且由于需要转换 query 一般较小,以及生成 sub-queries 时对 LLM 的能力要求并不高,在实际的 LLM 应用开发中,通常使用参数较小的本地模型+针对性优化的 prompt 即可完成任务。 而且为了减少模型的幻觉以及胡说八道,一般都将 temperature 设置为 0,确保生成的文本更加有确定性。
6.3 多查询结果融合 6.3.1 多查询结果融合策略及RRF 在 多查询重写策略 中,虽然可以生成多条查询并执行多次检索器检索,但是在合并数据的时候,并没有考虑最终结果的文档数,极端情况下,原始的 k 设置为 4,可能会返回 16 个文档(3 条子查询的文档,1 条原始问题查询的文档),除此之外,多查询重写策略 并不会考虑对应文档的权重,只按默认顺序进行合并。 于是就诞生了 RAG融合 的概念,它的主要思想是在 Multi-Query 的基础上,对其检索结果进行重新排序(即 reranking)后输出 Top K 个结果,最后再将这 Top K 个结果喂给 LLM 并生成最终答案,运行流程如下: 在 RAG融合 中,对文档列表进行排序&去重合并的算法为 RRF(Reciprocal Rank Fusion),即倒排序排名算法,该算法是滑铁卢大学(CAN)和 Google 合作开发的,而且该算法的原理其实非常简单,公式如下: 在 RRF 算法中,D 表示相关文档的全集,k 是固定常数 60,r(d) 表示当前文档 d 在其子集中的位置,该算法会对全集 D 进行二重遍历,外层遍历文档全集 D,内层遍历文档子集,在做内层遍历的时候,我们会累计当前文档在其所在子集中的位置并取倒数作为其权重。 常数 k 被设定为 60,这个值是在进行初步调查时确定的,在论文中,通过四个试点实验,每个实验结合了 30 种搜索配置应用于不同的 TREC 集合的结果,发现 k=60 接近最优值,k 值是多少并不是关键,主要是通过 k 值,可以很容易发现一个事实: 虽然高排名的文档更加重要,但低排名文档的重要性并不会像使用指数函数那样消失。
6.3.2 多查询结果融合策略实现 在 LangChain 中并没有直接实现 RAG多查询结果融合策略 的检索器,所以可以考虑自定义实现,或者是继承 MultiQueryRetriever 并重写 retrieve_docments() 与 unique_union() 方法来实现对文档的 RRF 排名计算与合并。在方法内部将每次检索到的内容填充到一个两层列表中,然后传递给 RRF 函数即可。示例代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 from typing import List import dotenv import weaviate from langchain.load import dumps, loads from langchain.retrievers import MultiQueryRetriever from langchain_core.callbacks import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() class RAGFusionRetriever (MultiQueryRetriever ): """RAG多查询结果融合策略检索器""" k: int = 4 def retrieve_documents ( self, queries: List [str ], run_manager: CallbackManagerForRetrieverRun ) -> List [List ]: """重写检索文档函数,返回值变成一个嵌套的列表""" documents = [] for query in queries: docs = self.retriever.invoke( query, config={"callbacks" : run_manager.get_child()} ) documents.append(docs) return documents def unique_union (self, documents: List [List ] ) -> List [Document]: """使用RRF算法来去重合并对应的文档,参数为嵌套列表,返回值为文档列表""" fused_result = {} for docs in documents: for rank, doc in enumerate (docs): doc_str = dumps(doc) if doc_str not in fused_result: fused_result[doc_str] = 0 fused_result[doc_str] += 1 / (rank + 60 ) reranked_results = [ (loads(doc), score) for doc, score in sorted (fused_result.items(), key=lambda x: x[1 ], reverse=True ) ] return [item[0 ] for item in reranked_results[:self.k]] db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = db.as_retriever(search_type="mmr" ) rag_fusion_retriever = RAGFusionRetriever.from_llm( retriever=retriever, llm=ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ), ) docs = rag_fusion_retriever.invoke("关于LLMOps应用配置的文档有哪些" ) print (docs) print (len (docs))
输出:
1 2 3 [Document(metadata={'source' : './项目API文档.md' , 'start_index' : 0.0}, page_content='LLMOps 项目 API 文档\n\n应用 API 接口统一以 JSON 格式返回,并且包含 3 个字段:code、data 和 message,分别代表业务状态码、业务数据和接口附加信息。\n\n业务状态码共有 6 种,其中只有 success(成功) 代表业务操作成功,其他 5 种状态均代表失败,并且失败时会附加相关的信息:fail(通用失败)、not_found(未找到)、unauthorized(未授权)、forbidden(无权限)和validate_error(数据验证失败)。\n\n接口示例:\n\njson { "code": "success", "data": { "redirect_url": "https://github.com/login/oauth/authorize?client_id=f69102c6b97d90d69768&redirect_uri=http%3A%2F%2Flocalhost%3A5001%2Foauth%2Fauthorize%2Fgithub&scope=user%3Aemail" }, "message": "" }' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 5818.0}, page_content='json { "code": "success", "data": { "list": [ { "id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "conversation_id": "2d7d3e3f-95c9-4d9d-ba9c-9daaf09cc8a8", "query": "能详细讲解下LLM是什么吗?", "answer": "LLM 即 Large Language Model,大语言模型,是一种基于深度学习的自然语言处理模型,具有很高的语言理解和生成能力,能够处理各式各样的自然语言任务,例如文本生成、问答、翻译、摘要等。它通过在大量的文本数据上进行训练,学习到语言的模式、结构和语义知识' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 3042.0}, page_content='1.2 [todo]更新应用草稿配置信息\n\n接口说明:更新应用的草稿配置信息,涵盖:模型配置、长记忆模式等,该接口会查找该应用原始的草稿配置并进行更新,如果没有原始草稿配置,则创建一个新配置作为草稿配置。\n\n接口信息:授权+POST:/apps/:app_id/config\n\n接口参数:\n\n请求参数:\n\napp_id -> str:需要修改配置的应用 id。\n\nmodel_config -> json:模型配置信息。\n\ndialog_round -> int:携带上下文轮数,类型为非负整型。\n\nmemory_mode -> string:记忆类型,涵盖长记忆 long_term_memory 和 none 代表无。\n\n请求示例:\n\njson { "model_config": { "dialog_round": 10 }, "memory_mode": "long_term_memory" }\n\n响应示例:\n\njson { "code": "success", "data": {}, "message": "更新AI应用配置成功" }\n\n1.3 [todo]获取应用调试长记忆' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 675.0}, page_content='json { "code": "success", "data": { "list": [ { "app_count": 0, "created_at": 1713105994, "description": "这是专门用来存储慕课LLMOps课程信息的知识库", "document_count": 13, "icon": "https://imooc-llmops-1257184990.cos.ap-guangzhou.myqcloud.com/2024/04/07/96b5e270-c54a-4424-aece-ff8a2b7e4331.png", "id": "c0759ca8-2d35-4480-83a8-1f41f29d1401", "name": "慕课LLMOps课程知识库", "updated_at": 1713106758, "word_count": 8850 } ], "paginator": { "current_page": 1, "page_size": 20, "total_page": 1, "total_record": 2 } }' )] 4
6.4 问题分解策略提升复杂问题检索正确率 6.4.1 复杂问题检索的难点与分解 在 RAG 应用开发中,对于一些提问相对复杂的原始问题来说,无论是使用原始问题进行检索,亦或者生成多个相关联的问题进行检索,往往都很难在向量数据库中找到关联性高的文档,导致 RAG 效果偏差。 例如向量数据库中存储了一份 机器的说明文档,对于这类数据,如果提问 如何完成某个部件的维修 这类问题,一般都会涉及到多个步骤与顺序,执行相似性搜索会有很大概率没法找到有关联的文档。 造成这个问题的原因有几种:
1. 复杂问题由多个问题按顺序步骤组成,执行相似性搜索时,向量数据库存储的都是基础文档数据,往往相似度低,但是这些数据在现实世界又可能存在很大的关联(文本嵌入模型的限制,一条向量不可能无损记录段落信息)。 2. 问题复杂度高或者涉及到数学问题,导致 LLM 没法一次性完成答案的生成,一次性传递大量的相关性文档,极大压缩了大语言模型生成内容上下文长度的限制。
对于这类 RAG 应用场景,可以使用 问题分解策略,将一个复杂问题分解成多个子问题,和 多查询重写策略 不一样的是,这个策略生成的子问题使用的是 深度优先,即解决完第一个问题后,对应的资料传递给第二个问题,以此类推;亦或者是并行将每个问题的答案合并成最终问题。 所以 问题分解策略 可以划分成两种方案:迭代式回答 与 并行式回答,两种方案的运行流程如下 其中迭代式回答,会将上一次的 提问+答案,还有这一次的 检索上下文 一起传递给 LLM,让其生成答案,迭代到最后一次,就是最终答案。而 并行式回答 则会同时检索,并同时调用 LLM 生成答案,最后在将答案进行汇总,让 LLM 整理生成最终答案。
6.4.2 迭代式回答实现 在 LangChain 中,并没有针对 问题分解策略 实现对应的 检索器 或者 预设链,所以只能自行实现这个优化策略,由于问题分解策略同样也是先生成对应的子问题(深入优先),所以需要单独构建一条链先进行问题的分解,然后迭代执行相应的检索,得到上下文,并使用 LLM 回复该问题,将得到的 迭代答案+问题,传递给下一个子问题。代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 from operator import itemgetter import dotenv import weaviate from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() def format_qa_pair (question: str , answer: str ) -> str : """格式化传递的问题+答案为单个字符串""" return f"Question: {question} \nAnswer: {answer} \n\n" .strip() decomposition_prompt = ChatPromptTemplate.from_template( "你是一个乐于助人的AI助理,可以针对一个输入问题生成多个相关的子问题。\n" "目标是将输入问题分解成一组可以独立回答的子问题或者子任务。\n" "生成与一下问题相关的多个搜索查询:{question}\n" "并使用换行符进行分割,输出(3个子问题/子查询):" ) decomposition_chain = ( {"question" : RunnablePassthrough()} | decomposition_prompt | ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) | StrOutputParser() | (lambda x: x.strip().split("\n" )) ) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = db.as_retriever(search_type="mmr" ) question = "关于LLMOps应用配置的文档有哪些" sub_questions = decomposition_chain.invoke(question) prompt = ChatPromptTemplate.from_template("""这是你需要回答的问题: --- {question} --- 这是所有可用的背景问题和答案对: --- {qa_pairs} --- 这是与问题相关的额外背景信息: --- {context} ---""" ) chain = ( { "question" : itemgetter("question" ), "qa_pairs" : itemgetter("qa_pairs" ), "context" : itemgetter("question" ) | retriever, } | prompt | ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) | StrOutputParser() ) qa_pairs = "" for sub_question in sub_questions: answer = chain.invoke({"question" : sub_question, "qa_pairs" : qa_pairs}) qa_pair = format_qa_pair(sub_question, answer) qa_pairs += "\n---\n" + qa_pair print (f"问题: {sub_question} " ) print (f"答案: {answer} " )
6.5 Step-Back 回答回退策略 6.5.1 少量示例模板 在与 LLM 的对话中,提供少量的示例被称为 少量示例,这是一种简单但强大的指导生成的方式,在某些情况下可以显著提高模型性能(与之对应的是零样本),少量示例可以降低 Prompt 的复杂度,快速告知 LLM 生成内容的规范。
在 LangChain 中,针对少量示例也封装对应的提示模板——FewShotPromptTemplate,这个提示模板只需要传递 示例列表 与 示例模板 即可快速构建 少量示例提示模板,使用示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import dotenv from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate from langchain_openai import ChatOpenAI dotenv.load_dotenv() example_prompt = ChatPromptTemplate.from_messages([ ("human" , "{question}" ), ("ai" , "{answer}" ), ]) examples = [ {"question" : "帮我计算下2+2等于多少?" , "answer" : "4" }, {"question" : "帮我计算下2+3等于多少?" , "answer" : "5" }, {"question" : "帮我计算下20*15等于多少?" , "answer" : "300" }, ] few_shot_prompt = FewShotChatMessagePromptTemplate( example_prompt=example_prompt, examples=examples, ) print ("少量示例模板:" , few_shot_prompt.format ()) prompt = ChatPromptTemplate.from_messages([ ("system" , "你是一个可以计算复杂数学问题的聊天机器人" ), few_shot_prompt, ("human" , "{question}" ), ]) llm = ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) chain = prompt | llm | StrOutputParser() print (chain.invoke("帮我计算下14*15等于多少" ))
输出:
1 2 3 4 5 6 7 少量示例模板: Human: 帮我计算下2+2等于多少? AI: 4 Human: 帮我计算下2+3等于多少? AI: 5 Human: 帮我计算下20*15等于多少? AI: 300 210
少量示例提示模板 在底层会根据传递的 示例模板 与 示例 格式化对应的 消息列表 或者 字符串,从而将对应的示例参考字符串信息添加到完整的提示模板中,简化了 Prompt 编写的繁琐程度。 对于聊天模型可以使用 FewShotChatMessagePromptTemplate,而文本补全基座模型可以使用 FewShotPromptTemplate。
6.5.2 Step-Back 回答回退策略 对于一些复杂的问题,除了使用 问题分解 来得到子问题亦或者依赖问题,还可以为复杂问题生成一个前置问题,通过前置问题来执行相应的检索,这就是 Setp-Back 回答回退策略(后退提示),这是一种用于增强语言模型的推理和问题解决能力的技巧,它鼓励 LLM 从一个给定的问题或问题后退一步,提出一个更抽象、更高级的问题,涵盖原始查询的本质。 后退提示背后的概念是,许多复杂的问题或任务包含很多复杂的细节和约束,这使 LLM 难以直接检索和应用相关信息。通过引入一个后退问题,这个问题通常更容易回答,并且围绕一个更广泛的概念或原则,让 LLM 可以更有效地构建它们的推理。 Step-Back 回答回退策略的运行流程也非常简单,构建一个 少量示例提示模板,让 LLM 根据传递的问题生成一个后退问题,使用 后退问题 执行相应的检索,利用检索到的文档+原始问题执行 RAG 索引增强生成,运行流程如下: 在 LangChain 中并没有封装好的 回答回退策略检索器,所以可以执行相应的封装,实现一个自定义检索器,实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 from typing import List import dotenv import weaviate from langchain_core.callbacks import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_core.language_models import BaseLanguageModel from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate from langchain_core.retrievers import BaseRetriever from langchain_core.runnables import RunnablePassthrough from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() class StepBackRetriever (BaseRetriever ): """回答回退检索器""" retriever: BaseRetriever llm: BaseLanguageModel def _get_relevant_documents ( self, query: str , *, run_manager: CallbackManagerForRetrieverRun ) -> List [Document]: """根据传递的query执行问题回退并检索""" examples = [ {"input" : "慕课网上有关于AI应用开发的课程吗?" , "output" : "慕课网上有哪些课程?" }, {"input" : "慕小课出生在哪个国家?" , "output" : "慕小课的人生经历是什么样的?" }, {"input" : "司机可以开快车吗?" , "output" : "司机可以做什么?" }, ] example_prompt = ChatPromptTemplate.from_messages([ ("human" , "{input}" ), ("ai" , "{output}" ), ]) few_shot_prompt = FewShotChatMessagePromptTemplate( examples=examples, example_prompt=example_prompt, ) prompt = ChatPromptTemplate.from_messages([ ("system" , "你是一个世界知识的专家。你的任务是回退问题,将问题改述为更一般或者前置问题,这样更容易回答,请参考示例来实现。" ), few_shot_prompt, ("human" , "{question}" ), ]) chain = ( {"question" : RunnablePassthrough()} | prompt | self.llm | StrOutputParser() | self.retriever ) return chain.invoke(query) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = db.as_retriever(search_type="mmr" ) step_back_retriever = StepBackRetriever( retriever=retriever, llm=ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ), ) documents = step_back_retriever.invoke("人工智能会让世界发生翻天覆地的变化吗?" ) print (documents) print (len (documents))
输出:
1 2 3 [Document(metadata={'source' : './项目API文档.md' , 'start_index' : 5818.0}, page_content='json { "code": "success", "data": { "list": [ { "id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "conversation_id": "2d7d3e3f-95c9-4d9d-ba9c-9daaf09cc8a8", "query": "能详细讲解下LLM是什么吗?", "answer": "LLM 即 Large Language Model,大语言模型,是一种基于深度学习的自然语言处理模型,具有很高的语言理解和生成能力,能够处理各式各样的自然语言任务,例如文本生成、问答、翻译、摘要等。它通过在大量的文本数据上进行训练,学习到语言的模式、结构和语义知识' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 6359.0}, page_content='1.7 [todo]删除特定的调试消息\n\n接口说明:用于删除 AI 应用调试对话过程中指定的消息,该删除会在后端执行软删除操作,并且只有当会话 id 和消息 id 都匹配上时,才会删除对应的调试消息。\n\n接口信息:授权+POST:/apps/:app_id/messages/:message_id/delete\n\n接口参数:\n\n请求参数:\n\napp_id -> uuid:路由参数,需要删除消息归属的应用 id,格式为 uuid。\n\nmessage_id -> uuid:路由参数,需要删除的消息 id,格式为 uuid。\n\n请求示例:\n\njson { "app_id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "message_id": "2d7d3e3f-95c9-4d9d-ba9c-9daaf09cc8a8" }\n\n响应示例:\n\njson { "code": "success", "data": {}, "message": "删除调试信息成功" }' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 490.0}, page_content='带有分页数据的接口会在 data 内固定传递 list 和 paginator 字段,其中 list 代表分页后的列表数据,paginator 代表分页的数据。\n\npaginator 内存在 4 个字段:current_page(当前页数) 、page_size(每页数据条数)、total_page(总页数)、total_record(总记录条数),示例数据如下:' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 2042.0}, page_content='dialog_round -> int:携带上下文轮数,类型为非负整型。\n\nmemory_mode -> string:记忆类型,涵盖长记忆 long_term_memory 和 none 代表无。\n\nstatus -> string:应用配置的状态,drafted 代表草稿、published 代表已发布配置。\n\nupdated_at -> int:应用配置的更新时间。\n\ncreated_at -> int:应用配置的创建时间。\n\nupdated_at -> int:应用的更新时间。\n\ncreated_at -> int:应用的创建时间。\n\n响应示例:' )] 4
对比 问题分解策略,回答回退策略 仅仅多调用一次 LLM,所以相应速度更快,性能更高,并且复杂度更低,对于一些参数量较小的模型,也可以实现不错的效果,对于 问题分解策略-迭代式回答,在一些极端的情况下,模型输出了有偏差的内容,每次都在有偏差的 问题+答案 生成新内容,很有可能会导致最后的输出完全偏离开始的预设。 就像早些年很火的 谷歌翻译将同一句话翻译20次,输出的内容就完全偏离了原来的预设
6.6 混合策略实现 doc-doc 对称检索 6.6.1 HyDE 混合策略 在前面的课时中,学习的优化策略都是将对应的 查询 生成 新查询,通过 新查询 来执行相应的检索,但是在数据库中存储的数据一般都是 文档 层面上的,数据会远远比 查询 要大很多,所以 query 和 doc 之间是不对称检索,能找到的相似性文档相对来说也比较少。 例如:今天回家的路上看到了美丽的风景,非常开心!想学习 python 该怎么办?这个请求中,前面的风景、开心等词语均为无关信息。会对真实的请求学习 python 产生干扰。如果直接搜索用户的请求,可能会产生不正确或无法回答的 LLM 响应。因此,有必要使得用户查询的语义空间与文档的语义空间保持一致。 特别是 query 和对应的相关内容(答案)可能只存在弱相关性,导致难以找到最相关的文档内容。
在这篇论文《Precise Zero-Shot Dense Retrieval without Relevance Labels》中提出了一个 HyDE混合策略 的概念,首先利用 LLM 将问题转换为回答问题的假设性文档/假回答,然后使用嵌入的 假设性文档 去检索真实文档,前提是因为 doc-doc 这个模式执行相似性搜索可以尝试更多的匹配项。 假回答和真回答虽然可能存在事实错误,但是会比较像,因此能更容易找到相关内容。 简单来说,就是先根据 query 生成一个 doc,然后根据 doc 生成对应的 embedding,再执行相应的检索,运行流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 from typing import List import dotenv import weaviate from langchain_core.callbacks import CallbackManagerForRetrieverRun from langchain_core.documents import Document from langchain_core.language_models import BaseLanguageModel from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.retrievers import BaseRetriever from langchain_core.runnables import RunnablePassthrough from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() class HyDERetriever (BaseRetriever ): """HyDE混合策略检索器""" retriever: BaseRetriever llm: BaseLanguageModel def _get_relevant_documents ( self, query: str , *, run_manager: CallbackManagerForRetrieverRun ) -> List [Document]: """传递检索query实现HyDE混合策略检索""" prompt = ChatPromptTemplate.from_template( "请写一篇科学论文来回答这个问题。\n" "问题: {question}\n" "文章: " ) chain = ( {"question" : RunnablePassthrough()} | prompt | self.llm | StrOutputParser() | self.retriever ) return chain.invoke(query) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="DatasetDemo" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = db.as_retriever(search_type="mmr" ) hyde_retriever = HyDERetriever( retriever=retriever, llm=ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ), ) documents = hyde_retriever.invoke("关于LLMOps应用配置的文档有哪些?" ) print (documents) print (len (documents))
输出 :
1 2 3 [Document(metadata={'source' : './项目API文档.md' , 'start_index' : 0.0}, page_content='LLMOps 项目 API 文档\n\n应用 API 接口统一以 JSON 格式返回,并且包含 3 个字段:code、data 和 message,分别代表业务状态码、业务数据和接口附加信息。\n\n业务状态码共有 6 种,其中只有 success(成功) 代表业务操作成功,其他 5 种状态均代表失败,并且失败时会附加相关的信息:fail(通用失败)、not_found(未找到)、unauthorized(未授权)、forbidden(无权限)和validate_error(数据验证失败)。\n\n接口示例:\n\njson { "code": "success", "data": { "redirect_url": "https://github.com/login/oauth/authorize?client_id=f69102c6b97d90d69768&redirect_uri=http%3A%2F%2Flocalhost%3A5001%2Foauth%2Fauthorize%2Fgithub&scope=user%3Aemail" }, "message": "" }' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 1621.0}, page_content='id -> uuid:应用 id,类型为 uuid。\n\nname -> string:应用名称。\n\nicon -> string:应用图标。\n\ndescription -> string:应用描述。\n\npublished_app_config_id -> uuid:已发布应用配置 id,如果不存在则为 null。\n\ndrafted_app_config_id -> uuid:草稿应用配置 id,如果不存在则为 null。\n\ndebug_conversation_id -> uuid:调试会话记录 id,如果不存在则为 null。\n\npublished_app_config/drafted_app_config -> json:应用配置信息,涵盖草稿配置、已发布配置,如果没有则为 null,两个配置的变量信息一致。\n\nid -> uuid:应用配置 id。\n\nmodel_config -> json:模型配置,类型为 json。\n\ndialog_round -> int:携带上下文轮数,类型为非负整型。' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 5818.0}, page_content='json { "code": "success", "data": { "list": [ { "id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "conversation_id": "2d7d3e3f-95c9-4d9d-ba9c-9daaf09cc8a8", "query": "能详细讲解下LLM是什么吗?", "answer": "LLM 即 Large Language Model,大语言模型,是一种基于深度学习的自然语言处理模型,具有很高的语言理解和生成能力,能够处理各式各样的自然语言任务,例如文本生成、问答、翻译、摘要等。它通过在大量的文本数据上进行训练,学习到语言的模式、结构和语义知识' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 490.0}, page_content='带有分页数据的接口会在 data 内固定传递 list 和 paginator 字段,其中 list 代表分页后的列表数据,paginator 代表分页的数据。\n\npaginator 内存在 4 个字段:current_page(当前页数) 、page_size(每页数据条数)、total_page(总页数)、total_record(总记录条数),示例数据如下:' )] 4
6.6.2 局限性 对于 doc-doc 类型的检索,虽然在语义空间上保持了一致,但是在 query->doc 的过程中,受限于各种因素,仍然可能产生错误信息。 第一个场景是在 query 没有足够上下文时,HyDE 容易误解对应的词,从而产生错误的信息。 例如提问 Bel是什么?,在没有执行 HyDE 混合策略而是直接查询得到答案如下 Bel 是由 Paul Graham 在四年的时间里(2015年3月26日至2019年10月12日),用 Arc 语言编写的一种编程语言。它基于 John McCarthy 最初的 Lisp,但添加了额外的功能。它是一个以代码形式表达的规范,旨在成为计算的形式化模型,是图灵机的一种替代方案。 但是执行 HyDE 混合策略生成假设性 doc 如下 Bel 是 Paul Graham 的化名,他是这段信息背后的作者,当时需要种子资金以维持生活,并且参与了一项交易,后来成为 Y Combinator 模式的典范。 在这个例子中,HyDE 在没有文档上下文的情况下错误地解释了 Bel,这会导致完全检索不到相关的文档信息。 第二个场景是一些 开放式的查询,HyDE 可能会产生偏见,例如提问 作者会如何评价艺术与工程的区别?,无需转换 query 即可得到正确的响应回答 作者可能会说,艺术和工程是两种需要不同技能和方法的学科。艺术更注重表达和创造力,而工程更专注于解决问题和技术知识。作者还暗示,艺术学校并不总是提供与工程学校同等水平的严谨性,绘画学生常常被鼓励发展个性化风格,而不是学习绘画的基础知识。此外,作者可能会指出,工程学相比艺术能提供更多的财务稳定性,正如作者自己创业初期需要种子资金来生活的经历所证明的那样。 在使用 HyDE混合策略 转换 query 时,生成的 doc 如下 作者可能会说,艺术比工程更持久和独立。他们提到,今天编写的软件几十年后就会过时,系统工作也不会长久。相比之下,他们指出绘画可以保留数百年,而且作为艺术家是可以谋生的。他们还提到,作为艺术家,你可以真正独立,不需要老板或研究资金。此外,他们指出艺术可以成为收入来源,适合那些无法接触传统就业形式的人,比如例子中的模特,能够通过为当地古董商建模和制作赝品而谋生。 总的来说,HyDE 是一个无监督的方法,可以帮助 RAG 提高效果。但是因为它不完全依赖于 embedding 而是强调问题的答案和查找内容的相似性,也存在一定的局限性。比如如果 LLM 无法理解用户问题,自然不会产生最佳结果,也可能导致错误增加。因此,需要根据场景决定是否选用此方法
6.7 集成多种检索器算法的混合检索 6.7.1 集成检索器的优势与使用 在 LangChain 中,封装了一个集成检索器 EnsembleRetriever,这个检索器接受一个检索器列表作为输入,并根据 RRF 算法对每个检索器的 get_relevant_documents() 方法产生的文档列表进行集成和重新排序。 集成检索器可以利用不同算法的优势,从而获得比任何单一算法更好的性能。集成检索器一个常见的案例是将 稀疏检索器(如BM25) 和 密集检索器(如嵌入相似度) 结合起来,因为它们的优势是互补的,这种检索方式也被称为混合检索(稀疏检索器擅长基于关键词检索,密集检索器擅长基于语义相似性检索)。
混合检索器 被广泛应用于各类 AI 应用开发平台,例如:Dify、Coze、智谱 等平台,
例如使用 BM25关键词搜索 和 FAISS相似性搜索 进行结合,实现混合搜索,首先安装 rank_bm25 包,命令如下
1 pip install -U rank_bm25
代码示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import dotenv from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever from langchain_community.vectorstores import FAISS from langchain_core.documents import Document from langchain_openai import OpenAIEmbeddings dotenv.load_dotenv() documents = [ Document(page_content="笨笨是一只很喜欢睡觉的猫咪" , metadata={"page" : 1 }), Document(page_content="我喜欢在夜晚听音乐,这让我感到放松。" , metadata={"page" : 2 }), Document(page_content="猫咪在窗台上打盹,看起来非常可爱。" , metadata={"page" : 3 }), Document(page_content="学习新技能是每个人都应该追求的目标。" , metadata={"page" : 4 }), Document(page_content="我最喜欢的食物是意大利面,尤其是番茄酱的那种。" , metadata={"page" : 5 }), Document(page_content="昨晚我做了一个奇怪的梦,梦见自己在太空飞行。" , metadata={"page" : 6 }), Document(page_content="我的手机突然关机了,让我有些焦虑。" , metadata={"page" : 7 }), Document(page_content="阅读是我每天都会做的事情,我觉得很充实。" , metadata={"page" : 8 }), Document(page_content="他们一起计划了一次周末的野餐,希望天气能好。" , metadata={"page" : 9 }), Document(page_content="我的狗喜欢追逐球,看起来非常开心。" , metadata={"page" : 10 }), ] bm25_retriever = BM25Retriever.from_documents(documents) bm25_retriever.k = 4 faiss_db = FAISS.from_documents(documents, embedding=OpenAIEmbeddings(model="text-embedding-3-small" )) faiss_retriever = faiss_db.as_retriever(search_kwargs={"k" : 4 }) ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, faiss_retriever], weights=[0.5 , 0.5 ], ) docs = ensemble_retriever.invoke("除了猫,你养了什么宠物呢?" ) print (docs) print (len (docs))
输出:
1 2 3 [Document(metadata={'page' : 10}, page_content='我的狗喜欢追逐球,看起来非常开心。' ), Document(metadata={'page' : 3}, page_content='猫咪在窗台上打盹,看起来非常可爱。' ), Document(metadata={'page' : 9}, page_content='他们一起计划了一次周末的野餐,希望天气能好。' ), Document(metadata={'page' : 1}, page_content='笨笨是一只很喜欢睡觉的猫咪' ), Document(metadata={'page' : 8}, page_content='阅读是我每天都会做的事情,我觉得很充实。' ), Document(metadata={'page' : 7}, page_content='我的手机突然关机了,让我有些焦虑。' ), Document(metadata={'page' : 5}, page_content='我最喜欢的食物是意大利面,尤其是番茄酱的那种。' )] 7
在实际的开发中,除了硬编码不同检索器与对应的权重,还可以在运行时配置检索器,在检索时动态控制某个检索器输出内容的数量、权重等,例如
1 2 3 4 5 6 7 8 9 10 faiss_retriever = faiss_db.as_retriever(search_kwargs={"k" : 4 }).configurable_fields( search_kwargs=ConfigurableField( id ="search_kwargs_faiss" , name="搜索参数" , description="要使用的搜索参数" , ) ) config = {"configurable" : {"search_kwargs_faiss" : {"k" : 1 }}} docs = ensemble_retriever.invoke("苹果" , config=config)
6.7.2 查询转换阶段优化策略总结 在 RAG 的 查询转换 阶段,目前市面上主流的优化策略其实我们都已经讲解完了,涵盖了:多查询重写、RAG 多查询结果融合、问题分解策略、回答回退策略、HyDE 混合策略、集成检索器策略 等,不同的优化策略有不同的优缺点: 1. 多查询重写:实现简单,使用参数较小的模型也可以完成对查询的转换(不涉及回答),因为多查询可以并行检索,所以性能较高,但是在合并的时候,没有考虑到不同文档的权重,仅仅按照顺序进行合并,会让某些高权重的文档在使用时可能被剔除 2. 问题分解策略:通过将复杂问题/数学问题分解成多个子问题,从而实现对每个子问题的 检索-生成 流程,最后再将所有子问题的 答案 进行合 并,在转换环节涉及到对子问题的回答,所以对于中间 LLM 的要求比较高,性能相对也比较差,在上下文长度不足的情况下,拆分问题并迭代回答,可能会让最终答案偏离原始的提问。 3. 回答回退策略:通过提出一个前置问题/通用问题用于优化原始的复杂问题,从而获得更大的搜索范围,提升检索到相关性高的文档的概率,因为中间 LLM 没涉及到回答,所以可以使用参数量较小的模型来实现,性能相对较高
6.8 检索器的逻辑路由缩减检索范围 – 逻辑路由阶段 在 RAG 应用开发中,想根据不同的问题检索不同的 检索器/向量数据库,其实只需要设定要对应的 Prompt,然后让 LLM 根据传递的问题返回需要选择的 检索器/向量数据库 的名称,然后根据得到的名称选择不同的 检索器 即可。
但是对于 LLM 来说,如果使用普通的 prompt 来约束输出内容的格式与规范,因为 LLM 的特性,很难保证输出格式符合特定的需求,所以可以考虑使用 函数回调 来实现,即设定一个 虚假的函数,告诉 LLM,这个函数有对应的参数,让 LLM 强制调用这个函数,这个时候 LLM 就会输出函数的调用参数,从而保证输出的统一性。
使用 函数回调 实现的检索器逻辑路由运行流程图如下 假设目前有 3 个向量数据库/集合,分别代表 python_docs、js_docs、golang_docs,需要根据用户传递的问题判断与哪个向量数据库最接近,使用最接近的向量数据库进行检索,代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 from typing import Literal import dotenv from langchain_core.prompts import ChatPromptTemplate from langchain_core.pydantic_v1 import BaseModel, Field from langchain_core.runnables import RunnablePassthrough from langchain_openai import ChatOpenAI dotenv.load_dotenv() class RouteQuery (BaseModel ): """将用户查询映射到最相关的数据源""" datasource: Literal ["python_docs" , "js_docs" , "golang_docs" ] = Field( description="根据给定用户问题,选择哪个数据源最相关以回答他们的问题" ) def choose_route (result: RouteQuery ) -> str : """根据传递的路由结果选择不同的检索器""" if "python_docs" in result.datasource: return "chain in python_docs" elif "js_docs" in result.datasource: return "chain in js_docs" else : return "golang_docs" llm = ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) structured_llm = llm.with_structured_output(RouteQuery) prompt = ChatPromptTemplate.from_messages([ ("system" , "你是一个擅长将用户问题路由到适当的数据源的专家。\n请根据问题涉及的编程语言,将其路由到相关数据源" ), ("human" , "{question}" ) ]) router = {"question" : RunnablePassthrough()} | prompt | structured_llm | choose_route question = """为什么下面的代码不工作了,请帮我检查下: from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages(["human", "speak in {language}"]) prompt.invoke("中文")""" print (router.invoke(question))
输出:
1 2 datasource='python_docs' chain for python_docs
6.9 语义路由选择不同的 Prompt 模板 – 逻辑路由阶段 在 RAG 应用开发中,针对不同场景的问题使用 特定化的prompt模板 效果一般都会比通用模板会好一些,例如在 教培场景,制作一个可以教学 物理+数学 的授课机器人,如果使用通用的 prompt模板,会导致 prompt 编写变得非常复杂;反过来如果 prompt 写的简单,有可能没法起到很好的回复效果。代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import dotenv from langchain.utils.math import cosine_similarity from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough, RunnableLambda from langchain_openai import OpenAIEmbeddings, ChatOpenAI dotenv.load_dotenv() physics_template = """你是一位非常聪明的物理教程。 你擅长以简洁易懂的方式回答物理问题。 当你不知道问题的答案时,你会坦率承认自己不知道。 这是一个问题: {query}""" math_template = """你是一位非常优秀的数学家。你擅长回答数学问题。 你之所以如此优秀,是因为你能将复杂的问题分解成多个小步骤。 并且回答这些小步骤,然后将它们整合在一起回来更广泛的问题。 这是一个问题: {query}""" embeddings = OpenAIEmbeddings(model="text-embedding-3-small" ) prompt_templates = [physics_template, math_template] prompt_embeddings = embeddings.embed_documents(prompt_templates) def prompt_router (input ) -> ChatPromptTemplate: """根据传递的query计算返回不同的提示模板""" query_embedding = embeddings.embed_query(input ["query" ]) similarity = cosine_similarity([query_embedding], prompt_embeddings)[0 ] most_similar = prompt_templates[similarity.argmax()] print ("使用数学模板" if most_similar == math_template else "使用物理模板" ) return ChatPromptTemplate.from_template(most_similar) chain = ( {"query" : RunnablePassthrough()} | RunnableLambda(prompt_router) | ChatOpenAI(model="gpt-3.5-turbo-16k" ) | StrOutputParser() ) print (chain.invoke("黑洞是什么?" )) print ("======================" ) print (chain.invoke("能介绍下余弦计算公式么?" ))
6.10 自查询检索器实现动态元数据过滤 – 查询构建阶段 6.10.1 自查询检索器实现 在 RAG 应用开发中,检索外部数据时,前面的优化案例中,无论是生成的 子查询、问题分解、生成假设性文档,最后在执行检索的时候使用的都是固定的筛选条件(没有附加过滤的相似性搜索)。
但是在某些情况下,用户发起的原始提问其实隐式携带了 筛选条件,例如提问:
1 请帮我整理下关于2023年全年关于AI的新闻汇总。
在这段 原始提问 中,如果执行相应的向量数据库相似性搜索,其实是附加了 筛选条件 的,即 year=2023,但是在普通的相似性搜索中,是不会考虑 2023 年这个条件的(因为没有添加元数据过滤器,2022年和2023年数据在高维空间其实很接近),存在很大概率会将其他年份的数据也检索出来。 并不是所有的数据都支持 查询构建 的,需要看存储的 Document 是否存在元数据,对应的数据库类型是否支持筛选 将 查询构建 这个步骤单独拎出来,它的运行流程其实很简单,但是底层的操作非常麻烦,如下: 在 LangChain 中,针对一些高频使用的向量数据库封装了 自查询检索器 的相关支持——SelfQueryRetriever,无需自行构建转换语句与解析,使用该类进行二次包装即可。 SelfQueryRetriever 使用起来也非常简单,以 Pinecone 向量数据库为例,首先安装对应的依赖:
1 pip install --upgrade --quiet lark
定义好 带元数据的文档、支持过滤的元数据、包装的向量数据库、文档内容的描述 等信息,即可进行快速包装,示例代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 import dotenv from langchain.chains.query_constructor.schema import AttributeInfo from langchain.retrievers import SelfQueryRetriever from langchain_core.documents import Document from langchain_openai import ChatOpenAI from langchain_openai import OpenAIEmbeddings from langchain_pinecone import PineconeVectorStore dotenv.load_dotenv() documents = [ Document( page_content="肖申克的救赎" , metadata={"year" : 1994 , "rating" : 9.7 , "director" : "弗兰克·德拉邦特" }, ), Document( page_content="霸王别姬" , metadata={"year" : 1993 , "rating" : 9.6 , "director" : "陈凯歌" }, ), Document( page_content="阿甘正传" , metadata={"year" : 1994 , "rating" : 9.5 , "director" : "罗伯特·泽米吉斯" }, ), Document( page_content="泰坦尼克号" , metadat={"year" : 1997 , "rating" : 9.5 , "director" : "詹姆斯·卡梅隆" }, ), Document( page_content="千与千寻" , metadat={"year" : 2001 , "rating" : 9.4 , "director" : "宫崎骏" }, ), Document( page_content="星际穿越" , metadat={"year" : 2014 , "rating" : 9.4 , "director" : "克里斯托弗·诺兰" }, ), Document( page_content="忠犬八公的故事" , metadat={"year" : 2009 , "rating" : 9.4 , "director" : "莱塞·霍尔斯道姆" }, ), Document( page_content="三傻大闹宝莱坞" , metadat={"year" : 2009 , "rating" : 9.2 , "director" : "拉库马·希拉尼" }, ), Document( page_content="疯狂动物城" , metadat={"year" : 2016 , "rating" : 9.2 , "director" : "拜伦·霍华德" }, ), Document( page_content="无间道" , metadat={"year" : 2002 , "rating" : 9.3 , "director" : "刘伟强" }, ), ] db = PineconeVectorStore( index_name="llmops" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), namespace="dataset" , text_key="text" ) retriever = db.as_retriever() metadata_filed_info = [ AttributeInfo(name="year" , description="电影的年份" , type ="integer" ), AttributeInfo(name="rating" , description="电影的评分" , type ="float" ), AttributeInfo(name="director" , description="电影的导演" , type ="string" ), ] self_query_retriever = SelfQueryRetriever.from_llm( llm=ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ), vectorstore=db, document_contents="电影的名字" , metadata_field_info=metadata_filed_info, enable_limit=True , ) docs = self_query_retriever.invoke("查找下评分高于9.5分的电影" ) print (docs) print (len (docs)) print ("===================" ) base_docs = retriever.invoke("查找下评分高于9.5分的电影" ) print (base_docs) print (len (base_docs))
输出:
1 2 3 [Document(metadata={'director' : '陈凯歌' , 'rating' : 9.6 , 'year' : 1993.0 }, page_content='霸王别姬' ), Document(metadata={'director' : '弗兰克·德拉邦特' , 'rating' : 9.7 , 'year' : 1994.0 }, page_content='肖申克的救赎' )] 2
自查询检索器 对于面向特定领域的专用 Agent 效果相对较好 (对通用 Agent 来说效果较差 ),因为这些领域的文档一般相对来说比较规范,例如:财报、新闻、自媒体文章、教培 等行业,这些行业的数据都能剥离出通用支持过滤与筛选的 元数据/字段,使用自查询检索器能抽象出对应的检索字段信息。
6.10.2 自查询检索器执行逻辑 在 LangChain 中,涉及调用第三方服务或者调用本地自定义工具的,例如课程中学习的 自查询检索器、检索器逻辑路由 等,在底层都是通过一个预设好的 Prompt 生成符合相应规则的内容(字符串、JSON),然后通过 解析器 解析生成的内容,并将解析出来的结构化内容调用特定的接口、服务亦或者本地函数实现。 例如在 自查询检索器 底层,首先使用 FewShotPromptTemplate+函数回调/结构化输出 生成特定规则的 查询语句。原始问题如下:
生成的查询语句原文如下:
1 2 3 4 { "query" : "" , "filter" : "gt(\"rating\", 9.5)" }
接下来使用特定的转换器,将生成的查询语句转换成适配向量数据库的 过滤器,并在检索时传递该参数,从而完成自查询构建的全过程,不同的向量数据库对应的转换器差异也非常大。
6.11 MultiVector 实现多向量检索文档 – 索引阶段 6.11.1 多表征/向量索引 如果能从多个维度记录该文档块的信息,会大大增加该文档块被检索到的概率,多个维度记录信息 等同于为文档块生成 多个向量,支持的方法如下: 1. 把文档切割成更小的块:通过检索更小的块,但是查找其父类文档(ParentDocumentRetriever)。 2. 摘要:使用 LLM 为每个文档块生成一段摘要,将其和原文档一起嵌入或者代替,返回时返回原文档。 3. 假设性问题:使用 LLM 为每个文档块生成适合回答的假设性问题,将其和原文档一起嵌入或者代替,返回时返回原文档。 通过这种方式可以为一个文档块生成多条特征/向量,在检索时能提升关联文档被检索到的概率 ,多向量检索的运行流程其实也非常简单,以 摘要文档 检索 原文档 为例,运行流程图如下: 通过上面的运行流程,可以很容易知道在 原始文档 和 摘要文档 中都在元数据中设置了 唯一标识,从向量数据库中找到符合规则的数据后,通过查找其 元数据 的唯一标识,即可在 文档数据库 中匹配出原文档,完成整个多表征/向量的检索。
6.11.2 多向量索引示例 在 LangChain 中,为多向量索引的集成封装了 MultiVectorRetriever 类,实例化该类只需要传递 向量数据库、字节存储数据库(文档数据库)、id标识(关联标识) 即可快速完成整个运行流程的集成。 以 FAISS向量数据库 和 本地文件存储库 为例,构建一个 存储摘要->检索原文 的优化策略,代码示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 import uuid import dotenv from langchain.retrievers import MultiVectorRetriever from langchain.storage import LocalFileStore from langchain_community.document_loaders import UnstructuredFileLoader from langchain_community.vectorstores import FAISS from langchain_core.documents import Document from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_text_splitters import RecursiveCharacterTextSplitter dotenv.load_dotenv() loader = UnstructuredFileLoader("./电商产品数据.txt" ) text_splitter = RecursiveCharacterTextSplitter(chunk_size=500 , chunk_overlap=50 ) docs = loader.load_and_split(text_splitter) summary_chain = ( {"doc" : lambda x: x.page_content} | ChatPromptTemplate.from_template("请总结以下文档的内容:\n\n{doc}" ) | ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) | StrOutputParser() ) summaries = summary_chain.batch(docs, {"max_concurrency" : 5 }) doc_ids = [str (uuid.uuid4()) for _ in summaries] summary_docs = [ Document(page_content=summary, metadata={"doc_id" : doc_ids[idx]}) for idx, summary in enumerate (summaries) ] byte_store = LocalFileStore("./multy-vector" ) db = FAISS.from_documents( summary_docs, embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) retriever = MultiVectorRetriever( vectorstore=db, byte_store=byte_store, id_key="doc_id" , ) retriever.docstore.mset(list (zip (doc_ids, docs))) search_docs = retriever.invoke("推荐一些潮州特产?" ) print (search_docs) print (len (search_docs))
输出:
1 2 3 [Document(metadata={'source' : './电商产品数据.txt' }, page_content='产品名称: 潮汕鱼丸\n\n电商网址: shop.example.com/fishballs\n\n产品描述: 潮汕鱼丸采用新鲜鱼肉,加入少量淀粉和调味料,手工捶打成丸,Q弹爽滑,鱼香浓郁。\n\n产品特点:\n\n原材料: 新鲜鱼肉、淀粉、盐、胡椒粉\n\n制作工艺: 传统手工捶打\n\n口感: Q弹爽滑,鲜美可口\n\n净重: 500克/袋、1000克/袋\n\n保质期: 6个月(冷冻保存)\n\n发货方式: 顺丰冷链配送,确保新鲜\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:\n\n鱼丸火锅: 搭配各类蔬菜、菌类,煮至鱼丸浮起即可。\n\n鱼丸煮汤: 与蔬菜同煮,味道鲜美。\n\n价格:\n\n500克: 55元/袋\n\n1000克: 100元/袋\n\n6. 潮汕豆腐花\n\n产品名称: 潮汕豆腐花\n\n电商网址: shop.example.com/tofupudding\n\n产品描述: 潮汕豆腐花使用优质黄豆,传统工艺制作,质地细腻,入口即化,豆香浓郁。\n\n产品特点:\n\n原材料: 黄豆、水、石膏\n\n制作工艺: 传统手工点浆\n\n口感: 细腻嫩滑,豆香浓郁\n\n净重: 450克/盒\n\n保质期: 5天(冷藏保存)' ), Document(metadata={'source' : './电商产品数据.txt' }, page_content='产品特点:\n\n原材料: 猪后腿肉、香料、盐、糖\n\n制作工艺: 精细切割,手工卷制\n\n口感: 鲜嫩多汁,咸香可口\n\n净重: 400克/袋、800克/袋\n\n保质期: 3个月(冷冻保存)\n\n发货方式: 顺丰冷链配送,确保新鲜\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:\n\n猪肉卷煎烤: 切片后煎至金黄,外脆里嫩。\n\n猪肉卷炖煮: 切块后与蔬菜同炖,风味更佳。\n\n价格:\n\n400克: 58元/袋\n\n800克: 108元/袋\n\n3. 潮汕三宝(酱油、甜醋、虾酱)\n\n产品名称: 潮汕三宝\n\n电商网址: shop.example.com/chaoshanthree\n\n产品描述: 潮汕三宝包含酱油、甜醋和虾酱。酱油由大豆、麦子自然发酵而成,甜醋以糯米酿制,虾酱选用新鲜海虾发酵,是潮汕菜肴必备调味品。\n\n产品特点:\n\n酱油: 大豆、麦子自然发酵,500ml/瓶\n\n甜醋: 糯米酿制,500ml/瓶\n\n虾酱: 新鲜海虾发酵,200克/瓶\n\n保质期: 酱油和甜醋12个月,虾酱6个月\n\n发货方式: 顺丰配送,确保完好\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:' ), Document(metadata={'source' : './电商产品数据.txt' }, page_content='口感: 鲜嫩多汁,味道浓郁\n\n净重: 500克/袋、1000克/袋\n\n保质期: 3个月(冷冻保存)\n\n发货方式: 顺丰冷链配送,确保新鲜\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:\n\n红烧狮子头: 加热后直接食用,适合作为主菜。\n\n狮子头炖菜: 与蔬菜同炖,味道更佳。\n\n价格:\n\n500克: 60元/袋\n\n1000克: 110元/袋\n\n10. 潮汕香菇肉酱\n\n产品名称: 潮汕香菇肉酱\n\n电商网址: shop.example.com/mushroomsauce\n\n产品描述: 潮汕香菇肉酱采用香菇和猪肉为主要原料,加入特制酱料炒制而成,香气扑鼻,味道鲜美。\n\n产品特点:\n\n原材料: 香菇、猪肉、酱料\n\n制作工艺: 精细切割,炒制均匀\n\n口感: 鲜香可口,酱香浓郁\n\n净重: 200克/瓶、400克/瓶\n\n保质期: 6个月(常温保存)\n\n发货方式: 顺丰配送,确保完好\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:\n\n拌饭: 加入米饭中,提升口感。\n\n拌面: 加入面条中,风味独特。\n\n价格:\n\n200克: 35元/瓶\n\n400克: 65元/瓶' ), Document(metadata={'source' : './电商产品数据.txt' }, page_content='口感: 细腻嫩滑,豆香浓郁\n\n净重: 450克/盒\n\n保质期: 5天(冷藏保存)\n\n发货方式: 顺丰冷链配送,确保新鲜\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:\n\n甜食: 加糖水、红豆、芝麻食用。\n\n咸食: 加入虾米、葱花、酱油食用。\n\n价格: 25元/盒\n\n7. 潮汕鱼露\n\n产品名称: 潮汕鱼露\n\n电商网址: shop.example.com/fishsauce\n\n产品描述: 潮汕鱼露以新鲜小鱼为原料,经过发酵、过滤而成,味道鲜美,是潮汕菜肴必备调味品。\n\n产品特点:\n\n原材料: 小鱼、盐\n\n制作工艺: 自然发酵,传统工艺\n\n口感: 鲜美咸香\n\n净重: 500ml/瓶\n\n保质期: 12个月\n\n发货方式: 顺丰配送,确保完好\n\n物流信息: 24小时内发货,预计2\n\n3天到货\n\n推荐菜系:\n\n凉拌菜: 作为调味料使用,提升菜肴鲜味。\n\n炒菜: 适合炒菜提鲜。\n\n价格: 38元/瓶\n\n8. 潮汕糯米肠\n\n产品名称: 潮汕糯米肠\n\n电商网址: shop.example.com/glutinousrice' )] 4
6.11.3 假设性查询检索原文档 除了使用 摘要 来检索全文,多向量检索一般还适用于 子文档检索父文档 和 假设性查询检索,其中 假设性查询检索 是利用 LLM 对切块后的文档生成多个 假设性标题,在向量数据库中存储 假设性标题 文档块,使用检索到的数据查找 原始文档。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 from typing import List import dotenv from langchain_core.documents import Document from langchain_core.prompts import ChatPromptTemplate from langchain_core.pydantic_v1 import BaseModel, Field from langchain_openai import ChatOpenAI dotenv.load_dotenv() class HypotheticalQuestions (BaseModel ): """生成假设性问题""" questions: List [str ] = Field( description="假设性问题列表,类型为字符串列表" , ) prompt = ChatPromptTemplate.from_template("生成一个包含3个假设性问题的列表,这些问题可以用于回答下面的文档:\n\n{doc}" ) llm = ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) structured_llm = llm.with_structured_output(HypotheticalQuestions) chain = ( {"doc" : lambda x: x.page_content} | prompt | structured_llm ) hypothetical_questions: HypotheticalQuestions = chain.invoke( Document(page_content="我叫慕小课,我喜欢打篮球,游泳" ) ) print (hypothetical_questions)
输出:
1 questions=['如果你不能打篮球,你会选择什么运动?' , '如果你不能游泳,你会选择什么运动?' , '如果你不能进行任何体育运动,你会选择什么爱好?' ]
接下来针对每个文档生成的 假设性查询 创建 Document列表,并添加 doc_id,添加到向量数据库中,并将 doc_id 与原始文档进行绑定,存储到 文档数据库/字节数据库 即可。
6.12 父文档检索器实现拆分和存储平衡 – 索引阶段 6.12.1 拆分文档与检索的冲突 在 RAG 应用开发中,文档拆分 和 文档检索 通常存在相互冲突的愿望,例如: 1. 我们可能希望拥有小型文档,以便它们的嵌入可以最准确地反映它们的含义,如果太长,嵌入/向量没法记录太多文本特征。 2. 但是又希望文档足够长,这样能保留每个块的上下文。
这个时候就可以考虑通过 拆分子文档块,检索 父文档块 的策略来实现这种平衡,即在检索中,首先获取小块,然后再根据小块元数据中存储的 id,使用 id 来查找这些块的父文档,并返回那些更大的文档,该策略适合一些不是特别能拆分的文档,或者是文档上下文关联性很强的场景。
请注意,这里的“父文档”指的是小块来源的文档,可以是整个原始文档,也可以是切割后比较大的文档块。
除了使用 MultiVectorRetriever 来实现该运行流程,在 LangChain 中,还封装了 ParentDocumentRetriever,可以更加便捷地完成该功能,使用技巧也非常简单,传递 向量数据库、文档数据库 和 子文档分割器 即可。小文档块检索大文挡块,代码示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import dotenv import weaviate from langchain.retrievers import ParentDocumentRetriever from langchain.storage import LocalFileStore from langchain_community.document_loaders import UnstructuredFileLoader from langchain_openai import OpenAIEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() loaders = [ UnstructuredFileLoader("./电商产品数据.txt" ), UnstructuredFileLoader("./项目API文档.md" ), ] docs = [] for loader in loaders: docs.extend(loader.load()) parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000 ) child_splitter = RecursiveCharacterTextSplitter(chunk_size=500 , chunk_overlap=50 ) vector_store = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="ParentDocument" , text_key="text" , embedding=OpenAIEmbeddings(model="text-embedding-3-small" ), ) store = LocalFileStore("./parent-document" ) retriever = ParentDocumentRetriever( vectorstore=vector_store, byte_store=store, parent_splitter=parent_splitter, child_splitter=child_splitter, ) retriever.add_documents(docs, ids=None ) search_docs = retriever.invoke("分享关于LLMOps的一些应用配置" ) print (search_docs) print (len (search_docs))
先将原始文档拆分成较大的块(例如 1000-2000 个 Token),然后将其拆分为较小块,接下来索引较小块,但是检索时返回较大块(非原文档)。
6.13 递归文档树检索高级RAG优化 – 索引阶段 6.13.1 RAPTOR 递归文档树策略 在传统的 RAG 中,我们通常依靠检索短的连续文本块来进行检索。但是,当我们处理的是长上下文时,我们就不能仅仅将文档分块嵌入到其中,或者仅仅使用上下文填充所有文档。相反,我们希望为 LLM 的长下文找到一种好的最小化分块方法,这就是 RAPTOR 的用武之地,在 RAPTOR 中,均衡了多文档、超长上下文、高准确性、超低成本 等特性。
RAPTOR 其实是一种用树状组织检索的递归抽象处理技术,它采用了一种自下而上的方法,通过对文本片段(块)进行聚类和归纳来形成一种分层结构,该技术在 https://arxiv.org/pdf/2401.18059 论文中被首次提出。
RAPTOR 的运行流程其实很简单,主要步骤如下: 1. 对原始文本进行分块,拆分成合适的大小; 2. 对拆分的文档块进行嵌入/向量化,向量目前处于高维,并将数据存储到向量数据库; 3. 将高维向量进行降维,降低运算成本,例如降低成 2 维或者 3 维; 4. 对降维向量进行聚类,找出同一类的文档组; 5. 合并文档组的文本,使用 LLM 对合并文档进行摘要汇总得到新的文本,重复 ②-⑤ 的步骤; 6. 直到最后只剩下一个 文档 并且该文档的长度符合大小时,结束整个流程;可视化其运行流程后如下: 在执行检索的时候,RAPTOR 模型采用了两种主要的检索策略:树遍历 和 折叠树,对比两种策略,论文中更推荐 折叠树 检索策略。
树遍历: 方法从树的根节点开始,逐层向下进行检索,在每一层它根据查询向量与节点嵌入的余弦相似性选择最相关的前 k 个节点,然后将这些节点的子节点作为下一层的候选节点集,并重复选择过程,直到达到叶节点,最终将所有选中的节点文本进行拼接,形成检索到的上下文。折叠树: 方法会先将整个 RAPTOR 树展平为单个层,即所有节点在同一层级上进行比较,计算查询向量与所有节点嵌入的余弦相似性,并选择最相关的前 k 个节点。 并且因为 折叠树 和 向量数据库基础搜索 一样都是遍历所有向量(同层),所以只需要将数据存储到向量数据库中,并执行 相似性搜索 其实就实现了 折叠树 检索策略。 对比其他的 RAG 优化策略,RAPTOR递归文档树 不仅保留了原始文档,还将同类文档进行层层抽象(层层总结),而且在 文本嵌入模型 参数较小的情况下,一般也能实现不错的效果(低成本、高性能),不过因为该数据存储的是 树状结构,每次在更新/新增数据时,操作对比其他优化策略麻烦很多。对于树状结构,一般的更新策略有: 1. 重新构建树结构:新增了大量文档时,可以重新构建整个 RAPTOR 树,确保数据不会遗漏。 2. 增量更新:如果新增的文档不是很多,可以考虑只更新部分树结构,将新文档的文本块作为新的叶子节点添加到树中,然后根据这些新节点的内容,调整或更新其上层节点的摘要信息。 3. 利用现有节点:如果新文档与现有树中的某些节点内容相似,可以考虑将新文档的文本块与现有节点合并,然后重新生成这些节点的摘要,以此来更新树结构。 4. 层次化更新:根据新文档的内容和重要性,选择在树的哪个层次上进行更新。如果新文档提供了对整个文档集的重要概述,可能需要在较高的层次上更新;如果新文档提供了细节信息,可能只需要在较低层次上更新。
6.13.2 RAPTOR 递归文档树的实现 在 LangChain 中并没有针对 RAPTOR 的实现,并且在该策略中涵盖了 高维数据降维、低维数据聚类、递归检索 等内容,对于该检索策略,目前使用得较少(更新与新增缺陷导致外挂文档增加时复杂度成倍递增),所以仅需要了解 RAPTOR 的运行流程即可。 首先安装特定的第三方 Python 包
1 pip install -U umap-learn scikit-learn tiktoken
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 from typing import Optional import dotenv import numpy as np import pandas import pandas as pd import umap import weaviate from langchain_community.document_loaders import UnstructuredFileLoader from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_huggingface import HuggingFaceEmbeddings from langchain_openai import ChatOpenAI from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_weaviate import WeaviateVectorStore from sklearn.mixture import GaussianMixture from weaviate.auth import AuthApiKey dotenv.load_dotenv() RANDOM_SEED = 224 embd = HuggingFaceEmbeddings( model_name="thenlper/gte-small" , cache_folder="./embeddings/" , encode_kwargs={"normalize_embeddings" : True }, ) model = ChatOpenAI(model="gpt-3.5-turbo-16k" , temperature=0 ) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="RaptorRAG" , text_key="text" , embedding=embd, ) def global_cluster_embeddings ( embeddings: np.ndarray, dim: int , n_neighbors: Optional [int ] = None , metric: str = "cosine" , ) -> np.ndarray: """ 使用UMAP对传递嵌入向量进行全局降维 :param embeddings: 需要降维的嵌入向量 :param dim: 降低后的维度 :param n_neighbors: 每个向量需要考虑的邻居数量,如果没有提供默认为嵌入数量的开方 :param metric: 用于UMAP的距离度量,默认为余弦相似性 :return: 一个降维到指定维度的numpy嵌入数组 """ if n_neighbors is None : n_neighbors = int ((len (embeddings) - 1 ) ** 0.5 ) return umap.UMAP(n_neighbors=n_neighbors, n_components=dim, metric=metric).fit_transform(embeddings) def local_cluster_embeddings ( embeddings: np.ndarray, dim: int , n_neighbors: int = 10 , metric: str = "cosine" , ) -> np.ndarray: """ 使用UMAP对嵌入进行局部降维处理,通常在全局聚类之后进行。 :param embeddings: 需要降维的嵌入向量 :param dim: 降低后的维度 :param n_neighbors: 每个向量需要考虑的邻居数量 :param metric: 用于UMAP的距离度量,默认为余弦相似性 :return: 一个降维到指定维度的numpy嵌入数组 """ return umap.UMAP( n_neighbors=n_neighbors, n_components=dim, metric=metric, ).fit_transform(embeddings) def get_optimal_clusters ( embeddings: np.ndarray, max_clusters: int = 50 , random_state: int = RANDOM_SEED, ) -> int : """ 使用高斯混合模型结合贝叶斯信息准则(BIC)确定最佳的聚类数目。 :param embeddings: 需要聚类的嵌入向量 :param max_clusters: 最大聚类数 :param random_state: 随机数 :return: 返回最优聚类数 """ max_clusters = min (max_clusters, len (embeddings)) n_clusters = np.arange(1 , max_clusters) bics = [] for n in n_clusters: gm = GaussianMixture(n_components=n, random_state=random_state) gm.fit(embeddings) bics.append(gm.bic(embeddings)) return n_clusters[np.argmin(bics)] def gmm_cluster (embeddings: np.ndarray, threshold: float , random_state: int = 0 ) -> tuple [list , int ]: """ 使用基于概率阈值的高斯混合模型(GMM)对嵌入进行聚类。 :param embeddings: 需要聚类的嵌入向量(降维) :param threshold: 概率阈值 :param random_state: 用于可重现的随机性种子 :return: 包含聚类标签和确定聚类数目的元组 """ n_clusters = get_optimal_clusters(embeddings) gm = GaussianMixture(n_components=n_clusters, random_state=random_state) gm.fit(embeddings) probs = gm.predict_proba(embeddings) labels = [np.where(prob > threshold)[0 ] for prob in probs] return labels, n_clusters def perform_clustering (embeddings: np.ndarray, dim: int , threshold: float ) -> list [np.ndarray]: """ 对嵌入进行聚类,首先全局降维,然后使用高斯混合模型进行聚类,最后在每个全局聚类中进行局部聚类。 :param embeddings: 需要执行操作的嵌入向量列表 :param dim: 指定的降维维度 :param threshold: 概率阈值 :return: 包含每个嵌入的聚类ID的列表,每个数组代表一个嵌入的聚类标签。 """ if len (embeddings) <= dim + 1 : return [np.array([0 ]) for _ in range (len (embeddings))] reduced_embeddings_global = global_cluster_embeddings(embeddings, dim) global_clusters, n_global_clusters = gmm_cluster(reduced_embeddings_global, threshold) all_local_clusters = [np.array([]) for _ in range (len (embeddings))] total_clusters = 0 for i in range (n_global_clusters): global_cluster_embeddings_ = embeddings[ np.array([i in gc for gc in global_clusters]) ] if len (global_cluster_embeddings_) == 0 : continue if len (global_cluster_embeddings_) <= dim + 1 : local_clusters = [np.array([0 ]) for _ in global_cluster_embeddings_] n_local_clusters = 1 else : reduced_embeddings_local = local_cluster_embeddings(global_cluster_embeddings_, dim) local_clusters, n_local_clusters = gmm_cluster(reduced_embeddings_local, threshold) for j in range (n_local_clusters): local_cluster_embeddings_ = global_cluster_embeddings_[ np.array([j in lc for lc in local_clusters]) ] indices = np.where( (embeddings == local_cluster_embeddings_[:, None ]).all (-1 ) )[1 ] for idx in indices: all_local_clusters[idx] = np.append(all_local_clusters[idx], j + total_clusters) total_clusters += n_local_clusters return all_local_clusters def embed (texts: list [str ] ) -> np.ndarray: """ 将传递的的文本列表转换成嵌入向量列表 :param texts: 需要转换的文本列表 :return: 生成的嵌入向量列表并转换成numpy数组 """ text_embeddings = embd.embed_documents(texts) return np.array(text_embeddings) def embed_cluster_texts (texts: list [str ] ) -> pandas.DataFrame: """ 对文本列表进行嵌入和聚类,并返回一个包含文本、嵌入和聚类标签的数据框。 该函数将嵌入生成和聚类结合成一个步骤。 :param texts: 需要处理的文本列表 :return: 返回包含文本、嵌入和聚类标签的数据框 """ text_embeddings_np = embed(texts) cluster_labels = perform_clustering(text_embeddings_np, 10 , 0.1 ) df = pd.DataFrame() df["text" ] = texts df["embd" ] = list (text_embeddings_np) df["cluster" ] = cluster_labels return df def fmt_txt (df: pd.DataFrame ) -> str : """ 将数据框中的文本格式化成单个字符串 :param df: 需要处理的数据框,内部涵盖text、embd、cluster三个字段 :return: 返回合并格式化后的字符串 """ unique_txt = df["text" ].tolist() return "--- --- \n --- ---" .join(unique_txt) def embed_cluster_summarize_texts (texts: list [str ], level: int ) -> tuple [pd.DataFrame, pd.DataFrame]: """ 对传入的文本列表进行嵌入、聚类和总结。 该函数首先问文本生成嵌入,基于相似性对他们进行聚类,扩展聚类分配以便处理,然后总结每个聚类中的内容。 :param texts: 需要处理的文本列表 :param level: 一个整数,可以定义处理的深度 :return: 包含两个数据框的元组 - 第一个 DataFrame (df_clusters) 包括原始文本、它们的嵌入以及聚类分配。 - 第二个 DataFrame (df_summary) 包含每个聚类的摘要信息、指定的处理级别以及聚类标识符。 """ df_clusters = embed_cluster_texts(texts) expanded_list = [] for index, row in df_clusters.iterrows(): for cluster in row["cluster" ]: expanded_list.append( {"text" : row["text" ], "embd" : row["embd" ], "cluster" : cluster} ) expanded_df = pd.DataFrame(expanded_list) all_clusters = expanded_df["cluster" ].unique() template = """Here is a sub-set of LangChain Expression Language doc. LangChain Expression Language provides a way to compose chain in LangChain. Give a detailed summary of the documentation provided. Documentation: {context} """ prompt = ChatPromptTemplate.from_template(template) chain = prompt | model | StrOutputParser() summaries = [] for i in all_clusters: df_cluster = expanded_df[expanded_df["cluster" ] == i] formatted_txt = fmt_txt(df_cluster) summaries.append(chain.invoke({"context" : formatted_txt})) df_summary = pd.DataFrame( { "summaries" : summaries, "level" : [level] * len (summaries), "cluster" : list (all_clusters), } ) return df_clusters, df_summary def recursive_embed_cluster_summarize ( texts: list [str ], level: int = 1 , n_levels: int = 3 , ) -> dict [int , tuple [pd.DataFrame, pd.DataFrame]]: """ 递归地嵌入、聚类和总结文本,直到达到指定的级别或唯一聚类数变为1,将结果存储在每个级别处。 :param texts: 要处理的文本列表 :param level: 当前递归级别(从1开始) :param n_levels: 递归地最大深度(默认为3) :return: 一个字典,其中键是递归级别,值是包含该级别处聚类DataFrame和总结DataFrame的元组。 """ results = {} df_clusters, df_summary = embed_cluster_summarize_texts(texts, level) results[level] = (df_clusters, df_summary) unique_clusters = df_summary["cluster" ].nunique() if level < n_levels and unique_clusters > 1 : new_texts = df_summary["summaries" ].tolist() next_level_results = recursive_embed_cluster_summarize( new_texts, level + 1 , n_levels ) results.update(next_level_results) return results loaders = [ UnstructuredFileLoader("./流浪地球.txt" ), UnstructuredFileLoader("./电商产品数据.txt" ), UnstructuredFileLoader("./项目API文档.md" ), ] text_splitter = RecursiveCharacterTextSplitter( chunk_size=500 , chunk_overlap=0 , separators=["\n\n" , "\n" , "。|!|?" , "\.\s|\!\s|\?\s" , ";|;\s" , ",|,\s" , " " , "" ], is_separator_regex=True , ) docs = [] for loader in loaders: docs.extend(loader.load_and_split(text_splitter)) leaf_texts = [doc.page_content for doc in docs] results = recursive_embed_cluster_summarize(leaf_texts, level=1 , n_levels=3 ) all_texts = leaf_texts.copy() for level in sorted (results.keys()): summaries = results[level][1 ]["summaries" ].tolist() all_texts.extend(summaries) db.add_texts(all_texts) retriever = db.as_retriever(search_type="mmr" ) search_docs = retriever.invoke("流浪地球中的人类花了多长时间才流浪到新的恒星系?" ) print (search_docs) print (len (search_docs))
输出:
1 2 3 [Document(page_content='我们的船继续航行,到了地球黑夜的部分,在这里,阳光和地球发动机的光柱都照不到,在大西洋清凉的海风中,我们这些孩子第一次看到了星空。天啊,那是怎样的景象啊,美得让我们心醉。小星老师一手搂着我们,一手指着星空,看,孩子们,那就是半人马座,那就是比邻星,那就是我们的新家!说完她哭了起来,我们也都跟着哭了,周围的水手和船长,这些铁打的汉子也流下了眼泪。所有的人都用泪眼探望着老师指的方向,星空在泪水中扭曲抖动,唯有那个星星是不动的,那是黑夜大海狂浪中远方陆地的灯塔,那是冰雪荒原中快要冻死的孤独旅人前方隐现的火光,那是我们心中的太阳,是人类在未来一百代人的苦海中惟一的希望和支撑……\n\n在回家的航程中,我们看到了启航的第一个信号:夜空中出现了一个巨大的彗星,那是月球。人类带不走月球,就在月球上也安装了行星发动机,把它推离地球轨道,以免在地球加速时相撞。月球上行星发动机产生的巨大彗尾使大海笼罩在一片蓝光之中,群星看不见了。月球移动产生的引力潮汐使大海巨浪冲天,我们改乘飞机向南半球的家飞去。\n\n启航的日子终于到了!' ), Document(page_content='离开木星时,地球已达到了逃逸速度,它不再需要返回潜藏着死亡的太阳,向广漠的外太空飞去,漫长的流浪时代开始了。\n\n就在木星暗红色的阴影下,我的儿子在地层深处出生了。\n\n第3章 叛乱\n\n离开木星后,亚洲大陆上一万多台地球发动机再次全功率开动,这一次它们要不停地运行500年,不停地加速地球。这500年中,发动机将把亚洲大陆上一半的山脉用做燃料消耗掉。\n\n从四个多世纪死亡的恐惧中解脱出来,人们长出了一口气。但预料中的狂欢并没有出现,接下来发生的事情出乎所有人的想像。\n\n在地下城的庆祝集会后,我一个人穿上密封服来到地面。童年时熟悉的群山已被超级挖掘机夷为平地,大地上只有裸露的岩石和坚硬的冻土,冻土上到处有白色的斑块,那是大海潮留下的盐渍。面前那座爷爷和爸爸度过了一生的曾有千万人口的大城市现在已是一片废墟,高楼钢筋外露的残骸在地球发动机光柱的蓝光中拖着长长的影子,好像是史前巨兽的化石……一次次的洪水和小行星的撞击已摧毁了地面上的一切,各大陆上的城市和植被都荡然无存,地球表面已变成火星一样的荒漠。' ), Document(page_content='“可老师,我们来不及的,地球来不及的,它还来不及加速到足够快,航行足够远,太阳就爆炸了!”\n\n“时间是够的,要相信联合政府!这我说了多少遍,如果你们还不相信,我们就退一万步说:人类将自豪地去死,因为我们尽了最大的努力!”\n\n人类的逃亡分为五步:第一步,用地球发动机使地球停止转动,使发动机喷口固定在地球运行的反方向;第二步,全功率开动地球发动机,使地球加速到逃逸速度,飞出太阳系;第三步,在外太空继续加速,飞向比邻星;第四步,在中途使地球重新自转,掉转发动机方向,开始减速;第五步,地球泊入比邻星轨道,成为这颗恒星的卫星。人们把这五步分别称为刹车时代、逃逸时代、流浪时代I(加速)、流浪时代II(减速)、新太阳时代。\n\n整个移民过程将延续两千五百年时间,一百代人。' ), Document(page_content='我们很快到达了海边,看到城市摩天大楼的尖顶伸出海面,退潮时白花花的海水从大楼无数的窗子中流出,形成一道道瀑布……刹车时代刚刚结束,其对地球的影响已触目惊心:地球发动机加速造成的潮汐吞没了北半球三分之二的大城市,发动机带来的全球高温融化了极地冰川,更给这大洪水推波助澜,波及到南半球。爷爷在三十年前亲眼目睹了百米高的巨浪吞没上海的情景,他现在讲这事的时候眼还直勾勾的。事实上,我们的星球还没启程就已面目全非了,谁知道在以后漫长的外太空流浪中,还有多少苦难在等着我们呢?' )] 4
6.13.3 索引阶段优化策略总结 索引阶段优化策略 涵盖了 分割策略(语义分割、递归字符分割)、多向量/表征检索、RAPTOR递归文档树检索,其中 特定Embeddings 即考虑使用维度更高的文本嵌入模型,并没有复杂的使用技巧。 目前在 索引阶段,使用最多的优化策略还是在 文档加载/分割 与 特定Embeddings 上,因为无论是 多向量/表征索引 还是 RAPTOR递归文档树,都会使用额外的 LLM 对文档进行相应的总结、信息提取、分类汇总等,数据越多,信息失真的概率会越大(所以谨慎使用)。
6.14 ReRank 重排序提升RAG系统 – 筛选阶段 6.14.1 ReRank 重排序 进一步优化筛选阶段,一般涵盖了 重排序、纠正性RAG 两种策略,其中 重排序 是使用频率最高,性价比最高,通常与 混合检索 一起搭配使用,也是目前主流的优化策略(Dify、Coze、智谱、绝大部分开源 Agent 项目都在使用)。 重排序 的核心思想见字知其意,即对检索到的文档 调整顺序,除此之外,重排序 一般还会增加 剔除无关/多余数据 的步骤,在前面的课时中,我们学习的 RRF 算法其实就是重排序中最基础一种。 运行流程如下: 并且 重排序 的逻辑是输入 文档列表,输出的仍然是 文档列表,和 DocumentTransformer 类似,不过在 LangChain 中 重排序 是一个 DocumentCompressor 组件(压缩组件),如果需要查找 重排序 组件,可以在 文档转换/检索器集成 列表中查找,一些高频使用的组件还进行了单独的封装,例如:LongContextReorder 、Cohere Reranker 等。
在 LangChain 中,使用重排工具很简单,可以单独使用,也可以利用 ContextualCompressionRetriever 检索器进行二次包装合并,并传递检索器+重排工具,这样检索输出得到的结果就是经过重排的了。
6.14.2 Cohere 重排序 目前可用的重新排序模型并不多,一种是选择 Cohere 提供的在线模型,可以通过 API 访问,此外还有一些开源模型,如:bge-rerank-base 和 bge-rerank-large 等,体验与评分最好的是 Cohere 的在线模型,不过它是一项付费服务(注册账号提供免费额度),并且由于 Cohere 服务部署在海外,国内访问速度并没有这么快。
对于学习,我们可以使用 Cohere 的在线模型,如果是部署企业内部的服务,可以使用 bge-rerank-large,链接如下: 1. Cohere 在线模型:https://cohere.com/ 2. bge-rerank-large 开源模型:https://huggingface.co/BAAI/bge-reranker-large
安装依赖:
1 pip install langchain-cohere
在 weaviate 向量数据库中,使用 Cohere 在线模型集成重排序示例代码(使用 weaviate 数据库的 DatasetDemo 集合,存储了 项目API文档.md 文档的内容,示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import dotenv import weaviate from langchain.retrievers import ContextualCompressionRetriever from langchain_cohere import CohereRerank from langchain_openai import OpenAIEmbeddings from langchain_weaviate import WeaviateVectorStore from weaviate.auth import AuthApiKey dotenv.load_dotenv() embedding = OpenAIEmbeddings(model="text-embedding-3-small" ) db = WeaviateVectorStore( client=weaviate.connect_to_wcs( cluster_url="https://mbakeruerziae6psyex7ng.c0.us-west3.gcp.weaviate.cloud" , auth_credentials=AuthApiKey("ZltPVa9ZSOxUcfafelsggGyyH6tnTYQYJvBx" ), ), index_name="DatasetDemo" , text_key="text" , embedding=embedding, ) rerank = CohereRerank(model="rerank-multil ingual-v3.0" ) retriever = ContextualCompressionRetriever( base_retriever=db.as_retriever(search_type="mmr" ), base_compressor=rerank, ) search_docs = retriever.invoke("关于LLMOps应用配置的信息有哪些呢?" ) print (search_docs) print (len (search_docs))
输出 :
1 2 3 [Document(metadata={'source' : './项目API文档.md' , 'start_index' : 2324.0, 'relevance_score' : 0.9298237}, page_content='json { "code": "success", "data": { "id": "5e7834dc-bbca-4ee5-9591-8f297f5acded", "name": "慕课LLMOps聊天机器人", "icon": "https://imooc-llmops-1257184990.cos.ap-guangzhou.myqcloud.com/2024/04/23/e4422149-4cf7-41b3-ad55-ca8d2caa8f13.png", "description": "这是一个慕课LLMOps的Agent应用", "published_app_config_id": null, "drafted_app_config_id": null, "debug_conversation_id": "1550b71a-1444-47ed-a59d-c2f080fbae94", "published_app_config": null, "drafted_app_config": { "id": "755dc464-67cd-42ef-9c56-b7528b44e7c8"' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 0.0, 'relevance_score' : 0.7358315}, page_content='LLMOps 项目 API 文档\n\n应用 API 接口统一以 JSON 格式返回,并且包含 3 个字段:code、data 和 message,分别代表业务状态码、业务数据和接口附加信息。\n\n业务状态码共有 6 种,其中只有 success(成功) 代表业务操作成功,其他 5 种状态均代表失败,并且失败时会附加相关的信息:fail(通用失败)、not_found(未找到)、unauthorized(未授权)、forbidden(无权限)和validate_error(数据验证失败)。\n\n接口示例:\n\njson { "code": "success", "data": { "redirect_url": "https://github.com/login/oauth/authorize?client_id=f69102c6b97d90d69768&redirect_uri=http%3A%2F%2Flocalhost%3A5001%2Foauth%2Fauthorize%2Fgithub&scope=user%3Aemail" }, "message": "" }' ), Document(metadata={'source' : './项目API文档.md' , 'start_index' : 675.0, 'relevance_score' : 0.098772585}, page_content='json { "code": "success", "data": { "list": [ { "app_count": 0, "created_at": 1713105994, "description": "这是专门用来存储慕课LLMOps课程信息的知识库", "document_count": 13, "icon": "https://imooc-llmops-1257184990.cos.ap-guangzhou.myqcloud.com/2024/04/07/96b5e270-c54a-4424-aece-ff8a2b7e4331.png", "id": "c0759ca8-2d35-4480-83a8-1f41f29d1401", "name": "慕课LLMOps课程知识库", "updated_at": 1713106758, "word_count": 8850 } ], "paginator": { "current_page": 1, "page_size": 20, "total_page": 1, "total_record": 2 } }' )] 3
通过 LangSmith 观察该检索器的运行流程,可以发现,原始检索器找到了 4 条数据,但是经过重排序后,只返回了相关性高的 3 条(可设置),有一条被剔除了,这也是 压缩器 和 文档转换器 的区别(不过在旧版本的 LangChain 中,两个没有区别,所以在很多文档转换器的文档中都可以看到压缩器的存在)。
6.15 纠正性索引增强生成 CRAG 优化策略 6.15.1 纠正性检索增强 纠正性检索增强生成(Corrective Retrieval-Augmented Generation,CRAG)是一种先进的自然语言处理技术,旨在提高检索的生成方法的鲁棒性和准确性。在 CRAG 中引入了一个轻量级的检索评估器来评估检索到的文档的质量,并根据评估结果触发不同的知识检索动作,以确保生成结果的准确性。 CRAG 的工作流程包含了以下几个步骤:
1. 检索文档 :首先,基于用户的查询,系统执行检索操作以获取相关的文档或信息。 2. 评估检索质量 :CRAG 使用一个轻量级的检索评估器对检索到的每个文档进行质量评估,计算出一个量化的置信度分数。 3. 触发知识检索动作 :根据置信度分数,CRAG 将触发以下二个动作之一: · 正确 :如果评估器认为文档与查询高度相关,将采用该文档进行知识精炼。 · 错误 :如果文档被评估为不相关或误导性,CRAG将利用网络搜索寻找更多知识来源。 4. 知识精炼 :对于评估为正确的文档,CRAG将进行知识精炼,抽取关键信息并过滤掉无关信息。 5. 网络搜索 :在需要时,CRAG会执行网络搜索以寻找更多高质量的知识来源,以纠正或补充检索结果。 6. 分解-重组 :CRAG采用一种分解-重组算法,将检索到的文档解构为关键信息块,筛选重要信息,并重新组织成结构化知识。 7. 生成文本 :最后,利用经过优化和校正的知识,传递给 LLM,生成对应文本。
运行流程如下:
需要借助 LangGraph 实现循环处理
6.16 使用 Self-RAG 纠正低质量的检索生成 这里我们从一个常见的生活场景入手——参加开卷考试,一般来说我们通常会采用以下两种作答策略:
· 方法一:对于熟悉的题目,直接快速作答;对于不熟悉的题目,快速翻阅参考书,找到相关部分,在脑海中整理分类和总结后,再在试卷上作答。 · 方法二:每一个题目都需要参考书本进行解答。先找到相关部分,在脑海中进行整合和总结后,再到试卷上书写答案。
显然,方法一 大家用的更多一些,是首选方法。方法二不仅耗时,还有可能引入无关的或错误的信息,导致出现混淆和错误,甚至在考生原本擅长的领域也不例外。 映射到 RAG 应用开发中,很容易可以发现,方法二 是典型的 RAG,即(检索->整合->生成)流程,而方法一就是 Self-RAG 的流程。 Self-RAG 全称为自我反思 RAG,见名知其意,即对原始查询、检索的内容、生成的内容进行自我反思,根据反思的结果执行不同的操作,例如:直接输出答案、重新检索、剔除不相关的内容、检测生成内容是否存在幻觉、检测生成内容是否有帮助 等,可以把 Self-RAG 看成是一个拥有自我反思能力的智能体,这个智能体主要用来依据相关知识库回复用户问题,自我迭代,直到输出满意的结果。
一个 Self-RAG 应用主要有三大步骤组成: 1. 按需检索(Retrieval as Needed):使用需要检索时,例如查询“慕小课是谁时?”,模型会输出一个 检索query,表示需要检索与 query 相关的内容;相反,当模型被要求写“写一篇关于Python依赖注入的文章”时,大模型会直接生成答案,无需进行检索。 2. 以并行方式生成内容(Parallel Generation):模型会同时使用 prompt 和检索到的内容来生成模型输出,在整个过程中,会触发多种类型的反思(Reflection),涵盖了:反思文档是否有关联、反思生成内容是否存在幻觉,如果不关联则重新检索,如果存在幻觉/支持度不够,则重新生成。 3. 内容的评估和选择:对步骤 2 中生成的内容进行评估,并选择最佳文档段落作为输出。
拆分成流程图后,Self-RAG 的运行流程如下: 在 Self-RAG 架构中,用到了 3 处循环结构,对于该架构的 AI 应用,单纯使用 LCEL 表达式构建的链应用也没法或者很难实现整个过程,同样必须使用到 LangGraph 来构建 循环图,所以关于 Self-RAG 整个流程的构建,依旧会等到我们学习完 LangGraph 再来尝试。
在 LangChain 官网的示例中,提供了一个 Self-RAG 的 LangSmith 日志运行流程,我们可以通过这个日志的运行流程来深入理解。 链接:https://smith.langchain.com/public/55d6180f-aab8-42bc-8799-dadce6247d9b/r