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?

Everyday Analogy

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.

Why It Matters

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

Technical Definition — Retrieval-Augmented Generation

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:

RAG — Retrieve the Right Page, Then Answer
1
Index the help docs (once, offline)chunk → embed → store vectors
Returns policy Shipping Warranty Refund timing
2
Customer asks: "Can I return shoes I wore once?"the query
3
Embed the question into a query vectorembed(query, input_type="query")
4
Cosine similarity → best match is the Returns policytop_k(query_vec, doc_vecs) → #0
5
Model answers, grounded in that passage"Returns must be unworn with tags — worn shoes don't qualify, sorry!"
What Just Happened?

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
}
Three Embedding APIs, Two Differences That Bite
  • Anthropic doesn't have one. This is the module's headline: no anthropic.embeddings exists. Claude RAG pairs Claude generation with a third-party embedder — Voyage AI is the official pick (voyageai, model voyage-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));
What Just Happened? — RAG Is Retrieval + M00

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

Expected output (grounded in the retrieved returns policy)
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?
✅ Checkpoint: Ask something not in the KB — "do you price-match competitors?" With the "ONLY the context" instruction, a grounded agent says it'll check with a colleague instead of inventing a policy. That refusal-to-hallucinate is the whole point. Then try swapping the embed import (Voyage → Gemini → OpenAI): the answer stays correct because retrieval is provider-agnostic.

Mix & Match: Embeddings ≠ Generation

The Big Idea — Two Separable Systems

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.

The One Rule You Can't Break

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

ConceptAnthropicGoogle GeminiOpenAI
Embeddings model?None — use Voyage AINativeNative
Packagevoyageaigoogle-genaiopenai
Key env varVOYAGE_API_KEYGEMINI_API_KEYOPENAI_API_KEY
Modelvoyage-4gemini-embedding-001text-embedding-3-small
Callvo.embed(texts, input_type=…)models.embed_content(…)embeddings.create(input=…)
Read vectorresult.embeddings[i]result.embeddings[i].valuesresult.data[i].embedding
doc vs query type?Yes (input_type)Yes (task_type)No
Default dims1024configurable (e.g. 768/3072)1536
Why the Differences?

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?

AIt makes the model generate faster
BIt lets the model call tools
CIt grounds answers in your own/current content, cutting hallucination on private or fresh facts
DIt gives the model permanent memory
Correct! RAG hands the model the relevant passages from your knowledge base so it answers from your content instead of guessing — the fix for private knowledge, freshness, and citability.

Q2: What's the headline surprise about embeddings across these three SDKs?

AGemini has no embeddings model
BAnthropic has no embeddings model — Claude RAG uses a third-party embedder like Voyage AI
COpenAI embeddings only work with GPT-3
DAll three share one embeddings endpoint
Correct! Anthropic offers no embeddings model and officially points to Voyage AI. So the standard Claude-RAG stack is Voyage embeddings + Claude generation.

Q3: How does an embedding make "worn shoes?" match a doc about "unworn returns"?

AExact keyword matching
BAlphabetical sorting
CIt asks the model to guess
DBoth become vectors capturing meaning; similar meaning → nearby vectors → high cosine similarity
Correct! Embeddings map meaning to position in vector space, so semantically related texts land close together even with different words. Cosine similarity ranks the matches.

Q4: You want to swap your embedder from OpenAI to Voyage on an existing index. What must you do?

ARe-embed the entire corpus with the new model — vectors from different models aren't comparable
BNothing — vectors are interchangeable across models
COnly re-embed the queries
DConvert the old vectors with a formula
Correct! Each embedding model has its own vector space; cosine similarity across models is meaningless. Switching embedders means re-embedding the whole corpus (and queries).

Q5: In the code, what is the generate() step actually doing?

AA special RAG API endpoint
BAn ordinary M00-style one-shot call — just with the retrieved context pasted into the prompt
CRe-training the model on your docs
DAnother embedding call
Correct! There's no RAG API. Generation is the plain M00 call; RAG just constructs a better prompt by prepending retrieved context. The only new surface this module added was 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.