Session 6. A retrieval baseline — Mon 21 Sep

Three failures, and what each looks like

Failure 1: an empty result

retrieve("vector embeddings cosine similarity", documents, top_k=3)
  (nothing retrieved)

No scores, no chunks, no error. Every query token — vector, embeddings, cosine, similarity — is absent from all twenty-three chunks, so nothing is ever scored and retrieve returns [].

An empty list is the honest answer, and the failure is what happens next. Nothing goes into the prompt, so a model asked anyway would answer from memory and cite whatever sounded right. agent.py refuses instead, before it spends the call:

trace[retrieve] top_k=3 -> []
trace[decision] no relevant chunks; refusing without an LLM call

No llm_call line. The refusal cost nothing and cannot hallucinate, because the part capable of hallucinating was never asked. The empty result is the easiest of the three failures to handle for exactly one reason: it is visible.

The wrong response is to make the retriever return something. Lowering a threshold until every query gets three chunks converts this failure into the third one, where the same nothing arrives dressed as an answer.

Failure 2: a stale index

snapshot = load_corpus(scratch)  # the index is built ONCE, from this snapshot
indexed = sorted(doc.doc_id for doc in snapshot)
tags_seen = sorted({tag for doc in snapshot for tag in doc.tags})

(scratch / "two.md").write_text(...)  # the corpus moves on; the index does not
on disk : ['one', 'two']
indexed : ['one']
tags    : ['alpha'] ->  'beta' in tags: False
rebuilt : ['one', 'two']

Nothing raised. Nothing was logged. The document is on disk, readable, correct, and invisible to every query, because the index is a value that was computed once from a corpus that has since moved.

This is the dangerous one, and the reason is in the output: the two other failures show up in the result, and this one shows up as a result that is merely old. It is right about the world it was built from. In production the usual cause is not a new file; it is an edited one, so the index still returns the document and the passage it returns no longer exists.

Three rules keep it from biting:

Today the whole corpus loads in milliseconds, so the notebook rebuilds instead of stamping. Say that out loud rather than pretending the problem is gone: rebuilding every time is a legitimate strategy at six documents and stops being one somewhere before six thousand.

Failure 3: the wrong document returned

retrieve("what is a good chunk size", documents, top_k=3)
  3.18  structured-outputs#2

One hit, a score in the normal range, and the wrong document. 3.18 looks like the scores that come back when retrieval works, because it is the same arithmetic: the query and the chunk share the token good, which is rare enough in this corpus to be worth 3.18.

That is the whole lesson about scores. A score measures word overlap, not relevance. It cannot go wrong, and it cannot tell you it went wrong, because being wrong is not a thing the number can express.

The same failure has a quieter version. Ask How does chunking work? and three chunks come back, tied at 2.53, on the word work:

  2.53  evaluation-basics#0
  2.53  prompt-injection#2
  2.53  rag-basics#1

The right passage is there — third, behind two documents that have nothing to do with chunking, and third only because the tie-break sorts by doc_id. At top_k=3 you keep it. At top_k=2 it is gone, and the prompt is filled with two irrelevant passages instead. Cutting top_k to reduce noise is exactly how that happens.

Which leaves one defence, and it is the habit this session exists to install:

Read the chunk, not the score.

Two lines of output — the id and the first sentence — are enough to see that structured-outputs#2 is not about chunk size. Nothing else in the pipeline will notice for you.

The diagnostic question

An answer comes back wrong. Ask one question before you touch anything:

Did the right passage reach the prompt?

Answer The failure Where the fix goes
No retrieval chunking, the index, the query
Yes generation instructions, schema, model

Never prompt-engineer a retrieval failure. A better instruction cannot recover a passage the model was never shown, and the hour you spend rewriting the prompt is an hour the retriever spends unchanged. That single question is worth more than any rule in this session, and answering it takes one print.

Metadata is what makes a citation checkable

Every chunk carries doc_id and position; every document carries tags and source. That is why agent.py can compare the ids the model cited against the ids retrieval actually returned, strip a citation nobody retrieved, cap the confidence and flag the answer for review.

trace[decision] fabricated citations stripped: ['internal-wiki']; flagged for human review

Without the metadata the citation is a plausible string. With it, the citation is a claim you can check against a list you already have. Session 7 turns that check into a number.

The failure table

The lab asks for a verdict per query, in four words, each of which names a different shape of result:

Verdict What came back
good the right document, ranked where it should be
missed the right document is absent, sometimes because nothing came back at all
irrelevant a wrong document is present
duplicated one document fills several slots and crowds the rest out

ch06-e1 re-runs retrieval for each query and compares your verdict with what happened. A query that retrieves nothing can only be missed, and a query that retrieves something cannot be. The check has no opinion about your reason, and it does require you to write one — a verdict without a reason is a label, and a label teaches nobody anything next week.

Recap

Page One line
The corpus and its loader a typed loader refuses loudly, because a half-loaded corpus produces normal-looking scores
Lexical retrieval tokens, overlap, IDF, a deterministic sort — explainable, and blind to paraphrase
Three failures empty is visible, stale is silent, wrong-document looks exactly like success

Exit ticket and homework

Change max_chars or top_k, rerun the failure table, and write down ONE improvement and ONE regression. There is always both, and a report with only the improvement in it is the thing session 7 is built to catch.