Module 4 · Day Four
Context Management
Demo · Indexing · RAG · Project
Context management: indexed sources feeding a retrieval-augmented chat
Context Management
Live demo
Solutions Manager — indexed Drive content + cited chat (aeiplatform)
From the demo
What that app had to solve

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.
Play with it →
TensorFlow Embedding Projector
Explore real high-dimensional embeddings in 3D — spin the cloud, search a word, watch neighbours light up.
projector.tensorflow.org
Download the example projectembedding-similarity.zip
Indexing · Lecture
The indexing pipeline
Source
PDFs, docs,
sheets, slides
Extract
text per file
(MIME-aware)
Chunk
N-char windows
with overlap
Embed
vector per chunk
(batched API)
Store
(chunk, vec, meta)
+ ANN index

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:

  1. Tentative end = start + 1500, capped at the text length.
  2. 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.
  3. Appends the trimmed slice.
  4. 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.

1 Add 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),
)
2 k-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.

LAYER 2 · few nodes, long hops LAYER 1 LAYER 0 · every vector, short hops query q nearest match
-- 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)
  • Non-Latin scripts tokenize denser — char-based estimators undercount
  • 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.

Starter project rag-starter.zip
What's in the zip
  • documents/ — 20 Wikipedia articles (Apollo missions + Saturn V, LM, CSM, Mission Control), ~850 KB
  • indexer.py — walks docs → paragraph-aware chunking → embed → index.pkl
  • backend/app.py — Flask chat: top-K retrieval, citation system prompt, [n] parsing
  • frontend/ — React chat UI; renders answers with a Sources: line
  • .env.example, requirements.txt, README.md
Your job (in order)
  1. Run the indexer: build the vector index over the documents/ corpus — one command, then confirm chunks landed in the store.
  2. Test the app: start the backend + frontend and ask it real questions about the corpus. Watch what it retrieves and how it cites.
  3. 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
  • Part 91 — general operating & flight rules
Download the corpusfaa-rag-corpus.zip
Practice with these 5
  1. What aeronautical experience is required for a private pilot certificate with an airplane single-engine rating? Part 61
  2. Which medical conditions disqualify an applicant for a first-class airman medical certificate? Part 67
  3. What are the fuel-reserve requirements for VFR flight, day versus night? Part 91
  4. How do operating requirements differ between Class B and Class C airspace? Parts 71 + 91
  5. What must a pilot do before operating in an active restricted area? Part 73
Entry 1 Entry 2 Entry 3 Entry 4 winner winner Champion
How the contest works
  1. Everyone builds a RAG chat over the FAA corpus and tunes it on the 5 practice questions.
  2. On contest day a fresh, unseen question goes to two entries head-to-head.
  3. The better-grounded, better-cited answer advances. Repeat until one remains.
Open the live bracket
Project · Contest
Contest Rubric

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.
10
Total
100