LLM工程师手册——RAG 特性管道

288 阅读1小时+

检索增强生成 (RAG) 是大多数生成式 AI 应用中的基础技术。RAG 的核心职责是将自定义数据注入大型语言模型 (LLM),使其能够执行特定操作(如总结、重述或提取注入的数据)。在使用 LLM 时,我们经常需要它处理未曾训练过的数据(如私有数据或新数据)。由于对 LLM 进行微调是一项高成本操作,RAG 成为一种强有力的策略,能够在不频繁微调的情况下访问新数据。

本章将以理论部分开篇,重点讲述 RAG 的基本原理及其工作机制。随后,我们将逐步介绍一个简单 RAG 系统的所有组件:分块、嵌入和向量数据库。最终,我们将展示用于高级 RAG 系统的各种优化。接下来,我们将继续探索 LLM Twin 的 RAG 特性管道架构,并应用本章开头讨论的所有理论概念。最后,我们将通过实现 LLM Twin 的 RAG 特性管道,结合本书中描述的系统设计,展示一个实用示例。

本章的主要内容包括:

  1. 理解 RAG
  2. 高级 RAG 概述
  3. 探索 LLM Twin 的 RAG 特性管道架构
  4. 实现 LLM Twin 的 RAG 特性管道

完成本章后,你将对 RAG 的概念及其在 LLM Twin 用例中的应用有清晰而全面的理解。

理解 RAG

RAG(检索增强生成)通过从外部源获取信息来提高生成式 AI 模型的准确性和可靠性。它是对 LLM(大型语言模型)内部知识的一种补充技术。在深入细节之前,我们先来理解 RAG 的含义:

  • 检索:搜索相关数据
  • 增强:将数据作为上下文添加到提示中
  • 生成:使用带有增强信息的提示来生成结果

任何 LLM 都只能理解其训练数据(有时称为参数化知识)。因此,即使 LLM 能完美回答过去发生的事件,它也无法访问最新数据或未曾训练过的外部资源。

以 OpenAI 的最强模型 GPT-4o 为例,该模型在 2024 年夏季推出,并训练至 2023 年 10 月的数据。因此,如果我们询问 2020 年疫情期间的事件,它可以完美回答,因为这些信息在其参数化知识中。然而,如果我们问 2024 年欧洲足球锦标赛的结果,由于知识的时间限制,模型无法回答,或者它可能会自信地给出错误的答案。

RAG 克服了 LLM 的这两个局限性。它允许访问最新或外部数据,防止幻觉现象,从而提高生成式 AI 模型的准确性和可靠性。

为什么要使用 RAG?

我们之前简要说明了在生成式 AI 应用中使用 RAG 的重要性。现在,我们将深入探讨“为什么”需要 RAG,并展示一个简单 RAG 框架的结构。

要理解 RAG,可以想象它通过在提示中注入必要的信息来回答用户的问题。然后,我们将增强后的提示传递给 LLM 来生成最终答案。此时,LLM 会利用附加的上下文来回答用户的问题。

RAG 解决了两个根本性的问题:

  1. 幻觉
  2. 陈旧或私有信息

幻觉

如果一个不使用 RAG 的聊天机器人被问及其未训练过的问题,它很可能会自信地给出一个不真实的答案。例如,如果模型的训练截止到 2023 年 10 月,而我们问它有关 2024 年欧洲足球锦标赛的问题,模型可能会编造一个随机的答案,让人难以分辨真假。即使 LLM 不总是产生幻觉,它的回答可信度依然存疑。因此,我们必须思考:“什么时候可以信任 LLM 的回答?”以及“如何评估回答是否正确?”

引入 RAG 后,LLM 将始终基于给定的上下文回答问题。LLM 扮演推理引擎的角色,而 RAG 提供的附加信息将作为生成答案的唯一可信来源。通过这种方式,我们可以快速评估 LLM 的回答是否基于外部数据。

陈旧信息

任何 LLM 都是基于有限的世界知识数据集进行训练或微调的,这主要有以下三个原因:

  1. 私有数据:无法在没有权利的情况下使用他人数据来训练模型。
  2. 新数据:新数据实时产生,要保持模型实时更新需要持续训练。
  3. 成本:训练或微调 LLM 需要极高的成本,难以频繁进行。

RAG 解决了这些问题,因为无需频繁地在新数据(甚至是私有数据)上微调 LLM。直接将必要数据注入到传递给 LLM 的提示中,就足以生成准确且有价值的回答。

总结来说,RAG 是构建稳健且灵活的生成式 AI 系统的关键。那么,如何根据用户的问题将合适的数据注入提示中呢?我们将在接下来的章节深入探讨 RAG 的技术细节。

基础 RAG 框架

每个 RAG 系统的核心结构都很相似。我们将首先关注 RAG 最简单的形式,之后逐步介绍更高级的 RAG 技术,以提升系统的准确性。为了避免重复,我们将“基础 RAG”和“简单 RAG”交替使用。

RAG 系统由三个主要模块组成,彼此独立:

  1. 数据导入管道:一个批量或流式管道,用于将数据导入向量数据库。
  2. 检索管道:用于查询向量数据库并检索与用户输入相关的条目。
  3. 生成管道:使用检索到的数据增强提示,并通过 LLM 生成答案的层。

这三个组件可以是独立的类或服务,我们将分别深入探讨每个组件。现在,让我们先回答“这三个模块是如何连接的?”这一问题。以下是一个简化的流程概述:

  • 后端:数据导入管道按计划或持续运行,将外部数据填充到向量数据库中。
  • 前端:用户提出问题。
  • 检索模块:接收问题,对用户输入进行预处理并查询向量数据库。
  • 生成管道:使用提示模板、用户输入和检索到的上下文创建提示。
  • LLM:接收提示并生成答案。
  • 展示:答案返回给用户。

image.png

当生成式 AI 应用需要访问任何类型的外部信息时,必须实现 RAG。例如,在构建金融助手时,可能需要获取最新的新闻、报告和价格,以提供有价值的答案。或者,如果构建旅行推荐系统,需要检索并解析潜在的景点、餐厅和活动列表。由于在训练阶段,LLM 无法访问特定的数据,因此在生成式 AI 项目中经常需要实施 RAG 策略。现在让我们深入了解数据导入、检索和生成管道。

数据导入管道

RAG 的数据导入管道从各种数据源(如数据仓库、数据湖、网页等)提取原始文档。然后,对文档进行清洗、分块(拆分成更小的部分)和嵌入,最终将嵌入的分块数据加载到向量数据库(或其他类似的向量存储)中。

因此,RAG 数据导入管道分为以下几个模块:

  • 数据提取模块:从数据库、API 或网页等各种来源收集所有必要的数据。此模块高度依赖于具体的数据类型。可能简单到只是查询数据仓库,也可能复杂到需要爬取维基百科。
  • 清洗层:标准化并删除不需要的字符,例如从输入文本中移除无效字符(如非 ASCII 字符、粗体和斜体字符)。另一种常见的清洗策略是用占位符替换 URL。不过,清洗策略会根据数据来源和嵌入模型的不同而有所变化。
  • 分块模块:将清洗后的文档拆分为更小的部分。由于需要将文档内容传递给嵌入模型,这有助于确保内容不超过模型的最大输入大小。此外,分块有助于将语义相关的特定区域分开。例如,在分块一本书的章节时,最优方式是将相似的段落分组到相同部分中。这样,在检索时,只需将必要的数据添加到提示中。
  • 嵌入组件:使用嵌入模型将分块内容(文本、图像、音频等)投影到一个具有语义信息的密集向量空间中(更多关于嵌入的内容请见下文的“什么是嵌入?”部分)。
  • 加载模块:将嵌入的分块和元数据文档一起存储。元数据包含重要信息,例如嵌入的内容、分块的来源 URL 以及内容的发布时间。嵌入用于作为索引查询相似的分块,而元数据用于访问增强提示的额外信息。

至此,我们拥有一个 RAG 数据导入管道,能够将原始文档作为输入、处理并填充到向量数据库中。下一步是从向量存储中正确检索相关数据。

检索管道

检索组件接收用户的输入(文本、图像、音频等),对其进行嵌入并查询向量数据库,寻找与用户输入相似的向量。

检索步骤的主要功能是将用户的输入投影到与向量数据库中嵌入索引相同的向量空间中。这允许我们通过比较向量存储中的嵌入与用户输入的向量来找到最相似的 K 条记录。这些记录将作为增强提示的内容,传递给 LLM 来生成答案。

在比较两个向量时,通常使用距离度量方法,例如欧几里得距离或曼哈顿距离,但最常用的是余弦距离,即 1 减去两个向量之间夹角的余弦值,如下所示:

image.png

它的取值范围为 -1 到 1,当向量 A 和 B 方向相反时为 -1,垂直时为 0,方向相同时为 1。大多数情况下,余弦距离在非线性复杂向量空间中表现良好。然而,选择合适的距离度量方法取决于数据和所使用的嵌入模型。

需要注意的关键因素是用户输入的嵌入和数据导入管道生成的嵌入必须在相同的向量空间中,否则无法计算它们之间的距离。因此,必须按照 RAG 数据导入管道中处理原始文档的方式对用户输入进行预处理,这意味着需要使用相同的函数、模型和超参数对用户输入进行清洗、分块(如果必要)和嵌入。这类似于训练和推理时的数据预处理方式一致性,否则推理结果将产生不准确的现象,这种现象称为训练-服务偏差(training-serving skew)。

生成管道

RAG 系统的最后一步是接收用户输入、检索数据,将其传递给 LLM,生成有价值的答案。

最终的提示(prompt)由系统和提示模板生成,并填充了用户的问题和检索到的上下文。根据应用的需求,可能使用一个或多个提示模板。通常,所有的提示工程都在提示模板级别进行。

以下是一个通用系统和提示模板的示例,以及它们如何与检索逻辑和 LLM 一起使用以生成最终答案:

system_template = """
You are a helpful assistant who answers all the user's questions politely.
"""

prompt_template = """
Answer the user's question using only the provided context. If you cannot answer using the context, respond with "I don't know."
Context: {context}
User question: {user_question}
"""

user_question = "<your_question>"
retrieved_context = retrieve(user_question)
prompt = f"{system_template}\n"
prompt += prompt_template.format(context=retrieved_context, user_question=user_question)
answer = llm(prompt)

随着提示模板的发展,每次变更都应通过机器学习操作 (MLOps) 的最佳实践进行跟踪和版本控制。这样,在训练或推理时,总能知道某个答案是由特定版本的 LLM 和提示模板生成的。可以通过 Git 进行版本控制,将提示模板存储在数据库中,或使用特定的提示管理工具(如 LangFuse)。

如在检索管道中所见,直接影响 RAG 系统准确性的一些关键因素包括存储在向量数据库中的外部数据嵌入、用户问题的嵌入,以及如何通过余弦距离等函数来比较两者的相似性。为了更好地理解 RAG 算法的这一部分,让我们深入了解嵌入是什么以及它们是如何计算的。

什么是嵌入?

可以想象,你正在试图教会计算机理解这个世界。嵌入就像一个特殊的翻译器,将各种事物转化为数值代码。这个代码并不是随机的,相似的词或项目会得到彼此接近的代码。这就像一张地图,具有相似含义的词汇聚在一起。

更为理论的定义是,嵌入是对象(如词语、图像或推荐系统中的物品)的密集数值表示,以向量形式编码在一个连续的向量空间中。这种转化有助于捕捉对象之间的语义含义和关系。例如,在自然语言处理 (NLP) 中,嵌入将词语转化为向量,在向量空间中,语义相似的词语会彼此靠近。

image.png

一种常见的方法是将嵌入可视化,以理解和评估它们的几何关系。由于嵌入通常有超过 2 或 3 个维度,通常在 64 到 2048 之间,需要将它们再投影到 2D 或 3D 空间中。

例如,可以使用 UMAP(UMAP 文档),这是一种降维方法,能够在将嵌入投影到 2D 或 3D 时保持点之间的几何关系。另一个常用于可视化向量的降维算法是 t-SNE(t-SNE 文档)。不过,与 UMAP 相比,t-SNE 更具随机性,无法很好地保留点之间的拓扑关系。

降维算法(如 PCA、UMAP 和 t-SNE)是一种数学技术,旨在减少数据集中的输入变量或特征数量,同时保持数据的基本模式、结构和关系。目标是将高维数据转换为较低维度的形式,使其更易于可视化、解释和处理,同时最大限度地减少重要信息的损失。这些方法有助于应对“维度灾难”,提高计算效率,并通常提升机器学习算法的性能。

image.png

为什么嵌入如此强大

首先,机器学习模型只能处理数值数据。对于表格数据,这通常不是问题,因为数据往往已经是数值形式或可以轻松转化为数值。但当我们想要将词语、图像或音频数据输入模型时,嵌入就非常有用了。

例如,在处理 Transformer 模型时,需要对所有文本输入进行分词,每个词都有一个相应的嵌入。这个过程的美妙之处在于它的简单性:Transformer 的输入是一个嵌入序列,神经网络的密集层可以轻松地解读这些嵌入。

基于此示例,可以用嵌入编码任何类别变量并将其输入到机器学习模型中。那么为什么不用其他简单方法,例如独热编码?当处理高基数的类别变量(如语言词汇)时,使用其他传统方法会遭遇维度灾难。例如,如果词汇量有 10,000 个词,使用独热编码后,每个词都会生成一个长度为 10,000 的向量。如果输入序列有 N 个词,这将生成 N * 10,000 个输入参数。如果 N ≥ 100(文本输入中常见),那么输入的规模就会变得过大而难以使用。其他传统方法(如哈希)虽然不遭遇维度灾难,但会丢失向量之间的语义关系。

  • 独热编码:将类别变量转换为二进制矩阵表示。每个类别由一个独特的二进制向量表示,对于每个类别变量,生成一个长度等于类别总数的二进制向量,其中仅在特定类别索引处值为一,其他位置为零。这种方法保留了类别的所有信息,简单且易于解释。然而,当类别变量有许多唯一值时,特征空间会变得非常高维,导致方法不切实际。
  • 特征哈希:也称为哈希编码或“哈希技巧”,通过对类别值应用哈希函数,将类别变量转换为数值特征。与独热编码相比,特征哈希不受唯一类别数的限制,通过将类别映射到固定数量的桶中来减少特征空间的维度,这在处理高基数类别变量时尤其有用。该方法在内存使用和计算时间上很高效,但有发生冲突的风险,即不同类别可能映射到同一个桶中,导致信息丢失,且映射难以解释。原始类别与哈希特征之间的关系也难以理解。

嵌入帮助我们编码类别变量,同时控制输出向量的维度。它们还采用巧妙的方法将信息压缩到比简单的哈希技巧更低的维度空间中。

其次,嵌入可以降低输入的维度,将其语义信息压缩到一个密集向量中。这在图像处理中尤为流行,通过 CNN 编码模块将高维语义映射到嵌入中,之后由 CNN 解码器进行分类或回归步骤。

以下图示展示了典型的 CNN 结构。可以想象每一层中的小方块,这些是“感受野”。每个方块将信息传递到上一层的单个神经元。随着在网络中逐层前进,有两件关键的事情正在发生:

  • 缩小图像:通过特定的“下采样”操作使层变小,关注重要细节。
  • 学习特征:“卷积”操作则增加了层的大小,网络从图像中学习更复杂的特征。

最后,末尾的全连接层汇总所有已处理的信息,将其转化为最终的向量嵌入,即图像的数值表示。

image.png

上图来源于 Wikimedia Commons(链接),并根据知识共享署名-相同方式共享 4.0 国际许可协议(CC BY-SA 4.0:许可链接)授权。

如何创建嵌入?

嵌入是由深度学习模型创建的,这些模型理解输入的上下文和语义,并将其投影到一个连续的向量空间中。

不同的深度学习模型可以用于创建嵌入,具体选择取决于数据输入类型。因此,在选择嵌入模型之前,了解数据的特性以及需要从中获得的内容至关重要。

例如,在处理文本数据时,早期常用的词汇嵌入方法包括 Word2Vec 和 GloVe,这些方法至今仍被用于较简单的应用中。另一种流行的方法是使用仅编码器的 Transformer 模型,例如 BERT 或其家族中的其他模型(如 RoBERTa),这些模型利用 Transformer 的编码器智能地将输入投影到密集向量空间中,可用作嵌入。

在 Python 中快速计算嵌入,可以使用 Sentence Transformers 包(在 Hugging Face 的 transformer 包中也有提供)。这个工具提供了用户友好的接口,使嵌入计算过程简洁高效。

在以下代码示例中,我们加载了一个 SentenceTransformer 模型,为三个句子计算嵌入,并最终计算它们之间的余弦相似度。与自身的相似度总是 1。第一个和第二个句子相似度接近 0,因为它们没有共同之处,而第一个和第三个句子之间的值较高,因为有一些上下文重叠:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
sentences = [
    "The dog sits outside waiting for a treat.",
    "I am going swimming.",
    "The dog is swimming."
]
embeddings = model.encode(sentences)
print(embeddings.shape)  # 输出: [3, 384]

similarities = model.similarity(embeddings, embeddings)
print(similarities)
# 输出:
# tensor([[ 1.0000, -0.0389, 0.2692],
#        [-0.0389, 1.0000, 0.3837],
#        [ 0.2692, 0.3837, 1.0000]])
#
# similarities[0, 0] = 第一个句子与自身的相似度。
# similarities[0, 1] = 第一个和第二个句子的相似度。
# similarities[2, 1] = 第三个和第二个句子的相似度。

以上代码的源代码可在此处找到:GitHub 链接

在嵌入部分的示例可以在本书所使用的虚拟环境中运行,该环境包含所有必要的依赖项。

随着时间的推移以及具体应用的不同,最优的嵌入模型可能会发生变化。在 Hugging Face 的 Massive Text Embedding Benchmark (MTEB) 上可以找到特定模型。可以根据需求选择最佳表现模型、最准确模型,或是占用内存最小的模型,这完全取决于具体需求(如精度和硬件)。不过,Hugging Face 和 SentenceTransformer 使不同模型间的切换十分便捷,因此可以随时尝试各种选项。

在处理图像时,可以通过卷积神经网络 (CNN) 为图像生成嵌入。流行的 CNN 网络基于 ResNet 架构。然而,无法直接对音频记录使用图像嵌入技术,而是可以将音频转换为频谱图等视觉表示,然后将图像嵌入模型应用于这些视觉表示,这样可以在计算机可理解的形式中捕捉图像和声音的本质。

通过 CLIP 等模型,可以将文本和图像嵌入到同一个向量空间中,这使得可以用句子来查找相似的图像,反之亦然,展示了 CLIP 的实用性。

在以下代码示例中,我们使用 CLIP 对一张猫的图像和三句话进行编码,最后使用余弦相似度来计算图像与句子之间的相似性:

from io import BytesIO
import requests
from PIL import Image
from sentence_transformers import SentenceTransformer

response = requests.get(
    "https://github.com/PacktPublishing/LLM-Engineering/blob/main/images/crazy_cat.jpg?raw=true"
)
image = Image.open(BytesIO(response.content))

model = SentenceTransformer("clip-ViT-B-32")
img_emb = model.encode(image)
text_emb = model.encode([
    "A crazy cat smiling.",
    "A white and brown cat with a yellow bandana.",
    "A man eating in the garden."
])
print(text_emb.shape)  # Output: (3, 512)

similarity_scores = model.similarity(img_emb, text_emb)
print(similarity_scores)
# 输出: tensor([[0.3068, 0.3300, 0.1719]])

源代码可在此处找到:GitHub 链接

以上内容介绍了如何计算嵌入。具体实现的领域非常广阔,但重要的是要知道嵌入可以用于大多数数字数据类别,如词语、句子、文档、图像、视频和图表。

需要理解的是,当你需要计算不同数据类别(如句子和图像)之间的距离时,必须使用专门的模型,这些模型设计用于将这两种数据类型投影到相同的向量空间中,例如 CLIP,从而确保距离计算的准确性。

嵌入的应用

由于生成式 AI 革命及其在 RAG 中的应用,嵌入在信息检索任务中变得极为流行,例如用于文本、代码、图像和音频的语义搜索,以及智能体的长期记忆。但在生成式 AI 之前,嵌入已广泛用于以下领域:

  • 表示输入到机器学习模型中的类别变量(如词汇 tokens)
  • 在推荐系统中编码用户和物品,并找到它们之间的关系
  • 聚类和异常检测
  • 通过 UMAP 等算法进行数据可视化
  • 将嵌入作为特征进行分类
  • 通过比较每个类别的嵌入并选择最相似的类别实现零样本分类

要完全理解 RAG 的工作原理,最后一步是研究向量数据库及其如何利用嵌入来检索数据。

关于向量数据库的更多内容

向量数据库是专门设计用于高效存储、索引和检索向量嵌入的数据库。传统的标量数据库难以处理向量数据的复杂性,而向量数据库在实时语义搜索等任务中至关重要。

虽然独立的向量索引(如 FAISS)在相似性搜索方面有效,但缺乏向量数据库的全面数据管理功能。向量数据库支持 CRUD 操作、元数据过滤、可扩展性、实时更新、备份、生态系统集成和强大的数据安全性,因此比独立索引更适合生产环境。

向量数据库如何工作?

想象一下传统的数据库搜索。你输入某个特定内容,系统会给出精确匹配的结果,而这就是传统数据库的工作方式。而向量数据库则不同,我们寻找的是与查询向量最接近的“邻居”。在底层,向量数据库使用近似最近邻(ANN)算法来找到这些近邻。

虽然 ANN 算法不会返回精确的最佳匹配,但标准的最近邻算法在实践中往往过于缓慢。同时,实验证明,仅使用接近最佳匹配的结果通常已经足够。因此,在准确性和延迟之间的权衡使得 ANN 算法成为首选。

向量数据库的典型工作流程
  1. 向量索引:向量使用针对高维数据优化的数据结构进行索引。常见的索引技术包括层次可导航小世界(HNSW)、随机投影、产品量化(PQ)和局部敏感哈希(LSH)。
  2. 相似性查询:在搜索过程中,数据库会查询索引向量,找到与输入向量最相似的那些。这一过程基于相似性度量,例如余弦相似度、欧氏距离或点积。每种度量方法各具优势,适用于不同的应用场景。
  3. 结果后处理:在识别潜在匹配后,结果会进行后处理以优化准确性,从而确保向用户返回最相关的向量。

向量数据库还可以基于元数据在向量搜索前后筛选结果。这两种方法在性能和准确性上各有权衡。查询还依赖于元数据(连同向量索引),因此包含一个用于过滤操作的元数据索引。

创建向量索引的算法

向量数据库使用多种算法来创建向量索引并有效地管理数据搜索:

  • 随机投影:通过将向量投影到较低维度空间来降低向量维度,使用随机矩阵进行投影。这种技术保留了向量之间的相对距离,加速了搜索。
  • 产品量化 (PQ) :通过将向量分割成较小的子向量并将这些子向量量化为代表性编码来压缩向量,减少内存使用并加速相似性搜索。
  • 局部敏感哈希 (LSH) :将相似的向量映射到相同的桶中,从而只聚焦于数据的一个子集,降低计算复杂度,实现快速的近似最近邻搜索。
  • 层次可导航小世界 (HNSW) :构建一个多层图,每个节点代表一组向量,相似节点之间相连,使算法能够高效地在图中导航并找到最近邻。

这些算法使得向量数据库可以高效地处理复杂和大规模数据,成为各种 AI 和 ML 应用的理想选择。

数据库操作

向量数据库还具有与标准数据库类似的特性,以确保在生产环境中的高性能、容错性和易管理性。关键操作包括:

  • 分片和复制:将数据分区(分片)到多个节点上,以确保可扩展性和高可用性。跨节点的数据复制有助于在节点故障时保持数据完整性和可用性。
  • 监控:持续监控数据库性能,包括查询延迟和资源使用情况(RAM、CPU、磁盘),有助于保持最佳运行状态,并在问题影响系统之前发现潜在问题。
  • 访问控制:实施强大的访问控制机制,确保只有授权用户才能访问和修改数据,包括基于角色的访问控制和其他安全协议,以保护敏感信息。
  • 备份:定期数据库备份对于灾难恢复至关重要。自动化备份过程确保在数据损坏或丢失时可以将数据恢复到先前状态。

高级 RAG 概述

我们刚介绍的基础 RAG 框架没有解决许多影响检索和答案生成质量的基本问题,例如:

  • 检索到的文档是否与用户的问题相关?
  • 检索到的上下文是否足够回答用户的问题?
  • 是否存在冗余信息,给增强后的提示添加了噪声?
  • 检索步骤的延迟是否符合要求?
  • 如果无法使用检索到的信息生成有效答案,我们该怎么办?

从以上问题中可以得出两个结论。第一,我们需要一个稳健的评估模块来量化和测量检索数据的质量,并生成相对用户问题的答案。我们将在第 9 章中详细讨论这一主题。第二,我们需要改进 RAG 框架,以在算法中直接解决检索的局限性。这些改进被称为高级 RAG。

基础 RAG 设计可以在以下三个阶段进行优化:

  1. 预检索阶段:关注如何结构化和预处理数据,以优化数据索引和查询。
  2. 检索阶段:围绕改进嵌入模型和元数据过滤,以提升向量搜索步骤的性能。
  3. 后检索阶段:主要集中在不同的方法上,过滤掉检索文档中的噪声,并在将提示传递给 LLM 生成答案之前对其进行压缩。

image.png

本节不旨在详尽列出所有可用的高级 RAG 方法,而是帮助建立关于哪些部分可以优化的直观理解。我们将仅使用基于文本数据的示例,但高级 RAG 的原理在不同数据类型中是相同的。接下来,让我们详细探讨三个组件的优化。

预检索阶段

预检索步骤可以通过以下两种方式实现:

  1. 数据索引:这是 RAG 数据导入管道的一部分,通常在清洗或分块模块中实现,以便为更好的索引预处理数据。
  2. 查询优化:在嵌入和从向量数据库中检索分块之前,直接对用户查询进行优化。

由于我们使用语义嵌入来表示分块文档的内容,大多数数据索引技术关注于更好地预处理和结构化数据,以提高检索效率,具体方法包括:

  • 滑动窗口:滑动窗口技术在文本块之间引入重叠,确保块边界附近的重要上下文得以保留,从而增强检索准确性。这在法律文档、科学论文、客户支持记录和医疗记录等领域尤为有利,因为关键信息通常跨越多个部分。嵌入是基于块和重叠部分计算的,因此滑动窗口通过保持边界间的上下文提高了系统检索相关和连贯信息的能力。
  • 增强数据粒度:这包括数据清洗技术,例如去除无关细节、验证事实准确性以及更新过时信息。一个干净且准确的数据集可以实现更精准的检索。
  • 元数据:添加如日期、URL、外部 ID 或章节标记等元数据标签,有助于在检索过程中高效筛选结果。
  • 优化索引结构:基于不同的数据索引方法,例如使用不同的块大小和多重索引策略来优化。
  • 小到大:该算法将用于检索的分块与用于生成最终答案的提示上下文分离。算法使用一小段文本来计算嵌入,同时在元数据中保留文本本身及其周围更大的上下文窗口。这样,使用较小的分块可以提高检索的准确性,而较大的上下文则为 LLM 提供更多的背景信息。

其直观解释是,如果使用完整文本来计算嵌入,可能会引入过多噪声,或者文本可能包含多个主题,导致整体语义表示较差的嵌入。

image.png

在查询优化方面,可以利用查询路由、查询重写和查询扩展等技术,以进一步优化 LLM 检索的信息:

  • 查询路由:基于用户的输入,可能需要与不同类别的数据交互,并针对每个类别进行不同的查询。查询路由用于根据用户输入决定采取的操作,类似于 if/else 语句,但决策是基于自然语言而非逻辑语句。例如,如图 4.6 所示,假设基于用户的输入,可以通过向量搜索查询向量数据库、将用户查询转换为 SQL 命令查询标准 SQL 数据库,或利用 REST API 调用互联网来检索额外上下文。查询路由器还可以检测是否需要上下文,从而避免冗余的外部数据存储调用。此外,查询路由器可以选择最佳的提示模板。例如,在 LLM Twin 用例中,根据用户是否需要文章段落、帖子或代码片段,可能需要不同的提示模板以优化生成过程。通常,路由器会使用 LLM 或嵌入选择最相似的路径。总的来说,查询路由类似于 if/else 语句,但因其直接处理自然语言而更加灵活。

  • 查询重写:有时,用户的初始查询可能无法完全匹配数据的结构。查询重写通过重新表述问题,使其更符合索引信息,方法包括:

    • 改写:在保持原意的基础上重新表述用户的查询(例如,将“气候变化的原因是什么?”改写为“引起全球变暖的因素”)。
    • 同义词替换:使用同义词替换不常见的词汇,扩大搜索范围(例如,将“joyful”替换为“happy”)。
    • 子查询:对于较长的查询,可以将其分解为多个简短且更集中的子查询,帮助检索阶段更精确地识别相关文档。
    • 假设文档嵌入 (HyDE) :由 LLM 创建一个假设响应,将原始查询和 LLM 的响应一同输入到检索阶段。
  • 查询扩展:此方法通过添加额外的术语或概念来丰富用户的问题,以便从不同角度理解初始问题。例如,当搜索“疾病”时,可以使用与原始查询词关联的同义词和相关词汇,如“病症”或“疾患”。

  • 自我查询:核心思想是将非结构化查询映射为结构化查询。LLM 识别输入文本中的关键实体、事件和关系,并将这些身份作为过滤参数以减少向量搜索空间(例如,识别查询中的城市名称,如“巴黎”,并将其添加到过滤条件中,以缩小向量搜索空间)。

数据索引和查询优化的预检索优化技术高度依赖于数据类型、结构和来源。因此,和其他数据处理管道一样,没有一种方法适用于所有情况,因为每个用例都有其特殊性。优化预检索 RAG 层需要实验,因此关键在于尝试多种方法(如本节列举的技术),不断迭代,并观察哪种方法效果最好。

检索

检索步骤可以通过以下两种基本方式进行优化:

  1. 改进 RAG 数据导入管道中使用的嵌入模型,用于编码分块文档,并在推理时转换用户的输入。
  2. 利用数据库的过滤和搜索功能,该步骤仅在推理时使用,以便基于用户输入检索最相似的分块。

这两种策略都符合我们的最终目标:通过利用查询与索引数据之间的语义相似性来优化向量搜索步骤。

改进嵌入模型

在改进嵌入模型时,通常需要微调预训练的嵌入模型,使其适应特定领域的术语和细微差别,特别是在术语不断变化或存在罕见术语的领域。

如果不想微调嵌入模型,也可以利用指令模型(例如 INSTRUCTOR)来引导嵌入生成过程,使用指令或提示使其更符合特定领域的需求。使用此类模型将嵌入网络定制化为适合数据的选项是不错的选择,因为微调模型需要消耗更多计算资源和人力资源。

以下代码示例展示了一个用于嵌入关于 AI 文章标题的 Instructor 模型:

from InstructorEmbedding import INSTRUCTOR

model = INSTRUCTOR("hkunlp/instructor-base")
sentence = "RAG Fundamentals First"
instruction = "Represent the title of an article about AI:"
embeddings = model.encode([[instruction, sentence]])
print(embeddings.shape)  # 输出: (1, 768)

源码可在此处找到:GitHub 链接

要运行此代码,需创建并激活一个新的虚拟环境:

python3 -m venv instructor_venv && source instructor_venv/bin/activate

并安装所需的 Python 依赖项:

pip install sentence-transformers==2.2.2 InstructorEmbedding==1.0.1

利用经典的数据库过滤和搜索功能

另一种改进检索的方法是利用数据库的经典过滤和搜索功能:

  • 混合搜索:这是向量和关键词搜索的结合。关键词搜索擅长找到包含特定关键词的文档。当任务需要精确定位并且检索信息必须包含准确关键词匹配时,混合搜索表现出色。向量搜索虽然强大,但有时在找到确切匹配方面表现欠佳,擅长识别更一般的语义相似性。通过结合两种方法,可以同时利用关键词匹配和语义相似性。混合搜索通常有一个称为 alpha 的参数,用于控制两种方法的权重。算法执行两个独立的搜索,随后对结果进行归一化并统一。
  • 过滤向量搜索:这种搜索利用元数据索引在元数据中筛选特定关键词。与混合搜索不同的是,这种方法只使用向量索引进行一次数据检索,并在向量搜索前或后执行过滤步骤,以缩小搜索空间。

在实际应用中,检索方面通常从过滤向量搜索或混合搜索开始,因为它们相对容易实现。这种方法提供了基于性能灵活调整策略的可能性。如果结果不符合预期,可以随时微调嵌入模型。

后检索阶段

后检索优化仅针对检索到的数据进行,以确保 LLM 的性能不受限制上下文窗口或噪声数据等问题的影响。这是因为检索到的上下文有时可能过大或包含无关信息,可能会干扰 LLM。

后检索步骤中常用的两种方法是:

  1. 提示压缩:在保留数据核心信息的前提下,删除不必要的细节。
  2. 重新排序:使用交叉编码器机器学习模型为用户输入和每个检索到的分块生成匹配分数。根据该分数对检索项进行排序,仅保留最相关的前 N 个结果。如图 4.7 所示,重新排序模型可以比简单的相似性搜索找到更复杂的用户输入与内容之间的关系。然而,由于成本较高,无法在初始检索阶段应用此模型。因此,一种常见策略是首先使用嵌入间的相似距离检索数据,然后使用重新排序模型进一步优化检索信息,如图 4.8 所示。

image.png

上述技术并非所有潜在解决方案的详尽列表。我们使用它们作为示例,以帮助直观理解在 RAG 工作流程的每个步骤中可以(并应当)优化的内容。事实上,这些技术会因数据类型的不同而有很大差异。

例如,如果处理多模态数据(如文本和图像),前面提到的大多数技术可能不适用,因为它们仅为文本设计。

image.png

总结来说,这些优化的主要目标是通过三个关键阶段来增强 RAG 算法:预检索、检索和后检索。具体而言,包括通过预处理数据来改进向量索引、调整用户查询以提高搜索准确性、改进嵌入模型、利用经典的数据库过滤操作以及去除噪声数据。牢记这些目标,可以有效优化 RAG 工作流程的数据处理和检索过程。

探索 LLM Twin 的 RAG 特性管道架构

在对 RAG 及其工作原理有了深入理解后,我们将继续探讨具体的 LLM Twin 用例。目标是提供一个实际的端到端示例,以巩固本章的理论内容。

任何 RAG 系统都分为两个独立的组件:

  1. 数据导入管道:接收原始数据,对其进行清洗、分块、嵌入,并将其加载到向量数据库中。
  2. 推理管道:查询向量数据库以获取相关上下文,最终通过 LLM 生成答案。

本章将重点实现 RAG 数据导入管道,第 9 章中将继续开发推理管道。

考虑到这一点,让我们快速回顾我们试图解决的问题以及原始数据的来源。请记住,我们正在构建一个端到端的机器学习系统,因此所有组件通过接口(或契约)进行交互,并且每个管道都有单一职责。在本例中,我们将原始文档导入、预处理并加载到向量数据库中。

我们要解决的问题

正如上一章所述,本书旨在展示如何构建一个由端到端 ML 系统支持的生产级 LLM Twin。本章特别希望设计一个 RAG 特性管道,从 MongoDB 数据仓库中提取原始社交媒体数据(如文章、代码仓库和帖子)。这些原始文档的文本将被清洗、分块、嵌入,最终加载到特性存储中。正如第 1 章中讨论的,我们将使用 ZenML 工件和 Qdrant 向量数据库实现逻辑特性存储。

为了构建一个全自动的特性管道,我们希望同步数据仓库和逻辑特性存储。请记住,在推理阶段,生成答案所需的上下文来自向量数据库。因此,数据仓库与特性存储之间的同步速度将直接影响我们 RAG 算法的准确性。

另一个关键考虑因素是如何自动化特性管道并将其集成到整个 ML 系统中。我们的目标是最大限度地减少两个数据存储之间的不同步现象,否则可能会影响系统的完整性。

总结而言,我们需要设计一个特性管道,持续同步数据仓库和逻辑特性存储,同时对数据进行相应处理。在特性存储中拥有数据对于生产级 ML 系统至关重要。LLM Twin 的推理管道将查询特性存储以执行 RAG,而训练管道将从中获取跟踪和版本化的微调数据集。

特性存储

特性存储将成为训练和推理管道中使用的所有特性的中心访问点。训练管道将使用特性存储中的清洗数据(作为工件存储)来微调 LLM,而推理管道将查询向量数据库以获取 RAG 所需的分块文档。这就是我们设计特性管道而不仅仅是 RAG 导入管道的原因。在实际操作中,特性管道包含多个子组件,其中之一是 RAG 逻辑。

需要牢记的是,特性管道主要用作思维导图,以帮助管理 ML 系统的复杂性。它清楚地表明以原始数据作为输入,然后输出特性和可选标签并将其存储在特性存储中。因此,可以直观地理解为:在数据仓库和特性存储之间的所有逻辑都属于特性管道命名空间,其中包含一个或多个子管道。例如,我们将实现一个新的管道,将清洗后的数据处理成指令数据集并存储为工件;这也归于特性管道的范畴,因为这些工件是逻辑特性存储的一部分。另一个例子是基于原始数据或计算出的特性实现数据验证管道。

另一个重要的观察是,如果按照标准约定,存储为字符串的文本数据不被视为特性。特性是直接输入模型的内容。例如,要将指令数据集或分块文档视为特性,就必须将其标记化。为什么呢?因为直接输入到模型的是标记,而不是字符串形式的句子。不幸的是,这使得系统变得更复杂且不够灵活。因此,我们将在运行时执行标记化。但这个观察很重要,因为它清楚表明不需要过于拘泥于特性/训练/推理(FTI)架构,而是应根据具体用例进行调整。

原始数据的来源

简要回顾一下,所有原始文档都存储在 MongoDB 数据仓库中。该数据仓库由第 3 章中介绍的数据收集 ETL 管道填充。ETL 管道从 Medium 和 Substack 等平台爬取数据,标准化后加载到 MongoDB 中。有关该主题的更多详细信息,请参见第 3 章。

设计 RAG 特性管道的架构

最后一步是设计 LLM Twin 应用的 RAG 特性管道架构。我们将采用批处理设计,定期从 MongoDB 数据仓库提取数据,进行处理,并将其加载到 Qdrant 向量数据库中。首先需要问自己一个问题:“为什么选择批处理管道?”

在回答这个问题之前,先快速了解批处理架构的工作方式以及与流式设计的比较。

image.png

批处理管道

在数据系统中,批处理管道是一种数据处理方法,在预定义的时间间隔内以较大批量(即“批次”)收集、处理和存储数据。这种方法与实时或流式数据处理不同,后者会在数据到达时持续处理。批处理管道的工作流程如下:

  1. 数据收集:从各种来源收集数据并存储,直到积累足够量的数据进行处理。这些来源可以包括数据库、日志、文件等。
  2. 定期处理:数据处理按固定时间间隔安排,例如每小时或每日。在此期间,对收集的数据进行批量处理,包括数据清洗、转换、聚合等操作。
  3. 数据加载:处理后,将数据加载到目标系统中,如数据库、数据仓库、数据湖或特性存储中,供分析、查询或进一步处理使用。

批处理管道在处理不需要立即处理的大量数据时特别有用,其优势包括:

  • 效率:批处理能够更高效地处理大量数据,优化资源分配并支持并行处理。
  • 复杂处理:批处理可以执行复杂的数据转换和聚合操作,这些操作可能对于实时处理来说过于耗费资源。
  • 简化性:批处理系统的架构通常比实时系统更简单,因此更易于实现和维护。

批处理与流式管道的比较

在实现特性管道时,主要有两种设计选择:批处理和流式处理。因此,了解两者之间的差异以及为何在 LLM Twin 用例中选择批处理架构是有价值的。

流式管道架构通常更复杂。流式应用的核心组件是一个分布式事件流平台(如 Apache Kafka 或 Redpanda)用于存储来自多个客户端的事件,以及一个流处理引擎(如 Apache Flink 或 Bytewax)来处理事件。为了简化架构,可以用 RabbitMQ 等队列替代事件流平台来存储事件直至处理完毕。

表 4.1:批处理与流式管道对比
方面批处理管道流式管道
处理调度在固定时间间隔内处理数据(如每分钟、每小时、每天)持续处理数据,延迟最小
效率更高效地处理大量数据,优化资源分配和并行处理处理单个数据点,提供即时洞察和更新,快速响应变化
处理复杂度能够执行复杂的数据转换和聚合设计用于处理高速数据流,延迟低
使用场景适用于不需即时数据处理的场景,常用于数据仓储、报告、ETL 处理和特性管道理想用于实时分析、特性、监控和事件驱动架构
系统复杂性与流式管道相比,更容易实现和维护需要低延迟处理、容错和可扩展性,因此更复杂,工具也更高级和复杂

例如,流式管道在社交媒体推荐系统(如 TikTok)中极为强大。用户行为变化频繁,推荐系统必须及时捕捉这些行为变化以保持用户的兴趣,而批处理在这一场景中显得不够及时。流式架构同样是实时欺诈检测算法(如 Stripe 和 PayPal)以及高频交易平台的基础,而这些系统需要毫秒级的响应。

另一方面,离线推荐系统(如电商或流媒体平台)可以使用批处理架构,因为用户行为变化较少。因此,可以基于历史行为数据在夜间更新推荐内容,批处理更加容易实现且成本更低。批处理管道在 ETL 设计中也很常见,用于从多个来源提取数据并加载到数据仓库。

对于 LLM Twin 用例中的数据收集管道也是一种 ETL 管道,从互联网抓取数据、结构化后加载到数据仓库中以供后续处理。

除了特性或预测的新鲜度,批处理管道的另一个缺点是通常会产生冗余预测。例如在流媒体平台(如 Netflix)中为所有用户生成每日预测,可能有大量用户当天未登录,或用户仅浏览部分推荐内容,因此浪费了计算资源。

因此,常见策略是从批处理架构开始,因为其实现更快更简单,然后在产品完善后逐步转向流式设计以降低成本并提升用户体验。

选择批处理架构的原因

总结来说,我们为 LLM Twin 的特性管道选择批处理架构(而非流式架构)的原因有以下几点:

  • 不需要即时数据处理:虽然数据仓库与特性存储的同步对于准确的 RAG 系统至关重要,但几分钟的延迟是可接受的。我们可以将批处理管道设置为每分钟运行,持续同步两个数据存储。由于数据量较小(仅有数千条记录),可以快速完成同步。
  • 简化性:流式管道的实现复杂度是批处理的两倍。为了在现实环境中尽量保持系统简单,便于理解、调试和维护。简单的架构通常也意味着更低的基础设施和开发成本。

图 8.10 比较了基于架构(流式与批处理)和数据量(小数据与大数据)选择的工具。在我们的用例中,我们处于小数据与批处理的象限,选择了 Python 和生成式 AI 工具的组合,如 LangChain、Sentence Transformers 和 Unstructured。

image.png

在本章稍后的“变更数据捕获:同步数据仓库和特性存储”部分中,我们将讨论何时需要从批处理架构切换到流式架构。

核心步骤

大多数 RAG 特性管道由五个核心步骤组成,LLM Twin 架构实现的管道也不例外。你可以将这一模式轻松适配到其他 RAG 应用中,LLM Twin 的 RAG 特性管道步骤如下:

  1. 数据提取:从 MongoDB 数据仓库提取最新的文章、代码仓库和帖子。此步骤通常会汇总所有需要处理的数据。
  2. 清洗:数据仓库中的数据是标准化的且部分清理过,但我们必须确保文本仅包含有用信息、无重复,并能被嵌入模型解读。例如,在将文本传递给嵌入模型之前,必须清洗并规范化所有非 ASCII 字符。为了保持语义密度,我们决定将所有 URL 替换为占位符并移除所有表情符号。清洗步骤更像是一门艺术,通常在完成初次迭代并添加评估机制后,还会不断改进。
  3. 分块:基于每种数据类别和嵌入模型采用不同的分块策略。例如,在处理代码仓库时希望分块范围更大,而处理文章时则希望范围更小甚至在段落级别。根据数据特点,可按章节、部分、段落、句子或固定窗口大小分块。同时,需确保分块大小不超过嵌入模型的最大输入尺寸。通常根据数据结构和模型输入限制进行分块。
  4. 嵌入:将每个分块单独传递给选定的嵌入模型。实现上,嵌入步骤通常最简单,因为如 SentenceTransformer 和 Hugging Face 等工具提供了高层接口。最关键的决定是选择适用的模型并决定是否需要微调。例如,我们使用了 SentenceTransformer 的“all-mpnet-base-v2”嵌入模型,适配大多数机器运行。不过我们提供了配置文件,用户可以根据最新技术轻松配置更强大的嵌入模型。
  5. 数据加载:最后一步是将分块文档的嵌入及其元数据(如作者、文档 ID、内容、URL、平台和创建日期)组合在一起,并将其推送到 Qdrant 向量数据库。为了将 Qdrant 用作特性的唯一数据来源,我们还将清洗后的文档(分块前)推送到 Qdrant。Qdrant 的元数据索引像 NoSQL 数据库,因此可以推送没有向量的数据,类似于使用标准的 NoSQL 引擎。

变更数据捕获:同步数据仓库和特性存储

正如本章多次提到的,数据在不断变化,导致数据库、数据湖、数据仓库和特性存储之间可能不同步。变更数据捕获(CDC)是一种同步多种数据存储类型的策略,避免额外的计算和 I/O 开销。CDC 捕获源数据库的任何 CRUD 操作并将其复制到目标数据库上,可选地在复制间添加预处理步骤。

在构建特性管道时,同步问题同样存在。关键设计选择是如何同步数据仓库和特性存储,以保持数据的实时性。

在 LLM Twin 的用例中,我们出于简化选择了一个朴素的方法:实现一个批处理管道,定期或手动触发。该管道从数据仓库读取所有原始数据,以批处理方式处理并将新记录插入或更新至 Qdrant 向量数据库。这在数据量小(千至几万条记录)时效果良好,但此方法带来以下问题:

  • 如果数据突然增加到数百万条或更多怎么办?
  • 如果记录从数据仓库中删除,该如何在特性存储中反映出来?
  • 如果只想处理数据仓库中的新增或更新项,而不是所有数据呢?

幸运的是,CDC 模式可以解决这些问题。在实现 CDC 时,可以选择多种方法,所有方法都使用推或拉策略:

  • 推模式:在推模式中,源数据库主动识别并传输数据更改到目标系统,实现几乎即时更新。缺点是如果目标系统无法访问会导致数据丢失,通常使用消息系统作为缓冲。
  • 拉模式:在拉模式中,源数据库仅记录数据更改,目标系统定期请求这些更改进行更新。此方法减轻了源数据库负载,但数据传播有延迟。为避免目标系统不可用时的数据丢失,消息系统同样重要。

总结来说,推模式适用于需要即时数据访问的应用,而拉模式更适合不需要实时更新的大规模数据传输。下面是常用的 CDC 模式:

  • 基于时间戳:在数据库表中添加一个修改时间列(如 LAST_MODIFIED 或 LAST_UPDATED)。下游系统可以查询此列来识别自上次检查以来更新的记录。实现简单,但仅限于跟踪更改,无法跟踪删除操作。
  • 基于触发器:利用数据库触发器在每次 INSERT、UPDATE 或 DELETE 操作时自动在单独的表中记录数据更改。这种方法跟踪全面但会影响数据库性能。
  • 基于日志:数据库的事务日志记录所有数据更改,包括时间戳。这种方法对源数据库性能影响最小,且无需更改模式,但缺点是缺乏标准化的日志格式,需要供应商特定的实现。

关于 CDC 的更多细节,推荐阅读 Confluent 博客上的文章 What is Change Data Capture?

利用这些 CDC 技术,我们可以在 RAG 特性管道中快速实现基于时间戳的拉取策略,以便在数据量增大时更优化地同步数据仓库和特性存储。

为什么将数据存储为两个快照?

在逻辑特性存储中,我们将数据存储为两个快照:

  1. 数据清洗后:用于微调 LLM。
  2. 文档分块和嵌入后:用于 RAG。

设计成这样的原因在于,训练和推理时特性应仅从特性存储中访问。这增加了设计的一致性,使架构更清晰。

此外,将专门为微调和嵌入清洗后的数据存储在 MongoDB 数据仓库中并不合适。数据仓库的数据是公司范围内共享的,为特定用例处理数据并不是最佳实践。例如,另一个摘要用例可能需要不同的数据清洗和预处理方法。为每个新用例创建带有用例前缀的“清洗数据”表会导致数据仓库混乱不堪。因此,数据仓库中的数据保持通用性,且仅在下游组件中按特定应用建模,在本例中是特性存储。

最后,正如我们在“核心步骤”部分提到的,你可以利用向量数据库的元数据索引作为 NoSQL 数据库。基于这些因素,我们决定在 Qdrant 中保留清洗后的数据,以及文档的分块和嵌入版本。

快速提醒一下,当将 LLM Twin 系统投入生产时,第 5 章介绍的创建指令数据集管道将从 Qdrant 读取清洗后的文档,对其进行处理,并将其作为版本化的 ZenML 工件保存。训练管道需要数据集,而不是原始文档。这提醒我们,逻辑特性存储包括用于在线服务的 Qdrant 向量数据库和用于离线训练的 ZenML 工件。

编排

ZenML 将负责编排批处理 RAG 特性管道。通过 ZenML,可以将其设定为定时运行(例如每小时运行一次),或者手动快速触发。另一种选择是在 ETL 数据收集管道完成后触发。

将特性管道集成到 ZenML(或其他编排工具)中实现自动化特性管道的目标是持续训练(CT)。我们将在第 11 章中详细讨论编排、调度和 CT。

实现 LLM Twin 的 RAG 特性管道

最后一步是回顾 LLM Twin 的 RAG 特性管道代码,看看我们如何应用本章讨论的内容。我们将逐步介绍以下内容:

  • ZenML 代码
  • Pydantic 域对象
  • 自定义对象-向量映射(OVM)实现
  • 所有数据类别的清洗、分块和嵌入逻辑

我们将采用自顶向下的方法,因此从 Settings 类和 ZenML 管道开始。

设置 (Settings)

我们使用 Pydantic 的 Settings 类(Pydantic Settings 文档)定义一个全局 Settings 类,从 .env 文件中加载敏感或非敏感变量。这种方法还带来了 Pydantic 的诸多优势,例如类型验证。例如,如果我们为 QDRANT_DATABASE_PORT 提供了字符串而不是整数,程序会崩溃。这种行为使整个应用程序更加确定且可靠。

以下是 Settings 类的示例代码,包含构建 RAG 特性管道所需的所有变量:

from pydantic import BaseSettings

class Settings(BaseSettings):
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"
    
    # RAG 配置
    TEXT_EMBEDDING_MODEL_ID: str = "sentence-transformers/all-MiniLM-L6-v2"
    RERANKING_CROSS_ENCODER_MODEL_ID: str = "cross-encoder/ms-marco-MiniLM-L-4-v2"
    RAG_MODEL_DEVICE: str = "cpu"
    
    # Qdrant 向量数据库
    USE_QDRANT_CLOUD: bool = False
    QDRANT_DATABASE_HOST: str = "localhost"
    QDRANT_DATABASE_PORT: int = 6333
    QDRANT_CLOUD_URL: str = "str"
    QDRANT_APIKEY: str | None = None

    … # 其他配置项 …

settings = Settings()

如内部 Config 类中所示,所有变量都有默认值,或可以通过提供 .env 文件来覆盖默认值。

ZenML 管道及步骤

ZenML 管道是 RAG 特性工程管道的入口,反映了 RAG 数据导入代码的五个核心阶段:提取原始文档、清洗、分块、嵌入并加载到逻辑特性存储中。feature_engineering() 函数中的调用是 ZenML 步骤,表示执行 RAG 的五个阶段的单个执行单元。代码可在 GitHub 仓库中找到:GitHub 链接

from zenml import pipeline
from llm_engineering.interfaces.orchestrator.steps import feature_engineering as fe_steps

@pipeline
def feature_engineering(author_full_names: list[str]) -> None:
    raw_documents = fe_steps.query_data_warehouse(author_full_names)
    cleaned_documents = fe_steps.clean_documents(raw_documents)
    last_step_1 = fe_steps.load_to_vector_db(cleaned_documents)
    embedded_documents = fe_steps.chunk_and_embed(cleaned_documents)
    last_step_2 = fe_steps.load_to_vector_db(embedded_documents)
    return [last_step_1.invocation_id, last_step_2.invocation_id]

如图 4.11 所示,在 ZenML 的仪表板中可以看到多次特性工程管道运行的情况。

image.png

图 8.12 展示了 RAG 特性管道的 DAG,在其中可以跟踪所有管道步骤及其输出工件。请记住,任何从 ZenML 步骤返回的内容都会自动保存为工件,存储在 ZenML 的工件注册表中,具备版本控制功能,并可在应用程序中共享。

image.png

最后一块拼图是了解如何动态配置 RAG 特性管道。其所有可用设置都作为函数参数公开。这里我们只需要一个作者姓名的列表,如函数签名所示:feature_engineering(author_full_names: list[str])。我们在运行时注入一个 YAML 配置文件,其中包含基于不同用例的所有必要值。例如,以下配置包含本书所有作者的姓名列表,因为我们希望用所有作者的数据填充特性存储(在 GitHub 仓库的 configs/feature_engineering.yaml 中提供):

parameters:
  author_full_names:
    - Alex Vesa
    - Maxime Labonne
    - Paul Iusztin

这种方法的优点在于,不需要修改代码即可用不同的输入值配置特性管道。运行时只需提供不同的配置文件,如下所示:

feature_engineering.with_options(config_path="…/feature_engineering.yaml")()

可以将配置文件路径硬编码,也可以通过 CLI 提供 config_path,以便在不同运行间修改管道配置。为简化操作,我们将配置文件硬编码。因此,可以通过调用 run.py 脚本来运行特性工程管道,命令如下:

python -m tools.run --no-cache --run-feature-engineering

当然,也可以轻松添加一个 CLI 参数传递 config_path 变量。还可以使用以下 poe 命令运行特性管道:

poetry poe run-feature-engineering-pipeline

接下来我们深入 ZenML 的各个步骤,逐步剖析每一个步骤。所有特性工程管道步骤的源码可在 GitHub 的 "steps/feature_engineering" 中找到。我们将从第一个步骤开始,即查询数据仓库以获取新内容并处理为特性。

查询数据仓库

首先需要注意的是,每个步骤都是一个使用 @step 装饰的 Python 函数,类似于 ZenML 管道的工作方式。以下函数接受作者全名列表作为输入,并执行以下核心步骤:

  1. 尝试使用作者的名字和姓氏获取或创建一个 UserDocument 实例,并将该实例添加到作者列表中。如果用户不存在,则会引发错误。
  2. 从数据仓库中获取该用户的所有原始数据,并将这些用户文档扩展到 documents 列表中。
  3. 最后,计算一个描述性元数据字典,记录并跟踪在 ZenML 中。
# 其他导入
from zenml import get_step_context, step

@step
def query_data_warehouse(
    author_full_names: list[str],
) -> Annotated[list, "raw_documents"]:
    documents = []
    authors = []
    for author_full_name in author_full_names:
        logger.info(f"查询数据仓库的用户:{author_full_name}")
        first_name, last_name = utils.split_user_full_name(author_full_name)
        logger.info(f"名字:{first_name}, 姓氏:{last_name}")
        user = UserDocument.get_or_create(first_name=first_name, last_name=last_name)
        authors.append(user)
        results = fetch_all_data(user)
        user_documents = [doc for query_result in results.values() for doc in query_result]
        documents.extend(user_documents)
    step_context = get_step_context()
    step_context.add_output_metadata(output_name="raw_documents", metadata=_get_metadata(documents))
    return documents

fetch_all_data() 函数利用线程池并行处理每个查询,因为我们有多个数据类别,每类都要分别查询文章、帖子和代码仓库的数据,因为它们存储在不同的集合中。每个查询都会调用数据仓库,由网络 I/O 和数据仓库延迟限制,而不是机器的 CPU。因此,将每个查询放到不同的线程中可以并行化这些操作,最终取最大查询延迟作为总执行时间,而不是累加每个查询的延迟。

在 Python 中,将 I/O 限制的调用并行化是良好的做法,因为它们不会被 Python 全局解释器锁(GIL)限制。而如果为每个调用启动单独的进程则会增加过多开销,因为进程启动比线程更耗时。并行化 CPU 或内存密集型操作时,适合使用进程,因为每个进程有不同的 GIL。

def fetch_all_data(user: UserDocument) -> dict[str, list[NoSQLBaseDocument]]:
    user_id = str(user.id)
    with ThreadPoolExecutor() as executor:
        future_to_query = {
            executor.submit(__fetch_articles, user_id): "articles",
            executor.submit(__fetch_posts, user_id): "posts",
            executor.submit(__fetch_repositories, user_id): "repositories",
        }
        results = {}
        for future in as_completed(future_to_query):
            query_name = future_to_query[future]
            try:
                results[query_name] = future.result()
            except Exception:
                logger.exception(f"'{query_name}' 请求失败。")
                results[query_name] = []
    return results

_get_metadata() 函数接收查询的文档和作者列表,并统计每个数据类别的文档数量:

def _get_metadata(documents: list[Document]) -> dict:
    metadata = {
        "num_documents": len(documents),
    }
    for document in documents:
        collection = document.get_collection_name()
        if collection not in metadata:
            metadata[collection] = {}
        if "authors" not in metadata[collection]:
            metadata[collection]["authors"] = list()
        metadata[collection]["num_documents"] = metadata[collection].get("num_documents", 0) + 1
        metadata[collection]["authors"].append(document.author_full_name)
    for value in metadata.values():
        if isinstance(value, dict) and "authors" in value:
            value["authors"] = list(set(value["authors"]))
    return metadata

我们将在 ZenML 仪表板中展示这些元数据,以快速查看加载数据的一些统计信息。例如,在图 4.13 中,可以看到 query_data_warehouse() 步骤的元数据标签,显示在该特性管道的运行中,我们从三位作者那里加载了 76 篇文档。这对监控和调试批处理管道非常有帮助。

你可以根据用例需要,随时扩展添加任何有意义的信息。

image.png

文档清洗

在清洗步骤中,我们遍历所有文档,并将所有逻辑委托给 CleaningDispatcher,它根据数据类别决定应用何种清洗逻辑。这样可以根据不同的数据类型(例如文章、帖子和代码仓库)应用不同的清洗方法,或者在未来具备应用不同清洗方法的能力。

@step
def clean_documents(
    documents: Annotated[list, "raw_documents"],
) -> Annotated[list, "cleaned_documents"]:
    cleaned_documents = []
    for document in documents:
        cleaned_document = CleaningDispatcher.dispatch(document)
        cleaned_documents.append(cleaned_document)
    step_context = get_step_context()
    step_context.add_output_metadata(output_name="cleaned_documents", metadata=_get_metadata(cleaned_documents))
    return cleaned_documents

计算出的元数据与 query_data_warehouse() 步骤中记录的类似。接下来进入分块和嵌入部分。

分块和嵌入清洗后的文档

与清洗文档类似,我们将分块和嵌入逻辑委托给一个调度器,该调度器根据数据类别处理不同的数据类型。需要注意的是,分块调度器返回的是一个列表而不是单个对象,这是合理的,因为文档会被拆分为多个块。本章的“调度层”部分将详细探讨调度器的实现。

@step
def chunk_and_embed(
    cleaned_documents: Annotated[list, "cleaned_documents"],
) -> Annotated[list, "embedded_documents"]:
    metadata = {"chunking": {}, "embedding": {}, "num_documents": len(cleaned_documents)}
    embedded_chunks = []
    for document in cleaned_documents:
        chunks = ChunkingDispatcher.dispatch(document)
        metadata["chunking"] = _add_chunks_metadata(chunks, metadata["chunking"])
        for batched_chunks in utils.misc.batch(chunks, 10):
            batched_embedded_chunks = EmbeddingDispatcher.dispatch(batched_chunks)
            embedded_chunks.extend(batched_embedded_chunks)
    metadata["embedding"] = _add_embeddings_metadata(embedded_chunks, metadata["embedding"])
    metadata["num_chunks"] = len(embedded_chunks)
    metadata["num_embedded_chunks"] = len(embedded_chunks)
    step_context = get_step_context()
    step_context.add_output_metadata(output_name="embedded_documents", metadata=metadata)
    return embedded_chunks

在图 4.14 中,可以看到分块和嵌入步骤的 ZenML 元数据。例如,可以快速了解到我们将 76 个文档转换为 2,373 个分块,以及用于文章分块的参数(如 chunk_size 为 500 和 chunk_overlap 为 50)。

image.png

在图 4.15 中,ZenML 的嵌入和分块步骤的其余元数据详细展示了用于计算向量的嵌入模型及其属性。

image.png

由于机器学习系统在生产环境中可能随时因漂移或未处理的用例而出现故障,利用元数据部分监控导入数据可以成为一项强大的工具,能够节省数天的调试时间,为业务节省数万美元甚至更多。

将文档加载到向量数据库

每个文章、帖子或代码仓库存储在向量数据库的不同集合中,因此我们需要根据数据类别对所有文档进行分组,然后将每组批量加载到 Qdrant 向量数据库中:

@step
def load_to_vector_db(
    documents: Annotated[list, "documents"],
) -> None:
    logger.info(f"将 {len(documents)} 个文档加载到向量数据库中。")
    grouped_documents = VectorBaseDocument.group_by_class(documents)
    for document_class, documents in grouped_documents.items():
        logger.info(f"将文档加载到 {document_class.get_collection_name()}")
        for documents_batch in utils.misc.batch(documents, size=4):
            try:
                document_class.bulk_insert(documents_batch)
            except Exception:
                return False
    return True

Pydantic 域实体

在了解调度器之前,我们必须先了解所使用的域对象。在实现 LLM Twin 时,我们部分遵循了领域驱动设计 (DDD) 的原则,这些原则认为域实体是应用程序的核心。因此,了解域类的层次结构非常重要。

域实体的代码可以在 GitHub 上找到:GitHub 链接

我们使用 Pydantic 来建模所有的域实体。写这本书时选择 Pydantic 是一个明智的决定,因为它是用于编写数据结构的首选 Python 包,具有开箱即用的类型验证功能。由于 Python 是动态类型语言,使用 Pydantic 进行运行时的类型验证可以显著增强系统的稳健性,确保始终处理正确类型的数据。

LLM Twin 应用程序的域分为两个维度:

  • 数据类别:帖子、文章和代码库
  • 数据状态:已清洗、已分块和已嵌入

我们决定为文档的每种状态创建一个基类,得到以下抽象基类:

class CleanedDocument(VectorBaseDocument, ABC)
class Chunk(VectorBaseDocument, ABC)
class EmbeddedChunk(VectorBaseDocument, ABC)

注意,这些类都继承自 VectorBaseDocument,这是我们自定义的对象-向量映射 (OVM) 实现,在本章的下一节中将详细解释。此外,它们还继承了 ABC,这使类成为抽象类,无法直接实例化这些类的对象,只能从它们继承。这也是基类通常被标记为抽象的原因。

每个上述的基类(表示状态)将会有一个子类来添加数据类别维度。例如,CleanedDocument 类将有以下子类:

class CleanedPostDocument(CleanedDocument)
class CleanedArticleDocument(CleanedDocument)
class CleanedRepositoryDocument(CleanedDocument)

如图 8.16 所示,我们将对 ChunkEmbeddedChunk 抽象基类重复相同的逻辑。我们会为每种数据类别和状态组合实现一个特定的文档类,最终得到九种类型的域实体。例如,在导入原始文档时,清洗步骤会生成一个 CleanedArticleDocument 实例,分块步骤会返回一个 ArticleChunk 对象列表,而嵌入操作会返回包含嵌入数据和向量数据库所需元数据的 EmbeddedArticleChunk 实例。

对于帖子和代码库也是同样的处理逻辑。

image.png

我们选择这种设计是因为状态列表很少更改,并且我们希望扩展数据类别列表。因此,按照状态来构建类结构,使我们可以通过继承这些基础抽象类来添加另一个数据类别。

让我们看看清理后的文档层级结构的完整代码。清理后的文档的所有属性都将保存在向量数据库的元数据中。例如,清理后的文章文档的元数据将始终包含文章的内容、平台、作者ID、作者全名以及文章链接。

另一个基本方面是 Config 内部类,它定义了向量数据库中的集合名称、实体的数据类别,以及在创建集合时是否利用向量索引:

class CleanedDocument(VectorBaseDocument, ABC):
    content: str
    platform: str
    author_id: UUID4
    author_full_name: str

class CleanedPostDocument(CleanedDocument):
    image: Optional[str] = None
    class Config:
        name = "cleaned_posts"
        category = DataCategory.POSTS
        use_vector_index = False

class CleanedArticleDocument(CleanedDocument):
    link: str
    class Config:
        name = "cleaned_articles"
        category = DataCategory.ARTICLES
        use_vector_index = False

class CleanedRepositoryDocument(CleanedDocument):
    name: str
    link: str
    class Config:
        name = "cleaned_repositories"
        category = DataCategory.REPOSITORIES
        use_vector_index = False

在结束本节之前,让我们还看一下 ChunkEmbeddedChunk 的基础抽象类:

class Chunk(VectorBaseDocument, ABC):
    content: str
    platform: str
    document_id: UUID4
    author_id: UUID4
    author_full_name: str
    metadata: dict = Field(default_factory=dict)
# … PostChunk, ArticleChunk, RepositoryChunk

class EmbeddedChunk(VectorBaseDocument, ABC):
    content: str
    embedding: list[float] | None
    platform: str
    document_id: UUID4
    author_id: UUID4
    author_full_name: str
    metadata: dict = Field(default_factory=dict)
# … EmbeddedPostChunk, EmbeddedArticleChunk, EmbeddedRepositoryChunk

我们还定义了一个枚举,将所有数据类别聚合到一个常量结构中:

class DataCategory(StrEnum):
    POSTS = "posts"
    ARTICLES = "articles"
    REPOSITORIES = "repositories"

最后一步是深入了解 VectorBaseDocument OVM 类的工作原理,以完全理解领域对象的运作方式。

OVM

OVM 的术语源自我们在第三章讨论的对象关系映射(ORM)模式。我们称之为 OVM 是因为我们使用的是嵌入和向量数据库,而不是结构化数据和 SQL 表。除此之外,它遵循与 ORM 模式相同的原则。

与第三章类似,我们将实现我们自己的 OVM 版本。尽管我们的示例很简单,但它展示了如何利用面向对象编程的最佳实践和原则来编写模块化和可扩展的类。

VectorBaseDocument 类的完整实现可以在 GitHub 上找到:链接

我们的 OVM 基类称为 VectorBaseDocument,它支持在 Qdrant 上执行 CRUD 操作。根据应用需求,我们仅限于创建和读取操作,但它可以很容易地扩展为更新和删除功能。

让我们看一下 VectorBaseDocument 类的定义:

from pydantic import UUID4, BaseModel
from typing import Generic
from llm_engineering.infrastructure.db.qdrant import connection

T = TypeVar("T", bound="VectorBaseDocument")

class VectorBaseDocument(BaseModel, Generic[T], ABC):
    id: UUID4 = Field(default_factory=uuid.uuid4)

    @classmethod
    def from_record(cls: Type[T], point: Record) -> T:
        _id = UUID(point.id, version=4)
        payload = point.payload or {}
        attributes = {
            "id": _id,
            **payload,
        }
        if cls._has_class_attribute("embedding"):
            payload["embedding"] = point.vector or None
        return cls(**attributes)

    def to_point(self: T, **kwargs) -> PointStruct:
        exclude_unset = kwargs.pop("exclude_unset", False)
        by_alias = kwargs.pop("by_alias", True)
        payload = self.dict(exclude_unset=exclude_unset, by_alias=by_alias, **kwargs)
        _id = str(payload.pop("id"))
        vector = payload.pop("embedding", {})
        if vector and isinstance(vector, np.ndarray):
            vector = vector.tolist()
        return PointStruct(id=_id, vector=vector, payload=payload)

VectorBaseDocument 类继承了 Pydantic 的 BaseModel,帮助我们结构化向量数据库中的单条记录的属性。每个 OVM 都默认使用 UUID4 作为唯一标识符。通过使用泛型(更准确地说是继承自 Generic[T]),VectorBaseDocument 类的所有子类的签名都将适应该类。例如,继承自 VectorBaseDocumentChunk 类的 from_record() 方法将返回 Chunk 类型,这极大地帮助了静态分析器和类型检查器(例如 mypy)。

from_record() 方法根据 Pydantic 将 Qdrant 的数据点格式转换为我们的内部结构。而 to_point() 方法则将当前实例的属性转换为 Qdrant 的 PointStruct() 格式。这两个方法将用于我们的创建和读取操作。

所有对 Qdrant 的操作都将通过在应用的基础设施层实例化的 connection 实例完成。

bulk_insert() 方法将每个文档映射到一个点,然后使用 Qdrant 的 connection 实例将所有点加载到 Qdrant 的指定集合中。如果插入失败,它会尝试创建集合并重新插入文档。通常,将逻辑分为两个函数是一个良好的实践。一个私有函数包含逻辑(在本例中为 _bulk_insert()),另一个公共函数处理所有错误和失败场景。

class VectorBaseDocument(BaseModel, Generic[T], ABC):
    # 省略其他内容
    @classmethod
    def bulk_insert(cls: Type[T], documents: list["VectorBaseDocument"]) -> bool:
        try:
            cls._bulk_insert(documents)
        except exceptions.UnexpectedResponse:
            logger.info(
                f"Collection '{cls.get_collection_name()}' does not exist. Trying to create the collection and reinsert the documents."
            )
            cls.create_collection()
            try:
                cls._bulk_insert(documents)
            except exceptions.UnexpectedResponse:
                logger.error(f"Failed to insert documents in '{cls.get_collection_name()}'.")
                return False
        return True

    @classmethod
    def _bulk_insert(cls: Type[T], documents: list["VectorBaseDocument"]) -> None:
        points = [doc.to_point() for doc in documents]
        connection.upsert(collection_name=cls.get_collection_name(), points=points)

集合名称从继承 OVM 的子类中定义的 Config 类中推断:

class VectorBaseDocument(BaseModel, Generic[T], ABC):
    # 省略其他内容
    @classmethod
    def get_collection_name(cls: Type[T]) -> str:
        if not hasattr(cls, "Config") or not hasattr(cls.Config, "name"):
            raise ImproperlyConfigured(
                "The class should define a Config class with the 'name' property that reflects the collection's name."
            )
        return cls.Config.name

我们还需要定义一个方法,允许我们从向量数据库中读取所有记录(不使用向量相似性搜索逻辑)。bulk_find() 方法使我们能够滚动(或列出)集合中的所有记录。以下函数滚动 Qdrant 向量数据库,返回一个数据点列表,并通过 from_record() 方法将它们映射到我们的内部结构。

class VectorBaseDocument(BaseModel, Generic[T], ABC):
    # 省略其他内容
    @classmethod
    def bulk_find(cls: Type[T], limit: int = 10, **kwargs) -> tuple[list[T], UUID | None]:
        try:
            documents, next_offset = cls._bulk_find(limit=limit, **kwargs)
        except exceptions.UnexpectedResponse:
            logger.error(f"Failed to search documents in '{cls.get_collection_name()}'.")
            documents, next_offset = [], None
        return documents, next_offset

    @classmethod
    def _bulk_find(cls: Type[T], limit: int = 10, **kwargs) -> tuple[list[T], UUID | None]:
        collection_name = cls.get_collection_name()
        offset = kwargs.pop("offset", None)
        offset = str(offset) if offset else None
        records, next_offset = connection.scroll(
            collection_name=collection_name,
            limit=limit,
            with_payload=kwargs.pop("with_payload", True),
            with_vectors=kwargs.pop("with_vectors", False),
            offset=offset,
            **kwargs,
        )
        documents = [cls.from_record(record) for record in records]
        if next_offset is not None:
            next_offset = UUID(next_offset, version=4)
        return documents, next_offset

最后一个步骤是定义一个方法,通过提供的查询嵌入执行向量相似性搜索。和之前一样,我们定义了公共 search() 和私有 _search() 方法。搜索是通过调用 connection.search() 函数由 Qdrant 执行的。

class VectorBaseDocument(BaseModel, Generic[T], ABC):
    # 省略其他内容
    @classmethod
    def search(cls: Type[T], query_vector: list, limit: int = 10, **kwargs) -> list[T]:
        try:
            documents = cls._search(query_vector=query_vector, limit=limit, **kwargs)
        except exceptions.UnexpectedResponse:
            logger.error(f"Failed to search documents in '{cls.get_collection_name()}'.")
            documents = []
        return documents

    @classmethod
    def _search(cls: Type[T], query_vector: list, limit: int = 10, **kwargs) -> list[T]:
        collection_name = cls.get_collection_name()
        records = connection.search(
            collection_name=collection_name,
            query_vector=query_vector,
            limit=limit,
            with_payload=kwargs.pop("with_payload", True),
            with_vectors=kwargs.pop("with_vectors", False),
            **kwargs,
        )
        documents = [cls.from_record(record) for record in records]
        return documents

现在我们已经了解了领域实体的外观和 OVM 的工作原理,接下来我们将讨论负责清理、切分和嵌入文档的调度器。

分发器层

分发器接收一个文档,并根据其数据类别(如文章、帖子或代码库)应用相应的处理器。处理器可以对文档进行清理、分块或嵌入。

让我们从 CleaningDispatcher 开始,它主要实现了一个 dispatch() 方法,该方法接收一个原始文档。根据其数据类别,实例化并调用一个处理器,应用该数据点特定的清理逻辑:

class CleaningDispatcher:
    cleaning_factory = CleaningHandlerFactory()
    
    @classmethod
    def dispatch(cls, data_model: NoSQLBaseDocument) -> VectorBaseDocument:
        data_category = DataCategory(data_model.get_collection_name())
        handler = cls.cleaning_factory.create_handler(data_category)
        clean_model = handler.clean(data_model)
        logger.info(
            "Data cleaned successfully.",
            data_category=data_category,
            cleaned_content_len=len(clean_model.content),
        )
        return clean_model

在分发器逻辑中,关键的是 CleaningHandlerFactory(),它会根据文档的数据类别实例化不同的清理处理器:

class CleaningHandlerFactory:
    @staticmethod
    def create_handler(data_category: DataCategory) -> CleaningDataHandler:
        if data_category == DataCategory.POSTS:
            return PostCleaningHandler()
        elif data_category == DataCategory.ARTICLES:
            return ArticleCleaningHandler()
        elif data_category == DataCategory.REPOSITORIES:
            return RepositoryCleaningHandler()
        else:
            raise ValueError("Unsupported data type")

分发器或工厂类并不复杂,但它们为文档操作提供了一个直观且简单的接口。处理文档时,与其关注数据类别并在业务逻辑中使用大量 if-else 语句,不如专门使用一个类来处理该问题。通过这种方式,我们可以使用一个类来清理任何文档,符合软件工程中的 DRY(不要重复自己)原则。通过遵循 DRY 原则,代码的单点故障更少,并且易于扩展。例如,如果我们添加一个新类型,只需要扩展 Factory 类,而不是修改代码中的多处。

ChunkingDispatcherEmbeddingDispatcher 也遵循相同的模式。它们分别使用 ChunkingHandlerFactoryEmbeddingHandlerFactory,根据输入文档的数据类别初始化正确的处理器。随后,它们调用处理器并返回结果。

所有分发器和工厂类的源代码可以在 GitHub 上找到:链接

工厂类利用了抽象工厂创建模式,它实例化了一组实现相同接口的类。在我们的案例中,这些处理器无论类型如何,都实现了 clean() 方法。

此外,处理器类家族利用了策略行为模式,当需要在对象中使用不同算法变体并在运行时切换算法时,该模式非常实用。

在我们的分发器层中,工厂和策略模式的组合直观地工作如下:

  • 最初,我们知道需要清理数据,但由于我们只能在运行时知道数据类别,因此无法事先确定要应用的策略。
  • 我们可以围绕清理代码编写整个代码,并在 Handler() 接口下抽象出逻辑,这将代表我们的策略。
  • 当我们获得一个数据点时,我们应用抽象工厂模式,为其数据类型创建正确的清理处理器。
  • 最终,分发器层使用处理器并执行正确的策略。

通过这样做,我们可以:

  • 将给定数据类别的逻辑隔离。
  • 利用多态性避免在代码中充斥大量 if-else 语句。
  • 使代码模块化且易于扩展。当有新的数据类别时,我们只需实现一个新的处理器并修改 Factory 类,而无需更改代码的其他部分。

到目前为止,我们只是对实体进行了建模,并展示了数据如何在应用程序中流动。还没有编写任何清理、分块或嵌入的实际代码。这是快速演示和面向生产的应用之间的显著区别。在演示中,你不必关注软件工程最佳实践或构建面向未来的代码结构。但是,在构建实际应用时,编写清晰、模块化和可扩展的代码对其长期可用性至关重要。

RAG 功能管道的最后一个组件是清理、分块和嵌入处理器的实现。

处理器

处理器与我们的领域结构一一对应,这意味着每个实体都有其对应的处理器,如图 8.17 所示。我们将总共拥有九个处理器类,它们遵循以下基础接口:

class CleaningDataHandler()
class ChunkingDataHandler()
class EmbeddingDataHandler()

这些处理器各自负责清理、分块和嵌入操作,根据数据类别对不同类型的文档应用相应的处理逻辑。

image.png

所有处理器的代码可以在 GitHub 上找到:链接

清理处理器

CleaningDataHandler() 策略接口的结构如下:

# 其他导入
from typing import Generic, TypeVar

DocumentT = TypeVar("DocumentT", bound=Document)
CleanedDocumentT = TypeVar("CleanedDocumentT", bound=CleanedDocument)

class CleaningDataHandler(ABC, Generic[DocumentT, CleanedDocumentT]):
    @abstractmethod
    def clean(self, data_model: DocumentT) -> CleanedDocumentT:
        pass

接下来,对于每个 postarticlerepository,我们需要实现不同的处理器,如下所示:

class PostCleaningHandler(CleaningDataHandler):
    def clean(self, data_model: PostDocument) -> CleanedPostDocument:
        return CleanedPostDocument(
            id=data_model.id,
            content=clean_text(" #### ".join(data_model.content.values())),
            # 从 data_model 对象复制其余参数
        )

class ArticleCleaningHandler(CleaningDataHandler):
    def clean(self, data_model: ArticleDocument) -> CleanedArticleDocument:
        valid_content = [content for content in data_model.content.values() if content]
        return CleanedArticleDocument(
            id=data_model.id,
            content=clean_text(" #### ".join(valid_content)),
            platform=data_model.platform,
            link=data_model.link,
            author_id=data_model.author_id,
            author_full_name=data_model.author_full_name,
        )

class RepositoryCleaningHandler(CleaningDataHandler):
    def clean(self, data_model: RepositoryDocument) -> CleanedRepositoryDocument:
        return CleanedRepositoryDocument(
            id=data_model.id,
            content=clean_text(" #### ".join(data_model.content.values())),
            # 从 data_model 对象复制其余参数
        )

这些处理器接收一个原始文档领域实体,清理其内容,并返回一个已清理的文档。所有处理器都使用 clean_text() 函数来清理文本。为了简单起见,我们对所有数据类别都使用了相同的清理技术。然而,在实际场景中,我们需要进一步优化并为每种数据类别创建不同的清理函数。策略模式让这一切变得简单,因为我们只需在处理器中替换清理函数即可。

clean_text() 函数中应用的清理步骤与第 5 章“创建指令数据集”部分讨论的步骤相同。为了避免重复内容,可以参考该章节。此时,我们的主要关注点是将整个逻辑自动化并集成到 RAG 特性管道中。因此,在实现了机器学习系统的生产化后,用于微调的所有清理数据都将从逻辑特征库中访问,从而成为访问数据的单一信息源。

分块处理器

首先,让我们了解 ChunkingDataHandler() 策略处理器。我们将 metadata 字典暴露为一个属性,以便将分块所需的所有必要属性聚合在一个结构中。通过这种结构,我们可以轻松地将所有信息记录到 ZenML 中,以便追踪和调试分块逻辑。该处理器接收已清理的文档作为输入,并返回分块实体。所有处理器代码可以在 GitHub 上找到。

# 其他导入
from typing import Generic, TypeVar

CleanedDocumentT = TypeVar("CleanedDocumentT", bound=CleanedDocument)
ChunkT = TypeVar("ChunkT", bound=Chunk)

class ChunkingDataHandler(ABC, Generic[CleanedDocumentT, ChunkT]):
    @property
    def metadata(self) -> dict:
        return {
            "chunk_size": 500,
            "chunk_overlap": 50,
        }

    @abstractmethod
    def chunk(self, data_model: CleanedDocumentT) -> list[ChunkT]:
        pass
ArticleChunkingHandler 类的实现

我们通过重写 metadata 属性并自定义分块逻辑所需的属性类型来实现 ArticleChunkingHandler() 类。例如,处理文章时,我们关注分块的最小和最大长度。

处理器的 chunk() 方法接收已清理的文章文档并返回一个文章分块实体列表。它使用 chunk_text() 函数将清理后的内容分成多个块。分块函数基于 metadata 中的 min_lengthmax_length 字段进行定制。分块 ID 是通过对分块内容进行 MD5 哈希计算得出的。因此,如果两个分块内容完全相同,它们将拥有相同的 ID,从而便于去重。最后,我们创建一个分块实体列表并返回。

class ArticleChunkingHandler(ChunkingDataHandler):
    @property
    def metadata(self) -> dict:
        return {
            "min_length": 1000,
            "max_length": 1000,
        }

    def chunk(self, data_model: CleanedArticleDocument) -> list[ArticleChunk]:
        data_models_list = []
        cleaned_content = data_model.content
        chunks = chunk_article(
            cleaned_content, min_length=self.metadata["min_length"], max_length=self.metadata["max_length"]
        )
        for chunk in chunks:
            chunk_id = hashlib.md5(chunk.encode()).hexdigest()
            model = ArticleChunk(
                id=UUID(chunk_id, version=4),
                content=chunk,
                platform=data_model.platform,
                link=data_model.link,
                document_id=data_model.id,
                author_id=data_model.author_id,
                author_full_name=data_model.author_full_name,
                metadata=self.metadata,
            )
            data_models_list.append(model)
        return data_models_list
chunk_article() 函数

chunk_article() 函数主要完成两项工作:

  1. 使用正则表达式找到给定文本中的所有句子,寻找句号、问号或感叹号后跟空格的情况。但是,它会避免在标点符号属于缩写(如“e.g.”或“Dr.”)的情况下分割。
  2. 将句子组合成单个分块,直到达到 max_length 限制。当达到最大长度且分块大小大于最小允许值时,将其添加到最终返回的列表中。
def chunk_article(text: str, min_length: int, max_length: int) -> list[str]:
    sentences = re.split(r"(?<!\w.\w.)(?<![A-Z][a-z].)(?<=.|?|!)\s", text)
    extracts = []
    current_chunk = ""
    for sentence in sentences:
        sentence = sentence.strip()
        if not sentence:
            continue
        if len(current_chunk) + len(sentence) <= max_length:
            current_chunk += sentence + " "
        else:
            if len(current_chunk) >= min_length:
                extracts.append(current_chunk.strip())
            current_chunk = sentence + " "
    if len(current_chunk) >= min_length:
        extracts.append(current_chunk.strip())
    return extracts
PostChunkingHandlerRepositoryChunkingHandler

PostChunkingHandlerRepositoryChunkingHandler 的结构与 ArticleChunkingHandler 类似。它们使用了一个更通用的分块函数 chunk_text(),值得一看。chunk_text() 函数是一个两步过程,包含以下逻辑:

  1. 使用 LangChain 的 RecursiveCharacterTextSplitter() 根据给定的分隔符或分块大小分割文本。使用分隔符,我们首先尝试在文本中查找段落,但如果没有段落或段落过长,我们会按照指定的分块大小进行分割。
  2. 为确保分块不超过嵌入模型的最大输入长度,我们将上一步生成的所有分块传递给 SentenceTransformersTokenTextSplitter(),其考虑了模型的最大输入长度。在此阶段,我们还应用了 chunk_overlap 逻辑,因为我们希望在确认分块足够小之后再应用重叠。
# 其他导入
from langchain.text_splitter import RecursiveCharacterTextSplitter, SentenceTransformersTokenTextSplitter
from llm_engineering.application.networks import EmbeddingModelSingleton

def chunk_text(text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> list[str]:
    character_splitter = RecursiveCharacterTextSplitter(separators=["\n\n"], chunk_size=chunk_size, chunk_overlap=0)
    text_split_by_characters = character_splitter.split_text(text)
    token_splitter = SentenceTransformersTokenTextSplitter(
        chunk_overlap=chunk_overlap,
        tokens_per_chunk=embedding_model.max_input_length,
        model_name=embedding_model.model_id,
    )
    chunks_by_tokens = []
    for section in text_split_by_characters:
        chunks_by_tokens.extend(token_splitter.split_text(section))
    return chunks_by_tokens

上述函数返回一个满足分块参数和嵌入模型最大输入长度的分块列表。

嵌入处理器

嵌入处理器与其他处理器略有不同,因为 EmbeddingDataHandler() 接口包含了大部分逻辑。我们采用这种方式是为了在调用嵌入模型时尽可能批量处理样本,从而优化推理过程。当在 GPU 上运行模型时,批量样本可以独立并行处理。因此,通过批量分块处理,我们可以将推理过程的效率提高 10 倍或更多,具体取决于批量大小和硬件配置。

我们实现了 embed() 方法用于在单个数据点上运行推理,还实现了 embed_batch() 方法。embed_batch() 方法接收分块文档作为输入,将其内容汇集到一个列表中传递给嵌入模型,并将结果映射到嵌入后的分块领域实体。映射通过 map_model() 抽象方法完成,该方法需要针对每个数据类别进行定制。

# 其他导入
from typing import Generic, TypeVar, cast
from llm_engineering.application.networks import EmbeddingModelSingleton

ChunkT = TypeVar("ChunkT", bound=Chunk)
EmbeddedChunkT = TypeVar("EmbeddedChunkT", bound=EmbeddedChunk)
embedding_model = EmbeddingModelSingleton()

class EmbeddingDataHandler(ABC, Generic[ChunkT, EmbeddedChunkT]):
    """
    所有嵌入数据处理器的抽象类。
    所有嵌入步骤的数据转换逻辑均在此完成
    """
    def embed(self, data_model: ChunkT) -> EmbeddedChunkT:
        return self.embed_batch([data_model])[0]

    def embed_batch(self, data_model: list[ChunkT]) -> list[EmbeddedChunkT]:
        embedding_model_input = [data_model.content for data_model in data_model]
        embeddings = embedding_model(embedding_model_input, to_list=True)
        embedded_chunk = [
            self.map_model(data_model, cast(list[float], embedding))
            for data_model, embedding in zip(data_model, embeddings, strict=False)
        ]
        return embedded_chunk

    @abstractmethod
    def map_model(self, data_model: ChunkT, embedding: list[float]) -> EmbeddedChunkT:
        pass

ArticleEmbeddingHandler 类的实现

我们以 ArticleEmbeddingHandler() 的实现为例,其他处理器结构相似。只需实现 map_model() 方法,该方法接收输入分块并以批量模式计算嵌入,其目的是将信息映射到 EmbeddedArticleChunk Pydantic 实体。

class ArticleEmbeddingHandler(EmbeddingDataHandler):
    def map_model(self, data_model: ArticleChunk, embedding: list[float]) -> EmbeddedArticleChunk:
        return EmbeddedArticleChunk(
            id=data_model.id,
            content=data_model.content,
            embedding=embedding,
            platform=data_model.platform,
            link=data_model.link,
            document_id=data_model.document_id,
            author_id=data_model.author_id,
            author_full_name=data_model.author_full_name,
            metadata={
                "embedding_model_id": embedding_model.model_id,
                "embedding_size": embedding_model.embedding_size,
                "max_input_length": embedding_model.max_input_length,
            },
        )

EmbeddingModelSingleton()

理解 EmbeddingModelSingleton() 如何工作是关键。它是 SentenceTransformer() 类的封装器,用于初始化嵌入模型。封装外部包是一个良好的实践,因此当需要更改第三方工具时,只需修改封装器的内部逻辑,而非整个代码库。

SentenceTransformer() 类使用 Settings 类中定义的 model_id 初始化,可以通过更改配置文件快速测试多个嵌入模型,而不需修改代码。这样,您可以根据具体使用场景、数据、硬件和延迟要求选择最佳的嵌入模型。

from sentence_transformers import SentenceTransformer
from llm_engineering.settings import settings
from .base import SingletonMeta

class EmbeddingModelSingleton(metaclass=SingletonMeta):
    def __init__(
        self,
        model_id: str = settings.TEXT_EMBEDDING_MODEL_ID,
        device: str = settings.RAG_MODEL_DEVICE,
        cache_dir: Optional[Path] = None,
    ) -> None:
        self._model_id = model_id
        self._device = device
        self._model = SentenceTransformer(
            self._model_id,
            device=self._device,
            cache_folder=str(cache_dir) if cache_dir else None,
        )
        self._model.eval()

    @property
    def model_id(self) -> str:
        return self._model_id

    @cached_property
    def embedding_size(self) -> int:
        dummy_embedding = self._model.encode("")
        return dummy_embedding.shape[0]

    @property
    def max_input_length(self) -> int:
        return self._model.max_seq_length

    @property
    def tokenizer(self) -> AutoTokenizer:
        return self._model.tokenizer

    def __call__(
        self, input_text: str | list[str], to_list: bool = True
    ) -> NDArray[np.float32] | list[float] | list[list[float]]:
        try:
            embeddings = self._model.encode(input_text)
        except Exception:
            logger.error(f"Error generating embeddings for {self._model_id=} and {input_text=}")
            return [] if to_list else np.array([])
        if to_list:
            embeddings = embeddings.tolist()
        return embeddings

该嵌入模型类实现了单例模式,这是一种创建型设计模式,确保类仅有一个实例,同时提供对该实例的全局访问点。EmbeddingModelSingleton() 类继承自 SingletonMeta 类,确保每次实例化 EmbeddingModelSingleton() 时都返回相同的实例。该模式适用于机器学习模型,因为它们可以一次性加载到内存中,之后可以在代码库中随时使用。否则,每次使用模型时都会重新加载,可能会导致内存问题。此外,这种方式可以方便地访问如 embedding_size 这样的属性,只需一次模型前向传递即可确定其输出的大小,在程序执行期间始终可以访问该属性。

总结

本章以 RAG(检索增强生成)的基本介绍开篇,解释了使用它的原因和时机。我们还了解了嵌入和向量数据库的工作原理,它们是任何 RAG 系统的基石。接着,我们探讨了高级 RAG 及其必要性,深入理解了 RAG 可以优化的部分,并提出了一些流行的高级 RAG 技术来处理文本数据。然后,我们将学到的 RAG 知识应用于设计 LLM Twin 的 RAG 特性管道架构,理解了批处理和流式管道的区别,并简要介绍了 CDC(数据变更捕获)模式,它帮助我们同步两个数据库。

接着,我们逐步实现了 LLM Twin 的 RAG 特性管道,包括如何将 ZenML 作为编排器、如何设计应用的领域实体以及如何实现 OVM 模块。我们还学习了如何运用软件工程的最佳实践(例如抽象工厂模式和策略模式)来实现一个模块化和可扩展的层,以便根据每个文档的数据类别应用不同的清理、分块和嵌入技术。

本章重点在于实现数据摄取管道,这是标准 RAG 应用的一个组成部分。在第 9 章中,我们将通过实现检索和生成组件并将其集成到推理管道中来完成 RAG 系统。但在此之前,下一章将探讨如何使用收集的数据生成自定义数据集并用其微调 LLM。

参考文献

  • Kenton, J.D.M.W.C. 和 Toutanova, L.K.(2019年6月). BERT: 用于语言理解的深度双向 Transformer 预训练模型. 在 naacL-HLT 会议论文集中 (Vol. 1, p. 2).
  • Liu, Y.(2019年). RoBERTa: 稳健优化的 BERT 预训练方法. arXiv 预印本 arXiv:1907.11692.
  • Mikolov, T.(2013年). 高效估计向量空间中的词表示. arXiv 预印本 arXiv:1301.3781.
  • Pennington, J., Socher, R. 和 Manning, C.(2014年). GloVe: 全球词向量表示. 在 2014 年自然语言处理实证方法会议论文集中 (pp. 1532–1543). 卡塔尔多哈: 计算语言学协会.
  • He, K., Zhang, X., Ren, S., 和 Sun, J.(2016年). 深度残差学习用于图像识别. 在 IEEE 计算机视觉与模式识别会议论文集中 (pp. 770-778).
  • Radford, A. 等(2021年7月). 从自然语言监督中学习可迁移视觉模型. 在国际机器学习会议论文集中 (pp. 8748-8763). PMLR.
  • 什么是数据变更捕获 (CDC)? | Confluent.(未注明日期). Confluent. www.confluent.io/en-gb/learn…
  • Refactoring.Guru.(2024年1月1日). Singleton. refactoring.guru/design-patt…
  • Refactoring.Guru.(2024年1月1日). Strategy. refactoring.guru/design-patt…
  • Refactoring.Guru.(2024年1月1日). Abstract Factory. refactoring.guru/design-patt…
  • Schwaber-Cohen, R.(未注明日期). 什么是向量数据库及其工作原理?用例和示例. Pinecone. www.pinecone.io/learn/vecto…
  • Monigatti, L.(2024年2月19日). 高级检索增强生成:从理论到 LlaMaIndex 实现. Medium. towardsdatascience.com/advanced-re…
  • Monigatti, L.(2023年12月6日). 生产就绪 RAG 应用的 12 种调优策略指南. Medium. towardsdatascience.com/a-guide-on-…
  • Maameri, S.(2024年5月10日). RAG 驱动应用中的路由. Medium. towardsdatascience.com/routing-in-…