DS 6042 · Lab 14 · 2026

microRAG — guided walkthrough

A 200-line RAG pipeline. Two retrievers. Stdlib + one optional dep. The whole protocol on one screen.

RAG (Retrieval-Augmented Generation) is how every modern enterprise AI assistant — internal Q&A, customer support, "ChatGPT on our docs" — connects a frozen language model to fresh, organization-specific knowledge. The pattern is the same everywhere: chunk documents, index them two ways, retrieve the top hits, paste them into the prompt, call the LLM. Five steps. The attacks in Lab 15 all target one of those five.

Most production RAG systems wrap LangChain or LlamaIndex around a vendor-hosted vector store (Pinecone, Weaviate, OpenSearch) plus a vendor LLM. Each layer adds capability and obscures structure. microRAG strips that obscurity away: one Python file, two retrievers (TF-IDF + hashed-feature embeddings), one hybrid scorer, one prompt assembler, one LLM call. Read top to bottom and you have read everything LangChain's RetrievalQA does — and you can point at exactly which step each attack in Lab 15 lands on.

foundational paper: Lewis et al. · Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (2020) · companion walkthroughs: Lab 02 · microGPT, Lab 04 · microagent, Lab 06 · microMCP · production server and attacks: Lab 15 · Attacking RAG.

This lab leans on a few retrieval terms — RAG, TF-IDF, embedding, BM25. Every term underlined like this is hoverable (or click / tab-and-Enter): a plain-language explainer opens right below the line you're reading and pushes the rest of the page down — nothing gets covered.

Where to find it

1 · Anatomy of RAG

A RAG system runs two pipelines that meet at a shared store. The ingestion path runs ahead of time: read the documents, slice them, index them. The query path runs when a question arrives and reads: score the question against the index, assemble a prompt, call the model. The asymmetry matters for security — a query can only surface what ingestion already stored, so in Lab 15 the attacker's goal is to get the ingestion path to store something it shouldn't.

Think · Pair · Share · derive the five steps
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare your step lists. Did one of you collapse "split the document" and "embed it" into one step, or split "retrieve" and "paste into the prompt" apart? Where do your counts differ, and which framing makes the eventual attack surface easier to point at?
  • Share · 2 minAs a table, commit to a single ordered list of step names. Then check it against the canonical five steps below — chunk, index, retrieve, augment, generate — and mark which of your steps merged or split.
the five steps end to end
  1. Chunk. Split each document into overlapping windows so retrieval can find the relevant slice. Deciding on chunk size and overlap is an engineering decision with no clear best answer; here we use 300/60.
  2. Index, twice. Once with a BM25/TF-IDF term-frequency model for exact keywords. Once with an embedding model for semantic similarity. Production systems use Weaviate / Pinecone / OpenSearch for the heavy lifting; we use Python lists.
  3. Retrieve, hybridly. A query gets scored against both indexes. The two scores live on different scales — a TF-IDF score is an unbounded sum of term weights, while an embedding score is a cosine near 0–1 — so they're normalized to a common range before being combined with a weighted sum. The top K chunks come back.
  4. Augment. The top chunks are pasted into the prompt as a "retrieved context" section, with the user's question below them and a system prompt above that tells the LLM to use only the context.
  5. Generate. The LLM produces an answer that should cite the retrieved sources. In a well-tuned RAG, hallucinations drop; in a poorly-tuned one, the LLM ignores the context and confabulates.
The two pipelines of RAG
RAG is two pipelines meeting at one store. The ingestion path (top) reads your documents, chunks them, and writes both indexes into the shared store — once, ahead of time. The query path (bottom) reads that store to retrieve the top chunks, builds a prompt, and calls the LLM — every time a question arrives.
INGESTION PATH · writes, ahead of time Documents Chunk Index · TF-IDF + embedding writes Shared store chunks · TF-IDF index · embeddings reads top-K QUERY PATH · reads, when a question arrives Question Retrieve Augment build prompt LLM answer

The asymmetry is the security story: a query can only surface what ingestion already wrote. That's why most Lab 15 attacks target the ingestion path — poison what gets written, and every later query reads it back.

Widget · interactive pipeline · click a stage
Each box is one of the five steps. Click a stage to see why it exists, the actual micro_rag.py code that implements it, and a snapshot of what flows out of that stage when the corpus is the four MegaCorpAI markdown files and the query is "how do I reset my password?".

The pipeline runs left-to-right at query time. Steps 1 and 2 also run once at ingest time (when documents are first loaded into the corpus). Lab 15 attacks every box and arrow in this diagram — knowing the diagram cold is the prerequisite for reading the attacks.

ComponentmicroRAG (this lab)baseline_rag (Lab 15)Production
TransportCLI · stdlib onlyHTTP · FastAPIHTTP · Streamlit / gRPC / Slack bot
Embedding modelhashed-feature (128-d)same fallbackall-MiniLM-L6-v2, OpenAI text-embedding-3, …
Vector storePython listPython listPinecone, Weaviate, pgvector, Qdrant
Keyword indexper-chunk TF-IDF dictsameOpenSearch, Elasticsearch
Guardrailsnoneliteral-substring filters (broken)moderation API + entity recognition
Code~200 lines~250 linesthousands · LangChain + vendor SDKs
All three columns run the same five steps. microRAG is the steps with the dressings removed; baseline_rag adds an HTTP surface and five planted vulnerabilities; production wraps everything in vendor SDKs.
Try it · why two indexes?

Why does almost every production RAG system run both a keyword index and an embedding index, when the embedding index can in theory capture both kinds of similarity?

Show answer
Embeddings excel at semantic match — "how do I sign in" finds "password reset" — but they're surprisingly bad at rare exact tokens: hostnames like db-prod.megacorpai.local, error codes, version numbers, model names. BM25/TF-IDF picks those up trivially. The keyword index ensures exact strings don't get vector-blurred; the embedding index ensures synonyms don't get keyword-missed. Hybrid retrieval is the cheap insurance against either being wrong alone.

2 · The microRAG code · five steps

Open rag/micro_rag.py. ↓ download lab-14-code.zip The same five steps, about 200 lines. Each subsection below is one step in file order, and each step's output is the next step's input: raw text becomes chunks, chunks become two indexes, a query becomes a ranked list, the list becomes a prompt, the prompt becomes an answer.

Think · Pair · Share · why chunk at all
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare your two failure scenarios. Did your partner catch the context-budget reason (a whole-document vector dilutes the relevant sentence) or the boundary reason (a sentence cut in half dies in both indexes) — and which did you miss?
  • Share · 2 minAs a table, fill a two-row table: no chunking → ? and no overlap → ?. Then read Step 1 · chunk below and the chunking widget to see whether the failure modes you named match the three the caption calls out.

2.1 Step 1 · chunk

Sliding window over the raw text. 300 characters per chunk, 60-character overlap. Real systems chunk by tokens (so the LLM's context budget is exact); we chunk by characters because it's smaller and the structural lesson is the same.

def chunk(text: str, size: int = 300, overlap: int = 60) -> list[str]:
    out, i = [], 0
    while i < len(text):
        out.append(text[i : i + size].strip())
        i += size - overlap
    return [c for c in out if c]

The overlap matters for retrieval quality — a sentence that lands awkwardly on a chunk boundary survives because the next chunk repeats its last 60 chars. It also matters for attacks: Lab 15 §7.2 (slow-drip poisoning) places malicious instructions in the overlap band specifically because most monitoring previews show only the start of each chunk.

Widget · chunk size and overlap · password_policy.md
Drag chunk size and overlap to see how the document is sliced. Each chunk gets a different background color; the dashed-outline strip is the overlap region — every character there belongs to two chunks. Production RAG systems iterate on these two knobs more than almost any other parameter.

Three failure modes to feel: (1) size too small → critical context spans many chunks, retrieval misses; (2) size too large → the LLM's context budget bursts when top-K is concatenated; (3) overlap = 0 → sentences split across chunk boundaries die in both indexes.

Knobs that matter

knobtypical rangewhat it controls
size200 – 800 chars (or 100 – 400 tokens)How much context lives in one retrievable unit. Larger = more coherent answers but fewer distinct units to compete for top-K.
overlap10–25% of sizeInsurance against bad cut-points. Zero overlap saves storage; 50% overlap doubles index size for marginal gain.
splitterchar · token · semanticmicroRAG uses a naive char window. Production: recursive splitters that prefer paragraph/sentence boundaries (LangChain RecursiveCharacterTextSplitter) or semantic splitters that group sentences by similarity.
per-doc strategyflat · structuredFor code or tables, chunking by syntax (functions, classes, rows) beats chunking by length. Production systems often run multiple chunkers and merge.

2.2 Step 2 · two indexes (TF-IDF + embedding)

A pile of chunks can't be searched directly: both the chunks and the query first have to become something numeric and comparable. microRAG does this twice, because the two methods notice different things — TF-IDF matches on the exact words that appear, while an embedding matches on meaning.

Start with TF-IDFterm frequency × inverse document frequency, the workhorse of text search since long before neural anything. Its whole idea fits in one sentence: a word points to a chunk in proportion to how often it appears here and how rarely it appears everywhere. Those are the two halves it multiplies together:

Multiply TF by IDF for each word, and a chunk becomes a list of weighted words — common filler terms weigh almost nothing, rare distinctive terms weigh a lot. To score a chunk against a query, you add up the weights of just the words the two have in common — so the chunk carrying the query's rarest words floats to the top. That one move, down-weight the common, up-weight the rare, is the entire reason TF-IDF can spot "password" or "database" against a wall of "the / a / your".

Worked example · IDF for the MegaCorpAI corpus

With N = 13 chunks, the formula is idf(t) = log((N + 1) / (df_t + 1)) + 1.0. A term that appears in exactly one chunk (df = 1) gets log(14/2) + 1 ≈ 2.95. A term that appears in every chunk (df = 13) gets log(14/14) + 1 = 1.00. The ratio matters: a rare term contributes ≈3× more weight per occurrence than a corpus-wide one.

Widget · IDF table · 13 chunks · ~310 terms
Each tile is a term in the corpus with its df (document frequency, how many chunks it appears in) and idf (the discriminating power that frequency translates into). Click a term to highlight which chunks contain it. Notice the high-idf terms cluster at the top — those are the hostnames, service names, and policy keywords TF-IDF is best at retrieving.

"megacorpai" appears in every doc → low IDF → not informative. "postgresql" appears in one chunk → high IDF → exactly the term that would rescue an architecture query. Lab 15's KB-leakage attack rides on this: the rarest, highest-IDF tokens in the corpus are also the highest-signal secrets.

Here is the TF-IDF index in code:

def build_tfidf(chunks):
    df = Counter()
    for c in chunks:
        df.update(set(tokenize(c["body"])))
    N = len(chunks)
    idf = {t: math.log((N + 1) / (df_t + 1)) + 1.0 for t, df_t in df.items()}
    for c in chunks:
        tf = Counter(tokenize(c["body"]))
        c["tfidf"] = {t: cnt * idf.get(t, 0.0) for t, cnt in tf.items()}
    return idf
why the hashed-feature fallback?

So the lab runs on any laptop, no GPU, no 100 MB model download, no PyTorch install fights. The pedagogical lessons (hybrid retrieval, attack surfaces, prompt assembly) don't depend on the embedding being good — they depend on having some embedding. The production server in Lab 15 uses the same fallback; if you want real semantic quality, swap the embed() function for a sentence-transformers call (the file shows you how, in the optional-import block).

Embeddings · what the hash is faking

The second index, the embedding, is blunter: hash each token into one of 128 buckets, count, and normalize. Both indexes share one interface — text in, comparable numbers out, cosine for similarity — which is precisely why a real model like all-MiniLM-L6-v2 can drop in later without the retrieval code noticing the swap. The hash itself is eight lines:

def embed(text):
    # hashed-feature embedding · 128 dims · no GPU / no model download
    vec = [0.0] * 128
    for tok in tokenize(text):
        h = int(hashlib.md5(tok.encode()).hexdigest(), 16)
        vec[h % 128] += 1.0
    n = math.sqrt(sum(v * v for v in vec)) or 1.0
    return [v / n for v in vec]

A real embedding model maps each token to a position in semantic space — "database" and "DB" land near each other, "VPN" and "tunnel" land near each other. Cosine similarity between two text vectors then measures topical proximity, not literal word overlap. That is the same learned-embedding idea you met in Lab 02, where microGPT's wte matrix mapped each token id to a trained vector — with two differences here: a real retrieval model learns its vectors (microRAG's hash does not), and it embeds a whole chunk at once rather than one token at a time. The hashed-feature embedding can't do that: hashing reduces "database" to bucket 47, "DB" to bucket 19, and they cosine-out to zero. What hashed-features do capture is exact-token overlap, which is approximately what TF-IDF already gives you. So in microRAG, the embedding leg is doing a poor man's TF-IDF — useful as a fallback, structurally identical to a real embedding from the calling code's perspective.

A real embedding places related words near each other
Each word sits at a point in the embedding's semantic space (2 of its hundreds of dimensions shown). Words that mean similar things land close together — database beside DB, VPN beside tunnel — so a cosine reads meaning, not spelling. microRAG's hashed embed() can't do this: it would scatter these same words to unrelated buckets, collapsing every distance below to noise.
AUTH / LOGIN DATABASE NETWORK / VPN ONBOARDING reset password login sign in table database DB Postgres GlobalProtect VPN connect tunnel orientation onboarding new hire far apart semantic space · 2 of N dimensions

database and DB nearly coincide; database and VPN sit far apart (dashed). That geometry is what "matches on meaning" means — and it's the one thing TF-IDF and the hashed fallback, which only see exact tokens, cannot give you.

Three properties production teams pick embeddings for: (1) dimension — 384 (MiniLM) is fast; 1536 (OpenAI text-embedding-3-small) is more discriminating; 3072 (text-embedding-3-large) is overkill for most. (2) Language coverage — multilingual vs English-only. (3) Domain fit — generic web vs code (jina-code, nomic-embed-code) vs biomedical (BioBERT). Swapping embeddings is one line of code; getting the right one for your corpus is a multi-week evaluation.

2.3 Step 3 · hybrid retrieval

The TF-IDF index and the embedding index have each scored every chunk now, and they disagree about which chunk wins. Their scores also sit on different scales — different numeric ranges. A TF-IDF score sums the tf × idf weight of every query word a chunk contains, so here it runs about 0–12 (a few query words, each worth up to the ~2.95 IDF cap from the worked example, with no hard ceiling). An embedding score is a cosine, so it always falls between roughly 0 and 1. Add the two raw scores together and the far-larger TF-IDF numbers drown out the embedding score completely — the ranking collapses to keyword-only, whatever the embedding judged.

So Step 3 puts them on equal footing before combining them. It normalizes each index's scores into a common [0, 1] range (min–max: the round's best chunk becomes 1, its worst 0), then blends the two with a single dial, w_kw, that sets how much to trust keywords versus meaning. The top-K chunks that survive the blend are what the answer gets built from.

def retrieve(query, chunks, idf, top_k=4, w_kw=0.5):
    q_emb = embed(query)
    kw = [tfidf_score(query, c["tfidf"], idf) for c in chunks]
    em = [cosine(q_emb, c["emb"]) for c in chunks]

    def normalize(xs):                       # min-max → [0, 1]
        lo, hi = min(xs), max(xs)
        return [0.0]*len(xs) if hi == lo else [(x - lo) / (hi - lo) for x in xs]

    kw_n, em_n = normalize(kw), normalize(em)
    scored = [
        {**c, "kw_score": kw_n[i], "em_score": em_n[i],
              "hybrid_score": w_kw * kw_n[i] + (1 - w_kw) * em_n[i]}
        for i, c in enumerate(chunks)
    ]
    scored.sort(key=lambda x: x["hybrid_score"], reverse=True)
    return scored[:top_k]

Three knobs: top-K, the keyword/embedding weight w_kw, and the score-normalization scheme. All three are attack surfaces — Lab 15 §4 (embedding collision) specifically exploits a high w_kw + min-max normalization, since a single multi-topic document can dominate the BM25 component for many unrelated queries.

Widget · live hybrid retrieval · 13 chunks scored against your query
Type a question (or pick a preset). Each row is one chunk; the orange bar is its keyword score weighted by w_kw, the green bar is its embedding score weighted by (1 − w_kw). Drag the w_kw slider and watch top-K reshuffle — at the extremes one retriever dominates, in the middle they negotiate.

Walk three queries: "how do I reset my password?" lands chunk 0 of password_policy.md on both legs; "what database hostnames exist?" lands architecture.md on keyword but a near-irrelevant chunk on embedding (a known weakness of the hashed-feature fallback); "employee onboarding new hire" lands clean and unanimous. The same widget is the diagnostic tool you'd reach for in production when a user reports a wrong answer.

What the weights mean in practice

Try it · what happens at the edges?

What does microRAG return for a query that mentions no word in any chunk? And for one that mentions every word in one chunk three times?

Show answer
For an empty match: all keyword scores are 0; after min-max normalization (hi == lo == 0) every chunk gets a normalized 0. The hybrid score is then purely the embedding term — which still returns something, even if irrelevant. The system never returns "I don't know" through retrieval; it just returns the least-bad guess.

For an over-saturated match: one chunk contains all the query's words, so it tops both indexes and its normalized hybrid score reaches 1.0 — and it fills the top-K, pushing out other chunks that may be just as relevant. Both edge cases are bugs: the first because the LLM gets garbage context; the second because the LLM is steered toward a single source even if other relevant chunks exist. Production systems add reranking (a second-pass scorer) and diversity constraints (max-K-per-source) to mitigate both.

2.4 Step 4 · augment

Augmentation glues the top chunks into a prompt. It looks trivial, but it hides RAG's one genuinely load-bearing design choice — the one every Lab 15 attack ultimately exploits.

SYSTEM_PROMPT = """You answer questions using ONLY the retrieved context below.
If the context does not contain the answer, say so plainly. Cite the source
filename at the end of every fact you take from the context."""


def augment(query, top):
    context = "\n\n".join(
        f"[source: {c['doc_path']} · chunk {c['chunk_idx']}]\n{c['body']}"
        for c in top
    )
    user = f"# Retrieved context\n\n{context}\n\n# User question\n\n{query}"
    return [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user",   "content": user},
    ]

This is the entire "RAG" insight: paste the retrieved chunks into the user prompt. The LLM has no special channel for "retrieved data" — chunks arrive as ordinary user tokens. This is the same trust-boundary problem Lab 04 §2.5 named and Lab 05 attacked. Lab 15 §5 (retrieval hijacking) drops instructions into uploaded documents specifically because those instructions reach the LLM as part of the "user" role and bypass any input filter.

2.5 Step 5 · generate

def chat(messages):
    import httpx
    url = os.environ["LLM_BASE_URL"].rstrip("/") + "/chat/completions"
    # The RC GenAI endpoint streams Server-Sent Events even for one-shot
    # calls, so ask for a stream and stitch the content deltas together.
    r = httpx.post(url,
        headers={"Authorization": f"Bearer {os.environ['LLM_API_KEY']}"},
        json={"model": os.environ["LLM_MODEL"], "messages": messages,
              "temperature": 0.2, "stream": True},
        timeout=120)
    r.raise_for_status()
    out = []
    for line in r.text.splitlines():
        if not line.startswith("data: "):
            continue
        payload = line[len("data: "):].strip()
        if payload == "[DONE]":
            break
        try:
            delta = json.loads(payload)["choices"][0]["delta"]
        except (json.JSONDecodeError, KeyError, IndexError):
            continue
        if delta.get("content"):
            out.append(delta["content"])
    return "".join(out).strip()

OpenAI-compatible round-trip — the same client microagent and microMCP use. The RC GenAI endpoint streams its reply as Server-Sent Events (even for a one-shot call), so chat() asks for a stream and stitches the delta.content pieces back together. Set the three LLM_* env vars (Rivanna's userguide) and you're done.

2.6 Scaling Step 2 · the vector database

microRAG's retrieve() brute-forces a cosine against every vector in a Python list. That's fine for 13 chunks and hopeless for 13 million — a single query would have to compare against all of them. The data structure production reaches for instead is a vector database (Pinecone, Weaviate, Qdrant, pgvector). Two things define it:

The widget below is that search, drawn in two dimensions. Real embeddings live in hundreds of dimensions, but the picture is the same: your query is a point, and retrieval hands back its nearest neighbors.

Widget · vector database · drag the query point
Each dot is a stored record — one chunk's embedding, colored by its source document. Drag the query (the ✚) anywhere and the database returns its k nearest neighbors (highlighted, with dashed lines). The panel lists the ranked hits and the full record of the top hit: its id, vector, and payload. This is exactly what retrieve() does — minus the ANN index that makes it fast at scale.

Drag toward a cluster and its chunks win; drag to empty space and the "nearest" neighbors come back anyway — a vector database never says "no match," it always returns the closest k. That is the same edge case Step 3's exercise flagged, now visible in the geometry.

3 · Run it ~15 min

You'll run microRAG on Rivanna: set up a session, pull the code, then run it two ways — first retrieval alone (no LLM, no keys — instant feedback on which chunks a query pulls), then the full pipeline with a model turning those chunks into an answer.

3.1 Get a session and the code

microRAG needs no GPU — it only calls the remote model over the network — so a plain CPU session is enough. Open Open OnDemandInteractive Apps → Code Server and launch it on the Interactive partition (1 core, 6 GB, no GPU, Work Directory /scratch/$USER). When it shows Running, click Connect to Code Server and open a terminal (Terminal → New Terminal). Pull the code straight from the lab site and unzip it:

$ cd /scratch/$USER && mkdir -p lab-14 && cd lab-14
$ wget https://researcher111.github.io/ML-Security-Public/labs/lab-14/lab-14-code.zip
$ unzip lab-14-code.zip
$ ls
microdata/  rag/  README.md

3.2 Retrieval only · no LLM needed

$ python rag/micro_rag.py retrieve "how do I reset my password?"
# Loading microdata …
# 13 chunks indexed, embedding dim=128

[0.846 kw=1.00 em=0.69] password_policy.md · chunk 0
  # Password Reset Policy  To reset your password at MegaCorpAI:  1. Visit the login page at https://login.megacorpai.local…
[0.500 kw=0.00 em=1.00] password_policy.md · chunk 2
  T Operations.
[0.140 kw=0.00 em=0.28] password_policy.md · chunk 1
  et a new passphrase with at least sixteen characters, including one symbol and one number…

The score columns are kw (keyword/TF-IDF), em (embedding cosine), hybrid (weighted blend). Chunk 0 wins on keywords because it contains "password" and "reset" verbatim. Chunk 2 wins on embedding because its hashed bucket happens to overlap with the query's. The hybrid blend favors chunk 0 — exactly what a user would want.

3.3 Full pipeline · against Rivanna's Kimi K2.5

$ export LLM_BASE_URL=https://open-webui.rc.virginia.edu/api
$ export LLM_API_KEY=...      # open-webui.rc.virginia.edu → Settings → Account → API keys
$ export LLM_MODEL="Kimi K2.5"
$ python rag/micro_rag.py ask "how do I reset my password?"
To reset your password at MegaCorpAI:
1. Visit https://login.megacorpai.local and click "Need help signing in".
2. Authenticate with Okta Verify (preferred) or SMS fallback.
3. Set a new passphrase with at least sixteen characters, including one symbol and one number.

Source: password_policy.md
If you're locked out, contact the helpdesk at extension 4357 [source: password_policy.md].

That's it — the entire RAG round trip in one CLI invocation. The same five steps a production system runs, no abstractions in the way.

Try it · break it on purpose

Without changing any code, ask microRAG a question whose answer is not in any of the four markdown files (e.g., "what is the office address?"). Does the LLM hallucinate? Does it refuse? What signal in the retrieval trace (use the retrieve subcommand) would have predicted which it does?

Show answer
If the top-K scores are all very low (e.g., all below 0.3 on the hybrid score), the retrieved context is mostly noise and the LLM has two options: (a) obey the system prompt and say "the context doesn't contain that information" — the well-behaved case; or (b) ignore the system prompt and confabulate from its training data — the hallucination case. Which one happens depends entirely on the LLM. Kimi K2.5 usually picks (a). Smaller models often pick (b). Two production fixes: set a hybrid-score floor (don't even call the LLM if no chunk clears it), and run an "answer is grounded in the context" check post-hoc.

4 · Scaling up · the production server ~30 min

Open ../lab-15/server/baseline_rag.py. ↓ download lab-15-code.zip It's ~250 lines — structurally a microRAG with an HTTP front door and three things scaled up:

Think · Pair · Share · where retrieved text gets trusted
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare attack paths. Did one of you stop at "the LLM gives a wrong answer" while the other reached "the retrieved chunk is an instruction the LLM follows"? Whose path needs the document to actually win retrieval, and how would you guarantee that?
  • Share · 2 minAs a table, name the one trust assumption microRAG makes that the production server inherits — and write it as a single sentence ("the system trusts that retrieved text is ___"). Carry it into §4 and check it against the planted-vulnerability table.

And four planted vulnerabilities — clearly marked with # Vuln N · … comments — matching the canonical RAG-attack taxonomy:

VulnmicroRAG stepbaseline_rag locationAttack in Lab 15
KB leakageSteps 1+4 · what gets ingested + what gets citedseed_from_disk() blindly indexes documents with secrets§2 · ask
Ingestion poisoningStep 1 · who can write to the corpus/ingest endpoint, no provenance check§3 · upload one doc
Embedding collisionStep 3 · hybrid retrieval weightw_kw = 0.6 + no max-K-per-source dedup§4 · one doc, many topics
Retrieval hijackingStep 4 · trust boundary on retrieved chunksfile_read tool + literal-path blocklist§5 · upload a "vacation guide"
Filter bypassStep 5 · output guardrailemail regex requires literal @§6 · ask for [at] substitution

As you read each one, ask yourself the question the previous "scaling up" labs primed you with: what does the new piece trust, and what happens if that trust is misplaced? Lab 15 calls each vulnerability out explicitly with a Vulnerability · … callout.

5 · What's next · before the attacks

Lab 15 · Attacking RAG takes the same baseline_rag.py you just read and runs five canonical attacks against it. Before you click through, take a few minutes with §4. Four of the additions there each open a different kind of trouble:

  1. The seed corpus ingested without curation in seed_from_disk().
  2. The /ingest endpoint that accepts any text with any claimed source.
  3. The hybrid retrieval weight w_kw = 0.6 with no per-source diversity cap.
  4. The file-read tool with a literal-path blocklist.

For each one, sketch an attack on your own. Even naming the seam and the kind of input that would exercise it is most of the work. Lab 15 walks the attacks; the further you can get pencil-and-paper first, the more useful that lab will be.

6 · Wrapping up · what a production RAG actually has ~15 min

microRAG is structurally complete — the same five steps are the skeleton underneath essentially every commercial RAG system you're likely to meet. What's missing from microRAG is the cost-and-correctness machinery production systems add around those five steps. Read this list as a map of the next several thousand lines of code you'd write if microRAG were going to support a thousand-user company tomorrow.

Step 0 · ingestion pipeline

Production: scheduled crawlers per source system (Confluence, Notion, GitHub wikis, Google Drive), PII / secret stripping (Presidio, AWS Macie), per-document sensitivity labels and provenance stored alongside the body. microRAG has none of this — Lab 15 §2 (KB leakage) is what happens when you skip it.

Step 1 · smart chunking

LangChain's RecursiveCharacterTextSplitter, LlamaIndex's SemanticSplitterNodeParser, structural splitters for code (per-function) and tables (per-row). Often multiple chunkers run in parallel and the top-K is merged.

Step 2 · hosted vector DB

Pinecone, Weaviate, Qdrant, Milvus, OpenSearch, or pgvector on Postgres. ANN indexes (HNSW, IVF-PQ) so retrieval is sub-millisecond at 100M chunks. Metadata filters so a query from user X can't retrieve chunks tagged internal-only.

Step 3 · two-stage retrieval

Fast first pass (BM25 + bi-encoder embedding) returns top-100. Slow second pass (cross-encoder reranker like Cohere Rerank or BGE-Reranker) re-scores those 100 with full attention over query × chunk, returns top-4. Reranking is the single biggest quality lever after embedding choice.

Step 3.5 · query rewriting

HyDE (Hypothetical Document Embeddings), multi-query expansion, query decomposition for multi-hop questions. Often a small LLM rewrites or expands the query before retrieval — "how do I VPN?" becomes "how to connect to GlobalProtect / verify IP range / install client".

Step 4 · prompt engineering

Templated context blocks with explicit citation markers ([1], [2]) that the LLM is instructed to reuse. "Grounded answer" system prompts that refuse outside the context. Some systems include the chunk metadata (source URL, last-modified date) so the LLM can cite freshness.

Step 5 · post-generation checks

"Grounded-in-context" verifiers (NLI models or LLM-as-judge) that score whether each sentence of the answer is supported by the retrieved chunks. PII / API-key redaction (regex + entity recognition). Refusal cascade if any check fails.

Observability

Arize Phoenix, LangSmith, Langfuse — trace every query through retrieval scores → augmented prompt → LLM output. Tag each trace with feedback. Aggregate into "which queries are we getting wrong?" dashboards. Without this, the team can't tell if a regression came from the embedding model, the reranker, or the LLM.

Evaluation harness

Golden-set Q&A pairs scored with retrieval-recall, answer-faithfulness, answer-relevance. RAGAS and TruLens are the open-source standards. CI gate: a PR that drops the score on the eval set blocks merge.

The full vendor stack at a glance

A typical 2026 production RAG chains those pieces into a single request path — and every stage is a scaled-up version of one of microRAG's five steps:

Widget · production RAG request path · hover a stage
The full vendor stack a query flows through at answer time, with rough per-stage latency and cost. Each box is colored by the microRAG step it belongs to. Hover or tap a stage for the mapping — and the failure in bare microRAG it exists to fix.

Total latency: ~800 ms. Total cost: a few cents per query. Total LOC: thousands. Total moving pieces: the eight cards above plus whatever your particular domain demands.

why study microRAG if production looks like that?

Because every box in the vendor diagram above is implementing one of the five microRAG steps. The reranker is a smarter Step 3. The grounded-answer verifier is a Step 5 post-check. The output redactor is the missing piece of Step 5. When a production RAG misbehaves, the question is always "which of the five steps is failing, and what would I change there?" microRAG is the mental model you reach for. Lab 15 is the corresponding question for adversarial behavior.

7 · Is the answer grounded? · NLI verification ~15 min

Retrieval can pull the perfect chunks and the LLM can still lie. Step 4's system prompt asks the model to use only the retrieved context, but nothing enforces it — the model can blend in a plausible fact from its training data, or quietly contradict the source. The assignment's grounding gate checks the retrieval side ("did we find anything relevant?"). This section checks the answer side: does each sentence the model wrote actually follow from a retrieved chunk? The production tool for that job is an NLI model.

Think · Pair · Share · catching a hallucination
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare. Did one of you land on "does chunk X entail sentence Y?" — a three-way judgment (supports / says nothing / contradicts) rather than a yes/no? That three-way judgment is exactly what an NLI model is trained to make.
  • Share · 2 minAs a table, write the rule: a sentence is grounded only if ___ of the retrieved chunks ___ it. Then check your rule against the widget below.

What NLI does

Natural Language Inference takes two short texts — a premise and a hypothesis — and returns one of three labels: the premise entails the hypothesis (if the premise holds, the hypothesis must too), contradicts it, or is neutral (the premise neither proves nor disproves it). It's a small, fast, decades-old NLP task that modern encoder models do very well.

Wiring it into RAG is one idea: premise = a retrieved chunk, hypothesis = one sentence of the answer. A sentence is grounded if at least one retrieved chunk entails it. If every chunk comes back neutral, the model made the sentence up — it's in the answer but supported by nothing you retrieved. If a chunk contradicts it, worse: the model misread its own source. Either way, flag it — drop the sentence, refuse the answer, or attach a "low-confidence" warning.

Widget · answer-grounding check · pick a sentence
On the left, the retrieved context (the premises). Pick a candidate answer sentence on the right (the hypothesis); the check finds the chunk that best supports it and returns the NLI verdict — entails, neutral, or contradicts — plus whether the sentence is grounded or flagged. Two of the four are hallucinations a fluent LLM might slip in.

Grounded ≠ true, though: if a poisoned chunk (Lab 15 §3, ingestion poisoning) entails the sentence, NLI happily marks it grounded. NLI checks the answer against the corpus — it can't check the corpus.

The models, and the cost

The workhorses are small encoders fine-tuned on NLI data — DeBERTa-v3 or RoBERTa trained on MNLI, or purpose-built ones like Vectara's hallucination_evaluation_model. They're a few hundred million parameters, run in tens of milliseconds on CPU, and cost a rounding error next to the generation call. The alternative — LLM-as-judge, asking a big model "is this sentence supported by that context?" — is more flexible but adds a second full LLM call per answer, which is slower and far pricier at scale. That's why the §6 pipeline uses a dedicated NLI step rather than a second LLM.

Try it · two grounding checks, one system

The assignment's grounding gate refuses when retrieval finds nothing relevant; this NLI check flags when the answer drifts from what was retrieved. Name one failure each catches that the other misses.

Show answer
The retrieval gate catches out-of-scope questions — it refuses before the LLM ever runs, stopping "a confident answer to a question we have no documents for." But it's blind to what the model does with good chunks: strong retrieval can still be followed by a hallucinated answer. The NLI check catches exactly that — an answer sentence unsupported by (or contradicting) the retrieved chunks — but it's useless when retrieval returned nothing worth entailing against. They're complementary: gate the input, verify the output.

Assignment · harden the retriever ~90 min · autograded

microRAG's retriever always returns something — the least-bad guess even for a question the corpus can't answer — and it lets one multi-chunk document dominate the whole top-K. Both are real weaknesses (the §2.3 edge-case exercise and the Lab 15 §4 attack). You'll fix them by implementing one function, rank(query), in a template that already contains a working microRAG retriever over a small bundled corpus.

Grounding gate — decide whether retrieval found anything, before you answer. A RAG retriever always hands back its top-K, ranked best-first — even when the corpus has no answer at all. Worse, as §2.3 showed, min–max normalization makes that top chunk's hybrid score look like a confident 1.0 no matter how weak the real match is. Paste those irrelevant chunks into the prompt and the LLM will usually produce a fluent, wrong answer out of the noise. A grounding gate is a relevance floor you check before calling the LLM: if the strongest genuine match — here, the best un-normalized keyword score — is too weak, the system abstains and says "I don't have that" instead of guessing. Production RAG calls this an answerability or retrieval-confidence check; it's the cheapest defense there is against hallucinating on out-of-scope questions.

Diversity cap — don't let one document own the whole answer. Retrieval ranks chunks by score alone, so a single document with several high-scoring chunks can fill every slot in the top-K — and then the LLM only ever sees that one source's view. That's a quality bug and a security one: in Lab 15 §4 (embedding collision) an attacker uploads one document engineered to win retrieval for many unrelated queries, sweeping the context out from under every legitimate source. A diversity cap fixes both by letting at most a couple of chunks from any single document into the top-K, forcing the results to span sources.

In short, one knob decides whether to answer, the other decides which sources get to shape the answer. Here they are side by side:

knob you implementwhat it doeswhat it blunts
Grounding gateIf the best raw keyword score across all chunks is below MIN_KW, return None — the runner prints REFUSE instead of a confident wrong answer.Hallucination on out-of-corpus questions (§2.3's "least-bad guess").
Diversity capKeep at most MAX_PER_SOURCE (= 2) chunks from any single document in the top-K.Lab 15 §4 embedding-collision — one uploaded document crowding out every other source.

The hook

base_retrieve(query) is given; it returns every chunk already sorted best-first. Each item:

# base_retrieve(query) -> list[dict], sorted by "hybrid" descending
{ "id": "database.md#2", "doc": "database.md",
  "raw_kw": 0.52,   # RAW (un-normalized) TF-IDF score — what the grounding gate needs
  "raw_em": 0.31,   # raw embedding cosine
  "hybrid": 0.90 }  # normalized blend used for the sort

def rank(query):        # <-- the only thing you write
    scored = base_retrieve(query)
    # 1. grounding gate:  return None if max(raw_kw) < MIN_KW
    # 2. diversity cap:   keep <= MAX_PER_SOURCE per doc, then take TOP_K
    ...                 # return a list of chunk ids, or None to REFUSE

Why raw_kw and not hybrid? Min–max normalization makes the top chunk's hybrid score ≈ 1.0 even when the query matches nothing — so the gate has to read the un-normalized keyword score (exactly the trap §2.3 described).

worked example · why the gate can't read hybrid

Min–max normalization is relative to one query's result set: the round's best chunk becomes 1.0 and its worst 0.0 — by construction, every single query. That's exactly what ranking needs (only relative order matters) and exactly what a gate can't use, because it erases all absolute information about how good the best match actually was. Score the same corpus against two queries and watch:

Query A · "how do I reset my password?" — the corpus has the answer.

chunkraw_kwkeyword score after min–max
password.md#00.521.00
password.md#10.240.46
network.md#20.000.00

Query B · "what is the office address in Tokyo?" — the corpus has nothing; one chunk happens to share a stray low-IDF word.

chunkraw_kwkeyword score after min–max
onboarding.md#30.011.00
database.md#00.0030.30
password.md#20.0000.00

In query B the best "match" is pure noise — a raw score of 0.01 — but min–max stretches that noise across the full [0, 1] range, so the noise-winner gets the same normalized 1.00 the genuinely excellent chunk earned in query A. After blending, the top chunk's hybrid comes out high in both cases. There is no threshold on hybrid that separates A from B, because normalization mapped both winners to the same value — the gate would pass everything. (The fully degenerate case is no better: if no query word appears anywhere, all raw keyword scores tie at 0, hi == lo zeroes the keyword leg, and hybrid becomes pure normalized embedding — where min–max again hands some chunk a 1.0.)

raw_kw still carries the signal the gate needs because raw TF-IDF sits on an absolute scale with a true zero: it sums tf × idf over the query words actually present in the chunk, so 0 means "not one query word appears here." max(raw_kw) separates the two queries cleanly — 0.52 vs 0.01 — and the floor MIN_KW = 0.05 lets A through while B abstains with REFUSE. The rule of thumb: gate on a score with absolute meaning, never on one that's been re-normalized per query. (In production, with a real embedding model, a raw cosine or a calibrated reranker score plays this role; microRAG's hashed-feature fallback makes its cosines too noisy to trust, so the raw keyword score is the honest signal here.)

I/O protocol · one query per line

$ echo "how do I reset my password?" | python3 assignment.py
OK: password.md#0, database.md#2, onboarding.md#1, network.md#0
$ echo "what is the office address in Tokyo?" | python3 assignment.py
REFUSE

Files & the autograder

↓ assignment.py ↓ test_assignment.py ↓ autograder.zip

Edit only the body of rank(). Grade yourself locally — the same checks Gradescope runs:

$ python3 test_assignment.py
[PASS] 10/10  benign: 'reset my password okta' -> top is password.md
…
[PASS] 25/25  grounding gate: nonsense query REFUSES (returns None)
[PASS] 25/25  diversity cap: 'database' <=2 per source, >=2 sources
        100/100

Rubric · 100 points, all automatic

checkpoints
Benign retrieval — the right document is #1 for four in-corpus queries (10 each)40
Grounding gate — an out-of-corpus query returns REFUSE25
Diversity cap — "database" returns ≤ 2 chunks per source and ≥ 2 sources25
Contract — every response is None or ≤ TOP_K valid chunk ids10
Total100
Going further · optional, ungraded

Each knob also opens a question. (a) The grounding gate turns on a MIN_KW threshold — what attack does a too-low threshold re-enable, and what does a too-high one break for legitimate users? (b) The diversity cap forces variety into the top-K — sketch a poisoning strategy from Lab 15 that a per-source cap makes easier, not harder. One short paragraph each.

Submit your edited assignment.py to Gradescope — nothing else. The grader imports your rank() and runs the checks above.

FAQ

Why not just use LangChain?

Same answer as microagent: LangChain runs the same five steps. It also runs a hundred other things — output parsers, callback systems, chains of chains. Use LangChain in production; use microRAG to understand what LangChain is doing under the hood, and to see exactly where Lab 15's attacks land.

Will the hashed-feature embedding catch any semantic similarity?

Surprisingly, yes — token overlap proxies for semantic similarity when the corpus is small and the queries share vocabulary with the documents. The retrieval traces in §3 work cleanly on a 4-document corpus. Quality drops sharply at scale; that's why production uses real embeddings. The lab uses the fallback so it runs without a model download.

What's the relationship to Lab 06 (microMCP)?

Both are ways to give an LLM external knowledge it didn't have at training time. MCP gives it tool-calling capability — fetch this URL, query this DB, read this file. RAG pre-fetches relevant text and pastes it into the prompt. Many production assistants use both (RAG for "what we already know," MCP for "what we can find out right now"). The two attack surfaces overlap — prompt injection, trust boundaries — but the failure modes differ enough to be worth covering separately.

What's the relationship to Lab 03 (nanochat)?

Lab 03 built the LLM. Lab 14 builds the retrieval system that connects an LLM to external knowledge. Together they cover both halves of "how a modern AI assistant answers a question." Production assistants in 2026 almost always pair a hosted LLM with a custom RAG over the organization's documents.