Retrieval-Augmented Generation (RAG) with Claude
Claude's built-in knowledge is frozen at training time and knows nothing about your private documents. Retrieval-augmented generation (RAG) fixes that by finding the right slice of your own data at query time and handing it to Claude as context — with citations, so you can verify every claim.
Imagine a friend who memorized a huge stack of books years ago but isn't allowed to bring notes into the room. That's Claude answering from memory alone. RAG is like handing that friend the exact right page from today's paperwork right before you ask the question — now they can answer using something real and current, and point to the exact line they got it from.
Why retrieval instead of a bigger prompt
☺ Like you're 10: Claude's training is like a textbook it read once, years ago, and put back on the shelf — it can't flip to today's page on its own. Retrieval hands it that page.
Every Claude model answers from two sources: what it learned during training (its parametric memory) and whatever you put in the prompt (context). Parametric memory is broad but stale and generic — it has no idea about last week's support tickets, your internal wiki, or a contract legal uploaded yesterday. It's also unverifiable: when Claude answers purely from memory, there's no sentence you can point to as the source of the fact.
RAG closes that gap. Instead of asking Claude to recall facts from training, you search your own corpus for the passages most relevant to the current question and place only those passages in the prompt. Claude then reasons over content that is current, private, and traceable — and can cite exactly which passage supports each part of its answer.
The RAG pipeline, step by step
☺ Like you're 10: Think of a librarian. First they file every book by topic, once (indexing). Then, whenever someone asks a question, they don't reread the whole library — they pull just the couple of books that matter (query time).
A RAG system has two separate paths that run at different times: an offline indexing path that prepares your documents once (and re-runs whenever content changes), and an online query path that runs on every request.
Concretely: split each source document into chunks (a paragraph, a heading section, a fixed token window); embed every chunk into a vector with an embedding model; store the vectors in a vector index alongside the original chunk text. At query time, embed the incoming question with the same embedding model, compare it against the index (typically by cosine similarity), pull back the top few matching chunks, and insert them into the prompt sent to Claude — along with an instruction to answer only from those chunks and cite which one supports each claim.
Embedding chunks and queries with Voyage AI
Anthropic doesn't build its own embedding model. Its documentation names Voyage AI as its recommended embeddings partner, citing dataset size and domain fit, inference performance, and customization as reasons to pick a specific Voyage model. The lineup includes general-purpose models (voyage-4-large, voyage-4, voyage-4-lite, voyage-4-nano), domain-specific models (voyage-law-2, voyage-code-3, voyage-finance-2), a multimodal model (voyage-multimodal-3.5), contextualized-chunk models called via contextualized_embed() (voyage-context-4, voyage-context-3), and rerankers (rerank-2.5, rerank-2.5-lite).
import voyageai
import numpy as np
vo = voyageai.Client() # reads VOYAGE_API_KEY from the environment
chunks = [
"Revenue grew 3% year-over-year in Q2.",
"The board approved a $50M buyback program.",
"Headcount increased by 12% to support the new EU region.",
]
# Embed the corpus once, at indexing time. input_type="document"
# prepends a fixed instruction tuned for things you're indexing.
doc_embeddings = vo.embed(
chunks, model="voyage-4", input_type="document"
).embeddings
# Embed each incoming query at request time with input_type="query".
query = "How much did the company spend on the buyback?"
query_embedding = vo.embed(
[query], model="voyage-4", input_type="query"
).embeddings[0]
# Voyage embeddings are normalized to length 1, so dot product
# ranks identically to cosine similarity — no extra normalization needed.
similarities = np.dot(doc_embeddings, query_embedding)
top_k = np.argsort(similarities)[::-1][:3]
retrieved_chunks = [chunks[i] for i in top_k]
A few knobs are worth knowing before you scale this up: quantization (float, int8, uint8, binary, ubinary) trades a little accuracy for much smaller, cheaper-to-store vectors, and Matryoshka embeddings let you truncate a vector to fewer dimensions without re-embedding. The input_type parameter ("query" vs. "document") matters more than it looks — matching it on each side of the pipeline materially improves retrieval quality, because Voyage prepends a different fixed instruction for each.
The embedding model lineup moves fast. Check platform.claude.com's embeddings documentation for the current recommended Voyage model before locking one into a new project — treat the list above as a snapshot, not a permanent choice.
Fixing ambiguous chunks: contextual retrieval
☺ Like you're 10: A chunk that just says "revenue grew 3%" is like a photo with no caption — three percent of what, whose, and when? You need the caption stapled back on before it's useful.
Naive chunking has a subtle failure mode: a chunk that says "revenue grew 3% this quarter" is nearly useless on its own — three percent compared to what, for which company, in which period? Split out of its surrounding document, the chunk that would have answered a specific query no longer carries enough signal to be retrieved for it.
Anthropic's Contextual Retrieval technique fixes this by prepending a short, Claude-generated explanation to each chunk before it's indexed — 50 to 100 tokens that situate the chunk within the full document (which company, which filing, which section) — and doing this for both the embedding index (Contextual Embeddings) and a keyword index (Contextual BM25), combining dense semantic search with sparse keyword search in a hybrid retriever.
import anthropic
client = anthropic.Anthropic()
def situate_chunk(full_document: str, chunk: str) -> str:
"""Ask Claude for a short, chunk-specific context blurb (50-100 tokens),
then prepend it to the chunk before embedding or BM25 indexing."""
prompt = f"""<document>{full_document}</document>
Here is a chunk from the document above:
<chunk>{chunk}</chunk>
In 1-2 sentences, give the short context needed to situate this chunk
within the overall document (e.g. the company, time period, or section
it refers to), so the chunk makes sense read on its own. Answer with
only the context, nothing else."""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=100,
messages=[{"role": "user", "content": prompt}],
)
context = next(b.text for b in response.content if b.type == "text")
return f"{context}\n\n{chunk}"
Because this runs once per chunk at indexing time rather than per query, a fast, inexpensive model like Claude Haiku 4.5 is a reasonable choice — you're generating a short blurb for every chunk in the corpus, and that cost adds up across a large document set. The results, measured against a baseline of embedding standalone chunks with no added context:
| Configuration | Retrieval failure rate | Reduction vs. baseline |
|---|---|---|
| Baseline — standalone chunk embeddings | 5.7% | — |
| + Contextual Embeddings | — | 35% fewer failures |
| + Contextual Embeddings + Contextual BM25 | 2.9% | 49% fewer failures |
| + Contextual Embeddings + Contextual BM25 + reranking | 1.9% | 67% fewer failures |
Each layer is additive: contextualizing the chunks helps on its own, combining it with a keyword index helps more (dense embeddings can miss exact terms like part numbers or error codes that BM25 catches), and adding a reranking pass on top of the retrieved candidates helps most.
Foxy: I embedded a chunk that says "revenue grew 3% this quarter" and now Claude has no idea whose revenue that even is!
Professor Owl: That's because the chunk got cut loose from its document. Out of context, three percent could belong to anybody.
Nutty the Squirrel: I fix that before I ever file it away. I have Claude write a one-sentence tag naming the company and the quarter, then staple it to the chunk. Now when I dig it back up, it explains itself.
Grounding answers with citations
☺ Like you're 10: It's like an open-book test where you don't just have to give the answer — you have to underline the exact sentence you copied it from.
Once you've retrieved the right chunks, the last step is asking Claude to show its work. Anthropic's Citations API lets Claude ground its answer directly in source documents: you pass in plain text or PDF content and enable citations on that document, Claude's platform auto-chunks it into sentences internally, and the response includes citations pointing back to the exact passages that support each claim.
{
"model": "claude-sonnet-5",
"max_tokens": 1000,
"messages": [
{
"role": "user",
"content": [
{
"type": "document",
"source": { "type": "text", "media_type": "text/plain", "data": "..." },
"citations": { "enabled": true }
},
{ "type": "text", "text": "What is our refund window?" }
]
}
]
}
The field that does the work is "citations": {"enabled": true} on the document block. Output tokens that simply echo a quoted source passage aren't charged, and Anthropic reports this beats hand-rolled citation implementations by up to 15% on recall accuracy in internal evals. Citations are available on the direct Anthropic API as well as through Vertex AI and Amazon Bedrock.
Document-block shapes evolve — check the current Citations API reference for the full set of supported source types (text, PDF) and response fields before you build against it.
If you're assembling retrieved chunks yourself rather than passing whole documents through the Citations API, you can get similar behavior with plain prompting: wrap each retrieved chunk in a tagged block and instruct Claude to cite the chunk id for every claim, and to say so explicitly if the chunks don't contain the answer.
import anthropic
client = anthropic.Anthropic()
context_block = "\n\n".join(
f"<chunk id='{i}'>{c}</chunk>" for i, c in enumerate(retrieved_chunks)
)
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=1000,
system=(
"Answer only using the numbered <chunk> tags in the user message. "
"For every claim, cite the chunk id it came from, like [chunk 2]. "
"If the chunks don't contain the answer, say so instead of guessing."
),
messages=[
{
"role": "user",
"content": f"<chunks>{context_block}</chunks>\n\nQuestion: {query}",
}
],
)
for block in message.content:
if block.type == "text":
print(block.text)
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const contextBlock = retrievedChunks
.map((c, i) => `<chunk id='${i}'>${c}</chunk>`)
.join("\n\n");
const message = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1000,
system:
"Answer only using the numbered <chunk> tags in the user message. " +
"For every claim, cite the chunk id it came from, like [chunk 2]. " +
"If the chunks don't contain the answer, say so instead of guessing.",
messages: [
{
role: "user",
content: `<chunks>${contextBlock}</chunks>\n\nQuestion: ${query}`,
},
],
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
}
Retrieved chunks are untrusted content, not instructions from you — the same indirect-injection risk that applies to tool results applies here. Tell Claude explicitly what the retrieved text is and where it came from, and don't let text embedded in a chunk get treated as a command. If your corpus includes anything users can influence (support tickets, uploaded files), treat it the same way you'd treat any adversarial third-party content read via a tool.
Retrieval done right vs. dumping everything in the prompt
It's tempting to skip retrieval entirely and paste your whole knowledge base into the system prompt on every call — "Claude has everything, so it can't miss anything." In practice that's the anti-pattern, and targeted retrieval of a handful of relevant chunks is the pattern.
Retrieve only the chunks most relevant to this specific query — typically the top handful by embedding similarity, optionally reranked — and stuff just those into the prompt with an instruction to cite the supporting chunk for every claim. Cost and latency stay roughly flat as the corpus grows, and Claude's attention isn't split across content that has nothing to do with the question.
Concatenate the entire knowledge base into every request. Cost scales with total corpus size on every single call, a large enough corpus blows past the context window outright, and burying the two relevant paragraphs in thousands of irrelevant ones makes it easier for Claude to miss the answer or cite the wrong passage.
Anthropic's own RAG cookbook walks through evaluating a pipeline the same way you'd evaluate any other production system: build a synthetic eval set of questions paired with ground-truth chunks, score retrieval on its own (precision, recall, F1, MRR) separately from end-to-end answer accuracy graded by an LLM judge, and iterate on each half independently. In their worked example, adding summary indexing and a Claude-based reranking step on top of basic retrieval lifted end-to-end accuracy from 71% to 81%. Their general guidance is to prioritize recall over precision at the retrieval stage — it's cheaper to let Claude's generation step filter out an irrelevant chunk than to have retrieval silently drop the one chunk that actually contained the answer.
Pick a handful of your own text files (READMEs, notes, docs) and chunk them by heading. Embed each chunk with Voyage's voyage-4 model using input_type="document", embed a test query with input_type="query", and retrieve the top 3 chunks by dot product. Feed those chunks into a Claude Messages API call that requires a chunk citation for every claim, and try it on a query that's genuinely ambiguous out of context (e.g. a number with no stated unit or period). Then add the contextual-retrieval step — prepend a one-sentence, Claude-generated summary to each chunk before embedding — and see whether that ambiguous query retrieves correctly.
You should now be able to explain why Claude's frozen training knowledge isn't enough for private or current data, walk through the two-path RAG pipeline (offline indexing, online query), embed chunks and queries with Voyage AI using matching input_type values, explain why Contextual Retrieval cuts failure rates, and ground an answer in citations instead of just a plausible-sounding claim. Next, see how retrieval fits into a larger tool-using system in Building Effective Agents.
Check your answers
- Why doesn't just making the prompt bigger fix Claude's knowledge gap the way retrieval does? A bigger prompt still relies on you already knowing which facts to paste in, and it doesn't make Claude's underlying training knowledge current or verifiable. Retrieval instead searches your corpus at query time for the passages actually relevant to the question and lets Claude cite exactly which one supports each claim — something parametric memory alone can never do.
- What problem does Contextual Retrieval solve, and how much does it cut failures when combined with BM25 and reranking? It fixes chunks that are ambiguous once split from their source document (like "revenue grew 3%" with no company or period) by prepending a short Claude-generated blurb before indexing. That alone cuts retrieval failures 35%; combined with Contextual BM25 it's 49%; adding a reranking pass on top brings it to 67%, versus a 5.7% baseline failure rate.
- Why should retrieved chunks be treated as untrusted content rather than instructions? Anything pulled from your corpus — especially if users can influence it, like support tickets or uploaded files — carries the same indirect-injection risk as tool results. Claude should be told explicitly what the text is and where it came from, so text embedded inside a chunk never gets mistaken for a command from you.