The demo showed natural-language questions producing answers cited inline to Google Drive sources, across many companies and many documents. Behind that, two distinct sets of challenges:
Indexing challenges
Many formats — docs, sheets, slides, PDFs — each needs text extraction
Chunking decisions — size and overlap choices govern retrieval quality forever after
Batch economics — embedding APIs have hard per-request limits; one bad doc shouldn't kill the run
Idempotency — re-indexing a changed file must not duplicate or leak old chunks
Same-model invariant — embedder at index time MUST equal embedder at query time
RAG challenges
Context budget — typical 50–80K tokens of context, spread across many sources
Coverage — cross-cutting queries ("list all X") must see every source, not just the loudest
Citations — every factual claim cited; nothing invented; links back to source
Follow-up handling — "compare their margins" embeds badly; needs rewriting
Structured + retrieved together — first-party data supplements vector search
These are this module's topics — taught with the smallest possible snippets, then practiced in the project.
Overview
How this module is organised
Two halves. Lecture in each, one combined project at the end.
Half 1
Indexing
Turn unstructured documents into a searchable vector index. Chunking, embeddings, vector stores, batch economics.
Half 2
RAG
Use that index to answer questions with citations. Retrieval budgets, context assembly, query rewrite, presentation.
First, the obvious question: why not just stuff everything into a giant context window (say 1M tokens) and skip indexing and retrieval entirely?
Answer
It doesn't fit. A real corpus — many companies, many documents — is tens or hundreds of millions of tokens. 1M is a rounding error against that.
Cost. You pay per input token on every call. Re-sending a huge buffer each turn is wildly expensive versus retrieving the handful of chunks that matter.
Latency. Bigger inputs mean slower responses, every single request.
Accuracy. Models degrade on very long contexts — relevant facts buried in the middle get missed ("lost in the middle"). A tight, relevant context usually answers better, not just cheaper.
Retrieval isn't a workaround for small context windows — it's how you stay fast, cheap, and accurate at scale.
Context Management
Indexing
Documents → chunks → vectors → store
Indexing · Lecture
Embeddings
An embedding is a fixed-length numeric vector. Semantically similar text lands near each other in vector space.
embedder.embed(["the cat sat on the mat",
"고양이가 매트 위에 앉았다",
"Python is a programming language"])
# → [
# [0.012, -0.034, 0.901, ..., 0.005], # 768 floats
# [0.014, -0.030, 0.893, ..., 0.008], # very close to row 1
# [0.412, 0.107, 0.022, ..., -0.319], # far from rows 1 and 2
# ]
Dimension — typically 384, 768, 1536, 3072. Higher = more precision, more storage, slower search.
Similarity — usually cosine (angle between vectors). Normalize to unit length and cosine ≈ dot product.
Provider matters — different models use different geometry; embeddings from model A and model B are not comparable.
Critical: use the same model at index time and query time. Always. This is the most common silent RAG bug.
Indexing · Lecture · Primer
What is an embedding model?
A model that turns a piece of text into a fixed-length list of numbers — a vector — so that meaning becomes geometry. Similar meanings land near each other; different meanings land far apart.
“the cat sat on the mat”
“고양이가 매트 위에 앉았다”
“Python is a programming language”
→
Embedding model
text → 768 floats
→
Unit circle
English
Hangul
“Python”
small θ
large θ
Not keyword matching — “고양이가 매트 위에 앉았다” scores close to “the cat sat on the mat”!
Fixed length — every input becomes the same-size vector (e.g. 768 dims), whatever its text length.
Closeness = cosine — normalize to unit length and every vector sits on the unit circle; the angle between two vectors is the similarity. Small angle → close to 1 → same meaning.
Each stage has a separate failure mode. Each stage has a separate cost model. Most production RAG problems trace back to a choice made in one of these five boxes.
Idempotency rule: re-indexing one document must not duplicate or leak old chunks. Delete-then-insert is the simplest correct pattern.
Indexing · Lecture
Chunking methods
Strategy
When
Cost
Fixed-size (N chars/tokens)
Quick start, uniform docs
Splits mid-sentence, mid-idea
Sentence / paragraph boundary
Prose, articles, reports
Variable size, retrieval noisier
Semantic (model-decided)
Long technical docs
$$ — extra LLM/embedding pass
Document-as-chunk
Short docs (< window)
Simple, but bigger retrieval payloads
A simple paragraph-aware sliding window — the default in most production systems →
~15 lines. 1500/200 is a fine starting point. Tune chunking before tuning retrieval — bad chunks ⇒ no amount of clever ranking saves you.
def chunk_text(text, target=1500, overlap=200):
text = (text or "").strip()
if len(text) <= target:
return [text] if text else []
chunks, start, step = [], 0, target - overlap
while start < len(text):
end = min(start + target, len(text))
# Prefer a paragraph break in the back half
if end < len(text):
min_break = start + target // 2
for sep in ("\n\n", "\n", ". "):
idx = text.rfind(sep, min_break, end)
if idx != -1:
end = idx + len(sep)
break
chunks.append(text[start:end].strip())
if end == len(text):
break
start = max(end - overlap, start + step // 2)
return [c for c in chunks if c]
Code walkthrough
chunk_text()
This splits a long string into overlapping chunks of roughly 1500 characters, trying to break at natural boundaries.
Setup: Strips whitespace. If the text fits in one chunk (≤ target), returns it as-is (or empty list if blank). step = target - overlap = 1300, the nominal advance between chunk starts.
Main loop: For each chunk starting at start:
Tentative end = start + 1500, capped at the text length.
Boundary snapping (only if not at the very end): instead of cutting mid-word, it looks for a natural break point in the back half of the chunk. min_break = start + 750 defines the earliest acceptable break. It tries separators in priority order — paragraph break "\n\n", then line break "\n", then sentence end ". " — and uses rfind to grab the last occurrence of the first one it finds within the window [min_break, end]. The new end lands just after that separator. If none are found, it keeps the hard 1500-char cut.
Appends the trimmed slice.
Advance: If end reached the text length, stop. Otherwise move start forward. end - overlap backs up 200 chars so consecutive chunks share context. The max(..., start + step // 2) is a safety floor — it guarantees start advances by at least 650 characters, preventing infinite loops or tiny crawling steps when an early boundary pulls end back close to start.
Cleanup: Filters out any empty chunks.
The net effect: chunks ~1500 chars, overlapping by ~200 for context continuity, preferring to end on paragraph/sentence boundaries when one exists in the second half of the window.
One subtle point: the actual overlap and stride vary because end shifts to boundaries, so chunks aren't uniformly sized — they're “up to ~1500, snapped to a natural break.”
Indexing · Lecture
Vector Database for Indexing
Postgres + pgvector — store each chunk's embedding next to its text, then query by similarity. Embed at both write and read time, with the same model.
1Add a vector to the DB
-- one-time schema
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
content text,
embedding vector(768)
);
# --- index one chunk ---
vec = embed(chunk) # 768 floats
db.execute(
"INSERT INTO chunks (content, embedding)"
" VALUES (%s, %s)",
(chunk, vec),
)
2k-NN similarity lookup
# embed the query with the SAME model
q = embed("how do I reset my password?")
rows = db.execute(
"SELECT content"
" FROM chunks"
" ORDER BY embedding <=> %s"
" LIMIT 5",
(q,),
).fetchall()
# <=> cosine distance — smaller = closer
# returns the 5 nearest chunks
pgvector operators: <=> cosine, <-> L2, <#> inner product. Add an HNSW index on embedding for O(log n) search.
Indexing · Lecture
Searching fast: ANN & HNSW
Brute-force k-NN compares the query to every vector — O(n). HNSW (Hierarchical Navigable Small World) builds a layered graph so search is roughly O(log n), trading a little recall for a lot of speed.
-- build the index once
CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- the query is unchanged: planner
-- uses the index automatically
SET hnsw.ef_search = 40; -- recall vs speed
SELECT content FROM chunks
ORDER BY embedding <=> %s
LIMIT 5;
m — links per node; ef_construction — build-time effort. Higher = better recall, more memory.
ef_search — query-time effort; the recall ↔ latency dial you tune live.
Approximate — may miss the true nearest now and then; match vector_cosine_ops to the <=> operator you query with.
Indexing · Lecture
Vector stores
Choice
Good for
Limitation
numpy / in-memory dict
Prototypes, tests, < 100K vectors
Lost on restart; no filtering
Postgres + pgvector
You already have Postgres; want SQL joins on metadata
~1M vectors comfortably; > 10M wants tuning
Managed (Pinecone, Weaviate, Qdrant, Vertex)
Scale, multi-region, hybrid search
Cost, vendor lock-in, another system to learn
Azure AI Search
Azure shops; native hybrid search with built-in indexers & skillsets
Azure-only; tier-based capacity and cost
SQLite + sqlite-vec
Single-user, local, embedded
Single writer, no concurrency
The simplest possible in-memory store (this is just a toy example):
class InMemoryStore:
def __init__(self):
self.records = {} # chunk_id -> record
def upsert(self, records):
for r in records:
self.records[r["chunk_id"]] = r
def search(self, query_vec, k=5):
scored = [(_cosine_distance(
r["embedding"], query_vec), r)
for r in self.records.values()]
scored.sort(key=lambda x: x[0])
return [r for _, r in scored[:k]]
Production systems use HNSW or similar approximate-nearest-neighbor (ANN) structures — O(log n) instead of O(n) search. Don't pick the heavyweight option first; in-memory works until it doesn't.
Indexing · Lecture
Production considerations
Batch economics
Most embedders have hard per-request caps (Vertex: 250 inputs and 20K tokens — whichever hits first)
Adaptive fallback: catch the cap error, halve the batch, retry
Operational
Re-indexing burns money — don't accidentally re-embed on every deploy
Track per-doc status (fetching / extracting / embedding / indexed / failed) — one bad PDF can't kill the whole run
Backfills — re-indexing the whole corpus in bulk (after changing chunking, embedding model, or onboarding a new source) — take hours, so they need pause/resume
Delete-then-insert keeps re-indexing consistent
RAG saves context, but err on the side to much retrieval
Context Management
RAG
Retrieve · Augment · Generate (with citations)
RAG · Lecture
RAG in 30 lines
# 1. RETRIEVE — embed the query, find top-K nearest chunks
query_vec = embedder.embed([question])[0]
hits = vector_store.search(query_vec, k=10)
# 2. AUGMENT — format chunks as a numbered context block
context = "\n\n".join(f"[{i+1}] {h['text']}" for i, h in enumerate(hits))
system_prompt = f"""You answer questions using only the provided context.
Cite each factual claim with the bracket number, e.g. "Sales grew 20% [3]".
If the context doesn't contain the answer, say so."""
user_prompt = f"CONTEXT:\n{context}\n\nQUESTION:\n{question}"
# 3. GENERATE — one Claude call
answer = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
).content[0].text
Quiz: What could go wong?
RAG · Lecture
The retrieval failures
Insufficient diversity
One verbose document dominates top-K. Other relevant docs never make it. Cross-cutting queries ("list all companies that…") miss most of the answer.
Fix: per-group budgets, MMR diversification, or rerank with a different signal.
Related but irrelevant
Top-K by cosine returns chunks that look textually similar but don't actually answer the question. "Best match per chunk ≠ best match per concept."
Fix: reranker (cheaper LLM scores each candidate), hybrid retrieval (vector + keyword), or query rewriting.
Search not specific enough
"What about their revenue?" — embedding this matches lots of revenue chunks but for wrong companies. Pronouns kill vector search.
Fix: rewrite the question into a self-contained query using conversation history. A cheap LLM call.
Too much context
Naively stuff K=50 chunks → 100K tokens → cost & latency balloon, model gets distracted by noise, citations get sloppy.
Fix: budget chunks, group by document, trim ruthlessly.
RAG · Lecture
Context assembly
How you arrange retrieved chunks in the prompt matters as much as which chunks you retrieved.
Group by source document — sequential chunks from one doc are more coherent than interleaved fragments from many.
Mark gaps — when you have chunks 3 and 7 from a doc but not 4–6, insert […] so the model knows there's missing material.
Number for citation — [1], [2], [3]; force the model to cite using these numbers.
Inline metadata — filename, date, author, source URL. Gives the model labels to cite and the UI text to render.
Mix retrieved + first-party — structured records (the "company card" pattern from the demo) supplement vector search with vetted facts.
"Context" isn't just retrieved chunks. It's the prompt you assemble out of all available structured and unstructured inputs, in an order the model can read.
RAG · Lecture
Citations — why and how
Why
Trust — users can verify each claim
Debuggability — when an answer is wrong you can see which chunks led there
Defense against hallucination — forcing the model to cite makes it visibly own its claims
Affordance — users click through to source documents
How it breaks
Invents [99] for a chunk that doesn't exist
Cites in jumbled order — [5][1][8] — feels janky
Skips citations on claims that need them
Cites the right chunk for the wrong claim
Refuses to cite, just says "based on the documents…"
# Parse the citation numbers the model used; drop any out-of-range
import re
used = [int(n) for n in re.findall(r"\[(\d+)\]", answer)]
valid = [n for n in used if 1 <= n <= len(retrieved_chunks)]
Defense: parse what the model produced, drop the invented ones, return the mapping (chunk → source) to the UI. Validate first; trust second.
Context Management
Project
Project
A Basic RAG-based Chat App
A complete, runnable RAG app: a 20-document Apollo corpus, an indexer that chunks → embeds → stores, and a chat backend that retrieves, grounds its answers, and cites sources. It works out of the box — your job is to run it, push on it, and judge where it holds up and where it doesn't.
frontend/ — React chat UI; renders answers with a Sources: line
.env.example, requirements.txt, README.md
Your job (in order)
Run the indexer: build the vector index over the documents/ corpus — one command, then confirm chunks landed in the store.
Test the app: start the backend + frontend and ask it real questions about the corpus. Watch what it retrieves and how it cites.
Show successes & failures: collect a few questions it answers well — and a few where retrieval misses or citations break down. Bring concrete examples of each.
Bring your own corpus? Drop your files into documents/ and re-run python indexer.py — the rest of the pipeline works unchanged. Present: one question it answers cleanly, one it gets wrong, and what you'd change to fix it.
RAG · Lecture
Single-turn RAG vs agentic RAG
Single-turn (retrieve once, answer)
Agentic (model drives retrieval)
1 retrieval + 1 LLM call
N retrievals over multiple turns
Question is self-contained or rewritten
Model decides what to search next
Latency: ~1s + LLM
Latency: N × (search + LLM)
Cost: predictable
Cost: bounded but variable
Best for: lookups, summaries, comparisons over known scope
Best for: open-ended research, multi-hop, "find X then Y"
The demo app is single-turn, with a separate query-rewrite step that's itself a cheap LLM call. The rewrite handles follow-ups; the main call answers. Two LLM calls, one retrieval — the cost-shape sweet spot for that use case.
Default to single-turn. Add agentic loops when single-turn provably can't do the job, not before.
Project · Contest
RAG-Chat Contest
Point your RAG chat at a real, messy corpus: the U.S. Federal Aviation Regulations (14 CFR). Tune it on the practice questions below — then go head-to-head on fresh questions, tournament-style.
The corpus
Six PDFs from 14 CFR — Aeronautics & Space, ~15 MB of dense legal text with cross-references, tables, and definitions:
Vol. 1 — FAA general (Parts 1–59): definitions, certification, airworthiness
Part 61 — pilot certification · Part 67 — medical standards
Part 71 / 73 — airspace designations & special-use airspace
How head-to-head answers are scored. 100 points total — grounding and answer quality carry the weight.
Category
Description
Points
Answer Quality
Factual accuracy (claims match sources, no hallucinations), relevance to the question, completeness, and synthesis across sources rather than text dumping.
30
Citations & Grounding
Claims are attributed to sources, citations point to the actual supporting passage, references resolve and are verifiable, and no fabricated sources.
25
Cost Management
Token-efficient: retrieves only what's needed and keeps prompts lean, reaching a correct, well-grounded answer with as few tokens as possible.
15
Clarity & Communication
Defines acronyms and jargon on first use, uses readable structure appropriate to the question, and avoids filler.
10
User Experience
Latency and responsiveness, conversational coherence across turns and follow-ups, and helpful tone.
10
Robustness & Safety
Handles ambiguous, adversarial, and out-of-scope queries gracefully, flags or refuses when appropriate, and resists prompt injection from retrieved content.