🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

RAG Tutorial 2026: Build AI Apps with LLMs and Vector Search

⏱️6 min read  ·  1,152 words

Retrieval-Augmented Generation (RAG) is the dominant pattern for building production AI applications in 2026. RAG grounds LLM responses in real data, eliminates hallucinations on domain knowledge, and keeps your AI up-to-date without expensive fine-tuning. This guide builds a production RAG system from scratch.

Why RAG?

LLMs have a knowledge cutoff and can’t access your private data. RAG solves both problems:

  • No hallucinations on facts — model answers from retrieved documents, not memory
  • Private data — index your own PDFs, databases, wikis
  • Up-to-date answers — update the knowledge base, not the model
  • Cheaper than fine-tuning — no training costs, instant updates
  • Source attribution — cite which documents were used for each answer

RAG Architecture

RAG Pipeline:

[Your Documents]
     ↓ chunk + embed
[Vector Database] (Pinecone, Qdrant, ChromaDB)
     ↑ semantic search
[User Query] → embed → search → [Top-K Chunks]
                                      ↓
                              [LLM Prompt]
                              "Using these documents: {chunks}
                               Answer: {query}"
                                      ↓
                              [Grounded Answer]

Setup: Installing Dependencies

pip install anthropic chromadb sentence-transformers          pypdf langchain langchain-community          fastapi uvicorn python-dotenv

Step 1: Document Ingestion

import anthropic
from pathlib import Path
from pypdf import PdfReader
import chromadb
from chromadb.utils import embedding_functions

# Initialize ChromaDB (local vector store)
client = chromadb.PersistentClient(path="./chroma_db")

# Use sentence-transformers for embeddings (free, local)
ef = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="all-MiniLM-L6-v2"  # fast, good quality, 384 dims
)

collection = client.get_or_create_collection(
    name="documents",
    embedding_function=ef
)

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    # Split text into overlapping chunks.
    words = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size - overlap):
        chunk = " ".join(words[i:i + chunk_size])
        chunks.append(chunk)
    return chunks

def ingest_pdf(pdf_path: str) -> int:
    # Ingest a PDF into the vector store.
    reader = PdfReader(pdf_path)
    all_chunks = []
    metadatas = []
    ids = []

    for page_num, page in enumerate(reader.pages):
        text = page.extract_text()
        if not text.strip():
            continue

        chunks = chunk_text(text)
        for i, chunk in enumerate(chunks):
            chunk_id = f"{Path(pdf_path).stem}_p{page_num}_c{i}"
            all_chunks.append(chunk)
            metadatas.append({
                "source": pdf_path,
                "page": page_num + 1,
                "chunk": i
            })
            ids.append(chunk_id)

    # Add to ChromaDB
    collection.add(documents=all_chunks, metadatas=metadatas, ids=ids)
    return len(all_chunks)

# Ingest documents
print(f"Ingested: {ingest_pdf('company_docs.pdf')} chunks")
print(f"Ingested: {ingest_pdf('product_manual.pdf')} chunks")

Step 2: Retrieval

def retrieve(query: str, n_results: int = 5) -> list[dict]:
    # Retrieve relevant chunks for a query.
    results = collection.query(
        query_texts=[query],
        n_results=n_results,
        include=["documents", "metadatas", "distances"]
    )

    chunks = []
    for doc, meta, dist in zip(
        results["documents"][0],
        results["metadatas"][0],
        results["distances"][0]
    ):
        chunks.append({
            "text": doc,
            "source": meta["source"],
            "page": meta["page"],
            "similarity": 1 - dist  # convert distance to similarity
        })

    # Filter low-relevance chunks
    return [c for c in chunks if c["similarity"] > 0.3]

Step 3: Generation with Claude

import anthropic

claude = anthropic.Anthropic()  # uses ANTHROPIC_API_KEY env var

def rag_query(question: str, n_results: int = 5) -> dict:
    # Answer a question using RAG.

    # Retrieve relevant context
    chunks = retrieve(question, n_results)

    if not chunks:
        return {
            "answer": "I couldn't find relevant information in the documents.",
            "sources": []
        }

    # Build context string
    context = "

---

".join([
        f"[Source: {c['source']}, Page {c['page']}]
{c['text']}"
        for c in chunks
    ])

    # Create prompt
    system = (
        "You are a helpful assistant that answers questions based ONLY on "
        "the provided documents. If the answer is not in the documents, say so clearly. "
        "Always cite the source document and page number for your answers."
    )

    user_message = (
        f"Documents:
{context}

"
        f"Question: {question}

"
        "Answer based only on the provided documents, citing sources."
    )

    # Call Claude
    response = claude.messages.create(
        model="claude-opus-4-5",
        max_tokens=1024,
        system=system,
        messages=[{"role": "user", "content": user_message}]
    )

    return {
        "answer": response.content[0].text,
        "sources": [{"source": c["source"], "page": c["page"]} for c in chunks],
        "tokens_used": response.usage.input_tokens + response.usage.output_tokens
    }

# Use it
result = rag_query("What is the return policy?")
print(result["answer"])
print("Sources:", result["sources"])

FastAPI RAG API

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="RAG API")

class QueryRequest(BaseModel):
    question: str
    n_results: int = 5

class QueryResponse(BaseModel):
    answer: str
    sources: list[dict]
    tokens_used: int

@app.post("/query", response_model=QueryResponse)
async def query(request: QueryRequest):
    if not request.question.strip():
        raise HTTPException(400, "Question cannot be empty")

    result = rag_query(request.question, request.n_results)
    return QueryResponse(**result)

@app.post("/ingest")
async def ingest_document(file_path: str):
    count = ingest_pdf(file_path)
    return {"status": "ok", "chunks_indexed": count}

# Run: uvicorn main:app --reload

Advanced RAG Patterns

Hybrid Search (Keyword + Semantic)

# Combine BM25 keyword search with vector search
from rank_bm25 import BM25Okapi

def hybrid_search(query: str, docs: list[str], alpha: float = 0.5) -> list[str]:
    # Semantic search scores
    semantic_results = collection.query(query_texts=[query], n_results=10)
    semantic_scores = {doc: score for doc, score in
                      zip(semantic_results["ids"][0], semantic_results["distances"][0])}

    # BM25 keyword scores
    tokenized = [doc.split() for doc in docs]
    bm25 = BM25Okapi(tokenized)
    bm25_scores = bm25.get_scores(query.split())

    # Combine (Reciprocal Rank Fusion)
    combined = alpha * (1 - semantic_scores.get(id, 1)) + (1-alpha) * bm25_score
    return sorted_by_combined_score

Reranking

# Use a cross-encoder to rerank retrieved chunks
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank(query: str, chunks: list[str], top_k: int = 3) -> list[str]:
    pairs = [(query, chunk) for chunk in chunks]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(chunks, scores), key=lambda x: x[1], reverse=True)
    return [chunk for chunk, score in ranked[:top_k]]

Production Considerations

  • Chunking strategy — experiment with chunk size (200-1000 tokens), overlap (10-20%)
  • Embedding model — OpenAI text-embedding-3-small for quality, local models for cost
  • Vector DB — ChromaDB/Qdrant for self-hosted, Pinecone for managed
  • Caching — cache embeddings and frequent query results
  • Evaluation — RAGAS framework for RAG-specific metrics (faithfulness, relevance)
  • Streaming — stream Claude responses for better UX

RAG is now the baseline pattern for enterprise AI in 2026. Start with a simple ChromaDB + Claude setup, measure answer quality with RAGAS, then optimize chunking and retrieval. The combination of vector search and LLM reasoning is incredibly powerful for knowledge-intensive applications.

✍️ Leave a Comment

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

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা