How RAG Works, Visually

The mental model behind chunks, retrieval, and grounded answers

Pranjal Rawat

The problem, and the fix

Ask a plain language model a question about documents it has never seen, and it will still answer. Confidently, and often wrong.

Retrieval-augmented generation is the fix: look the answer up before writing it.

Ask it something it has never seen

A plain model has no way to check itself against real documents. It fills the gap with whatever is most probable, not what is true.

"How many days of PTO do I get?" language model, alone "15 days" "20 days" "not sure" Three answers, one question

Same question, three different answers, and no way to tell which one is real.

Meet Aurora Cloud

Aurora Cloud is the fictional company behind every example in this deck: 48 documents across four topics.

HR PTO, expenses, leave policy 12 docs PRODUCT pricing, specs, release notes 12 docs ENGINEERING runbooks, architecture 12 docs OFF-TOPIC unrelated pages 12 docs 48 documents - exactly what the model above never saw

What retrieval changes

Retrieval-augmented generation adds one step in front of writing: look before you answer.

question search the corpus retrieved chunk [1] grounded answer Look the answer up, then write it

The answer is no longer memory. It is a copy of what the corpus actually says.

The rule: cite or refuse

Grounding is a contract, not a suggestion. A covered question gets a citation. An uncovered one gets a decline, never a guess.

any question covered by a chunk -> answer + [1] not covered -> decline, no guess Cite or refuse - never a bare guess

A refusal is a correct answer too, when the corpus genuinely doesn’t cover it.

Cite-or-refuse, the contract behind every answer this deck builds toward: a claim traces to a retrieved chunk and names it with [n], or the system declines instead of guessing.

Retrieval turns a prompt into evidence; generation must cite it or refuse. Every concept from here on is one piece of making that contract real.

The Problem, and the Fix

The Problem, and the Fix

The Problem, and the Fix

The Problem, and the Fix

The Problem, and the Fix

Two clocks, one system

Building the index and answering a question are two different jobs, on two different clocks.

One runs once, ahead of time. The other runs every time someone types a question.

Two different jobs, two different clocks: the offline build (chunk, embed, index) happens once; the online loop (retrieve, augment, generate) reruns for every question.

The corpus becomes a frozen index

The raw documents get chunked and turned into vectors a single time. After that, the index just sits there, ready to be searched.

OFFLINE · RUNS ONCE corpus chunk chunks embed + fit index A batch job. Nothing here is waiting on a question.

Every question walks the same four steps

A question comes in, gets turned into a search, and the results get folded into a prompt before the model ever runs.

ONLINE · RUNS PER QUESTION query search index retrieve build prompt augment call model generate Same four steps, every single time someone asks.

Same shape, very different rhythm

Put both loops side by side and the contrast is the whole point: one column runs once, the other runs again for every question that comes in.

OFFLINE ONLINE corpus chunk chunks embed + fit index a batch job, ahead of time query search index retrieve build prompt augment call model generate runs again for every new question next question

This is the whole map

Everything from here on zooms into one box from this picture: how retrieval actually scores a match, how the prompt gets assembled, how the model is kept from making things up.

The whole pipeline, five stages

ingest

raw documents: HR, product, engineering, off-topic

index

chunk, embed, and fit the vectors into one structure

retrieve

score every chunk against the query, keep the top-k

rerank

score just those candidates again, more carefully

generate

cite the evidence, or refuse

The Naive RAG Loop

The Naive RAG Loop

The Naive RAG Loop

The Naive RAG Loop

The Naive RAG Loop

One document holds many facts

A document mixes several separate facts into one block of text. A question only needs one of them back, not the whole page.

Paid Time Off Policy v1 15 days accrued per year carries over up to 5 days request 2 weeks ahead the one line a query needs Retrieval works chunk by chunk - it can only hand back what it indexed as one unit.

Chunk, the unit retrieval actually indexes and hands back: one self-contained slice of a document, small enough that a question only pulls the fact it needs.

Equal, non-overlapping cuts can break an idea

A window of fixed size slides across the words. The simplest version just cuts, edge to edge, with nothing shared.

chunk 1 chunk 2 a fact split exactly here survives whole in neither chunk

A fact that straddles a chunk boundary survives whole in neither chunk. Fixed-size, non-overlapping cuts guarantee this happens somewhere in a large corpus, overlap (the next slide) is the guard.

Overlap keeps the idea whole

Shift the window by less than its full size, and consecutive chunks share a few words at the seam.

chunk 1 chunk 2 overlap The last words of chunk 1 are the first words of chunk 2 - the idea at the seam survives in both.

Size is a tradeoff

A bigger window swallows unrelated ideas into one chunk. A smaller window slices a single idea into fragments that lose their surrounding context.

size too big pricing retention encryption three unrelated facts, one chunk size too small ...costs 6 dollars a month... one idea, chopped into pieces with no context There is no universally right size - it depends on how self-contained your documents' ideas are.

Most of Aurora Cloud’s documents need no splitting at all

Under the window this pipeline actually uses, nearly every one of the 48 documents collapses to a single chunk. Shrink the window, and one of them finally splits for real.

48 SHORT DOCUMENTS this one splits The walkthrough shrinks the window until a real document splits, then proves the overlap is real too.

Chunk size is a tradeoff, not a default: too big dilutes a chunk across unrelated facts, too small shatters one fact into pieces with no context. Overlap fixes the second failure, not the first.

Chunking the Corpus

Chunking the Corpus

Chunking the Corpus

Chunking the Corpus

Embeddings are geometry

A chunk of text turns into a list of numbers. That list is a location - somewhere you could point to on a map.

Once meaning has a location, comparing meanings becomes measuring distance.

Embedding, the list of numbers a chunk (or a query) turns into: a location in a shared space where distance stands in for how related two pieces of text are.

A chunk becomes a point in space

"requests for time off must be submitted two weeks in advance..." a chunk of text vectorize [0.02, 0.31, 0.00, 0.18, 0.00, ...] a TF-IDF vector project a point in space

Hundreds of numbers, two you can draw

a real vector has hundreds of numbers ... ... one number per dimension of meaning keep the 2 that separate topics best a flat picture you can actually look at

Search by meaning is search by distance

query: "how many days off do I get" nearest -> this is the answer far -> never considered

The query gets vectorized exactly like every chunk, then dropped onto the same map. Whichever dot it lands nearest is the answer.

The next screens are the real thing

Not a mockup: the real 48-document Aurora Cloud corpus, chunked and turned into real TF-IDF vectors, projected to 2 axes. Each dot is a real chunk, colored by its document’s tag. The star is a real query, placed by the same vectorizer, at its real projected position.

Once meaning has a location, “search by meaning” has a precise mechanical definition: nearest-neighbor distance on that map. Retrieval is exactly this, run at real scale.

Embeddings Are Geometry

Embeddings Are Geometry

Embeddings Are Geometry

Embeddings Are Geometry

Building the Index

Every chunk now has a vector. Those vectors don’t sit around loose - they get packed together into one structure: the index. Building it happens once. Answering a question against it never does.

Vector index, every chunk’s embedding stored together in one fitted structure. Built once from the whole corpus; every later question is a read against it, never a rebuild.

Vectors get stored together

48 separate vectors. One structure holds all of them.

48 chunk vectors the index Not 48 separate lookups later - one shared structure, right now.

Fit once, then query without limit

The fit is a single event. Every search after it is free to repeat.

BUILD - ONCE fit the index QUERY - ANY NUMBER OF TIMES search #1 search #2 search #3 ... The fit sits at the left edge, once. Everything to its right is the same lookup, repeated.

Many questions, one index

Three unrelated questions. One already-built index answers all of them.

the index "how many days of PTO do I get?" "what happens during a database outage?" "how do I sharpen a kitchen knife?" One PTO question, one outage question, one totally off-topic question - same index, zero refits.

The index never rebuilds

The wrong mental model refits per question. The right one fits once.

NOT THIS rebuild rebuild rebuild a fresh index per question - thrown away every time THIS one index search search search Built once, searched as many times as there are questions. Next: watch it happen for real.

Fitting the index is a one-time cost; search is what recurs, and search is cheap. Treating “build the index” as a per-question step is the mistake to rule out.

Building the Index

Building the Index

Building the Index

Building the Index

Retrieval: The Nearest Points Win

The index holds 48 points. Retrieval is the one operation that matters: drop the query onto the same map, and read off whichever points sit closest.

Nothing about retrieval is mysterious once the geometry is in place. It’s a distance calculation, run once per question.

Retrieval, scoring every chunk in the index against the query’s vector and keeping the top few. One distance calculation, run once per question, before generation ever sees the corpus.

From a question to a ranked list

"how many days of PTO do I get?" vectorize query vector compare 48 chunk scores top-k Every chunk gets a score. Only the highest few ever get shown to anyone.

Nearest wins, and “nearest” is a number

query 0.481 - close 0.068 - far 0.062 - far Rank by score, take the top few, done.

The next screens are the real thing

Not a mockup: the real 48-document Aurora Cloud corpus, the real fitted index from the last concept, one real query run through .search(). The ranked list, the scores, and the map are all computed live.

Retrieval always returns its top-k, confidently, whether the best score is 0.48 or 0.02. Deciding what a weak score means is generation’s job, not retrieval’s.

Retrieval: The Nearest Points Win

Retrieval: The Nearest Points Win

Retrieval: The Nearest Points Win

Retrieval: The Nearest Points Win

Retrieval: The Nearest Points Win

One ranking isn’t enough

Dense search ranks by meaning. Sparse search ranks by exact words. Run the same query through both and they don’t agree - fusing the two beats trusting either alone.

Three notions of “relevant”

lexical

BM25 / TF-IDF, rewards exact term overlap, blind to paraphrase

semantic

dense cosine similarity, rewards topical closeness, blind to rare exact terms

hybrid

reciprocal rank fusion, combines both rankings by position, not raw score

Dense vs. sparse: two different notions of “relevant”

DENSE - COSINE SIMILARITY query Q Pricing Escalation 0.28 0.16 scores every doc by overall topical closeness SPARSE - BM25 notify escalation on-call Escalation Policy exact term match wins, regardless of topic overlap

Same query, two different rankings

DENSE #1Pricing #2Overview #3Escalation Policy SPARSE #1Escalation Policy #2Pricing #3Overview Same corpus, same query - Escalation Policy jumps from #3 to #1.

Fusion: combine by rank, not raw score

DENSE #1Pricing #2Overview #3Escalation SPARSE #1Escalation #2Pricing #3Overview FUSED #1Pricing #2Escalation #3Overview score(chunk) = 1/(60 + rank_dense) + 1/(60 + rank_sparse) - rank position only, never raw score

Why fusion is more robust than either alone

DENSE paraphrases, synonyms, "what happens when..." SPARSE product names, IDs, exact jargon FUSED doesn't have to guess which case the query is

Dense and sparse have different blind spots, paraphrase versus rare exact terms. Fusing them means the system never has to bet on which failure mode a given query will hit.

Hybrid Search

Hybrid Search

Hybrid Search

Hybrid Search

Reranking: A Second, More Careful Pass

Retrieval scores every chunk against the query with one cheap, global signal, across the whole corpus at once. That is what makes it fast, and it is also why the order it returns is only approximately right.

Reranking takes just the candidates retrieval already found and looks at each one again, one at a time, with a narrower, more literal question: how much of the query’s actual wording is in this chunk.

Reranking, a second, narrower pass over just the candidates retrieval already found, scoring each one on its own instead of against the whole corpus at once. It reorders; it never fetches a new document.

The First Pass Is Approximate

A single global signal, spread across every word in the query, does not always put the right document on top.

RETRIEVAL'S ORDER A CLOSER LOOK #1 · candidate A matches many query words a little #2 · candidate B matches one query word exactly #1 · candidate B #2 · candidate A Same two candidates. Scored one at a time, the exact match wins.

A single global signal, scored across the whole corpus at once, is what makes retrieval fast, and exactly why its order is only approximately right. Reranking trades that speed for a second, narrower look.

What Actually Happened: “on-call handoff day”

Tried the near-duplicate pairs first (PTO days, expense threshold, outage lag) - none of them swap, because the one word that tells those two docs apart never appears in the query. This query, tried next, produces a real swap at #1.

query: "on-call handoff day" BEFORE · TF-IDF COSINE AFTER · KEYWORD OVERLAP #1 Onboarding Checklist score 0.208 #2 On-call Rotation Schedule score 0.138 #1 On-call Rotation Schedule score 0.557 #2 Onboarding Checklist score 0.371 "Onboarding Checklist" repeats the generic word "day" twice. Only "On-call Rotation Schedule" contains the exact word "handoff" - reranking promotes it.

Reranking Reorders. It Does Not Fetch.

5 CANDIDATES rerank() SAME 5, REORDERED No new document ever enters. Reranking can only work with what retrieval already found.

What Reranking Can’t Fix

The corpus has three near-duplicate pairs, same wording, one changed number. None of them swap under this reranker.

Paid Time Off Policy v1 "...accrue 15 days..." Paid Time Off Policy v2 "...accrue 20 days..." query: "how many days of paid time off" Neither "15" nor "20" is in the query, so both chunks overlap it identically. The tie always breaks back to whichever retrieval already ranked first.

That is an honest limit, not a bug: a reranker that only counts word overlap can’t tell two documents apart on the one word neither of them is being asked about.

A reranker that only counts word overlap can’t break a tie on the one word neither candidate mentions. Aurora Cloud’s near-duplicate PTO pair (15 days vs. 20) never swaps under this reranker.

Reranking

Reranking

Reranking

Reranking

The chunks aren’t the input

Retrieval hands back three objects. The model reads one string. Something has to build that string.

RETRIEVAL'S OUTPUT Paid Time Off Policy v2 score 0.481 Paid Time Off Policy v1 score 0.480 Remote Work Guidelines score 0.068 a title, a score, a wall of text - three separate objects

A chat model takes plain text in. Three Chunk objects are not plain text yet.

assemble_prompt() turns them into one string

The retrieved chunks flow into a single card, and each one gains a number as it lands.

PTO Policy v2 PTO Policy v1 Remote Work Guidelines assemble_prompt() [1] PTO Policy v2, excerpt [2] PTO Policy v1, excerpt [3] Remote Work Guidelines, excerpt

Every excerpt is numbered, not named

Chunk: "PTO Policy v2" [1] (PTO Policy v2) ... the number is the excerpt's only name inside the prompt order of chunks in decides the order of numbers out - nothing else does

A chunk’s doc title never reaches the model directly. Its [n] does.

An instruction wraps the excerpts

PREAMBLE the question, plus "answer using only the excerpts below" EXCERPTS [1] [2] [3] CLOSING RULE "cite each fact as [n]. If the excerpts don't cover it, say so."

What crosses the boundary to the model

EVERYTHING RAG KNOWS corpus index 48 chunks THE MODEL the prompt string corpus, index, and every other chunk never cross - only this one string does

The model never sees the corpus. It never sees the index. It sees one string, built once, per question.

The model’s entire universe for an answer is the literal prompt string assemble_prompt() built. Corpus, index, and every other chunk never cross that boundary, which is exactly what makes grounding checkable.

Augmentation: Building the Prompt

Augmentation: Building the Prompt

Augmentation: Building the Prompt

Augmentation: Building the Prompt

Cite or Refuse

Retrieval and augmentation hand the model a stack of excerpts. Generation is the last step: turn that stack into a sentence a human can trust, or say, honestly, that the corpus doesn’t have the answer.

Cite or refuse is the discipline that makes a RAG system trustworthy.

Two ways a question can end

query: "how many days of PTO do I get" ANSWERED Full-time employees accrue 20 days of paid time off per year. [1] Paid Time Off Policy v2 score clears the refusal bar query: "what's the weather in Tokyo tomorrow" REFUSED "The corpus doesn't cover this well enough to answer." score never clears the bar Same generator, same gate - the evidence decides which card you land on.

A citation is a pointer, not decoration

"...accrue 20 days of paid time off per year." [1] RETRIEVED CHUNK Paid Time Off Policy v2 "...accrue 20 days of paid time off..." [1] is a pointer, not decoration - it names the exact chunk the sentence came from.

Where the line gets drawn

low similarity high similarity refusal threshold weather query: 0.00 PTO query: 0.48 Below the line: refuse. Above it: cite.

No relevant chunk retrieved, without a refusal gate, the model falls back on parametric memory and answers fluently anyway, with no warning it is guessing. The similarity threshold is the guard that turns “no evidence” into an honest decline instead of a confident hallucination.

No partial credit

check top score ABOVE THE BAR write the answer, cite [n] BELOW THE BAR decline, honestly No partial credit - an answer is either grounded, or it doesn't happen.

A plain model always produces text. Cite-or-refuse gives the system a branch a plain model doesn’t have: the ability to say “I don’t know” instead of a fluent guess.

Generation: Cite or Refuse

Generation: Cite or Refuse

Generation: Cite or Refuse

Generation: Cite or Refuse

Generation: Cite or Refuse

Agentic RAG: The Tool-Use Loop

Every concept so far runs retrieval exactly once per question. An agent gets to check its own result first, and search again if it wasn’t good enough.

One search, or two?

The retrieval loop so far always asks once. An agent gets to check its own work first.

One-shot retrieval search once answer immediately no matter how weak the hit Agentic retrieval search, then check the score reformulate if it's weak search again before answering The only difference: the ability to look at its own result and try again.

The decision loop

One tool call, one confidence check, and a fork: answer now, or search again first.

question call search_corpus check confident? yes answer no -> reformulate, search_corpus again

A real trace

The question: “what’s up with the database when it just kind of goes down.” Vague on purpose.

search_corpus("what's up with the database ... goes down") -> top: On-call Runbook: Database Outage v2, score 0.176 0.176 < 0.2 -> not confident, reformulate search_corpus("what's with database ... goes down") -> top: On-call Runbook: Database Outage v2, score 0.176 same top doc, same score - the reformulation confirmed it, not changed it finish: "If the primary database is unreachable, page the database on-call immediately. [1]"

How the rewrite happens

The reformulation is a word filter, not a rewrite from scratch: drop anything four letters or shorter.

what's up with the database when it just kind of goes down grey + struck: words of length 3 or less what's with database when just kind goes down A real agent would rewrite with an LLM call. This toy loop keeps it deterministic on purpose, so the figure is exactly reproducible.

Not blind repetition

The agent only pays for a second search when the first one earns it.

CONFIDENT ON THE FIRST TRY - ONE CALL question search_corpus answer score 0.212 WEAK ON THE FIRST TRY - TWO CALLS question search_corpus search again answer 0.176

The only difference between one-shot and agentic retrieval is the ability to check a confidence score and try again. The retry is capped at one call, gated on a real threshold, never an unbounded search spiral.

Agentic RAG: The Tool-Use Loop

Agentic RAG: The Tool-Use Loop

Agentic RAG: The Tool-Use Loop

Evaluation: How You Know It’s Working

A demo can look convincing and still be wrong. The only way to trust a RAG system is to run it against questions with known answers, and grade it honestly.

Every concept so far - chunking, retrieval, hybrid search, reranking, generation - is a design choice. Evaluation is how you find out whether those choices actually work.

A frozen set of questions, each with a known answer

Write down real questions a user would ask, then name the one document that actually answers each. Freeze the list. Score every future change against it, unchanged.

"parental leave weeks" hr-06 · Parental Leave Policy "code review pull request size" eng-08 · Code Review Guidelines "aurora vault encryption key rotation" prod-08 · Aurora Vault Encryption "what is the meaning of life" no such document exists - on purpose

Recall@k is a looser bar than faithful

Recall@k asks whether the right document is anywhere in the top few results. Faithful asks a stricter question - is it the very top hit, with a real similarity score worth trusting.

recall@5 right doc lands anywhere in the top 5 faithful right doc is the #1 hit, above threshold

Recall@k, whether the right document lands anywhere in the top k results. A looser bar than faithful, which asks whether it’s the #1 hit with a score worth trusting.

Running it for real

"parental leave weeks" "code review pull request size" "aurora vault encryption key rotation" "what is the meaning of life" The one X is not a defect. It is the system correctly saying it does not know.

Back to where this deck started

A plain language model would have answered the meaning-of-life question anyway, fluently and with no warning that it was guessing. This system checks itself against real evidence first. It only speaks when it has some.

Every design choice in this deck, chunk size, hybrid fusion, reranking, the refusal threshold, is just a hypothesis until it’s run against frozen questions with known answers. Evaluation is how you find out whether those choices actually work.

Evaluation: How You Know It Works

Evaluation: How You Know It Works

Evaluation: How You Know It Works

Making It Real, With Chroma

Everything so far ran a hand-built TF-IDF index. Swap in chromadb, a real vector store, and chonkie, a real chunker, and the retrieval story does not change, only the plumbing underneath it does.

Chroma, an embedded vector store, pip install chromadb, no server to run. A collection stores documents, embeds them, and answers .query() calls with the nearest matches, all inside the same Python process.

The Toy Index, Now Real Chroma

The Toy Index, Now Real Chroma

The Toy Index, Now Real Chroma

The Toy Index, Now Real Chroma

The hand-built TF-IDF index becomes one real library call, coll.query(). Same corpus, same query, a relevant document retrieved, now backed by a real vector store instead of a few hundred lines of hand-rolled code.

chroma

embedded and local, runs in-process, no server, the vector store this coda actually calls

milvus, pinecone

managed vector stores built for scale, a server (or a cloud service) to run and operate

chonkie, docling

chonkie chunks text, used above for real, docling ingests PDFs and other documents into text, named here, not run

References

Guides Lewis et al., Retrieval-Augmented Generation (2020) · Robertson & Zaragoza, BM25 and Beyond (2009) · LangChain, retrieval concepts · Anthropic, Contextual Retrieval

Siblings How Git Works, Visually · How Inference & Serving Work, Visually · How Evals & Observability Work, Visually · How Agentic Harnesses Work, Visually · How LangChain & LangGraph Work, Visually

Every figure in this deck: a real TF-IDF pipeline over the 48-document Aurora Cloud corpus, regenerable with build/regen_all.py