万字详解,和你用RAG+LangChain实现chatpdf

像chatgpt这样的大语言模型(LLM)可以回答很多类型的问题,但是,如果只依赖LLM,它只知道训练过的内容,不知道你的私有数据:如公司内部没有联网的企业文档,或者在LLM训练完成后新产生的数据。(即使是最新的GPT-4 Turbo,训练的数据集也只更新到2023年4月)所以,如果我们开发一个聊天机器人,可以与自己的文档对话,让LLM基于文档的信息回答我们的问题,是一件很有意义的事情。

本次我们会基于RAG的原理,通过LangChain来实现与pdf文档对话。

本次用到的文档放在这里的docs目录:https://github.com/fireshort/langchain-chat-with-your-data 我们这次会以吴恩达教授CS229(斯坦福的机器学习课程)的pdf为例子。

什么是RAG?


RAG是Retrieval-augmented generation(检索增强生成)的简称,它结合了检索和生成的能力,为文本序列生成任务引入额外的外部知识(通常是私有的或者是实时的数据),就是用外部信息来增强LLM的知识。RAG 将传统的语言生成模型与大规模的外部知识库相结合,使模型在生成响应或文本时可以动态地从这些知识库中检索相关信息。这种结合方法旨在增强模型的生成能力,使其能够产生更为丰富、准确和有根据的内容,特别适合需要具体细节或外部事实支持的场合。

RAG一般分为下面几步:

检索:对于给定的输入(问题),模型首先使用检索系统从大型文档集合中查找相关的文档或段落。这个检索系统通常基于密集向量搜索。

上下文编码:找到相关的文档或段落后,模型将它们与原始输入(问题)一起放到Prompt里。

生成:使用编码的上下文信息,模型生成输出(答案)。这通常通过大模型完成。

RAG原理

使用LangChain实现


RAG看起来还是比较抽象,我们接下来会用LangChain实现,可以细分为下面5步:

  1. Document Loading:文档加载器把 Documents 加载为以 LangChain 能够读取的形式。
  2. Splitting:文本分割器把 Documents 切分为指定大小的、语义上有意义的块,一般称为“文档块”或者“文档片”。
  3. Storage:将上一步中分割好的“文档块”以“嵌入”(Embedding)的形式存储到向量数据库(Vector DB)中,形成一个个的“嵌入片”。
  4. Retrieval:应用程序从存储中检索分割后的文档(例如通过比较余弦相似度,找到与输入问题类似的嵌入片)。
  5. Output:把问题和相似的文档块传递给语言模型(LLM),使用包含问题、检索到的文档块的提示生成答案。

注意,最新版的openai库与当前的LangChain不兼容,要安装0.28.1版的openai库。

!pip install openai==0.28.1

要先用.env文件来初始化环境变量。

关于如何用.env文件初始化环境变量和LangChain的入门教程,推荐阅读专栏《基于LangChain的LLM应用开发》:https://juejin.cn/column/7290751135904038953

from langchain.document_loaders import PyPDFLoader 
from langchain.memory import ConversationBufferMemory 
from langchain.vectorstores import Chroma 
from langchain.embeddings.openai import OpenAIEmbeddings 
from langchain.text_splitter import RecursiveCharacterTextSplitter 
from langchain.chat_models import AzureChatOpenAI 
from langchain.chains import ConversationalRetrievalChain 
# 用.env文件初始化环境变量 
from dotenv import load_dotenv, find_dotenv 
_ = load_dotenv(find_dotenv())  # read local .env file

文档加载


为了创建一个与pdf文档对话的应用,首先要将pdf文档加载为LangChain可以使用的格式。LangChain提供了文档加载器来完成这件事。LangChain有超过80种不同类型的文档加载器。

文档加载器把各种不同来源的数据格式转换成标准化的格式:Document类,包括page_content(文档内容)和关联的metadata(元数据,如果是pdf的话会包括来源和页码{‘source’: ‘docs/cs229_lectures/MachineLearning-Lecture01.pdf’, ‘page’: 0});如果是其他的文档类型,如Notion则没有页码)

需要先安装pypdf库:! pip install pypdf

# 加载文档 
pdffiles = [ 
    "docs/cs229_lectures/MachineLearning-Lecture01.pdf", 
    "docs/cs229_lectures/MachineLearning-Lecture01.pdf",  # 故意重复以模拟杂乱数据 
    "docs/cs229_lectures/MachineLearning-Lecture02.pdf", 
    "docs/cs229_lectures/MachineLearning-Lecture03.pdf" 
] 
docs = [] 
for file_path in pdffiles: 
    loader=PyPDFLoader(file_path) 
    docs.extend(loader.load()) 
print(f"The number of docs:{len(docs)}") 
# print(docs[0])

这里故意重复加载第一章的pdf,目的是为了演示如何处理重复数据。在实际的工程中,即使经过数据清洗,很多时候也难以避免重复数据。

文档分割


文档已经加载了,但是这些文档仍然相当大,我们需要将加载的文本分割成更小的块,以便进行嵌入和向量存储。这一步很重要,因为我们对文档检索,只需要检索最相关的内容,没必要加载整个巨大的文档,一般只需要得到与主题相关的段落或句子就够了。

这一步看似简单,在实际实现的时候,有很多细节要考虑。

LangChain 中,文本分割器的工作原理如下:将文本分成小的、具有语义意义的块。开始将这些小块组合成一个更大的块,直到达到一定的大小。一旦达到该大小,一个块就形成了,可以开始创建新文本块。这个新文本块和刚刚生成的块要有一些重叠,以保持块之间的上下文。

LangChain 提供的各种文本拆分器可以帮助你从下面几个角度设定你的分割策略和参数:文本如何分割、块的大小chunk_size、块之间重叠文本的长度chunk_overlap。

  • CharacterTextSplitter

CharacterTextSplitter只是简单粗暴的按字符长度来分割,很容易切断语义关联的段落/句子,一般只用来测试。

  • TokenTextSplitter

TokenTextSplitter是按Token的长度来切割,一般用来理解Token的含义和位置。

  • MarkdownHeaderTextSplitter

MarkdownHeaderTextSplitter(Markdown标题文本切割器)适合用来切割markdown格式的文本,可以按标题或子标题来切割md文件,并且把标题/子标题的信息添加到元数据。

  • RecursiveCharacterTextSplitter

对于普通文本,一般使用RecursiveCharacterTextSplitterRecursiveCharacterTextSplitter会根据默认的分隔符["nn", "n", " ", ""]递归地分割文本,通常可以很好地保持段落、句子和单词在一起,语义也相对比较完整。

some_text = """When writing documents, writers will use document structure to group content.  
This can convey to the reader, which idea's are related. For example, closely related ideas  
are in sentances. Similar ideas are in paragraphs. Paragraphs form a document. nn   
Paragraphs are often delimited with a carriage return or two carriage returns.  
Carriage returns are the "backslash n" you see embedded in this string.  
Sentences have a period at the end, but also, have a space. 
and words are separated by space.""" 
r_splitter = RecursiveCharacterTextSplitter( 
    chunk_size=150, 
    chunk_overlap=20, 
) 
r_splitter.split_text(some_text)

这是输出结果:["When writing documents, writers will use document structure to group content. This can convey to the reader, which idea’s are related. For example,", ‘For example, closely related ideas are in sentances. Similar ideas are in paragraphs. Paragraphs form a document.’, ‘Paragraphs are often delimited with a carriage return or two carriage returns. Carriage returns are the "backslash n" you see embedded in this’, ’embedded in this string. Sentences have a period at the end, but also, have a space.and words are separated by space.’]

chunk_size块大小设为了150个字符,实际上由于递归地分割文本,最终文本块的长度会≤150。块之间重叠文本的长度chunk_overlap被设为20,但是实际上也会考虑分隔符,重叠文本长度会≤20,甚至可能没有重叠。在一块的开头保留上一块结尾的一部分内容,有助于保持上下文的连贯性。

对于特殊的文本,如FAQ之类,切割的时候可以强制把问题和答案放在一起,这样检索的时候可以保证能把相关的文本找出来。总的原则是块的语义尽量相关。这个也算某种意义的“高内聚”?

选定了分割器之后,代码很简单:

# 文档分割 
text_splitter = RecursiveCharacterTextSplitter( 
    chunk_size=1500, 
    chunk_overlap=150 
) 
splits = text_splitter.split_documents(docs) 
print(f"The number of splits:{len(splits)}") 
# print(splits[0])

向量库存储


紧接着,我们将这些分割后的文本转换成嵌入的形式,并将其存储在向量数据库中。向量数据库有很多种,比如 Pinecone、Weaviate、Chroma 和 Qdrant,有收费的,也有开源的。在本文的例子中,我们使用了 OpenAIEmbeddings 来生成嵌入,然后使用 Chromadb这个向量数据库来存储嵌入(需要 pip install chromadb)。

什么是嵌入(Embedding)?嵌入就是文本在向量空间中的数字表示。内容相似的文本在向量空间中会有相似的向量值。

# 向量库存储 
embedding = OpenAIEmbeddings() 
persist_directory = 'docs/chroma/' 
# 由于接口限制,每次只能传16个文本块,需要循环分批传入 
for i in range(0, len(splits), 16): 
    batch = splits[i:i+16] 
    vectordb = Chroma.from_documents( 
        documents=batch, 
        embedding=embedding, 
        persist_directory=persist_directory 
    ) 
vectordb.persist() #保存下来,后面可以直接使用 
# 已经保存到向量数据库,从数据库里读取 
vectordb = Chroma(persist_directory=persist_directory, 
                  embedding_function=embedding) 
print(vectordb._collection.count())

吴恩达教授的机器学习pdf文件已经以“文档块嵌入片”的格式被存储在向量数据库里面了。我们只需要查询这个向量数据库,就可以找到大体上相关的信息。

这里我们是通过OpenAIEmbeddings 来手动对文本生成嵌入,再将嵌入保存到向量数据库。也有的向量数据库是创建集合(类似mongodb的集合)的时候指定Embedding 模型,在存储的时候只需要保存纯文本,就会自动将文本对应按照 Embedding 模型的嵌入保存到一个固定的字段Vector。(参考《腾讯向量数据库-连接并写入原始文本》:https://cloud.tencent.com/document/product/1709/98828

检索


当文档存储到向量数据库之后,我们需要根据问题和任务来提取最相关的信息。此时,信息提取的基本方式就是把问题也转换为向量,然后去和向量数据库中的各个向量进行比较,然后选择最相似的前n个分块。最后将这n个最相似的分块与问题一起传递给LLM,就可以得到答案。

# 检索 
metadata_field_info = [ 
    AttributeInfo( 
        name="source", 
        description="The lecture the chunk is from, should be one of `docs/cs229_lectures/MachineLearning-Lecture01.pdf`, `docs/cs229_lectures/MachineLearning-Lecture02.pdf`, or `docs/cs229_lectures/MachineLearning-Lecture03.pdf`", 
        type="string", 
    ), 
    AttributeInfo( 
        name="page", 
        description="The page from the lecture", 
        type="integer", 
    ), 
] 
document_content_description = "Lecture notes" 
llm = AzureChatOpenAI(deployment_name="GPT-4", temperature=0) 
self_query_retriever= SelfQueryRetriever.from_llm( 
    llm, 
    vectordb, 
    document_content_description, 
    metadata_field_info, 
    search_type="mmr", 
    search_kwargs={'k': 5, 'fetch_k': 10}, 
    verbose=True 
) 
compressor = LLMChainExtractor.from_llm(llm) 
compression_retriever = ContextualCompressionRetriever( 
    base_compressor = compressor, 
    base_retriever = self_query_retriever 
) 
memory = ConversationBufferMemory( 
    memory_key="chat_history", 
    return_messages=True 
) 
qa = ConversationalRetrievalChain.from_llm( 
    llm=llm, 
    chain_type="stuff", 
# retriever=retriever, 
    retriever=compression_retriever, 
    memory=memory 
)

我们先定义一个SelfQueryRetriever,这个Retriever命名有点“圣人之道,吾性自足,不假外求”的味道,其实就是调用LLM,利用FewShotPromptTemplate来确定是否要用文档块的元数据来过滤查询到的文档块。如果我们提问“what did they say about regression in the third lecture?”,LLM会加上一个filter:

{
<br></br>
    "query": "regression",
<br></br>
    "filter": "eq("source", "docs/cs229_lectures/MachineLearning-Lecture03.pdf")"
<br></br>
}
<br></br>

self_query_retriever还定义了这两个参数,这两个是一起的:

search_type = "mmr", search_kwargs = {‘k’: 5, ‘fetch_k’: 10},

search_type= “mmr” 是Maximum marginal relevance的缩写,最大边际相关性的意思

fetch_k=10:向量数据库返回10个相关的文档块

k=5:从返回的10个文档块中挑出5个最不相干的文档

很多情况下,search_type设置为mmr会比similarity好,如果设置为similarity,像我们前面故意加载了重复的pdf文件,会出现两个完全一样的文档块,那样既浪费token,得到的信息也不完整。这个其实也是“兼听则明,偏信则暗”的道理,决策前能尽量听取不同角度的意见,最后的决策就不容易出现偏差。

接下来我们定义了一个ContextualCompressionRetriever,通过base_retriever = self_query_retriever ,将它与self_query_retriever 连接起来。ContextualCompressionRetriever的用途是对取到的每一块文本块通过LLM进行总结,去掉无关紧要的废话。这里只是演示,我觉得意义不太大,一次问答就多了几次LLM调用,成本增大了几倍,响应时间也长了不少。

最后,我们通过ConversationalRetrievalChain.from_llm将文档块和用户的问题传给LLM,LLM返回最终的答案。

使用的Prompt类似:

Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer. Use three sentences maximum. Keep the answer as concise as possible. Always say "thanks for asking!" at the end of the answer.

{context}

Question: {question}

Helpful Answer:

文档块的内容会放到{context},用户的问题放到{question}。

chain_type设置为stuff,那么文档块的内容是直接拼接在一起放到{context}。还有 map_reduce、refine、map_rerank等chain_type,可以查询LangChain文档了解具体的使用情景。一般上下文没有超的话,还是用stuff比较合适。

我们还定义了ConversationBufferMemory来记录聊天历史,有了它,文档问答机器人才可以和我们畅聊。

如我们先问“Is probability a class topic?” AI回答:Yes, the context indicates that familiarity with basic probability and statistics is assumed for the class.

接着问:why are those prerequisites needed? 如果直接把这个问题传给向量数据库,向量数据库是给不出答案的。现在有了ConversationBufferMemory,LLM查找历史纪录将问题转为:Why are basic probability and statistics considered prerequisites for the class?

再推荐一次LangChain的smith平台,没有这个平台,没那么容易看清楚这些纷纷扰扰的细节:https://smith.langchain.com/

生成回答并展示


这一步是问答系统应用的主要 UI 交互部分,我们创建一个 Flask 应用(需要安装 Flask 包)接收用户的问题,并生成相应的答案,最后通过 index.html 对答案进行渲染和呈现。

在这一步,我们使用了之前创建的 ConversationalRetrievalChain链来获取相关的文档块和生成答案。然后,将这些信息返回给用户,显示在网页上。

# Output 问答系统的UI实现 
from flask import Flask, request, render_template 
app = Flask(__name__)  # Flask APP 
@app.route('/', methods=['GET', 'POST']) 
def home(): 
    if request.method == 'POST': 
# 接收用户输入作为问题 
        question = request.form.get('question') 
# ConversationalRetrievalChain链 - 读入问题,生成答案 
        result = qa({"question": question}) 
        print(result) 
# 把大模型的回答结果返回网页进行渲染 
        return render_template('index.html', result=result) 
    return render_template('index.html') 
if __name__ == "__main__": 
    app.run(host='0.0.0.0', debug=True, port=5000)

延伸


目前用RAG+LangChain技术来与我们的文档对话还是有点麻烦,OpenAI最新发布的Assistants API大大简化了:只需要上传文档,不需要为您的文档计算和存储嵌入,也不需要实现分块和搜索算法,Assistants API会根据在ChatGPT中构建知识检索的经验,优化使用何种检索技术。

目前Assistants API还处于Beta阶段。后面估计其他大模型厂商也会跟上,推出类似的接口。不过RAG+LangChain还是不能放下,这个处理虽然麻烦,但是可以掌控更多的细节,有更多的应用场景。

阅读原文参与讨论:https://juejin.cn/post/7304946949940609051

参考


  1. 视频:https://learn.deeplearning.ai/langchain-chat-with-your-data
  2. 全部源代码:https://github.com/fireshort/langchain-chat-with-your-data
  3. 腾讯向量数据库-连接并写入原始文本:https://cloud.tencent.com/document/product/1709/98828
  4. https://openai.com/blog/new-models-and-developer-products-announced-at-devday
  5. https://platform.openai.com/docs/assistants/overview
11