基于 RAG+LangChain 实现 ChatPDF 文档对话系统
像 ChatGPT 这样的大语言模型(LLM)可以回答很多类型的问题,但是,如果只依赖 LLM,它只知道训练过的内容,不知道你的私有数据:如公司内部没有联网的企业文档,或者在 LLM 训练完成后新产生的数据。(即使是最新的 GPT-4 Turbo,训练的数据集也只更新到 2023 年 4 月)所以,如果我们开发一个聊天机器人,可以与自己的文档对话,让 LLM 基于文档的信息回答我们的问题,是一件很有意义的事情。
本次我们会基于 RAG 的原理,通过 LangChain 来实现与 pdf 文档对话。
什么是 RAG?
RAG 是 Retrieval-augmented generation(检索增强生成)的简称,它结合了检索和生成的能力,为文本序列生成任务引入额外的外部知识(通常是私有的或者是实时的数据),就是用外部信息来增强 LLM 的知识。RAG 将传统的语言生成模型与大规模的外部知识库相结合,使模型在生成响应或文本时可以动态地从这些知识库中检索相关信息。这种结合方法旨在增强模型的生成能力,使其能够产生更为丰富、准确和有根据的内容,特别适合需要具体细节或外部事实支持的场合。
RAG 一般分为下面几步:
- 检索:对于给定的输入(问题),模型首先使用检索系统从大型文档集合中查找相关的文档或段落。这个检索系统通常基于密集向量搜索。
- 上下文编码:找到相关的文档或段落后,模型将它们与原始输入(问题)一起放到 Prompt 里。
- 生成:使用编码的上下文信息,模型生成输出(答案)。这通常通过大模型完成。
使用 LangChain 实现
RAG 看起来还是比较抽象,我们接下来会用 LangChain 实现,可以细分为下面 5 步:
- Document Loading:文档加载器把 Documents 加载为以 LangChain 能够读取的形式。
- Splitting:文本分割器把 Documents 切分为指定大小的、语义上有意义的块,一般称为'文档块'或者'文档片'。
- Storage:将上一步中分割好的'文档块'以'嵌入'(Embedding)的形式存储到向量数据库(Vector DB)中,形成一个个的'嵌入片'。
- Retrieval:应用程序从存储中检索分割后的文档(例如通过比较余弦相似度,找到与输入问题类似的嵌入片)。
- Output:把问题和相似的文档块传递给语言模型(LLM),使用包含问题、检索到的文档块的提示生成答案。
注意,最新版的 openai 库与当前的 LangChain 不兼容,要安装 0.28.1 版的 openai 库。
!pip install openai==0.28.1
要先用.env 文件来初始化环境变量。
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
from dotenv import load_dotenv, find_dotenv
_ = load_dotenv(find_dotenv())
文档加载
为了创建一个与 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)}")
这里故意重复加载第一章的 pdf,目的是为了演示如何处理重复数据。在实际的工程中,即使经过数据清洗,很多时候也难以避免重复数据。
文档分割
文档已经加载了,但是这些文档仍然相当大,我们需要将加载的文本分割成更小的块,以便进行嵌入和向量存储。这一步很重要,因为我们对文档检索,只需要检索最相关的内容,没必要加载整个巨大的文档,一般只需要得到与主题相关的段落或句子就够了。
这一步看似简单,在实际实现的时候,有很多细节要考虑。
LangChain 中,文本分割器的工作原理如下:将文本分成小的、具有语义意义的块。开始将这些小块组合成一个更大的块,直到达到一定的大小。一旦达到该大小,一个块就形成了,可以开始创建新文本块。这个新文本块和刚刚生成的块要有一些重叠,以保持块之间的上下文。
LangChain 提供的各种文本拆分器可以帮助你从下面几个角度设定你的分割策略和参数:文本如何分割、块的大小 chunk_size、块之间重叠文本的长度 chunk_overlap。
CharacterTextSplitter:只是简单粗暴的按字符长度来分割,很容易切断语义关联的段落/句子,一般只用来测试。
TokenTextSplitter:是按 Token 的长度来切割,一般用来理解 Token 的含义和位置。
MarkdownHeaderTextSplitter:适合用来切割 markdown 格式的文本,可以按标题或子标题来切割 md 文件,并且把标题/子标题的信息添加到元数据。
RecursiveCharacterTextSplitter:对于普通文本,一般使用 RecursiveCharacterTextSplitter。它会递归地分割文本,通常可以很好地保持段落、句子和单词在一起,语义也相对比较完整。
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. \n\n \
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,甚至可能没有重叠。在一块的开头保留上一块结尾的一部分内容,有助于保持上下文的连贯性。
选定了分割器之后,代码很简单:
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1500,
chunk_overlap=150
)
splits = text_splitter.split_documents(docs)
print(f"The number of splits:{len(splits)}")
向量库存储
紧接着,我们将这些分割后的文本转换成嵌入的形式,并将其存储在向量数据库中。向量数据库有很多种,比如 Pinecone、Weaviate、Chroma 和 Qdrant,有收费的,也有开源的。在本文的例子中,我们使用了 OpenAIEmbeddings 来生成嵌入,然后使用 Chromadb 这个向量数据库来存储嵌入(需要 pip install chromadb)。
什么是嵌入(Embedding)?嵌入就是文本在向量空间中的数字表示。内容相似的文本在向量空间中会有相似的向量值。
embedding = OpenAIEmbeddings()
persist_directory = 'docs/chroma/'
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 文件已经以'文档块嵌入片'的格式被存储在向量数据库里面了。我们只需要查询这个向量数据库,就可以找到大体上相关的信息。
检索
当文档存储到向量数据库之后,我们需要根据问题和任务来提取最相关的信息。此时,信息提取的基本方式就是把问题也转换为向量,然后去和向量数据库中的各个向量进行比较,然后选择最相似的前 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=compression_retriever,
memory=memory
)
我们先定义一个 SelfQueryRetriever,这个 Retriever 命名有点'圣人之道,吾性自足,不假外求'的味道,其实就是调用 LLM,利用 FewShotPromptTemplate 来确定是否要用文档块的元数据来过滤查询到的文档块。如果我们提问"what did they say about regression in the third lecture?",LLM 会加上一个 filter:
{
"query": "regression",
"filter": "eq(\"source\", \"docs/cs229_lectures/MachineLearning-Lecture03.pdf\")"
}
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?
生成回答并展示
这一步是问答系统应用的主要 UI 交互部分,我们创建一个 Flask 应用(需要安装 Flask 包)接收用户的问题,并生成相应的答案,最后通过 index.html 对答案进行渲染和呈现。
在这一步,我们使用了之前创建的 ConversationalRetrievalChain 链来获取相关的文档块和生成答案。然后,将这些信息返回给用户,显示在网页上。
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def home():
if request.method == 'POST':
question = request.form.get('question')
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 还是不能放下,这个处理虽然麻烦,但是可以掌控更多的细节,有更多的应用场景。
总结:
本文详细介绍了基于 RAG 架构和 LangChain 框架构建 PDF 文档对话系统的核心流程。通过文档加载、文本分割、向量化存储、智能检索及生成回答五个关键步骤,实现了私有数据的问答能力。文中重点讲解了 RecursiveCharacterTextSplitter 的分割策略、Chroma 向量数据库的持久化操作、SelfQueryRetriever 的元数据过滤机制以及 ConversationalRetrievalChain 的会话记忆功能。此外,还提供了基于 Flask 的简易 Web 应用示例,展示了如何将后端逻辑与前端交互结合。尽管 Assistants API 提供了更便捷的方案,但掌握 RAG+LangChain 的基础原理对于深入理解大模型应用开发、处理复杂场景及定制化需求依然至关重要。开发者可根据实际需求选择合适的技术栈,平衡开发效率与系统可控性。