Session 7. Retrieval and grounding metrics — Tue 22 Sep

Measure before you optimize

"It seemed better" is not a result

Session 6 left you with a retrieval pipeline and a habit: look at the chunks before blaming the model. Looking is diagnosis. It does not tell you whether yesterday's change helped, because you cannot hold twenty retrievals in your head and compare them with twenty from last week.

A measurement can. It needs three things, and none of them is infrastructure: a fixed set of cases, a pass condition, and a command anyone can run.

The metrics, and the one you can afford today

Metric The question it answers What it costs to compute
Hit rate @k did the expected document make the top k? one labeled pair per case
Recall of all the relevant passages, how many came back? every relevant passage labeled, per query
Precision of what came back, how much was relevant? every returned passage judged, per query
Latency how long did the user wait? a timer
Context size how much of the prompt budget did retrieval burn? a token count

Recall and precision are the honest pair, and they are expensive: they need someone to label every passage in the corpus for every query. Hit rate needs one doc id per query. That is why the session lives on hit rate — not because it is the best metric, because it is the one that exists by lunchtime.

Know what you gave up. Hit rate at 3 says the right document was somewhere in the top three. It does not say it was first, does not say the other two were junk, and does not say the passage that came back is the passage that answers the question. It catches regressions, which is most of the value, and it flatters you about everything else.

A labeled set is ten minutes of work

labeled = [
    ("How does chunking work in RAG?", "rag-basics"),
    ("What stopping conditions should a loop have?", "agent-loops"),
    ("Why validate structured output strictly?", "structured-outputs"),
    ("What is an MCP server?", "mcp-overview"),
    ("How do I defend against instructions inside documents?", "prompt-injection"),
]

Query, expected doc id. No database, no framework, no annotation tool. Five cases beat zero cases by infinity, because zero cases means every claim you make about retrieval is a memory of one query you tried on Tuesday.

Two rules make the set worth having. Write it before the fix. A set written after you know what you want it to say is a mirror. And write it against the real corpus, so a label is a fact about data/corpus/, not a guess.

Scoring it

def hit_rate(cases, top_k=3):
    hits = 0
    for query, expected in cases:
        got = {s.chunk.doc_id for s in retrieve(query, documents, top_k=top_k)}
        hits += expected in got
        print(f"{'HIT ' if expected in got else 'MISS'}  {expected:20} <- {query}")
    return hits / len(cases)
HIT   rag-basics           <- How does chunking work in RAG?
HIT   agent-loops          <- What stopping conditions should a loop have?
HIT   structured-outputs   <- Why validate structured output strictly?
HIT   mcp-overview         <- What is an MCP server?
MISS  prompt-injection     <- How do I defend against instructions inside documents?

baseline hit rate @3: 80%

Eleven lines of code, one number, and a per-case line that says which case to go look at. The per-case print matters more than the total: 80% tells you to do something, MISS prompt-injection tells you what.

The MISS, read as words

The query asks about instructions inside documents. prompt-injection.md calls the same thing injection, arriving from untrusted content, defended with delimiters. One topic, two vocabularies, no shared content word — so a lexical scorer has nothing to add up, and four documents that share the ordinary words instructions and documents outrank the one that is actually about it.

This is the paraphrase gap, and it is the measured case for embeddings: semantic search closes exactly this, and buys a dependency and a failure mode you cannot read by eye. Quantify the gap before you pay for it. Right now the gap is worth one case out of five, and the next page fixes it for free.

Fewer, better passages

The other reflex to kill early: raising top_k to 10 "to be safe". It is not safe.

Post-filtering and reranking (score the top 50 cheaply, reorder them with a stronger model, keep 3) and prompt compression (drop the sentences the query does not touch) both attack the same problem from the other end: fewer, better passages. Both are real tools and neither is in today's budget. They earn their place after a measurement says the cheap fix ran out — which is the whole point of having the measurement first.

Two things a hit rate cannot tell you

It cannot tell you whether the model used the passage that arrived. That is grounding, and it needs the full agent and a different set — page 3.

And it cannot tell you whether the number is honest. The next page takes hit rate from 80% to 100% with three words, and the number is a lie by omission until you measure it somewhere it was not tuned.