← Start here · Module 4B · Project
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.
experiments/leaderboard.md.
Projects/Module_04_rag/CONTEST.md.
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:
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.
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.
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.
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;
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.
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;
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;
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;
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;
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.
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.
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;
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;
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;
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;
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 →.
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.
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.
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 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.
decisionKill 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.
An offline grid search compared four embedding models on this corpus —
bge · gte · minilm · e5. Top rows of the leaderboard (over 11 questions):
| chunking | embed | method | K | coverage | MRR | input-tok proxy |
|---|---|---|---|---|---|---|
| section | bge | hybrid | 8 | 0.864 | 0.636 | 17,284 |
| section | gte | hybrid | 8 | 0.864 | 0.602 | 17,284 |
| section | minilm | hybrid | 8 | 0.864 | 0.530 | 17,284 |
| section | bge | vector | 5 | 0.818 | 0.718 | 10,802 |
| section | e5 | vector | 5 | 0.727 | 0.480 | 10,802 |
Highlighted row = shipped config. "input-tok proxy" is a per-config total across the 11 questions.
the tensionThree models tied on coverage at 0.864 — so coverage alone can't name a winner.
measuredThe 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.
decisionShip 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 ⑥).
Hand a question straight to claude-sonnet-4-6 and it answers from training memory — fluent,
uncited, sometimes wrong.
A SYSTEM_PROMPT with three hard rules:
[n] citation to every claim.The prompt itself was a measured choice, not a vibe — three candidates were generated and scored by an Opus rubric judge:
| prompt | overall | complete | grounded | cite_ok | tok_out |
|---|---|---|---|---|---|
| B_balanced | 4.93 | 5.00 | 5.00 | 1.00 | 391 |
| A_current | 4.60 | 4.40 | 5.00 | 1.00 | 313 |
| C_warm | 4.20 | 3.80 | 5.00 | 1.00 | 577* |
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.
K sets how many retrieved chunks go into the prompt — and it's the one knob that really moves the bill.
measuredOn 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;
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".
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.
Every turn re-sends the whole history, so cost grows like 1 + 2 + … + N ≈ N²:
| Question | Static RAG | Agentic |
|---|---|---|
| Specific (contest style) | 2,281 | 4,282 · 1 search |
| Broad ("all classes") | 2,949 · has gaps | 81,320 · 13 searches = 27× |
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.
Both live-app problems have an obvious fix that quietly makes things worse; the craft is spotting that:
(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.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.
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.index.meta.json records the build model; a startup dimension
probe fails loud (SystemExit) on a mismatch instead of returning confident garbage.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.
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.
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.
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.
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.
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.
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.
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.
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).
rag-starter/indexer.py — chunk → embed → save; owns CHAMPION_* and embed()harness/retrieval.py — retrieve() (vector / bm25 / hybrid), format_context(), select_context()rag-starter/backend/app.py — Flask /api/chat: retrieve → prompt → Claude → citationsrag-starter/streamlit_app.py — same retrieval, Streamlit UI, streaming, live panel, inline source cardsharness/agent_rag.py — the agentic-loop experiment (search_cfr tool), kept for comparisonrag-starter/phase0_smoke.py — one-shot smoke test through the real /api/chat pathindex.pkl / index.meta.json — the self-describing index the two phases meet through