RAG 系统的组成部分

157 阅读24分钟

当你在开发基于检索增强生成(RAG)的应用时,理解每个组件的细节、它们如何集成以及支持这些系统的技术至关重要。 在本章中,我们将讨论以下主题:

  • 关键组件概述
  • 索引
  • 检索与生成
  • 提示词设计
  • 定义你的LLM
  • 用户界面(UI)
  • 评估

这些主题将为你提供关于RAG应用中关键组件的全面理解。

技术要求

本章的代码位于以下GitHub仓库:
github.com/PacktPublis…

关键组件概述

本章深入探讨构成RAG系统的复杂组件。让我们从整个系统的概述开始。
在第一章中,我们从技术角度介绍了RAG系统的三个主要阶段(见图4.1):

  • 索引
  • 检索
  • 生成

image.png

我们将继续构建这个概念,但同时也会引入构建应用程序所需的实际开发方面。这些内容包括提示词设计、定义你的大语言模型(LLM)、用户界面(UI)和评估组件。后续章节将进一步深入探讨这些领域。所有这些都将通过代码实现,这样你可以将我们讨论的概念框架与实际实现直接联系起来。让我们从索引开始。

索引

我们将更详细地研究RAG系统的第一阶段——索引。请注意,我们跳过了设置部分,包括安装和导入包,以及设置OpenAI和相关账户。这是每个生成式人工智能(AI)项目中的典型步骤,不仅仅是RAG系统。我们在第二章提供了详细的设置指南,如果你想回顾我们为支持这些步骤所添加的库,可以回到那里查看。

索引是RAG的第一主要阶段。如图4.2所示,它位于用户查询之后的步骤:

image.png

在我们第二章的代码中,索引是你首先看到的代码部分。这是将你引入RAG系统的数据进行处理的步骤。正如你在代码中看到的,在这个场景中,数据是通过WebBaseLoader加载的网页文档。这是该文档的开头部分(见图4.3):

image.png

在第二章中,你可能注意到,后续阶段的代码——检索和生成——是在用户查询被传递给链条之后执行的。这是在实时进行的,意味着它在用户与系统交互的时刻发生。另一方面,索引通常发生在用户与RAG应用程序交互之前很久。这使得索引与其他两个阶段有很大的不同,它具有在应用程序使用时之外的时间执行的灵活性。这被称为“离线预处理”,意味着此步骤在用户甚至没有打开应用程序之前就完成了。当然,也有一些情况下索引可以在实时进行,但这种情况较少见。目前,我们将重点讨论更常见的离线预处理步骤。

以下是我们的文档提取代码:

loader = WebBaseLoader(
    web_paths=("https://kbourne.github.io/chapter1.html",)
    bs_kwargs=dict(
        parse_only=bs4.SoupStrainer(
            class_=("post-content", "post-title", "post-header")
       )
    ),
)
docs = loader.load()

在这段代码中,我们正在加载一个网页。但试想,如果这段代码是从PDF、Word文档或其他形式的非结构化数据中提取数据呢?正如第三章中讨论的,非结构化数据在RAG应用中非常流行。历史上,相较于结构化数据(来自SQL数据库等应用),非结构化数据对公司来说访问起来非常困难。但RAG改变了这一切,现在公司终于意识到如何有效地利用这些数据。我们将在第十一章回顾如何使用文档加载器访问其他类型的数据,并介绍如何使用LangChain实现。

无论你拉取的数据是什么类型,它都会经过一个类似的处理过程,如图4.4所示:

image.png

代码中的文档加载器填充了文档组件,以便稍后使用用户查询进行检索。但在大多数RAG应用中,你必须将数据转化为一种更易搜索的格式:向量。稍后我们将更详细地讨论向量,但首先,为了将数据转换为向量格式,你必须应用拆分。在我们的代码中,这一部分是:

text_splitter = SemanticChunker(OpenAIEmbeddings())
splits = text_splitter.split_documents(docs)

拆分将你的内容分割成可以向量化的可消化的块。不同的向量化算法对内容的最大大小有不同的要求。在这个例子中,我们使用的是OpenAIEmbeddings()向量化器,它当前的最大输入是8191个token。

注意
在OpenAI API中,文本通过字节级别的字节对编码(BPE)词汇表进行标记化。这意味着原始文本被拆分成子词tokens,而不是单个字符。给定输入文本所消耗的token数量取决于具体内容,因为常见的词汇和子词通常由单个token表示,而不常见的词汇可能会被拆分成多个token。对于英文文本来说,平均一个token大约是四个字符。然而,这只是一个粗略估算,具体文本的情况可能会有所不同。例如,像"a"或"the"这样短的词会是一个token,而一个较长的、不常见的词可能会被拆分成多个token。

这些可消化的块需要小于8191个token的限制,而其他嵌入服务也有其token限制。如果你使用的是定义了块大小和块重叠的拆分器,请记住考虑重叠部分对于token限制的影响。你需要将重叠部分添加到总体块大小中,才能确定该块的实际大小。以下是使用RecursiveCharacterTextSplitter的示例,其中块大小为1000,块重叠为200:

text_splitter = RecursiveCharacterTextSplitter(
   chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(docs)

扩展块重叠是确保块之间不会丢失上下文的常见方法。例如,如果一个块恰好把一个法律文档中的地址一分为二,那么在搜索时,你可能就无法找到这个地址了。但通过块重叠,你可以解决类似的问题。我们将在第十一章中回顾各种拆分器选项,包括LangChain中的递归字符TextSplitter。

索引阶段的最后一步是定义向量存储并将由数据拆分构建的嵌入添加到该向量存储中。在代码中,你可以看到它是这样实现的:

vectorstore = Chroma.from_documents(
                   documents=splits,
                   embedding=OpenAIEmbeddings())
retriever = vectorstore.as_retriever()

在这个例子中,我们使用Chroma DB作为向量数据库(或存储),将拆分后的数据传递给它,并应用OpenAI的嵌入算法。与其他索引步骤一样,所有这些通常是在用户访问应用程序之前离线完成的。这些基于向量的嵌入被存储在该向量数据库中,以供将来查询和检索。Chroma DB只是许多可以在此处使用的数据库之一,OpenAIEmbeddings API也只是许多向量化算法中的一个。我们将在第七章和第八章中详细讨论这个主题,届时我们将探讨向量、向量存储和向量检索。

回到我们的索引过程图,图4.5提供了一个更准确的表示:

image.png

你可能会想知道,为什么我们没有将定义检索器的步骤视为检索阶段的一部分。原因在于,我们正在建立一个检索机制,但直到用户提交查询后,检索才会在检索阶段进行。索引阶段专注于构建其他两个阶段依赖的基础设施,实际上我们是在索引数据,以便稍后可以进行检索。在这部分代码的末尾,你将拥有一个准备好并等待接收用户查询的检索器。接下来,让我们讨论使用该检索器的代码部分——检索和生成步骤!

检索与生成

在我们的RAG应用代码中,我们将检索和生成阶段进行了结合。从图示的角度来看,这看起来像图4.6所示:

image.png

虽然检索和生成是RAG应用中的两个独立阶段,各自承担着重要的功能,但在我们的代码中它们被合并了。当我们调用 rag_chain 作为最后一步时,它会依次经过这两个阶段,因此在讨论代码时,很难将它们分开。但从概念上来说,我们会在这里将它们分开,然后展示它们如何结合起来处理用户查询并提供智能的生成性AI响应。我们先从检索阶段开始。

检索相关步骤

在完整的代码(可在第二章中找到)中,实际的检索操作发生在两个地方。第一个是:

# 后处理
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

第二个则是在RAG链中的第一步:

{"context": retriever | format_docs, "question": RunnablePassthrough()}

当代码启动时,它按以下顺序运行:

rag_chain.invoke("What are the Advantages of using RAG?")

这个链条会使用用户查询并执行我们在链中定义的步骤:

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

在这个链条中,用户的查询会传递给第一个链条环节,它会将查询传递给我们之前定义的检索器,然后进行相似性搜索,将用户查询与向量存储中的其他数据进行匹配。此时,我们已经获取了一份与用户查询在语义上相似的内容字符串列表。

然而,正如第二章所示,由于我们所使用工具的格式问题,检索步骤存在一些小问题。{question}{context} 占位符都期望字符串类型的输入,但我们用于填充context的检索机制返回的是一长串独立的内容字符串。我们需要一个机制将这些内容字符串转换为下一个链条环节期望的字符串格式。

所以,如果仔细观察检索器的代码,你可能会发现检索器实际上处于一个小链条(retriever | format_docs)中,这通过管道符(|)来表示,因此检索器的输出会直接传递到 format_docs 函数:

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

我们可以把这看作检索阶段中的一个后处理步骤。数据已经被检索出来,但还不是我们所需要的格式,因此还没有完成。format_docs 函数完成了这个任务,将内容以正确的格式返回。

然而,这样做只为我们提供了 {context},即输入变量占位符中的一个。我们还需要另一个占位符 {question} 来填充我们的提示(prompt)。幸运的是,对于查询(question)我们没有遇到像context那样的格式问题,因为查询本身就是一个字符串。因此,我们可以使用一个名为 RunnablePassthrough 的便捷对象,正如它的名字所示,它会原样传递输入(即查询)。

如果把整个第一个链条环节看作一个整体,它实际上完成了检索步骤,格式化了输出,并将其整理成正确的格式传递到下一个步骤:

{"context": retriever | format_docs, "question": RunnablePassthrough()}

等等。如果你进行的是向量搜索,应该将用户查询转换为向量吧?我们不是在说我们将用户查询的数学表示与其他向量进行距离比较,找到最相近的吗?那么,这个过程在哪里进行呢?我们创建的检索器来自于向量存储的一个方法:

retriever = vectorstore.as_retriever()

该检索器来源于一个 Chroma 向量数据库,并使用 OpenAIEmbeddings() 作为其嵌入函数:

vectorstore = Chroma.from_documents(
    documents=splits,
    embedding=OpenAIEmbeddings())

.as_retriever() 方法内置了所有功能,可以将用户查询转换为与其他嵌入格式匹配的嵌入,并执行检索过程。

注意
因为使用了 OpenAIEmbeddings() 对象,它会将嵌入数据发送到 OpenAI API,因此会产生费用。在这种情况下,只有一个嵌入;对于OpenAI,目前每百万个令牌的费用是 0.10。所以,对于输入"WhataretheAdvantagesofusingRAG?",根据OpenAI的计算,这只用了10个令牌,费用仅为0.10。所以,对于输入 "What are the Advantages of using RAG?",根据OpenAI的计算,这只用了10个令牌,费用仅为 0.000001。虽然这看起来不多,但我们想在涉及任何费用时做到完全透明!

这就完成了我们的检索阶段,输出的数据已经格式化好,可以传递给下一个步骤——提示(prompt)!接下来,我们将讨论生成阶段,在这一阶段,我们将利用大语言模型(LLM)生成对用户查询的响应。

生成阶段

生成阶段是最后一个阶段,在这个阶段,你将使用LLM根据在检索阶段获取的内容生成对用户查询的响应。但在此之前,我们需要做一些准备工作。让我们一步步来看看。

总体而言,生成阶段由两部分代码组成,首先是提示(prompt):

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

然后是LLM模型:

llm = ChatOpenAI(model_name="gpt-4o", temperature=0)

定义好提示和LLM后,这些组件被用于RAG链中:

| prompt
| llm

请注意,在检索和生成阶段,question 部分都被加粗了。我们已经说明了它在检索阶段的用途,用于作为相似性搜索的基础。现在,我们将展示它如何再次被用于生成提示,并传递给LLM进行生成。

提示词

提示词是任何生成式AI应用中的基础组成部分,不仅仅是RAG。当你开始谈论提示词,特别是在RAG上下文中,你就知道LLM(大语言模型)很快会参与进来。但首先,你必须为我们的LLM创建并准备一个合适的提示词。从理论上讲,你可以自己编写提示词,但我想借此机会教你一个非常常见的开发模式,并让你在需要时习惯使用它。在这个例子中,我们将从LangChain Hub拉取提示词。

LangChain将其Hub描述为一个“发现、分享和版本控制提示词”的地方。其他用户在这里分享了他们打磨过的提示词,这使得你可以基于这些常见的知识进行构建。通过这种方式开始使用提示词,拉取预设计的提示词并查看它们是如何编写的,是一个很好的起点。但最终,你会希望编写自己的、更为定制化的提示词。

我们来谈谈这个提示词在检索过程中的作用。这个“提示词”是我们刚刚讨论的检索阶段之后链中的下一个环节。你可以在 rag_chain 中看到它:

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

遵循LangChain模式,提示词的输入是前一步骤的输出。你可以通过如下方式随时打印出这些输入:

prompt = hub.pull("jclemens24/rag-prompt")
prompt.input_variables

输出结果是:

['context', 'question']

这与我们在前一步定义的相匹配:

{"context": retriever | format_docs,
 "question": RunnablePassthrough()}

通过打印整个提示词对象(使用 print(prompt)),你会发现它不仅仅包含文本提示词和输入变量,还包括更多内容:

input_variables=['context', 'question'] 
messages=[HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['context', 'question'], template="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, just say that you don't know.\nQuestion: {question} \nContext: {context} \nAnswer:"))]

我们进一步解析这些内容。首先是输入变量。这些是我们刚刚讨论过的,这个特定的提示词接收的输入变量。根据不同的提示词,它们可能会有所不同。messages[] 列表中有多个消息,在这个例子中,列表中只有一条消息。这条消息是一个 HumanMessagePromptTemplate 实例,它代表了一种特定类型的消息模板。它通过一个 PromptTemplate 对象初始化。PromptTemplate 对象是通过指定的输入变量和模板字符串创建的。再次提醒,输入变量是 contextquestion,你可以看到它们如何放入模板字符串中:

template="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, just say that you don't know.\nQuestion: {question} \nContext: {context} \nAnswer:"

{question}{context} 占位符将在实际使用提示词时,被从前一步检索阶段中获取的 questioncontext 值替换。这个链环的输出就是被填充了 {question}{context} 的字符串模板。

最后,模板的末尾是 Answer:,没有任何内容。这个部分提示LLM给出一个答案,这是LLM交互中常见的模式,旨在引导模型给出回答。

简而言之,提示词是一个对象,插入到LangChain链中,并通过输入填充提示词模板,生成你将传递给LLM进行推理的提示词。这基本上是RAG系统中的生成阶段的准备步骤。

接下来的步骤是引入LLM,它是整个操作背后的“大脑”!

定义LLM

选择好提示词模板后,我们就可以选择LLM,这是任何RAG应用中的核心组件。下面的代码展示了 rag_chain 中下一个链环的LLM模型:

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

如前所述,前一步的输出(即提示词对象)将作为下一步——LLM的输入。在这个例子中,提示词将直接传递到LLM,与我们在前一步生成的提示词一起。

rag_chain 之前,我们定义了我们想使用的LLM:

llm = ChatOpenAI(model_name="gpt-4o", temperature=0)

这行代码创建了一个 ChatOpenAI 类的实例,来自 langchain_openai 模块,它作为OpenAI语言模型的接口,具体是GPT-4o模型。通常,LLM会通过 invoke 方法接收提示词,你也可以直接在代码中调用它,如下所示:

llm_only = llm.invoke("Answering in less than 100 words, what are the Advantages of using RAG?")
print(llm_only.content)

通过这种方式,你实际上是直接向LLM请求答案。

如果你运行上面的代码,它会给你来自GPT-4o的回答,它知道RAG。但是,作为对比,假设我们将其更改为GPT-3.5呢?这是我使用ChatGPT 3.5时的回答:

RAG (Red, Amber, Green) status reporting allows for clear and straightforward communication of project progress or issues. It helps to quickly identify areas that need attention or improvement, enabling timely decision-making. RAG status also provides a visual representation of project health, making it easy for stakeholders to understand the current situation at a glance. Additionally, using RAG can help prioritize tasks and resources effectively, increasing overall project efficiency and success.

哎呀!ChatGPT 3.5不了解RAG!至少在我们所讨论的上下文中并不了解。这突出了使用RAG的价值,可以通过增加它的数据来增强其知识。ChatGPT 3.5的最新截止日期是2022年1月,生成式AI的RAG概念可能还不够流行,因此它无法立刻理解我们所说的RAG缩写。

通过使用RAG,我们可以增强其知识,并利用LLM的其他技能,如总结和数据查找,以实现更好的整体结果。但如果我们将问题更改为“在100字以内回答,使用RAG的优势是什么?”并使用一个更现代的模型(它的训练数据可能包含更多关于RAG应用的内容),你很可能会得到一个更好的回答,因为LLM训练数据的截止日期更为接近!

但我们不是直接调用LLM,而是传递给它我们通过检索阶段构建的提示词,从而获得一个更为丰富的信息回答。你可以在这里结束链,链的输出就是从LLM返回的结果。在大多数情况下,这不仅仅是你在ChatGPT中输入时看到的文本——它是JSON格式的,并包含了很多其他数据。因此,如果你想要一个漂亮格式的字符串输出,反映LLM的响应,你需要在链中再加一个链环:StrOutputParser()对象。StrOutputParser()是LangChain中的一个实用类,用来将语言模型的关键输出解析为字符串格式。它不仅剥离掉了你不想处理的信息,还确保返回的生成响应是一个字符串。

当然,代码的最后一行是启动整个过程的那一行:

rag_chain.invoke("What are the Advantages of using RAG?")

在检索阶段之后,这个用户查询第二次作为提示词输入变量之一,传递到LLM中。在这里,“What are the advantages of using RAG?”就是传入链中的字符串。

如我们在第二章中讨论的那样,未来这个提示词将包括一个来自UI的查询。让我们讨论UI作为RAG系统的另一个重要组件。

UI

为了使这个应用更加专业和可用,您必须为没有代码的普通用户提供一种直接输入查询并查看结果的方式。UI(用户界面)作为用户与系统之间的主要交互点,因此在构建RAG应用时是一个至关重要的组件。高级界面可能包括自然语言理解(NLU)功能,以更准确地解释用户的意图,这是自然语言处理(NLP)的一种形式,专注于语言理解部分。这个组件对于确保用户能够轻松有效地与系统沟通其需求至关重要。

这开始于用UI替换最后一行代码:

rag_chain.invoke("What are the Advantages of using RAG?")

这一行将被用户提交文本问题的输入字段替代,而不是像之前那样直接传入一个字符串。这还包括以更用户友好的界面显示LLM返回的结果,比如在设计精美的屏幕上。在第六章中,我们将通过代码展示这一点,但现在我们先从更高层次讨论如何为您的RAG应用添加界面。

当一个应用加载给用户时,他们将通过某种方式与其交互。这通常通过界面实现,界面可以是从网页上的简单文本输入字段到更复杂的语音识别系统。关键是准确捕捉用户查询的意图,并将其转化为系统可以处理的格式。添加UI的一个明显好处是,它允许用户测试其他查询的结果。用户可以输入任何他们想要的查询并查看结果。

预处理

如我们所讨论的,尽管用户仅在UI中输入了类似“什么是任务分解?”的问题,但在提交该问题之后,通常会进行预处理,使得查询更适合LLM。这主要在提示(prompt)中完成,并且还借助许多其他功能。但所有这些都发生在幕后,用户是看不到这些过程的。用户在这个场景中看到的只是以用户友好的方式显示的最终输出。

后处理

即使LLM返回了响应,这个响应通常也会在显示给用户之前经过后处理。
以下是一个实际LLM输出的例子:

AIMessage(content="The advantages of using RAG include improved accuracy and relevance of responses generated by large language models, customization and flexibility in responses tailored to specific needs, and expanding the model's knowledge beyond the initial training data.")

作为链条中的最后一步,我们通过 StrOutputParser() 解析出纯文本字符串:

'The advantages of using RAG (Retrieval Augmented Generation) include improved accuracy and relevance, customization, flexibility, and expanding the model's knowledge beyond the training data. This means that RAG can significantly enhance the accuracy and relevance of responses generated by large language models, tailor responses to specific needs, and access and utilize information not included in initial training sets, making the models more versatile and adaptable.'

这显然比前一步的输出要好,但它仍然是在您的笔记本中显示的。在一个更专业的应用中,您会希望将其以用户友好的方式显示在屏幕上。您可能还希望显示其他信息,比如我们在第三章代码中展示的源文档。这将取决于您的应用目的,并且在不同的RAG系统之间会有显著差异。

输出界面

对于完整的UI,这个字符串将传递给界面,界面显示返回到链中的消息。这个界面可以非常简单,就像您在图4.7中看到的ChatGPT界面那样。

image.png

你还可以构建一个更强大的系统,更适合你的目标用户群体。如果目标是让系统更具对话性,界面也应该设计成促进进一步互动的方式。你可以为用户提供选项来细化查询、提出后续问题或请求更多信息。

UI中的另一个常见功能是收集关于响应的有用性和准确性的反馈。这可以用来持续改进系统的性能。通过分析用户交互和反馈,系统可以学习更好地理解用户意图,优化向量搜索过程,并提高生成响应的相关性和质量。这将引导我们进入最后一个关键组件:评估。

评估

评估组件对于评估和改进RAG系统的性能至关重要。尽管有许多常见的评估实践,但最有效的评估系统将侧重于对用户最重要的内容,并提供改进这些功能和能力的评估。通常,这涉及使用各种度量标准来分析系统的输出,如准确性、相关性、响应时间和用户满意度。这些反馈用于识别需要改进的领域,并指导系统设计、数据处理和LLM集成方面的调整。持续评估对于保持高质量响应并确保系统有效满足用户需求至关重要。

如前所述,你还可以通过多种方式收集用户反馈,包括定性数据(带开放性问题的输入表单)或定量数据(是/否、评分或其他数字化表示)来评估响应的有用性和准确性。点赞/点踩通常用于快速获取用户反馈,并衡量应用在多个用户中的整体效果。

我们将在第十章中深入探讨如何将评估纳入你的代码。

总结

本章没有提供RAG系统的所有组件的详尽列表。然而,这些组件是每个成功的RAG系统中常见的组成部分。请记住,RAG系统是不断发展的,新的组件每天都在出现。你RAG系统的关键部分应该是添加能够提供用户所需功能的组件。这可能非常具体,取决于你的项目,但通常是公司业务的直观衍生。

本章提供了构建成功RAG系统的关键组件的全面概述,探讨了三个主要阶段:索引、检索和生成,并解释了这些阶段如何协同工作,以提供增强的响应。

除了核心阶段,本章还强调了UI和评估组件的重要性。UI作为用户与RAG系统之间的主要交互点,允许用户输入查询并查看生成的响应。评估对于评估和改进RAG系统的性能至关重要,这涉及使用各种度量标准分析系统的输出并收集用户反馈。持续评估有助于识别改进的领域,并指导系统设计、数据处理和LLM集成方面的调整。

尽管本章讨论的组件并非详尽无遗,但它们构成了大多数成功RAG系统的基础。

然而,每个RAG系统中有一个非常重要的方面是我们在本章中没有涉及的:安全性。我们将用整整一章的内容来讨论安全性的关键方面,特别是与RAG相关的内容。

参考资料

LangChain的提示中心信息:docs.smith.langchain.com/old/categor…