Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced

RAG — Retrieval-Augmented Generation

Vector databases, semantic search, chunking strategies, and the full RAG pipeline from document to answer. Build a Stripe knowledge base Q&A system.

40–52 min March 2026
Before any code — what problem RAG solves

Fine-tuning teaches a model new behaviour. RAG gives a model access to documents it has never seen — without any training at all.

A customer asks Stripe's support bot: "What is the settlement cycle for international payments?" The LLM does not know — this is specific to Stripe's current policy which changes quarterly and was never in the training data. Fine-tuning would require retraining every time the policy changes. That is expensive, slow, and impractical.

RAG solves this differently. Before answering, it retrieves the most relevant sections from Stripe's documentation. Those sections are injected into the LLM's context window alongside the question. The LLM answers from the retrieved context — not from its weights. Update the documentation and the answers update instantly. No retraining. No fine-tuning.

RAG is now the standard architecture for any application that needs an LLM to answer questions about private, recent, or frequently-updated information. DoorDash's internal tool answering HR policy questions, Amazon's product Q&A bot, Brex's financial terms assistant — all are RAG systems.

🧠 Analogy — read this first

An open-book exam vs a closed-book exam. Fine-tuning is memorising everything before the exam — works until the syllabus changes. RAG is the open-book exam — you bring the textbook and look up answers during the test. The student (LLM) still needs to be smart enough to find and synthesise the right information — but they do not need to memorise every fact in advance.

The retrieval step is critical — bringing the wrong textbook chapters into context produces wrong answers even with a perfect LLM. Most RAG failures are retrieval failures, not generation failures.

The architecture

Two phases — indexing (offline) and retrieval+generation (online)

RAG pipeline — indexing phase vs query phase
INDEXING PHASE (offline — run once)
1. Load documentsPDF, HTML, Markdown, database
2. Chunk text500–1000 tokens per chunk
3. Embed chunkstext-embedding-3-small → 1536-dim vector
4. Store in vector DBFAISS, Chroma, Pinecone, Weaviate
QUERY PHASE (online — every request)
1. Receive question"What is settlement cycle?"
2. Embed questionSame embedding model as indexing
3. Vector searchTop-k most similar chunks (k=3–5)
4. Build promptSystem + retrieved context + question
5. LLM generatesAnswer grounded in retrieved docs
python
# pip install faiss-cpu sentence-transformers

import numpy as np
from sentence_transformers import SentenceTransformer

# ── Stripe knowledge base — simulated documentation ─────────────────
documents = [
    {
        'id': 'settlement-001',
        'title': 'Settlement Cycle — Domestic Payments',
        'text': (
            "Stripe settles domestic payments within T+2 business days. "
            "T is the day the payment is captured. Weekends and public holidays "
            "are excluded from the settlement cycle. For example, a payment "
            "captured on Friday will be settled by the following Tuesday."
        ),
    },
    {
        'id': 'settlement-002',
        'title': 'Settlement Cycle — International Payments',
        'text': (
            "International payments on Stripe are settled within T+7 business days. "
            "Currency conversion is done at the prevailing forex rate on the day of "
            "settlement. SWIFT charges of USD 15-25 may apply per transaction."
        ),
    },
    {
        'id': 'refund-001',
        'title': 'Refund Policy',
        'text': (
            "Refunds are processed within 5-7 business days for credit cards and "
            "2-3 business days for ACH and debit cards. Stripe does not charge "
            "any fee for processing refunds. Partial refunds are supported."
        ),
    },
    {
        'id': 'webhook-001',
        'title': 'Webhook Configuration',
        'text': (
            "Stripe webhooks send event notifications to your server URL. "
            "Supported events include payment.captured, payment.failed, "
            "refund.created, and order.paid. Webhooks are retried up to 3 times "
            "on failure with exponential backoff."
        ),
    },
    {
        'id': 'dispute-001',
        'title': 'Payment Disputes and Chargebacks',
        'text': (
            "When a customer disputes a payment, Stripe notifies you via webhook "
            "and dashboard. You have 7 days to submit evidence. Required documents: "
            "delivery proof, invoice, customer communication. Failure to respond "
            "results in automatic chargeback."
        ),
    },
]

# ── Step 1: Embed all documents ────────────────────────────────────────
print("Loading embedding model...")
embedder = SentenceTransformer('all-MiniLM-L6-v2')   # fast, 384-dim, free

texts      = [d['text'] for d in documents]
embeddings = embedder.encode(texts, show_progress_bar=False)
print(f"Embedded {len(documents)} documents → shape: {embeddings.shape}")

# ── Step 2: Build FAISS index ─────────────────────────────────────────
import faiss

dim   = embeddings.shape[1]   # 384
index = faiss.IndexFlatIP(dim)  # Inner product = cosine similarity (for normalised vectors)

# Normalise for cosine similarity
faiss.normalize_L2(embeddings)
index.add(embeddings)
print(f"FAISS index: {index.ntotal} vectors, dim={dim}")

# ── Step 3: Retrieve for a query ──────────────────────────────────────
def retrieve(query: str, k: int = 3) -> list[dict]:
    q_emb = embedder.encode([query])
    faiss.normalize_L2(q_emb)
    scores, indices = index.search(q_emb, k)

    results = []
    for score, idx in zip(scores[0], indices[0]):
        results.append({
            'document': documents[idx],
            'score':    float(score),
        })
    return results

# Test retrieval
queries = [
    "How long does international payment settlement take?",
    "When will my refund arrive?",
    "What happens if a customer files a dispute?",
]

print("
Retrieval results:")
for query in queries:
    results = retrieve(query, k=2)
    print(f"
  Q: '{query}'")
    for r in results:
        print(f"    [{r['score']:.3f}] {r['document']['title']}")
The most important design decision

Chunking — how you split documents determines retrieval quality

Chunking is the single biggest lever in RAG quality. Too small: each chunk lacks context — the retrieved snippet is meaningless without surrounding text. Too large: the relevant sentence is buried in noise — the LLM hallucinates because it cannot find the answer in a 2000-token wall of text. The goal: each chunk should be semantically self-contained and contain exactly one answerable concept.

Fixed size (character/token)500–1000 tokens, 100–200 overlap

Split every N characters or tokens. Overlap of 10-20% between chunks.

Simple, predictable chunk sizes. Easy to implement.
Splits mid-sentence, mid-paragraph. Destroys semantic boundaries.
Use: Quick prototype only. Never use in production.
Recursive character splitting500 tokens, 50 overlap

Split on paragraphs first, then sentences, then words — trying to preserve semantic units.

Better semantic boundaries than fixed-size. Standard LangChain default.
Still splits on arbitrary boundaries. No understanding of document structure.
Use: Good general-purpose default for most documents.
Semantic chunkingVariable — split at semantic boundaries

Embed consecutive sentences. Split where embedding distance jumps — indicating a topic change.

Each chunk is semantically coherent. Best retrieval quality.
Slower (requires embedding all sentences). Variable chunk sizes.
Use: Production RAG where quality matters more than speed.
Document-structure awareOne section per chunk

Use headings, sections, and document structure to define chunks. Each section = one chunk.

Perfect for structured documents (API docs, manuals, legal).
Requires document structure metadata. Chunks vary wildly in size.
Use: Technical documentation, legal documents, product manuals.
python
import re
import numpy as np
from sentence_transformers import SentenceTransformer

# ── Three chunking strategies on the same document ────────────────────
stripe_doc = """
# Stripe Settlement Guide

## Domestic Settlements
Stripe settles domestic payments within T+2 business days. T is the day
the payment is captured. Weekends and public holidays are excluded from
the settlement cycle. For example, a payment captured on Friday will be
settled by the following Tuesday.

The minimum settlement amount is $100. Settlements below this threshold
are carried forward to the next settlement cycle. You can view all pending
settlements in the Stripe Dashboard under Settlements > Pending.

## International Settlements
International payments are settled within T+7 business days. Currency
conversion is done at the prevailing forex rate on the day of settlement.
SWIFT charges of USD 15-25 may apply per transaction depending on your
bank. Contact support@stripe.com for bulk international settlement rates.

## Settlement Disputes
If you believe a settlement amount is incorrect, raise a dispute within
30 days of the settlement date. Provide your settlement ID and bank
statement showing the received amount. Resolution typically takes 5-7
business days.
"""

# ── Strategy 1: Fixed-size chunking ───────────────────────────────────
def fixed_size_chunks(text, chunk_size=200, overlap=40):
    words  = text.split()
    chunks = []
    i = 0
    while i < len(words):
        chunk = ' '.join(words[i:i+chunk_size])
        chunks.append(chunk)
        i += chunk_size - overlap
    return chunks

fixed_chunks = fixed_size_chunks(stripe_doc)
print(f"Fixed-size chunking: {len(fixed_chunks)} chunks")
for i, c in enumerate(fixed_chunks[:2]):
    print(f"  Chunk {i+1} ({len(c.split())} words): '{c[:80]}...'")

# ── Strategy 2: Recursive character splitting ─────────────────────────
def recursive_split(text, max_tokens=150, separators=['

', '
', '. ', ' ']):
    if len(text.split()) <= max_tokens:
        return [text.strip()] if text.strip() else []
    for sep in separators:
        parts = text.split(sep)
        if len(parts) > 1:
            chunks = []
            current = ''
            for part in parts:
                candidate = current + sep + part if current else part
                if len(candidate.split()) <= max_tokens:
                    current = candidate
                else:
                    if current:
                        chunks.append(current.strip())
                    current = part
            if current:
                chunks.append(current.strip())
            return [c for c in chunks if c]
    return [text.strip()]

recursive_chunks = recursive_split(stripe_doc)
print(f"
Recursive splitting: {len(recursive_chunks)} chunks")
for i, c in enumerate(recursive_chunks[:2]):
    print(f"  Chunk {i+1} ({len(c.split())} words): '{c[:80]}...'")

# ── Strategy 3: Semantic chunking ─────────────────────────────────────
def semantic_chunks(text, embedder, threshold=0.3):
    sentences = [s.strip() for s in re.split(r'(?<=[.!?])s+', text) if s.strip()]
    if len(sentences) <= 1:
        return sentences

    embs = embedder.encode(sentences)

    # Compute cosine similarity between adjacent sentences
    chunks, current = [], [sentences[0]]
    for i in range(1, len(sentences)):
        sim = np.dot(embs[i-1], embs[i]) / (
            np.linalg.norm(embs[i-1]) * np.linalg.norm(embs[i]) + 1e-8
        )
        if sim < threshold:   # low similarity = topic change = new chunk
            chunks.append(' '.join(current))
            current = [sentences[i]]
        else:
            current.append(sentences[i])
    if current:
        chunks.append(' '.join(current))
    return chunks

embedder       = SentenceTransformer('all-MiniLM-L6-v2')
semantic       = semantic_chunks(stripe_doc, embedder, threshold=0.4)
print(f"
Semantic chunking: {len(semantic)} chunks")
for i, c in enumerate(semantic[:2]):
    print(f"  Chunk {i+1} ({len(c.split())} words): '{c[:80]}...'")

print(f"
Chunk count comparison:")
print(f"  Fixed-size:   {len(fixed_chunks)}")
print(f"  Recursive:    {len(recursive_chunks)}")
print(f"  Semantic:     {len(semantic)}")
Storing and searching embeddings

FAISS, Chroma, and Pinecone — which vector database to use

A vector database stores embedding vectors and supports approximate nearest neighbour (ANN) search — finding the k most similar vectors to a query vector in milliseconds, even across millions of documents. Every RAG system uses one.

FAISS
In-memory library · Meta
Scale: Up to ~10M vectors
Persistence: Manual (save/load index)
Metadata: No — external dict needed
Cost: Free, open source
Prototyping, small-medium production, embedded in Python apps.
Chroma
Embedded DB · Chroma
Scale: Up to ~1M vectors
Persistence: Automatic (local or server)
Metadata: Yes — rich filtering
Cost: Free, open source
Local development, small production. Best for getting started fast.
Pinecone
Managed cloud DB · Pinecone
Scale: Billions of vectors
Persistence: Automatic (cloud)
Metadata: Yes — full filtering
Cost: Free tier, then paid
Production at scale. No infrastructure management. Multi-tenant apps.
python
# ── FAISS — in-memory, no dependencies beyond faiss-cpu ─────────────
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
import json, pickle

embedder = SentenceTransformer('all-MiniLM-L6-v2')

# Sample Stripe knowledge base
chunks = [
    {'id': '1', 'text': 'Domestic settlement takes T+2 business days.', 'source': 'settlement-guide'},
    {'id': '2', 'text': 'International settlement takes T+7 business days.', 'source': 'settlement-guide'},
    {'id': '3', 'text': 'Refunds for ACH take 2-3 business days.', 'source': 'refund-policy'},
    {'id': '4', 'text': 'Webhooks retry up to 3 times on failure.', 'source': 'webhook-docs'},
    {'id': '5', 'text': 'Disputes must be responded to within 7 days.', 'source': 'dispute-guide'},
    {'id': '6', 'text': 'Minimum settlement amount is $100.', 'source': 'settlement-guide'},
]

texts = [c['text'] for c in chunks]
embs  = embedder.encode(texts, normalize_embeddings=True)

dim   = embs.shape[1]
index = faiss.IndexFlatIP(dim)
index.add(embs)

# Save index and metadata
faiss.write_index(index, '/tmp/stripe.index')
with open('/tmp/stripe_meta.pkl', 'wb') as f:
    pickle.dump(chunks, f)
print(f"FAISS index saved: {index.ntotal} vectors")

# Load and search
loaded_index = faiss.read_index('/tmp/stripe.index')
with open('/tmp/stripe_meta.pkl', 'rb') as f:
    loaded_chunks = pickle.load(f)

def faiss_search(query, k=3):
    q = embedder.encode([query], normalize_embeddings=True)
    scores, idxs = loaded_index.search(q, k)
    return [(loaded_chunks[i], float(s)) for s, i in zip(scores[0], idxs[0])]

# ── Chroma — persistent, metadata filtering ───────────────────────────
try:
    import chromadb

    client     = chromadb.PerforceClient(path='/tmp/stripe_chroma')
    collection = client.get_or_create_collection(
        name='stripe_kb',
        metadata={'hnsw:space': 'cosine'},
    )

    # Add documents with metadata
    collection.add(
        ids       = [c['id'] for c in chunks],
        documents = [c['text'] for c in chunks],
        metadatas = [{'source': c['source']} for c in chunks],
    )

    # Query with metadata filter
    results = collection.query(
        query_texts=['How long does settlement take?'],
        n_results=3,
        where={'source': 'settlement-guide'},   # filter by source
    )
    print(f"
Chroma results (filtered to settlement-guide):")
    for doc, dist in zip(results['documents'][0], results['distances'][0]):
        print(f"  [{1-dist:.3f}] {doc}")
except ImportError:
    print("
chromadb not installed: pip install chromadb")

# ── FAISS search test ─────────────────────────────────────────────────
print("
FAISS search results:")
for chunk, score in faiss_search("settlement timeline", k=3):
    print(f"  [{score:.3f}] {chunk['text']}")
Putting it all together

Complete RAG system — Stripe knowledge base Q&A

The RAG prompt — structure matters as much as retrieval
System prompt:
"You are a Stripe support assistant. Answer questions using ONLY the provided context. If the answer is not in the context, say 'I don't have that information in my knowledge base.' Do not make up information."
User prompt:
"Context:\n[chunk 1]\n[chunk 2]\n[chunk 3]\n\nQuestion: {user_question}"

Grounding instruction prevents hallucination. Without it, the LLM will blend retrieved context with its own (potentially wrong) training knowledge.

python
import numpy as np
import faiss
import pickle
from sentence_transformers import SentenceTransformer

# ── Complete RAG pipeline from scratch ────────────────────────────────

class RAGPipeline:
    def __init__(self, embedding_model='all-MiniLM-L6-v2'):
        self.embedder = SentenceTransformer(embedding_model)
        self.index    = None
        self.chunks   = []

    # ── INDEXING ──────────────────────────────────────────────────────
    def add_documents(self, documents: list[dict], chunk_size=100):
        """
        documents: list of {'title': str, 'text': str, 'source': str}
        Chunks each document and adds to FAISS index.
        """
        all_chunks = []
        for doc in documents:
            words  = doc['text'].split()
            overlap = 20
            i = 0
            while i < len(words):
                chunk_text = ' '.join(words[i:i+chunk_size])
                all_chunks.append({
                    'text':   chunk_text,
                    'source': doc.get('source', ''),
                    'title':  doc.get('title', ''),
                })
                i += chunk_size - overlap

        self.chunks = all_chunks
        texts = [c['text'] for c in all_chunks]
        embs  = self.embedder.encode(texts, normalize_embeddings=True,
                                      show_progress_bar=False)

        dim         = embs.shape[1]
        self.index  = faiss.IndexFlatIP(dim)
        self.index.add(embs)
        print(f"Indexed {len(all_chunks)} chunks from {len(documents)} documents")

    # ── RETRIEVAL ─────────────────────────────────────────────────────
    def retrieve(self, query: str, k: int = 3) -> list[dict]:
        if self.index is None:
            raise RuntimeError("No documents indexed. Call add_documents() first.")
        q_emb = self.embedder.encode([query], normalize_embeddings=True)
        scores, idxs = self.index.search(q_emb, k)
        return [
            {**self.chunks[i], 'score': float(s)}
            for s, i in zip(scores[0], idxs[0])
            if i >= 0
        ]

    # ── GENERATION ────────────────────────────────────────────────────
    def build_prompt(self, query: str, retrieved: list[dict]) -> str:
        context = '

'.join([
            f"[{i+1}] (Source: {r['source']})
{r['text']}"
            for i, r in enumerate(retrieved)
        ])
        return f"""You are a Stripe support assistant. Answer using ONLY the context below.
If the answer is not in the context, say "I don't have that information."

Context:
{context}

Question: {query}
Answer:"""

    def answer(self, query: str, k: int = 3,
                llm_fn=None) -> dict:
        """
        Full RAG: retrieve + generate.
        llm_fn: callable(prompt) -> str. If None, returns prompt only.
        """
        retrieved = self.retrieve(query, k=k)
        prompt    = self.build_prompt(query, retrieved)

        if llm_fn:
            answer = llm_fn(prompt)
        else:
            # Without LLM — show what would be sent
            answer = "[LLM would generate here — see prompt below]"

        return {
            'query':     query,
            'retrieved': retrieved,
            'prompt':    prompt,
            'answer':    answer,
        }

# ── Build the Stripe knowledge base ────────────────────────────────
rag = RAGPipeline()

knowledge_base = [
    {
        'title':  'Settlement Guide',
        'source': 'settlement-guide',
        'text': (
            "Stripe settles domestic payments within T+2 business days. "
            "T is the day the payment is captured. Weekends and public holidays "
            "are excluded. International payments settle within T+7 business days. "
            "Minimum settlement amount is $100. Amounts below this carry forward. "
            "Currency conversion uses prevailing forex rate on settlement day. "
            "SWIFT charges of USD 15-25 may apply to international settlements."
        ),
    },
    {
        'title':  'Refund Policy',
        'source': 'refund-policy',
        'text': (
            "Refunds are processed within 5-7 business days for credit cards. "
            "ACH and debit card refunds take 2-3 business days. "
            "Stripe charges no fee for processing refunds. "
            "Partial refunds are supported for all payment methods. "
            "Refund status can be tracked in the Stripe Dashboard."
        ),
    },
    {
        'title':  'Dispute Resolution',
        'source': 'dispute-guide',
        'text': (
            "When a customer disputes a payment, Stripe notifies you via webhook. "
            "You have 7 days to submit evidence via the Dashboard. "
            "Required documents: delivery proof, invoice, customer communication. "
            "Failure to respond results in automatic chargeback. "
            "Resolved disputes are settled within 30-45 days."
        ),
    },
    {
        'title':  'Webhook Configuration',
        'source': 'webhook-docs',
        'text': (
            "Webhooks send event notifications to your server URL. "
            "Supported events: payment.captured, payment.failed, refund.created, order.paid. "
            "Webhooks retry up to 3 times on failure with exponential backoff. "
            "Verify webhook signatures using your webhook secret. "
            "Webhook secret is available in Stripe Dashboard > Settings > Webhooks."
        ),
    },
]

rag.add_documents(knowledge_base)

# ── Test queries ───────────────────────────────────────────────────────
test_queries = [
    "How long does international settlement take?",
    "What documents do I need for a dispute?",
    "How many times does a webhook retry?",
    "What is the fee for refunds?",
    "How do I configure SSL certificates?",   # out-of-scope query
]

print("
RAG Pipeline Results:")
print("=" * 60)

for query in test_queries:
    result = rag.answer(query, k=2)
    print(f"
Q: {query}")
    print(f"Retrieved:")
    for r in result['retrieved']:
        print(f"  [{r['score']:.3f}] {r['source']}: {r['text'][:60]}...")
    print(f"Prompt (first 200 chars): {result['prompt'][:200]}...")
Connecting to an LLM

RAG with OpenAI, Groq, and local models — production patterns

python
# ── RAG with different LLM backends ──────────────────────────────────

# ── Option 1: OpenAI API ──────────────────────────────────────────────
def openai_rag(prompt: str, model='gpt-3.5-turbo') -> str:
    """
    pip install openai
    import openai; client = openai.OpenAI(api_key=os.environ['OPENAI_API_KEY'])
    """
    # client = openai.OpenAI()
    # response = client.chat.completions.create(
    #     model=model,
    #     messages=[{'role': 'user', 'content': prompt}],
    #     temperature=0,   # deterministic — important for factual Q&A
    #     max_tokens=300,
    # )
    # return response.choices[0].message.content
    return "[OpenAI response]"

# ── Option 2: Groq API (fast, free tier) ─────────────────────────────
def groq_rag(prompt: str, model='openai/gpt-oss-120b') -> str:
    """
    pip install groq
    Get free API key from console.groq.com
    """
    # from groq import Groq
    # client  = Groq(api_key=os.environ['GROQ_API_KEY'])
    # response = client.chat.completions.create(
    #     model=model,
    #     messages=[
    #         {'role': 'system', 'content': 'Answer using only the provided context.'},
    #         {'role': 'user',   'content': prompt},
    #     ],
    #     temperature=0,
    #     max_tokens=300,
    # )
    # return response.choices[0].message.content
    return "[Groq response]"

# ── Option 3: Local model with Ollama ─────────────────────────────────
def ollama_rag(prompt: str, model='llama3') -> str:
    """
    Install: https://ollama.com
    Run:     ollama pull llama3
    pip install ollama
    """
    # import ollama
    # response = ollama.chat(
    #     model=model,
    #     messages=[{'role': 'user', 'content': prompt}],
    # )
    # return response['message']['content']
    return "[Ollama response]"

# ── Production RAG with streaming ─────────────────────────────────────
def rag_with_streaming(rag_pipeline, query, llm_client=None):
    """
    Stream the answer token by token — better UX than waiting for full response.
    """
    retrieved = rag_pipeline.retrieve(query, k=3)
    prompt    = rag_pipeline.build_prompt(query, retrieved)

    print(f"Q: {query}")
    print(f"Retrieved {len(retrieved)} chunks (top score: {retrieved[0]['score']:.3f})")
    print(f"Answer: ", end='', flush=True)

    # With OpenAI streaming:
    # stream = client.chat.completions.create(
    #     model='gpt-3.5-turbo',
    #     messages=[{'role': 'user', 'content': prompt}],
    #     stream=True,
    # )
    # for chunk in stream:
    #     if chunk.choices[0].delta.content:
    #         print(chunk.choices[0].delta.content, end='', flush=True)
    # print()

    print("[streaming answer would appear here token by token]")
    return {'query': query, 'sources': [r['source'] for r in retrieved]}

# ── Citation tracking — which sources were used ───────────────────────
def rag_with_citations(rag_pipeline, query, k=3):
    """
    Return answer with source citations — important for trust and debugging.
    """
    retrieved = rag_pipeline.retrieve(query, k=k)

    # Add citation markers to context
    context_with_refs = '

'.join([
        f"[{i+1}] {r['text']}"
        for i, r in enumerate(retrieved)
    ])

    prompt = f"""Answer the question using the numbered sources below.
After your answer, list which source numbers [1], [2], [3] you used.

{context_with_refs}

Question: {query}
Answer (with citations):"""

    return {
        'query':   query,
        'prompt':  prompt,
        'sources': [{'id': i+1, 'source': r['source'], 'text': r['text'][:100]}
                    for i, r in enumerate(retrieved)],
    }

# Test citation RAG
from sentence_transformers import SentenceTransformer
import faiss

# Rebuild rag (in a real app this would be the persistent rag object)
rag2 = RAGPipeline()
rag2.add_documents(knowledge_base)

result = rag_with_citations(rag2, "What is the settlement timeline for international payments?")
print(f"
Citation RAG:")
print(f"  Sources retrieved:")
for s in result['sources']:
    print(f"    [{s['id']}] {s['source']}: {s['text']}...")
Errors you will hit

Every common RAG mistake — explained and fixed

RAG retrieves the wrong chunks — relevant documents exist but are not returned
Why it happens

Embedding model mismatch: the query embedding and document embeddings were produced by different models, or different normalisation was applied. Also caused by chunking that splits key information across chunk boundaries — a question about settlement timelines retrieves a chunk that mentions settlement but not the timeline. Or the query is too short and lacks enough signal for semantic search.

Fix

Always use the same embedding model for indexing and querying — never mix models. Use normalise_embeddings=True consistently for cosine similarity. For short queries, expand them with hypothetical document embeddings (HyDE): generate a hypothetical answer with the LLM, embed that, search with it — produces much richer query embeddings. Add overlap between chunks (20%) to prevent key information from being split.

LLM ignores retrieved context and answers from training knowledge instead
Why it happens

The system prompt does not strongly enough instruct the LLM to use only the provided context. LLMs default to their training knowledge when the instruction is weak ('use the following context') vs strong ('answer ONLY using the context below — if the answer is not there, say you do not know'). Also caused by temperature > 0 — higher temperature increases creativity and hallucination.

Fix

Use a strong grounding instruction: 'Answer ONLY using the context. Do not use any other knowledge.' Set temperature=0 for factual Q&A — this makes the model deterministic and less likely to hallucinate. Add a fallback instruction: 'If the answer is not in the context, say exactly: I do not have that information in my knowledge base.' Test with questions that have no answer in the knowledge base to verify the model correctly refuses.

FAISS search returns wrong results after saving and reloading the index
Why it happens

The document metadata (chunk texts, sources) was not saved alongside the FAISS index. FAISS only stores the float vectors — it has no knowledge of what each vector represents. After reloading, the index returns integer indices but you have no mapping from index position back to document text. Also: FAISS IndexFlatIP vs IndexFlatL2 mismatch between save and load.

Fix

Always save metadata separately alongside the FAISS index: faiss.write_index(index, 'index.faiss') and pickle.dump(chunks, open('chunks.pkl','wb')). Load both together. Always use the same index type (IndexFlatIP for normalised cosine, IndexFlatL2 for Euclidean). Consider using Chroma instead — it handles metadata persistence automatically.

RAG answers are correct but include information not in the retrieved chunks
Why it happens

The LLM is blending retrieved context with its own training knowledge. The generation step is not strictly grounded. Happens with models that have strong priors about the topic (e.g. GPT-4 knows a lot about payments) and will fill in gaps from memory even when instructed not to. Also: context window is too large — with many retrieved chunks the model loses track of the grounding instruction.

Fix

Reduce k (number of retrieved chunks) to 2-3 and ensure each chunk is directly relevant. Add post-processing: after generation, check each factual claim against the retrieved chunks — if a claim is not in any chunk, flag it as potentially hallucinated. Use a smaller, less opinionated model for grounded Q&A. Explicitly state 'every fact in your answer must appear verbatim or paraphrased from the context.'

What this looks like at work

Running RAG in production — re-indexing pipelines, embedding versions, and quality drift

The RAG pipeline built in this module runs once: load documents, embed, index, query. A production knowledge base changes constantly — new support articles, edited pricing pages, deprecated policies — and the index has to keep up without ever serving a query against a stale or partially-rebuilt index. The two problems that dominate a real RAG deployment are keeping the index fresh without downtime, and knowing when retrieval quality has quietly degraded, because nothing in the pipeline itself raises an alarm when it starts returning worse chunks.

Blue-green re-indexing — never serve queries against a half-updated index
1. Documentation changesA support article is edited or added in the source system (CMS, Notion, internal wiki).
2. Re-index into a fresh indexA new vector index is built from scratch in the background — the live index keeps serving traffic unaffected.
3. Validate before switchingThe new index is checked against a fixed eval set of known query-to-expected-chunk pairs before it goes live.
4. Atomic swapTraffic is pointed at the new index only after it passes validation — the old index is kept around for instant rollback.

This is the same blue-green deployment pattern used for any production database migration — never mutate the live index in place, always build the replacement alongside it and cut over atomically.

Embedding model version mismatches are the single most common way a re-index pipeline silently breaks retrieval. Every chunk in the vector store has to be embedded by the exact same model version as the one embedding incoming queries — a cosine similarity computed between a vector from embedding model version A and a vector from version B is meaningless, even if both models have the same output dimension, because each model's vector space encodes similarity differently. Teams that upgrade an embedding model without re-embedding the entire existing index get a RAG system that still runs without errors and just quietly returns worse and worse retrieval results.

python
from datetime import datetime

# ── Tag every stored vector with the embedding model that produced it ──
# This is what actually prevents the version-mismatch failure mode above.

EMBEDDING_MODEL_VERSION = 'all-MiniLM-L6-v2@v2024.1'

def embed_and_tag(embedder, chunks: list) -> list:
    texts = [c['text'] for c in chunks]
    vectors = embedder.encode(texts, normalize_embeddings=True)
    tagged = []
    for chunk, vec in zip(chunks, vectors):
        tagged.append({
            **chunk,
            'vector':            vec,
            'embedding_version': EMBEDDING_MODEL_VERSION,
            'indexed_at':        datetime.utcnow().isoformat(),
        })
    return tagged

def query_with_version_check(query_embedder, query_text: str, index_version: str):
    """Refuse to search if the query embedder doesn't match the index's embedding version."""
    current_version = EMBEDDING_MODEL_VERSION
    if current_version != index_version:
        raise RuntimeError(
            f"Embedding version mismatch: index was built with '{index_version}', "
            f"query embedder is '{current_version}'. Re-index required before serving queries."
        )
    return query_embedder.encode([query_text], normalize_embeddings=True)

# ── Retrieval quality monitoring — catch drift before users complain ──
class RetrievalQualityMonitor:
    def __init__(self, eval_queries: list):
        # eval_queries: [{'query': ..., 'expected_chunk_id': ...}, ...] — a fixed labelled set
        self.eval_queries    = eval_queries
        self.daily_recall    = {}   # date -> recall@k on the eval set
        self.daily_avg_score = {}   # date -> average top-1 similarity score on live traffic

    def run_daily_eval(self, retrieve_fn, k: int = 5) -> float:
        hits = 0
        for case in self.eval_queries:
            results = retrieve_fn(case['query'], k=k)
            retrieved_ids = [r['id'] for r in results]
            if case['expected_chunk_id'] in retrieved_ids:
                hits += 1
        recall = hits / len(self.eval_queries)
        today = datetime.utcnow().date().isoformat()
        self.daily_recall[today] = recall
        return recall

    def log_live_query_score(self, top_score: float):
        today = datetime.utcnow().date().isoformat()
        self.daily_avg_score.setdefault(today, []).append(top_score)

    def check_for_drift(self, window_days: int = 7, drop_threshold: float = 0.1) -> bool:
        """Flag if recall@k has dropped meaningfully vs the trailing window."""
        dates = sorted(self.daily_recall.keys())
        if len(dates) < window_days + 1:
            return False
        baseline = sum(self.daily_recall[d] for d in dates[-window_days-1:-1]) / window_days
        today_recall = self.daily_recall[dates[-1]]
        dropped = (baseline - today_recall) > drop_threshold
        if dropped:
            print(f"DRIFT ALERT: recall@k dropped from {baseline:.3f} to {today_recall:.3f} "
                  f"— check for a bad re-index, stale documents, or an embedding version mismatch")
        return dropped

print("RetrievalQualityMonitor: runs the fixed eval set daily, tracks recall@k over time,")
print("and alerts when retrieval quality drops — catching drift before it shows up as")
print("customer-visible wrong answers.")

Live traffic monitoring closes the gap a fixed eval set cannot: real user queries are messier and more varied than any hand-built eval set, so production RAG systems also track the raw similarity score of the top retrieved chunk across all live queries, not just the labelled ones. A sustained drop in average top-1 score — even with no code change and no re-index — is often the first signal that the underlying document corpus has drifted away from what the index actually contains.

Misconceptions

Five things people get wrong about RAG

Myth: RAG eliminates hallucination

RAG reduces hallucination by grounding generation in retrieved text, but it does not make hallucination structurally impossible. Two separate failure modes survive it: the LLM can still blend retrieved context with its own training-time priors and state something not actually in the context (the "LLM ignores retrieved context" error covered above), and if retrieval itself returns the wrong chunks, the model can generate a perfectly well-grounded, perfectly confident answer to the wrong information. Grounding instructions, temperature=0, and citation tracking narrow the gap; none of them close it completely.

Myth: RAG and fine-tuning are competing techniques — you pick one

They solve different problems and are frequently used together. RAG supplies fresh, private, or frequently-changing facts at query time without retraining — update the documents and the answers update instantly. Fine-tuning changes the model's behaviour: its output format, tone, task-following ability, or domain vocabulary — none of which retrieval can fix, because injecting more context does not teach a model to reliably output JSON or adopt a specific persona. A production support bot commonly does both: a fine-tuned (or well-prompted) model for consistent tone and format, fed retrieved context for the actual facts.

Myth: bigger chunks (more context per chunk) always improve answer quality

More context sounds strictly safer, but oversized chunks dilute the signal the retriever is trying to match on — a 2000-token chunk covering five topics gets a mediocre similarity score against a query about any one of them, and even when it is retrieved, the specific answer is buried in surrounding noise the LLM has to sift through. Chunking quality is about matching chunk boundaries to semantic units — one chunk, one answerable concept — not about maximising size. Overlap (10-20%) exists specifically to solve the opposite problem (a fact split across a chunk boundary), not as a reason to make every chunk larger.

Myth: if retrieved context contradicts the model's training knowledge, the model will always defer to the provided context

This is exactly what a strong grounding instruction is trying to force, but it is not guaranteed behaviour — it is a tug-of-war between the prompt's instruction and the model's parametric priors, and the priors can win, especially on topics the model has strong, widely-corroborated training signal about. A model that "knows" a well-known public fact may still surface it even when a retrieved chunk explicitly states your organisation's different internal policy. This is precisely why production RAG systems test with adversarial queries where the retrieved context deliberately conflicts with common knowledge, and why citation tracking exists — to make it possible to catch, after the fact, when the model answered from memory instead of from the sources it was given.

Myth: semantic (vector) search alone is sufficient — keyword search is obsolete

Embedding similarity captures topical and semantic closeness well, but it is weak on exactly the things keyword/lexical search is strong at: exact identifiers, product SKUs, error codes, numbers, negation ("not eligible" embeds close to "eligible"), and rare domain jargon that the embedding model under-represents. This is why production retrieval systems increasingly use hybrid search — combining a sparse method like BM25 with dense vector search and merging the results (often with reciprocal rank fusion) — rather than relying on vector similarity alone. "Garbage in, garbage out" applies here directly: no amount of LLM quality compensates for a retriever that missed the one chunk with the exact number the user asked about.

Interview prep

RAG — 5 questions interviewers actually ask

Q1 — RAG vs fine-tuning: when would you choose each, and could you use both?

Choose RAG when the model needs access to information that is private, large, or changes frequently — a knowledge base, product catalogue, or policy document — because retrieval lets you update the source documents and get updated answers with zero retraining. Choose fine-tuning when the problem is about the model's behaviour: getting it to reliably follow a specific output format, adopt a domain vocabulary, or perform a task pattern it does not do well zero-shot — none of which more context in the prompt reliably fixes. In practice the strongest systems combine both: a model fine-tuned (or carefully prompted) for consistent tone, format, and refusal behaviour, fed retrieved context at query time for the facts. I'd push back on a framing that treats them as alternatives — they operate on different axes of the problem.

Q2 — Why does chunk size and overlap matter so much for RAG quality?

Chunk size sets a trade-off between two failure modes on either side of it. Too small and a retrieved chunk lacks the surrounding context needed to make sense of it — you get back a sentence fragment that is technically similar to the query but unusable on its own. Too large and the chunk's embedding becomes an average over multiple unrelated ideas, which both hurts retrieval precision (the chunk scores lower against any single specific query) and, even when retrieved, buries the actual answer in surrounding text the LLM has to parse through. Overlap exists to solve a third, orthogonal failure: a single answerable fact sitting exactly on a chunk boundary and being split in half, so neither resulting chunk contains the complete answer. 10-20% overlap costs some storage and redundancy but meaningfully reduces boundary-split failures.

Q3 — Explain 'garbage in, garbage out' for RAG — how does retrieval quality bound generation quality?

The LLM in a RAG system only sees what retrieval hands it — it has no independent channel back to the source documents to double-check or search further on its own. If the retriever returns the wrong chunks (wrong topic, outdated version, or simply missed the one relevant passage), the generation step is working from bad input no matter how capable the underlying model is; a stronger LLM will produce a more fluent and confident wrong answer, not a correct one. This is why, when a RAG system misbehaves, the very first debugging step should always be printing what was actually retrieved for that query, before looking at the generation step at all — in my experience the large majority of RAG failures are retrieval failures wearing a generation-failure costume.

Q4 — What happens when retrieved context contradicts the model's parametric knowledge, and how do you handle it?

There is no hard guarantee the model defers to the retrieved context — it is a probabilistic contest between the prompt's grounding instruction and whatever the model learned during pretraining, and strongly-held training priors (widely known facts) can win even against an explicit "use only the context below" instruction. You mitigate this at multiple levels: a strong, unambiguous grounding instruction and temperature=0 as the baseline; testing the system specifically with adversarial cases where retrieved context intentionally conflicts with common knowledge, to measure how often it fails; and citation tracking in production, so that when the model's claim doesn't match any cited source you can flag it automatically rather than trusting the answer at face value.

Q5 — A RAG system is giving wrong answers. Where do you look first, and why?

Retrieval, before generation, essentially every time. Print the actual chunks that were retrieved for the failing query and their similarity scores — often the answer is immediately visible: the right chunk wasn't in the top-k, the embedding model used for the query differs from the one used at indexing time, or the chunk exists but is missing the specific detail because of a bad chunk boundary. Only once you've confirmed the correct information was actually retrieved and handed to the model does it make sense to suspect the generation step — a weak grounding instruction, too high a temperature, or the model blending in its own priors. Debugging generation first, before verifying retrieval, is the most common wasted-effort mistake I see people make on RAG systems.

What comes next

You can build a RAG system. Next: get better answers by engineering better prompts.

RAG handles the retrieval problem — getting relevant context into the LLM's window. But the quality of the generated answer also depends heavily on how the prompt is structured. Zero-shot, few-shot, chain-of-thought, ReAct — each prompting pattern consistently improves LLM outputs for different task types. Module 53 covers the patterns that actually work in production with real before/after examples.

Next — Module 53 · NLP
Prompt Engineering

Zero-shot, few-shot, chain-of-thought, ReAct — the patterns that consistently improve LLM outputs, with real before/after examples for every technique.

Start →

🎯 Key Takeaways

  • RAG gives an LLM access to documents it has never seen without any training. The pipeline has two phases: indexing (chunk documents → embed → store in vector DB, run once) and querying (embed question → vector search → retrieve top-k chunks → inject into LLM prompt → generate answer, runs on every request).
  • Chunking is the single biggest lever in RAG quality. Fixed-size chunking is simple but breaks semantic boundaries. Recursive character splitting is the practical default. Semantic chunking (split where embedding similarity drops) produces the best retrieval quality. Use 500–1000 tokens per chunk with 10–20% overlap to prevent key information from being split.
  • FAISS is the standard in-memory vector library for small-to-medium datasets. Chroma adds metadata filtering and automatic persistence. Pinecone is managed cloud for production scale. Always use the same embedding model and normalisation at index time and query time — mismatches silently produce wrong retrieval results.
  • The RAG prompt must include a strong grounding instruction: "Answer ONLY using the context below. If the answer is not in the context, say you do not have that information." Without this the LLM blends retrieved context with its own training knowledge and hallucinates. Set temperature=0 for factual Q&A.
  • Most RAG failures are retrieval failures not generation failures. If the LLM gives wrong answers, check what was retrieved first — print the top-k chunks. HyDE (Hypothetical Document Embeddings) improves retrieval for short or ambiguous queries: generate a hypothetical answer first, embed that, use it as the search vector.
  • Add citation tracking in production: number the retrieved chunks in the prompt and ask the LLM to cite which sources it used in its answer. This makes hallucination visible — if the LLM cites source [3] but source [3] does not contain the claimed fact, it hallucinated. Enables automatic fact-checking post-generation.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...