Session 7. Retrieval and grounding metrics — Tue 22 Sep
The cheapest fix, and what it cost
Add the words the document uses
The MISS was a vocabulary gap. The query said instructions inside documents; the document says injection, untrusted, delimiters. Nothing about the index is broken. The query is simply written in the wrong language.
So rewrite the query before it reaches retrieval:
SYNONYMS = {
"instructions": "injection untrusted delimiters",
}
def expand(query: str) -> str:
expanded = query
for phrase, extra in SYNONYMS.items():
if phrase in query.lower():
expanded += " " + extra
return expanded
HIT prompt-injection <- How do I defend against instructions inside documents? injection untrusted delimiters
expanded hit rate @3: 100%
80% to 100%. No new dependency, no index rebuild, no embedding model, and the whole change fits in a dict you can read out loud. That last property is the one to notice: when this fix misbehaves you can see why in the printed query. A dense retriever that misbehaves gives you a vector.
One change per measurement. The cell changed exactly one thing and re-ran the
same five cases. Change the expansion and top_k together and the number moves
for a reason you cannot name.
Now measure it somewhere it was not tuned
The rule was written by looking at the case it had to fix. So 100% is a
measurement of the fix on its own homework. Here are four queries it never saw,
all containing the word instructions, none of them about injection:
probes = [
("What instructions should a skill file contain?", "mcp-overview"),
("How do I write instructions for chunking documents?", "rag-basics"),
("What instructions make an agent loop stop?", "agent-loops"),
("What instructions produce strict JSON?", "structured-outputs"),
]
probe hit rate @1: 75% -> 0%
probe hit rate @3: 75% -> 50%
top-1 for each probe, after expansion:
['prompt-injection'] (expected mcp-overview)
['prompt-injection'] (expected rag-basics)
['prompt-injection'] (expected agent-loops)
['prompt-injection'] (expected structured-outputs)
Every probe now returns prompt-injection first. The rule fires on the ordinary
word instructions, and the three words it injects are rare in this corpus, so
they carry high inverse-document-frequency weight and swamp everything the user
actually asked. The fix did not add knowledge. It added a bias, and the bias
points at whichever document the last miss belonged to.
Two numbers, one change:
| Set | Metric | Before | After |
|---|---|---|---|
| labeled (tuned on) | hit rate @3 | 80% | 100% |
| probes (not tuned on) | hit rate @1 | 75% | 0% |
| probes (not tuned on) | hit rate @3 | 75% | 50% |
Report only the first row and you have not lied about any single number. You have still misled everyone who reads it.
The honest report
report = {
"improvement": (
"Hit rate @3 on the labeled set went from 80% to 100%: case 5 now reaches "
"prompt-injection, which the question's own words never scored high enough to find."
),
"regression_or_risk": (
"The rule fires on every query containing 'instructions', and on four probe queries "
"it was never tuned on it pushed prompt-injection to rank 1 in all four: probe hit "
"rate @1 fell from 75% to 0%, and @3 from 75% to 50%."
),
}
The shape is fixed and it is two fields, because a report with one field is a
sales pitch. ch07-e2 rejects an empty regression_or_risk, and it rejects
"none", because "none" is almost never true. If you genuinely cannot find a cost,
you have not measured off the tuning set yet.
What a good regression sentence contains: what fires, on what, and the number it moved. "May cause false positives" is not a regression, it is a mood.
Where the other fixes sit
Query expansion is the first tool because it is the cheapest and the most inspectable. It is not the only one, and each of the others trades the same way.
| Fix | Buys | Costs |
|---|---|---|
| Query expansion | paraphrase recall, today, in a readable dict | hand-kept lists rot; broad keys poison unrelated queries |
| Metadata filtering | precision, by excluding whole documents up front | a wrong filter makes the right answer unreachable, and silently |
| Embeddings | real paraphrase matching, no word overlap needed | a dependency, an index to rebuild, and a failure you cannot read |
| Reranking | ordering, when recall is fine and rank is not | latency and a second model in the path |
| Prompt compression | context budget | the sentence you dropped was the one that mattered |
Pre-filter or post-filter is the same trade in miniature. Filtering before retrieval is fast and can make a document invisible; filtering after is safer and you pay to retrieve things you then discard.
None of these is "the upgrade". Each is a change you make one at a time, against a set you wrote first, and report with both of its numbers.
The habit this page is really teaching
Measure, change one thing, re-measure, then re-measure somewhere the change was not designed for. The last step is the one people skip, and skipping it is how a team ships a retriever that scores beautifully on the eval and worse in production. The eval was not wrong. It was tuned.
The same trap has a bigger version, and it is the next page: not a fix tuned to the set, but a system tuned to the pass condition.