M05 — RAG / Retrieval Grounding
Acme is fluent, remembers the conversation, and can call tools — but it has never read your return policy. Ask about refunds and it will confidently make something up. RAGRetrieval-Augmented Generation: fetch the relevant text from your own knowledge base and put it in the prompt, so the model answers from your content instead of its training. fixes that: retrieve the right passage from your help docs and hand it to the model. Three SDKs, three embedding APIs, one retrieval pattern — and one surprise about Anthropic.
Learning Objectives
- Explain what RAG is and why it beats "hope the model knows" for private, current, or factual content
- Walk the RAG pipeline: chunk → embedTurn text into a vector (a list of numbers) that captures its meaning. Texts with similar meaning end up close together in vector space. → store → embed the query → similarity search → ground the answer
- Generate embeddings in all three ecosystems — and discover that Anthropic has no embeddings model, so Claude RAG uses Voyage AI
- Write provider-agnostic cosine-similarity retrieval that works with any embedder
- Ground an answer by stuffing retrieved context into the prompt — which is just M00's call with extra context
- Understand that embeddings and generation are separable: mix any embedder with any chat model
Why RAG?
BEFORE: Two support reps take a policy quiz. One answers from memory. The other has the policy binder open and looks up each answer before responding.
PAIN: The from-memory rep sounds just as confident — but misremembers "30-day returns" as "60 days," and invents a warranty clause that doesn't exist. Confident and wrong is worse than "let me check," because customers act on it. A model with no access to your docs is exactly this rep: fluent, authoritative, and quietly making things up (that's hallucinationWhen a model states something false with full confidence, because it's predicting plausible text rather than looking anything up.).
MAPPING: RAG hands the model the binder — but open to the right page. Before it answers, you retrieve the handful of policy passages most relevant to the question and paste them into the prompt. The model then answers an open-book question from your actual content, not its guesses. Same fluent rep, now grounded.
Three problems RAG solves that a bigger model can't: private knowledge (your policies were never in any training set), freshness (yesterday's price change), and citability (you can show which passage the answer came from). For a support agent, "our return window is 30 days, per this policy line" beats a confident guess every time — and it's the difference between a bot you can trust with customers and one you can't.
The RAG Pipeline
RAG has two phases. Indexing (once, offline): split your docs into chunksSmall passages (a paragraph or a few sentences). You retrieve chunks, not whole documents, so the model gets just the relevant slice., embed each chunk into a vector, and store the vectors. Querying (per question): embed the user's question the same way, find the chunks whose vectors are most similar (nearest neighbors), and paste those chunks into the prompt as context. The model generates its answer from that context.
The magic is embeddings: text turned into vectors where similar meaning = nearby vectors. "Can I send back worn shoes?" lands near "Returns: unworn items with tags…" even though they share almost no words. Similarity is measured with cosine similarityA score from -1 to 1 for the angle between two vectors. 1 = same direction (very similar meaning), 0 = unrelated. The standard way to rank retrieval matches..
Watch one question flow through the pipeline — the KB is embedded once, then the question retrieves the single best-matching policy line and the model answers from it:
Only one of the four policy chunks reached the model — the returns line — because its vector was closest to the question's vector. The model never saw the shipping or warranty text, so it couldn't be distracted or confused by it. RAG is as much about excluding irrelevant context as including the relevant bit. Retrieve narrowly; answer accurately.
Step 1 — The Knowledge Base & Retrieval
First, the parts with no AI in them: a tiny help-doc KB and a pure-code cosine-similarity search. This is provider-agnostic — it works with vectors from any embedder. (Each doc here is already short, so one doc = one chunk; real docs get split into paragraph-sized chunks first.)
# acme_kb.py — the help-doc KB + pure-Python retrieval (no AI here).
import math
DOCS = [
"Returns: unworn items with tags can be returned within 30 days of delivery for a full refund.",
"Shipping: standard shipping takes 3-5 business days; express is 1-2 days for $12.",
"Warranty: all footwear carries a 1-year warranty against manufacturing defects.",
"Refunds: refunds go to the original payment method within 5-7 business days of receiving the return.",
"Order changes: you can change or cancel an order within 1 hour of placing it, before it ships.",
]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb)
def top_k(query_vec, doc_vecs, k=2):
ranked = sorted(range(len(doc_vecs)),
key=lambda i: cosine(query_vec, doc_vecs[i]), reverse=True)
return ranked[:k] # indices of the best-matching docs
// acmeKb.mjs — the help-doc KB + pure-JS retrieval (no AI here).
export const DOCS = [
"Returns: unworn items with tags can be returned within 30 days of delivery for a full refund.",
"Shipping: standard shipping takes 3-5 business days; express is 1-2 days for $12.",
"Warranty: all footwear carries a 1-year warranty against manufacturing defects.",
"Refunds: refunds go to the original payment method within 5-7 business days of receiving the return.",
"Order changes: you can change or cancel an order within 1 hour of placing it, before it ships.",
];
function cosine(a, b) {
const dot = a.reduce((s, x, i) => s + x * b[i], 0);
return dot / (Math.hypot(...a) * Math.hypot(...b));
}
export function topK(queryVec, docVecs, k = 2) {
return docVecs
.map((v, i) => [i, cosine(queryVec, v)])
.sort((x, y) => y[1] - x[1])
.slice(0, k)
.map(([i]) => i); // indices of the best-matching docs
}
Step 2 — Embed (the one provider-specific part)
This is where the three ecosystems differ — and where Anthropic surprises you. Each tab defines one embed(texts, input_type) function returning a list of vectors. Note the big one: Anthropic has no embeddings model at all, so Claude-based RAG embeds with Voyage AI (Anthropic's official recommendation).
# embed_voyage.py — Anthropic has NO embeddings model.
# Its docs recommend Voyage AI, so Claude RAG embeds with Voyage.
# pip install voyageai | export VOYAGE_API_KEY="..."
import voyageai
vo = voyageai.Client() # reads VOYAGE_API_KEY
def embed(texts, input_type): # input_type: "document" | "query"
result = vo.embed(texts, model="voyage-4", input_type=input_type)
return result.embeddings # list of 1024-d vectors (L2-normalized)
// embed_voyage.mjs — Anthropic has NO embeddings model; use Voyage AI.
// npm i voyageai | VOYAGE_API_KEY
import { VoyageAIClient } from "voyageai";
const vo = new VoyageAIClient({ apiKey: process.env.VOYAGE_API_KEY });
export async function embed(texts, inputType) { // "document" | "query"
const resp = await vo.embed({ input: texts, model: "voyage-4", inputType });
return resp.data.map(d => d.embedding); // list of 1024-d vectors
}
# embed_gemini.py — Gemini has a native embeddings model.
from google import genai
from google.genai import types
client = genai.Client() # reads GEMINI_API_KEY
def embed(texts, input_type): # "document" | "query"
task = "RETRIEVAL_QUERY" if input_type == "query" else "RETRIEVAL_DOCUMENT"
result = client.models.embed_content(
model="gemini-embedding-001", contents=texts,
config=types.EmbedContentConfig(task_type=task),
)
return [e.values for e in result.embeddings]
// embed_gemini.mjs — Gemini has a native embeddings model.
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({}); // reads GEMINI_API_KEY
export async function embed(texts, inputType) { // "document" | "query"
const taskType = inputType === "query" ? "RETRIEVAL_QUERY" : "RETRIEVAL_DOCUMENT";
const resp = await ai.models.embedContent({
model: "gemini-embedding-001", contents: texts, config: { taskType },
});
return resp.embeddings.map(e => e.values);
}
# embed_openai.py — OpenAI has a native embeddings model.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
def embed(texts, input_type=None): # OpenAI has no document/query distinction
result = client.embeddings.create(model="text-embedding-3-small", input=texts)
return [d.embedding for d in result.data] # list of 1536-d vectors
// embed_openai.mjs — OpenAI has a native embeddings model.
import OpenAI from "openai";
const client = new OpenAI(); // reads OPENAI_API_KEY
export async function embed(texts, _inputType) { // no document/query distinction
const resp = await client.embeddings.create({
model: "text-embedding-3-small", input: texts,
});
return resp.data.map(d => d.embedding); // list of 1536-d vectors
}
- Anthropic doesn't have one. This is the module's headline: no
anthropic.embeddingsexists. Claude RAG pairs Claude generation with a third-party embedder — Voyage AI is the official pick (voyageai, modelvoyage-4). - document vs query input types. Voyage and Gemini want you to say whether you're embedding a stored document (
"document"/RETRIEVAL_DOCUMENT) or a search query ("query"/RETRIEVAL_QUERY) — it measurably improves retrieval. OpenAI has no such distinction; you embed both the same way. - Different dimensions (Voyage 1024, OpenAI 1536, Gemini configurable). Fine on their own — but see the "never mix indexes" warning below.
Step 3 — Retrieve & Ground
Now assemble it. This part is provider-agnostic too: it uses embed() from Step 2 (any provider) and top_k() from Step 1, then builds a grounded prompt. The final generate() call is exactly the one-shot request you wrote in M00 — just with retrieved context prepended. Embed the KB once at startup; retrieve per question.
# rag.py — ties Steps 1+2 together. Works with ANY provider's embed()/generate().
from acme_kb import DOCS, top_k
from embed_voyage import embed # swap for embed_gemini / embed_openai freely
DOC_VECS = embed(DOCS, "document") # index the KB once, at startup
def answer(question: str, generate) -> str:
q_vec = embed([question], "query")[0] # embed the question
hits = top_k(q_vec, DOC_VECS, k=2) # nearest chunks (indices)
context = "\n".join(DOCS[i] for i in hits) # the retrieved passages
prompt = (
"Answer the customer using ONLY the policy context below. "
"If the answer isn't in it, say you'll check with a colleague.\n\n"
f"Context:\n{context}\n\nQuestion: {question}"
)
return generate(prompt) # generate() = a one-shot call to ANY chat model (see M00)
# `generate` is literally M00: e.g. a Claude / Gemini / GPT one-shot that
# takes a prompt string and returns text. Grounding = M00 + retrieved context.
print(answer("Can I return shoes I wore once?", generate))
// rag.mjs — ties Steps 1+2 together. Works with ANY provider's embed()/generate().
import { DOCS, topK } from "./acmeKb.mjs";
import { embed } from "./embed_voyage.mjs"; // swap for embed_gemini / embed_openai
const DOC_VECS = await embed(DOCS, "document"); // index the KB once, at startup
export async function answer(question, generate) {
const qVec = (await embed([question], "query"))[0]; // embed the question
const hits = topK(qVec, DOC_VECS, 2); // nearest chunks (indices)
const context = hits.map(i => DOCS[i]).join("\n"); // retrieved passages
const prompt =
"Answer the customer using ONLY the policy context below. " +
"If the answer isn't in it, say you'll check with a colleague.\n\n" +
`Context:\n${context}\n\nQuestion: ${question}`;
return generate(prompt); // generate() = a one-shot call to ANY chat model (M00)
}
// `generate` is literally M00: a one-shot Claude / Gemini / GPT call.
console.log(await answer("Can I return shoes I wore once?", generate));
There's no "RAG API." RAG is a prompt-construction technique: embed → find nearest chunks → paste them into an ordinary prompt → make an ordinary generation call. The only new SDK surface in this whole module is the one-line embed() from Step 2. Everything else is plain code plus the M00 call you already know. That's why RAG works identically across all three providers — the retrieval half doesn't care who generates.
Run It
I'm sorry, but worn shoes don't qualify — our policy is that returns must be unworn and still have their tags, within 30 days of delivery. If there's a defect, though, our 1-year warranty may cover it — want me to check?
embed import (Voyage → Gemini → OpenAI): the answer stays correct because retrieval is provider-agnostic.
Mix & Match: Embeddings ≠ Generation
Retrieval and generation are completely independent. The embedder turns text into vectors; the chat model writes the answer. Nothing requires them to come from the same company. Perfectly normal combinations: Voyage embeddings + Claude generation (the standard Claude-RAG stack, since Anthropic has no embedder), OpenAI embeddings + Gemini generation, or all-one-vendor. Choose your embedder for retrieval quality and cost, and your chat model for answer quality — separately.
Never mix vectors from different embedding models in the same index. A voyage-4 vector and a text-embedding-3-small vector live in different spaces — cosine similarity between them is meaningless noise. Pick one embedding model, embed your whole corpus and your queries with it, and if you switch models later, re-embed everything. (Different chat models are fine to swap freely — that constraint is only on the embedder.)
Three Ways, One Idea
| Concept | Anthropic | Google Gemini | OpenAI |
|---|---|---|---|
| Embeddings model? | None — use Voyage AI | Native | Native |
| Package | voyageai | google-genai | openai |
| Key env var | VOYAGE_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY |
| Model | voyage-4 | gemini-embedding-001 | text-embedding-3-small |
| Call | vo.embed(texts, input_type=…) | models.embed_content(…) | embeddings.create(input=…) |
| Read vector | result.embeddings[i] | result.embeddings[i].values | result.data[i].embedding |
| doc vs query type? | Yes (input_type) | Yes (task_type) | No |
| Default dims | 1024 | configurable (e.g. 768/3072) | 1536 |
Anthropic made a deliberate focus choice — build the best chat/agent models, partner (Voyage) for embeddings — which is why "Claude RAG" is really "Voyage + Claude." Google and OpenAI ship embeddings as part of a broader platform. The document/query (or task-type) split that Voyage and Gemini expose reflects research that queries and stored passages benefit from slightly different encodings; OpenAI folds that into one model. All produce the same kind of thing — a vector — and the retrieval code doesn't care which made it. That's why your cosine/top_k from Step 1 never changed.
Knowledge Check
Q1: What problem does RAG primarily solve?
Q2: What's the headline surprise about embeddings across these three SDKs?
Q3: How does an embedding make "worn shoes?" match a doc about "unworn returns"?
Q4: You want to swap your embedder from OpenAI to Voyage on an existing index. What must you do?
Q5: In the code, what is the generate() step actually doing?
embed().Module Summary
Key Takeaways
- RAG = retrieve then generate: embed your docs, find the chunks nearest the question, paste them into the prompt, answer from them.
- Anthropic has no embeddings model — Claude RAG uses Voyage AI. Gemini and OpenAI have native embedders.
- Retrieval is provider-agnostic: cosine similarity over vectors doesn't care who made them. Only
embed()is provider-specific. - Embeddings and generation are separable — mix any embedder with any chat model. But never mix vectors from different embedding models in one index.
- Grounding is just M00 + context. There's no RAG API; it's a prompt-construction pattern around the call you already know.
Next: M06 — Multi-Agent Systems
Acme is now one very capable agent. But real support desks have specialists: a triage rep who routes billing to Billing and shipping to Shipping. In M06 you'll split Acme into a triage agent that hands off to Orders and Refunds specialists — built three ways: Claude subagents, the OpenAI Agents SDK (handoffs), and Google's ADK.