Python · 6 stages
≈ 8 min read

Teach a language model tolook it upbefore it answers.

LLMs hallucinate, forget and go stale. RAG retrieves the right facts first, then generates an answer grounded in them.

Start the walkthrough ↓
Built by Your NameScroll ↓
The problem

Why models need to look things up

A language model only knows what it read during training. Ask it about anything else and it answers anyway, from memory, with total confidence.

i.

It hallucinates

Asked about something it never saw, a model doesn't stop. It predicts the most plausible-sounding answer, true or not.

ii.

It goes stale

Training stops on a cutoff date. Prices, policies and APIs that changed afterwards simply don't exist to the model.

iii.

It can't see your data

Your handbook, tickets and docs were never in its training set. Without help, it can only guess what they say.

The idea in one picture

Same question. Different footing.

RAG doesn't make the model smarter. It changes what the model is looking at when it answers.
Without RAGConfident · wrong
What's the refund window for annual plans?

Annual plans can be refunded within 60 days of purchase, no questions asked.

source: whatever it remembers

With RAGGrounded · cited
What's the refund window for annual plans?

Annual plans are refundable in full within 30 days of purchase1; after that, refunds are prorated by unused months2.

  • [1]billing-policy.md §4 — Annual plans are refundable in full within 30 days of purchase.
  • [2]faq/refunds.md §1 — After 30 days, refunds are prorated by the number of unused months.
The whole picture

Two pipelines, one shared memory

Indexing turns your documents into a searchable vector index, once. Querying uses that index every time someone asks.

Indexing runs once: documents are chunked, embedded and stored in a vector index. Querying runs on every question: the question is embedded with the same model, the nearest chunks are retrieved from that index, added to the prompt, and the model generates a cited answer.

Indexing · once

documents

Split documents into small, overlapping pieces.

Turn each chunk into a vector that captures its meaning.

The shared memory. Written once by indexing, read on every question.

Querying · every question

question

Embed the question with the same model and fetch the nearest chunks.

Put those chunks in the prompt, numbered so they can be cited.

The model answers from the sources and cites them.

→ answer

The pipeline

Six stages, from documents to answers

Each stage up close: what it does, the Python that does it, and a figure that shows it working.
01 / CHUNK

Cut documents into pieces a model can use

Whole documents are too long to search or to fit in a prompt. So we slice them into small, overlapping chunks. The overlap keeps a sentence that straddles a boundary from losing its meaning.

size 500 · overlap 50
01_chunk.py
def chunk_text(text: str, size: int = 500, overlap: int = 50) -> list[str]:
    """Split text into overlapping windows of `size` characters."""
    chunks = []
    step = size - overlap
    for start in range(0, max(len(text) - overlap, 1), step):
        chunk = text[start : start + size].strip()
        if chunk:
            chunks.append(chunk)
    return chunks


with open("handbook.md") as f:
    chunks = chunk_text(f.read())
02 / EMBED

Turn meaning into numbers

An embedding model reads each chunk and outputs a vector: a list of numbers that captures what the text is about. Chunks that mean similar things get vectors that point in similar directions.

384 dims
02_embed.py
from sentence_transformers import SentenceTransformer

# Small, fast, open-source embedding model. Runs locally.
model = SentenceTransformer("BAAI/bge-small-en-v1.5")

embeddings = model.encode(
    chunks,
    normalize_embeddings=True,  # unit length, so dot product = cosine
    batch_size=64,
)

print(embeddings.shape)  # (1248, 384)
03 / STORE

Index the vectors for fast lookup

A vector store keeps every chunk next to its vector and builds an index, so finding the nearest neighbours of a new vector takes milliseconds instead of comparing against everything.

n = 1,248 chunks
03_store.py
import chromadb

client = chromadb.PersistentClient(path="./index")
collection = client.get_or_create_collection(
    name="handbook",
    configuration={"hnsw": {"space": "cosine"}},
)

collection.add(
    ids=[f"chunk_{i}" for i in range(len(chunks))],
    documents=chunks,
    embeddings=embeddings.tolist(),
)
04 / RETRIEVE

Find the chunks closest to the question

When a question arrives, we embed it with the same model and ask the store for the top-k most similar chunks by cosine similarity. These are the facts the answer will rest on.

top-k = 3 · cosine
04_retrieve.py
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "


def retrieve(question: str, k: int = 3) -> list[str]:
    """Return the k chunks closest in meaning to the question."""
    q = model.encode(QUERY_PREFIX + question, normalize_embeddings=True)
    results = collection.query(
        query_embeddings=[q.tolist()],
        n_results=k,
    )
    return results["documents"][0]


contexts = retrieve("What's the refund window for annual plans?")
05 / AUGMENT

Put the evidence in the prompt

The retrieved chunks are numbered and placed in front of the question, with instructions to answer only from them and to cite each claim. The model now reads your documents, not just its memory.

≈ 1.9k tokens
05_augment.py
def build_prompt(question: str, contexts: list[str]) -> str:
    """Number the chunks and put them in front of the question."""
    sources = "\n\n".join(
        f"[{i}] {chunk}" for i, chunk in enumerate(contexts, start=1)
    )
    return (
        "Answer the question using only the sources below.\n"
        "Cite every claim like [1]. If the sources don't contain\n"
        "the answer, say you don't know.\n\n"
        f"Sources:\n{sources}\n\n"
        f"Question: {question}"
    )


prompt = build_prompt("What's the refund window for annual plans?", contexts)
06 / GENERATE

Answer, with receipts

The LLM writes the answer from the sources it was given and cites them. If the sources don't cover the question, a well-instructed model says so instead of guessing.

cited: 2 sources
06_generate.py
import anthropic

llm = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY


def answer(question: str) -> str:
    prompt = build_prompt(question, retrieve(question))
    response = llm.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=4096,
        messages=[{"role": "user", "content": prompt}],
    )
    return "".join(b.text for b in response.content if b.type == "text")


print(answer("What's the refund window for annual plans?"))
Where it goes wrong

The pipeline is simple. Getting it right isn't.

Most RAG failures aren't the model's fault. They come from what was retrieved, or what wasn't. These are the ones you meet first.
01

Bad chunk boundaries

A fixed-size cut splits a table or a key sentence in half, so neither chunk carries the full fact.

Fix: Split on structure (headings, paragraphs) first, then size. Keep some overlap.

02

Close, but not relevant

Vector search returns text that sounds similar but doesn't answer the question, and the model uses it anyway.

Fix: Retrieve more candidates, then rerank them with a cross-encoder before keeping the top few.

03

Exact terms get missed

Embeddings blur product codes, names and error strings, so a query for “ERR_4021” finds nothing useful.

Fix: Hybrid search: combine keyword (BM25) scores with vector scores.

04

A stale index

Documents change but their vectors don't, so answers quote last quarter's policy.

Fix: Re-embed on change and track a version or timestamp per chunk.

05

Too much context

Stuffing twenty chunks into the prompt buries the right one and drives up cost and latency.

Fix: Keep k small, order by relevance, and compress or drop near-duplicates.

06

Answers that ignore the sources

The model falls back on what it already believes, and nobody notices because nothing is measured.

Fix: Evaluate both halves: retrieval recall on a labelled set, and faithfulness of answers to their sources.

Glossary

Ten words, in plain English

Embedding
A vector that represents the meaning of a piece of text, produced by an embedding model.
Vector
An ordered list of numbers. Here, 384 of them per chunk.
Vector store
A database that stores vectors and finds the nearest ones to a query quickly.
Chunk
A small, overlapping slice of a source document, sized to search and to fit in a prompt.
Cosine similarity
How closely two vectors point in the same direction, from −1 to 1. Higher means more alike.
Top-k
How many of the most similar chunks to retrieve for a question.
Reranking
A second, more precise pass that reorders retrieved chunks by true relevance.
Hybrid search
Combining keyword matching with vector similarity so exact terms aren't lost.
Context window
The maximum amount of text a model can read in one request, measured in tokens.
Grounding
Tying an answer to specific sources it can cite. The opposite of hallucination.