Zhanbo's Blog
Back to home

读懂 LlamaIndex:从 RAG 数据流到 Tool、Agent 与 Workflow

RAG/Learning Notes/AI Agent
2026-08-03
11 min read
0 reads
LlamaIndexRAGEmbeddingVectorStoreChromaDBRetrieverQueryEngineAgentWorkflow

In brief

LlamaIndex 经常和 LangChain、LangGraph、smolagents 一起出现在 Agent 教程中,但它真正有辨识度的部分并不是 Agent 或 Multi-Agent,而是围绕私有数据建立的一整套 RAG 抽象:DocumentNodeIngestionPipelineVectorStoreIndexRetrieverQueryEngineResponseSynthesizer。本文从一份 persona 数据集出发,完整拆解数据加载、分块、Embedding、向量存储、检索、回答合成与评估的链路,并进一步解释为什么 QueryEngine 还要包装成 Tool、什么时候 Agent 层属于过度设计、Workflow 又如何通过 Event 类型连接多个 Step。最后给出 LlamaIndex、LangGraph 与 smolagents 的定位对比,以及本地运行 Notebook 时遇到的典型问题。

从名字开始:LlamaIndex 的重点是 Index

只看 Agent Course 中的示例,很容易把 LlamaIndex 理解成另一个 Agent 框架:它有 Tool、Agent、Multi-Agent,也有 Workflow。然而这些能力并不独特,LangChain、LangGraph 和 smolagents 都能提供类似机制。

LlamaIndex 更核心的问题其实是:

外部数据怎样被加载、切分、索引和检索,最终成为 LLM 可以使用的上下文?

因此,LlamaIndex 更接近 LLM 应用中的“数据访问与知识检索层”。它的核心路径可以概括为:

Agent 与 Workflow 是建立在这条数据链路之上的应用和编排能力。理解 LlamaIndex,首先要把 RAG 主链路理顺。

RAG 第一阶段:Loading 不等于 Embedding

课程使用 Hugging Face 上的 persona 数据集:

from datasets import load_dataset

dataset = load_dataset(
    path="dvilasuero/finepersonas-v0.1-tiny",
    split="train",
)

这里的 load_dataset() 只负责把远程数据集加载成 Hugging Face Dataset。它还没有创建 LlamaIndex 的 Document,也没有调用 LlamaIndex 的 Embedding 模型。

数据集本身实际上已经包含 embedding 字段,并记录了生成该向量的模型:

model_name_embeddings = Alibaba-NLP/gte-large-en-v1.5
embedding              = 1024 维向量

但课程随后只取出 persona 文本并写入文件:

from pathlib import Path

Path("data").mkdir(parents=True, exist_ok=True)

for i, persona in enumerate(dataset):
    with open(Path("data") / f"persona_{i}.txt", "w") as f:
        f.write(persona["persona"])

这一操作丢弃了数据集已有的 embedding、最近邻和聚类字段。也就是说,后面的 LlamaIndex Pipeline 并没有复用原始向量,而是从纯文本重新开始。

接下来:

from llama_index.core import SimpleDirectoryReader

reader = SimpleDirectoryReader(input_dir="data")
documents = reader.load_data()

SimpleDirectoryReader 会读取目录中的文件,并把正文和文件元数据包装为:

list[Document]

概念上相当于:

documents = []

for file in directory:
    documents.append(
        Document(
            text=read_text(file),
            metadata={"file_name": file.name},
        )
    )

因此,Loading 阶段的结果不是向量,而是适合 LlamaIndex 后续处理的 Document 对象列表。

Document.example()[Document.example()]

课程还出现过:

nodes = await pipeline.arun(
    documents=[Document.example()]
)

Document.example() 创建一个示例 Document,外层的 [] 则把单个对象包装成列表,因为 documents 参数接收多个文档。

对应 Java 的思路接近:

List<Document> documents = List.of(Document.example());

这不是特殊语法,只是“创建对象后立即放入 List”的 Python 写法。

RAG 第二阶段:IngestionPipeline 完成分块和 Embedding

加载完成后,课程使用:

from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.ingestion import IngestionPipeline

pipeline = IngestionPipeline(
    transformations=[
        SentenceSplitter(chunk_overlap=0),
        HuggingFaceEmbedding(
            model_name="BAAI/bge-small-en-v1.5"
        ),
    ]
)

nodes = await pipeline.arun(documents=documents)

transformations 会按照声明顺序执行:

Document
  ↓ SentenceSplitter
Node
  ↓ HuggingFaceEmbedding
带 embedding 的 Node

Node 不等于 Embedding

Node 是数据结构,不是“向量化结果”的同义词。刚经过 SentenceSplitter 的 Node 可以只有文本:

Node
├── text
├── metadata
├── relationships
└── embedding = None

经过 HuggingFaceEmbedding 后,embedding 属性才会被填充。对于 BAAI/bge-small-en-v1.5,向量通常是 384 维:

print(nodes[0].embedding is not None)
print(len(nodes[0].embedding))

需要注意,后续从 Chroma 检索回来的 TextNode 可能再次显示 embedding=None。这不代表数据库没有向量,而是检索结果通常不会把整段向量重新传回应用层;相似度已经由 Chroma 使用存储的向量计算完成。

为什么不直接使用数据集已有的 embedding?

课程重新 Embedding 有两个重要原因。

第一,原数据集的向量对应整条 persona,而 SentenceSplitter 可能把一条文本切成多个 Node。每个新 Node 都需要自己的向量。

第二,文档向量和查询向量必须来自同一个 Embedding 模型。原数据集使用 Alibaba-NLP/gte-large-en-v1.5,课程查询阶段使用 BAAI/bge-small-en-v1.5。不同模型产生的向量不在同一个语义空间中,不能直接进行有意义的相似度比较。

RAG 第三阶段:把 Node 自动写入 Chroma

IngestionPipeline 增加 vector_store

import chromadb
from llama_index.vector_stores.chroma import ChromaVectorStore

db = chromadb.PersistentClient(path="./alfred_chroma_db")
chroma_collection = db.get_or_create_collection("alfred")
vector_store = ChromaVectorStore(
    chroma_collection=chroma_collection
)

pipeline = IngestionPipeline(
    transformations=[
        SentenceSplitter(chunk_size=25, chunk_overlap=0),
        HuggingFaceEmbedding(
            model_name="BAAI/bge-small-en-v1.5"
        ),
    ],
    vector_store=vector_store,
)

此时,真正触发处理和写入的仍然是:

nodes = await pipeline.arun(documents=documents)

完整数据流变成:

IngestionPipeline(...) 只是配置对象;只有执行 run()arun() 才会真正分块、Embedding 和保存。

可以通过以下代码确认写入数量:

print(chroma_collection.count())

Document 数量不一定等于 Chroma 记录数,因为一个 Document 可以被拆成多个 Node。

Index 到底是什么

数据写入 Chroma 后,课程创建:

from llama_index.core import VectorStoreIndex
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

embed_model = HuggingFaceEmbedding(
    model_name="BAAI/bge-small-en-v1.5"
)

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

这一步不会重新写入或重新 Embedding 数据。它是在已有 Vector Store 外建立 LlamaIndex 的检索抽象,告诉系统:

数据存储在哪里
查询文本用什么模型转为向量
如何把检索结果还原为 Node
如何接入后续 Retriever 和 QueryEngine

因此,Index 不是简单标签,也不等于数据库。更合适的理解是:

Index 是面向查询的数据访问和检索协调层。

它与 Spring Boot 的 Mapper/Repository 有些相似,但还包含查询向量生成、Node 恢复以及 LlamaIndex StorageContext 等能力。

从 Index 派生三种查询接口

同一个 Index 可以提供三个层次的接口。

as_retriever():只找资料

retriever = index.as_retriever(similarity_top_k=3)
nodes = retriever.retrieve("Who is interested in AI?")

返回值通常是:

list[NodeWithScore]

Retriever 只负责:

问题文本 → 查询向量 → 相似度搜索 → NodeWithScore

它通常不调用生成式 LLM,适合检查 RAG 到底找到了什么。

as_query_engine():检索后生成一次答案

query_engine = index.as_query_engine(
    llm=llm,
    similarity_top_k=3,
)

response = query_engine.query(
    "Who is interested in AI?"
)

QueryEngine 可以理解为:

Retriever
+ Prompt
+ Response Synthesizer
+ LLM

它返回的不只是字符串,还包括最终答案、来源 Node 和元数据。

as_chat_engine():在 RAG 上增加对话历史

ChatEngine 适合处理:

第一轮:找一个对 AI 感兴趣的人。
第二轮:他还有什么其他兴趣?

第二轮中的“他”依赖上一轮对话。ChatEngine 会结合聊天历史理解或改写问题,再调用 Retriever。

三者可以简单记成:

Retriever   = 找资料
QueryEngine = 根据资料回答一次问题
ChatEngine  = 根据资料进行连续对话

Response Processing:检索相同,答案合成方式不同

response_mode 通常不改变 Retriever 找到哪些 Node,而是决定如何把这些 Node 交给 LLM。

refine

refine 按顺序读取 Node,并不断修改已有答案:

问题 + Node A → 初始答案
初始答案 + Node B → 修订答案
修订答案 + Node C → 最终答案

优点是每个 Node 都有机会补充细节,缺点是 LLM 调用次数多、速度慢,并且结果可能受 Node 顺序影响。

compact

compact 会尽量把多个 Node 合并进一个上下文窗口,再生成答案。如果内容放不下,才把它们打包成较大的块并执行类似 refine 的处理。

这是普通 RAG 问答中常用的默认选择:调用次数少,延迟和成本较低。

tree_summarize

tree_summarize 会先分组生成中间摘要,再递归合并:

它适合大量资料的整体总结,但中间摘要也可能损失细节。

三者的关系是:

模式主要方式LLM 调用适合场景
refine逐段修订较多多段细节逐步补充
compact尽量合并通常最少普通 RAG 问答
tree_summarize树状归纳中等或较多大量文档整体总结

Evaluation:不是一个“总分”,而是多个评价维度

课程介绍的 Evaluator 本质上属于 LLM-as-a-judge:再调用一个 LLM 作为裁判,但每个 Evaluator 看到的输入不同。

FaithfulnessEvaluator

比较:

最终回答 vs 检索到的 Context

它判断回答是否被 source_nodes 支持,主要用于发现幻觉。不需要标准答案。

需要注意:Faithfulness 只表示“忠于 Context”,不表示 Context 本身符合现实。如果资料本身错误,而回答忠实复述错误资料,Faithfulness 仍可能通过。

AnswerRelevancyEvaluator

比较:

用户问题 vs 最终回答

它判断回答是否切题,不直接使用检索 Context。一个答案可以非常切题,却没有任何检索依据。

CorrectnessEvaluator

比较:

最终回答 vs Reference Answer

它通常需要人工准备的标准答案,用于判断端到端结果是否正确。

三个指标可能产生不同结果:

情况FaithfulnessRelevancyCorrectness
忠实复述错误资料
答非所问但内容有依据
使用外部常识答对但 Context 无依据

它们主要评价最终 Response 的不同侧面。若要直接评价 Retriever,还需要 Hit Rate、MRR、Precision@K、Recall@K 或 Context Relevancy 等指标。

QueryEngine 为什么还要包装成 Tool

课程将 QueryEngine 包装为:

from llama_index.core.tools import QueryEngineTool

query_engine_tool = QueryEngineTool.from_defaults(
    query_engine=query_engine,
    name="name",
    description="a specific description",
    return_direct=False,
)

QueryEngineTool 没有增加新的检索能力。它只是给 QueryEngine 增加 Agent 可识别的统一接口:

  • 名称
  • 描述
  • 输入 Schema
  • call() / acall()
  • ToolOutput

可以类比为把已有 Service 包装成统一 Tool Adapter。

进一步创建 Agent:

query_engine_agent = AgentWorkflow.from_tools_or_functions(
    [query_engine_tool],
    llm=llm,
    system_prompt=(
        "You are a helpful assistant that has access "
        "to a database containing persona descriptions."
    ),
)

此时调用链变成:

Agent
  ↓ 决定是否调用
QueryEngineTool
  ↓ 适配调用
QueryEngine
Retriever
ChromaDB

它有点像 Spring Boot 中的 Controller → Service → Mapper → Database。一次请求最终可能只查询一次数据库,各层主要用于分离职责。

但 Agent 与普通 Controller 存在一个重要差别:Controller 的调用路径由代码写死,Agent 的调用路径由 LLM 在运行时决定。Agent 可以直接回答、调用一个 Tool、连续调用多个 Tool,或者根据观察结果改变计划。

只有一个 Tool 时,Agent 是否多余?

如果所有问题都必须查询同一个知识库,那么直接调用 QueryEngine 更简单:

response = await query_engine.aquery(question)

只有一个 Tool 且必定调用时,Agent 层通常会增加额外 LLM 请求、延迟和成本。课程这样写主要是为了展示“上一节的 Tool 如何接入 Agent”。

Agentic RAG 真正有价值的情况通常是:

Agent
├── Persona Query Tool
├── SQL Tool
├── Web Search Tool
├── Calculator Tool
└── Weather Tool

Agent 需要根据任务动态选择工具,甚至多次检索、改写问题并综合多个结果。

ToolSpec:相关工具的适配器集合

ToolSpec 可以理解为某个服务或业务域的一组 Tool 定义。例如:

from llama_index.tools.google import GmailToolSpec

tool_spec = GmailToolSpec()
tool_spec_list = tool_spec.to_tool_list()

GmailToolSpec 本身不是 Agent 最终看到的一个“大 Tool”。to_tool_list() 会把相关方法分别转换成多个独立 Tool,例如搜索邮件、读取邮件、创建草稿和发送邮件。

更准确的关系是:

ToolSpec      = 一组相关能力的定义和适配器
to_tool_list  = 展开为多个标准 Tool
Agent         = 根据名称、描述和参数选择具体 Tool

Workflow:通过 Event 类型连接 Step

LlamaIndex Workflow 使用事件驱动模型:

from llama_index.core.workflow import (
    Event,
    StartEvent,
    StopEvent,
    Workflow,
    step,
)

class ProcessingEvent(Event):
    intermediate_result: str

class MultiStepWorkflow(Workflow):
    @step
    async def step_one(
        self,
        ev: StartEvent,
    ) -> ProcessingEvent:
        return ProcessingEvent(
            intermediate_result="Step 1 complete"
        )

    @step
    async def step_two(
        self,
        ev: ProcessingEvent,
    ) -> StopEvent:
        final_result = (
            f"Finished processing: "
            f"{ev.intermediate_result}"
        )
        return StopEvent(result=final_result)

w = MultiStepWorkflow(timeout=10, verbose=False)
result = await w.run()

执行顺序不是由函数名或代码书写位置决定的,而是由类型注解决定:

step_one 接收 StartEvent,返回 ProcessingEvent
step_two 接收 ProcessingEvent,返回 StopEvent

因此 Workflow 自动形成:

await w.run() 会创建 StartEvent、启动事件调度并等待 StopEvent。返回 StopEvent 后,Workflow 结束并将其中的 result 交给调用者。

它类似 Spring 的事件发布与 @EventListener,但 Workflow 会围绕 Event 和 Step 构建一个可执行流程,而不只是广播普通应用事件。

LlamaIndex、LangGraph 与 smolagents 的边界

三个框架都能构建 Agent,但核心关注点不同:

框架核心关键词主要解决的问题
LlamaIndexIndex / Data / RAG如何让 LLM 使用外部与私有数据
LangGraphGraph / State / Workflow如何编排复杂、有状态、可恢复的流程
smolagentsSmall / Agent Loop / CodeAgent如何用较少抽象运行轻量 Agent

LlamaIndex

优势在复杂数据和 RAG:数据连接、Document/Node、Ingestion、Index、Retriever、Reranker、Response Synthesis 与 Evaluation。Workflow 是有用扩展,但不是最独特的贡献。

LangGraph

重点是状态化流程运行:State、Node、Edge、分支、循环、持久化、暂停恢复和 Human-in-the-loop。它更像 Agent 系统的 Control Plane。

smolagents

强调更薄、更容易理解的 Agent Loop,并提供 CodeAgentToolCallingAgent。它适合快速实验和代码型 Agent,但不会把复杂 RAG 数据层作为中心。

一个职责清晰的组合是:

LangGraph:决定流程怎样走
LlamaIndex:决定从数据中检索什么

即 LangGraph 作为控制层,LlamaIndex 作为数据与检索层。简单项目则没有必要同时引入多个框架。

本地 Notebook 运行的几个经验

Kernel 重启不会删除安装包

Kernel 重启只会清空内存中的变量、对象和正在执行的任务。通过 pip 安装到 Conda 环境的包、下载到本地缓存的模型、.env 文件以及 Chroma 持久化数据都不会消失。

Notebook 导出的 .py 不一定能直接执行

Colab 导出的 Python 文件可能包含:

!pip install ...
nodes = await pipeline.arun(...)

其中 !pip 和顶层 await 都依赖 IPython/Jupyter 运行环境。学习课程时保留 .ipynb 更合适;需要进入正式项目时,再将验证后的单元格整理成普通模块和入口脚本。

本地有 GPU,不代表 PyTorch 使用 GPU

即使机器安装了 NVIDIA GPU,如果环境中的 PyTorch 是 CPU Build:

torch.version.cuda = None
torch.cuda.is_available() = False

Embedding 仍会全部在 CPU 上执行。性能排查应同时检查硬件、驱动和 PyTorch Build,而不是只看任务管理器中是否出现显卡。

小规模课程没有必要处理全部数据

如果后续只使用:

documents[:10]

就没有必要先创建并读取 5000 个小文件。学习阶段可以在数据集加载后立即限制数量,以减少下载、磁盘 I/O 和 Embedding 时间。

总结

理解 LlamaIndex 的关键不是记住多少类名,而是分清各层职责:

Reader              把外部数据变成 Document
SentenceSplitter    把 Document 变成 Node
Embedding           把 Node 文本变成向量
IngestionPipeline   串联转换并写入 Vector Store
VectorStoreIndex    提供面向查询的索引抽象
Retriever           找到相关 Node
ResponseSynthesizer 决定如何让 LLM 使用这些 Node
QueryEngine         完成一次 RAG 问答
QueryEngineTool     把 QueryEngine 适配给 Agent
Agent               动态选择和编排 Tool
Workflow            使用 Event 控制确定性的流程

如果应用只是单知识库问答,直接使用 QueryEngine 往往已经足够。只有在需要多种工具、动态路由、多次检索或复杂状态管理时,Agent 与 Workflow 才真正体现价值。

LlamaIndex 最重要的贡献可以归结为一句话:

它为“LLM 如何访问、检索和回答私有数据”建立了一套完整且可组合的领域模型。

参考资料

ZB

Zhanbo Chen

Java Backend & AI Agent Developer

Back to home
Comments
读懂 LlamaIndex:从 RAG 数据流到 Tool、Agent 与 Workflow | Zhanbo