INTRODUCTION: UNDERSTANDING THE LANDSCAPE OF LLM APPLICATIONS
Welcome to the world of building applications with Large Language Models. If you have worked with traditional software development, you know that applications need data to be useful. A customer management system needs customer data, a blog needs articles, and a recommendation engine needs user preferences. The same principle applies to LLM applications, but with a twist.
Large Language Models like GPT, Claude, or Llama are incredibly powerful. They can write, reason, and answer questions. However, they have a critical limitation: they only know what they were trained on. If you ask GPT about your company's internal documents, your personal research notes, or yesterday's meeting minutes, it cannot help you because it has never seen that data.
This is where LlamaIndex comes in. LlamaIndex is a framework that bridges the gap between your private data and the reasoning capabilities of Large Language Models. Think of it as a sophisticated librarian that knows exactly where to find information in your documents and can hand that information to an LLM so it can answer your questions accurately.
The core problem LlamaIndex solves is this: How do we give an LLM access to external data in a way that is efficient, accurate, and scalable? The answer involves several steps: loading your data, breaking it into manageable pieces, creating searchable representations of that data, retrieving the most relevant pieces when needed, and finally combining those pieces with an LLM to generate answers.
In this tutorial, we will build toward creating a multi-agent research system. Imagine you want to build an application that can research a topic by reading through hundreds of documents, extracting relevant information, synthesizing insights, and presenting a coherent summary. This is a realistic and practical goal, and by the end of this tutorial, you will understand how to build such a system using LlamaIndex.
FOUNDATIONAL CONCEPTS: THE BUILDING BLOCKS OF LLAMAINDEX
Before we write any code, we need to understand the fundamental concepts that make LlamaIndex work. These concepts form the mental model you will use throughout your journey with the framework.
Documents: The Starting Point
A Document in LlamaIndex represents a piece of data. This could be a text file, a PDF, a web page, a database entry, or any other source of information. The Document is the raw material we will work with. Think of it as a book in a library before it has been cataloged or organized.
When you create a Document object, you are essentially telling LlamaIndex: "Here is some information I want to work with." The Document contains the actual text content and can also contain metadata like the source filename, creation date, author, or any other information that might be useful later.
Nodes: Breaking Down Information
Imagine trying to find a specific fact in a thousand-page book. You would not read the entire book every time you needed one piece of information. Instead, you would use the table of contents, index, or chapter summaries to narrow down where to look. This is exactly what Nodes do in LlamaIndex.
A Node is a chunk of a Document. When LlamaIndex processes a Document, it breaks it down into smaller, manageable pieces called Nodes. Each Node represents a coherent piece of information, typically a paragraph or a few paragraphs. This chunking process is crucial because it allows the system to retrieve only the relevant parts of a document instead of the entire thing.
Nodes also maintain relationships with each other. A Node knows which Document it came from and can reference the Nodes that come before and after it. This relationship information helps maintain context when retrieving information.
Embeddings: The Magic of Semantic Search
Here is where things get interesting. Traditional search systems work by matching keywords. If you search for "dog," you get results that contain the word "dog." But what if you search for "canine companion"? A keyword-based system would miss documents about dogs because the exact word "dog" is not present.
Embeddings solve this problem through semantic search. An embedding is a mathematical representation of text that captures its meaning. It is a list of numbers, typically hundreds or thousands of them, that represent the semantic content of the text. The beautiful thing about embeddings is that texts with similar meanings have similar embeddings, even if they use completely different words.
When we create embeddings for our Nodes, we are translating human language into a mathematical space where similar concepts are close together. This allows us to search by meaning rather than by exact word matches. If you search for "canine companion," the system can find documents about dogs because the embeddings for these phrases are mathematically similar.
Vector Stores: Organizing the Mathematical Space
Once we have embeddings for all our Nodes, we need a way to store them and search through them efficiently. This is what a vector store does. A vector store is a specialized database designed to store embeddings and perform similarity searches quickly.
When you query a vector store, you provide an embedding of your question, and the vector store returns the embeddings (and their associated Nodes) that are most similar to your question. This similarity search is the core of how LlamaIndex retrieves relevant information.
Indexes: Bringing It All Together
An Index in LlamaIndex is the complete system that manages your Documents, Nodes, embeddings, and vector store. When you create an Index, you are building a searchable knowledge base. The Index handles the entire pipeline: taking your Documents, breaking them into Nodes, creating embeddings, storing them in a vector store, and providing methods to query the stored information.
There are different types of Indexes for different use cases, but they all serve the same fundamental purpose: making your data searchable and accessible to an LLM.
Now that we understand these concepts, let us start building.
GETTING STARTED: YOUR FIRST LLAMAINDEX APPLICATION
Let us begin with the simplest possible example: loading a document and asking questions about it. This example will introduce you to the basic workflow of LlamaIndex.
First, we need to install LlamaIndex. You would do this using pip:
pip install llama-index
Now, let us create our first application. We will start by importing the necessary components:
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
Let me explain what each of these imports does. The VectorStoreIndex is the type of Index we will create. It uses vector embeddings to store and retrieve information. The SimpleDirectoryReader is a utility that makes it easy to load documents from a folder on your computer. The Settings object allows us to configure global settings for LlamaIndex. Finally, we import the OpenAI classes for the LLM and embedding model we will use.
Before we can use OpenAI's models, we need to configure our API key. In a real application, you would store this in an environment variable for security:
import os
os.environ["OPENAI_API_KEY"] = "your-api-key-here"
Now we configure the global settings for LlamaIndex. This tells LlamaIndex which LLM and embedding model to use throughout our application:
Settings.llm = OpenAI(model="gpt-3.5-turbo", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-ada-002")
The temperature parameter controls how creative or deterministic the LLM's responses are. A lower temperature like 0.1 makes responses more focused and consistent, which is usually what we want for factual question answering.
Now let us load some documents. Create a folder called "data" and place some text files in it. These could be articles, notes, or any text content you want to query:
documents = SimpleDirectoryReader("data").load_data()
This single line does a lot of work. The SimpleDirectoryReader scans the "data" folder, finds all readable files, and converts them into Document objects. Each file becomes one Document.
Next, we create an Index from these Documents:
index = VectorStoreIndex.from_documents(documents)
This is where the magic happens. When you call from_documents, LlamaIndex performs several steps automatically. First, it breaks each Document into Nodes using a default chunking strategy. Then, it creates embeddings for each Node using the embedding model we configured. Finally, it stores these embeddings in a vector store (by default, an in-memory vector store).
Now we can query our Index. We create a query engine from the Index:
query_engine = index.as_query_engine()
A query engine is an object that knows how to take a question, retrieve relevant information from the Index, and use an LLM to generate an answer. Let us ask it a question:
response = query_engine.query("What are the main topics discussed in these documents?")
print(response)
When you call query, several things happen behind the scenes. First, LlamaIndex creates an embedding of your question. Then, it searches the vector store for the Nodes whose embeddings are most similar to your question's embedding. These are the most relevant pieces of information. Next, LlamaIndex takes these relevant Nodes and combines them with your question into a prompt for the LLM. Finally, the LLM reads the relevant information and generates an answer.
The response object contains the answer as well as metadata about which Nodes were used to generate it. You can access the actual answer text using str(response) or response.response.
This simple example demonstrates the complete workflow of LlamaIndex: load documents, create an Index, and query it. However, there is much more depth to explore in each of these steps.
UNDERSTANDING DOCUMENT LOADING: BRINGING YOUR DATA INTO LLAMAINDEX
The first step in any LlamaIndex application is getting your data into a format the framework can work with. LlamaIndex provides many tools for loading different types of data.
Loading from Files
We have already seen the SimpleDirectoryReader, which is the easiest way to load documents from files. However, you can also load individual files directly:
from llama_index.core import Document
# Load a single text file
with open("my_document.txt", "r") as f:
text = f.read()
document = Document(text=text)
When you create a Document manually like this, you have full control over its content and metadata. The metadata parameter accepts a dictionary where you can store any information about the document:
document = Document(
text=text,
metadata={
"filename": "my_document.txt",
"author": "John Doe",
"date_created": "2024-01-15",
"category": "research"
}
)
This metadata is preserved throughout the indexing process and can be used later for filtering and organization.
Loading from Different File Types
LlamaIndex supports many file types beyond plain text. For PDFs, you can use specialized readers:
from llama_index.readers.file import PDFReader
loader = PDFReader()
documents = loader.load_data(file="research_paper.pdf")
The PDFReader extracts text from PDF files and creates Document objects. Similarly, there are readers for Word documents, HTML files, Markdown files, and many other formats.
Loading from Web Pages
You can also load content directly from the web:
from llama_index.readers.web import SimpleWebPageReader
loader = SimpleWebPageReader()
documents = loader.load_data(urls=["https://example.com/article"])
This is particularly useful for building applications that need to stay updated with online content.
Loading from Databases
For applications that need to work with structured data, LlamaIndex can load from databases:
from llama_index.readers.database import DatabaseReader
reader = DatabaseReader(
sql_database=your_database_connection
)
query = "SELECT title, content FROM articles WHERE category = 'AI'"
documents = reader.load_data(query=query)
Each row returned by the query becomes a Document, with the columns converted to text.
Understanding Document Structure
Every Document object has two main components: the text content and the metadata. The text is what will be indexed and searched. The metadata provides context and can be used for filtering.
When you load documents, think about what metadata would be useful. If you are building a research assistant, you might want to track the publication date, author, and source of each document. If you are building a customer support system, you might want to track the product category and version number.
Here is an example of creating a Document with rich metadata:
document = Document(
text="LlamaIndex is a framework for building LLM applications...",
metadata={
"source": "documentation",
"section": "introduction",
"version": "0.9.0",
"last_updated": "2024-01-15"
}
)
This metadata travels with the Document through the entire indexing and retrieval process, allowing you to build sophisticated filtering and organization schemes.
WORKING WITH NODES: THE CHUNKING PROCESS
Once you have Documents, the next step is breaking them into Nodes. This chunking process is critical for effective retrieval. If chunks are too large, you will retrieve too much irrelevant information. If chunks are too small, you will lose important context.
Understanding Node Parsers
LlamaIndex uses Node Parsers to convert Documents into Nodes. The default parser is the SentenceSplitter, which tries to create chunks that respect sentence boundaries:
from llama_index.core.node_parser import SentenceSplitter
parser = SentenceSplitter(
chunk_size=1024,
chunk_overlap=20
)
nodes = parser.get_nodes_from_documents(documents)
The chunk_size parameter specifies the maximum size of each chunk in characters. The chunk_overlap parameter specifies how much overlap there should be between consecutive chunks. Overlap is important because it ensures that information that spans chunk boundaries is not lost.
Let me explain why overlap matters. Imagine a document with this text: "The capital of France is Paris. Paris is known for the Eiffel Tower." If we chunk this without overlap and the split happens between the two sentences, one chunk might say "The capital of France is Paris" and the next might say "Paris is known for the Eiffel Tower." If someone asks "What is Paris known for?" the system might only retrieve the second chunk, which does not mention that Paris is the capital of France. With overlap, both sentences would appear in both chunks, preserving the full context.
Customizing Chunk Size
The optimal chunk size depends on your use case. For technical documentation where each paragraph is self-contained, smaller chunks work well. For narrative text where context builds over multiple paragraphs, larger chunks are better.
Here is how you might experiment with different chunk sizes:
# Small chunks for precise retrieval
small_parser = SentenceSplitter(chunk_size=512, chunk_overlap=50)
small_nodes = small_parser.get_nodes_from_documents(documents)
# Large chunks for more context
large_parser = SentenceSplitter(chunk_size=2048, chunk_overlap=100)
large_nodes = large_parser.get_nodes_from_documents(documents)
Semantic Chunking
Sometimes you want chunks that respect the semantic structure of your documents. The SemanticSplitterNodeParser creates chunks based on semantic similarity:
from llama_index.core.node_parser import SemanticSplitterNodeParser
semantic_parser = SemanticSplitterNodeParser(
buffer_size=1,
breakpoint_percentile_threshold=95,
embed_model=Settings.embed_model
)
nodes = semantic_parser.get_nodes_from_documents(documents)
This parser creates embeddings for sentences and groups together sentences that are semantically similar. When it detects a significant shift in topic (based on the breakpoint_percentile_threshold), it starts a new chunk. This approach creates chunks that are more coherent and topically focused.
Accessing Node Information
Once you have Nodes, you can inspect their content and metadata:
for node in nodes[:3]: # Look at first 3 nodes
print(f"Node ID: {node.node_id}")
print(f"Text: {node.text[:100]}...") # First 100 characters
print(f"Metadata: {node.metadata}")
print("---")
Each Node has a unique identifier (node_id), the text content, and metadata inherited from its parent Document. Nodes also maintain relationships with their parent Document and sibling Nodes, which helps maintain context during retrieval.
CREATING AND UNDERSTANDING INDEXES
Now that we have Nodes, we need to organize them into an Index. The Index is the searchable structure that makes retrieval possible.
The Vector Store Index
The most commonly used Index type is the VectorStoreIndex. We have already seen it in action, but let us understand it more deeply:
from llama_index.core import VectorStoreIndex
index = VectorStoreIndex(nodes)
When you create a VectorStoreIndex, it performs these steps for each Node. First, it creates an embedding of the Node's text using the configured embedding model. Then, it stores this embedding in a vector store along with a reference to the original Node. The vector store organizes these embeddings in a way that makes similarity search efficient.
Persisting and Loading Indexes
Creating an Index can be time-consuming, especially with large document collections. You do not want to rebuild the Index every time you run your application. LlamaIndex allows you to save and load Indexes:
# Save the index to disk
index.storage_context.persist(persist_dir="./storage")
# Later, load the index from disk
from llama_index.core import StorageContext, load_index_from_storage
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(storage_context)
The persist method saves all the components of the Index: the Nodes, their embeddings, and the vector store structure. When you load the Index later, it is immediately ready to use without needing to reprocess the documents.
Understanding Storage Context
The StorageContext is an object that manages all the storage components of an Index. It includes the document store (which stores the original Documents), the index store (which stores the Index structure), and the vector store (which stores the embeddings).
You can customize the storage by providing your own stores:
from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.core.storage.index_store import SimpleIndexStore
from llama_index.core.vector_stores import SimpleVectorStore
storage_context = StorageContext.from_defaults(
docstore=SimpleDocumentStore(),
index_store=SimpleIndexStore(),
vector_store=SimpleVectorStore()
)
index = VectorStoreIndex(
nodes,
storage_context=storage_context
)
This gives you fine-grained control over how and where your data is stored.
Using External Vector Stores
For production applications, you might want to use a specialized vector database instead of the simple in-memory store. LlamaIndex supports many vector databases like Pinecone, Weaviate, Chroma, and Qdrant:
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb
# Initialize Chroma client
chroma_client = chromadb.Client()
chroma_collection = chroma_client.create_collection("my_collection")
# Create vector store
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
# Create storage context with the vector store
storage_context = StorageContext.from_defaults(vector_store=vector_store)
# Create index
index = VectorStoreIndex(
nodes,
storage_context=storage_context
)
Using an external vector store provides benefits like persistence, scalability, and advanced features like filtering and hybrid search.
RETRIEVAL: FINDING THE RIGHT INFORMATION
Retrieval is the process of finding the most relevant Nodes for a given query. This is where the power of embeddings and vector search comes into play.
Basic Retrieval
The simplest way to retrieve information is to use the Index's retriever:
retriever = index.as_retriever(similarity_top_k=3)
nodes = retriever.retrieve("What is LlamaIndex used for?")
for node in nodes:
print(f"Score: {node.score}")
print(f"Text: {node.text}")
print("---")
The similarity_top_k parameter specifies how many Nodes to retrieve. The retriever returns the Nodes with the highest similarity scores to the query. The score indicates how similar the Node's embedding is to the query's embedding.
Understanding Retrieval Modes
LlamaIndex supports different retrieval modes. The default mode is vector search, which uses embedding similarity. However, you can also use keyword-based search or hybrid approaches:
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.vector_stores import VectorStoreQueryMode
# Vector search (default)
vector_retriever = VectorIndexRetriever(
index=index,
similarity_top_k=5,
vector_store_query_mode=VectorStoreQueryMode.DEFAULT
)
# Hybrid search (combines vector and keyword search)
hybrid_retriever = VectorIndexRetriever(
index=index,
similarity_top_k=5,
vector_store_query_mode=VectorStoreQueryMode.HYBRID
)
Hybrid search is useful when you want to combine the semantic understanding of vector search with the precision of keyword matching.
Metadata Filtering
One of the most powerful features of retrieval is the ability to filter by metadata. This allows you to narrow down the search space before performing similarity search:
from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter
filters = MetadataFilters(
filters=[
ExactMatchFilter(key="category", value="research"),
ExactMatchFilter(key="year", value="2024")
]
)
retriever = index.as_retriever(
similarity_top_k=5,
filters=filters
)
nodes = retriever.retrieve("What are the latest findings?")
This retriever will only search among Nodes whose metadata matches the specified filters. This is incredibly useful for building applications that need to search within specific subsets of data.
Advanced Retrieval: Reranking
Sometimes the initial retrieval results are not optimally ordered. Reranking is a technique where you take the initial retrieval results and reorder them using a more sophisticated model:
from llama_index.core.postprocessor import SentenceTransformerRerank
reranker = SentenceTransformerRerank(
model="cross-encoder/ms-marco-MiniLM-L-2-v2",
top_n=3
)
retriever = index.as_retriever(similarity_top_k=10)
query = "How does LlamaIndex handle document chunking?"
nodes = retriever.retrieve(query)
# Rerank the nodes
reranked_nodes = reranker.postprocess_nodes(nodes, query_str=query)
The reranker takes the top 10 results from the initial retrieval and reorders them, returning only the top 3 most relevant ones. Reranking models are specifically trained to judge relevance and often provide better results than pure embedding similarity.
QUERY ENGINES: GENERATING ANSWERS
Retrieving relevant Nodes is only half the battle. We need to combine those Nodes with an LLM to generate coherent answers. This is what query engines do.
The Basic Query Engine
We have already seen the basic query engine in action:
query_engine = index.as_query_engine()
response = query_engine.query("What is the main purpose of LlamaIndex?")
Behind the scenes, this query engine performs retrieval and then constructs a prompt for the LLM. The prompt includes the query and the retrieved Nodes, asking the LLM to answer the question based on the provided context.
Customizing the Query Engine
You can customize many aspects of the query engine:
query_engine = index.as_query_engine(
similarity_top_k=5,
response_mode="tree_summarize",
verbose=True
)
The similarity_top_k parameter controls how many Nodes to retrieve. The response_mode parameter controls how the LLM synthesizes the answer from multiple Nodes. The verbose parameter makes the query engine print detailed information about what it is doing.
Understanding Response Modes
Different response modes are appropriate for different situations. The compact mode (default) combines all retrieved Nodes into a single prompt. This is efficient but can hit token limits with many or large Nodes:
query_engine = index.as_query_engine(response_mode="compact")
The tree_summarize mode is useful when you have many Nodes. It builds a tree of summaries, summarizing groups of Nodes and then summarizing the summaries:
query_engine = index.as_query_engine(response_mode="tree_summarize")
This approach can handle more Nodes without hitting token limits, though it requires multiple LLM calls.
The refine mode generates an initial answer from the first Node, then iteratively refines the answer by considering each additional Node:
query_engine = index.as_query_engine(response_mode="refine")
This mode is good for complex questions where the answer needs to be built up incrementally.
Streaming Responses
For better user experience, you can stream responses as they are generated:
query_engine = index.as_query_engine(streaming=True)
response = query_engine.query("Explain how embeddings work in LlamaIndex")
for text in response.response_gen:
print(text, end="", flush=True)
This prints the response as it is generated, word by word, rather than waiting for the complete response.
Accessing Source Nodes
Every response includes information about which Nodes were used to generate it:
response = query_engine.query("What are the benefits of using LlamaIndex?")
print(f"Answer: {response.response}")
print("\nSources:")
for node in response.source_nodes:
print(f"- {node.node.metadata.get('filename', 'Unknown')}")
print(f" Score: {node.score}")
print(f" Text preview: {node.node.text[:100]}...")
This is crucial for building trustworthy applications. Users can see exactly where the information came from and verify the sources.
BUILDING YOUR FIRST AGENT: ADDING REASONING CAPABILITIES
So far, we have built systems that can answer questions based on documents. But what if we want our system to perform actions, use tools, or make decisions? This is where agents come in.
An agent is an LLM-powered system that can reason about what actions to take, use tools to gather information or perform tasks, and make decisions based on the results. Think of an agent as an autonomous assistant that can plan and execute multi-step tasks.
Understanding Tools
Before we build an agent, we need to understand tools. A tool is a function that an agent can call. It could be anything: a function to search the web, a function to perform calculations, a function to query a database, or a function to query our LlamaIndex.
Let us create a simple tool that performs calculations:
from llama_index.core.tools import FunctionTool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
multiply_tool = FunctionTool.from_defaults(fn=multiply)
The docstring is important because the agent uses it to understand what the tool does. The agent reads the docstring and decides when to use the tool.
Creating a Query Engine Tool
We can turn our query engine into a tool that an agent can use:
from llama_index.core.tools import QueryEngineTool
query_engine = index.as_query_engine()
query_tool = QueryEngineTool.from_defaults(
query_engine=query_engine,
name="document_search",
description="Useful for answering questions about the documents in the knowledge base. Use this when you need to find information from the indexed documents."
)
Now we have a tool that allows an agent to search our document index.
Building a Simple Agent
Let us create an agent that can use both tools:
from llama_index.core.agent import ReActAgent
agent = ReActAgent.from_tools(
tools=[multiply_tool, query_tool],
llm=Settings.llm,
verbose=True
)
The ReActAgent uses a reasoning pattern called ReAct (Reasoning and Acting). It alternates between reasoning about what to do next and taking actions using tools.
Now we can interact with the agent:
response = agent.chat("What is 25 times 17?")
print(response)
The agent will recognize that this is a multiplication problem and use the multiply_tool. If you ask a question about the documents, it will use the query_tool instead:
response = agent.chat("What are the main features of LlamaIndex?")
print(response)
Understanding Agent Reasoning
When you set verbose=True, you can see the agent's reasoning process:
response = agent.chat("Find information about embeddings and multiply the number of dimensions by 2")
The agent will:
- Reason that it needs to find information about embeddings
- Use the query_tool to search the documents
- Extract the number of dimensions from the response
- Use the multiply_tool to multiply by 2
- Formulate a final answer
This multi-step reasoning is what makes agents powerful.
Creating Custom Tools
You can create tools for any functionality your application needs:
def get_current_date() -> str:
"""Get the current date in YYYY-MM-DD format."""
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
date_tool = FunctionTool.from_defaults(fn=get_current_date)
def search_web(query: str) -> str:
"""Search the web for current information."""
# In a real application, this would call a web search API
return f"Web search results for: {query}"
web_search_tool = FunctionTool.from_defaults(fn=search_web)
agent = ReActAgent.from_tools(
tools=[query_tool, date_tool, web_search_tool],
llm=Settings.llm,
verbose=True
)
Now the agent can search documents, get the current date, and search the web.
BUILDING A MULTI-AGENT RESEARCH SYSTEM
Now we arrive at our ultimate goal: building a multi-agent system that can conduct research. This system will have multiple specialized agents that work together to accomplish complex tasks.
The Architecture
Our research system will have three agents:
- A document researcher that searches through indexed documents
- A web researcher that searches the internet for current information
- A coordinator that manages the other agents and synthesizes their findings
Creating Specialized Query Engines
First, let us create multiple indexes for different types of documents:
# Index for technical documentation
tech_docs = SimpleDirectoryReader("data/technical").load_data()
tech_index = VectorStoreIndex.from_documents(tech_docs)
tech_query_engine = tech_index.as_query_engine()
# Index for research papers
research_docs = SimpleDirectoryReader("data/research").load_data()
research_index = VectorStoreIndex.from_documents(research_docs)
research_query_engine = research_index.as_query_engine()
Now we create tools from these query engines:
tech_tool = QueryEngineTool.from_defaults(
query_engine=tech_query_engine,
name="technical_documentation",
description="Contains technical documentation and implementation guides. Use this for questions about how to implement features or understand technical details."
)
research_tool = QueryEngineTool.from_defaults(
query_engine=research_query_engine,
name="research_papers",
description="Contains academic research papers and theoretical information. Use this for questions about research findings, theories, and academic concepts."
)
Creating the Document Researcher Agent
document_researcher = ReActAgent.from_tools(
tools=[tech_tool, research_tool],
llm=Settings.llm,
verbose=True
)
This agent specializes in searching through our document collections.
Creating the Web Researcher Agent
For the web researcher, we would create a tool that searches the internet:
def web_search(query: str) -> str:
"""Search the web for current information about a topic."""
# In a real application, this would use a web search API
# For demonstration, we return a placeholder
return f"Web search results for '{query}': [Current web information would appear here]"
web_tool = FunctionTool.from_defaults(fn=web_search)
web_researcher = ReActAgent.from_tools(
tools=[web_tool],
llm=Settings.llm,
verbose=True
)
Creating the Coordinator Agent
The coordinator agent has access to the other agents as tools:
from llama_index.core.tools import FunctionTool
def search_documents(query: str) -> str:
"""Search through indexed documents for information."""
response = document_researcher.chat(query)
return str(response)
def search_web(query: str) -> str:
"""Search the web for current information."""
response = web_researcher.chat(query)
return str(response)
doc_search_tool = FunctionTool.from_defaults(fn=search_documents)
web_search_tool = FunctionTool.from_defaults(fn=search_web)
coordinator = ReActAgent.from_tools(
tools=[doc_search_tool, web_search_tool],
llm=Settings.llm,
verbose=True
)
Using the Multi-Agent System
Now we can ask the coordinator complex research questions:
response = coordinator.chat(
"Research the current state of large language models. "
"First, check our research papers for theoretical background. "
"Then, search the web for the latest developments. "
"Finally, synthesize this information into a comprehensive summary."
)
print(response)
The coordinator will:
- Reason about how to approach the task
- Use the document search tool to query research papers
- Use the web search tool to find current information
- Synthesize the information from both sources
- Provide a comprehensive answer
This demonstrates the power of multi-agent systems. Each agent specializes in a specific task, and they work together to accomplish complex goals.
ADVANCED FEATURES: MEMORY AND CONTEXT
As you build more sophisticated applications, you will need agents that can remember previous interactions and maintain context across multiple queries.
Chat Memory
LlamaIndex agents have built-in memory that maintains conversation history:
agent = ReActAgent.from_tools(
tools=[query_tool],
llm=Settings.llm,
verbose=True
)
# First interaction
response1 = agent.chat("What is LlamaIndex?")
print(response1)
# Second interaction - the agent remembers the previous context
response2 = agent.chat("What are its main features?")
print(response2)
The agent remembers that "its" refers to LlamaIndex from the previous question.
Accessing Chat History
You can access and manage the chat history:
# Get the chat history
history = agent.chat_history
for message in history:
print(f"{message.role}: {message.content}")
Resetting Memory
Sometimes you want to start a fresh conversation:
agent.reset()
This clears the chat history, allowing you to start a new conversation without the context of previous interactions.
Custom Memory
For more control, you can implement custom memory systems:
from llama_index.core.memory import ChatMemoryBuffer
memory = ChatMemoryBuffer.from_defaults(token_limit=2000)
agent = ReActAgent.from_tools(
tools=[query_tool],
llm=Settings.llm,
memory=memory,
verbose=True
)
The token_limit parameter ensures that the memory does not grow too large by removing old messages when the limit is reached.
WORKFLOWS: ORCHESTRATING COMPLEX PROCESSES
For applications that need to follow specific processes or workflows, LlamaIndex provides workflow capabilities.
Understanding Workflows
A workflow is a structured sequence of steps. Unlike agents that decide what to do dynamically, workflows follow a predefined process. This is useful when you have a specific procedure that should always be followed.
Here is a simple workflow example:
from llama_index.core.workflow import Workflow, StartEvent, StopEvent, step
class ResearchWorkflow(Workflow):
@step
async def gather_information(self, ev: StartEvent) -> StopEvent:
"""First step: gather information from documents."""
query = ev.query
# Query the index
retriever = index.as_retriever(similarity_top_k=5)
nodes = retriever.retrieve(query)
# Extract information
context = "\n".join([node.text for node in nodes])
return StopEvent(result=context)
This workflow has a single step that gathers information. In a real application, you would have multiple steps that process information in sequence.
Multi-Step Workflows
Here is a more complex workflow with multiple steps:
from llama_index.core.workflow import Workflow, StartEvent, StopEvent, step
class AnalysisWorkflow(Workflow):
@step
async def retrieve_data(self, ev: StartEvent) -> dict:
"""Step 1: Retrieve relevant data."""
query = ev.query
retriever = index.as_retriever(similarity_top_k=5)
nodes = retriever.retrieve(query)
return {"nodes": nodes, "query": query}
@step
async def analyze_data(self, data: dict) -> dict:
"""Step 2: Analyze the retrieved data."""
nodes = data["nodes"]
query = data["query"]
# Use LLM to analyze
context = "\n".join([node.text for node in nodes])
prompt = f"Analyze this information and extract key insights:\n{context}"
response = Settings.llm.complete(prompt)
return {"analysis": response.text, "query": query}
@step
async def generate_report(self, data: dict) -> StopEvent:
"""Step 3: Generate final report."""
analysis = data["analysis"]
query = data["query"]
prompt = f"Create a comprehensive report for the query '{query}' based on this analysis:\n{analysis}"
response = Settings.llm.complete(prompt)
return StopEvent(result=response.text)
This workflow has three distinct steps: retrieve data, analyze it, and generate a report. Each step receives the output of the previous step.
EVALUATION: MEASURING PERFORMANCE
As you build LlamaIndex applications, you need to measure how well they perform. LlamaIndex provides evaluation tools for this purpose.
Evaluating Retrieval
You can evaluate how well your retrieval system finds relevant information:
from llama_index.core.evaluation import RetrieverEvaluator
# Create an evaluator
retriever_evaluator = RetrieverEvaluator.from_metric_names(
["mrr", "hit_rate"],
retriever=retriever
)
# Define test queries and expected relevant documents
eval_queries = [
"What is LlamaIndex?",
"How do embeddings work?",
"What are the benefits of using agents?"
]
# Run evaluation
eval_results = await retriever_evaluator.aevaluate_queries(eval_queries)
The evaluator measures metrics like Mean Reciprocal Rank (MRR) and hit rate, which tell you how often the retriever finds relevant information.
Evaluating Response Quality
You can also evaluate the quality of generated responses:
from llama_index.core.evaluation import FaithfulnessEvaluator, RelevancyEvaluator
# Evaluates if the response is faithful to the source documents
faithfulness_evaluator = FaithfulnessEvaluator(llm=Settings.llm)
# Evaluates if the response is relevant to the query
relevancy_evaluator = RelevancyEvaluator(llm=Settings.llm)
query = "What are the main features of LlamaIndex?"
response = query_engine.query(query)
# Evaluate faithfulness
faithfulness_result = faithfulness_evaluator.evaluate_response(
query=query,
response=response
)
print(f"Faithfulness: {faithfulness_result.passing}")
print(f"Feedback: {faithfulness_result.feedback}")
# Evaluate relevancy
relevancy_result = relevancy_evaluator.evaluate_response(
query=query,
response=response
)
print(f"Relevancy: {relevancy_result.passing}")
print(f"Feedback: {relevancy_result.feedback}")
These evaluators use an LLM to judge the quality of responses, providing automated quality assurance for your application.
PUTTING IT ALL TOGETHER: A COMPLETE RESEARCH ASSISTANT
Let us build a complete research assistant that incorporates everything we have learned. This application will be able to search documents, remember conversations, use multiple tools, and provide well-sourced answers.
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.core.tools import QueryEngineTool, FunctionTool
from llama_index.core.agent import ReActAgent
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
import os
# Configure API key
os.environ["OPENAI_API_KEY"] = "your-api-key-here"
# Configure global settings
Settings.llm = OpenAI(model="gpt-4", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-ada-002")
# Load and index documents
documents = SimpleDirectoryReader("data").load_data()
index = VectorStoreIndex.from_documents(documents)
# Create query engine with custom settings
query_engine = index.as_query_engine(
similarity_top_k=5,
response_mode="tree_summarize"
)
# Create document search tool
doc_tool = QueryEngineTool.from_defaults(
query_engine=query_engine,
name="document_search",
description="Search through the indexed documents for information. Use this for questions about the content in the knowledge base."
)
# Create additional utility tools
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression."""
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {str(e)}"
calc_tool = FunctionTool.from_defaults(fn=calculate)
def summarize_text(text: str, max_length: int = 100) -> str:
"""Summarize a piece of text to a specified maximum length."""
prompt = f"Summarize the following text in no more than {max_length} words:\n\n{text}"
response = Settings.llm.complete(prompt)
return response.text
summary_tool = FunctionTool.from_defaults(fn=summarize_text)
# Create the agent with all tools
research_assistant = ReActAgent.from_tools(
tools=[doc_tool, calc_tool, summary_tool],
llm=Settings.llm,
verbose=True,
max_iterations=10
)
# Interactive loop
print("Research Assistant Ready. Type 'quit' to exit.")
print("=" * 50)
while True:
user_input = input("\nYou: ")
if user_input.lower() == 'quit':
break
response = research_assistant.chat(user_input)
print(f"\nAssistant: {response}")
# Show sources if available
if hasattr(response, 'source_nodes') and response.source_nodes:
print("\nSources:")
for i, node in enumerate(response.source_nodes, 1):
print(f"{i}. {node.node.metadata.get('filename', 'Unknown')}")
This complete application demonstrates all the key concepts we have covered. It loads documents, creates a searchable index, provides multiple tools for the agent to use, maintains conversation history, and provides source attribution.
BEST PRACTICES AND TIPS
As you build your own LlamaIndex applications, keep these best practices in mind.
Chunk Size Optimization
The optimal chunk size depends on your use case. Start with the default (1024 characters) and adjust based on your results. If you find that retrieved chunks lack context, increase the chunk size. If you find that retrieved chunks contain too much irrelevant information, decrease the chunk size.
Metadata Strategy
Think carefully about what metadata to include with your documents. Good metadata enables powerful filtering and organization. Include information like source, date, author, category, and any other attributes that might be useful for filtering.
Embedding Model Selection
Different embedding models have different strengths. The default OpenAI embedding model works well for general purposes, but specialized models might perform better for domain-specific applications. Consider experimenting with different models for your use case.
LLM Selection
Similarly, different LLMs have different capabilities. GPT-4 provides the best reasoning and accuracy but is more expensive. GPT-3.5-turbo is faster and cheaper but less capable. Choose based on your requirements for accuracy, speed, and cost.
Error Handling
Always include error handling in production applications:
try:
response = query_engine.query(user_question)
print(response)
except Exception as e:
print(f"An error occurred: {str(e)}")
# Log the error, notify administrators, etc.
Monitoring and Logging
Keep track of your application's performance. Log queries, responses, retrieval scores, and any errors. This information is invaluable for debugging and optimization.
Cost Management
LLM applications can be expensive. Monitor your API usage and implement rate limiting if necessary. Consider caching responses for common queries to reduce costs.
Security Considerations
Never expose your API keys in code. Use environment variables or secure configuration management. If your application handles sensitive data, ensure that it is properly secured and that access is controlled.
CONCLUSION: YOUR JOURNEY WITH LLAMAINDEX
You have now learned the fundamental concepts and practical skills needed to build sophisticated LLM applications with LlamaIndex. Let us recap what you have learned.
You started by understanding the core problem that LlamaIndex solves: connecting LLMs with external data. You learned about the fundamental building blocks: Documents, Nodes, embeddings, and Indexes. You saw how to load data from various sources and how to chunk it effectively.
You learned about retrieval mechanisms and how to find the most relevant information for a query. You explored query engines and how they combine retrieval with LLM generation to produce answers. You discovered the power of agents and how they can reason, use tools, and accomplish complex tasks.
You built toward a sophisticated multi-agent research system, learning how to coordinate multiple specialized agents to accomplish complex research tasks. You explored advanced features like memory, workflows, and evaluation.
Most importantly, you learned by doing. Every concept was illustrated with working code examples that you can adapt for your own applications.
The journey does not end here. LlamaIndex is a rich framework with many more features to explore. You can dive deeper into advanced retrieval techniques, experiment with different LLM providers, build custom components, and integrate with various data sources and tools.
As you build your own applications, remember the core principles. Start simple and add complexity gradually. Test and evaluate your application's performance. Iterate based on real-world usage. And most importantly, focus on solving real problems for real users.
The field of LLM applications is rapidly evolving, and LlamaIndex evolves with it. Stay engaged with the community, read the documentation, and experiment with new features as they are released.
You now have the knowledge and skills to build powerful, intelligent applications that leverage the reasoning capabilities of LLMs combined with your own data. The possibilities are limitless. What will you build?