构建一个 RAG 应用


一、概述

 LLM 支持的最强大的应用程序之一是 复杂的问答 (Q&A) 聊天机器人。这些应用程序可以回答有关特定源信息的问题。这些应用程序使用一种称为 检索增强生成 (RAG) 的技术。

 这里将展示如何 基于文本数据源 构建一个简单的问答应用程序。在此过程中,我们将介绍典型的问答架构,并重点介绍更多高级问答技术的资源。

 我们还将了解 LangSmith 如何帮助我们跟踪和理解我们的应用程序。随着我们的应用程序变得越来越复杂,LangSmith 将变得越来越有用。


二、什么是 RAG

 RAG 是一种利用附加数据增强 LLM 知识的技术。
 LLM 可以推理广泛的主题,但他们的知识 仅限于 他们接受训练的 特定时间点公共数据。如果想构建能够推理 私有数据模型截止日期后引入的数据 的 AI 应用程序,则需要使用模型所需的特定信息来增强模型的知识。将适当的信息引入模型提示的过程称为 检索增强生成 (RAG)
 LangChain 有许多组件,旨在帮助构建问答应用程序以及更广泛的 RAG 应用程序。

 注意:这里重点介绍非结构化数据的问答。如果您对结构化数据的 RAG 感兴趣,请查看 关于通过 SQL 数据进行问答的教程


三、概念

 一个典型的 RAG 应用包含两个主要的部分:

  • Indexing (索引):从源中提取数据并对其进行索引的管道。这通常是离线进行的。
  • Retrieval 和 generation (检索和生成):实际的 RAG 链,它在运行时接受用户查询并从索引中检索相关数据,然后将其传递给模型。

从原始数据到答案的最常见完整序列如下所示:
在这里插入图片描述
在这里插入图片描述

1、Indexing

  1. Load (加载):首先我们需要加载数据。这可以通过 DocumentLoaders 完成。
  2. Split (拆分):Text splitters 文本拆分器 将大型文档拆分成较小的块。这对于索引数据和将其传递给模型都很有用,因为大块数据更难搜索,并且不适合模型的有限上下文窗口。
  3. Store (存储):我们需要一个地方来存储和索引我们的拆分,以便以后可以搜索它们。这通常使用 VectorStoreEmbeddings 模型来完成。

2、Retrieval and generation

  1. Retrieval (检索):给定用户输入,使用检索器从存储中检索相关分割。
  2. Generation (生成):ChatModel/LLM 使用 包含问题和检索到的数据的 提示 生成 答案

四、设置

1、Jupyter Notebook

 Jupyter 笔记本非常适合学习如何使用 LLM 系统,因为很多时候可能会出错 (意外输出、API 故障等),而在交互式环境中阅读指南是更好地理解它们的好方法。

2、下载安装 langchain

 要安装 LangChain,请运行:

pip install langchain # 在python 中

3、LangSmith

点击跳转至 LangSmith 的使用教程


五、预览

 在本指南中,我们将在网站上构建一个 QA 应用程序。我们将使用的特定网站是 Lilian Weng 撰写的 LLM Powered Autonomous Agents 博客文章,它允许我们针对帖子的内容提出问题。

 我们可以创建一个简单的 索引管道RAG链

1、安装一些必要的包

pip install -qU langchain-openai
pip install langchain-chroma
pip install langchain_community
pip install langchainhub
pip install bs4
pip install langchain-huggingface
pip install faiss-cpu

2、import 以及一些环境

import os
# import openai
import bs4
# from langchain_openai import ChatOpenAI
from langchain import hub
from langchain_chroma import Chroma
from langchain_community.document_loaders import WebBaseLoader
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
# from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
# from langsmith.wrappers import wrap_openai
from langsmith import traceable
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.llms import Ollama

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
os.environ["LANGCHAIN_API_KEY"] = "<langsmith api key>" # 这里的 your-api-key 就是上一步获得的 api key
os.environ["LANGCHAIN_PROJECT"] = "<project name>" # 这里输入在langsmith中创建的项目的名字
# The below examples use the OpenAI API, though it's not necessary in general
# os.environ["OPENAI_API_KEY"] = "<openai api key>" # 这里需要 openai 的 api key

3、Load

# load
# Only keep post title, headers, and content from the full HTML.
bs4_strainer = bs4.SoupStrainer(class_=("post-title", "post-header", "post-content"))
loader = WebBaseLoader(
    web_paths=("https://lilianweng.github.io/posts/2023-06-23-agent/",),
    bs_kwargs={"parse_only": bs4_strainer},
)
docs = loader.load()

4、Split

# split
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=200, add_start_index=True
)
all_splits = text_splitter.split_documents(docs)

5、Embedding and Store

# embedding and store
embeddings_model = HuggingFaceEmbeddings() # 这是 huggingface 的 embedding 方法,需要科学上网
vectorstore = FAISS.from_documents(documents=all_splits, embedding=embeddings_model) # 这是FAISS的方法
# embeddings_model = OpenAIEmbeddings()
# vectorstore = Chroma.from_documents(documents=all_splits, embedding=embeddings_model)  # 这是chroma的方法,不知道为什么,这个方法不行

6、Retrieve

# retrieve
retriever = vectorstore.as_retriever(search_type="similarity", search_kwargs={"k": 6})

7、Generate

# generate
llm = Ollama(model="MINICPM-LLama3-V2.5:latest")

prompt = hub.pull("rlm/rag-prompt")

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs),


rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

QQ = input("请输入你的问题:\n")
for chunk in rag_chain.stream(QQ):
    print(chunk, end="", flush=True)

# 这段代码的功能是删除向量存储中的某个集合
# vectorstore.delete_collection()

六、背景知识

向量存储 (Vector Store)
 向量存储是一种数据结构,用于存储文本数据的向量表示。这些向量表示通常是通过某种嵌入模型,如 OpenAIEmbeddings,将 文本 转换为 固定长度的 数值向量。这些向量表示可以用于各种任务,如文本相似度计算、信息检索等。
Chroma
 Chroma 是一个处理向量存储的库,提供了从文档创建向量存储、检索向量等功能。在前面的代码中,我们使用 Chroma 创建了一个向量存储并存储了文档的向量表示。
删除集合
 调用 delete_collection() 方法通常用于在不再需要特定集合或所有向量存储数据时,清理资源。这样做的原因可能是:

  1. 释放存储空间。
  2. 清理不再需要的数据,以便重新创建新的向量存储。
  3. 确保数据安全和隐私,避免数据泄露或误用。

七、详细说明

 现在让我们一步一步更细致地来看上面的代码,以真正了解发生了什么。

1、Indexing —— Load

 我们首先需要加载博客文章内容。我们可以使用 DocumentLoaders 来实现这一点,它们是从源加载数据并返回一系列 Documents 的对象。一个 Document 是一个包含一些 page_content (str) 和 metadata (dict) 的对象
 在这种情况下,我们将使用 WebBaseLoader,它使用 urllib 从 Web URL 加载 HTML,并使用 BeautifulSoup 将其解析为文本。我们可以通过 bs_kwargs 将参数传递给 BeautifulSoup解析器,来自己定义 H T M L → 文本 HTML \rightarrow 文本 HTML文本 的解析,参考 BeautifulSoup docs
 在这种情况下,只有具有“post-content”、“post-title”、“post-header” 类的 HTML标签 是相关的,因此我们将删除所有其他标签。

# Only keep post title, headers, and content from the full HTML.
bs4_strainer = bs4.SoupStrainer(class_=("post-title", "post-header", "post-content"))
loader = WebBaseLoader(
    web_paths=("https://lilianweng.github.io/posts/2023-06-23-agent/",),
    bs_kwargs={"parse_only": bs4_strainer},
)
docs = loader.load()
  • 第二行代码,使用 BeautifulSoup 模块中的 SoupStrainer 类创建了一个 bs4_strainer 对象,用于过滤 HTML 元素。这个 bs4_strainer 只选择包含 class 属性为 post-title、post-header 或 post-content 的 HTML 元素。
  • 然后,创建了一个 WebBaseLoader 的实例 loader。这个实例用于从指定的网址加载内容。
    • web_paths 参数是一个包含 URL 的元组,表示要加载的网页路径。在这个例子中,指定的 URL 是 https://lilianweng.github.io/posts/2023-06-23-agent/。
    • bs_kwargs 参数包含传递给 BeautifulSoup 的额外参数。在这个例子中,bs_kwargs 包含 parse_only 关键字参数,它指定了前面定义的 bs4_strainer 对象。这样,只有匹配 bs4_strainer 筛选条件的 HTML 元素才会被解析和加载。
  • 这行代码调用 loader 实例的 load 方法,从指定的网页路径加载并解析内容。结果存储在 docs 变量中。

2、Indexing —— Split

 我们加载的文档长度超过 42k 个字符。这太长了,许多模型的 上下文窗口 (context window) 都放不下。即使对于那些可以在上下文窗口中容纳完整帖子的模型,模型也很难在很长的输入中找到信息。

 为了解决这个问题,我们将文档拆分成块以进行 嵌入和向量存储。这应该有助于我们在运行时 仅检索 博客文章中 最相关的部分

 在本例中,我们将文档拆分为 1000 个字符的块,块之间有 200 个字符的重叠。重叠有助于 降低 将语句与与其相关的重要上下文 分离 的可能性。我们使用 RecursiveCharacterTextSplitter,它将使用常用分隔符 (如换行符) 递归拆分文档,直到每个块的大小合适。这是 针对一般文本用例 的推荐文本拆分器。

 我们设置 add_start_index=True,以便每个分割文档在初始文档中开始的 字符索引 被保存为 元数据属性 “start_index”

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=200, add_start_index=True
)
all_splits = text_splitter.split_documents(docs)
  • 第一部分,创建了一个 RecursiveCharacterTextSplitter 实例,命名为 text_splitter,并配置了以下参数:
    • chunk_size=1000:每个分割片段的最大字符数为1000。
    • chunk_overlap=200:每个片段之间的重叠字符数为200,这有助于确保片段之间的连续性。
    • add_start_index=True:在分割后的每个块中添加起始索引信息,便于后续处理和分析。
  • 第四行代码,调用 text_splitter 实例的 split_documents 方法,将前面加载的文档 docs 分割成较小的块,并将这些块存储在 all_splits 变量中。

3、Idexing —— Embedding and Store

 现在我们需要索引 66 个文本块,以便我们可以在运行时搜索它们。最常见的方法是嵌入每个文档拆分的内容,并将这些嵌入插入到一个 vector database (or vector store) 中。当我们想要搜索我们的拆分时,我们会进行文本搜索查询,嵌入它,然后执行某种 “相似性”搜索,以识别与我们的查询嵌入 最相似的 嵌入的存储拆分。最简单的相似性度量是 余弦相似性 cosine similarity,即我们测量 每对嵌入 (高维向量) 之间角度的余弦

 我们可以在单个命令中使用 FAISS 向量数据库HuggingFaceEmbeddings 模型,嵌入和存储所有文档拆分。

embeddings_model = HuggingFaceEmbeddings()
# vectorstore = Chroma.from_documents(documents=all_splits, embedding=embeddings_model)  # 这是chroma的方法,不知道为什么,这个方法不行
vectorstore = FAISS.from_documents(documents=all_splits, embedding=embeddings_model) # 这是FAISS的方法
  • 第一行代码,创建了一个 HuggingFaceEmbeddings 的实例 embeddings_model,用于将文本块转换为嵌入向量。HuggingFaceEmbeddings 通常使用预训练的语言模型(例如 BERT、GPT 等)来生成文本嵌入。
  • 第二行注释代码,使用 Chroma 向量存储库将文档块和对应的嵌入存储起来,但在执行时遇到了一些问题。
  • 第三行代码,使用 FAISS 向量存储库将文档块和对应的嵌入存储起来。FAISS(Facebook AI Similarity Search)是一个高效的相似性搜索库,广泛用于处理和搜索大规模向量数据。

 这样就完成了管道的索引部分。此时,我们有一个可查询的向量存储,其中包含博客文章的分块内容。给定一个用户问题,理想情况下,我们应该能够返回回答该问题的博客文章片段。

4、Retrieval and generation —— Retrieve

 现在让我们编写实际的应用程序逻辑。我们想要创建一个简单的应用程序,它接受用户问题,搜索与该问题相关的文档,将检索到的文档和初始问题传递给模型,并返回答案。

 首先,我们需要定义搜索文档的逻辑。LangChain 定义了一个 Retriever 接口,它包装了一个索引,可以根据字符串查询返回相关文档。

 最常见的 Retriever 类型是 VectorStoreRetriever,它使用向量存储的 相似性搜索功能 来促进检索。任何 VectorStore 都可以通过 VectorStore.as_retriever() 轻松转换为 Retriever

retriever = vectorstore.as_retriever(search_type="similarity", search_kwargs={"k": 6})
retrieved_docs = retriever.invoke("What are the approaches to Task Decomposition?")
len(retrieved_docs)
print(retrieved_docs[0].page_content)
  • 第一行代码的含义是创建检索器:
    • retriever = vectorstore.as_retriever(…):这行代码通过 vectorstore 向量存储对象调用 as_retriever 方法,创建 一个 检索器对象
    • search_type=“similarity”:指定了检索类型为 相似性搜索。这意味着后续的查询将会寻找与查询文本 相似 的文档。
    • search_kwargs={“k”: 6}:这个参数指定了 搜索的参数k 表示 返回的最相似文档数量,这里设置为 6。
  • 第二行代码是执行检索:
    • retriever.invoke(…):这行代码调用了检索器的 invoke 方法来执行查询。
    • “What are the approaches to Task Decomposition?”:这是要查询的文本。检索器将会根据这个文本找出与之相似的文档。
    • retrieved_docs:这个变量将包含从检索器返回的文档结果列表。
  • 最后两行代码的含义分别是,返回了检索到的文档数量,以及打印了第一个检索到的文档的内容。

5、Retrieval and generation —— Generate

 让我们将所有内容整合成一个链条,该链条接收问题、检索相关文档、构建提示、将其传递给模型并解​​析输出。
 我们将使用 gpt-3.5-turbo OpenAI 聊天模型,但可以替换成任何 LangChain LLM 或 ChatModel。(这里以 openai 为例子展示代码)

llm = Ollama(model="MINICPM-LLama3-V2.5:latest")

prompt = hub.pull("rlm/rag-prompt")

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs),


rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

QQ = input("请输入你的问题:\n")
for chunk in rag_chain.stream(QQ):
    print(chunk, end="", flush=True)

# 这段代码的功能是删除向量存储中的某个集合
# vectorstore.delete_collection()

我们将使用加入了 LangChain prompt hub 的 RAG 提示。

  • 第一行代码,创建了一个 Ollama 的实例 llm,使用的是本地名为 MINICPM-LLama3-V2.5:latest 的模型,用于生成回答。
  • 这行代码从某个 hub 中拉取一个提示模板 rag-prompt,这个模板将用于指导模型生成回答。
  • 第四行代码,创建了一个 format_docs 函数,将输入的文档列表 docs 格式化为一个字符串,每个文档的内容用两个换行符分隔。
  • 这段代码定义了一个 RAG 链 rag_chain,步骤如下:
    • {“context”: retriever | format_docs, “question”: RunnablePassthrough()}: 定义输入,包含两个部分:context 和 question。
      • retriever 是一个检索器,用于检索相关文档。
      • | 是一个链式操作符 (管道操作符),表示将前一个操作的输出作为输入传递给下一个操作,它将 retriever 的输出传递给 format_docs 函数。
      • format_docs 是前面定义的文档格式化函数。
      • RunnablePassthrough() 是一个传递操作,用于直接传递输入的问题,不对其进行任何修改。它确保问题字符串能够原样传递到后续步骤中。
    • | prompt: 使用从 hub 中拉取的提示模板 prompt。
    • | llm: 使用实例 llm 生成回答。
    • | StrOutputParser(): 使用 StrOutputParser 解析生成的输出。
  • 这最后一部分代码,运行 RAG 链,输入问题 “What is Task Decomposition?”。使用 stream 方法逐步生成并输出结果,每个生成的块(chunk)立即打印出来。end=“” 确保输出不换行,flush=True 确保每个块立即显示。

 让我们剖析一下 LCEL 来了解发生了什么。
 首先,每个组件 (retriever、prompt、llm 等) 都是 Runnable 的实例。这意味着它们实现相同的方法,例如 sync 和 async .invoke、.stream 或 .batch,这使得它们更容易连接在一起。它们可以通过 | 运算符 连接到 RunnableSequence (另一个 Runnable)。
 当遇到 | 运算符 时,LangChain 会自动将某些对象转换为 Runnable。在这里,format_docs 被转换为 RunnableLambda,而带有“context” 和 “question” 的字典被转换为 RunnableParallel。细节并不重要,重要的是每个对象都是 Runnable。

 接下来,让我们来追踪一下输入问题如何流经上述 Runnables。

 正如我们上面所见,prompt 的输入应是一个包含键 “context” 和 “question” 的 字典。因此,链的第一个元素构建了 runables,它将根据输入问题计算这两个值:

  1. retriever | format_docs,将问题传递给检索器,生成 Document 对象,然后传递给 format_docs 生成字符串;
  2. RunnablePassthrough(),将不加改变地传递输入的问题。

 然后 chain.invoke(question) 将构建一个格式化的提示,准备进行推理。注意:使用 LCEL 进行开发时,使用这样的子链进行测试是可行的。
 链的最后步骤是 llm (运行推理) 和 StrOutputParser() (仅从 LLM 的输出消息中提取字符串内容)。

 可以通过 LangSmith 跟踪分析此链的各个步骤。

5.1、Built-in chains (LangChain 内置的链)

 如果愿意,LangChain 包含实现上述 LCEL 的便捷函数。我们编写了两个函数:

  • create_stuff_documents_chain 指定如何将检索到的上下文输入到提示和 LLM 中。在这种情况下,我们将“填充”内容到提示中,即我们将包含所有检索到的上下文,而无需任何总结或其他处理。它主要实现我们上面的 rag_chain,以及输入键 context 和 input —— 它使用检索到的上下文和查询生成答案。
  • create_retrieval_chain 添加检索步骤并通过链传播检索到的上下文,将其与最终答案一起提供。它具有输入键 input,并在其输出中包括 input, context 和 answer。
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate

system_prompt = (
    "You are an assistant for question-answering tasks. "
    "Use the following pieces of retrieved context to answer "
    "the question. If you don't know the answer, say that you "
    "don't know. Use three sentences maximum and keep the "
    "answer concise."
    "\n\n"
    "{context}"
)

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        ("human", "{input}"),
    ]
)


question_answer_chain = create_stuff_documents_chain(llm, prompt)
rag_chain = create_retrieval_chain(retriever, question_answer_chain)

response = rag_chain.invoke({"input": "What is Task Decomposition?"})
print(response["answer"])

 在问答应用程序中,向用户展示用于生成答案的来源通常很重要。LangChain 的内置 create_retrieval_chain 将把检索到的源文档传播到“context”键中的输出:

for document in response["context"]:
    print(document)
    print()

5.2、通过 ollama 部署本地 LLMs

 详细内容 另一篇博客

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值