启动你的LlamaIndex之旅

961 阅读39分钟

现在是深入探讨并更技术性地了解LlamaIndex如何在幕后发挥魔力的时候了。在本章中,我们将探索构成LlamaIndex架构的一些关键概念和组件。我们将学习框架用来摄取、结构化和查询数据的核心构建块。理解这些基本原理将为我们开始实际应用打下坚实的基础。我们将遍历每个概念的理论方面,然后将理论与实际应用连接起来。

本章涵盖的主要主题如下:

  1. 揭示LlamaIndex的基本构建块——文档、节点和索引
  2. 构建我们的第一个交互式增强大型语言模型(LLM)应用程序
  3. 启动我们的个性化智能辅导系统(PITS)项目——一个动手练习

技术要求

为了运行本章中的示例,你需要在环境中安装以下Python库:

还需要两个LlamaIndex集成包:

本章的所有代码示例可以在本书的GitHub仓库的ch3子文件夹中找到:github.com/PacktPublis…

揭示LlamaIndex的基本构建块——文档、节点和索引

from llama_index.core import Document

text = "The quick brown fox jumps over the lazy dog."
doc = Document(
    text=text,
    metadata={'author': 'John Doe','category': 'others'},
    id_='1'
)
print(doc)

pip install wikipedia
pip install llama-index-readers-wikipedia

from llama_index.readers.wikipedia import WikipediaReader

loader = WikipediaReader()
documents = loader.load_data(
    pages=['Pythagorean theorem', 'General relativity']
)
print(f"loaded {len(documents)} documents")

from llama_index.core import Document
from llama_index.core.schema import TextNode

doc = Document(text="This is a sample document text")
n1 = TextNode(text=doc.text[0:16], doc_id=doc.id_)
n2 = TextNode(text=doc.text[17:30], doc_id=doc.id_)
print(n1)
print(n2)

Node ID: 102b570f-5b22-48b5-b9b6-6378597e920d Text: This is a sample
Node ID: 0ad81b09-bf12-4063-bfe4-6c5fd3c36cd4 Text: document text

from llama_index.core import Document
from llama_index.core.node_parser import TokenTextSplitter

doc = Document(
    text=(
        "This is sentence 1. This is sentence 2. "
        "Sentence 3 here."
    ),
    metadata={"author": "John Smith"}
)
splitter = TokenTextSplitter(
    chunk_size=12,
    chunk_overlap=0,
    separator=" "
)
nodes = splitter.get_nodes_from_documents([doc])
for node in nodes:
    print(node.text)
    print(node.metadata)

Metadata length (6) is close to chunk size (12). Resulting chunks are less than 50 tokens. Consider increasing the chunk size or decreasing the size of your metadata to avoid this.
This is sentence 1. {'author': 'John Smith'}
This is sentence 2. {'author': 'John Smith'}
Sentence 3 here. {'author': 'John Smith'}

from llama_index.core import Document
from llama_index.core.schema import (
    TextNode,
    NodeRelationship,
    RelatedNodeInfo
)

doc = Document(text="First sentence. Second Sentence")
n1 = TextNode(text="First sentence", node_id=doc.doc_id)
n2 = TextNode(text="Second sentence", node_id=doc.doc_id)
n1.relationships[NodeRelationship.NEXT] = n2.node_id
n2.relationships[NodeRelationship.PREVIOUS] = n1.node_id
print(n1.relationships)
print(n2.relationships)

from llama_index.core import SummaryIndex, Document
from llama_index.core.schema import TextNode

nodes = [
    TextNode(
        text="Lionel Messi is a football player from Argentina."
    ),
    TextNode(
        text="He has won the Ballon d'Or trophy 7 times."
    ),
    TextNode(text="Lionel Messi's hometown is Rosario."),
    TextNode(text="He was born on June 24, 1987.")
]

index = SummaryIndex(nodes)
query_engine = index.as_query_engine()
response = query_engine.query("What is Messi's hometown?")
print(response)
Messi's hometown is Rosario.

随着我们开始使用LlamaIndex,现在是时候理解其架构的一些关键概念和组件了。你可以将本章视为对使用LlamaIndex构建的典型检索增强生成(RAG)架构的简要介绍,并概述该框架提供的最重要工具。这将使你对如何构建一个简单的RAG应用程序有基本的理解。在接下来的章节中,我们将逐步详细探讨这里介绍的每一个组件。

从高层次来看,LlamaIndex帮助将外部数据源连接到LLMs。为了有效地做到这一点,它需要以允许高效检索和查询的方式摄取、结构化和组织你的数据。在本章的第一部分,我们将探索使LlamaIndex能够增强LLMs的核心元素——文档、节点和索引。

文档

一切都从数据开始。

尝试直接处理原始数据就像用手抓水一样棘手。它通常没有任何固定结构,四散各处。这时我们需要介入并给予它一些形状。这正是我们在LlamaIndex中用文档来做的事情。文档是我们捕获和包含任何类型数据的方式,无论你是手动输入数据还是从外部来源加载数据。就像把数据放在一个漂亮的瓶子里,使其更容易处理。

想象一下,你有一堆保存为PDF格式的公司程序,并希望使用像GPT-4这样强大的语言模型来理解它们。在LlamaIndex中,每一个程序都会被转换为一个文档对象——不仅仅是文件。如果你有存储在数据库中的数据或通过API传输的数据——这些也可以是文档。请查看图3.1以获取视觉概述:

image.png

将Document类想象成一个容器。它不仅保存了原始文本或数据的来源,还包含了你决定附加的任何额外信息。这些额外的信息称为元数据,当你开始搜索你的文档时,它可以让你对查询变得非常具体。

这是一个手动创建文档的基本示例:

from llama_index.core import Document

text = "The quick brown fox jumps over the lazy dog."
doc = Document(
    text=text,
    metadata={'author': 'John Doe', 'category': 'others'},
    id_='1'
)
print(doc)

在这个例子中,导入Document类后,我们创建了一个名为doc的Document对象。该对象包含实际文本、文档ID以及我们选择的一些额外元数据,这些元数据作为字典提供。

以下是Document对象的一些最重要属性:

  • text:此属性存储文档的文本内容。
  • metadata:此属性是一个字典,可以用来包含关于文档的额外信息,例如文件名或类别。元数据字典中的键必须是字符串,值可以是字符串、浮点数或整数。
  • id_:这是每个文档的唯一ID。你可以手动设置这个ID,但如果你不指定ID,LlamaIndex会自动为你生成一个。

你还可以在LlamaIndex的GitHub仓库中找到其他属性。然而,为了简化起见,我们目前只关注这三个属性。这些属性提供了各种方法来定制和增强LlamaIndex中Document类的功能。

图3.2展示了LlamaIndex文档的基本结构。

image.png

LlamaIndex的文档包含未经处理的原始数据。尽管给出的示例展示了如何手动创建一个文档,但在实际应用中,这些文档通常是通过从各种数据源批量生成的。这种数据的批量摄取使用了一个广泛库中预定义的数据加载器——有时称为连接器或简单的读取器——这个库被称为LlamaHub (llamahub.ai/)。

注意 这些即插即用的软件包主要由LlamaIndex社区开发,扩展了框架核心组件的功能。它们提供了不同的LLMs、代理工具、嵌入模型、向量存储和数据加载器。这些数据摄取工具与各种数据文件格式、数据库和API端点兼容。LlamaHub中已有130多种不同的数据读取器,并且列表还在不断增长。我们将在下一章中更详细地介绍LlamaHub。现在,我们将专注于数据加载器。

这是使用预定义的LlamaHub数据加载器进行自动数据摄取的基本示例。在运行示例之前,确保你已经安装了技术要求部分提到的库,并完成了第二章中提到的所有必要的环境准备工作:

首先,安装用于轻松访问和解析Wikipedia数据的库,第二个是LlamaIndex与Wikipedia数据加载器的集成库。

pip install wikipedia
pip install llama-index-readers-wikipedia

安装这两个库后,你将能够运行以下示例:

from llama_index.readers.wikipedia import WikipediaReader

loader = WikipediaReader()
documents = loader.load_data(
    pages=['Pythagorean theorem', 'General relativity']
)
print(f"loaded {len(documents)} documents")

WikipediaReader加载器使用Wikipedia Python包从Wikipedia文章中提取文本。除了WikipediaReader,LlamaHub中还有许多其他专业的数据连接器。

因此,创建文档是一个非常简单的过程。但原始的文档对象如何转换成LLMs能够高效处理和推理的格式呢?这就是节点的作用。

节点

虽然文档代表的是原始数据,并且可以直接使用,但节点是从文档中提取的较小内容块。将文档分解成更小、更易管理的文本块有几个目的:

  • 使专有知识适应模型的提示限制:想象一下,如果我们有一个50页长的内部程序,在将其全部内容作为提示输入时肯定会遇到大小限制的问题。然而,在实际操作中,我们很可能不需要在一个提示中输入整个程序。因此,只选择相关的节点可以解决这个问题。
  • 创建围绕特定信息的语义数据单元:这可以使数据的处理和分析更加容易,因为它被组织成较小、更加集中的单元。
  • 允许节点之间创建关系:这意味着节点可以根据它们之间的关系链接在一起,创建一个互联数据的网络。这对于理解文档中不同信息片段之间的连接和依赖关系非常有用。

请参见图3.3,了解这一概念的视觉表示:

image.png

在LlamaIndex中,节点(Nodes)也可以存储图像,但我们在本书中不会重点介绍该功能。从现在开始,我们的主要角色将是TextNode类。

以下是TextNode类的一些重要属性:

  • text:从原始文档中提取的文本块。
  • start_char_idxend_char_idx:可选的整数值,用于存储文本在文档中的起始和结束字符位置。当文本是较大文档的一部分时,这会非常有用,可以精确定位其位置。
  • text_templatemetadata_template:模板字段,用于定义文本和元数据的格式化方式。它们有助于生成更结构化和可读的TextNode表示。
  • metadata_seperator:这是一个字符串字段,用于定义元数据字段之间的分隔符。当包含多个元数据项时,这个分隔符用于保持可读性和结构性。
  • 任何有用的元数据,例如父文档ID、与其他节点的关系和可选标签。当需要时,这些元数据可以用于存储附加上下文。我们将在第四章“将数据摄取到我们的RAG工作流”中更详细地讨论。

与文档一样,如果你想查看TextNode属性的完整列表,可以在LlamaIndex的GitHub仓库中找到相关描述:LlamaIndex GitHub repository

你应该知道,节点会自动继承在文档级别已经存在的任何元数据,但它们的元数据也可以单独定制。

在LlamaIndex中,有几种创建节点的方法,我们将在接下来的小节中讨论。让我们从手动创建节点开始。

from llama_index.core import Document
from llama_index.core.schema import TextNode

doc = Document(text="This is a sample document text")
n1 = TextNode(text=doc.text[0:16], doc_id=doc.id_)
n2 = TextNode(text=doc.text[17:30], doc_id=doc.id_)
print(n1)
print(n2)

# 输出示例
# Node ID: 102b570f-5b22-48b5-b9b6-6378597e920d Text: This is a sample
# Node ID: 0ad81b09-bf12-4063-bfe4-6c5fd3c36cd4 Text: document text

手动创建节点对象

以下是如何手动创建节点对象的简单示例:

from llama_index.core import Document
from llama_index.core.schema import TextNode

doc = Document(text="This is a sample document text")
n1 = TextNode(text=doc.text[0:16], doc_id=doc.id_)
n2 = TextNode(text=doc.text[17:30], doc_id=doc.id_)
print(n1)
print(n2)

在这个示例中,我们使用Python的文本切片功能手动提取两个节点的文本。当你想完全控制节点的文本和伴随的元数据时,这种手动方法非常有用。

为了理解后台发生的事情,让我们看看这段代码的输出:

注意 正如你所见,这两个节点包含一个随机生成的ID和我们从原始文档中切片的文本段。TextNode构造函数使用Python的UUID模块自动为每个节点生成一个ID。但如果我们想使用不同的标识方案,也可以在创建节点后自定义该标识符。

使用分割器自动从文档中提取节点

由于文档分块在RAG工作流中非常重要,LlamaIndex为此提供了内置工具。其中一个工具是TokenTextSplitter。

以下是如何自动生成节点的一个示例,TokenTextSplitter尝试将文档文本分割成包含完整句子的块。每个块将包含一个或多个句子,并且块之间有默认的重叠以保持更多上下文。

在底层,我们可以在SimpleNodeParser上自定义一些参数,如chunk_sizechunk_overlap,但我们将在下一章中更多地讨论这些参数及其工作原理。现在,让我们看看如何在文档对象上使用TokenTextSplitter的默认设置:

from llama_index.core import Document
from llama_index.core.node_parser import TokenTextSplitter

doc = Document(
    text=(
        "This is sentence 1. This is sentence 2. "
        "Sentence 3 here."
    ),
    metadata={"author": "John Smith"}
)
splitter = TokenTextSplitter(
    chunk_size=12,
    chunk_overlap=0,
    separator=" "
)
nodes = splitter.get_nodes_from_documents([doc])
for node in nodes:
    print(node.text)
    print(node.metadata)

这是此次代码的输出:

注意 由于块大小决定了一次可以处理多少内容,如果元数据过大,它将占用每个块的大部分空间,留给实际内容文本的空间较少。这可能导致块主要是元数据,实际内容很少。在我们的示例中,由于有效块大小(块大小减去元数据占用的空间)导致的块少于50个标记,触发了警告。这被认为对于高效处理来说太小了。

这是一个简单的示例,旨在说明如何自动将数据分块为单独的节点。如果你查看每个节点的元数据,你还会注意到它自动继承自原始文档。

还有其他创建节点的方法吗?

是的,还有其他几种方法。在下一章中,我们将深入探讨LlamaIndex中可用的文本分割和节点解析技术。你还将有机会了解它们的工作原理以及提供的自定义选项。

但等等,还有更多关于节点的内容要了解。

节点不喜欢孤单——它们渴望关系

现在我们已经介绍了一些创建简单节点的基本示例,接下来我们来添加一些节点之间的关系吧?

以下是手动创建两个节点之间简单关系的示例:

from llama_index.core import Document
from llama_index.core.schema import (
    TextNode,
    NodeRelationship,
    RelatedNodeInfo
)

doc = Document(text="First sentence. Second Sentence")
n1 = TextNode(text="First sentence", node_id=doc.doc_id)
n2 = TextNode(text="Second sentence", node_id=doc.doc_id)
n1.relationships[NodeRelationship.NEXT] = n2.node_id
n2.relationships[NodeRelationship.PREVIOUS] = n1.node_id
print(n1.relationships)
print(n2.relationships)

在这个示例中,我们手动创建了两个节点,并定义了它们之间的前后关系。这个关系跟踪节点在原始文档中的顺序。此代码告诉LlamaIndex这两个节点属于初始文档,并且它们按特定顺序排列。

图3.4展示了运行代码后LlamaIndex现在理解的内容:

image.png

节点关系注意事项

你应该知道,LlamaIndex包含自动创建节点关系的必要工具。例如,当使用之前讨论的自动节点解析器时,在其默认配置下,LlamaIndex将自动在生成的节点之间创建前后关系。

我们还可以定义其他类型的关系。除了简单的前后关系外,节点还可以使用以下关系进行连接:

  • SOURCE:源关系表示节点被提取或解析的原始源文档。当你将一个文档解析成多个节点时,你可以使用源关系追踪每个节点的来源文档。
  • PARENT:父关系表示一个层级结构,其中具有此关系的节点比关联节点高一个级别。在树结构中,一个父节点可以有一个或多个子节点。此关系用于导航或管理嵌套数据结构,其中可能有一个主节点和表示部分、段落或其他细分的子节点。
  • CHILD:这是PARENT的相反关系。具有子关系的节点是另一个节点的下级——父节点。子节点可以看作是从父节点延伸出来的树结构中的叶子或分支。

为什么关系重要?

在LlamaIndex中创建节点之间的关系有几个原因:

  • 启用更多上下文查询:通过将节点链接在一起,你可以在查询期间利用它们的关系来检索更多相关的上下文。例如,在查询一个节点时,你可以返回前一个或下一个节点以提供更多上下文。
  • 允许跟踪来源:关系编码了来源——源节点的来源以及它们是如何连接的。这在需要识别节点的原始来源时非常有用。
  • 启用节点导航:通过关系遍历节点可以启用新的查询类型。例如,查找包含某个关键词的下一个节点。沿着关系进行导航为搜索提供了另一个维度。
  • 支持构建知识图谱:节点和关系是知识图谱的构建块。将节点链接成图结构允许使用LlamaIndex从文本中构建知识图谱。我们将在第五章“使用LlamaIndex进行索引”中详细讨论知识图谱。
  • 改善索引结构:一些LlamaIndex索引,例如树和图,利用节点关系来构建其内部结构。关系允许构建更复杂和更具表现力的索引拓扑结构。我们将在第五章“使用LlamaIndex进行索引”中详细讨论这一点。

总而言之,关系为节点增加了额外的上下文连接。这支持更有表现力的查询、来源跟踪、知识图谱构建和复杂的索引结构。

索引

我们的第三个重要概念——索引——指的是用于组织节点集合以优化存储和检索的特定数据结构。

一个简化的类比

将数据整理以进行RAG就像为一次重要旅行准备衣物一样——你必须确保一切都井井有条并且易于获取!假设你在为一次重要的商务旅行打包。你可以随便把所有东西扔进行李箱,但你的衬衫、袜子、裤子和其他物品会混在一起!问题是,当你想快速拿到需要的东西时,可能会拿错,最终发明出全新的着装规范。

这就是为什么在准备LLM增强时对数据进行索引如此重要。没有索引,你的数据就是一堆杂乱无章的事实和文件,就像在一个爆满的行李箱里找一双配对的袜子。

适当的索引将信息整齐地分类。例如,我们的销售记录在一个索引中,支持票据在另一个索引中。这就像将相关物品一起打包。这样就将杂乱的数据转变为AI可以利用的整齐组织的知识。你从随机寻找变成从定制口袋中准确拿取需要的东西。

所以,请记住——为了避免将来的挫败感和浪费时间,尽早对数据进行索引和结构化。这将使你的工作更加轻松。

LlamaIndex支持不同类型的索引,每种索引都有其优势和权衡。以下是一些可用的索引类型:

  • SummaryIndex:这非常类似于一个食谱盒——它按顺序保存你的节点,以便你可以一个一个地访问它们。它接收一组文档,将它们分块成节点,然后将它们连接成一个列表。对于阅读大文档来说非常有用。
  • DocumentSummaryIndex:它为每个文档构建一个简明摘要,并将这些摘要映射回各自的节点。它通过使用这些摘要快速识别相关文档来促进高效的信息检索。
  • VectorStoreIndex:这是更复杂的索引类型之一,可能是大多数RAG应用中的主力。它将文本转换为向量嵌入,并使用数学方法将相似的节点分组,帮助定位相似的节点。
  • TreeIndex:对于那些喜欢秩序的人来说,这是完美的解决方案。此索引的行为类似于将较小的盒子放在较大的盒子里,按层级组织节点。在内部,每个父节点存储子节点的摘要。这些是通过LLM使用通用摘要提示生成的。此特定索引在摘要中非常有用。
  • KeywordTableIndex:想象一下你需要通过现有的成分找到一道菜。关键字索引将重要词语连接到它们所在的节点。通过查找关键字,可以轻松找到任何节点。
  • KnowledgeGraphIndex:当你需要将事实链接到存储为知识图谱的大型数据网络时,这非常有用。对于回答关于大量互连信息的复杂问题非常好。
  • ComposableGraph:这允许你创建复杂的索引结构,其中文档级索引被索引到更高级的集合中。也就是说,如果你想从多个文档中访问数据,可以构建索引的索引。

我们将在第五章“使用LlamaIndex进行索引”中详细讨论这些索引的内部工作原理及其他变体。这只是对该主题的概述。

LlamaIndex中的所有索引类型共享一些常见的核心功能:

  • 构建索引:每种索引类型可以通过在初始化时传入一组节点来构建。这会构建基础索引结构。
  • 插入新节点:在构建索引后,可以手动插入新节点。这会添加到现有的索引结构中。
  • 查询索引:构建完成后,索引提供查询接口,以根据特定查询检索相关节点。检索逻辑因索引类型而异。

各索引类型的具体结构和查询方式不同。但这种构建、插入和查询的模式是一致的。了解每种索引类型的特定功能对于充分利用其潜力非常重要。在第五章“使用LlamaIndex进行索引”中,我们将详细讨论这个主题,并为每种索引类型提供具体示例。

现在,让我们通过一个简单的示例来说明SummaryIndex的创建:

from llama_index.core import SummaryIndex, Document
from llama_index.core.schema import TextNode

nodes = [
    TextNode(
        text="Lionel Messi is a football player from Argentina."
    ),
    TextNode(
        text="He has won the Ballon d'Or trophy 7 times."
    ),
    TextNode(text="Lionel Messi's hometown is Rosario."),
    TextNode(text="He was born on June 24, 1987.")
]

index = SummaryIndex(nodes)
query_engine = index.as_query_engine()
response = query_engine.query("What is Messi's hometown?")
print(response)
# 输出:Messi's hometown is Rosario.

这非常简单易懂。我们首先定义了一组包含数据的节点,然后基于这些节点创建了SummaryIndex。此索引是一个基于列表的数据结构。

想象SummaryIndex就像一个小记事本,你在上面记录了很多故事中的要点。当它被设置时,它会把一大堆故事分成更小的部分,并按顺序排列在一个列表中。最棒的是,在构建这种类型的索引时,LlamaIndex甚至不需要使用LLM。

我们到了吗?

几乎到了。索引对于组织数据非常有用,但我们如何从中获取答案呢?这就是检索器和响应合成器的作用!

让我们以刚创建的Lionel Messi索引为例。假设你问:“梅西的家乡是哪里?”看看以下内容:

response = query_engine.query("What is Messi's hometown?")
print(response)

这是输出:

Messi's hometown is Rosario.

这实际上是如何在后台工作的?

QueryEngine包含一个检索器,负责从索引中检索与查询相关的节点。检索器会查找并排名与该查询相关的节点,从索引中抓取可能包含有关梅西家乡信息的节点。

但仅仅返回一个节点列表并不太有用。QueryEngine的另一个部分——节点后处理器——在此时发挥作用。这个部分使得在最终响应生成之前,可以对检索到的节点进行转换、重新排名或过滤。有许多类型的后处理器可用,并且每种都可以根据具体用例进行配置和自定义。

QueryEngine对象还包含一个响应合成器,它通过以下步骤使用LLM生成最终响应:

  • 响应合成器获取检索器选择的节点并由节点后处理器处理,然后将它们格式化为LLM提示。
  • 提示包含查询和来自节点的上下文。
  • 将此提示提供给LLM以生成响应。
  • 使用LLM对原始响应进行必要的后处理,返回最终的自然语言答案。

因此,index.as_query_engine()为我们创建了一个完整的查询引擎,包含默认版本的三个元素:检索器、节点后处理器和响应合成器。

我们将在第六章和第七章中更详细地讨论这三个元素。

运行此引擎的最终结果将是一个自然语言答案,例如“梅西的家乡是罗萨里奥”。

记住 这是使用一种名为SummaryIndex的特定索引类型的基本示例。每种索引类型的行为都不同,我们将在第五章中讨论。例如:TreeIndex按层次排列节点,允许摘要,而KeywordIndex映射关键词以便快速查找。索引结构影响性能并决定其最佳用例。索引结构本身定义了数据管理逻辑。如我们所见,索引需要与检索器、后处理器和响应合成器结合,形成完整的查询管道,使应用程序能够利用索引数据。

即将在后续章节中添加更多详细信息。但此时,你应该对索引及其作用有一个高层次的理解。

让我们看看图3.5,了解完整流程的概述。

image.png

如图3.5所示,该过程涉及以下步骤:

  1. 将数据加载为文档
  2. 将文档解析成连贯的节点
  3. 从节点构建优化的索引
  4. 在索引上运行查询以检索相关节点
  5. 合成最终响应

记不住这么多?让我们回顾一下LlamaIndex的构建块。

关键概念的快速回顾

以下是我们到目前为止所覆盖的内容的快速回顾:

  • 文档(Documents) :摄取的原始数据
  • 节点(Nodes) :从文档中提取的逻辑块
  • 索引(Indexes) :根据使用情况组织节点的数据结构
  • 查询引擎(QueryEngine) :包含检索器、节点后处理器和响应合成器

理解这些构建块对于使用LlamaIndex至关重要。它们使你能够有效地结构化和连接外部数据到LLMs。

现在,你有了概念基础。接下来,让我们通过查看一个简化的工作流模型并构建一个实际应用来巩固这些知识。

构建我们的第一个交互式增强LLM应用

from llama_index.core import Document, SummaryIndex
from llama_index.core.node_parser import SimpleNodeParser
from llama_index.readers.wikipedia import WikipediaReader

loader = WikipediaReader()
documents = loader.load_data(pages=["Messi Lionel"])
parser = SimpleNodeParser.from_defaults()
nodes = parser.get_nodes_from_documents(documents)
index = SummaryIndex(nodes)
query_engine = index.as_query_engine()

print("Ask me anything about Lionel Messi!")

while True:
    question = input("Your question: ")
    if question.lower() == "exit":
        break
    response = query_engine.query(question)
    print(response)

通过以上代码,我们可以构建我们的第一个LlamaIndex应用。

在继续下一步之前,请确保你已经满足本章开头提到的技术要求。对于以下代码示例,我们需要Wikipedia包来解析特定的Wikipedia文章并从中提取示例数据。

一旦成功安装Wikipedia包,示例应用程序应该可以正常运行。以下是代码:

需要注意的是,这不是真正的聊天系统,因为它不保留对话的上下文。更准确地说,它可以被描述为一个简单的问答系统。

以下是代码的快速介绍:

  1. 我们首先使用WikipediaReader数据加载器将关于Lionel Messi的Wikipedia页面加载为文档。这会摄取原始文本数据。
  2. 接下来,我们使用SimpleNodeParser将文档解析为较小的节点块。这将文本拆分为逻辑段。
  3. 然后,我们从节点构建SummaryIndex。它按顺序组织节点以便完全检索上下文。
  4. 我们定义QueryEngine,形成一个完整的查询管道。
  5. 最后,我们创建一个循环,查询索引,将问题传递给QueryEngine。这处理检索相关节点、提示LLM并返回最终响应。

你可以查看图3.5以直观地了解整体工作流程——摄取数据,将其解析为节点,构建索引,并查询它以检索和合成最终答案。

使用LlamaIndex的日志功能理解逻辑和调试我们的应用程序

当你运行像前面示例中的代码时,你可能会觉得幕后有些魔法在发生。你传入一些文本,调用一个简单的索引方法,然后就可以开始查询由你自己的数据驱动的AI助手。

但随着你的应用程序变得更加复杂,你会希望准确了解LlamaIndex在幕后是如何运作的。这时日志功能就变得非常重要。LlamaIndex提供了大量有用的日志语句,逐步展示索引和查询期间发生的事情。就像有一个小的调试解说员描述每个动作一样。

启用基本日志记录非常简单,只需添加以下代码:

import logging
logging.basicConfig(level=logging.DEBUG)

启用调试日志记录后,你会看到LlamaIndex执行的操作,例如:

  • 将文档解析为节点
  • 决定使用哪种索引结构
  • 为LLM格式化提示
  • 根据查询检索相关节点
  • 从节点中合成响应

正如我们在接下来的章节中所看到的,日志还会显示有用的数据,例如:

  • 用于API调用的标记数量
  • 延迟信息
  • 任何警告或错误

注意 当事情没有按预期工作时,不要惊慌!只需检查日志。它们提供了识别问题的重要线索。目前,使用基本日志功能应该足够了。启用此功能后,大多数后台活动现在将在运行时显示,你可以逐步监控应用程序的流程。我们将在第九章“自定义和部署我们的LlamaIndex项目”中讨论更多高级调试。

自定义LlamaIndex使用的LLM

假设我们想配置框架使用另一个LLM。默认情况下,LlamaIndex使用OpenAI API和GPT-3.5-Turbo模型。以下是GPT-3.5-Turbo的主要特性概述:

  • 与GPT-4相比,它运行更快且成本更低
  • 虽然不如GPT-4等其他模型先进,但它仍然是一个非常强大的生成和对话模型
  • 它在各种自然语言处理(NLP)任务中表现非常出色,例如分类、摘要或翻译

你可以理解LlamaIndex的创建者选择此模型的原因。综上所述,它在性能和成本方面为大多数用例提供了良好的平衡。对于大多数应用程序,它可能已经足够了。如果你已经测试了应用程序,你会看到它处理关于Lionel Messi的问题非常好。

但如果我们需要为更具体的情况定制它呢?例如,我们需要GPT-4的最佳性能,Claude-2提供的更大上下文,或者我们想使用开源AI。

简单的步骤

我们只需要在应用程序的开头添加三行代码:

from llama_index.llms.openai import OpenAI
from llama_index.core.settings import Settings

Settings.llm = OpenAI(temperature=0.8, model="gpt-4")

确保你在导入后立即添加Settings.llm行,以便它适用于所有其他操作。以下是每一步的解释:

  1. 第一行从llama_index.llms.openai导入OpenAI类,以便我们可以使用它来初始化一个OpenAI LLM。
  2. 第二个导入负责Settings类。我们将使用它来自定义LLM。
  3. 接下来,我们使用GPT-4模型配置Settings,并将温度设置为0.8,覆盖默认LLM。

我们刚刚配置了LlamaIndex,以便在所有操作中使用GPT-4而不是默认的GPT-3.5-Turbo模型。代码的下一部分将构建索引并使用新配置的LLM运行一个简单的查询:

from llama_index.core.schema import TextNode
from llama_index.core import SummaryIndex

nodes = [
    TextNode(text="Lionel Messi's hometown is Rosario."),
    TextNode(text="He was born on June 24, 1987.")
]

index = SummaryIndex(nodes)
query_engine = index.as_query_engine()
response = query_engine.query("What is Messi's hometown?")
print(response)

接下来,我们需要讨论温度参数。

温度参数

在OpenAI模型(如GPT-3.5和GPT-4)上,此参数控制AI响应的随机性和创造性。查看图3.6以了解概述:

image.png

OpenAI模型的温度值范围是0到2。较高的值会产生更随机、更有创造性的输出,而较低的值会产生更集中的、确定性的输出。

温度值为0时,对于相同的输入提示,几乎每次都会产生相同的输出。你可能注意到我用了“几乎”这个词。这是因为即使温度设置为0,大多数模型在相同的提示下仍可能产生略微不同的答案。这是由于模型初始化中的固有随机性或模型内部状态的微妙变化,这些变化可能是由于浮点精度限制或神经网络中某些操作的随机性造成的。即使温度值为0,这些小的变化也可能导致相同输入的输出略有不同。

设置合适的温度取决于你的用例——是想要强烈基于事实数据的响应还是更具想象力的响应。对于代码生成或数据分析任务,温度值为0.2是合适的,而对于写作或聊天机器人响应等更注重创造性的任务,则建议设置为0.5或更高。

注意 如果你的用例确实需要在多次迭代中对相同提示保持一致的响应,这里有一些实用建议。在我的实验研究中,我使用GPT-3.5-Turbo-1106模型和温度值为0时获得了最一致的结果。

除了温度,还有其他几个参数可以通过字典形式传递给additional_kwargs参数进行调整。如果你计划在RAG工作流中使用OpenAI模型,建议你熟悉这些LLM设置,因为它们在RAG场景中可能非常重要。除了温度,top_pseed参数特别有用,因为它们可以用来控制输出的随机性。有关详细列表,你可以查阅OpenAI官方文档:OpenAI文档

以下是一个简单的游乐场,你可以用来实验不同的LLM设置:

from llama_index.llms.openai import OpenAI

llm = OpenAI(
    model="gpt-3.5-turbo-1106",
    temperature=0.2,
    max_tokens=50,
    additional_kwargs={
        "seed": 12345678,
        "top_p": 0.5
    }
)

response = llm.complete(
    "Explain the concept of gravity in one sentence"
)
print(response)

使用上面的代码,你可以实验不同的设置,检查输出并找到最适合你的特定用例的配置。

如果你想知道目前可用的不同LLM在RAG中的用途,这里有一个从LlamaIndex文档中提取的并排比较。这个列表是由LlamaIndex社区通过测试各种LLM构建的:LLMs比较

理解如何使用Settings进行自定义

你可能已经注意到,在前一节中我使用了一个叫做Settings的东西来自定义AI模型。这里需要简单解释一下。

Settings是LlamaIndex中的一个关键组件,它允许你自定义和配置在索引和查询期间使用的元素。它包含了LlamaIndex中常用的对象,例如:

  • LLM:允许用自定义的LLM覆盖默认的LLM,如前例所示。
  • 嵌入模型:用于生成文本向量以实现语义搜索。这些向量称为嵌入,我们将在第五章“使用LlamaIndex进行索引”中更详细地讨论它们。
  • NodeParser:用于设置默认的节点解析器。
  • CallbackManager:处理LlamaIndex内部事件的回调。我们稍后将看到,这用于调试和追踪我们的应用程序。

Settings中还有其他参数可以调整。我们将在第九章“自定义和部署我们的LlamaIndex项目”中更深入地探讨不同的自定义选项。无论你想更改什么,自定义都会像前面的示例一样进行。一旦你定义了自定义的Settings,所有后续操作都会使用此配置。

好了,我们已经介绍了足够多的概念。来点编程怎么样?

开始我们的PITS项目——动手练习

pip install pyyaml
LOG_FILE = "session_data/user_actions.log"
SESSION_FILE = "session_data/user_session_state.yaml"
CACHE_FILE = "cache/pipeline_cache.json"
CONVERSATION_FILE = "cache/chat_history.json"
QUIZ_FILE = "cache/quiz.csv"
SLIDES_FILE = "cache/slides.json"
STORAGE_PATH = "ingestion_storage/"
INDEX_STORAGE = "index_storage"
QUIZ_SIZE = 5
ITEMS_ON_SLIDE = 4

from global_settings import SESSION_FILE
import yaml
import os

def save_session(state):
    state_to_save = {key: value for key, value in state.items()}
    with open(SESSION_FILE, 'w') as file:
        yaml.dump(state_to_save, file)

def load_session(state):
    if os.path.exists(SESSION_FILE):
        with open(SESSION_FILE, 'r') as file:
            try:
                loaded_state = yaml.safe_load(file) or {}
                for key, value in loaded_state.items():
                    state[key] = value
                return True
            except yaml.YAMLError:
                return False
    return False

def delete_session(state):
    if os.path.exists(SESSION_FILE):
        os.remove(SESSION_FILE)
    for key in list(state.keys()):
        del state[key]

from datetime import datetime
from global_settings import LOG_FILE
import os

def log_action(action, action_type):
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    log_entry = f"{timestamp}: {action_type} : {action}\n"
    with open(LOG_FILE, 'a') as file:
        file.write(log_entry)

def reset_log():
    with open(LOG_FILE, 'w') as file:
        file.truncate(0)

准备好动手实践了吗?现在是时候开始构建我们的PITS项目了。我们已经有了足够的理论基础,在本章中,我们将开始准备更高级的元素。

我尝试以模块化结构构建项目。我相信这有助于代码的清晰性,并使我们能够逐个理解LlamaIndex的一些重要概念。正如我在前一章中提到的,你可以在阅读本书的同时编写代码,或者使用我为你提供的GitHub仓库下载并完整地研究它。

免责声明 现有代码库中有许多方面可以改进,PITS缺少许多功能,不能被视为生产就绪的应用程序。例如,在我的实现中,没有身份验证,应用程序是单用户的。而且,为了简短代码,我没有处理太多的错误处理。但当然,这些不是错误,而是功能。这样,你可以继续PITS的故事,添加缺失的元素并将其转变为商业级应用程序。为什么不呢?

在开始之前,我想简要解释一下我们应用程序的代码结构。以下是PITS使用的Python源代码文件列表及其简要说明:

  • app.py:Streamlit应用程序的主要入口点。它负责应用程序的初始化,并根据应用程序逻辑管理不同屏幕之间的导航。
  • document_uploader.py:与LlamaIndex接口以摄取和索引上传的文档。
  • training_material_builder.py:基于用户的当前知识构建学习材料(幻灯片和旁白)。它利用上传和索引的材料生成学习内容。
  • training_interface.py:实际教学将在这里进行。它显示幻灯片和导师的旁白以及用户交互的对话侧栏。
  • quiz_builder.py:基于摄取的材料和用户的当前知识生成测验。
  • quiz_interface.py:管理测验并根据结果评估用户的知识水平——就像高中时讨厌的测验。
  • conversation_engine.py:管理对话侧栏,响应用户查询并提供解释。它还跟踪与导师的对话上下文,避免重复并确保相关帮助。还检索之前讨论的摘要,确保导师从上次停止的地方继续。
  • storage_manager.py:处理所有文件操作,如保存和加载会话状态和用户上传。它管理本地文件存储,以后可以适配云存储解决方案。
  • session_functions.py:处理本地存储和检索会话信息——最终在云端。
  • logging_functions.py:处理应用程序的所有用户交互的日志记录。写入带时间戳的描述性日志语句,以跟踪用户在应用程序中的操作。本地存储和检索应用程序日志——最终在云端。
  • global_settings.py:包含应用程序设置、配置和最终的Streamlit部署机密。集中管理参数以便于管理和更新。
  • user_onboarding.py:负责用户注册步骤的模块。
  • index_builder.py:构建应用程序中使用的索引的模块。

请记住,目前应用程序设计为本地运行。在第九章“自定义和部署我们的LlamaIndex项目”中,我们将详细讨论Streamlit应用的部署选项。在继续之前,请确保你已经安装了本章开头提到的第二个包——Python的YAML包。PITS的session_functions模块将需要它。我稍后会解释。

要安装它,请使用以下代码:

pip install pyyaml

现在,我们将重点关注PITS的三个模块:

  • global_settings.py
  • session_functions.py
  • logging_functions.py

让我们看看源代码

首先,我们从global_settings.py中的全局设置开始:

这是我们存储全局配置的地方。我们将在这里使用不同的参数来自定义PITS的体验并调整一些内部设置。目前,我想强调的两个参数是LOG_FILE和SESSION_FILE。它们用于定义“日志文件”和会话相关数据的存储位置。日志文件将用于记住所有用户交互并维护对话上下文。会话文件将允许在保持会话状态的情况下恢复现有会话。

现在,让我们继续session_functions.py。

session_functions.py模块包含处理用户会话状态的保存、加载和删除的函数:

def save_session(state):
    state_to_save = {key: value for key, value in state.items()}
    with open(SESSION_FILE, 'w') as file:
        yaml.dump(state_to_save, file)

def load_session(state):
    if os.path.exists(SESSION_FILE):
        with open(SESSION_FILE, 'r') as file:
            try:
                loaded_state = yaml.safe_load(file) or {}
                for key, value in loaded_state.items():
                    state[key] = value
                return True
            except yaml.YAMLError:
                return False
    return False

def delete_session(state):
    if os.path.exists(SESSION_FILE):
        os.remove(SESSION_FILE)
    for key in list(state.keys()):
        del state[key]

save_session函数将当前状态作为参数,其中包含有关用户会话的所有必要信息,并将其写入名为SESSION_FILE的文件。在保存之前,状态被转换为YAML格式,这确保它可以在以后轻松重新加载。

此函数尝试读取SESSION_FILE,如果存在,则将存储的会话数据加载到提供的状态对象中。如果文件成功读取并且YAML内容正确解析,则返回True,表示会话状态已恢复。否则,返回False。

当需要清除会话时,此函数删除SESSION_FILE并从传递的状态对象中删除所有键,实际上重置了会话。

为什么选择YAML?

我选择YAML作为序列化格式而不是Streamlit自己的持久化格式,因为它是人类可读且平台独立的。YAML适用于层次数据结构,使其在必要时可以在应用程序之外轻松读取和编辑。它允许会话状态以结构化的标准格式存储,可以根据需要轻松传输或修改。YAML通常用于配置文件,但也适用于存储简单数据结构,例如我们的会话状态。

我们还需要创建logging_functions.py。以下是代码:

from datetime import datetime
from global_settings import LOG_FILE
import os

def log_action(action, action_type):
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    log_entry = f"{timestamp}: {action_type} : {action}\n"
    with open(LOG_FILE, 'a') as file:
        file.write(log_entry)

def reset_log():
    with open(LOG_FILE, 'w') as file:
        file.truncate(0)

logging_functions.py模块负责记录事件、用户操作和应用程序执行期间的其他重要事件。我设计它主要是为了在PITS代理与用户交互时提供上下文,但也用于监控和调试目的。

以下是模块中函数的功能:

  • log_action(action, action_type):此函数记录一个操作或事件。它接受两个参数:描述发生了什么的字符串action和对操作进行分类的action_type。该函数获取当前时间戳,将其与操作和类型格式化,并将此条目附加到LOG_FILE中。这有助于维护操作和事件的时间顺序记录。
  • reset_log():在当前实现中,当用户返回现有会话时,他们可以选择开始一个新会话。当这种情况发生时,我们清除日志文件以避免收集过多数据。此函数打开LOG_FILE并截断其内容,实际上删除了所有日志条目。在生产环境中这通常不常见,因为日志对于历史数据分析很有价值,但在我们的情况下,它简化了流程。

我知道我承诺过我们会在编写PITS代码时享受乐趣,而我完全明白日志记录看起来不像是“哈哈”,更像是“无聊”,但相信我,如果你不能调试你的应用程序,就没有乐趣可言。我们需要在这里打下基础,并将在接下来的章节中继续其余模块的介绍。

总结

本章涵盖了基础概念,如文档、节点和索引——LlamaIndex的核心构建块。我展示了一个简单的工作流程,将数据加载为文档,使用解析器将其解析成连贯的节点,从节点构建优化的索引,然后查询索引以检索相关节点并合成响应。

LlamaIndex的日志功能被介绍为理解底层逻辑和调试应用程序的重要工具。日志揭示了LlamaIndex如何解析、索引、提示LLM、检索节点和合成响应。我们使用Settings类展示了如何自定义LlamaIndex使用的LLM和其他服务。

我们还开始构建我们的PITS辅导应用程序,打下了会话管理和日志记录功能的基础。这种模块化结构将使我们能够在应用程序构建过程中逐步探索LlamaIndex的功能。

随着基础知识的建立,是时候继续探索更高级的LlamaIndex功能了。旅程继续!