使用 LlamaIndex 进行索引

1,118 阅读45分钟

本章深入探讨了 LlamaIndex 中不同类型的索引。我们将解释索引的工作原理、关键功能、定制选项、底层架构和应用场景。总体来说,本章将作为一个指南,帮助利用 LlamaIndex 的索引功能来构建高性能和可扩展的 RAG 系统。让我们开始吧!

在本章中,我们将覆盖以下主题:

  • 索引数据概述:鸟瞰式了解数据索引
  • 理解向量存储索引:解析向量存储索引的工作原理
  • 理解嵌入:深入了解嵌入技术
  • 持久化和重用索引:如何保存和重用索引
  • 探索 LlamaIndex 中的其他索引类型:了解 LlamaIndex 提供的其他索引类型
  • 基于其他索引构建索引(ComposableGraph) :利用可组合图构建新索引
  • 估算构建和查询索引的潜在成本:如何评估构建和查询索引的成本

技术要求

在本章中,您需要在环境中安装以下软件包:

此外,示例代码还需要两个集成软件包:

本章的所有代码示例可以在本书 GitHub 仓库的 ch5 子文件夹中找到:GitHub 仓库

数据索引 – 一览全景

在第3章《揭示LlamaIndex的基本构建模块——文档、节点、索引》中,我们简要讨论了索引在RAG应用中的重要性和一般功能。现在,我们将更详细地了解LlamaIndex中可用的不同索引方法,包括它们的优缺点和具体用例。

原则上,即使没有索引,也可以访问数据。但这就像在没有目录的情况下阅读一本书。只要书中的故事是连贯的,并且可以按顺序逐节逐章阅读,阅读将会是一种享受。然而,当我们需要快速查找书中的某个特定主题时,情况就会有所不同。如果没有目录,搜索过程将会缓慢而繁琐。

在LlamaIndex中,索引不仅仅是一个简单的目录。索引不仅提供了导航所需的结构,还提供了更新或访问的具体机制。这包括检索器的逻辑和用于获取数据的机制,这些将在第6章《查询我们的数据,第1部分——上下文检索》中详细讨论。

在本书中,我保持了简单的介绍,给您提供了索引如何工作的基本知识,并提供了一些示例来帮助您理解它们的使用。探索使用和混合这些索引的所有可能方式将是一个巨大的任务,这不是我们在这里要做的。

我们稍后会讨论每种索引类型的独特之处,但首先,让我们看看它们的共同特点。

所有索引类型的共同特征

LlamaIndex中的每种索引都有其自身的特性和功能,但由于所有索引都继承自BaseIndex类,因此它们共享某些功能和参数,这些可以为任何类型的索引进行自定义:

  • 节点:所有索引都是基于节点的,我们可以选择哪些节点包含在索引中。此外,所有索引类型都提供了插入新节点或删除现有节点的方法,允许动态更新索引。我们可以通过直接将节点提供给索引构造函数来构建索引,例如vector_index = VectorStoreIndex(nodes),或者我们可以使用from_documents()提供文档列表,让索引自行提取节点。请记住,在实际构建索引之前,我们可以使用设置来自定义其底层机制。正如我们在第3章《了解如何使用设置进行自定义》部分讨论的那样,这个简单的类允许不同的设置,例如更改LLM、嵌入模型或索引使用的默认节点解析器。
  • 存储上下文:存储上下文定义了索引的数据(文档和节点)如何以及在哪里存储。这种自定义对于根据应用程序的需求高效地管理数据存储至关重要。
  • 进度显示show_progress选项允许我们选择是否在长时间运行的操作(如构建索引)过程中显示进度条。此功能使用tqdm Python库实现,可以用于监控大型索引任务的进度。
  • 不同的检索模式:每种索引允许不同的预定义检索模式,可以根据应用程序的特定需求进行设置。您还可以自定义或扩展检索器类,以更改查询处理方式和从索引中检索结果的方式。更多内容将在第6章《查询我们的数据,第1部分——上下文检索》中介绍。
  • 异步操作:某些索引实现的use_async参数决定了某些操作是否应异步执行。异步处理允许系统同时管理多个操作,而不是等待每个操作顺序完成。这对于性能优化尤其重要,特别是在处理大型数据集或复杂操作时。

简要说明

在进一步深入并开始尝试示例代码之前,需要考虑一个重要的事项:索引通常依赖LLM调用来进行摘要或嵌入。就像在第4章《将数据导入我们的RAG工作流》中讨论的元数据提取一样,LlamaIndex中的索引也可能引发成本和隐私问题。在进行大规模实验以测试您的想法之前,请确保阅读本章末尾的成本相关部分。

让我们从第一个和最常用的索引类型开始。

理解 VectorStoreIndex

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader

# 载入数据
documents = SimpleDirectoryReader("files").load_data()

# 创建索引
index = VectorStoreIndex.from_documents(documents)

print("索引创建成功!")

# 安装依赖包
# pip install llama-index-embeddings-huggingface

from llama_index.embeddings.huggingface import HuggingFaceEmbedding

# 初始化嵌入模型
embedding_model = HuggingFaceEmbedding(
    model_name="WhereIsAI/UAE-Large-V1"
)

# 获取文本嵌入
embeddings = embedding_model.get_text_embedding(
    "The quick brown fox jumps over the lazy cat!"
)

print(embeddings[:15])

由于本电子书版没有固定的页码,以下页码仅作为参考,根据纸质版书籍的页码进行超链接。

在LlamaIndex中,VectorStoreIndex被认为是主要的工作马,最常用的索引类型。

对于大多数RAG应用程序来说,VectorStoreIndex可能是最佳解决方案,因为它便于在文档集合上构建索引,其中输入文本块的嵌入存储在索引的向量存储中。构建完成后,此索引可以用于高效查询,因为它允许对文本的嵌入表示进行相似性搜索,这使得它非常适合需要从大量数据集合中快速检索相关信息的应用程序。如果您还不熟悉诸如嵌入、向量存储或相似性搜索等术语,不必担心,我们将在以下章节中详细讲解。LlamaIndex中的VectorStoreIndex类默认支持这些操作,还允许异步调用和进度跟踪,这可以在典型的RAG场景中提高性能和用户体验。

VectorStoreIndex 的简单使用示例

以下是构建 VectorStoreIndex 的最基本方法:

如您所见,仅用几行代码,我们就已加载了文档,VectorStoreIndex 处理了所有操作。请注意,使用这种方法,我们完全跳过了节点解析步骤,因为索引通过使用 from_documents() 方法自行完成了这一操作。

我们可以自定义几个参数以调整 VectorStoreIndex

  • use_async:此参数启用异步调用。默认值为 False
  • show_progress:此参数在构建索引过程中显示进度条。默认值为 False
  • store_nodes_override:此参数强制 LlamaIndex 在索引存储和文档存储中存储节点对象,即使向量存储中已经保留了文本。对于需要直接访问节点对象的场景,即使其内容已存储在向量存储中,这可能会很有用。我们将在本章稍后部分详细讨论索引存储、文档存储和向量存储。此参数的默认设置为 False

让我们通过图5.1来查看这种类型索引的可视化表示:

image.png

VectorStoreIndex 处理了导入的文档,将其分解为节点。它使用了文本分割器、块大小、块重叠等默认参数。当然,我们可以根据需要自定义所有这些参数。

注意
固定大小的分块将文本分割成相同大小的块,选项上可以有一些重叠。虽然这种方法计算便宜且易于实现,但简单的分块方法可能并不总是最佳选择。对各种分块大小进行性能测试是优化应用程序特定需求的关键。

包含原始文本块的节点随后通过语言模型嵌入到高维向量空间中。嵌入的向量被存储在索引的向量存储组件中。接下来,当进行查询时,查询文本也会以类似的方式嵌入,并使用名为余弦相似度的方法与存储的向量进行比较。最相似的向量——即最相关的文档块——将作为查询结果返回。这个过程利用了向量空间的数学属性,能够快速且具有语义意识地检索信息,从而找到最能回答用户查询的文档。

听起来有点混乱?让我们在下一节中一起了解这些概念。

理解嵌入(Embeddings)

简单来说,向量嵌入(vector embeddings)代表了一种机器可理解的数据格式。它们捕捉了数据的意义,并可以在概念上表示一个词、整个文档,甚至是图像和声音等非文本信息。某种程度上,嵌入代表了LLM的标准思维语言。在LLM的背景下,嵌入作为模型理解和处理信息的基础表示。它们将各种复杂数据转换为统一的高维空间,使LLM能够更有效地进行比较、关联和预测等操作。图5.2提供了数据嵌入过程的示意图:

image.png

因为所有的底层工作都涉及数学。数学与数字的配合效果最好——更确切地说,是与大量的浮点数字配合,每个数字代表一个假设向量空间中的维度。LLM可以使用这些数字数组来理解、解释并生成基于输入的响应。本质上,这些向量嵌入中的数字使得LLM能够以一种有意义且结构化的方式查看和思考数据。

这个系统的美妙之处在于它处理模糊性和复杂性的能力。模型能够理解词语之间的语义关系,例如同义词、反义词和更复杂的语言模式。在多义词的情况下,同一个词在不同的上下文中可以有不同的含义。例如,“bank”这个词可以指河岸或金融机构。向量嵌入通过提供上下文敏感的表示,帮助LLM理解这些细微差别。因此,在一种情况下,“bank”可能与“river”和“shore”这些词密切相关,而在另一种情况下,则更接近于“money”和“account”。

快速提示
一个重要的因素是,被嵌入的文本块的大小会影响精确度——块太小会丧失上下文;块太大则可能稀释含义。

如果你对嵌入还不太熟悉,以下示例可能有助于更好地理解这个概念。让我们为三个随机选择的句子分配一些任意的向量嵌入:

  • 句子 1: The quick brown fox jumps over the lazy dog
  • 句子 2: A fast dark-colored fox leaps above a sleepy canine
  • 句子 3: Apples are sweet and crunchy

在实际情况中,与这些句子相关的嵌入会通过使用嵌入模型自动计算。嵌入模型是一个专门的人工智能模型,用于将复杂的数据(如文本、图像或图表)转换为数值格式。这些嵌入通常也是高维的,但为了说明,我将使用简单的三维任意选择的向量。以下是这三句话的假设嵌入:

  • 句子 1 嵌入: [0.8, 0.1, 0.3]
  • 句子 2 嵌入: [0.79, 0.14, 0.32]
  • 句子 3 嵌入: [0.2, 0.9, 0.5]

这些数字纯粹是概念性的,旨在显示句子 1 和句子 2 的嵌入在向量空间中彼此更接近,因为它们的含义相似。而句子 3 的嵌入则离前两者较远,因为它的含义不同。请查看图 5.3,以便对这三个嵌入进行直观的比较。

image.png

可视化嵌入

当我们在三维空间中对这些句子进行可视化时,句子 1 和句子 2 会被绘制在彼此接近的位置,而句子 3 则会被绘制在远离它们的位置。这种空间表示方式使机器学习模型能够确定语义上的相似性。

当你使用查询在向量存储索引中进行搜索以检索有用的上下文时,LlamaIndex 会将你的搜索词转换为类似的嵌入,然后在预先计算好的文本块嵌入中找到最接近的匹配项。

我们称这个过程为相似性或距离搜索。因此,当你遇到“top-k 相似性搜索”这个术语时,你应该知道它依赖于一种计算向量嵌入之间相似性的算法。它以一个向量嵌入作为输入,并返回在向量存储中找到的最相似的 k 个向量。由于初始向量和返回的 top-k 邻近向量彼此相似,我们可以认为它们的含义在概念上也相似。现在你明白了为什么我之前称嵌入为 LLM 的标准思维语言。无论它们代表的是文本、图像还是其他类型的信息,这已经不再重要。我们通过数字来衡量它们的相似性。

唯一可能因使用情况不同而有所不同的,是定义距离或相似性的实际公式。

剧透:接下来会有一些数学概念。

理解相似性搜索

在机器学习和深度学习的领域中,相似性搜索的概念非常重要。它构成了许多应用的基础,从推荐系统和信息检索到聚类和分类任务。当模型和系统与高维数据进行交互时,识别数据点之间的模式和关系变得至关重要。这涉及到衡量数据元素之间的接近度或相似度,这通常在一个向量空间中进行,其中每个项都被表示为一个向量。

在这个空间中定位彼此接近的点使机器能够评估相似性,并进而做出决策、得出推论,或者在我们的案例中,根据这种接近度检索信息。随着深度学习中嵌入的出现,对有效相似性搜索的需求也在增长。由于嵌入捕捉了它们所代表的数据的语义意义,对这些向量进行相似性搜索使机器能够以接近人类认知的水平理解内容。

让我们探索一下 LlamaIndex 目前用于测量向量相似性的方法,每种方法都有其独特的优势和适用性。

余弦相似性

这种方法测量两个向量之间夹角的余弦值。想象两个箭头指向不同的方向;它们之间的夹角越小,它们就越相似。

请查看图 5.4,该图展示了两个向量之间的余弦相似性比较:

image.png

在嵌入方面,小夹角(或高余弦相似性分数,接近 1)表示它们所代表的内容是相似的。这种方法在文本分析中特别有用,因为它不太受文档长度的影响,而更多地关注它们在向量空间中的方向或取向。

注意
余弦相似性也是 LlamaIndex 用于计算嵌入之间相似性的默认方法。

点积

点积,也称为标量积,因为它由一个单一值表示,是另一种计算两个向量彼此对齐程度的方法。为了计算两个向量的标量积,算法将向量的对应元素相乘,然后将这些乘积相加。

让我们以向量 A:[2,3] 和向量 B:[4,1] 为例。点积的计算方法是将它们的对应元素相乘: (2×4) + (3×1),结果是 8 + 3 = 11。因此,这两个向量的点积是 11。

图 5.5 示例了这个概念:

image.png

在前面的图示中,点积通过将一个向量投影到另一个向量上来进行可视化。这种投影展示了点积的几何解释。它通过将一个向量的分量投影到另一个向量的方向上,然后将这些投影分量与第二个向量的对应分量相乘来计算。所有这些乘积的和就是点积。这种可视化帮助我们理解,点积不仅仅是衡量向量是否指向相同方向的一个度量,它还考虑了向量的长度。

点积的值越高,表示向量之间的相似性越高。与余弦方法不同,点积对比较的两个向量的长度和相对方向都很敏感。与点积不同,余弦相似性通过向量的大小对点积进行归一化。这种归一化使得余弦相似性完全衡量向量之间的方向对齐程度,而不受向量长度的影响。

向量越长,点积结果越高,这在 RAG 场景中是一个重要因素。较长的向量可能表示较长的文档或更详细的信息,由于其固有的较大点积值,可能会主导检索结果。这可能使系统倾向于检索较长的文档,即使它们不是最相关的。

欧几里得距离

这种方法与点积和余弦相似性方法不同。点积和余弦相似性关注的是向量之间的角度或对齐程度,而欧几里得距离关注的是向量实际值之间的接近程度。这在向量的值表示实际计数或测量时尤其有用,尤其是当向量维度具有现实世界的物理意义时。

请查看图 5.6 以获取欧几里得距离的可视化表示:

image.png

现在,你应该对嵌入、向量相似性如何工作,特别是它们在 LlamaIndex 中的实现有了基础了解。如果你希望进一步熟悉这一概念,可以在网上查找更多信息。以下是一些建议的阅读资源,你可以从这些资源开始:

了解相似性度量

LlamaIndex 如何生成这些嵌入?

简而言之,LlamaIndex 使用的默认配置是 OpenAI 的 text-embedding-ada-002 模型。这个模型经过训练,可以有效地捕捉文本的语义含义,从而支持语义搜索、主题聚类、异常检测等应用。它在质量、性能和成本之间提供了很好的平衡。LlamaIndex 默认使用这个模型来嵌入文档以及查询嵌入。

然而,当你需要索引大量数据时,托管模型的成本可能会超出你的预算。在其他情况下,你可能会担心数据隐私,倾向于使用本地模型。或者,某些情况下,你可能需要使用特定主题或技术领域的专业模型。

好消息是,LlamaIndex 还支持各种其他嵌入模型。例如,如果你希望使用本地模型,可以设置服务上下文使用本地嵌入,这利用了 Hugging Face 提供的平衡默认模型(BAAI/bge-small-en-v1)。这对于减少成本或有数据处理本地化需求的情况特别有用。

Hugging Face 简介

Hugging Face 是 AI 领域的重要资源,主要以其广泛的预训练机器学习模型而闻名,尤其是在自然语言处理(NLP)领域。它的重要性在于使最先进的 AI 模型、工具和技术对大众开放,使开发者和研究人员能够相对轻松地实现先进的 AI 功能。类似于 GitHub,Hugging Face 采用了社区驱动的方法,用户可以分享、协作和改进 AI 模型,类似于开发者在 GitHub 上分享和贡献代码库。这种以社区为中心的模式加速了 AI 创新和进展的传播。

在运行下一个示例之前,请确保安装了必要的集成:

这个示例将展示如何设置本地嵌入模型:

在第一次运行时,代码将从 Hugging Face 下载 Universal AnglE Embedding 模型。这是当前表现最佳的嵌入模型之一,提供了出色的整体性能和质量平衡。

更多信息请访问:Universal AnglE Embedding 模型

下载并初始化嵌入模型后,脚本将计算句子的嵌入,并显示向量的前 15 个值。

对于高级用户或特定应用,LlamaIndex 使得集成自定义嵌入模型变得容易。你可以简单地扩展 LlamaIndex 提供的 BaseEmbedding 类,并实现生成嵌入的自定义逻辑。

这里是定义自定义嵌入类的示例:自定义嵌入

除了 OpenAI 和本地模型,还有与 Langchain 的集成,允许你使用他们提供的任何嵌入模型。你还可以通过 LlamaIndex 提供的其他集成选项使用 Azure、CohereAI 和其他提供商的嵌入模型。这种灵活性确保了无论你的需求或限制是什么,你都可以配置 LlamaIndex 以使用适合你应用的嵌入模型。

如何选择嵌入模型?

选择嵌入模型可以显著影响 RAG 应用的性能、质量和成本。选择特定模型时需要考虑以下关键点:

  • 质量性能:不同的嵌入模型可能以不同的方式编码文本的语义。虽然像 OpenAI 的 Ada 这样的模型具有广泛的文本理解能力,但其他模型可能针对特定领域或任务进行了微调,在这些场景下可能表现更好。领域特定模型可能会对专门的主题提供更准确的表示。
  • 定量性能:这包括模型捕捉语义相似性的能力、在基准测试中的表现以及对未见数据的泛化能力。这在不同模型和应用领域之间可能差异很大。要了解最受欢迎模型的基准测试,可以查阅 Hugging Face 网站上的 Massive Text Embedding Benchmark (MTEB) 排行榜
  • 延迟和吞吐量:对于具有实时约束或大数据量的应用,嵌入模型的速度可能是决定因素。还要考虑模型能处理的最大输入块大小,这会影响文本如何被划分以进行嵌入。请记住,在摄取期间你的节点将计算嵌入,这不会影响你的整体应用性能。然而,在检索过程中,每个查询都必须实时嵌入,以便测量相似性并检索相关节点。这是延迟和吞吐量变得重要的地方。要了解不同嵌入模型的表现,请查看这篇文章:文本嵌入延迟:一种半科学的方法
  • 多语言支持:嵌入模型可以是多语言的或专门训练用于特定语言。根据你的用例,这也可能成为一个重要的决策因素。例如,较小的模型如 Mistral 在处理英语数据时可以提供与托管模型(如 GPT 3.5)相媲美的结果,但在其他语言中的表现则明显较差。
  • 资源需求:嵌入模型的大小和计算开销可能差异很大。较大的模型可能提供更准确的嵌入,但可能需要更多的计算资源,从而导致更高的成本。
  • 可用性:一些嵌入模型可能仅通过特定的 API 提供或需要安装特定的软件,这可能影响集成和使用的便捷性。幸运的是,LlamaIndex 提供了很高的自定义程度。
  • 本地或离线使用:当数据隐私是一个问题或在无互联网访问的环境中操作时,你可能会倾向于使用本地模型。
  • 使用成本:考虑云托管嵌入模型的 API 调用成本与本地嵌入模型的计算和存储成本之间的差异。

好消息是,LlamaIndex 支持许多开箱即用的嵌入模型,并提供灵活的选项以使用各种嵌入模型。

顺便提一下,完整的支持模型列表可以在这里找到:LlamaIndex 支持的嵌入模型列表

对于大多数用例,OpenAI 的默认嵌入模型 – text-embedding-ada-002 – 将在我们讨论的所有参数之间提供良好的平衡。然而,如果你有特定的需求或限制,你可能会受益于探索和基准测试不同的模型,以找到最适合你特定应用的模型。

现在,我们了解了嵌入模型,接下来我们将关注如何存储和重用这些嵌入。

持久化和重用索引

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader 

documents = SimpleDirectoryReader("data").load_data() 
index = VectorStoreIndex.from_documents(documents) 
index.storage_context.persist(persist_dir="index_cache") 
print("Index persisted to disk.")

from llama_index.core import StorageContext, load_index_from_storage 

storage_context = StorageContext.from_defaults(persist_dir="index_cache") 
index = load_index_from_storage(storage_context) 
print("Index loaded successfully!")
pip install chromadb
import chromadb 
from llama_index.vector_stores.chroma import ChromaVectorStore 
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, StorageContext 

db = chromadb.PersistentClient(path="chroma_database") 
chroma_collection = db.get_or_create_collection("my_chroma_store") 
vector_store = ChromaVectorStore(chroma_collection=chroma_collection) 
storage_context = StorageContext.from_defaults(vector_store=vector_store) 

documents = SimpleDirectoryReader("files").load_data() 
index = VectorStoreIndex.from_documents(documents=documents, storage_context=storage_context) 

results = chroma_collection.get() 
print(results)

index = VectorStoreIndex.from_vector_store(vector_store=vector_store, storage_context=storage_context)

一个重要的问题是:在索引过程中生成的向量嵌入应该存储在哪里?存储它们非常重要,原因如下:

  • 避免重复计算:每次会话都重新嵌入文档和重建索引会产生高昂的计算成本。生成大规模文档集的高质量嵌入需要大量的处理,随着时间的推移,可能会变得非常昂贵。通过持久化索引,可以保留这些预先计算的成果。
  • 实现低延迟处理:通过加载已经计算好的嵌入来避免运行时嵌入和索引,可以使应用程序更快地启动和运行。
  • 保证查询一致性和准确性:重新加载索引可以确保我们重复使用先前会话中使用的相同向量和结构,从而保证一致且准确的查询执行。

如果我们想避免在每次运行时重新生成它们,这些向量嵌入需要存放在某个地方——一个存储库——以便高效存储和检索。这就是 LlamaIndex 中向量存储的作用。

默认情况下,LlamaIndex 使用内存中的向量存储,但为了持久化,它提供了一种简单的方法:使用 .persist() 方法。此方法将所有数据写入指定位置的磁盘,从而实现持久化。

我们来看如何持久化并加载向量嵌入。首先,我们创建处理文档嵌入的索引:

要持久化这些数据,我们使用 persist() 方法:

这会将整个索引数据保存到磁盘。在未来的会话中,我们可以轻松地重新加载这些数据:

通过从持久化目录重建 StorageContext 并使用 load_index_from_storage,我们可以有效地重新构建索引,而无需重新索引数据。

理解 StorageContext

StorageContext 作为在索引和查询期间使用的可配置存储组件的统一管理者。其关键组件如下:

  • 文档存储 (docstore) :管理文档的存储。数据本地存储在名为 docstore.json 的文件中。
  • 索引存储 (index_store) :管理索引结构的存储。索引本地存储在名为 index_store.json 的文件中。
  • 向量存储 (vector_stores) :这是一个字典,用于管理多个向量存储,每个存储可能用于不同的目的。向量存储本地存储在 vector_store.json 文件中。
  • 图存储 (graph_store) :管理图数据结构的存储。LlamaIndex 会自动创建一个名为 graph_store.json 的文件来存储图结构。

StorageContext 类将文档、向量、索引和图数据存储整合在一个统一的接口下。上述用于本地存储数据的文件在我们调用 persist() 方法时由 LlamaIndex 自动创建。如果我们不想将它们保存在当前文件夹中,可以指定一个持久化位置,以便在未来的会话中从该位置加载它们。

LlamaIndex 提供了开箱即用的本地存储,但我们可以将它们替换为更强大的持久化解决方案,例如 AWS S3、Pinecone、MongoDB 等。

作为示例,我们来探索如何使用 ChromaDB 自定义向量存储,一个高效的开源向量引擎。

首先,确保你安装了 chromadb

pip install chromadb

代码的第一部分处理必要的导入:

接下来,我们初始化 Chroma 客户端并在 Chroma 中创建一个集合以存储我们的数据:

在 ChromaDB 中,我们创建集合来存储数据。这些集合类似于关系数据库中的表。my_chroma_store 集合将保存我们的嵌入。

接下来,我们使用 ChromaVectorStore 初始化一个定制的向量存储,并将其连接到 StorageContext 中:

我们现在可以开始加载文档并构建索引:

我们可以使用 get() 方法显示 Chroma 集合的整个内容:

随后,在未来的会话中恢复这个索引也非常简单:

我们刚刚重新构建了原始索引。

通过封装像 ChromaDB 这样的向量数据库,LlamaIndex 使得企业级向量存储通过一个简单的存储抽象变得易于访问。复杂性被隐藏起来,让你可以专注于应用逻辑,同时仍然利用工业级的数据基础设施。

总而言之,LlamaIndex 在向量存储方面提供了灵活性——从简单的内存存储用于测试,到云托管数据库用于大型现实部署。通过存储集成,交换任何组件都非常容易!

向量存储与向量数据库的区别

在管理和查询大规模向量集合时,通常会使用 "向量存储" 和 "向量数据库" 这两个术语,特别是在涉及 NLP、图像识别等任务的机器学习应用中。在本章中我经常提到它们,有时暗示它们是相似的概念。然而,它们之间存在微妙的区别:

  • 向量存储:通常指的是存储系统或存储库,用于存储向量。这些向量是高维的,表示复杂的数据,如文本、图像或音频,格式可以被机器学习模型处理。向量存储主要关注这些向量的高效存储。它可能没有高级功能来查询或分析数据,其主要目的是维护一个大型向量存储库,可以检索并用于各种机器学习任务。
  • 向量数据库:另一方面,向量数据库是一个更复杂的系统,它不仅存储向量,还提供高级功能来查询和分析它们。这包括执行相似性搜索和其他复杂操作的能力,这在机器学习和数据分析中非常有用。向量数据库设计用于处理向量数据的复杂性,如它们的高维性以及需要专门的索引技术来实现高效的搜索和检索。

简而言之,向量存储更多的是关于存储方面的内容,而向量数据库则包括了存储和查询功能,这使得向量数据库在需要快速准确地搜索大规模向量化数据的应用中尤为重要。

通常,向量数据库的一项显著特点是支持 CRUD(创建、读取、更新、删除)操作。而是否支持 CRUD 操作,取决于具体的实现和存储设计。然而,一般而言,向量存储,特别是简化版或基础版,可能不会像传统数据库系统那样支持所有 CRUD 操作。

在许多机器学习和 AI 应用中,向量一旦创建并存储后,通常不会频繁更新或删除,因此某些向量存储可能更侧重于高效的存储和检索(即创建和读取操作),而不是全面的 CRUD 功能。

相比之下,更复杂的向量数据库则更可能提供完整的 CRUD 功能,使得对向量数据的管理更加动态和灵活。

开始深入理解向量数据库的一个好起点是:Microsoft 的文档

探索 LlamaIndex 中的其他索引类型

from llama_index.core import SummaryIndex, SimpleDirectoryReader 

documents = SimpleDirectoryReader("files").load_data() 
index = SummaryIndex.from_documents(documents) 
query_engine = index.as_query_engine() 
response = query_engine.query("How many documents have you loaded?") 
print(response) 
# 输出:I have loaded two documents.

from llama_index.core import DocumentSummaryIndex, SimpleDirectoryReader 

documents = SimpleDirectoryReader("files").load_data() 
index = DocumentSummaryIndex.from_documents(documents, show_progress=True) 
summary1 = index.get_document_summary(documents[0].doc_id) 
summary2 = index.get_document_summary(documents[1].doc_id) 
print("\n Summary of the first document: " + summary1) 
print("\n Summary of the second document: " + summary2)

from llama_index.core import KeywordTableIndex, SimpleDirectoryReader 

documents = SimpleDirectoryReader("files").load_data() 
index = KeywordTableIndex.from_documents(documents) 
query_engine = index.as_query_engine() 
response = query_engine.query("What famous buildings were in ancient Rome?") 
print(response)

from llama_index.core import TreeIndex, SimpleDirectoryReader 

documents = SimpleDirectoryReader("files").load_data() 
index = TreeIndex.from_documents(documents) 
query_engine = index.as_query_engine() 
response = query_engine.query("Tell me about dogs") 
print(response)

from llama_index.core import KnowledgeGraphIndex, SimpleDirectoryReader 

documents = SimpleDirectoryReader("files").load_data() 
index = KnowledgeGraphIndex.from_documents(documents, max_triplets_per_chunk=2, use_async=True) 
query_engine = index.as_query_engine() 
response = query_engine.query("Tell me about dogs.") 
print(response)

在大多数 RAG 场景中,VectorStoreIndex 可能是主要工具,但 LlamaIndex 提供了许多其他有用的索引工具。它们都有各自的特点和用途,下面我们将详细探讨这些索引类型。

SummaryIndex

SummaryIndex 提供了一种简单但强大的数据索引方法,适用于检索目的。与专注于向量存储中的嵌入的 VectorStoreIndex 不同,SummaryIndex 基于一个简单的数据结构,节点按顺序存储。SummaryIndex 的结构如下图所示:

图 5.7 - SummaryIndex 的结构 image.png

构建索引时,它会获取一组文档,将其分割成较小的块,然后将这些块编译成一个顺序列表。所有操作均在本地进行,不涉及 LLM 或任何嵌入模型。

实际应用场景

假设我们在软件开发项目中创建一个文档搜索工具。软件项目随着时间的推移往往会积累大量文档,包括技术规范、API 文档、用户指南和开发者笔记。SummaryIndex 可以帮助开发者快速在所有文档中进行搜索。例如,开发者可以查询“支付网关 API 的错误处理流程是什么?”SummaryIndex 会扫描索引的文档,找到讨论错误处理的相关部分,而无需复杂的嵌入模型或大量计算资源。对于那些由于资源限制而无法维护大型向量存储,或优先考虑简便性和速度的环境,SummaryIndex 特别有用。

SummaryIndex 对于需要简单数据索引的应用非常有效,尤其是在线性扫描数据就足够或不需要复杂嵌入检索的场景中。

SummaryIndex 的简单使用模型

创建 SummaryIndex 的过程非常简单:

  • 在这里,节点是从样本文件创建的,然后用这些节点实例化 SummaryIndex。这个简单的模型无需嵌入或向量存储的复杂性,便可以快速设置完成。

如果你正确克隆了书中 GitHub 仓库的结构,并在 files 子文件夹中包含了两个文本文件,那么前面的代码段的输出应该是:

  • “已加载两个文档。”

理解 SummaryIndex 的内部机制

在内部,SummaryIndex 通过在类似列表的结构中存储每个节点进行操作。当执行查询时,索引会遍历此列表以查找相关节点。虽然这个过程比 VectorStoreIndex 中基于嵌入的搜索要简单,但它对于许多应用仍然有效。

该索引可以与多种检索器一起使用,例如 SummaryIndexRetriever、SummaryIndexEmbeddingRetriever 和 SummaryIndexLLMRetriever,它们各自提供不同的搜索和检索机制。在查询期间,SummaryIndex 采用创建和改进的方法来形成响应。它首先根据文本的第一个块组装初步答案,随后通过将其他文本块作为上下文信息来改进这个初步响应。改进过程可能包括保持初步答案、略微修改它或完全改写原始响应。

我们将在第六章“查询数据,第一部分 - 上下文检索”中详细介绍检索部分。

DocumentSummaryIndex

LlamaIndex 提供了多种专门设计的索引工具,DocumentSummaryIndex 通过总结文档并将这些摘要映射到索引中的相应节点,优化了信息检索过程。这一过程通过使用摘要快速识别相关文档,从而实现高效的数据检索。

图 5.8 - DocumentSummaryIndex 的结构

image.png

DocumentSummaryIndex 对于处理需要文档内容简明概述的查询非常有用,因为它可以显著缩小搜索范围,特别适合于需要快速访问大型和多样化数据集中的特定文档的应用。

实际应用场景

在一个大型组织内开发知识管理系统时,DocumentSummaryIndex 非常实用。在这样的环境中,员工通常需要快速访问大量文档,包括报告、研究论文、政策文件和技术手册。DocumentSummaryIndex 可以帮助员工在用户查询的基础上快速找到特定信息。

DocumentSummaryIndex 的简单使用模型

创建 DocumentSummaryIndex 包含一系列步骤,从文档的聚合到后续的总结。以下代码片段展示了创建这个索引的基本设置:

  • 这一过程包括从目录读取文档,将它们解析为节点,生成文档摘要,然后将相应的节点与这些摘要关联,以便快速检索。

在检索过程中,这种关联将允许基于用户查询和文档摘要提取相关节点。

KeywordTableIndex

KeywordTableIndex 在 LlamaIndex 中实现了一种类似于术语表的架构,用于通过重要术语快速匹配查询与相关节点。这种结构依赖于一个简单的关键字表,但在目标事实查找方面非常有效。

图 5.9 - KeywordTableIndex 的结构

image.png

KeywordTableIndex 特别适用于关键字匹配对检索相关信息至关重要的场景。这些关键字成为中央查找表中的参考键,每个关键字指向相关节点,例如术语表定义。

KeywordTableIndex 的简单使用模型

创建 KeywordTableIndex 非常简单:

  • 在这里,索引会自动从数据中提取关键字并设置一个关键字表,简化了设置基于关键字的检索系统的过程。

TreeIndex

TreeIndex 引入了一种分层方法来组织和检索信息。这种结构将数据以层次树的形式组织起来。

图 5.10 - TreeIndex 的结构

image.png

TreeIndex 通过将一组文档作为输入,逐层构建树结构;每个父节点能够使用一般摘要提示总结子节点,每个中间节点包含总结其下级组件的文本。

TreeIndex 的简单使用模型

创建 TreeIndex 的过程非常简单:

  • 这一过程包括 TreeIndex 获取文档,将它们分层结构化,然后允许使用这种结构进行高效的数据检索。

KnowledgeGraphIndex

KnowledgeGraphIndex 通过从提取的三元组构建知识图谱(KG)来增强查询处理能力。此类索引主要依赖于 LLM 从文本中提取三元组,但也提供了使用自定义提取函数的灵活性。

图 5.11 - KnowledgeGraphIndex 的结构

image.png

实际应用场景

一个有趣的应用场景是新闻聚合应用程序,在这种情况下,每天从各种来源(如报纸、博客和社交媒体平台)中摄取大量文本。KnowledgeGraph 可以用来表示如人、组织、地点等实体及其随时间的关系。

KnowledgeGraphIndex 的简单使用模型

以下是构建和查询知识图谱的简单方法:

  • 在此设置中,索引通过从文档中提取三元组来构建知识图谱,从而实现复杂的关系查询。

KnowledgeGraphIndex 的结构构建

KnowledgeGraphIndex 通过从文本数据中提取主语-谓语-宾语三元组来操作,形成一个知识图谱。它可以使用默认的内置方法或自定义的三元组提取函数来构建结构。

总结

在不同的场景中选择适合的索引类型,可以大大提高检索的效率和准确性。在接下来的章节中,我们将更深入地探讨这些索引的使用场景及其潜在的优势和挑战。

使用 ComposableGraph 在其他索引之上构建索引

from llama_index.core import (ComposableGraph, SimpleDirectoryReader, TreeIndex, SummaryIndex)

documents = SimpleDirectoryReader("files").load_data()
index1 = TreeIndex.from_documents([documents[0]])
index2 = TreeIndex.from_documents([documents[1]])

summary1 = "A short introduction to ancient Rome"
summary2 = "Some facts about dogs"

graph = ComposableGraph.from_indices(SummaryIndex, [index1, index2], index_summaries=[summary1, summary2])
query_engine = graph.as_query_engine()
response = query_engine.query("What can you tell me?")
print(response)

在 LlamaIndex 中,ComposableGraph 提供了一种通过将索引相互叠加来组织信息的高级方式。

图 5.12 展示了一个 ComposableGraph 的概览

image.png

这种方法允许在单个文档内构建低层级索引,并将这些索引聚合到跨多个文档的高层级索引中。例如,你可以为每个文档的文本构建一个 TreeIndex,并为一个文档集合中的每个 TreeIndex 构建一个 SummaryIndex。

如何使用 ComposableGraph

以下是一个简单的代码示例,展示了 ComposableGraph 的用法:

在这个示例中,ComposableGraph 促进了文档内详细信息的组织和跨文档的摘要。

首先,我们加载两个测试文档:一个与古罗马有关,另一个描述了狗。然后,我们为每个文档创建一个 TreeIndex。

我们还定义了两个文档的摘要。

小提示

作为手动定义摘要的替代方法,我们还可以查询每个单独的索引以自动生成内容摘要,或者使用 SummaryExtractor 来实现同样的目的。

在下一步中,我们构建了一个包含两个 TreeIndex 及其摘要的 ComposableGraph。对于这个示例,代码的输出应该类似于以下内容:“I can tell you about the ancient Roman civilization and dogs and their various breeds, traits, and personalities.”

一旦 ComposableGraph 构建完成,根 SummaryIndex 将概述每个文档的各个索引的内容。

这一概念的更详细描述

在内部,ComposableGraph 通过将索引层叠在一起来创建分层结构。这使得能够使用低层级索引组织单个文档内的详细信息,并将这些索引聚合成跨多个文档的高层级索引。

该过程首先为每个文档创建单独的索引,以捕捉文档内的详细信息。此外,还为每个文档定义摘要。

然后使用 from_indices() 类方法构建 ComposableGraph。它接收根索引类(在我们的示例中为 SummaryIndex)、子索引(在我们的示例中为两个 TreeIndex 实例)及其相应的摘要作为输入。该方法为每个子索引创建 IndexNodes 实例,并将摘要与相应的索引关联。这些 IndexNodes 实例随后用于构建根索引。

在查询过程中,ComposableGraph 从顶层摘要索引开始,每个节点对应一个底层索引。查询递归地从根索引开始执行,遍历子索引。ComposableGraphQueryEngine 负责这一递归查询过程。

查询引擎根据查询从根索引中检索相关节点。对于每个相关节点,它使用节点关系中存储的 index_id 来识别对应的子索引。然后,它使用原始查询查询子索引以获取更详细的信息。此过程递归进行,直到查询完所有相关子索引。

可以为 ComposableGraph 中的每个索引配置自定义查询引擎,从而在层级结构的不同层次上实现定制的检索策略。这通过无缝整合各级索引的信息,实现了对复杂数据集的深度、分层理解。

总的来说,ComposableGraph 允许高效地从高层摘要和详细的低层索引中检索相关信息,从而全面理解基础数据。

现在我们已经介绍了适用于 RAG 实现的索引类型,是时候解决一个关键问题——成本

估算构建和查询索引的潜在成本

import tiktoken
from llama_index.core import (TreeIndex, SimpleDirectoryReader, Settings)
from llama_index.core.llms.mock import MockLLM
from llama_index.core.callbacks import (CallbackManager, TokenCountingHandler)

llm = MockLLM(max_tokens=256)
token_counter = TokenCountingHandler(
    tokenizer=tiktoken.encoding_for_model("gpt-3.5-turbo").encode
)
callback_manager = CallbackManager([token_counter])

Settings.callback_manager = callback_manager
Settings.llm = llm

documents = SimpleDirectoryReader("cost_prediction_samples").load_data()
index = TreeIndex.from_documents(
    documents=documents,
    num_children=2,
    show_progress=True
)

print("Total LLM Token Count:", token_counter.total_llm_token_count)
import tiktoken
from llama_index.core import (MockEmbedding, VectorStoreIndex, SimpleDirectoryReader, Settings)
from llama_index.core.callbacks import (CallbackManager, TokenCountingHandler)
from llama_index.core.llms.mock import MockLLM

embed_model = MockEmbedding(embed_dim=1536)
llm = MockLLM(max_tokens=256)
token_counter = TokenCountingHandler(
    tokenizer=tiktoken.encoding_for_model("gpt-3.5-turbo").encode
)
callback_manager = CallbackManager([token_counter])

Settings.embed_model = embed_model
Settings.llm = llm
Settings.callback_manager = callback_manager

documents = SimpleDirectoryReader("cost_prediction_samples").load_data()
index = VectorStoreIndex.from_documents(
    documents=documents,
    show_progress=True
)

print("Embedding Token Count:", token_counter.total_embedding_token_count)

query_engine = index.as_query_engine(service_context=service_context)
response = query_engine.query("What's the cat's name?")

print("Query LLM Token Count:", token_counter.total_llm_token_count)
print("Query Embedding Token Count:", token_counter.total_embedding_token_count)

类似于元数据提取器,索引在成本和数据隐私方面也存在问题。正如我们在本章中所见,大多数索引在构建和/或查询时都在某种程度上依赖于 LLM。

如果不注意潜在的成本,反复调用 LLM 处理大量文本会迅速打破你的预算。例如,如果你要从数千个文档中构建 TreeIndex 或 KeywordTableIndex,那么在索引构建过程中不断调用 LLM 会带来显著的成本。嵌入也可能依赖于对外部模型的调用,因此 VectorStoreIndex 也是另一个重要的成本来源。根据我的经验,预防和预测是避免意外高成本的最佳方式。

就像处理元数据提取一样,我建议首先观察并应用一些最佳实践:

  • 使用不涉及 LLM 调用的索引:如 SummaryIndex 或 SimpleKeywordTableIndex 以消除索引构建成本。
  • 使用更便宜的 LLM 模型:如果不需要完全准确性,可以使用计算需求较低的便宜 LLM 模型,但要注意可能的质量折扣。
  • 缓存和重用索引:避免重复构建索引,通过缓存和重用先前构建的索引来节省成本。
  • 优化查询参数:减少搜索过程中对 LLM 的调用,例如减少 VectorStoreIndex 中的 similarity_top_k 值以降低查询成本。
  • 使用本地模型:在 LlamaIndex 中使用索引时,考虑使用本地 LLM 和嵌入模型来进一步管理成本和维护数据隐私。此方法不仅可以提供更多的隐私控制,还可以减少对昂贵外部服务的依赖,特别是在处理大量数据或在严格预算限制下操作时,本地模型可以显著降低成本。

关于本地 AI 模型的重要提示

RAG 在模型处理过程中引入了额外的知识和上下文信息,有效地弥合了由于较小的训练数据集而造成的差距。因此,即使是那些没有经过大量或多样化数据训练的模型,RAG 也允许它们访问超出其初始训练集的信息,从而提高其性能和输出质量。

这些指导方针肯定会帮助你降低成本,但在为较大的数据集建立索引之前,提前估算一下总是个好主意。

以下是如何使用 MockLLM 来估算构建 TreeIndex 的 LLM 成本的基本示例:

在前面的部分中,我们首先进行了必要的导入。如果你不熟悉在此处使用 tiktoken 作为 tokenizer 的原因,可以回顾第 4 章“将数据摄入到我们的 RAG 工作流中”,我们在其中讨论了使用元数据提取器的潜在成本估算。接下来,让我们设置 MockLLM:

我们刚刚创建了一个具有指定最大 token 限制的 MockLLM 实例,作为最坏情况下的最大成本。然后我们使用以下代码初始化了 TokenCountingHandler,它使用与我们的实际 LLM 模型匹配的 tokenizer:

该处理器将跟踪 token 使用情况。这个构造模拟了一个 LLM,而实际上并没有调用 gpt-3.5-turbo API。

我们已经加载了文档,现在准备构建 TreeIndex:

在构建索引后,脚本会显示存储在 TokenCountingHandler 中的 total_llm_token_count 值。

在这个示例中,我们只使用了 MockLLM 类,因为在构建 TreeIndex 时没有使用嵌入。这使我们能够在实际构建索引并调用真正的 LLM 之前,估算最坏情况下的 LLM token 成本。相同的方法也可以用于估算查询成本。

这里的主要教训是什么? 虽然索引解锁了许多功能,但如果没有优化,过度使用会极大地影响成本。始终在为较大的数据集建立索引之前估算 token 使用情况。

这是第二个示例。它类似于前一个,但这次我们首先估算了构建 VectorStoreIndex 的嵌入成本,然后估算查询索引的总成本:

第一部分处理了导入。接下来,我们设置了 MockEmbedding 和 MockLLM 对象:

在初始化 MockEmbedding 和 MockLLM 对象后,我们定义了 TokenCountingHandler 和 CallbackManager,并将它们包装在自定义设置中。现在是时候加载我们的示例文档,并使用自定义设置构建 VectorStoreIndex:

如果你已经成功克隆了书籍的 GitHub 仓库,ch5 文件夹中的 cost_prediction_samples 子文件夹应该包含一个关于猫 Fluffy 的虚构故事的文件。VectorStoreIndex 使用嵌入模型在索引期间将文本文档编码为向量。在我们的第二个示例中,我们使用 MockEmbedding 和 TokenCountingHandler 估算了这些嵌入调用的 token 成本。嵌入 token 计数提供了构建每个文档的索引在文本长度上的成本指示。

为了获得完整的视图,我们可以更进一步,估算搜索成本:

这也显示了嵌入查询和响应合成的潜在搜索费用。我们还需要使用 MockLLM 来捕获在响应合成过程中假设消耗的 LLM token。

总而言之,遵循预防性最佳实践,并始终在整个文档集合上进行索引之前预测索引构建和查询费用!

现在是时候在我们的项目中取得一些进展了。让我们回顾一下我们的 PITS 项目。

为 PITS 学习材料建立索引——实操

from llama_index.core import (
    VectorStoreIndex, TreeIndex, load_index_from_storage
)
from llama_index.core import StorageContext
from global_settings import INDEX_STORAGE
from document_uploader import ingest_documents

def build_indexes(nodes):
    try:
        storage_context = StorageContext.from_defaults(
            persist_dir=INDEX_STORAGE
        )
        vector_index = load_index_from_storage(
            storage_context, index_id="vector"
        )
        tree_index = load_index_from_storage(
            storage_context, index_id="tree"
        )
        print("所有索引已从存储中加载。")
    except Exception as e:
        print(f"加载索引时发生错误:{e}")
        storage_context = StorageContext.from_defaults()
        vector_index = VectorStoreIndex(
            nodes, storage_context=storage_context
        )
        vector_index.set_index_id("vector")
        tree_index = TreeIndex(
            nodes, storage_context=storage_context
        )
        tree_index.set_index_id("tree")
        storage_context.persist(
            persist_dir=INDEX_STORAGE
        )
        print("新索引已创建并持久化。")
    
    return vector_index, tree_index

通过对 LlamaIndex 中索引工作原理的扎实理解,我们现在可以在辅导应用程序中实现索引逻辑。

让我们创建 index_builder.py 模块。这个模块负责索引的创建。在当前实现中,它创建了两个索引:VectorStoreIndexTreeIndex。如你所见,这是一个非常基本的实现,肯定有改进的空间。首先处理导入:

接下来,我们将实现索引构建函数:

我们首先检查索引是否已经持久化到磁盘上。如果是,那么我们利用持久化功能避免重新构建它们的额外成本。

注意:index_id 的使用

由于我们在同一个存储文件夹(INDEX_STORAGE)中持久化了多个索引,在使用 load_index_from_storage 时,我们需要指定它们各自的 ID,以便 LlamaIndex 可以识别正确的索引。

如果我们在 INDEX_STORAGE 文件夹中找不到它们,我们就会从节点构建这些索引。我们还使用 set_index_id 为每个索引设置一个 ID,以便在将来的会话中可以正确加载它们:

build_indexes 函数返回两个索引对象,我们将在后续的应用程序中使用。

目前到这里为止。我们将在第六章“查询数据,第一部分——上下文检索”中进行下一步操作。

总结

在本章中,我们探讨了 LlamaIndex 中的各种索引策略和架构。索引为构建高性能 RAG 系统提供了关键能力。

在本章中,我们重点介绍了最常用的索引类型——VectorStoreIndex。我们还了解了嵌入、向量存储、相似性搜索和存储上下文等关键概念,这些都与 VectorStoreIndex 密切相关。

我们还介绍了其他索引类型,如用于简单线性扫描的 SummaryIndex、用于关键词搜索的 KeywordTableIndex、用于层次数据的 TreeIndex,以及用于基于关系查询的 KnowledgeGraphIndex。我们介绍了 ComposableGraph 作为构建多级索引的工具,并讨论了成本估算技术和最佳实践。

总体而言,本章概述了 LlamaIndex 中的索引功能,为构建复杂且高效的 RAG 应用程序奠定了基础。

在第六章中再见,我们将讨论在 LlamaIndex 中查询数据的方法。