为什么有RAG技术 我们先来看一下当前AI大模型和搜索引擎会有什么问题:
AI善于总结知识和语义理解,但是知识有限,它的训练还是有截止日期的
搜索引擎时效性很好,但是信息分散,对语义理解困难
AI结合搜索引擎可以弥补一部分时效性问题,然而对于本地知识和内网资料等非公开资料难以获取
因此我们需要文档加载和检索技术来增强AI的生成能力。
这样的技术,便是我们所要技术的RAG技术
RAG流程 我们来了解一下RAG的流程,之后我们再一步步介绍
文档加载 (Document Loading):加载多种不同来源加载文档。LangChain 提供了 100 多种不同的 文档加载器,包括 PDF 在内的非结构化的数据、SQL 在内的结构化的数据,以及 Python、Java 之类的代码等。
文本分割 (Splitting):文本分割器按一定规则把 Documents 切分为指定大小的块。
存储 (Storage):存储涉及到两个环节,分别是:
向量转换:将切分好的文档块进行嵌入(Embedding),即将文档块转换成向量的形式。
向量存储:将 Embedding 后的向量数据,存储到向量数据库中。
检索 (Retrieval):数据存入向量数据库后。当我们需要进行数据检索时,会通过某种检索算法找到与输入问题相似的文档块。
输出 (Output):把问题以及检索出来的文档块一起提交给 LLM,LLM 会通过问题和检索出来的提示一起来生成更加合理的答案
1.文档加载器 首先我们从第一步开始,也就是文档加载器,首先认识下文档类
文档类 在langchain中使用langchain_core.documents.base.Document表示文档类,通常情况下,一个文档对象表示一个较大文档的一部分内容,描述被切分出来的文本块,每个文档类有以下几个关键参数/属性/成员
id :可选的文档标识符。理想情况下,这应该在整个文档集合中是唯一的,并格式化为UUID,但不会强制执行。
page_content :字符串文本
metadata :与内容关联的任意元数据。类型为 dict [Optional]
加载pdf文档 这里用加载pdf文档举例,他来自from langchain_community.document_loaders.pdf import PyPDFLoader,我们来看看接口
__init()__,传入位置形参file_path
load() → list[Document]:将文件内容载入到文档列表中
我从网上找了一个在gitee开源的markdown文件,转换成了pdf文档,我们来把它载入,并打印一些数据看看
1 2 3 4 5 6 7 8 9 10 11 from langchain_community.document_loaders.pdf import PyPDFLoaderfile_path = "./docs/面经.pdf" loader = PyPDFLoader(file_path) docs = loader.load() print ("第一页的元数据" )print (docs[0 ].metadata)print ("第一页的内容" )print (docs[0 ].page_content)
1 2 3 4 5 6 7 8 9 10 D:\program_software\AnaConda\envs\langchain313\python.exe D:\codes\code_pycharm\langChainTool\document.py 第一页的元数据 {'producer': 'Aspose.Words for .NET 25.7.0', 'creator': 'PyPDF', 'creationdate': '', 'source': './docs/面经.pdf', 'total_pages': 13, 'page': 0, 'page_label': '1'} 第一页的内容 项目 1. grpc是基于http的流传输有包大小限制,grpc为什么能传输突破这个限制大小的 包 流式传输、分帧、编码压缩 ....
可以看到,打印出的元数据也包含了不少内容
加载md文档 相比于pdf有明显的一页一页的分页,markdown则是一个较为完整的一大份块文本,我们来看看它是怎么加载成文档的,相应的文档加载器名字也有了变化,有了个无结构unstructed的描述,它的init()参数多了一个mode,用于控制分页方式:
single:(默认)将一整个文档作为文档对象返回
elements: 会将整个文档按Titile和NarrativeText等不同类型的元素。
我们来对比下两种模式的差别
1 2 3 4 5 6 7 8 9 10 11 from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoaderfile_path = "./docs/面经.md" loader = UnstructuredMarkdownLoader(file_path, mode="element" ) docs = loader.load() print ("查看页数" )print (len (docs))print ("第一页的元数据" )print (docs[0 ].metadata)
single模式
1 2 3 4 5 6 查看页数 1 第一页的元数据 {'source': './docs/面经.md'} Process finished with exit code 0
element模式
1 2 3 4 查看页数 105 第一页的元数据 {'source': './docs/面经.md', 'category_depth': 3, 'languages': ['zho'], 'file_directory': './docs', 'filename': '面经.md', 'filetype': 'text/markdown', 'last_modified': '2024-09-15T16:28:31', 'category': 'Title', 'element_id': '07ecec887623ab3b15dd46e3bcaec62b'}
2.文档分割器 文档加载器直接加载进来的文档有时候太大了,为了保证每一个文档对象都足够小到易于被管理和搜索,我们需要用到文本分割器。毕竟大块文本更难搜索且不适合上下文有窗口限制的LLM,拆分之后才能利于搜索和精确匹配。
基于文档长度分割 其实这种分割方式还能细分成两种:基于字符拆长度拆分和基于Token长度拆分,前面介绍切割消息列表的时候已经介绍过了,我们在这里就不细说区别了
我们要用到的组件来自from langchain_text_splitters import CharacterTextSplitter
它有如下参数:
separator:分隔符
chunk_size: 块大小
chunk_overlap: 块重叠大小,即分割后的文档会重叠多少
length_function:长度计算函数,如果是len,就是按字符长度拆分
is_separator_regex:分隔符是否是正则表达式
我们来分一波文档看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoaderfrom langchain_text_splitters import CharacterTextSplitterfile_path = "./docs/面经.md" loader = UnstructuredMarkdownLoader(file_path) docs = loader.load() text_spliter = CharacterTextSplitter( separator="\n\n" , chunk_size=100 , chunk_overlap=20 , length_function=len , is_separator_regex=False ) docs = text_spliter.split_documents(docs) for doc in docs[:10 ]: print ('*' *10 ) print (doc)
前几行输出如下
为什么会爆红呢?因为langchain为了保证语义完整性,有时会创建超过规定长度的文档块,这是在预期行为范围内的,不用担心。
如果想减少爆红,我们可以把chunk_size改大一点,这样就会减少爆红了
基于Token长度分割
这里还有一种分割方法,就是使用tiktoken分词器来拆分,示例代码如下,就是改了下分词器的获取方式,使用了from_tiktoken_encoder()方法
1 2 3 text_splitter = CharacterTextSplitter.from_tiktoken_encoder( encoding_name="cl100k_base" , chunk_size=200 , chunk_overlap=50 )
硬性约束长度拆分 如果我们想硬性约束块大小,我们可以用from langchain_text_splitters import RecursiveCharacterTextSplitter,或者RecursiveCharacterTextSplitter.from_tiktoken_encoder
特殊文档结构拆分 像是代码文本(Python,C++…)以及Json、markdown这些有特殊结构的文档,可以用特定的分割器进行更好地分割
3.文本向量 在完成分割文档的任务后,我们就该着手解决文本向量化的事了
说明:我们之前一直用的大语言模型是生成式模型。它理解输入并生成新的文本(回答问题、写文章)。它内部实际上也使用嵌入技术来理解输入,但最终目标是“创造”。而嵌入模型(Embedding Models)是表示型模型。它的目标不是生成文本,而是为输入的文本创建一个最佳的、富含语义的数值表示(向量)。
特别的,在这些向量组成的数学上的向量空间里,有两个特别有意义的参数:
• 欧氏距离(Euclidean Distance):就是我们高中几何学的两点之间的直线距离。距离越短,相似度越高。 • 余弦相似度(Cosine Similarity):它忽略向量的绝对长度(大小),只关注两个向量在方向上的差异。在文本和语义的世界里,“方向”代表“含义”,而“长度”往往只代表“文本的长度”或“词汇的多少”。换句话说,余弦相似度关注的是 “你们是否指向同一个方向” / “你们是否代表同一个含义”
嵌入文本向量 我们来实践一下把文档转换为向量,这次我们依然要用到嵌入模型,这里用的是免费的from langchain_huggingface import HuggingFaceEmbeddings,不同的是我们要使用它的方法接口:
.embed_documents() : 用于处理文档 Documents 。它的输入是字符串列表。例如要将一个知识库里的所有段落都转换成向量后存入数据库,就会使用这个方法。
.embed_query() : 用于处理查询 Query 。它的输入是单个文本(一个字符串,str)。例如,当用户提出一个问题时,需要将这个问题转换成向量
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 from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoaderfrom langchain_text_splitters import CharacterTextSplitterfrom langchain_huggingface import HuggingFaceEmbeddingsfile_path = "./docs/面经.md" loader = UnstructuredMarkdownLoader(file_path) docs = loader.load() text_spliter = CharacterTextSplitter( separator="\n\n" , chunk_size=100 , chunk_overlap=20 , length_function=len , is_separator_regex=False ) docs = text_spliter.split_documents(docs) embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2" ) str_arr = [doc.page_content for doc in docs] doc_vector = embeddings.embed_documents(str_arr) print (f"文档数量为:{len (docs)} ,生成了{len (doc_vector)} 个向量的列表" )print (f"第一个文档向量维度:{len (doc_vector[0 ])} " )print (f"第二个文档向量维度:{len (doc_vector[1 ])} " )
部分输出如下
1 2 3 文档数量为:99,生成了99个向量的列表 第一个文档向量维度:768 第二个文档向量维度:768
4.向量存储 数据库的存储功能大差不差,但是对于搜索功能,相比于普通关系型数据库的精确搜索,对于向量我们更需向量数据库提供的内容相似性搜索
向量数据库则提供了专门用于高效存储、管理和检索高维向量的能力。其核心就是 “高效地组织和检索这些数据”
对于这一特殊功能,向量数据库有着专门的优化:
专门的索引–例如近似最近邻(ANN)搜索
常见的方法有近似最近邻(ANN)搜索 :为了追求极致的速度,它愿意牺牲一点点精度。它不会保证找到绝对最相似的向量(即最近邻),但能以极高的概率找到非常相似的向量 。通过聚类、分层、压缩等算法技术,将搜索范围从“整个数据库”缩小到“几个最可能的候选集”。
向量相似度计算优化–使用并行计算能力
向量数据库底层使用高度优化的库来进行向量运算。如 FAISS 向量数据库,它是 Facebook AI 研究院开发的一种高效的相似性搜索和聚类的库。它能够快速处理大规模数据,并且支持在高维空间中进行相似性搜索。这些库充分利用了 CPU 的 SIMD 指令集和 GPU 的并行计算能力,让大规模的向量计算速度极快。
数据库管理功能
除了CRUD等基础操作,还支持元数据过滤、可扩展性、可持久化、易于集成等优点
LangChain 框架则通过与这些向量数据库集成,让开发者无需手动处理向量生成、存储和比较的复杂性,只需关注业务逻辑本身,极大地提高了开发效率和应用性能。
内存存储 我们先来使用一些简单易部署的内存数据库,特别的,大部分内存存储实例在初始化时需要传入指定的向量模型作为参数。
1 2 3 4 from langchain_huggingface import HuggingFaceEmbeddingsfrom langchain_core.vectorstores import InMemoryVectorStoreembeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2" ) store = InMemoryVectorStore(embedding=embeddings)
接下来我们往里面添加文档
我们可以使用 add_documents 方法,向内存存储中去添加文档。没错,直接添加分割好的文档对象列表就行。要注意的是,该方法会为添加的文档编排索引,索引列表随着该方法返回。
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 from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoaderfrom langchain_text_splitters import CharacterTextSplitterfrom langchain_huggingface import HuggingFaceEmbeddingsfrom langchain_core.vectorstores import InMemoryVectorStorefile_path = "./docs/面经.md" loader = UnstructuredMarkdownLoader(file_path) docs = loader.load() text_spliter = CharacterTextSplitter( separator="\n\n" , chunk_size=100 , chunk_overlap=20 , length_function=len , is_separator_regex=False ) docs = text_spliter.split_documents(docs) embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2" ) store = InMemoryVectorStore(embedding=embeddings) ids = store.add_documents(documents=docs) print (f"共编排了{len (ids)} 个文档索引" )print (f"前3个文档的索引是:{ids[:3 ]} " )
末尾的输出如下
1 2 共编排了99个文档索引 前3个文档的索引是:['594e2b3c-d679-4df5-8b77-87bb95dff7d4', 'e88a24f2-2306-4977-b1a6-58e71c648661', 'cf97bae9-6d6a-4bb5-a22c-82159b5734d8']
5. 向量搜索 相似性搜索 我们使用similarity_search方法来执行基于语义的相似性搜索,即根据向量的余弦相似性进行搜索
它有几个主要参数:
query:查询请求,用于参考语义相似性的
k: 查询出的文档的最大数量
filter:过滤器,传入def func(doc:Document)->bool类型的函数即可完成过滤
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 from langchain_community.document_loaders.markdown import UnstructuredMarkdownLoaderfrom langchain_text_splitters import CharacterTextSplitterfrom langchain_huggingface import HuggingFaceEmbeddingsfrom langchain_core.vectorstores import InMemoryVectorStorefile_path = "./docs/面经.md" loader = UnstructuredMarkdownLoader(file_path) docs = loader.load() text_spliter = CharacterTextSplitter( separator="\n\n" , chunk_size=100 , chunk_overlap=20 , length_function=len , is_separator_regex=False ) docs = text_spliter.split_documents(docs) embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2" ) store = InMemoryVectorStore(embedding=embeddings) ids = store.add_documents(documents=docs) results = store.similarity_search(query="Linux的特性" ,k=2 ) for doc in results: print ("*" *40 ) print (doc.page_content)
输出如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 **************************************** 语言 1. 内联函数的缺点 代码膨胀,编译时间增加,降低维护性,寄存器压力 Linux命令 1. 两台服务器,从一台服务器下载另一台上面的zip文件,用linux什么命令 **************************************** 隔离性:虚拟机提供了系统级别的隔离,每个虚拟机都拥有独立的操作系统和硬件资源,彼此之间完全隔离。 Docker
6.检索 实践样例:RAG样例 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 from langchain_huggingface import HuggingFaceEmbeddingsfrom langchain_openai import ChatOpenAIfrom langchain_community.document_loaders.markdown import UnstructuredMarkdownLoaderfrom langchain_text_splitters import CharacterTextSplitterfrom langchain_core.vectorstores import InMemoryVectorStorefrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParserfrom langchain_core.runnables import RunnablePassthroughembeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2" ) model = ChatOpenAI(model="gpt-4o-mini" ) loader = UnstructuredMarkdownLoader("docs/面经.md" ) data =loader.load() text_spliter = CharacterTextSplitter( separator="\n\n" , chunk_size=200 , chunk_overlap=20 , length_function=len , is_separator_regex=False ) docs = text_spliter.split_documents(data) store = InMemoryVectorStore(embedding=embeddings) store.add_documents(docs) retriever = store.as_retriever() prompt_template = ChatPromptTemplate.from_messages( [ ("human" ,"你是问答助手,必须使用检索到的上下文片段来回答问题,如果你不知道答案,就说不知道答案,最多回复三句话的结果,回答简明扼要" ), ( "human" , """ Question:{question}, Context:{context} Answer: """ ) ] ) def format_docs (docs ): return "\n\n" .join( doc.page_content for doc in docs) chain = ( {"context" : retriever| format_docs,"question" : RunnablePassthrough()} | prompt_template | model | StrOutputParser() ) while True : question = input ("\n请输入您的问题" ).strip() if not question: continue print ("回答:" ) for chunk in chain.stream(question): print (chunk,end="" , flush=True ) print ()