← Start here · Module 4B · Project

🛩️ Module 4B · Project — RAG over a corpus

The end-to-end flow of the champion RAG app: index a corpus of 14 CFR (FAA) documents once, then answer every question with cited sources. Two phases — offline indexing and online query — joined by a self-describing index on disk.

🏆 Result: graded 9 / 10 — the top score in the class — by the instructor, Larry Arnstein (CTO @ Clause; ex-Apple, Impinj, Xnor.ai; former UW faculty). The corpus is ~1,297 pages of US federal aviation law; every retrieval choice below was picked by a 45-config overnight grid search — receipts in experiments/leaderboard.md.
📚 Concepts live in Module 4 · Context / RAG; the rubric & corpus are in 4A.9 Project & Projects/Module_04_rag/CONTEST.md.
🔎 This is the reference walkthrough of the finished solution. It zooms in five times — 60 seconds → 5 → 10 → 20 minutes → every decision — so you can read it at whatever depth you need. The matching code is in Projects/Module_04_rag/rag-starter/ and Projects/Module_04_rag/harness/.

▶ Boot this RAG app in the browser — it embeds right here, no install:

depth 1 / 54B.1 · The 60-second version 🧒

Think of it like a librarian. First you get the library ready once. Then, every time someone asks a question, the librarian grabs just the right notes and answers — and tells you which note it came from.

flowchart LR
  subgraph ONCE["🗂️  STEP 1 — Get ready  (just once)"]
    direction LR
    A["📚
Our
documents"] --> B["✂️
Cut into
small notes"] --> C["🗄️
File them
on a shelf"] end subgraph EVERY["💬  STEP 2 — Answer a question  (every time)"] direction LR D["🧑
You ask a
question"] --> E["🔍
Grab the few
matching notes"] --> F["🤖
Read them &
write an answer"] --> G["✅
Answer +
📎 where it's from"] end C -. "shelf is ready" .-> E classDef once fill:#11271a,stroke:#3fb95088,color:#e6edf3,font-size:15px; classDef every fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3,font-size:15px; class A,B,C once; class D,E,F,G every;

That's the whole idea. 🟢 top row is done once when we build the app; 🔵 bottom row happens on every question. Below we zoom in: the real steps, then every last detail.

🔍 zoom in

depth 2 / 54B.2 · The 5-minute version 🔎

Same two lanes, now with the real machinery. The trick that makes it work: embeddings turn every chunk (and your question) into a vector — a list of numbers where similar meaning → nearby vectors. So "find the relevant notes" becomes "find the closest vectors", and the answer is written using only those retrieved chunks, each tagged with a number so it can be cited.

flowchart TB
  subgraph BUILD["🏗️  BUILD THE INDEX — once"]
    direction LR
    D["📄
Documents"] --> CH["✂️ Chunk
split into
passages"] --> EM["🔢 Embed
text → vector"] --> IX[("💾 Index
vectors + text")] end subgraph ASK["💬  ANSWER A QUESTION — every time"] direction LR Q["❓
Question"] --> QE["🔢 Embed
the question"] --> FIND["🔍 Find the top-5
closest chunks"] --> CTX["📋 Numbered context
[1] [2] … [5]"] --> LLM["🤖 Claude
answers from
the context only"] --> SR["📎 Answer +
Sources [n]"] end IX -. "search in here" .-> FIND NOTE["💡 closest = closest in meaning.
Same idea in different words still matches,
because it lands on a nearby vector."] FIND -.- NOTE classDef build fill:#11271a,stroke:#3fb95088,color:#e6edf3; classDef serve fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef disk fill:#2a2410,stroke:#d2932288,color:#e6edf3; classDef note fill:#161b22,stroke:#8b949e66,color:#e6edf3,stroke-dasharray:4 3; class D,CH,EM build; class Q,QE,FIND,CTX,LLM,SR serve; class IX disk; class NOTE note;

New words that matter: chunk (a passage-sized piece), embedding / vector (text as numbers), index (all the vectors saved to disk), top-K (the K=5 best matches), context (the numbered chunks handed to the model), citation [n] (which chunk a sentence came from). The 10-minute version keeps these words and adds the engineering that makes them trustworthy.

🔍 zoom in

depth 3 / 54B.3 · The 10-minute version 🛠️

Let's build it the honest way. First a naive pipeline that already works — it retrieves and answers. Then we watch it break in three quiet ways, fix each, and redraw the same pipeline with the fixes in place.

Start simple — the naive pipeline

Here's the smallest thing that runs. Documents are chunked one blind way, embedded, and stored as bare vectors. A question is embedded, the closest 5 chunks come back, and Claude answers. It works… but the three ⚠️ marks are where it quietly goes wrong — one per stage, which we take in order right after.

flowchart TB
  subgraph BUILD["🏗️  BUILD (naive) — once"]
    direction LR
    D["📄 documents"] --> CH["✂️ chunk — one
blind way for all"] --> EM["🔢 embed"] --> IX[("💾 index.pkl
bare vectors")] end subgraph QRY["💬  QUERY (naive) — every time"] direction LR Q["❓ question"] --> RET["🔍 top-5
closest"] --> LLM["🤖 Claude
answers freely"] --> ANS["💬 answer
(no sources)"] end IX -. search .-> RET W1(["⚠️ ① a rule gets
sliced in half"]) W2(["⚠️ ② which model
built these vectors?"]) W3(["⚠️ ③ nothing forces the
answer to use them"]) CH -.- W1 IX -.- W2 LLM -.- W3 classDef build fill:#11271a,stroke:#3fb95088,color:#e6edf3; classDef serve fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef disk fill:#2a2410,stroke:#d2932288,color:#e6edf3; classDef warn fill:#2d1215,stroke:#f8514988,color:#e6edf3; class D,CH,EM build; class Q,RET,LLM,ANS serve; class IX disk; class W1,W2,W3 warn;

Where it quietly breaks 🔬

Taking the three ⚠️ marks in order. For each we do the same thing: watch it break (before), name the flaw, then bring in the fix (after). The name for each idea arrives only after you've felt the problem.

① Problem: a single rule, sliced in half

What goes wrong: chunk every file the same blind way — a cut every ~1000 characters — and a regulation like §91.151 gets split across two chunks. Half the rule lands in chunk 7, the other half in chunk 8.

❌ Before — one blind cut for everything

flowchart LR
  D["📄 §91.151
(one whole rule)"] --> CUT["✂️ blind
1000-char cut"] CUT --> A["chunk 7
…first half"] CUT --> B["chunk 8
second half…"] A --> X["❌ retrieval gets a fragment
no chunk IS §91.151 → can't cite it"] B --> X classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef danger fill:#2d1215,stroke:#f8514988,color:#e6edf3; class D,CUT,A,B s; class X danger;

The flaw: the clause is broken, so the model only ever sees a piece of it — and because no chunk equals §91.151, the answer has nothing to cite.

The fix: look at the text first. FAA files carry <!-- §… --> tags, so cut on those § boundaries — one whole rule per chunk, keeping its section + part. Untagged text keeps the fixed windows. Routing the text to the right chunker by its content is content-routed chunking.

✅ After — route by content

flowchart LR
  T["📄 a document"] --> Q{"has § tags?"}
  Q -->|"yes · FAA"| S["chunk_by_section
1 chunk = 1 rule
✅ cite §91.151 (Part 91)"] Q -->|no| C["chunk_text
~1000 chars + overlap"] classDef b fill:#11271a,stroke:#3fb95088,color:#e6edf3; classDef d fill:#241a2e,stroke:#a371f788,color:#e6edf3; class T,S,C b; class Q d;

② Problem: which model built this index?

What goes wrong: the index is just a pile of vectors on disk. Nothing on it records which embedding model produced them. Weeks later you query with a different model — say minilm instead of the bge you built with.

❌ Before — vectors with no label

flowchart LR
  BLD["🏗️ build with bge"] --> PKL[("index.pkl
just vectors,
no label")] PKL --> QRY["🔎 query with minilm
(different model)"] QRY --> X["💥 incompatible vectors
meaningless matches — and no error"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef k fill:#2a2410,stroke:#d2932288,color:#e6edf3; classDef danger fill:#2d1215,stroke:#f8514988,color:#e6edf3; class BLD,QRY s; class PKL k; class X danger;

The flaw: vectors from two models live in different spaces (often different sizes). The "nearest" chunks come back as nonsense — and nothing crashes, so it's confident garbage you might never notice.

The fix: stamp the model name into a sidecar, index.meta.json (e.g. {"embed_model": "bge"}). The query reads it first and loads the same model. An index that carries its own build settings is a self-describing index — the single source of truth.

✅ After — the index carries its model

flowchart LR
  BLD["🏗️ build with bge"] --> PKL[("index.pkl")]
  BLD --> MJ[("index.meta.json
model = bge")] MJ -. "read first" .-> QRY["🔎 query with bge
✅ same model"] PKL --> QRY classDef b fill:#11271a,stroke:#3fb95088,color:#e6edf3; classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef k fill:#2a2410,stroke:#d2932288,color:#e6edf3; class BLD b; class QRY s; class PKL,MJ k;

③ Problem: the model just makes things up

What goes wrong: hand the question straight to Claude and it answers from its own training memory — fluent, confident, and sometimes wrong. No sources attached, no way for the reader to check.

❌ Before — answer from memory

flowchart LR
  Q["❓ question"] --> M["🤖 answers from
its own memory"] M --> X["❌ fluent but unsourced,
sometimes invented (hallucination)"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef danger fill:#2d1215,stroke:#f8514988,color:#e6edf3; class Q,M s; class X danger;

The flaw: nothing forces the answer to come from your documents, so there's no grounding and nothing to cite — exactly where hallucinations slip in.

The fix: wrap the call in a SYSTEM_PROMPT with hard rules — use only the CONTEXT, cite every claim [n], and if the context doesn't cover it, say so. Invalid [n] get dropped in code afterward. Tying the answer to the retrieved sources this way is a grounded prompt.

✅ After — answer only from context

flowchart TB
  CX["📋 CONTEXT [1]…[5]
+ your QUESTION"] --> M["🤖 Claude"] RU["📏 RULES
only use context ·
cite [n] · no context → say so"] --> M M --> A["✅ grounded answer
every claim tagged [n]"] A --> D["🧹 code drops
any invalid [n]"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef r fill:#241a2e,stroke:#a371f788,color:#e6edf3; class CX,M,A,D s; class RU r;

The fixed pipeline ✅

Now the same skeleton with all three holes closed: chunking routes by content, the index ships an index.meta.json so the query embeds with the same model, and a grounded SYSTEM_PROMPT forces cited, context-only answers (invalid [n] dropped). Compare it to the naive diagram above — same shape, the three ⚠️ gone.

flowchart TB
  subgraph BUILD["🏗️  BUILD — python indexer.py  (once)"]
    direction TB
    D["📄 documents/"] --> RT{"route by
content"} RT -->|"§-tagged (FAA)"| SEC["chunk_by_section
1 chunk per §
keeps section + part"] RT -->|plain text| CHR["chunk_text
~1000 chars + overlap"] SEC --> EMB["embed chunks
(chosen model)"] CHR --> EMB EMB --> SV["save_index"] SV --> PKL[("💾 index.pkl
vectors + text
+ section/part")] SV --> MET[("📝 index.meta.json
which model built it")] end subgraph QUERY["💬  QUERY — app.py / streamlit  (every time)"] direction TB LD["load index
+ its embed model"] --> Q["❓ question"] Q --> RET["retrieve top-5
vector · cosine similarity"] RET --> FMT["format_context
numbered [1] … [5]"] FMT --> SP["+ SYSTEM_PROMPT
answer only from context ·
cite [n] · say “don’t know” if missing"] SP --> LLM["🤖 Claude generates"] LLM --> CI["parse [n] · drop invented · dedupe"] CI --> OUT["📎 Answer + Sources
§xxx (Part nn)"] end PKL -. "loaded once" .-> LD MET -. "query with the SAME model
(single source of truth)" .-> RET classDef build fill:#11271a,stroke:#3fb95088,color:#e6edf3; classDef serve fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef disk fill:#2a2410,stroke:#d2932288,color:#e6edf3; classDef decision fill:#241a2e,stroke:#a371f788,color:#e6edf3; class D,SEC,CHR,EMB,SV build; class LD,Q,RET,FMT,SP,LLM,CI,OUT serve; class PKL,MET disk; class RT decision;

Quick self-check ✅

Why split FAA regulations one-chunk-per-§ instead of into fixed 1000-char windows?

A § is a natural unit — keeping one rule per chunk means the model sees the complete clause and can cite §91.151 (Part 91). Untagged text has no such boundaries, so it uses fixed windows with overlap instead.

You build the index with bge but query it with minilm. What happens?

Vectors from different models aren't comparable. That's exactly why index.meta.json records the build model — so the query uses the same one. Single source of truth.

The retrieved context doesn't contain the answer. What should the grounded model do?

Grounding means answer only from context and admit when the sources fall short — that's the anti-hallucination rule. Any invalid [n] the model does emit is dropped in code afterwards.

🔍 zoom in

depth 4 / 54B.4 · The 20-minute version 🚀 — from correct to production

The 10-minute pipeline is correct. But the moment real users chat with the live app (streamlit_app.py), three new problems appear that a clean pipeline never shows — and there's one genuinely hard design call. Same problem-first, before → after treatment.

The live app, one turn end to end

Here's what actually happens on a single question in the deployed chat. The three fixes below are the boxes the 10-minute pipeline didn't have: contextualize a follow-up, cap the context with select_context, and stream while showing a live retrieval panel.

flowchart TB
  subgraph LIVE["🚀  ONE LIVE TURN — streamlit_app.py"]
    direction TB
    Q["🧑 question"] --> FU{"bare
follow-up?"} FU -->|yes| CTX["prepend the previous question
so it inherits the topic"] FU -->|"no · standalone"| RAW["search as-is"] CTX --> RET["retrieve top-5 §-sections"] RAW --> RET RET --> SEL["select_context
split into ~500-char windows,
keep those closest to the question
up to a ~5000-char budget"] SEL --> STR["🤖 stream the answer
token-by-token ▌"] STR --> REN["renumber [n] to 1…N ·
inline source cards ·
Cornell verify links"] end PANEL["🔍 live retrieval panel:
real steps + token counts,
and it persists under the answer"] RET -.- PANEL SEL -.- PANEL classDef serve fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef decision fill:#241a2e,stroke:#a371f788,color:#e6edf3; classDef note fill:#161b22,stroke:#8b949e66,color:#e6edf3,stroke-dasharray:4 3; class Q,CTX,RAW,RET,SEL,STR,REN serve; class FU decision; class PANEL note;

Three problems that only show up in production 🔬

④ Problem: one §-section blows the token budget

What goes wrong: §-sections vary wildly — median ~1,000 chars, but some run to ~200,000. Stuff the raw top-5 into the prompt and a single giant section pushes past 15k input tokens: slow and costly. The obvious fix — just keep the first few thousand characters — is worse: the answer often lives in a late subsection like (a)(1)(iv), which front-truncation throws away.

❌ Before — stuff the raw sections (or truncate the front)

flowchart LR
  H["top-5 §-sections
(one is ~200k chars)"] --> CX["📋 stuff all 5
into CONTEXT"] CX --> X["💥 15k+ input tokens — slow & costly
(or front-truncate → drop clause (a)(1)(iv))"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef danger fill:#2d1215,stroke:#f8514988,color:#e6edf3; class H,CX s; class X danger;

The flaw: position is the wrong thing to cut on. The relevant clause can sit anywhere in a long section, so keeping the front both wastes tokens and risks dropping the answer.

The fix: cut on relevance, not position. select_context splits each hit into ~500-char windows, ranks them by similarity to the question, and keeps the best ones up to a ~5,000-char budget — about 1,250 tokens. Same budget, better recall, no re-index (it runs at request time on the already-loaded model).

✅ After — sub-chunk rerank (select_context)

flowchart LR
  H["top-5 §-sections"] --> W["✂️ split into
~500-char windows"] W --> R["rank windows by
similarity to the QUESTION"] R --> K["keep best-first
up to ~5,000 chars"] K --> OK["✅ ≈1,250 tokens,
keeps the right clause
even if it's (a)(1)(iv)"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef b fill:#11271a,stroke:#3fb95088,color:#e6edf3; class H,W,R,K s; class OK b;

⑤ Problem: a follow-up question loses its topic

What goes wrong: someone asks "What are the fuel-reserve requirements?" then follows with "what about at night?". Embed that follow-up on its own and there are no topic words to match — retrieval returns random sections and the app wrongly says "out of scope".

❌ Before — embed the bare follow-up alone

flowchart LR
  P["Q1: fuel reserves,
day vs night?"] -.-> Q2["Q2: “what about
at night?”"] Q2 --> E["🔍 embed “what about
at night?” by itself"] E --> X["❌ no topic words →
retrieves unrelated §§ →
false “out of scope”"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef danger fill:#2d1215,stroke:#f8514988,color:#e6edf3; class P,Q2,E s; class X danger;

The flaw — and the trap: the follow-up needs the previous topic. But blindly prepending the prior question to every query is also wrong — a full standalone question gets polluted (a "Class B/C airspace" prefix once buried a fuel-reserve question and retrieved airspace §§ instead).

The fix: only prepend when the turn looks like a bare follow-up (≤5 words, or opens with a cue like "what about"). Standalone questions search on their own. Generation still gets the full history either way. This is query contextualization.

✅ After — contextualize only bare follow-ups

flowchart LR
  Q2["Q2: “what about at night?”"] --> D{"bare follow-up?
≤5 words / cue"} D -->|yes| J["prepend Q1's topic →
“fuel reserves … at night”"] D -->|"no · standalone"| S["search as-is
(don't pollute it)"] J --> OK["✅ inherits the topic →
finds §91.151"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef b fill:#11271a,stroke:#3fb95088,color:#e6edf3; classDef d fill:#241a2e,stroke:#a371f788,color:#e6edf3; class Q2,S s; class J,OK b; class D d;

⑥ Problem: the user stares at a frozen screen

What goes wrong: retrieval + generation take several seconds. A plain spinner shows nothing, then the whole answer appears at once. It feels broken, and you can't see how the answer was found — which is exactly what a reviewer wants to inspect.

❌ Before — spinner, then a wall of text

flowchart LR
  Q["🧑 question"] --> WsP["⏳ spinner…
5–10s of nothing"] WsP --> A["answer dumps
all at once"] A --> X["❌ feels frozen ·
no view into retrieval"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef danger fill:#2d1215,stroke:#f8514988,color:#e6edf3; class Q,WsP,A s; class X danger;

The flaw: no feedback during the wait, and the retrieval reasoning is invisible.

The fix: stream the answer token-by-token behind a live cursor, and drive a process panel that prints the real steps (embed → top-5 sections → trim N→M chars) with actual numbers — which then persists in a collapsed panel under the answer, so it's re-viewable later.

✅ After — stream + live retrieval panel

flowchart LR
  Q["🧑 question"] --> P["🔍 live panel:
embed → top-5 → trim
(real numbers)"] P --> S["✍️ answer streams
token-by-token ▌"] S --> OK["✅ fast & transparent;
trace persists under the answer"] classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef b fill:#11271a,stroke:#3fb95088,color:#e6edf3; class Q,P,S s; class OK b;

One hard call: single-shot vs agentic 🧭

A multi-part question — "make a table covering all airspace classes" — exposes a real limit of single-shot RAG: the operating rules live in Part 91 while the class designations live in Part 71, and one retrieval can miss a part. An agentic loop, where the model calls a search_cfr tool as many times as it needs, recovers them — but each search is resent in the history, so tokens grow roughly quadratically.

flowchart TB
  Q["❓ “table of all airspace classes”
(spans Part 91 + Part 71)"] --> A["single-shot
1 retrieve → 1 answer"] Q --> B["agentic loop
model calls search_cfr
up to 3× (capped)"] A --> AR["✅ cheap & fast
⚠️ can miss a part in another Part"] B --> BR["✅ recovers every part
💰 tokens grow ~quadratically"] AR --> DEC["🏁 shipped: single-shot
agentic kept as an experiment"] BR --> DEC classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef warn fill:#2a2410,stroke:#d2932288,color:#e6edf3; classDef win fill:#11271a,stroke:#3fb95088,color:#e6edf3; class Q,A,B s; class AR,BR warn; class DEC win;

The trade-off was decided by cost vs quality: single-shot answers the typical question well for a fraction of the tokens, so it ships; the agentic loop stays in harness/agent_rag.py as a documented experiment. The full story is in the slide deck →.

Quick self-check ✅

A relevant §-section is 200k characters. Why not just keep its first ~5,000 chars?

Cut on relevance, not position: split into ~500-char windows, rank by similarity to the question, keep best-first up to the budget. Same tokens, but the right clause survives.

Why prepend the previous question only for bare follow-ups, not for every question?

A "Class B/C airspace" prefix once buried a fuel-reserve question → airspace §§ instead of §91.151. So contextualize only when the turn looks bare (≤5 words / a cue word).

The agentic loop gave great answers on multi-part questions. Why ship single-shot instead?

It's a cost/quality trade-off. Agentic recovers parts a single retrieval misses, but at many times the tokens — so it stays an experiment while single-shot ships.

🔍 zoom in · deepest level

depth 5 / 54B.5 · The whole picture — every decision

The four zoom levels above showed how the pipeline runs. This last level is the decision log: the calls that shaped the shipped system. Every one was settled the same way — measure, don't guess, and weigh quality ↔ cost — and every number below is a real measurement on this corpus, not a benchmark headline. Each card follows one shape: situation the tension measured decision.

🧭 One sentence for the whole project: measure, don't guess — and always weigh quality against cost.
2,184 indexed law chunks  ·  bge · 1024d embeddings  ·  K = 5 chunks per question  ·  ~2.3k tokens per answer. The end-to-end flow diagram lives in 4B.4; here we ask why each knob sits where it does.

⓪ 🧪 Before any tuning — build the ruler (and a war story about killing a run)

situation

You can't pick a "best" config until you can measure configs, so nothing was tuned until an evaluation harness existed: a verified holdout question set (each with its expected §-sections), a retrieval scorer computing recall coverage MRR (kept independent of the chunking axis), and a final split held back to guard against overfitting.

the tension

The four knobs — chunking × embedding × method × K — were swept overnight: 2 chunkers × 4 embeddings × 3 methods × 3 K = 792 runs, logged append-only (one line per config×question) with content-keyed resume. Then the character-chunk half froze at 495 / 792 — the process had ballooned to 16 GB and was thrashing swap.

decision

Kill it. Safe to do, because the section run — the real winner — was already complete and append-only had preserved all 495 lines. Lesson: build the ruler first, and design experiments to be resumable and safe to interrupt — a free bonus config isn't worth cooking the machine all night.

① 🤖 Which embedding model? → bge (measured, not famous)

situation

An offline grid search compared four embedding models on this corpus — bge · gte · minilm · e5. Top rows of the leaderboard (over 11 questions):

chunkingembedmethodKcoverageMRRinput-tok proxy
sectionbgehybrid80.8640.63617,284
sectiongtehybrid80.8640.60217,284
sectionminilmhybrid80.8640.53017,284
sectionbgevector50.8180.71810,802
sectione5vector50.7270.48010,802

Highlighted row = shipped config. "input-tok proxy" is a per-config total across the 11 questions.

the tension

Three models tied on coverage at 0.864 — so coverage alone can't name a winner.

measured

The tie broke on two finer signals: rank quality — bge's MRR 0.636 beat gte's 0.602 and minilm's 0.530 — and small-K efficiency — bge hits 0.818 at K=5 while minilm needs K=8 for the same score. A better embedding literally buys back tokens.

decision

Ship bge at vector · K5. The "strong" model e5 came last — losing even to gte smelled like a usage bug (missing query:/passage: prefixes), so it was benched, not trusted. Benchmark fame doesn't reproduce on your data; only measurement does. And because vectors from different models aren't comparable, the winner gets engraved into the index (see ⑥).

② ✍️ How to stop hallucination? → grounded prompt + code enforcement

situation

Hand a question straight to claude-sonnet-4-6 and it answers from training memory — fluent, uncited, sometimes wrong.

decision

A SYSTEM_PROMPT with three hard rules:

  • Answer only from the retrieved CONTEXT.
  • Attach a [n] citation to every claim.
  • If the context doesn't support it, say "I don't know" — never invent.
measured

The prompt itself was a measured choice, not a vibe — three candidates were generated and scored by an Opus rubric judge:

promptoverallcompletegroundedcite_oktok_out
B_balanced4.935.005.001.00391
A_current4.604.405.001.00313
C_warm4.203.805.001.00577*

Prompt guides, code enforces: shipped SYSTEM_PROMPT = GROUNDING_RULES + BALANCED_STYLE (overall 4.93). After generation the app also parses the [n] markers, drops out-of-range ones, and dedupes — so even a stray citation can't slip in. *C_warm was truncated at max_tokens.

③ 💸 How many chunks per question? → K = 5 (the cost lever)

situation

K sets how many retrieved chunks go into the prompt — and it's the one knob that really moves the bill.

measured

On real API calls the output is a flat ~100 tokens, and 91–96% of cost is input (K × passage size). Input tokens by K: K3 = 1,762 · K5 = 2,656 · K8 = 5,136.

flowchart LR
  K8["K = 8
coverage 0.864"] --> C8["5,136
input tokens"] K5["K = 5
coverage 0.818"] --> C5["2,656
input tokens"] C8 -->|"−0.046 coverage = tiny"| V["✅ ship K = 5
≈ half the tokens"] C5 --> V classDef s fill:#0d1f33,stroke:#1f6feb88,color:#e6edf3; classDef b fill:#11271a,stroke:#3fb95088,color:#e6edf3; class K8,K5,C8,C5 s; class V b;
decision

Coverage barely dips (0.864 → 0.818) from K8→K5 but tokens roughly halve — a great trade for the rubric's 15-point Cost Management score, which literally asks for "as few tokens as possible".

④ 🧭 Single-shot or agentic? → ship single-shot, cap the agent

situation

The experiment: give the model a search_cfr tool and let it search as many times as it wants. On "make a table of all airspace classes" the agent searched 13 times, noticed Part 71 only says where airspace is (not the operating rules in Part 91), and searched Part 91 on its own — real self-correction, complete table.

the tension

Every turn re-sends the whole history, so cost grows like 1 + 2 + … + N ≈ N²:

QuestionStatic RAGAgentic
Specific (contest style)2,2814,282 · 1 search
Broad ("all classes")2,949 · has gaps81,320 · 13 searches = 27×
decision

The rubric scores results only and rewards low cost, and 4 of 5 practice questions live in one Part — which static answers perfectly and cheaply. So single-shot ships. The agent isn't thrown away: it's capped at MAX_SEARCHES = 3 (keeping even a broad question near ~15k) and kept on the bench as a ready fallback.

⑤ 🎯 Two traps where the naive fix is worse — cut on relevance, not position

the trap

Both live-app problems have an obvious fix that quietly makes things worse; the craft is spotting that:

  • Giant §-sections blow the token budget. Naive fix — keep the first few thousand chars — throws away late clauses like (a)(1)(iv). Real fix: select_context re-ranks ~500-char windows by similarity to the question and keeps the best up to ~1,250 tokens.
  • Bare follow-ups ("what about at night?") lose their topic. Naive fix — always prepend the previous question — pollutes standalone queries (a "Class B/C" prefix once dragged a fuel question into airspace §§). Real fix: query contextualization prepends only for short follow-ups (≤5 words / cue words).

Full before/after diagrams for both are in 4B.4; the point to carry away is the pattern — cut and contextualize on meaning, never on position or reflex.

⑥ ⚙️ What keeps build-time and query-time in sync? → one source of truth

  • CHAMPION_* — four lines (chunker=section · embed=bge · method=vector · K=5) that both indexer.py and the app read, so the index and the query can never silently disagree.
  • Self-describing index: index.meta.json records the build model; a startup dimension probe fails loud (SystemExit) on a mismatch instead of returning confident garbage.
  • Two frontends, one retrieval: the Flask/React backend and the Streamlit app share retrieve() and format_context(), so [n] points to the same chunk everywhere.

The reasoning behind each of these is expanded in 4B.6 · Why it's built this way.

⑦ 🎓 The honesty check — score the champion once on held-back data

measured

Before shipping, the champion config (bge / vector / K5 + B_balanced) was run exactly once on the final split it had never seen: 3.5 / 4 — 2 correct, 1 correct refusal, and 1 grounded-partial where retrieval missed a § but the model did not hallucinate.

decision

Did not retune on the final split. Hold out a set you touch exactly once, so the reported score isn't inflated by tuning to it — that's the difference between a real number and a flattering one.

Quick self-check ✅

Three embedding models tied at 0.864 coverage. Why was bge chosen?

When coverage ties, the tie-breakers are rank quality (bge's MRR 0.636) and how few chunks it needs (0.818 at K=5 vs minilm's K=8). Fewer chunks = fewer tokens = lower cost.

Why does the agentic loop's cost grow roughly like N² with the number of searches?

The model has no memory between turns, so all earlier search results are resent each time — 1 + 2 + … + N ≈ N². 13 searches ballooned to 81,320 tokens (27× the static cost).

Given the measured numbers, why is dropping K from 8 to 5 a good trade?

Output is a flat ~100 tokens and 91–96% of cost is input (K × passages), so K is the real cost lever: K8 = 5,136 vs K5 = 2,656 input tokens — roughly half — for only a 0.046 coverage dip (0.864 → 0.818). Cost nearly halves; quality barely moves.

4B.6 · Why it's built this way

① One config, two phases — CHAMPION_*

The four CHAMPION_* constants in indexer.py (chunker=section, embed=bge, method=vector, k=5) are the only place the deployed retrieval setup is defined. The index is built with them and the app queries with them, so build-time and query-time can't drift apart. Swap a champion → edit these four lines → re-run the indexer.

② The index describes itself — A1

index.meta.json records which embedding model produced the vectors. A reader loads that model back via load_index_embed_model(), so you can never silently query a bge index with minilm. The startup dimension probe is the backstop: if the query model's output dimension doesn't match the stored vectors, the app exits loudly instead of returning meaningless nearest neighbours.

③ Two chunkers, content-routed — pick_chunker

FAA text carries <!-- §91.151 | part91 --> tags, so chunk_by_section makes one chunk per regulation and keeps section/part on each record — that's what lets an answer cite §91.151 (Part 91) exactly. Anything untagged falls back to the generic ~1000-char sliding window with overlap so no information is lost across a cut.

④ Shared numbering — format_context

format_context() produces the [1] … [5] block for the prompt. The same function is used by the live app and the experiment harness, so the [n] the model cites always maps to the same chunk everywhere. Citation parsing then drops any [n] out of range or invented by the model.

⑤ Two frontends, one retrieval

Projects/Module_04_rag/rag-starter/backend/app.py (Flask + React) and streamlit_app.py both call the identical retrieve() with the identical CHAMPION_* config — only the UI differs. Streamlit copies the system prompt inline temporarily to avoid importing app.py's module-level side effects (index load, Anthropic client, Flask app).

4B.7 · File map

  • rag-starter/indexer.py — chunk → embed → save; owns CHAMPION_* and embed()
  • harness/retrieval.pyretrieve() (vector / bm25 / hybrid), format_context(), select_context()
  • rag-starter/backend/app.py — Flask /api/chat: retrieve → prompt → Claude → citations
  • rag-starter/streamlit_app.py — same retrieval, Streamlit UI, streaming, live panel, inline source cards
  • harness/agent_rag.py — the agentic-loop experiment (search_cfr tool), kept for comparison
  • rag-starter/phase0_smoke.py — one-shot smoke test through the real /api/chat path
  • index.pkl / index.meta.json — the self-describing index the two phases meet through