使用LangChain从RAG中获取更多收益

278 阅读28分钟

我们已经多次提到LangChain,并向您展示了很多LangChain代码,包括实现LangChain特定语言的代码:LangChain表达式语言(LCEL)。现在,您已经熟悉了使用LangChain实现检索增强生成(RAG)的方法,我们认为现在是时候深入探讨LangChain的各种功能,以便优化您的RAG管道。

在本章中,我们将探索LangChain中一些较少为人所知,但对增强RAG应用非常重要的组件。我们将涵盖以下内容:

  • 文档加载器:用于加载和处理来自不同来源的文档。
  • 文本拆分器:用于将文档分割成适合检索的块。
  • 输出解析器:用于结构化语言模型的响应。

我们将通过不同的代码实验室逐步演示每种组件的使用,从文档加载器开始。

技术要求

本章的代码已放置在以下GitHub仓库中:GitHub仓库链接。每个代码实验室的文件名会在各自的部分中提到。

代码实验室 11.1 – 文档加载器

您需要从GitHub仓库访问的文件名为 CHAPTER11-1_DOCUMENT_LOADERS.ipynb

文档加载器在访问、提取和加载数据,使得我们的RAG应用能够正常运行中起着关键作用。文档加载器用于从各种来源(如文本文件、PDF、网页或数据库)加载和处理文档,并将文档转换为适合索引和检索的格式。

让我们安装一些新的包来支持我们的文档加载,这些包与不同的文件格式相关:

%pip install bs4
%pip install python-docx
%pip install docx2txt
%pip install jq

第一个包 bs4(即Beautiful Soup 4)可能您已经很熟悉,我们在第二章中曾用它解析HTML。我们还安装了几个与Microsoft Word相关的包,如 python_docx,它帮助创建和更新Microsoft Word(.docx)文件,以及 docx2txt,它从.docx文件中提取文本和图像。jq包是一个轻量级的JSON处理器。

接下来,我们将进行一个额外的步骤,这是您在实际情况中可能不会执行的,即将我们的PDF文档转换成其他格式,以便我们测试提取这些格式的内容。我们将在OpenAI设置代码后添加一个全新的文档加载器部分。

在这一部分中,我们将提供生成文件的代码,然后是不同的文档加载器及其相关包,用于从这些文件类型中提取数据。目前,我们有一个PDF版本的文档。我们还需要一个HTML/网页版本、一个Microsoft Word版本以及一个JSON版本的文档。

我们将从OpenAI设置单元下方的一个新单元开始,导入转换所需的新包:

from bs4 import BeautifulSoup
import docx
import json

正如我们提到的,BeautifulSoup包帮助我们解析基于HTML的网页。我们还导入了docx,它代表Microsoft Docx文档处理格式。最后,我们导入了json来解析和处理JSON格式的代码。

接下来,我们将定义我们将为每种格式保存的文件名:

pdf_path = "google-2023-environmental-report.pdf"
html_path = "google-2023-environmental-report.html"
word_path = "google-2023-environmental-report.docx"
json_path = "google-2023-environmental-report.json"

在这里,我们为每个文件定义了路径,这些文件将在后续使用加载器时加载。它们将是我们从原始PDF文档生成的最终文件。

接下来,这段关键的代码将提取PDF中的文本,并使用这些文本生成所有这些新的文件类型:

with open(pdf_path, "rb") as pdf_file:
    pdf_reader = PdfReader(pdf_file)
    pdf_text = "".join(
        page.extract_text() for page in pdf_reader.pages)
    soup = BeautifulSoup("<html><body></body></html>", "html.parser")
    soup.body.append(pdf_text)
    with open(html_path, "w", encoding="utf-8") as html_file:
        html_file.write(str(soup))
          doc = docx.Document()
          doc.add_paragraph(pdf_text)
          doc.save(word_path)
    with open(json_path, "w") as json_file:
        json.dump({"text": pdf_text}, json_file)

我们以非常基础的方式生成了HTML、Word和JSON版本的文档。如果您要在管道中实际使用这些文档,我们建议应用更多的格式化和提取,但对于本次演示,这将为我们提供必要的数据。

接下来,我们将在代码的索引阶段下添加文档加载器。我们已经使用过前两个文档加载器,在本实验室中将展示它们,但做了更新,使它们可以互换使用。对于每个文档加载器,我们将展示该加载器特定的包导入以及加载器代码。在早期章节中,我们使用了直接从网站加载的网页加载器,所以如果您有这种用例,可以参考该文档加载器。与此同时,这里我们分享的是一种稍微不同的文档加载器,它专注于使用本地HTML文件,比如我们刚刚生成的HTML文件。下面是HTML加载器的代码:

from langchain_community.document_loaders import BSHTMLLoader
loader = BSHTMLLoader(html_path)
docs = loader.load()

在这里,我们使用之前定义的HTML文件加载HTML文档中的代码。最终变量 docs 可以与我们在后续文档加载器中定义的任何其他 docs 互换使用。这段代码的工作原理是,您一次只能使用一个加载器,它将替换 docs 为其版本的 docs(包括文档来源的元数据标签)。如果您运行这个单元,然后跳到拆分单元,接下来的代码可以运行,并且您将看到来自不同文件类型的相同数据。稍后我们会在代码中做一点小更新,稍后会提到。

LangChain网站上列出了其他一些HTML加载器,您可以在这里查看:HTML文档加载器

接下来,我们讨论的文件类型是我们已经处理过的PDF:

from PyPDF2 import PdfReader
docs = []
with open(pdf_path, "rb") as pdf_file:
    pdf_reader = PdfReader(pdf_file)
    pdf_text = "".join(page.extract_text() for page in pdf_reader.pages)
    docs = [Document(page_content=page) for page in pdf_text.split("\n\n")]

在这里,我们使用了一个更简洁的版本来提取PDF中的数据。使用这种新方法可以访问数据的另一种方式,但无论哪种方法都能在您的代码中有效,最终使用PdfReader从PyPDF2中提取数据,将结果加载到 docs 变量中。

需要注意的是,LangChain支持许多流行的PDF提取工具的集成,这使得加载PDF文档的方式多种多样。以下是一些集成:PyPDF2(我们这里使用的)、PyPDF、PyMuPDF、MathPix、Unstructured、AzureAIDocumentIntelligenceLoader和UpstageLayoutAnalysisLoader。

我们建议您查看最新的PDF文档加载器列表。LangChain提供了有关这些加载器的教程,您可以在这里查看:PDF文档加载器

接下来,我们将加载来自Microsoft Word文档的数据:

from langchain_community.document_loaders import Docx2txtLoader
loader = Docx2txtLoader(word_path)
docs = loader.load()

这段代码使用LangChain的Docx2txtLoader文档加载器,将我们之前生成的Word文档转换为文本,并将其加载到 docs 变量中,稍后可以由拆分器使用。再次强调,运行剩余的代码将与HTML或PDF文档相同的方式工作。有很多加载Word文档的选项,您可以在这里找到它们:Microsoft Word文档加载器

最后,我们看到类似的方法用于JSON加载器:

from langchain_community.document_loaders import JSONLoader
loader = JSONLoader(
    file_path=json_path,
    jq_schema='.text',
)
docs = loader.load()

在这里,我们使用JSON加载器加载以JSON对象格式存储的数据,但结果是一样的:生成的 docs 变量可以传递给拆分器,并转换为我们在剩余代码中使用的格式。其他JSON加载器的选项可以在这里找到:JSON文档加载器

请注意,一些文档加载器会在生成的Document对象的元数据字典中添加额外的元数据。这会导致我们的代码在添加自己的元数据时出现一些问题。为了解决这个问题,我们在索引和创建向量存储时更新了以下行:

dense_documents = [Document(page_content=doc.page_content,
    metadata={"id": str(i), "search_source": "dense"}) for
        i, doc in enumerate(splits)]
sparse_documents = [Document(page_content=doc.page_content,
    metadata={"id": str(i), "search_source": "sparse"}) for
        i, doc in enumerate(splits)]

我们还在最终输出的代码中进行更新,以测试响应,修改代码的第二行以处理更改后的元数据标签:

for i, doc in enumerate(retrieved_docs, start=1):
    print(f"Document {i}: Document ID: {doc.metadata['id']}")
    print(f"source: {doc.metadata['source']}")
    print(f"Content:\n{doc.page_content}\n")

运行每个加载器,然后运行剩余的代码,看看每个文档的实际效果!LangChain中有许多不同的文档加载器,每个都为不同的文档类型提供支持。如果您想了解有关如何从不同的源加载文档的更多信息,可以参考LangChain文档中的其他详细内容。

代码实验室 11.2 – 文本分割器

您需要从GitHub仓库访问的文件名为 CHAPTER11-2_TEXT_SPLITTERS.ipynb

文本分割器将文档分割成可以用于检索的块。较大的文档会对我们的RAG应用的许多部分构成威胁,而文本分割器是我们的第一道防线。如果您能够向量化一个非常大的文档,文档越大,您在向量嵌入中丧失的上下文表示就越多。但这也假设您能成功地向量化一个非常大的文档,而实际情况是,您通常做不到!大多数嵌入模型对于我们可以传递的文档大小都有相对较小的限制,而这些文档的规模往往比我们通常处理的大文档要小。例如,我们用于生成嵌入的OpenAI模型的上下文长度是8,191个token。如果我们试图将一个比这个长度更长的文档传递给模型,它将生成错误。这些就是分割器存在的主要原因,但这不是引入该步骤过程中的唯一复杂性。

我们需要考虑的文本分割器的关键要素是它们如何分割文本。假设您有100段文字需要分割。在某些情况下,其中可能有两三段是语义上应该放在一起的,比如这一节中的段落。在其他情况下,您可能会遇到节标题、URL或其他类型的文本。理想情况下,您希望将语义相关的文本保持在一起,但这远比看起来要复杂!为了更好地理解这一点,请访问以下网站并复制一大段文本:chunkviz.up.railway.app/

ChunkViz是Greg Kamradt创建的一个工具,帮助您可视化您的文本分割器如何工作。更改分割器的参数,使用我们所使用的参数:块大小为1000,块重叠为200。尝试使用字符分割器与递归字符文本分割器进行比较。请注意,在图11.1中提供的示例中,递归字符分割器以大约434个块大小分别捕获所有段落:

image.png

随着您增加块大小,文本分割器会很好地保持段落的分割,但最终每个块会包含越来越多的段落。不过需要注意的是,这对于不同的文本会有所不同。如果您的文本中有非常长的段落,您需要设置更大的块大小来捕获完整的段落。

与此同时,如果您尝试使用字符分割器,它将在任何设置下都可能在句子的中间进行切割:

image.png

句子的这种分割可能会显著影响块捕捉文本中所有重要语义的能力。你可以通过改变块重叠度来弥补这一点,但你仍然会得到部分段落,这会导致噪声,干扰LLM生成最优的回应。

接下来,我们将通过实际的编码示例逐步了解可用的一些选项。

字符文本分割器

这是分割文档最简单的方法。文本分割器可以将文本划分为任意大小的N字符块。通过添加分隔符参数(例如 \n),你可以稍微改善这一点。但这是理解文本分块如何工作的一个很好的起点,之后我们可以探索更有效但复杂的方法。

下面是使用 CharacterTextSplitter 对象处理我们的文档的代码,它可以与其他分割器的输出互换使用:

from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
    separator="\n",
    chunk_size=1000,
    chunk_overlap=200,
    is_separator_regex=False,
)
splits = text_splitter.split_documents(docs)

第一次分割(split[0])的输出如下所示:

Document(page_content='Environmental \nReport\n2023What's \ninside\nAbout this report\nGoogle's 2023 Environmental Report provides an overview of our environmental \nsustainability strategy and targets and our annual progress towards them.\u20091  \nThis report features data, performance highlights, and progress against our targets from our 2022 fiscal year (January 1 to December 31, 2022). It also mentions some notable achievements from the first half of 2023. After two years of condensed reporting, we're sharing a deeper dive into our approach in one place.\nADDITIONAL RESOURCES\n• 2023 Environmental Report: Executive Summary\n• Sustainability.google\n• Sustainability reports\n• Sustainability blog\n• Our commitments\n• Alphabet environmental, social, and governance (ESG)\n• About GoogleIntroduction  3\nExecutive letters  4\nHighlights  6\nOur sustainability strategy 7\nTargets and progress summary 8\nEmerging opportunities 9\nEmpowering individuals  12\nOur ambition 13\nOur appr\noach 13\nHelp in\ng people make  14')

可以看到,文档中有许多 \n(换行符)和一些 \u 字符。我们看到它大约以1,000个字符为基础,找到最接近这个长度的 \n 字符,并将其作为第一个分块。这个分割点恰好在句子中间,这可能是个问题!

下一个分块看起来是这样的:

Document(page_content='Highlights  6\nOur sustainability strategy 7\nTargets and progress summary 8\nEmerging opportunities 9\nEmpowering individuals  12\nOur ambition 13\nOur appr\noach 13\nHelp in\ng people make  14 \nmore sustainable choices  \nReducing home energy use 14\nProviding sustainable  \ntrans\nportation options  17 \nShari\nng other actionable information 19\nThe journey ahead  19\nWorking together 20\nOur ambition 21\nOur approach 21\nSupporting partners  22\nInvesting in breakthrough innovation 28\nCreating ecosystems for collaboration  29\nThe journey ahead  30Operating sustainably 31\nOur ambiti\non 32\nOur oper a\ntions  32\nNet-\nzero c\narbon  33\nWater stewardship 49\nCircular econom\ny 55\nNature and biodiversity 67\nSpotlight: Building a more sustainable  \ncam\npus in Mountain View73 \nGovernance and engagement  75\nAbout Google\n 76\nSustainab i\nlity governance  76\nRisk management  77\nStakeholder engagement  78\nPublic policy and advocacy  79\nPartnerships  83\nAwards and recognition  84\nAppendix  85')

正如你所看到的,由于我们设置了200个字符的块重叠,文本分割器回退了一些。然后它从此处向前推1,000个字符,并在另一个 \n 字符处分割。

接下来,我们来看看这些参数的作用:

  • 分隔符 – 根据使用的分隔符,你可能会得到各种不同的结果。对于这个例子,我们使用了 \n,它适用于这份文档。但如果你在这个特定的文档中使用 \n\n(双换行符)作为分隔符,由于文档中没有双换行符,它就不会进行分割!\n\n 实际上是默认的分隔符,因此请确保关注此点,使用适合你的内容的分隔符!
  • 块大小 – 这是你希望每个块的字符数。尽管在文本末尾可能会有所变化,但大多数情况下,分块的大小将保持一致。
  • 块重叠 – 这是你希望在每个连续块中重叠的字符数。这是确保你捕获所有上下文的简单方法。例如,如果没有块重叠并且在句子中间进行分割,大多数上下文可能无法很好地出现在任何一个块中。但通过重叠,你可以更好地覆盖这些边缘的上下文。
  • 分隔符是否为正则表达式 – 这是另一个参数,指示分隔符是否为正则表达式格式。

在这个例子中,我们将块大小设置为1000,块重叠为200。通过这段代码,我们的意思是希望使用小于1,000个字符的块,但重叠200个字符。这种重叠技术类似于卷积神经网络(CNN)中看到的滑动窗口技术,其中你将窗口滑过图像的较小部分并重叠,以捕获不同窗口之间的上下文。在这里,我们要捕获的是块之间的上下文。

另外几点需要注意:

  • 文档对象 – 我们使用的是 LangChain 的 Document 对象来存储文本,因此我们使用 create_documents 函数,确保文档在向量化后能够继续工作。如果你只想直接获取文本内容,可以使用 split_text 函数。
  • create_documents 期望的是一个列表create_documents 期望的是文本列表,所以如果你只有一个字符串,需要将其包裹在 [] 中。在我们的例子中,docs 已经是一个列表,所以这个要求已满足。
  • 分割与分块 – 这两个术语可以互换使用。

你可以在 LangChain 官网查看更多关于该文本分割器的信息:LangChain 文档
API 文档链接:CharacterTextSplitter API

不过,我们可以做得更好;接下来我们将看看一种更复杂的方法——递归字符文本分割。

递归字符文本分割器

我们之前已经见过这个!在我们的代码实验中,这个分割器是使用得最多的,因为它是 LangChain 推荐的分割通用文本的工具。这正是我们现在要做的!

顾名思义,这个分割器会递归地分割文本,旨在保持相关的文本片段尽量靠近在一起。你可以传递一个字符列表作为参数,它将尝试按顺序分割这些字符,直到文本块足够小为止。默认的字符列表是 ["\n\n", "\n", " ", ""],它能很好地工作,但我们也将 ". " 加入到列表中。这将尝试将所有段落、由 "\n"". " 定义的句子以及尽可能长的单词保持在一起。

以下是我们的代码:

recursive_splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", ". ", " ", ""],
    chunk_size=1000,
    chunk_overlap=200
)
splits = recursive_splitter.split_documents(docs)

在这个分割器的内部实现中,文本块首先是基于 "\n\n"(段落分割符)进行分割的。但它并不会就此停下来;它会检查块的大小,如果它大于我们设置的 1,000 字符,它就会使用下一个分隔符("\n")来进行分割,以此类推。

让我们来谈谈它是如何递归地工作,把文本分割成多个块。这个递归算法只会在文本的长度超过块大小时应用,流程如下:

  1. 它会找到文本范围 [chunk_size - chunk_overlap, chunk_size] 内的最后一个空格或换行符。这样可以确保文本块在词边界或换行符处分割。
  2. 如果找到了合适的分割点,它会把文本分割成两部分:分割点前的文本和分割点后的剩余文本。
  3. 它会递归地将相同的分割过程应用于剩余的文本,直到所有文本块都满足块大小的限制。

类似于字符分割器,递归分割器也在很大程度上由你设置的块大小驱动,但它结合了之前描述的递归方法,提供了一种直接且合乎逻辑的方式,确保文本上下文能够合理地捕获在文本块中。

RecursiveCharacterTextSplitter 特别适用于处理需要通过具有输入大小限制的语言模型处理的大型文本文档。通过将文本分割成较小的块,你可以单独将这些块输入语言模型,若需要的话,再将结果合并。

显然,递归分割器比字符分割器更进一步,但它仍然没有完全基于语义进行分割,只是依赖于诸如段落和句子分隔符等常规分隔符。不过,这种方法并不能处理那些由多个段落组成、在语义上是一个持续思想的情况,而这些段落在其向量表示中应当作为一个整体被捕获。接下来,我们将看看是否能通过语义分块器做得更好。

语义分块器

这可能是你之前见过的,因为我们在第一个代码实验中使用过它!SemanticChunker 是一个很有趣的工具,目前被列为实验性功能,但在 LangChain 网站上的描述如下:“首先按句子进行分割。然后,如果它们在语义上足够相似,就将它们合并在一起。”换句话说,这里的目标是避免定义一个任意的块大小,这个大小是字符和递归分割器用于划分文本的关键参数,而是更注重文本的语义来进行分割。你可以在 LangChain 网站上了解更多关于这个分块器的信息:链接

在内部实现中,SemanticChunker 会将文本分割成句子,把相邻的句子组合成三句话一组,然后在它们在嵌入空间中相似时进行合并。

那么什么时候它不会很好地工作呢?当文档的语义难以识别时。例如,如果你的文本中有大量的代码、地址、名字、内部引用 ID 和其他对嵌入模型几乎没有语义意义的内容,这可能会降低 SemanticChunker 正确分割文本的能力。但总体来说,SemanticChunker 还是很有前景的。

以下是使用 SemanticChunker 的代码示例:

from langchain_experimental.text_splitter import SemanticChunker
embedding_function = OpenAIEmbeddings()
semantic_splitter = SemanticChunker(embedding_function,
    number_of_chunks=200)
splits = semantic_splitter.split_documents(docs)

在这里,我们从 langchain_experimental.text_splitter 模块导入了 SemanticChunker 类。我们使用与向量化文档相同的嵌入模型,并将它们传递给 SemanticChunker 类。请注意,这会产生一些费用,因为它使用了我们用来生成嵌入的 OpenAI API 密钥。SemanticChunker 使用这些嵌入来确定如何根据语义相似性分割文档。我们还将 number_of_chunks 设置为 200,表示希望将文档分割成的块数。这决定了分割过程的粒度。number_of_chunks 的值越高,分割就会越细致;而较低的值则会生成较少且较大的块。

本次代码实验设置为一次使用每种类型的分割器。逐个运行每个分割器,并查看它们如何影响你的结果。还可以尝试改变参数设置,如 chunk_sizechunk_overlapnumber_of_chunks,具体取决于你使用的分割器。探索所有这些选项将帮助你更好地理解它们如何应用于你的项目。

作为最后一个支持组件,我们将讨论输出解析器,它负责从我们的 RAG 应用中生成最终输出。

代码实验 11.3 – 输出解析器

你需要从 GitHub 仓库中访问的文件名为 CHAPTER11-3_OUTPUT_PARSERS.ipynb

任何 RAG 应用的最终结果都会是文本,可能还包括一些格式化、元数据以及其他相关数据。这个输出通常来自 LLM(大语言模型)本身。但有时你希望获得比单纯文本更结构化的格式。输出解析器是帮助你在 RAG 应用中结构化 LLM 响应的类。这个结构化的输出会被传递到链条中的下一步,或者在所有代码实验中作为模型的最终输出。

我们将同时介绍两种不同的输出解析器,并在 RAG 流水线中的不同位置使用它们。我们从我们已经熟悉的解析器开始,即字符串输出解析器。

relevance_prompt 函数下,添加以下代码到一个新的单元格中:

from langchain_core.output_parsers import StrOutputParser
str_output_parser = StrOutputParser()

请注意,我们已经在后续的 LangChain 链代码中使用过这个解析器,但我们将这个解析器赋值给一个名为 str_output_parser 的变量。让我们深入了解这个解析器的工作原理。

字符串输出解析器

这是一个基本的输出解析器。在非常简单的应用中,像我们之前的代码实验那样,你可以直接使用 StrOutputParser 类作为输出解析器的实例。或者,你也可以像我们刚刚做的那样将其赋值给一个变量,特别是当你期望在代码的多个地方看到它时,我们就是这么做的。但我们已经见过这个解析器很多次了。它会从 LLM 获取输出,在它被使用的地方将 LLM 的字符串响应输出到链条中的下一个环节。这个解析器的文档可以在以下链接找到:StrOutputParser 文档

接下来,让我们看一下另一种新的解析器——JSON 输出解析器。

JSON 输出解析器

顾名思义,这个输出解析器从 LLM 获取输入并将其输出为 JSON 格式。需要注意的是,你可能不需要这个解析器,因为许多新型的模型提供商都支持内置返回结构化输出(如 JSON 和 XML)的方式。而这个方法是为那些不支持的模型提供的。

我们首先导入一些新模块,这些模块来自我们已经从 LangChain 安装的库(langchain_core):

from langchain_core.output_parsers import JsonOutputParser
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_core.outputs import Generation
import json

这些代码行导入了 langchain_core 库中的必要类和模块以及 json 模块。JsonOutputParser 用于解析 JSON 输出,BaseModelField 用于定义 JSON 输出模型的结构,Generation 用于表示生成的输出。而 json 包用于更好地管理我们的 JSON 输入/输出。

接下来,我们将创建一个名为 FinalOutputModel 的 Pydantic 模型,用于表示 JSON 输出的结构:

class FinalOutputModel(BaseModel):
    relevance_score: float = Field(description="The relevance score of the retrieved context to the question")
    answer: str = Field(description="The final answer to the question")

这个模型有两个字段:relevance_score(浮动类型)和 answer(字符串类型),并且它们都有相应的描述。在实际应用中,这个模型可能会变得更加复杂,但这为你展示了如何定义它的一般概念。

接下来,我们将创建一个 JsonOutputParser 解析器的实例:

json_parser = JsonOutputParser(pydantic_model=FinalOutputModel)

这行代码将 JsonOutputParser 类与 FinalOutputModel 模型作为参数传递给 json_parser,以便在后续的代码中使用这个解析器。

接下来,我们将在两个其他辅助函数之间插入一个新函数,并更新 conditional_answer 以使用这个新函数。这段代码添加在现有的 extract_score 函数之后,保持不变:

def format_json_output(x):
    # print(x)
    json_output = {"relevance_score": extract_score(x['relevance_score']), "answer": x['answer']}
    return json_parser.parse_result([Generation(text=json.dumps(json_output))])

format_json_output 函数接收一个字典 x 作为输入,并将其格式化为 JSON 输出。它创建一个 json_output 字典,包含两个键:“relevance_score”(通过调用 extract_score 获取 x['relevance_score'])和“answer”(直接从 x 获取)。然后,它使用 json.dumpsjson_output 字典转换为 JSON 字符串,并创建一个 Generation 对象,将 JSON 字符串作为其文本。最后,它使用 json_parser 解析 Generation 对象并返回解析结果。

我们需要在之前使用的 conditional_answer 函数中引用这个函数。像这样更新 conditional_answer

def conditional_answer(x):
    relevance_score = extract_score(x['relevance_score'])
    if relevance_score < 4:
        return "I don't know."
    else:
        return format_json_output(x)

在这里,我们更新了 conditional_answer 函数,以便在判断答案相关时应用 format_json_output 函数,并在返回输出之前提供格式化后的 JSON 输出。

接下来,我们将之前的两个链条组合成一个更大的链条,处理整个流水线。过去,为了更集中地展示某些部分,我们将它们分开展示,但现在我们有机会简化,并展示这些链条如何结合在一起,处理整个逻辑流:

rag_chain = (
    RunnableParallel({"context": ensemble_retriever,
                      "question": RunnablePassthrough()})
    | RunnablePassthrough.assign(context=(lambda x: format_docs(x["context"])))
    | RunnableParallel({"relevance_score": (
              RunnablePassthrough()
              | (lambda x: relevance_prompt_template.format(
                        question=x["question"],
                        retrieved_context=x["context"]
                      )
              )
              | llm
              | str_output_parser
          ),
          "answer": (
              RunnablePassthrough()
              | prompt
              | llm
              | str_output_parser
          ),
    })
    | RunnablePassthrough().assign(final_result=conditional_answer)
)

如果回顾之前的代码实验,你会发现这里用两个链条表示。注意,这里仍然使用 str_output_parser,就像之前一样。你没有看到 JSON 解析器的使用,因为它已经在 format_json_output 函数中应用,该函数是从 conditional_answer 函数中调用的,后者在最后一行中出现。这种简化的链条结构适用于这个例子,专注于将输出解析为 JSON,但需要注意,我们失去了在之前代码实验中使用的上下文。这只是一个展示如何将输出解析器简单地融入 RAG 应用中的替代方法。

最后,由于我们的最终输出是 JSON 格式,且我们需要添加上下文,我们需要更新我们的测试运行代码:

result = rag_chain.invoke(user_query)
print(f"Original Question: {user_query}\n")
print(f"Relevance Score: {result['relevance_score']}\n")
print(f"Final Answer:\n{result['final_result']['answer']}\n\n")
print(f"Final JSON Output:\n{result}\n\n")

当我们打印结果时,会看到与之前类似的输出,但现在我们展示的是 JSON 格式的最终输出:

Original Question: What are Google's environmental initiatives?
Relevance Score: 5
Final Answer:
Google's environmental initiatives include empowering individuals to take action, working together with partners and customers, operating sustainably… [TRUNCATED]
Final JSON Output:
{
    'relevance_score': '5',
    'answer': "Google's environmental initiatives include empowering individuals to take action, working together with partners and customers, operating sustainably, achieving net-zero carbon emissions, water stewardship, engaging in a circular economy, and supporting sustainable consumption of public goods. They also engage with suppliers to reduce energy consumption and greenhouse gas emissions, report environmental data, and assess environmental criteria. Google is involved in various sustainability initiatives, such as the iMasons Climate Accord, ReFED, and projects with The Nature Conservancy. They also invest in breakthrough innovation and support sustainability-focused accelerators. Additionally, Google focuses on renewable energy, data analytics tools for sustainability, and AI for sustainability to drive more intelligent supply chains.",
    'final_result': {
        'relevance_score': 5.0,
        'answer': "Google's environmental initiatives include empowering individuals to take action, working together with partners and customers, operating sustainably, achieving net-zero carbon emissions, water stewardship, engaging in a circular economy, and supporting sustainable consumption of public goods. They also engage with suppliers to reduce energy consumption and greenhouse gas emissions, report environmental data, and assess environmental criteria. Google is involved in various sustainability initiatives, such as the iMasons Climate Accord, ReFED, and projects with The Nature Conservancy. They also invest in breakthrough innovation and support sustainability-focused accelerators. Additionally, Google focuses on renewable energy, data analytics tools for sustainability, and AI for sustainability to drive more intelligent supply chains."
    }
}

这是一个简单的 JSON 输出示例,但你可以基于此扩展,并使用我们定义并传递给输出解析器的 FinalOutputModel 类,将 JSON 格式化成任何你需要的结构。

你可以在这里找到更多关于 JSON 解析器的信息:JSON 解析器文档

需要注意的是,依赖 LLM 输出特定格式(如 JSON)是很困难的。一个更强大的系统会将解析器更深入地集成到系统中,这样它就能更好地利用 JSON 输出,但这也意味着需要更多的检查,以确保格式符合要求,确保下一步能够正常处理格式化的 JSON。在我们的代码中,我们实现了一个轻量级的 JSON 格式化层,以展示输出解析器如何简单地融入我们的 RAG 应用。

总结

在本章中,我们了解了LangChain中的各个组件,它们可以增强RAG应用程序。代码实验室11.1集中讨论了文档加载器,它用于从各种来源(如文本文件、PDF、网页或数据库)加载和处理文档。本章展示了如何使用不同的LangChain文档加载器从HTML、PDF、Microsoft Word和JSON格式加载文档,并指出一些文档加载器会添加元数据,这可能需要在代码中进行调整。

代码实验室11.2讨论了文本分割器,它将文档分割成适合检索的块,解决了大文档和上下文表示在向量嵌入中的问题。本章介绍了CharacterTextSplitter,它将文本分割成任意大小的N字符块;RecursiveCharacterTextSplitter,它递归地分割文本,同时尽量将相关内容保持在一起;SemanticChunker则作为一种实验性的分割器,它结合语义相似的句子,以创建更有意义的块。

最后,代码实验室11.3集中讨论了输出解析器,它将RAG应用程序中语言模型的响应结构化。本章介绍了字符串输出解析器,它将LLM的响应输出为字符串;以及JSON输出解析器,它使用定义的结构将输出格式化为JSON。提供了一个示例,展示了如何将JSON输出解析器集成到RAG应用程序中。

在下一章中,我们将讨论一个相对高级但非常强大的主题——LangGraph和AI代理。