Zhanbo Chen
Back to home

Understanding LlamaIndex: From RAG Data Flow to Tool, Agent, and Workflow

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

In brief

LlamaIndex often appears alongside LangChain, LangGraph, and smolagents in Agent tutorials, but its truly distinctive part is not Agent or Multi-Agent, but rather a complete set of RAG abstractions built around private data: Document, Node, IngestionPipeline, VectorStoreIndex, Retriever, QueryEngine, and ResponseSynthesizer. Starting from a persona dataset, this article fully deconstructs the pipeline of data loading, chunking, embedding, vector storage, retrieval, response synthesis, and evaluation. It further explains why QueryEngine needs to be wrapped as a Tool, when Agent layer constitutes over-engineering, and how Workflow connects multiple Steps through Event types. Finally, it provides positioning comparisons between LlamaIndex, LangGraph, and smolagents, as well as typical issues encountered when running Notebooks locally.

Start with the Name: LlamaIndex's Focus is on Index

Looking only at the examples in the Agent Course, it's easy to misunderstand LlamaIndex as another Agent framework: it has Tool, Agent, Multi-Agent, and also Workflow. However, these capabilities are not unique—LangChain, LangGraph, and smolagents all provide similar mechanisms.

LlamaIndex's more fundamental question is actually:

How is external data loaded, split, indexed, and retrieved to eventually become context that an LLM can use?

Therefore, LlamaIndex is closer to the "data access and knowledge retrieval layer" in LLM applications. Its core path can be summarized as:

Agent and Workflow are application and orchestration capabilities built on top of this data pipeline. To understand LlamaIndex, you first need to clarify the RAG main flow.

RAG Phase One: Loading is Not the Same as Embedding

The course uses the persona dataset from Hugging Face:

from datasets import load_dataset

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

Here, load_dataset() is only responsible for loading the remote dataset into a Hugging Face Dataset. It does not yet create a LlamaIndex Document, nor does it invoke LlamaIndex's Embedding model.

The dataset itself actually already contains an embedding field and records the model used to generate the vector:

model_name_embeddings = Alibaba-NLP/gte-large-en-v1.5
embedding              = 1024-dimensional vector

However, the course then only extracts the persona text and writes it to a file:

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"])

This operation discards the dataset's existing embedding, nearest neighbors, and clustering fields. In other words, the subsequent LlamaIndex Pipeline does not reuse the original vectors but starts fresh from plain text.

Next:

from llama_index.core import SimpleDirectoryReader

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

SimpleDirectoryReader reads files in the directory and wraps the content and file metadata as:

list[Document]

Conceptually equivalent to:

documents = []

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

Therefore, the result of the Loading phase is not vectors but a list of Document objects suitable for LlamaIndex's subsequent processing.

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

The course also has:

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

Document.example() creates a sample Document, and the outer [] wraps the single object into a list, since the documents parameter accepts multiple documents.

The approach is similar to Java:

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

This is not special syntax, just Python's way of "creating an object and immediately putting it into a List".

RAG Phase Two: IngestionPipeline Completes Chunking and Embedding

After loading, the course uses:

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 execute in the order they are declared:

Document
  ↓ SentenceSplitter
Node
  ↓ HuggingFaceEmbedding
Node with embedding

Node is Not the Same as Embedding

Node is a data structure, not synonymous with "vectorized result". A Node that just passed through SentenceSplitter can have only text:

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

After HuggingFaceEmbedding, the embedding attribute gets populated. For BAAI/bge-small-en-v1.5, vectors are typically 384 dimensions:

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

Note that TextNode retrieved from Chroma later may again show embedding=None. This doesn't mean the database has no vectors; it's that retrieval results typically don't pass the entire vector segment back to the application layer; similarity has already been calculated by Chroma using stored vectors.

Why Not Directly Use the Dataset's Existing Embeddings?

The course re-embeds for two important reasons.

First, the vectors in the original dataset correspond to entire personas, while SentenceSplitter may split one piece of text into multiple Nodes. Each new Node needs its own vector.

Second, document vectors and query vectors must come from the same Embedding model. The original dataset uses Alibaba-NLP/gte-large-en-v1.5, while the course's query phase uses BAAI/bge-small-en-v1.5. Vectors from different models are not in the same semantic space and cannot directly perform meaningful similarity comparisons.

RAG Phase Three: Automatically Writing Nodes to Chroma

Add vector_store to IngestionPipeline:

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,
)

What truly triggers processing and writing is still:

nodes = await pipeline.arun(documents=documents)

The complete data flow becomes:

IngestionPipeline(...) is just a configuration object; only executing run() or arun() actually performs chunking, embedding, and saving.

You can confirm the number of writes with:

print(chroma_collection.count())

The number of Documents is not necessarily equal to the number of Chroma records, since one Document can be split into multiple Nodes.

What is Index Really

After data is written to Chroma, the course creates:

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,
)

This step does not rewrite or re-embed data. It establishes a retrieval abstraction of LlamaIndex on top of an existing Vector Store, telling the system:

Where is data stored
What model is used to convert query text to vectors
How to restore retrieval results back to Node
How to integrate subsequent Retriever and QueryEngine

Therefore, Index is not a simple label, nor is it equivalent to a database. A more appropriate understanding is:

Index is a query-oriented data access and retrieval coordination layer.

It is somewhat similar to Spring Boot's Mapper/Repository, but also includes capabilities such as query vector generation, Node recovery, and LlamaIndex StorageContext.

Deriving Three Query Interfaces from Index

The same Index can provide three levels of interfaces.

as_retriever(): Find resources only

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

The return value is typically:

list[NodeWithScore]

Retriever is only responsible for:

Question text → Query vector → Similarity search → NodeWithScore

It typically does not invoke a generative LLM, and is suitable for checking what RAG actually found.

as_query_engine(): Generate an answer once after retrieval

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

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

QueryEngine can be understood as:

Retriever
+ Prompt
+ Response Synthesizer
+ LLM

It returns not just a string, but also the final answer, source Nodes, and metadata.

as_chat_engine(): Add conversation history on top of RAG

ChatEngine is suitable for handling:

Round 1: Find someone interested in AI.
Round 2: What other interests does he have?

The "they" in round 2 depends on the previous conversation. ChatEngine combines chat history to understand or rewrite the question, then calls the Retriever.

The three can be simply remembered as:

Retriever   = Find resources
QueryEngine = Answer a question based on resources
ChatEngine  = Have continuous dialogue based on resources

Response Processing: Retrieval remains the same, answer synthesis methods differ

response_mode typically does not change which Nodes the Retriever finds, but rather determines how to pass these Nodes to the LLM.

refine

refine reads Nodes sequentially and continuously revises the existing answer:

Question + Node A → Initial answer
Initial answer + Node B → Revised answer
Revised answer + Node C → Final answer

The advantage is that each Node has a chance to add details, but the disadvantage is more LLM calls, slower speed, and results may be affected by Node order.

compact

compact tries to merge multiple Nodes into a single context window to generate an answer. If content doesn't fit, it packages them into larger chunks and performs processing similar to refine.

This is the common default choice in ordinary RAG question-answering: fewer LLM calls, lower latency and cost.

tree_summarize

tree_summarize first groups and generates intermediate summaries, then recursively merges:

It is suitable for overall summarization of large amounts of resources, but intermediate summaries may also lose details.

The relationship between the three is:

ModeMain ApproachLLM CallsSuitable Scenario
refineGradual revisionMoreMulti-segment details progressively added
compactMerge as much as possibleUsually leastOrdinary RAG question-answering
tree_summarizeTree-based inductionMedium or moreOverall summarization of large documents

Evaluation: Not a "total score", but multiple evaluation dimensions

The Evaluators introduced in the course are essentially LLM-as-a-judge: calling another LLM as a judge, but each Evaluator sees different inputs.

FaithfulnessEvaluator

Compares:

Final Answer vs Retrieved Context

It judges whether the answer is supported by source_nodes, mainly used to discover hallucinations. No standard answer is needed.

Note: Faithfulness only means "faithful to Context", not that the Context itself conforms to reality. If the source material is wrong and the answer faithfully reproduces the wrong information, Faithfulness may still pass.

AnswerRelevancyEvaluator

Compares:

User Question vs Final Answer

It judges whether the answer is on-topic, without directly using retrieved Context. An answer can be highly on-topic yet have no retrieval basis.

CorrectnessEvaluator

Compares:

Final Answer vs Reference Answer

It typically requires manually prepared reference answers to judge whether end-to-end results are correct.

The three metrics may produce different results:

ScenarioFaithfulnessRelevancyCorrectness
Faithfully reproduce wrong informationHighHighLow
Answer irrelevant but with retrieval basisHighLowLow
Answer correctly using external knowledge but no Context basisLowHighHigh

They mainly evaluate different aspects of the final Response. To directly evaluate Retriever, additional metrics such as Hit Rate, MRR, Precision@K, Recall@K, or Context Relevancy are needed.

Why QueryEngine Needs to be Wrapped as a Tool

The course wraps QueryEngine as:

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 does not add new retrieval capabilities. It only provides QueryEngine with a uniform interface that Agent can recognize:

  • Name
  • Description
  • Input Schema
  • call() / acall()
  • ToolOutput

It can be compared to wrapping an existing Service into a uniform Tool Adapter.

Further create an 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."
    ),
)

At this point the call chain becomes:

Agent
  ↓ Decide whether to call
QueryEngineTool
  ↓ Adapt the call
QueryEngine
  ↓
Retriever
  ↓
ChromaDB

It is somewhat like Spring Boot's Controller → Service → Mapper → Database. A single request may ultimately query the database only once, with each layer mainly used to separate concerns.

However, there is an important difference between Agent and ordinary Controller: the call path of Controller is fixed by code, while the call path of Agent is determined by LLM at runtime. Agent can directly answer, call one Tool, call multiple Tools in sequence, or change plans based on observations.

Is Agent redundant when there is only one Tool?

If all questions must query the same knowledge base, it is simpler to directly call QueryEngine:

response = await query_engine.aquery(question)

When there is only one Tool and it must be called, the Agent layer typically adds extra LLM requests, latency, and cost. The course writes it this way mainly to demonstrate "how the Tool from the previous section connects to Agent".

Agentic RAG is truly valuable in cases like:

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

Agent needs to dynamically select tools based on tasks, and may even perform multiple retrievals, rewrite questions, and synthesize results from multiple sources.

ToolSpec can be understood as a set of Tool definitions for a certain service or business domain. For example:

from llama_index.tools.google import GmailToolSpec

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

GmailToolSpec itself is not a single "large Tool" that the Agent ultimately sees. to_tool_list() converts the related methods into multiple independent Tools respectively, such as searching emails, reading emails, creating drafts, and sending emails.

More precisely, the relationship is:

ToolSpec      = Definition and adapter for a group of related capabilities
to_tool_list  = Expand into multiple standard Tools
Agent         = Select specific Tool based on name, description, and parameters

Workflow: Connecting Steps via Event Types

LlamaIndex Workflow uses an event-driven model:

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()

The execution order is not determined by function names or code writing position, but by type annotations:

step_one receives StartEvent, returns ProcessingEvent
step_two receives ProcessingEvent, returns StopEvent

Therefore, the Workflow automatically forms:

await w.run() creates a StartEvent, starts event dispatching, and waits for StopEvent. After StopEvent returns, the Workflow ends and passes its result to the caller.

It resembles Spring's event publishing and @EventListener, but Workflow builds an executable flow around Events and Steps rather than just broadcasting ordinary application events.

Boundaries between LlamaIndex, LangGraph, and smolagents

All three frameworks can build Agents, but their core focus differs:

FrameworkCore KeywordsMain Problem Solved
LlamaIndexIndex / Data / RAGHow to let LLM use external and private data
LangGraphGraph / State / WorkflowHow to orchestrate complex, stateful, recoverable flows
smolagentsSmall / Agent Loop / CodeAgentHow to run lightweight Agents with minimal abstraction

LlamaIndex

Its strength lies in complex data and RAG: data connection, Document/Node, Ingestion, Index, Retriever, Reranker, Response Synthesis, and Evaluation. Workflow is a useful extension, but not its most unique contribution.

LangGraph

The focus is on stateful flow execution: State, Node, Edge, branching, loops, persistence, pause/resume, and Human-in-the-loop. It is more like the Control Plane for Agent systems.

smolagents

Emphasizes a thinner, more understandable Agent Loop, and provides CodeAgent and ToolCallingAgent. It suits rapid experimentation and code-based Agents, but does not place complex RAG data layers as the center.

A combination with clear responsibilities is:

LangGraph: Decide how the workflow proceeds
LlamaIndex: Decide what to retrieve from the data

That is, LangGraph as the control layer, LlamaIndex as the data and retrieval layer. Simple projects don't need to introduce multiple frameworks at once.

Some Experiences Running Local Notebooks

Kernel Restart Does Not Delete Installed Packages

Kernel restart only clears variables, objects, and running tasks in memory. Packages installed via pip into the Conda environment, models downloaded to local cache, .env files, and Chroma persisted data all remain.

Exported Notebook .py Files May Not Execute Directly

Python files exported from Colab may contain:

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

where both !pip and top-level await depend on IPython/Jupyter runtime environments. When learning courses, keeping .ipynb is more appropriate; when entering formal projects, organize verified cells into normal modules and entry scripts.

Having a Local GPU Does Not Mean PyTorch Uses GPU

Even if the machine has an NVIDIA GPU installed, if PyTorch in the environment is a CPU Build:

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

Embeddings will still execute entirely on CPU. Performance investigation should check hardware, drivers, and PyTorch Build simultaneously, rather than just looking for GPU in Task Manager.

Small-Scale Courses Don't Need to Handle All Data

If later only using:

documents[:10]

there is no need to create and read 5000 small files first. During the learning phase, you can limit the quantity immediately after loading the dataset to reduce download, disk I/O, and Embedding time.

Summary

The key to understanding LlamaIndex is not memorizing class names, but clarifying responsibilities at each layer:

Reader              transforms external data into Document
SentenceSplitter    transforms Document into Node
Embedding           transforms Node text into vectors
IngestionPipeline   chains transformations and writes to Vector Store
VectorStoreIndex    provides query-oriented index abstraction
Retriever           finds relevant Node
ResponseSynthesizer decides how to have LLM use these Node
QueryEngine         completes one RAG question-answering
QueryEngineTool     adapts QueryEngine for Agent
Agent               dynamically selects and orchestrates Tool
Workflow            uses Event control for deterministic workflow

If the application is just a single knowledge base Q&A, using QueryEngine directly is often sufficient. Only when needing multiple tools, dynamic routing, multiple retrievals, or complex state management do Agent and Workflow truly demonstrate their value.

LlamaIndex's most important contribution can be summarized in one sentence:

It establishes a complete and composable domain model for "how LLM accesses, retrieves, and answers private data."

References

ZB

Zhanbo Chen

Java Backend & AI Agent Developer

Back to home
Comments