Retrieval-
Augmented
Generation
≈ 8 min readq → [0.031, −0.048,
0.072, 0.006, …]
top-k = 3
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 ↓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.
It hallucinates
Asked about something it never saw, a model doesn't stop. It predicts the most plausible-sounding answer, true or not.
It goes stale
Training stops on a cutoff date. Prices, policies and APIs that changed afterwards simply don't exist to the model.
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.
Same question. Different footing.
Annual plans can be refunded within 60 days of purchase, no questions asked.
source: whatever it remembers
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.
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
Querying · every question
question
→ answer
Six stages, from documents to answers
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 50def 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())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 dimsfrom 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)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 chunksimport 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(),
)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 · cosineQUERY_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?")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 tokensdef 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)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 sourcesimport 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?"))The pipeline is simple. Getting it right isn't.
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.
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.
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.
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.
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.
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.
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.