learn-claude-with-phoebe / Deep dive 6.4
Learn Claude with Phoebe · Deep-dive track 6.4

RAG & agentic search

Claude has a knowledge cutoff, has never seen your internal docs, and cannot fit your whole wiki in one request. Retrieval augmented generation fixes all three: find the relevant pieces, put them in context, answer grounded in them. Today you build the full pipeline - and learn the two upgrades that separate demo RAG from production RAG.

🔴 Deep dive DS & AI 45 min self-paced or live
0-5 · Setup 5-35 · Core 35-45 · Try it
Part 0

Why this page exists

Every "chat with our docs" project is RAG. Most of them disappoint - not because the model is weak, but because retrieval quietly returns the wrong chunks and nobody measures it. This page builds the pipeline in the right order: chunk, index two ways, retrieve hybrid, answer with citations, then rerank and contextualize. Each step is a page of Python, and you will know exactly which knob to turn when quality is off.

Core - the pipeline everyone needs Advanced - production upgrades RAG module of all 3 engineering courses
★ What you walk out with A working hybrid retrieve-and-answer pipeline in about 40 lines, plus the reranking and contextual-retrieval upgrades and a way to measure whether retrieval or generation is your actual problem.
Part 1 · foundations

Why RAG, and how to chunk 7 min core

Retrieval quality is decided before any query runs - at indexing time, by how you cut the documents.

CoreWhy RAG - and when NOT to bother3 min

Three problems, one mechanism:

  • Knowledge cutoff: the model was trained up to a date; your world moved on.
  • Private data: your runbooks, tickets, and contracts were never in training data.
  • Context limits: even a huge context window cannot hold your whole wiki, and stuffing it costs money and attention - relevant needles do better in a small, clean haystack.

The mechanism: at question time, retrieve the top-k most relevant chunks from your corpus and paste them into the prompt with the question. Claude answers from what it was shown, grounded and citable.

Paste the document, or build RAG Is the corpus under ~100k stable tokens? yes no Just paste it into the prompt Retrieve: build the hybrid pipeline RAG earns its complexity only once the corpus is too big, changes often, or needs per-question search.
🔍 Click to zoom - under 100k stable tokens, paste; above that, retrieve
Don't over-engineer small corpora If your entire knowledge base is one 30-page handbook, RAG is the wrong tool - just paste the document (6.5 covers PDFs and prompt caching, which makes repeat-pasting cheap). RAG earns its complexity when the corpus is too big for context, changes often, or must be searched per-question. Rule of thumb: under ~100k tokens of stable text, paste; above that, retrieve.
CoreChunking strategies: size vs structure, and overlap4 min

A chunk is the unit of retrieval: too small and it lacks the context to be understood, too big and one relevant sentence drags in a page of noise. Two families:

  • By size: cut every N characters or tokens (typically 500-1,000 tokens) with 10-20% overlap so a sentence straddling a boundary survives in at least one chunk. Dumb, robust, works on anything.
  • By structure: split on headings, paragraphs, or sections, so each chunk is a semantically complete unit. Better retrieval when documents HAVE structure - and yours mostly do.
Your corpusChunking that worksWatch out for
Docs / wiki / runbooksBy heading (H2/H3 sections), fall back to size for monster sectionsKeep the heading text IN the chunk - it is the best retrieval signal you have
Support tickets / emailsOne ticket (or one thread) = one chunk; split only huge threadsDo not merge tickets - cross-ticket chunks retrieve garbage
Tables / CSVsOne row group or logical slice per chunk, headers repeated in every chunkA table split mid-row without headers is unreadable to the model too

Store metadata with every chunk - source document, section title, URL - as a dict like {"text": ..., "source": ..., "section": ...}. You will need it for citations in Part 2 and for contextual retrieval in Part 3.

Part 2 · the pipeline

Two kinds of search, one RAG flow 13 min core

Semantic search finds meaning, lexical search finds exact terms. Production pipelines use both - then hand the winners to Claude.

CoreEmbeddings and semantic search4 min

An embedding maps a text to a vector such that similar meanings land close together. Search = embed the query, embed the chunks (once, at index time), rank by cosine similarity. Anthropic does not ship an embeddings endpoint - it recommends external providers, and Voyage AI is the usual pick (any embedding provider slots in the same way).

★ Embed and retrieve (minimal)import numpy as np import voyageai vo = voyageai.Client() # uses VOYAGE_API_KEY texts = [c["text"] for c in chunks] doc_emb = np.array( vo.embed(texts, model="voyage-3", input_type="document").embeddings ) def semantic_search(question, k=3): q = np.array( vo.embed([question], model="voyage-3", input_type="query").embeddings[0] ) sims = doc_emb @ q / (np.linalg.norm(doc_emb, axis=1) * np.linalg.norm(q)) return [chunks[i] for i in sims.argsort()[::-1][:k]]
  • This IS a vector store for corpora up to tens of thousands of chunks - a numpy matrix and a dot product. Reach for a real vector database (pgvector, Qdrant, Chroma) when you need persistence, filters, or millions of vectors, not before.
  • input_type matters: Voyage embeds queries and documents slightly differently for better matching - pass it.
  • Embed once, cache forever: chunks only need re-embedding when they change. Store vectors next to the chunks.
CoreBM25: keyword search still wins on exact terms4 min

Ask "what does error E-4102 mean?" and semantic search shrugs - E-4102 has no meaning to embed. BM25, the classic lexical algorithm behind decades of search engines, nails it: it rewards exact token matches, weighted by rarity. Product codes, error IDs, people's names, SKUs, legal clause numbers - lexical territory, all of it.

★ BM25 in four linesfrom rank_bm25 import BM25Okapi tokenized = [c["text"].lower().split() for c in chunks] bm25 = BM25Okapi(tokenized) def lexical_scores(question): return bm25.get_scores(question.lower().split())

Hybrid search runs both indexes and merges: normalize each score list, take a weighted sum (start 50/50), rank by the combined score. That is the "multi-index pipeline" from the official module - two indexes over the same chunks, one merged ranking. Semantic catches paraphrases ("staff offboarding" finds "employee exit process"), lexical catches identifiers, and each covers the other's blind spot.

CoreThe full RAG flow, end to end5 min

Question in, cited answer out. Everything above, assembled - this is the skeleton to steal:

★ The ~40-line RAG pipelineimport anthropic import numpy as np import voyageai from rank_bm25 import BM25Okapi client = anthropic.Anthropic() vo = voyageai.Client() # ---- index once ---- chunks = chunk_documents(load_docs()) # [{"text": ..., "source": ...}, ...] texts = [c["text"] for c in chunks] doc_emb = np.array( vo.embed(texts, model="voyage-3", input_type="document").embeddings ) bm25 = BM25Okapi([t.lower().split() for t in texts]) # ---- retrieve (hybrid) ---- def retrieve(question, k=5): q = np.array( vo.embed([question], model="voyage-3", input_type="query").embeddings[0] ) sem = doc_emb @ q / (np.linalg.norm(doc_emb, axis=1) * np.linalg.norm(q)) lex = bm25.get_scores(question.lower().split()) sem = (sem - sem.min()) / (sem.max() - sem.min() + 1e-9) lex = (lex - lex.min()) / (lex.max() - lex.min() + 1e-9) combined = 0.5 * sem + 0.5 * lex return [chunks[i] for i in combined.argsort()[::-1][:k]] # ---- answer with citations ---- def answer(question): hits = retrieve(question) context = "\n\n".join( f'<chunk source="{c["source"]}">\n{c["text"]}\n</chunk>' for c in hits ) msg = client.messages.create( model="claude-sonnet-5", max_tokens=1024, system=( "Answer using ONLY the provided chunks. Cite the source of every " "claim in brackets, like [handbook.md]. If the chunks do not " "contain the answer, say you could not find it - do not guess." ), messages=[ {"role": "user", "content": f"{context}\n\nQuestion: {question}"} ], ) return msg.content[0].text print(answer("What is our laptop refresh policy?"))
  • XML-style tags around chunks (6.2's lesson applied): Claude reliably distinguishes retrieved material from the question, and the source attribute enables citations.
  • The "say you could not find it" line is load-bearing. Without it, Claude answers from training data when retrieval misses - the classic RAG hallucination, and it looks exactly like a correct answer.
  • Agentic search is this flow with the loop from 6.3: expose retrieve as a tool and Claude decides when to search, reads the results, and searches again with a reformulated query if they are weak. Ten extra lines, noticeably better on vague questions.
Part 3 · from demo to production

Reranking, contextual retrieval, and measuring it 10 min core

The Bedrock and Vertex courses add two techniques here for a reason: they are the highest-leverage fixes when plain hybrid RAG plateaus.

AdvancedReranking: retrieve wide, then choose carefully3 min

Fast retrieval is approximate - the right chunk is usually in the top 50 but not always in the top 5. So retrieve wide and cheap, then let a slower, smarter model re-score the candidates against the question and keep the best few. A dedicated reranker (Voyage rerank-2, Cohere Rerank) is fastest; Claude itself works fine as the judge:

★ Rerank 50 down to 5 with Claude as judgedef rerank(question, candidates, keep=5): scored = [] for c in candidates: # candidates = retrieve(question, k=50) msg = client.messages.create( model="claude-haiku-4-5", # small + cheap is plenty here max_tokens=4, system=( "Rate 0-10 how useful this chunk is for answering the " "question. Reply with the number only." ), messages=[ { "role": "user", "content": f"Question: {question}\n\nChunk:\n{c['text']}", } ], ) scored.append((float(msg.content[0].text.strip()), c)) scored.sort(key=lambda pair: -pair[0]) return [c for _, c in scored[:keep]]

Run the candidate scoring concurrently (or as a 6.3-style batch for offline jobs) - it is 50 independent calls. When latency matters, the dedicated reranker does the same job in one call: vo.rerank(question, documents, model="rerank-2", top_k=5).

AdvancedContextual retrieval: fix chunks before they are embedded4 min

The quiet failure of chunking: a chunk saying "the fee increases to 3.5% after the first year" embeds fine - but for WHICH product, WHICH contract? The surrounding document knew; the chunk forgot. Anthropic's published fix, contextual retrieval, asks Claude to write a 1-2 sentence situating context for each chunk and prepends it BEFORE embedding and BM25 indexing. Their measurements: substantially fewer retrieval failures (roughly a one-third to one-half reduction, more when combined with reranking).

★ Contextualize each chunk at index timedef contextualize(doc_text, chunk_text): msg = client.messages.create( model="claude-haiku-4-5", max_tokens=150, messages=[ { "role": "user", "content": ( f"<document>\n{doc_text}\n</document>\n\n" "Here is a chunk from that document:\n" f"<chunk>\n{chunk_text}\n</chunk>\n\n" "Write 1-2 sentences situating this chunk within the " "document, to improve search retrieval of the chunk. " "Reply with the context only." ), } ], ) return msg.content[0].text + "\n\n" + chunk_text chunks = [ {**c, "text": contextualize(doc_text_for(c), c["text"])} for c in raw_chunks ] # then embed + BM25-index the contextualized text as usual

It costs one small-model call per chunk, once, at index time - and prompt caching (6.5) makes it cheap, since every chunk of the same document reuses the cached doc_text. Index-time spend for query-time quality is almost always a good trade.

AdvancedEvaluating RAG: retrieval and generation are separate exams3 min

"The answers are bad" has two different diagnoses, and 6.2's eval discipline applies to each separately:

  • Retrieval metrics: for each test question, did the chunk containing the answer surface in the top k? Build 20-30 question-to-gold-chunk pairs and compute recall@k. No API calls needed, runs in seconds - this is where most failures live, and where chunking, hybrid weights, reranking, and contextual retrieval all show up as measurable deltas.
  • Answer metrics: GIVEN the right chunks in context, is the answer correct, cited, and faithful? Grade with the model-based grader from 6.2. Failures here mean prompt work, not retrieval work.
Real world

A team's docs-bot gave wrong answers on 30% of pilot questions and everyone blamed the model. A 25-question retrieval eval took an afternoon and showed recall@5 was 52% - the model never saw the right text. Structure-aware chunking took it to 70%, contextual retrieval to 88%, and answer accuracy followed almost one-for-one. Nobody touched the answering prompt. Measure retrieval first; it is usually the culprit and always the cheaper fix.

Recall at k=5 after each retrieval upgrade Baseline (no upgrades) 52% + structure chunking 70% + contextual retrieval 88% Recall at k=5 (%): the share of questions where the right chunk was actually retrieved.
🔍 Click to zoom - structure-aware chunking and contextual retrieval nearly closed the gap
One knob at a time Change chunking OR hybrid weights OR reranking, rerun the retrieval eval, log the delta. RAG has enough moving parts that untracked tweaking is indistinguishable from superstition.
35-45 · hands on

Try it yourself ◐ 3 exercises

1 · Build the pipeline on real docs. Take 10-20 real documents (your team wiki export, or any public docs), chunk by headings, and stand up the 40-line hybrid pipeline from Part 2. Ask 5 questions you know the answers to. For each, print the retrieved chunks BEFORE the answer - get in the habit of reading what Claude was actually shown.

2 · Break semantic search, fix it with hybrid. Add 3 questions with exact identifiers in them (an error code, a person's name, a product SKU). Run pure semantic (weight 1.0/0.0), pure lexical (0.0/1.0), then 50/50 hybrid, and compare which chunks surface at each setting. Write down where each mode failed - that intuition is the whole lesson.

3 · Measure, then upgrade. Build a 15-question retrieval eval (question, gold chunk id) and compute recall@5 for your pipeline. Then add ONE upgrade - reranking (50 to 5) or contextual retrieval - and rerun. Report the delta like you would to a stakeholder: "recall@5 went from X to Y for $Z of index-time tokens."

Source material

Official courses covered

This page teaches the RAG and Agentic Search module that all three 8-hour engineering courses share, plus the retrieval upgrades from the Bedrock and Vertex editions.

RAG and Agentic Search module - all 3 engineering courseschunking, embeddings, semantic search, BM25, multi-index / hybrid pipeline, full RAG flow
Reranking + contextual retrieval - Bedrock/Vertex extrasreranking results, contextual retrieval technique, and the retrieval-vs-answer eval split

Deep dive 6.4 cheat sheet · pin this

RAG or paste?Small stable corpus (under ~100k tokens): paste the doc, cache the prompt. Big, changing, or per-question: retrieve.
ChunkingSplit by structure (headings, tickets) first, by size (500-1,000 tokens, 10-20% overlap) as fallback. Keep headings and metadata in the chunk.
Hybrid searchEmbeddings (Voyage or similar) for meaning + BM25 for exact codes/names. Normalize, weight 50/50, merge. Each covers the other's blind spot.
The flowQuestion, retrieve top-k hybrid, wrap chunks in tags with sources, answer with citations and a "say if not found" rule.
UpgradesRetrieve 50 then rerank to 5; prepend Claude-written chunk context before indexing (contextual retrieval). Both move recall the most.
Eval the halvesRecall@k on gold chunks measures retrieval; 6.2-style graded answers measure generation. Diagnose separately - retrieval is usually the culprit.