使用LangChain和ChatGPT构建多文档阅读器和聊天机器人
最好的部分?聊天机器人会记住您的聊天记录
这些天有很多人工智能产品问世,可以让你与自己的私人PDF和文档进行交互。但是它们是如何工作的呢?又该如何构建一个呢?事实上,在幕后,这其实相当简单。
让我们开始吧!
我们将从一个简单的聊天机器人开始,它可以与一个文档进行互动,并最终完成一个更高级的聊天机器人,它可以与多个不同的文档和文档类型进行互动,并且保持聊天历史记录,这样你可以在最近的对话上下文中问它问题。
Contents
How Does It Work?
Interacting With a Single PDF
Interacting With a Single PDF Using Embeddings and Vector Stores
Adding Chat History
Interacting With Multiple Documents
Improvements
Summary
它是如何工作的?
在基本层面上,文档聊天机器人如何工作?在其核心上,它与ChatGPT完全相同。在ChatGPT上,您可以将一段文本复制到提示中,然后要求ChatGPT为您总结文本或基于文本生成一些答案。
与单一文档(如PDF、Microsoft Word或文本文件)互动的方式类似。我们从文档中提取所有文本,将其传入语言模型(LLM)提示中,例如ChatGPT,然后根据文本提问。这与上面的ChatGPT示例相同。
与多个文件互动
当文档非常庞大或者我们想要与多个文档进行交互时,情况就会变得更加有趣。由于LLM(大型语言模型)的请求通常有大小(令牌)限制,因此将所有这些文档的信息传递给LLM是不可能的,如果我们尝试传递太多信息的话,只会导致请求失败。
我们只能将相关信息发送给LLM提示来解决这个问题。但是我们如何从我们的文档中获取只有相关信息呢?这就是嵌入和向量存储的作用所在。
嵌入和向量存储
我们希望能够从我们的文档中发送只与LLM提示相关的信息。嵌入和向量存储可以帮助我们实现这一点。
嵌入可能会让你感到有点困惑,如果你之前没有听说过它们,那么刚开始时不要担心它们似乎有点陌生。稍微解释一下,并将它们作为我们设置的一部分使用,应该能帮助你更清楚地理解它们的使用方式。
嵌入允许我们根据语义意义来组织和分类文本。因此,我们将文档分割成许多小的文本块,并使用嵌入来描述每个文本块的语义意义。嵌入转换器用于将文本片段转换为嵌入形式。
嵌入是通过给文本赋予一个向量(坐标)表示来对其进行分类。这意味着靠近彼此的向量(坐标)表示具有类似含义的信息。嵌入向量与对应嵌入的文本块一起存储在向量存储器中。
一旦我们有了提示,我们可以使用嵌入式转换器与与其在语义上最相关的文本片段进行匹配,以便我们知道一种将提示与向量存储中的其他相关文本片段进行匹配的方法。在我们的情况下,我们使用OpenAI的嵌入式转换器,它使用余弦相似性方法计算文档与问题之间的相似度。
现在我们拥有了与我们的提示相关的较小子集的信息,我们可以查询LLM并将只相关信息作为上下文传递给我们的提示。
这就是我们能够克服LLM提示中大小限制的原因。我们使用嵌入和向量存储来传递与我们的查询相关的相关信息,并让它根据这些信息回传给我们。
所以,在LangChain中,我们如何做到这一点呢?幸运的是,LangChain已经内置了这个功能,并且只需几个简短的方法调用,我们就可以开始了。让我们开始吧!
编码时间!
与单个pdf文件互动
让我们先处理一个pdf文件,稍后我们会处理多个文件。
第一步是从pdf文件创建一个文档。文档是LangChain中使用来与信息互动的基础类。如果我们看一下文档的类定义,它是一个非常简单的类,只有一个page_content方法,允许我们访问文档的文本内容。
class Document(BaseModel):
"""Interface for interacting with a document."""
page_content: str
metadata: dict = Field(default_factory=dict)
我们使用LangChain提供的DocumentLoaders将内容源转换为一份文档列表,每页一个文档。
例如,有一些文档加载器可以用于将PDF、Word文档、文本文件、CSV文件、Reddit、Twitter、Discord等来源转换为文档列表,LangChain链条然后能够处理这些文档。这些都是一些很酷的来源,一旦您设置好了这些基础,就有很多可供玩耍的东西。
首先,让我们为项目创建一个目录。您可以按照我们的步骤一步步创建,或者使用以下命令克隆 GitHub 存储库,其中包含所有示例和样本文档。如果您克隆了存储库,请确保按照 README.md 文件中的说明正确设置您的 OpenAI API 密钥。
git clone git@github.com:smaameri/multi-doc-chatbot.git
否则,如果您想一步一步地跟随:
mkdir multi-doc-chatbot
cd multi-doc-chatbot
touch single-doc.py
mkdir docs
# lets create a virtual environement also to install all packages locally only
python3 -m venv .venv
. .venv/bin/activate
然后从这里下载样本简历 RachelGreenCV.pdf,并将其存储在docs文件夹中。
让我们安装我们设置所需的所有软件包:
pip install langchain pypdf openai chromadb tiktoken docx2txt
现在我们已经设置好项目文件夹,让我们将PDF转换为文档。我们将使用PyPDFLoader类。另外,现在让我们设置好OpenAI API密钥。我们后面会用到它。
import os
from langchain.document_loaders import PyPDFLoader
os.environ["OPENAI_API_KEY"] = "sk-"
pdf_loader = PyPDFLoader('./docs/RachelGreenCV.pdf')
documents = pdf_loader.load()
这将返回一个文档列表,每个PDF页面对应一个文档。在Python类型方面,它将返回一个列表[文档]。因此,列表的索引将对应于文档的页面,例如,documents[0]代表第一页,documents[1]代表第二页,依此类推。
最简单的问答链实现方式是使用load_qa_chain。它可以加载一个链,允许您传入所有您想要查询的文档。
from langchain.llms import OpenAI
from langchain.chains.question_answering import load_qa_chain
# we are specifying that OpenAI is the LLM that we want to use in our chain
chain = load_qa_chain(llm=OpenAI())
query = 'Who is the CV about?'
response = chain.run(input_documents=documents, question=query)
print(response)
现在运行这个脚本以获取响应:
➜ multi-doc-chatbot: python3 single-doc.py
The CV is about Rachel Green.
实际上,在这里背后发生的是,文档的文本(即PDF文本)与查询一起被发送到OpenAPI Chat API,全部作为一个请求。
此外,load_qa_chain实际上在一些文本中包装了整个提示,指示LLM仅使用所提供的上下文信息。因此,发送给OpenAI的提示看起来类似于以下内容:
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.
{context} // i.e the pdf text content
Question: {query} // i.e our actualy query, 'Who is the CV about?'
Helpful Answer:
这就是为什么,如果你尝试问一些随机的问题,比如"巴黎在哪里?",聊天机器人会回答说它不知道。
为了显示发送给LLM的完整提示,您可以在load_qa_chain()方法上设置verbose=True标志,这将在控制台打印出实际发送到提示的所有信息。这有助于了解它在后台是如何工作的,以及实际发送到OpenAI API的提示。
chain = load_qa_chain(llm=OpenAI(), verbose=True)
正如我们在开始时提到的,当我们只需要发送少量信息时,这种方法非常好用。大多数LLM都对可以在单个请求中发送的信息量有限制。因此,我们无法在一个请求中发送所有文档中的信息。
为了克服这个问题,我们需要一种聪明的方法,只发送我们认为与我们的问题/提示相关的信息。
使用嵌入式功能与单个PDF进行互动
嵌入模型拯救!
正如前面所解释的,我们可以使用嵌入和向量存储将只有相关信息发送给我们的提示。我们需要遵循的步骤是:
- 将所有文档分割成小块文本。
- 将每个文本块传递给嵌入式转换器,将其转换为嵌入
- 将嵌入和相关文本存储在矢量存储中
让我们开始吧!
为了开始,请创建一个名为single-long-doc.py的新文件,用来表示此脚本可以用于处理太长而无法作为上下文传递给提示的PDF文件。
touch single-long-doc.py
现在,在文件中添加以下代码。代码中的评论中解释了具体步骤。记得添加你的 API key。
import os
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
os.environ["OPENAI_API_KEY"] = "sk-"
# load the document as before
loader = PyPDFLoader('./docs/RachelGreenCV.pdf')
documents = loader.load()
# we split the data into chunks of 1,000 characters, with an overlap
# of 200 characters between the chunks, which helps to give better results
# and contain the context of the information between chunks
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
documents = text_splitter.split_documents(documents)
# we create our vectorDB, using the OpenAIEmbeddings tranformer to create
# embeddings from our text chunks. We set all the db information to be stored
# inside the ./data directory, so it doesn't clutter up our source files
vectordb = Chroma.from_documents(
documents,
embedding=OpenAIEmbeddings(),
persist_directory='./data'
)
vectordb.persist()
一旦将内容加载为嵌入到向量存储器中,我们回到了与仅有一个PDF进行交互时相似的情境。也就是说,我们现在准备将信息传递到LLM提示中。然而,不同于最初将所有文档作为上下文源传递给链式模型,我们将传入我们的向量存储器作为源,链式模型将根据我们的问题仅检索与之相关的文本,并将该信息仅在LLM提示内部发送。
这次我们将使用RetrievalQA链,它可以将我们的向量存储用作上下文信息的源。
再次,该链条将用一些文字包裹我们的提示,指示它仅使用所提供的信息来回答问题。因此,最终发送给LLM的提示看起来像这样:
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.
{context} // i.e the chunks of text retrieved deemed to be moset semantically
// relevant to our question
Question: {query} // i.e our actualy query
Helpful Answer:
所以,让我们创建一个检索问答链,并向LLM发出一些查询。我们创建检索问答链,将向量存储作为我们的信息源。在幕后,这将只根据提示和存储之间的语义相似性检索相关数据。
请注意,我们在我们的检索器上设置了search_kwargs={'k': 7},这意味着我们希望从我们的向量存储中发送七个文本块到我们的提示。如果超过这个数量,我们将超出OpenAI提示令牌的限制。但是我们拥有的信息越多,我们的答案就越准确,所以我们确实希望尽可能多地发送。本文提供了一些关于调整参数用于文档聊天机器人LLMs的有用信息。
from langchain.chains import RetrievalQA
from langchain.llms import OpenAI
qa_chain = RetrievalQA.from_chain_type(
llm=OpenAI(),
retriever=vectordb.as_retriever(search_kwargs={'k': 7}),
return_source_documents=True
)
# we can now execute queries against our Q&A chain
result = qa_chain({'query': 'Who is the CV about?'})
print(result['result'])
现在运行脚本,您将看到结果。
➜ multi-doc-chatbot python3 single-long-doc.py
Rachel Green.
太棒了!我们现在已经通过嵌入和向量存储使我们的文档阅读器和聊天机器人正常运行!
请注意,我们将向量存储的persist_directory设置为./data。因此,这是向量数据库存储所有信息的位置,包括生成的嵌入向量以及与每个嵌入相关的文本块。
如果你在项目根目录中打开数据目录,你会看到所有的数据库文件都在其中。很酷对吧!就像MySQL或Mongo数据库一样,它有自己的目录来存储所有的信息。
如果您更改存储的代码或文档,并且聊天机器人的回答开始变得奇怪,请尝试删除此目录,它将在下次脚本运行时重新创建。这有时可帮助解决奇怪的回答。
添加聊天记录
现在,如果我们想进一步,我们还可以让我们的聊天机器人记住之前的问题。
按照实施的方式,每次与聊天机器人的互动只需要将我们之前的全部对话记录,包括问题和答案,传递给提示。这是因为LLM没有存储关于我们之前请求的信息的方式,所以我们必须在每次调用LLM时传递全部信息。
幸运的是,LangChain还有一组类让我们可以直接实现这一点。这被称为ConversationalRetrievalChain,它允许我们传入一个额外的参数称为chat_history,其中包含了我们与LLM之前的对话列表。
让我们为此创建一个新的脚本,名为multi-doc-chatbot.py(稍后我们将添加多文档支持😉)。
touch multi-doc-chatbot.py
设置PDF加载器、文本分割器、嵌入和向量存储与之前一样。现在,让我们启动问答链。
from langchain.chains import ConversationalRetrievalChain
from langchain.chat_models import ChatOpenAI
qa_chain = ConversationalRetrievalChain.from_llm(
ChatOpenAI(),
vectordb.as_retriever(search_kwargs={'k': 6}),
return_source_documents=True
)
chain运行命令接受chat_history作为参数。所以首先,让我们通过嵌套标准输入和标准输出命令在终端上启用连续对话。接下来,我们必须根据与LLM的对话手动建立这个列表。chain不会自动完成这个步骤。所以对于每个问题和答案,我们将建立一个名为chat_history的列表,每次都会将其传递回chain运行命令。
import sys
chat_history = []
while True:
# this prints to the terminal, and waits to accept an input from the user
query = input('Prompt: ')
# give us a way to exit the script
if query == "exit" or query == "quit" or query == "q":
print('Exiting')
sys.exit()
# we pass in the query to the LLM, and print out the response. As well as
# our query, the context of semantically relevant information from our
# vector store will be passed in, as well as list of our chat history
result = qa_chain({'question': query, 'chat_history': chat_history})
print('Answer: ' + result['answer'])
# we build up the chat_history list, based on our question and response
# from the LLM, and the script then returns to the start of the loop
# and is again ready to accept user input.
chat_history.append((query, result['answer']))
删除数据目录,以便在下一次运行时重新创建。当您更改链条和代码设置而不删除之前设置中创建的数据时,响应有时可能会表现奇怪。
运行脚本
运行python3 multi-doc-chatbot.py的脚本
并开始与文档互动。请注意,它能够识别先前问题和答案的上下文。您可以提交"exit"或"q"来退出脚本。
multi-doc-chatbot python3 multi-doc-chatbot.py
Prompt: Who is the CV about?
Answer: The CV is about Rachel Green.
Prompt: And their surname only?
Answer: Rachel Greens surname is Green.
Prompt: And first?
Answer: Rachel.
所以,就是这样!我们现在已经构建了一个可以与我们自己的多个文档进行交互并且能维护聊天历史记录的聊天机器人。但是等等,我们现在还只能与一个PDF进行交互,对吗?
与多个文档交互
与多个文档进行交互很简单。如果您还记得,从我们的PDF文档加载器创建的文档只是一个文档列表,即List[Document]。因此,为了增加我们可以交互的文档基础,我们可以将更多文档添加到此列表中。
让我们将更多文件添加到我们的文档文件夹中。您可以从GitHub存储库的文档文件夹中复制剩余的示例文档。现在我们的文档文件夹中应该有一个.pdf、一个.docx和一个.txt文件。
现在我们可以简单地遍历文件夹中的所有文件,并将它们中的信息转化为文档。从那以后,整个流程与之前相同。我们只需将文档列表传递给文本分解器,然后将分块信息传递给嵌入变换器和向量存储。
所以,在我们的情况下,我们希望能够处理pdf文档、Microsoft Word文档和文本文件。我们将遍历docs文件夹,根据文件的扩展名处理文件,使用相应的加载器,并将它们添加到文档列表中,然后将其传递给文本拆分器。
from langchain.document_loaders import Docx2txtLoader
from langchain.document_loaders import TextLoader
documents = []
for file in os.listdir('docs'):
if file.endswith('.pdf'):
pdf_path = './docs/' + file
loader = PyPDFLoader(pdf_path)
documents.extend(loader.load())
elif file.endswith('.docx') or file.endswith('.doc'):
doc_path = './docs/' + file
loader = Docx2txtLoader(doc_path)
documents.extend(loader.load())
elif file.endswith('.txt'):
text_path = './docs/' + file
loader = TextLoader(text_path)
documents.extend(loader.load())
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=10)
chunked_documents = text_splitter.split_documents(documents)
# we now proceed as earlier, passing in the chunked_documents to the
# to the vectorstore
# ...
现在您可以再次运行脚本,并对所有候选人提问。如果在将新文件添加到文档文件夹后删除/data文件夹,似乎会有所帮助。否则,聊天机器人似乎无法获取新的信息。
python3 multi-doc-chatbot.py
所以我们现在有了一个能够与多个文件中的信息进行互动并且能够保存聊天历史记录的聊天机器人。我们可以通过给终端输出添加一些颜色以及处理空字符串输入来增加一些亮点。以下是完整的脚本复制:
import sys
import os
from langchain.document_loaders import PyPDFLoader
from langchain.document_loaders import Docx2txtLoader
from langchain.document_loaders import TextLoader
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.llms import OpenAI
from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.text_splitter import CharacterTextSplitter
from langchain.prompts import PromptTemplate
os.environ["OPENAI_API_KEY"] = "sk-XXX"
documents = []
for file in os.listdir("docs"):
if file.endswith(".pdf"):
pdf_path = "./docs/" + file
loader = PyPDFLoader(pdf_path)
documents.extend(loader.load())
elif file.endswith('.docx') or file.endswith('.doc'):
doc_path = "./docs/" + file
loader = Docx2txtLoader(doc_path)
documents.extend(loader.load())
elif file.endswith('.txt'):
text_path = "./docs/" + file
loader = TextLoader(text_path)
documents.extend(loader.load())
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=10)
documents = text_splitter.split_documents(documents)
vectordb = Chroma.from_documents(documents, embedding=OpenAIEmbeddings(), persist_directory="./data")
vectordb.persist()
pdf_qa = ConversationalRetrievalChain.from_llm(
ChatOpenAI(temperature=0.9, model_name="gpt-3.5-turbo"),
vectordb.as_retriever(search_kwargs={'k': 6}),
return_source_documents=True,
verbose=False
)
yellow = "\033[0;33m"
green = "\033[0;32m"
white = "\033[0;39m"
chat_history = []
print(f"{yellow}---------------------------------------------------------------------------------")
print('Welcome to the DocBot. You are now ready to start interacting with your documents')
print('---------------------------------------------------------------------------------')
while True:
query = input(f"{green}Prompt: ")
if query == "exit" or query == "quit" or query == "q" or query == "f":
print('Exiting')
sys.exit()
if query == '':
continue
result = pdf_qa(
{"question": query, "chat_history": chat_history})
print(f"{white}Answer: " + result["answer"])
chat_history.append((query, result["answer"]))
LangChain资料库
要更深入了解后台发生了什么,我鼓励您下载LangChain源代码,并浏览一下以了解其工作原理。
git clone https://github.com/hwchase17/langchain
如果您使用像PyCharm这样的IDE浏览源代码(我认为社区版是免费的),您可以通过热键点击(CMD + 点击)进入每个方法和类调用,并且它会直接将您带到它们被编写的地方,这对于在代码库中查看事物如何工作非常有用。
改进
当你开始与聊天机器人玩耍,并看到它如何对不同问题进行回答时,你会注意到它只有偶尔给出正确答案。
我们目前的方法确实有一些限制。例如,OpenAI令牌限制为4,096个令牌,这意味着我们不能从向量数据库发送超过大约6-7块文本。这意味着我们甚至可能无法发送来自所有文档的信息,这对于我们想要知道例如我们的简历文档中所有人员的姓名是很重要的。例如,其中一个文档可能被完全忽略了,这样我们就会错过一个关键的信息片段。
而我们在这里只有三个文档。想象一下拥有100个的情况。在某些情况下,仅仅4096个令牌限制将无法给我们提供准确的答案。也许你需要使用不同的LLM,例如不是OpenAI的那个,你可以有更高的令牌限制,这样你可以发送更多的上下文。这些日子你听到的一个功能是拥有更大令牌限制的LLM。
如果文档源大小过大,也许训练LLM模型是一种选择,而不是通过提示上下文发送信息。或者也许可以通过串联参数或向量存储检索技术进行一些其他智能调整。也许聪明的提示工程或某种递归查找代理是可行的方法。本文提供了一些关于如何调整提示以获得更好回答的想法。
很可能会是所有这些的结合,答案也可能因您希望解析的文档类型而有所不同。例如,如果您选择专注于特定文档类型,例如简历、用户手册或网站爬取,可能会有一些针对特定类型内容更适合的优化方案。
总体而言,要获得一个功能良好的多文档阅读器,我认为你需要超越仅仅让它运行起来的表面部分,并开始研究一些增强功能,使其成为一个更具能力和有用的聊天机器人。
摘要
所以,就是这样。我们构建了一个单文档的聊天机器人,并且完成了一个多文档的聊天机器人,它可以记忆我们的聊天历史。希望本文有助于消除一些有关嵌入、向量存储、链和向量存储检索器中参数调整的神秘感。
我在这里写了另一篇关于建立 YouTube 视频聊天机器人的文章。这些概念与此文章非常相似,只是我们不再使用文本文档(例如 PDF)作为内容的来源,而是使用 YouTube 视频的转录,这样我们就可以与视频进行互动并提出问题。
希望这能对你有所帮助。干杯!
为了获取我最新的帖子,请在Medium和Twitter上关注我。