RAG Tutorial in Python: LlamaIndex & Retrieval-Augmented Generation

Large Language Models (LLMs) make things up, and a Python RAG app is the cure. You saw the hallucination problem in the large language models tutorial: the model guesses when it does not actually know. RAG (Retrieval-Augmented Generation) stops that guessing by feeding it the right documents before it answers. Instead of leaning on whatever it memorized during training, you pull the relevant passages from your own files and paste them into the prompt.

The model then answers from real, checkable sources. Think of it like an open-book exam: a student who can look up the textbook gives far better answers than one working from a foggy memory. That is why RAG is the most important production pattern in Generative AI (GenAI). It solves the trust problem, because your users can click straight to the source.

In this post you build a complete RAG pipeline with LlamaIndex, the tool most teams reach for first when they need to search their own documents. You will load documents, chunk them into bite-sized pieces, turn those pieces into vectors, store the vectors, and then ask questions in plain English. By the end you will have a working system that answers questions about your own files and shows you which file each answer came from.

“The question of whether a computer can think is no more interesting than the question of whether a submarine can swim.”

Edsger Dijkstra

Last Updated: July 2026 | Tested on: Python 3.14.6, llama-index 0.14.22 | Difficulty: Advanced | Reading Time: 17 minutes

📋 Prerequisites:

Retrieval-Augmented Generation (RAG) is the pattern of handing an LLM your own documents so it can answer questions about them accurately. The model’s training data may be stale, or it may have never seen your private files at all. RAG works around that. It fetches the relevant passages from your document collection and slips them into the prompt, so the model answers from your actual data instead of from memory.

RAG is the most practical way to build AI apps over private data without paying to fine-tune a model. This post walks through the full pipeline with LlamaIndex: loading documents, chunking, embedding, storing them in a vector index, and querying. By the end you will have a working RAG system that answers questions about your own documents and cites its sources.

The RAG Pipeline

Every RAG system has two phases: ingestion (loading and indexing your documents) and querying (retrieving relevant chunks and generating answers). The diagram below shows the complete flow.

Query PipelineIngestion PipelineretrieveDocumentsPDF, MD, HTMLChunk512 tokens + overlapEmbedtext-embedding-3Vector StoreChromaDBUser QuestionEmbed QuerySimilarity SearchTop-K chunksAugment PromptContext + QuestionLLM generatesanswer with citationsPython RAG Pipeline: Chunk, Embed, Retrieve, and Generate Cited Answers

The diagram shows the RAG (Retrieval-Augmented Generation) pipeline end to end. On the ingestion side, documents are chunked and embedded into vectors that live in an index. On the query side, a user question is embedded and matched against that index to pull back the most relevant chunks, and those chunks are dropped into the LLM prompt as context before it writes an answer. This is how RAG sidesteps the model’s knowledge cutoff and its habit of making things up: every answer is grounded in your actual documents. Three knobs control both accuracy and cost, and they are the ones you will tune most: chunk size, the embedding model, and how many chunks you retrieve per question.

A note on tools (at the time of writing, mid-2026): the pipeline above, load, chunk, embed, store, retrieve, generate, is the stable part you should learn. The framework is the swappable part. We use LlamaIndex here because it leads on retrieval and ingestion right now, but the very same six steps map cleanly onto LangChain or Haystack if your team already runs one of those. If you would rather not host the retrieval layer yourself, managed RAG is a real option: OpenAI’s Responses API file_search tool and AWS Bedrock Knowledge Bases both index your files for you. One thing to avoid: do not build on OpenAI’s older Assistants API, which shuts down on August 26, 2026 (see the OpenAI deprecations page), use the Responses API instead. This space moves every few weeks, so always check each project’s own docs for the current version and API shape.

Document Ingestion: Load, Chunk, Embed, Store

Ingestion is the one-time setup step. You run it whenever your documents change, not on every question. Picture a librarian taking a stack of new books, reading each one, writing index cards for the important bits, and filing those cards in a drawer. That filing is exactly what this script does. Here a developer named Rahul points LlamaIndex at a folder, it reads every file, cuts each file into chunks, turns each chunk into a vector (a list of numbers that captures its meaning), and saves the lot to disk so you never have to repeat the work. Every Python RAG pipeline, whatever the framework, starts with this filing step.

📄 rag_ingestion.py: building the knowledge base

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.openai import OpenAI as LlamaOpenAI
from llama_index.embeddings.openai import OpenAIEmbedding

# Rahul builds a RAG system for his team's documentation
# Step 1: Configure the LLM and the embedding model.
# Model IDs churn every few weeks, so check the provider docs for the
# current names. At the time of writing (mid-2026) both of these are current.
Settings.llm = LlamaOpenAI(model="gpt-5.4-mini", temperature=0)  # cheap, capable default tier
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")

# Prefer not to depend on a paid, closed embedding model? Swap in a local
# open-weight one and nothing else in this pipeline changes:
#   from llama_index.embeddings.huggingface import HuggingFaceEmbedding
#   Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5")

# Step 2: Load documents from a directory
documents = SimpleDirectoryReader("./company_docs/").load_data()
print(f"Loaded {len(documents)} documents")
for doc in documents[:3]:
    print(f"  {doc.metadata.get('file_name', 'unknown')}: {len(doc.text)} chars")

# Step 3: Chunk documents intelligently
splitter = SentenceSplitter(
    chunk_size=512,      # ~512 tokens per chunk
    chunk_overlap=50,    # 50-token overlap for context continuity
)

nodes = splitter.get_nodes_from_documents(documents)
print(f"\nSplit into {len(nodes)} chunks")
print(f"Average chunk size: {sum(len(n.text) for n in nodes) // len(nodes)} chars")

# Step 4: Create vector index (embeds and stores)
index = VectorStoreIndex(nodes)

# Save for later use
index.storage_context.persist(persist_dir="./rag_index/")
print(f"\nIndex saved to ./rag_index/")
print(f"Ready for querying!")

▶ Output

Loaded 12 documents
  api_reference.md: 15234 chars
  deployment_guide.md: 8921 chars
  architecture_overview.md: 12456 chars

Split into 87 chunks
Average chunk size: 498 chars

Index saved to ./rag_index/
Ready for querying!
About this output: the embedding step calls the OpenAI API, which needs an API key and costs money, so we did not run this block live here. The numbers above are from a sample run on a 12-file docs folder. Your counts will differ with your own files. The local-only parts (the SentenceSplitter chunking you see later in this post) we did run, and that output is real.

What happened here: Four small steps and you have a searchable knowledge base. SimpleDirectoryReader read every file in the folder and handed back a list of documents. SentenceSplitter cut them into chunks of about 512 tokens, with a 50-token overlap so an idea that spans two chunks is not sliced clean in half. VectorStoreIndex then did the expensive part: it sent each chunk to the embedding model, got back a vector, and stored every chunk next to its vector. persist() wrote all of that to disk. Run this once, and every future question reuses the saved index instead of re-embedding the whole folder. That is the librarian’s drawer of index cards from earlier, now filled and filed.

Querying with Citations

With the index saved, asking a question is the easy part. Think of it like asking a librarian who has already filed every index card: you do not wait while the books get re-read, the answer comes straight from the drawer. Here a developer named Aditi loads the index back from disk and turns it into a query engine. When she asks a question, the engine embeds it the same way the chunks were embedded, finds the closest chunks by vector similarity, and feeds them to the model as context. This is the moment a Python RAG system earns its keep: the answer arrives with its sources attached.

The model writes the answer, and because we also keep the source chunks, we can show exactly which files the answer leaned on. That last part is what turns a black box into something you can trust.

📄 rag_query.py: answering questions with source citations

from llama_index.core import StorageContext, load_index_from_storage

# Aditi queries the RAG system
storage_context = StorageContext.from_defaults(persist_dir="./rag_index/")
index = load_index_from_storage(storage_context)

# Create query engine with citation support
query_engine = index.as_query_engine(
    similarity_top_k=3,        # Retrieve top 3 most relevant chunks
    response_mode="compact",    # Compact responses using all retrieved context
)

# Ask questions about company documentation
questions = [
    "How do I deploy the application to production?",
    "What authentication methods does the API support?",
    "What is the recommended database setup for high availability?",
]

for q in questions:
    response = query_engine.query(q)
    print(f"\nQ: {q}")
    print(f"A: {response.response[:300]}...")
    print(f"\nSources:")
    for node in response.source_nodes:
        score = node.score if node.score else 0
        source = node.node.metadata.get("file_name", "unknown")
        print(f"  [{score:.3f}] {source}: {node.node.text[:100]}...")
    print("-" * 60)

▶ Output

Q: How do I deploy the application to production?
A: To deploy to production, follow these steps: 1) Build the Docker image using
`docker build -t myapp:latest .` 2) Push to your container registry with
`docker push registry.example.com/myapp:latest` 3) Update the Kubernetes
deployment manifest with the new image tag 4) Apply with `kubectl apply -f deploy.yaml`...

Sources:
  [0.892] deployment_guide.md: ## Production Deployment\n\nThe recommended deployment...
  [0.834] architecture_overview.md: ### Infrastructure\n\nThe application runs on...
  [0.756] api_reference.md: ## Health Check Endpoint\n\nUse GET /health to verify...
------------------------------------------------------------

Q: What authentication methods does the API support?
A: The API supports three authentication methods: 1) API key authentication via the
X-API-Key header for server-to-server calls 2) JWT bearer tokens for user
authentication via OAuth 2.0 3) Session-based auth for the web dashboard...

Sources:
  [0.921] api_reference.md: ## Authentication\n\nAll API endpoints require authentication...
  [0.867] api_reference.md: ### OAuth 2.0 Flow\n\nFor user-facing applications...
  [0.712] deployment_guide.md: ## Security Configuration\n\nEnsure API keys are stored...
------------------------------------------------------------
About this output: querying calls both the embedding model and the LLM, so it needs an API key and costs a few cents per run. We did not run this block live here. The answers and similarity scores above are from a sample run on the same 12-file docs folder, shown so you can see the shape of a real response. Run it against your own files with your own key and you will get answers about your documents, not these.

What happened here: The query engine embedded each question, found the three closest chunks by vector similarity, dropped them into the LLM prompt as context, and asked the model to answer using only that context. Each source line carries a similarity score from 0 to 1: higher means a closer match. Because the prompt tells the model to stick to the retrieved chunks, it has far less room to invent things. It is not magic, retrieval can still miss, but a grounded answer with citations is a world away from a confident guess. And the source list means a reader can open the file and check for themselves.

Chunking Strategies: The Most Underrated RAG Decision

Here is the part most Python RAG builders get wrong. Your retrieval is only as good as your chunks. If a chunk is huge, it crams several unrelated ideas together, and the match score gets watered down. If a chunk is tiny, it loses the context that gave a sentence its meaning. Think of cutting a pizza: slice it too big and one piece has three different toppings fighting for attention, slice it too small and you cannot taste anything. You want pieces that are big enough to be satisfying on their own but small enough to be about one thing.

There are three common ways to cut. Here a developer named Vinay lines them up so you can see when each one earns its keep.

📄 chunking.py: how chunking strategy affects RAG quality

# Vinay compares three chunking strategies side by side
strategies = {
    "Fixed-size (512 chars)": {
        "method": "Split every 512 characters",
        "pros": "Simple, predictable chunk size",
        "cons": "Cuts mid-sentence, breaks context",
        "best_for": "Quick prototyping",
    },
    "Sentence-based (512 tokens)": {
        "method": "Split at sentence boundaries, max 512 tokens",
        "pros": "Preserves sentence integrity, overlap handles boundaries",
        "cons": "Unrelated sentences may share a chunk",
        "best_for": "Most RAG applications (default choice)",
    },
    "Semantic chunking": {
        "method": "Split where embedding similarity drops",
        "pros": "Each chunk is semantically coherent",
        "cons": "Slower (requires embedding computation), variable sizes",
        "best_for": "High-quality RAG where accuracy matters most",
    },
}

print("Chunking Strategy Comparison\n")
for name, info in strategies.items():
    print(f"  {name}")
    print(f"    Method:   {info['method']}")
    print(f"    Pros:     {info['pros']}")
    print(f"    Cons:     {info['cons']}")
    print(f"    Best for: {info['best_for']}")
    print()

▶ Output

Chunking Strategy Comparison

  Fixed-size (512 chars)
    Method:   Split every 512 characters
    Pros:     Simple, predictable chunk size
    Cons:     Cuts mid-sentence, breaks context
    Best for: Quick prototyping

  Sentence-based (512 tokens)
    Method:   Split at sentence boundaries, max 512 tokens
    Pros:     Preserves sentence integrity, overlap handles boundaries
    Cons:     Unrelated sentences may share a chunk
    Best for: Most RAG applications (default choice)

  Semantic chunking
    Method:   Split where embedding similarity drops
    Pros:     Each chunk is semantically coherent
    Cons:     Slower (requires embedding computation), variable sizes
    Best for: High-quality RAG where accuracy matters most

Tables are fine, but watching the splitter actually cut is better. The sentence-based splitter needs no Application Programming Interface (API) key, it only counts tokens and looks for sentence boundaries, so this next block really runs on your machine. Vinay feeds it a short paragraph and asks for small chunks so the cuts are easy to see.

📄 chunk_demo.py: a real sentence-based split you can run locally

from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter

# No API key needed: SentenceSplitter only counts tokens and
# cuts at sentence boundaries. Nothing is sent to a model.
text = (
    "Retrieval-Augmented Generation grounds an LLM in your own documents. "
    "Instead of trusting the model's memory, you fetch the relevant passages and "
    "paste them into the prompt. The model then answers from real sources. "
    "Chunking is the step that decides which passages can be fetched. "
    "If a chunk is too big, it mixes unrelated ideas and dilutes the match. "
    "If a chunk is too small, it loses the context around a single sentence. "
    "A 512-token chunk with a small overlap is the sweet spot for most teams. "
    "The overlap carries a few tokens from the previous chunk so a sentence that "
    "straddles a boundary is never cut clean in half."
)

doc = Document(text=text)
splitter = SentenceSplitter(chunk_size=60, chunk_overlap=10)
nodes = splitter.get_nodes_from_documents([doc])

print(f"Original text: {len(text)} chars")
print(f"Split into {len(nodes)} chunks")
print()
for i, node in enumerate(nodes, start=1):
    print(f"Chunk {i} ({len(node.text)} chars): {node.text}")

▶ Output

Original text: 620 chars
Split into 3 chunks

Chunk 1 (279 chars): Retrieval-Augmented Generation grounds an LLM in your own documents. Instead of trusting the model's memory, you fetch the relevant passages and paste them into the prompt. The model then answers from real sources. Chunking is the step that decides which passages can be fetched.
Chunk 2 (215 chars): If a chunk is too big, it mixes unrelated ideas and dilutes the match. If a chunk is too small, it loses the context around a single sentence. A 512-token chunk with a small overlap is the sweet spot for most teams.
Chunk 3 (124 chars): The overlap carries a few tokens from the previous chunk so a sentence that straddles a boundary is never cut clean in half.

What happened here: The splitter never cut a sentence in the middle. It packed whole sentences into each chunk until it hit the 60-token budget, then started a fresh chunk. That is the whole point of sentence-based splitting: every chunk is a clean unit of meaning. In a real pipeline you would use chunk_size=512, not 60, but the behavior is identical, just with bigger pieces. This is the same SentenceSplitter the ingestion script used earlier, so what you see here is exactly how your documents get cut before they are embedded.

Common Mistakes

⚠️ Common Mistakes:
  • Chunks too large: 2,000-token chunks dilute relevance. Smaller chunks (256-512 tokens) with overlap produce better retrieval because each chunk is more focused.
  • No chunk overlap: Without overlap, context at chunk boundaries is lost. Use 10-15% overlap (e.g., 50-token overlap for 512-token chunks).
  • Not evaluating retrieval quality: If the retriever returns irrelevant chunks, the best LLM in the world cannot generate good answers. Always check what is actually being retrieved before blaming the LLM.

Practice Exercises

  1. Exercise 1, your first RAG: Drop three or four of your own notes or README files into a folder, run the ingestion script, then ask the query engine two questions about them. Confirm the answers come from your files and not from the model’s memory.
  2. Exercise 2, tune the chunks: Take the local chunk_demo.py and run it three times with chunk_size set to 30, 60, and 120. Watch how the number of chunks and the cut points change. Which size keeps each idea together best?
  3. Exercise 3, inspect retrieval: Before trusting any answer, print response.source_nodes for a question you know the answer to. If the top chunk is the wrong one, that is your bug, not the model. Fix retrieval first.

More in this series:

Frequently Asked Questions

When should I use RAG vs fine-tuning?

Use RAG when you need access to specific, frequently changing documents (company docs, knowledge bases, product catalogs). Use fine-tuning (fine-tuning tutorial) when you need the model to learn a specific behavior or style. Many production Python RAG apps use both: fine-tuning for style and retrieval for knowledge.

What types of documents can I use with RAG?

Through LlamaHub, LlamaIndex reads a huge range of sources: PDF, Word, PowerPoint, HTML, Markdown, CSV, JSON, databases (SQL, MongoDB), and APIs (Notion, Slack, Google Docs), among many others. Use SimpleDirectoryReader for plain files and the specialized readers for APIs and databases. Check the LlamaHub docs for the current loader list.

How accurate is RAG compared to asking the LLM directly?

For factual questions about your own documents, RAG sharply reduces hallucination versus asking the model cold, because the answer is grounded in retrieved text instead of memory. The whole thing hinges on retrieval quality. If the right chunks are retrieved, the model almost always gets the answer right. If the wrong chunks are retrieved, RAG can actually do worse than a plain query, since the model is now anchored to irrelevant context. That is why you always check what is being retrieved before you trust the answers.

Interview Questions on Python RAG

The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.

Q: What are the two phases of a RAG pipeline, and what happens in each?

Ingestion and querying. Ingestion is a one-time step that loads documents, splits them into chunks, embeds each chunk into a vector, and stores those vectors in an index. Querying embeds the user’s question, retrieves the most similar chunks, and hands them to the LLM as context so it answers from your data instead of its memory.

Q: Why add overlap between chunks?

Overlap carries a few tokens from the end of one chunk into the start of the next, so an idea or sentence that straddles a boundary is not cut clean in half. Without it, retrieval can miss context that sits right at a chunk edge. A common setting is 10 to 15 percent, for example a 50-token overlap on a 512-token chunk.

Q: Your RAG app returns confident answers that are flat wrong, even though the documents contain the correct information. What do you check first?

Check retrieval before blaming the LLM. Print response.source_nodes and look at which chunks came back and their similarity scores. If the right chunk is not in the top-K, the problem is retrieval, so revisit chunk size, overlap, the embedding model, or raise similarity_top_k. The model can only answer from the context it was handed, so a bad retrieval guarantees a bad answer.

Q: Ingestion is slow and your API bill spikes every time you restart the app. What is wrong?

You are most likely re-embedding the whole corpus on every startup instead of persisting and reloading the index. Embedding is the expensive API call. Run ingestion once, call index.storage_context.persist(), and on later runs use load_index_from_storage to read the saved index from disk so you never pay to re-embed unchanged documents.

Q: What does similarity_top_k control, and what is the trade-off?

It sets how many of the most similar chunks the retriever pulls into the prompt as context. Set it too low and you may miss the chunk that holds the answer; set it too high and you pad the prompt with noise, raise token cost, and can distract the model. Three to five is a common starting point, then tune it based on what actually gets retrieved.

Q: What is an embedding, and why does RAG rely on it?

An embedding is a list of numbers (a vector) that captures the meaning of a piece of text, so texts with similar meaning sit close together in vector space. RAG embeds both your chunks and the user’s question, then finds the chunks whose vectors are nearest the question’s vector. That similarity search is what lets it fetch relevant passages even when the wording does not match exactly.

What’s Next?

You now have a working Python RAG pipeline: you loaded documents, chunked them with SentenceSplitter, embedded and stored them in a vector index, and queried that index to get answers that cite their sources. The biggest lever, as you saw, is chunking, so tune chunk size and overlap before you reach for a fancier model. Next, in the vector databases tutorial, we go deeper into the storage layer, comparing ChromaDB, Pinecone, and pgvector so you can pick the right vector database for your scale and budget.

Want the full path from Python basics to production AI? Explore the complete Python + AI/ML tutorial series home.

Previous: LLM Observability with Langfuse: Traces, Spans, and Cost

Next: Python: Vector Databases (ChromaDB, Pinecone, pgvector for AI Apps)

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *