Session 6. A retrieval baseline — Mon 21 Sep
Lexical retrieval, and what it cannot do
The simplest index there is
An index is a key and a list of documents. The corpus already ships keys, so the first index writes itself:
tag_index: dict[str, list[str]] = {}
for doc in documents:
for tag in doc.tags:
tag_index.setdefault(tag, []).append(doc.doc_id)
tag_index = {tag: sorted(ids) for tag, ids in tag_index.items()}
agent ['agent-loops']
chunking ['rag-basics']
citations ['rag-basics']
...
tools ['agent-loops', 'mcp-overview']
Twenty-seven tags over six documents. Look at what that means: twenty-six of
those tags point at exactly one document, and only tools points at two. The
index is perfectly precise and nearly useless, for two reasons that both matter.
A question has to hit a key exactly. "How do I split a document up?" contains
no tag, so this index returns nothing at all — not a bad answer, no answer.
And when a key does hit, the unit it returns is a whole document, which for
mcp-overview is 2,349 characters of which maybe one paragraph is relevant.
So keep the shape — a key, a list of things that carry it — and change the key from a hand-written tag to every word in the text, and the unit from a document to a passage. That is lexical retrieval.
Chunking: what the retriever actually sees
The retriever never sees a document. It sees chunks.
chunks = chunk_document(doc, max_chars=800)
--- rag-basics#0 (383 chars)
# RAG Basics Retrieval-augmented generation (RAG) grounds a model's answer in documents you control. Instead of hoping ...
--- rag-basics#1 (707 chars)
A minimal RAG pipeline has four stages. **Chunking** splits documents into passages small enough to be individually rele ...
--- rag-basics#2 (558 chars)
When a RAG system answers wrongly, the first diagnostic question is: did the right passage reach the prompt? If retrieva ...
--- rag-basics#3 (614 chars)
Every answer should name the document ids it drew from, and the application should verify those ids against what was act ...
chunk_document packs whole paragraphs until adding the next one would cross
max_chars, so chunks land on paragraph boundaries and none of these four is
exactly 800 characters. One paragraph longer than max_chars on its own is the
exception: it gets truncated, and that is the only place a chunk can end
mid-sentence. Every chunk keeps doc_id and position, which is what makes a
citation checkable later — rag-basics#2 is an address you can go and read.
max_chars is a trade you make on purpose. Small chunks score sharply and
arrive at the prompt without their context. Large chunks carry their context
and dilute the score, because overlap is measured against everything else in
the chunk too. Your homework is to move that number and report both sides of
what happens.
Scoring, in four steps
scored = retrieve(query, documents, top_k=3)
for hit in scored:
print(f"{hit.score:6.2f} {hit.chunk.doc_id}#{hit.chunk.position}")
4.88 rag-basics#1
4.88 rag-basics#2
3.61 rag-basics#0
- Tokenise.
[a-z0-9]+over the lowercased text, then drop stopwords.how,do,i,fromcarry no signal in this corpus, so they are removed from both the query and the chunk. - Overlap. Keep only the chunks that share at least one token with the query. A chunk with no shared token is never scored, which is why an empty result is a real outcome rather than a low one.
- Weight by rarity. Each shared token adds
log(1 + total_chunks / df), wheredfcounts the chunks containing it. A token in every chunk adds the least it can; a token in one chunk adds several times more. That is inverse document frequency, and it is the difference between this and counting words. - Sort, then cut. The key is
(-score, doc_id, position). The two extra fields are not decoration:rag-basics#1andrag-basics#2tie at4.88above, and without a tie-break the order would depend on dictionary iteration and your failure table would stop being reproducible.
Deterministic, dependency-free, and you can always answer "why did this match?" by intersecting two sets of tokens by hand. That is why the baseline is lexical, and why it is a respectable baseline rather than a placeholder.
What it cannot do
Ask this corpus about chunk size:
retrieve("what is a good chunk size", documents, top_k=3)
3.18 structured-outputs#2
One hit. The wrong document. rag-basics has a whole section on chunking and
it is nowhere. Take the query apart and the mechanism is not mysterious at all:
| Query token | In the corpus? |
|---|---|
what, is, a |
stopwords, dropped before scoring |
chunk |
never appears — rag-basics writes chunking and chunks |
size |
never appears anywhere in the corpus |
good |
appears in structured-outputs#2 |
The retriever did precisely what it promises. chunk and chunking are
different strings, so nothing connects them, and the single surviving token
pulls back a passage about something else entirely. There is no stemming, no
synonyms, no notion that two words mean the same thing.
An embedding index would close that gap, and this is not the session that adds one. Session 7 measures how wide the gap is first, because an upgrade with no measured baseline is a preference, not an improvement — and embeddings bring their own costs: a dependency, a model, an index to rebuild, and scores you can no longer explain by reading two lists of words.
Write the miss down instead. That is what the failure table in the notebook is
for, and missed is a finding, not a mistake you made.