What RAG does, in short

Retrieval-augmented generation is three steps, run on every question: retrieve the few passages most likely to hold the answer, augment a prompt with them, then generate from what was handed over. Nothing about the model changes - only what reaches its input does.

What that fixes, and what it costs to run, is What is RAG? at 101. This article takes apart the first step. Retrieve is one word there and nine sections here, because it is where the quality of every answer is decided - and where a pipeline that looks fine returns the wrong passage without raising an error.

The full flow: six steps

The running example is an annual report in Markdown, built to fail in useful ways: its sections share words but not meanings.

The running example · two of its sections
## Section 1: Medical Research This year saw significant strides in our understanding of XDR-47, a "bug" we have not seen before.
## Section 2: Software Engineering This division dedicated significant effort to studying various infection vectors in our distributed systems.

In the medical section, "bug" means a microbe - XDR-47 is a drug-resistant pathogen. Ask about a bug in the software and the word matches anyway, so the medical chunk can be the one that comes back. The cybersecurity section carries the other trap: it is the incident report for INC-2023-Q4-011, which the engineering and legal sections name too, so one incident can match three sections at once. The financial section is the mirror image: it discusses the same quarter's losses and remediation costs in the register of an incident report without ever naming the incident.

Before opening any one part, here is the whole pipeline - six steps, the first three run once per document, the last three on every question. Each step is the subject of a section below; the bold titles link to them.

Index time · steps 1-3 · once per document
  1. Chunk the source text. Split each document into passages small enough to embed and to cite. The medical section and the engineering section become separate chunks, each carrying its heading.
  2. Embed each chunk. Run every chunk through the embedding model, in document mode. One vector per chunk - in the two-dimensional toy, [0.97, 0.34] for medicine and [0.30, 0.97] for engineering, each stored scaled to unit length.
  3. Store the vectors with their text. Put each vector in a vector database next to the chunk that produced it. This is the last step that happens ahead of time; from here the pipeline waits for a question.
Query time · steps 4-6 · on every question
  1. Embed the question. Same model, query mode. "What did the software engineering department do this year?" becomes [0.10, 0.89] - a point in the same space as the chunks.
  2. Find the nearest chunks. The database returns the k stored vectors with the highest cosine similarity to the question's. Here engineering scores 0.98 against medicine's 0.43, and its chunk comes back.
  3. Build the prompt and generate. Instructions, the retrieved chunks, and the question go to Claude, which answers from what it was handed rather than from memory.
The six steps as one diagram: index time and query time Index time, steps 1 to 3, once per document: documents are chunked, each chunk is embedded, and the vectors are stored with their text in a vector index. Query time, steps 4 to 6, on every question: the question is embedded with the same model, the index returns the k nearest passages, they are assembled into a prompt with instructions and the question, and the LLM generates an answer with citations. INDEX TIME · STEPS 1-3 · ONCE PER DOCUMENT Documentspdfs, wiki, tickets Chunk~300 tokens each Embed embedding model one vector per chunk Vector index each vector stored with its text query vector k passages QUERY TIME · STEPS 4-6 · EVERY QUESTION Questionfrom the user Embedsame model as indexing Search top knearest k chunks Promptinstructions +passages + question LLMgenerate Answerwith citations
The six steps as one picture · index time runs once per document, query time on every question

Steps 1 to 3 are the index side of the seam, 4 to 6 the query side. The sections that follow open each step in order, then add the two things the six-step version leaves out - a second, lexical index and a fusion step between 5 and 6 - and end with how to tell which step is the one failing.

The two phases meet at one object: the chunk. Choices upstream of it - boundaries, overlap, the embedding model and its input mode - are frozen into the index and change only by rebuilding it. Choices downstream - how many to fetch, how to merge two rankings, whether to rerank - change per request. Hence the first debugging rule of the article: if the fix requires re-embedding, the bug is on the index side.

Chunking: where quality is decided

Chunking is cutting source documents into pieces small enough that a search can hand back only the part that answers a question. It has more leverage on answer quality than anything else in the pipeline, because it runs first and everything downstream inherits it. A chunk is the unit of all of it: what gets embedded, what gets scored, what gets pasted into the prompt, and what the model cites. Cut badly and the prompt fills with passages that matched a word rather than a meaning, and the model answers confidently from them - no embedding model, however good, recovers meaning that ended up on the other side of a cut.

Concretely, on an 800-page annual report. Instead of storing and retrieving the whole document, the system splits it once, indexes the pieces, and retrieves from those:

One question, one chunk
Document → Chunks → Index → Retrieve relevant chunks → LLM
The question "What were the main cybersecurity risks?"
Chunk 147 - Risk Factors "Cybersecurity incidents increased during Q4..."

Only that chunk, and perhaps the few neighbouring ones, is added to the prompt - not all 800 pages. Where the cuts fall is the choice being made, and four strategies cover the field. All four cut the same document - the two sections from section 02 - and the first two are worth seeing side by side, because they produce different chunks from identical input.

01 · Size

Fixed windows

  • Cuts every N characters or tokens, each window repeating the tail of the last
  • Wins when you do not control the format - text, PDFs, code, logs
  • Breaks mid-sentence, and separates a heading from the body it describes
  • The production default: it never fails, it only cuts awkwardly

333 characters · 3 chunks · 111 characters each · no overlap

Source document
This year our company engaged in many areas of research. ## Section 1: Medical Research This year saw significant strides in our understanding of XDR-47, a "bug" we have not seen before. ## Section 2: Software Engineering This division dedicated significant effort to studying various infection vectors in our distributed systems
Output chunks
Chunk 1 · 111 charsThis year our company engaged in many areas of research. ## Section 1: Medical Research This year saw signifi
Chunk 2 · 111 charscant strides in our understanding of XDR-47, a "bug" we have not seen before. ## Section 2: Software Engineeri
Chunk 3 · 111 charsng This division dedicated significant effort to studying various infection vectors in our distributed systems
Both cuts land mid-word · "significant" and "Engineering" are split, and the medical heading leaves its sentence behind in another chunk · overlap off here so the cuts stay visible
02 · Structure

The document's own markers

  • Cuts on headings, sections, paragraphs
  • Wins when you control the format - Markdown, internal reports, docs with a style guide
  • Every chunk is a complete unit, and its heading travels with it
  • Breaks on sources with no reliable markers, and on sections long enough to hold three topics - pair it with a size cap

333 characters · 3 chunks · 56, 130 and 143 characters · cut on "## "

Source document
This year our company engaged in many areas of research.
## Section 1: Medical Research This year saw significant strides in our understanding of XDR-47, a "bug" we have not seen before.
## Section 2: Software Engineering This division dedicated significant effort to studying various infection vectors in our distributed systems
Output chunks
Chunk 1 · intro · 56 charsThis year our company engaged in many areas of research.
Chunk 2 · Section 1 · 130 chars## Section 1: Medical Research This year saw significant strides in our understanding of XDR-47, a "bug" we have not seen before.
Chunk 3 · Section 2 · 143 chars## Section 2: Software Engineering This division dedicated significant effort to studying various infection vectors in our distributed systems
Same document, no word split · each heading arrives with the body it describes, and the chunks come out uneven: 56, 130, 143
03 · Semantic

Where the topic turns

  • Cuts on drops in similarity between consecutive sentences
  • Wins on long unstructured text where boundary quality beats index cost
  • Costs one embedding per sentence at index time, plus a similarity threshold tuned per corpus
  • Breaks when the topic drifts gradually - there is no similarity drop to cut on, so one threshold either over-splits or never fires
04 · Sentence

N sentences at a time

  • Cuts on sentence ends - a regex such as (?<=[.!?])\s+ - with one or two sentences of overlap
  • Wins on prose without structure: the middle ground for most text documents
  • Breaks on code, tables, lists, and any text where a period is not a sentence end - versions, abbreviations, decimals

Chunk size is the knob under all of this, and it pulls in two directions. Too small and the chunk cannot answer on its own: the vector is precise, but the passage that reaches the model is a fragment whose subject was two sentences earlier. Too large and the one vector per chunk averages several topics into a point near none of them - a 2,000-word chunk about three things retrieves for nothing. What you are aiming for is a coherent unit of meaning - a paragraph, a subsection, a few related paragraphs - not a character count that happens to land near one. Most systems land between 200 and 500 tokens with 10 to 20 percent overlap, then move from there on evidence, which is the subject of the last section.

Embeddings: meaning as coordinates

A printed reference diagram on a lamplit walnut desk, shot from above, titled EMBEDDINGS IN RAG. Four panels. Panel 1, index time: a document stack feeds a CHUNKING box, which produces three chunks labelled financial results, cybersecurity, and medical XDR-47; all three pass through an amber EMBEDDING MODEL box captioned input_type equals document, become three rows of bracketed numbers, and land in a VECTOR INDEX cylinder. Panel 2, query time: the question "what cybersecurity incidents occurred?" passes through a second amber EMBEDDING MODEL box, captioned input_type equals query, and becomes one vector. Panel 3, similarity search: that vector goes to a NEAREST BY ANGLE box, which returns three ranked chunks. Panel 4, generation: a PROMPT box holding the question, the retrieved context, and an instruction feeds an LLM box, which produces an ANSWER. A kraft sticky note on the desk reads "Same meaning. Different words."
One model, two input modes · document at index time, query at question time - the same box on both sides of the seam
An embedding is an array of numbers that is really a position - and only the angles between positions mean anything.

The array the model returns is the embedding of the text. It is also a vector: same numbers, different name for the same thing. Embedding says where the array came from - a model, from a piece of content. Vector says what it is mathematically - a list of numbers you can compare by geometry, since read in order those numbers are coordinates, and coordinates give you both a point in the space and a direction from the origin pointing at it. Embedding, vector, point: one array, three names, and this article uses whichever one fits the sentence. The only asymmetry worth keeping: every embedding is a vector, but a vector is an embedding only if a model produced it from some content.

How many numbers the list holds is fixed by the model, never by the text. Voyage AI's voyage-3-large returns exactly 1,024 - [0.0137, -0.0412, 0.0089, ...], out to the 1,024th - for a single word and for a full chunk alike: never fewer, never more, and not a maximum. A different model returns a different count; 384, 1,536 and 3,072 are all common. Every text in one index therefore has to go through the same model, because only equal-length vectors from one space can be compared at all. The vectors printed in this article have 2 numbers instead of 1,024, so they fit on the page and the arithmetic can be done by hand.

Text goes into the embedding model and those numbers come out, each between -1 and 1. Read them as coordinates: they place the text in a space where distance means similarity of meaning, so "refund policy" and "how do I get my money back" land as neighbours despite sharing no words. That is the whole trick, and it is what lets a search find meaning instead of matching strings.

Two-number vectors, so they fit on the page
Text Vector Cosine with row 1"refund policy" [0.31, 0.95] 1.0000 "how do I get my money back" [0.28, 0.96] 0.9995 "the river bank flooded" [0.97, -0.24] 0.0728
The same three vectors drawn as arrows from the origin A two-dimensional plot with three arrows starting at the origin. Two of them are almost on top of each other, pointing up and to the right: "refund policy" at 0.31, 0.95 and "how do I get my money back" at 0.28, 0.96, which are 1.8 degrees apart with a cosine of 0.9995. The third points right and slightly below the horizontal axis: "the river bank flooded" at 0.97, negative 0.24, which is 85.8 degrees away with a cosine of 0.0728. A dashed arc marks the angle between the first and the third. ONE PAIR OF NUMBERS · THREE NAMES dimension 1 dimension 2 origin the angle is the whole signal "refund policy" [0.31, 0.95] "how do I get my money back" [0.28, 0.96] 1.8° apart · cosine 0.9995 · one meaning, two wordings "the river bank flooded" [0.97, -0.24] 85.8° away · cosine 0.0728
The pair of numbers is the embedding · the arrow from the origin is the vector · its tip is the point · only the angle between arrows carries meaning

The first two share not one word, and their vectors point almost the same way - cosine 0.9995, as close as two directions get. The third shares the shape of a sentence and nothing else, and it points somewhere unrelated: 0.0728. That number is the entire retrieval signal. The vectors above are the two-number stand-in, and the next section reuses it to do the arithmetic by hand.

The embedding model doing that work is not the LLM. It is a separate, much smaller neural network, trained on pairs of text known to be related - a question and its answer, a title and its article - with a single objective: pull related pairs closer together and push unrelated ones apart. That training is where the geometry comes from. It cannot chat or reason; it reads text and returns coordinates, one pass, no generation, which is what makes it cheap enough to run over an entire corpus. The same text through the same model returns the same vector every time, and that is what keeps an index valid until you change the model. Like any model it has a context window, so text longer than that window is truncated rather than summarized - one more reason chunks are cut before they are embedded.

The numbers themselves are not readable. There is no "medical" axis and no "software" axis - the axes were learned during training and mean nothing on their own, so an embedding is never inspected, only compared against another one.

What is worth knowing at this level is operational:

  • Same model on both sides. Two models, however similar, produce two unrelated coordinate systems. A query embedded by one and chunks embedded by another compare as noise, with no error anywhere. Changing the embedding model means re-embedding every chunk - the index-side rule the flow above ends on.
  • Mind the input mode. Voyage models are trained asymmetrically: pass input_type="document" at index time and input_type="query" at question time. Swap them and recall drops quietly, which is the worst kind of drop.
  • Vectors come back unit-length. Voyage normalizes to magnitude 1, so the dot product of two vectors is their cosine similarity and the division by the two magnitudes disappears. Not every provider does this; check, because everything downstream assumes it.
  • Batch at index time. One call per chunk is slow and rate-limited; one call per few hundred chunks is the normal shape. At query time there is one string to embed.
  • Anthropic does not ship an embedding model. Its documentation points to Voyage AI, which is what the Claude Academy course and this article use. Nothing here depends on that choice; swap the client and keep the shape.

One more knob lives here: dimension. voyage-3-large can return 256, 512, 1,024, or 2,048 numbers per text. Fewer dimensions mean a smaller index and faster search for a measurable loss in recall; more mean the opposite. It is a trade you make once, at index time, and measure like every other one.

Cosine similarity and the vector index

"Nearest" needs a definition, and two numbers are in play that read as opposites of each other.

Cosine similarity is the cosine of the angle between two vectors: 1 when they point the same way, 0 when they are unrelated, -1 when they point opposite. Higher is better, and 1 is the best score there is.

Cosine distance is that number turned around, 1 - similarity: 0 when the vectors point the same way, 1 when unrelated, 2 when opposite. Lower is better, and 0 is the best score there is.

Same measurement, opposite direction - which means the sort order flips with the name: you want the largest similarity, or the smallest distance. Sort a list of distances the wrong way and you hand the model the worst passages in the corpus, with nothing anywhere to tell you. An index that scores by distance has to sort ascending for exactly that reason, and many libraries report distance by default, so check which one you are holding before you sort it.

Direction is what carries meaning; magnitude tends to track length and confidence, which is why vectors are normalized before comparing.

The toy version makes the arithmetic visible. Pretend the model has two dimensions - "how medical" and "how software" - and embed the two sections and a question about the engineering department. Both score columns compare against the question's vector, which is why its own row reads 1.00 and 0.00:

Text Vector Cosine similarity Cosine distance
Question [0.10, 0.89] 1.00 0.00
§2 Engineering [0.30, 0.97] 0.98 0.02
§1 Medical [0.97, 0.34] 0.43 0.57

The engineering section wins by a wide margin even though it also mentions "infection vectors" - the direction of the whole chunk is what counts, not a shared word. That is the strength of the method and, two sections from now, its weakness.

A vector index is the structure that answers "which stored vectors are closest to this one" quickly. The honest minimum is a list and a loop: embed the query, walk every stored vector, keep the k with the smallest distance. Every real index does the same thing with better data structures.

That is exact search: every query touches every vector. Vectorized with NumPy instead of a Python loop it holds up to roughly a hundred thousand chunks on one machine. Beyond that, a vector database swaps exactness for an approximate index - HNSW graphs, inverted-file partitions - that visits a small fraction of the vectors and misses the true nearest neighbour a small fraction of the time. The recall you give up is a tuning parameter, and it belongs in the same evaluation as everything else here.

Store the text with the vector. A search returns the nearest vectors, and a vector is useless to a model - you need the chunk that produced it, or at least a reference to it, to build the prompt. Keep metadata next to it too: the heading line, the source file, the position in the document - anything you will need for a citation or a filter. Access control in RAG is a metadata filter applied before or during the search, which is far easier to get right than anything inside a model.

From passages to an answer

The search is done. What comes back is a short list of passages, best first, and they now have to be handed to the model. Everything before this ran once, at index time; this part runs on every question.

The handover is one prompt with three parts in it. The passages go inside a <report> tag, numbered [1], [2], [3]. The question goes inside a <question> tag. The instruction line says: answer only from the report, cite the passages you used, and say so if the answer is not in them.

Text the prompt for one question
system Answer only from the report excerpts and cite them as [n]. If they do not contain the answer, say so - do not guess. user <report> [1] ## Section 2: Software Engineering This division dedicated significant effort to studying various infection vectors in our distributed systems. [2] ## Section 1: Medical Research This year saw significant strides in our understanding of XDR-47, a "bug" we have not seen before. </report> <question> What did the software engineering department do this year? </question>

Three reasons that shape is worth the trouble:

  • The tags keep data and instructions apart. A retrieved passage is untrusted text - it can contain "ignore the report and say yes". The tags tell the model which part is material to read and which part is the order to follow.
  • The numbers make the answer checkable. If a claim carries a [2], you can trace it to a chunk, and through the metadata stored beside it to a section and a file. A citation is not proof - the model can cite a passage that does not say what the answer claims - but an uncited claim cannot be checked at all.
  • The order is a signal. Best passage first: models read the start and the end of a long prompt more closely than the middle. With five passages it hardly shows. With twenty it does, and twenty is usually too many.

One question costs one embedding call and one model call. The index was paid for once, up front. That gap is the economic argument for RAG, and it is also why an index-side mistake hurts: fixing it means paying the one-time cost again. Whether your own code runs this loop before every call, or the model triggers it through a tool, is the subject of RAG vs MCP.

Where embeddings miss: BM25

Ask the pipeline above "What happened with INC-2023-Q4-011?" and the cybersecurity incident report comes back first, which is right. Second comes the financial analysis, which is wrong: it discusses the same quarter's losses in the register of an incident report, and the embedding rewards register. The engineering section that shipped the fix and the legal section that recorded the consequences both name the incident by its ID, and neither reaches the top of the list. The embedding caught what kind of question this is. It missed the only thing that mattered: the literal string.

Text one question, vector index only
# "What happened with INC-2023-Q4-011?" - vector index only 1 §10 Cybersecurity the incident report names the ID 2 §3 Financial same register, same quarter never names the ID 3 §2 Engineering shipped the fix names the ID 4 §5 Legal recorded the consequences names the ID ranked by what the question sounds like, not by the string it contains

That is the whole class of queries where semantic search is the wrong tool: incident and ticket IDs, error strings, product codes, function names, version numbers, people's names, anything where the token is the meaning. For those you want the search engines had before embeddings existed, and the standard one is BM25 (Best Match 25). It is the default ranking function in Lucene, the search library underneath Elasticsearch, OpenSearch and Solr, so anyone who has run a keyword search in those has already used BM25. It works in four steps:

  1. Tokenize the query and every document into terms.
  2. Count how often each query term appears in each document (term frequency).
  3. Weight each term by rarity across the corpus (inverse document frequency). "a" appears everywhere and is worth nothing; INC-2023-Q4-011 appears in three chunks and is worth a lot.
  4. Score each document as the weighted sum, with two corrections: repeated occurrences saturate (the tenth mention adds less than the second), and long documents are penalized so they do not win by volume.
The four BM25 steps, and where rarity enters Four boxes in a row: tokenize, which lowercases and splits the query; count, which is term frequency per document; weight, which scores each term by its rarity across the corpus; and score, which sums the weighted terms and ranks the documents. Below them, the worked contrast: the word "with" appears in all ten chunks, so its weight is near zero and it separates nothing, while inc-2023-q4-011 appears in three, carries a high weight, and lifts exactly those three above everything else, which scores zero. BM25 · FOUR STEPS ON ONE QUESTION Tokenizelowercase · split Countterm frequency Weightby rarity · idf Scoresum, then rank "with" is in all 10 chunks → weight near zero → separates nothing "inc-2023-q4-011" is in 3 → weight high → those 3 rank, the rest score zero
Four steps, and the third is the one that decides everything · a term is worth exactly as much as it is rare, which is how an incident ID outranks every common word in the same query

The tokenizer is where BM25 pipelines actually break. A default that splits on every non-letter turns INC-2023-Q4-011 into inc, 2023, q4, 011 - four common fragments instead of one rare token - and the exact-match advantage is gone before scoring starts. Keep identifiers whole: lowercase, split on whitespace, and let hyphens and digits stay inside a token.

A BM25 index exposes the same two operations as the vector index - add documents, and search - which is the point of the next section. Two things to notice about what comes back. The scores are not on any fixed scale: a BM25 score of 8 means "much better than 2", nothing more, and it is not comparable to a cosine distance. And a chunk with no query term at all scores zero and is dropped, which is exactly the behaviour the vector index cannot give you: BM25 knows when it has found nothing.

Where BM25 loses is the mirror image. "Refund" does not match "money back"; a question in different words from the document scores nothing; a chunk that discusses the topic without using the query's terms is invisible. Each method is blind exactly where the other sees.

Hybrid search and rank fusion

So run both. Every production retrieval system of any size does: the question goes to the vector index and the BM25 index in parallel, each returns a ranked list, and the lists are merged. The merge is the interesting part, because the two scores live on different scales - cosine distance between 0 and 2, BM25 anywhere from 0 upward - and any attempt to add or weight them directly is guesswork that breaks when the corpus changes.

The shape, end to end - two signals into one ranked list, with the optional reranker on the way out:

Query time in a hybrid RAG pipeline The question at the top forks into two searches drawn side by side. On the left, vector search: the question is embedded and the index returns its nearest chunks by angle. On the right, lexical search: the question is tokenized and BM25 returns its best exact-term matches. Each returns a stack of twenty ranked chunks, one ranked by meaning and one by term. The two stacks converge into an accented box, fuse on rank, which sums one over sixty plus rank across both lists. Below it an optional rerank step narrows twenty to five, and the survivors go into the prompt with the question for Claude to answer with citations. QUESTION What happened with INC-2023-Q4-011? Vector searchembeddings · nearest by angle Lexical searchBM25 · exact terms Top 20 ranked by meaning Top 20 ranked by term Fuse on rank1 / (60 + rank), summed Rerankoptional · 20 → 5 Promptchunks + question → Claude
Two retrieval signals, fused on rank · the vector path finds meaning, the lexical path finds exact terms, and the fusion trusts agreement over either alone

The standard answer is to throw the scores away and merge on rank. Reciprocal Rank Fusion gives each document, in each list, a contribution of 1 / (60 + rank) and sums across lists. That constant - written k in the original paper, and nothing to do with the k of top-k - is conventionally 60, and it is doing something specific: it flattens the difference between rank 1 and rank 2 so that appearing in both lists outweighs topping one. At 60, a chunk ranked first by both signals scores 2/61 ≈ 0.0328; a chunk ranked first by one signal and absent from the other scores 1/61 ≈ 0.0164; second in one list only, 1/62 ≈ 0.0161. Agreement between two independent signals is the strongest evidence you have that a chunk is relevant, and RRF is a formula that says so. Set it to 1 and the same ranks give 1.0, 0.5 and 0.33, where a single confident signal can outvote agreement - which is why nobody sets it to 1.

Because both indexes expose the same two operations, the fusion needs nothing more than a wrapper: one object holding any number of indexes, which forwards documents to all of them at index time and, at query time, asks each for its top 20 and sums 1 / (60 + rank) across the lists it gets back.

Swapping it in is a one-line change where the index is built: one retriever wrapping two indexes instead of a single vector index. Nothing changes in the query path - whatever asks for passages calls search() and neither knows nor cares what the store is underneath.

Text hybrid search · one question, two indexes
# "What happened with INC-2023-Q4-011?" - each index ranks, then fuse on rank vector → §10 Cybersecurity · §3 Financial · §2 Engineering · §5 Legal meaning: an incident and a fix - not the literal ID bm25 → §10 Cybersecurity · §2 Engineering · §5 Legal the literal ID: three chunks name it, §3 does not rrf → §10 0.0328 · §2 0.0320 · §5 0.0315 · §3 0.0161 Σ 1/(60+rank): in both lists beats top of one

On the incident question the three rankings above show what changes. Vector-only put the cybersecurity report first and the financial analysis second - a section that sounds like the incident and never names it. Hybrid keeps cybersecurity first, as the only chunk both signals ranked at the top, and promotes the engineering fix and the legal follow-up - third and fourth by meaning, second and third by the literal ID - into second and third place. The financial section, present in one list only, drops to fourth. No threshold and no weight decided that; two rankings and a sum did. Each of the 20 candidates per index costs nothing extra to fetch, and fusing 40 down to 5 is arithmetic.

That shape also tells you what "multi-index" means in practice. A recency index that ranks by date, a metadata filter that returns only what this user may see, a graph index that follows links between documents - each is a third object with the same two operations, and the retriever does not change. That is worth more than any single retrieval trick, because the next trick is always coming.

Reranking and contextual retrieval

Two upgrades sit on top of hybrid search, one on each side of the seam.

Reranking is query-side. Every score so far was computed with the query and the chunk encoded separately - a bi-encoder - which is what makes search over a million chunks affordable. A reranker is a cross-encoder: it reads the query and one chunk together and scores how well that chunk answers that question. Far too slow for the corpus, exactly right for the shortlist. The pattern is retrieve wide, rerank narrow: widen the top-20-per-index fetch from the diagram above to 100 or 150 candidates, rerank those, and keep the best 5 to 20 for the prompt. Voyage's rerank-2, Cohere's Rerank, and a handful of open models all take a query and a list of texts and return the list reordered. It is one call, and in Anthropic's measurements it is the single largest improvement after hybrid search itself.

Contextual retrieval is index-side, and it attacks the problem chunking created. A chunk that reads "the company's revenue grew 3% over the previous quarter" is unretrievable for a question about ACME's Q2 results: neither the company nor the quarter is in the text. The fix Anthropic published in 2024 is to have a model write a short sentence - 50 to 100 tokens - situating each chunk in its document, and to prepend that sentence to the chunk before both embedding it and indexing it for BM25. The prompt is one line: give a short succinct context to situate this chunk within the overall document for the purposes of improving search retrieval of the chunk. With prompt caching the whole document sits in cache while its chunks stream past, and the cost comes to about a dollar per million document tokens.

The numbers, measured as the share of questions whose answer chunk was not in the top 20 retrieved:

Pipeline Top-20 failure rate Reduction
Embeddings only 5.7% -
Contextual embeddings 3.7% 35%
+ contextual BM25 2.9% 49%
+ reranking 1.9% 67%

One threshold from the same work is worth carrying around. Below roughly 200,000 tokens of knowledge base - about 500 pages - none of this is needed: put the whole corpus in the prompt, cache it, and skip retrieval entirely. RAG is a response to scale; below the scale, the simplest pipeline is no pipeline.

Debugging a pipeline that works

RAG fails without errors: the answer reads well and is wrong, or the answer is "not in the documents" and it was. The method for finding out why is the same every time.

Split the failure first. For any bad answer, look at the chunks the model was given - which means logging them with every answer, always. If the right chunk was in the prompt, the problem is generation: the instruction, the order, the model, the number of chunks competing for attention. If it was not, the problem is retrieval, and the model is innocent. Half of all RAG debugging time is spent tuning the wrong half.

Measure retrieval on its own. Pair 50 to 200 real questions with the chunk that answers each, then score recall@k - the share of questions whose answer chunk lands in the top k - and watch it as you change chunk size, k, or the reranker cut-off. It makes no model calls and runs in seconds, which is what turns tuning into evidence instead of guesswork.

Then match the symptom to its fix:

Symptom Where it lives Fix
Right chunk ranked 6 to 20 Query side Raise k, or add a reranker
Right chunk never comes back Index side The question used other words: check the input mode is not swapped, then try contextual retrieval
Exact IDs and codes miss Lexical side Add a BM25 index; if you have one, fix the tokenizer
Chunk cannot answer alone Chunking Bigger chunks, more overlap, or return the parent section
Right chunk, wrong answer Generation Fewer chunks, best first, and demand citations
Answers went stale Freshness Re-embed changed documents on a schedule
Slow Everywhere Time each stage before tuning any of them

Every change in this article was made to the retrieve step. The model was never touched, never fine-tuned, never asked to remember anything - it read what the pipeline put in front of it. That is the closing line of the 101 article restated with the knobs named: make the search good and the answers follow. Now you know which parts of the search there are to make good.