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 vectorHowever, 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 embeddingNode 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 = NoneAfter 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 QueryEngineTherefore, 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 → NodeWithScoreIt 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
+ LLMIt 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 resourcesResponse 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 answerThe 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:
| Mode | Main Approach | LLM Calls | Suitable Scenario |
|---|---|---|---|
refine | Gradual revision | More | Multi-segment details progressively added |
compact | Merge as much as possible | Usually least | Ordinary RAG question-answering |
tree_summarize | Tree-based induction | Medium or more | Overall 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 ContextIt 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 AnswerIt 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 AnswerIt typically requires manually prepared reference answers to judge whether end-to-end results are correct.
The three metrics may produce different results:
| Scenario | Faithfulness | Relevancy | Correctness |
|---|---|---|---|
| Faithfully reproduce wrong information | High | High | Low |
| Answer irrelevant but with retrieval basis | High | Low | Low |
| Answer correctly using external knowledge but no Context basis | Low | High | High |
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
↓
ChromaDBIt 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 ToolAgent needs to dynamically select tools based on tasks, and may even perform multiple retrievals, rewrite questions, and synthesize results from multiple sources.
ToolSpec: Adapter collection for related tools
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 parametersWorkflow: 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 StopEventTherefore, 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:
| Framework | Core Keywords | Main Problem Solved |
|---|---|---|
| LlamaIndex | Index / Data / RAG | How to let LLM use external and private data |
| LangGraph | Graph / State / Workflow | How to orchestrate complex, stateful, recoverable flows |
| smolagents | Small / Agent Loop / CodeAgent | How 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 dataThat 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() = FalseEmbeddings 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 workflowIf 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."