Chapter 9: Evaluation
Eight chapters, four retrieval techniques, word overlap, embeddings, hybrid search, re-ranking, and every single time the case for “this one is better” was one or two examples: a question that failed before and passed after. That’s how this whole series has argued for each improvement, and it’s worth being honest about what that actually is: anecdote, not measurement.
This chapter builds an actual test set and measures all four techniques against it, at once, side by side. Some of what it finds is exactly what you’d expect. At least one result won’t be.
Where we left off: Eight chapters, four retrieval techniques, and every case for “this one is better” has been one or two hand-picked examples. That’s anecdote, not measurement.
What we fix now: Build an actual labeled test set and measure all four retrieval techniques against it, at once, side by side. At least one result won’t be what you’d expect.
By the end, you can:
explain recall@k and why the choice of k depends on what happens downstream
measure multiple retrieval techniques on the same test set instead of arguing from single examples
trace a specific, surprising result back to its actual cause instead of just accepting the number
Source
import numpy as np
import re
import math
# Same setup as Chapters 2-8.
training_corpus = [
"Employees may carry over unused vacation days into the next calendar year.",
"Staff can roll over unused holiday days into next year.",
"Workers get paid time off and vacation days every year.",
"Most employees take a vacation or holiday once a year.",
"Unused vacation time can roll over to next year for many employees.",
"Holiday leave and vacation leave are treated the same way at most companies.",
"Some employees prefer to use their holiday days instead of saving them.",
"Vacation days and holiday days both count as paid time off.",
"Expense reports must be submitted within days of the purchase.",
"Employees submit an expense report after every business purchase.",
"The finance team reviews each expense report and each purchase receipt.",
"A purchase over the limit needs approval before the expense is reimbursed.",
"Expense claims and purchase receipts are reviewed together by finance.",
"Remote employees receive a home office stipend to set up their workspace.",
"The company gives remote workers a stipend for home office equipment.",
"Employees working remote from home can request an office stipend.",
"A stipend helps remote staff pay for office furniture at home.",
"The office stipend is a one time payment for remote employees.",
"Vacation policy, expense policy, and remote work policy are all in the handbook.",
"Every employee should read the handbook for vacation and expense rules.",
"Employees who are sick should notify their manager as soon as possible.",
"Sick leave is paid time off for employees who are too unwell to work.",
"A doctor's note may be required after three consecutive sick days.",
"Unused sick leave does not roll over into the next year.",
"Employees should stay home and rest when they are sick.",
"Standard working hours are nine to five, Monday through Friday.",
"Employees may request a flexible schedule with manager approval.",
"Overtime must be approved in advance by a manager.",
"Most staff work standard business hours unless a flexible schedule is approved.",
"Employees are expected to treat colleagues and customers with respect.",
"Harassment or discrimination of any kind will not be tolerated.",
"Violations of the code of conduct may result in disciplinary action.",
"Every employee must follow the code of conduct and treat others with respect.",
"Company laptops must be encrypted and password protected.",
"Employees should not install unauthorized software on company devices.",
"Report a lost or stolen laptop to IT immediately.",
"IT equipment must be returned when an employee leaves the company.",
"The IT team manages laptops, software, and device security for the company.",
"Contact the helpdesk if you have a technical problem with your computer.",
"The support team can help reset passwords and fix login issues.",
"Call the helpdesk for any urgent technical problem during business hours.",
]
def tokenize(text):
return re.findall(r"[a-z']+", text.lower())
tokens_per_sentence = [tokenize(s) for s in training_corpus]
vocab = sorted(set(w for sent in tokens_per_sentence for w in sent))
word_index = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
window = 4
cooc = np.zeros((V, V))
for sent in tokens_per_sentence:
for i, w in enumerate(sent):
for j in range(max(0, i - window), min(len(sent), i + window + 1)):
if i != j:
cooc[word_index[w], word_index[sent[j]]] += 1
X = np.log1p(cooc)
U, S, Vt = np.linalg.svd(X, full_matrices=False)
DIM = 25
word_vectors = U[:, :DIM] * S[:DIM]
def embedding(word):
return word_vectors[word_index[word]] if word in word_index else None
def embed_text(text):
words = [w for w in tokenize(text) if w in word_index]
if not words:
return np.zeros(DIM)
return np.mean([embedding(w) for w in words], axis=0)
def cosine_similarity(a, b):
norm = np.linalg.norm(a) * np.linalg.norm(b)
return float(np.dot(a, b) / norm) if norm else 0.0
handbook_document = """Vacation Policy
Employees may carry over up to 5 unused vacation days into the next calendar year. Time off should be requested from a manager at least two weeks in advance when possible.
Sick Leave Policy
Employees who are too unwell to work should notify their manager as soon as possible. Sick leave is paid time off and does not roll over into the next year. A doctor\'s note may be required after three consecutive sick days.
Expense Policy
Expense reports must be submitted within 30 days of the purchase date. The finance team reviews each expense report and matching purchase receipt before reimbursement.
Remote Work Policy
Remote employees are provided a one-time home office stipend of 500 euros. The stipend covers office furniture and basic equipment needed to work from home.
Working Hours
Standard working hours are nine to five, Monday through Friday. Employees may request a flexible schedule with manager approval, and any overtime must be approved in advance.
Code of Conduct
Employees are expected to treat colleagues and customers with respect. Harassment or discrimination of any kind will not be tolerated, and violations may result in disciplinary action.
IT Support
For urgent technical problems, contact the helpdesk at extension 4477. For non-urgent requests, submit a ticket through the internal support portal."""
section_chunks = [p.strip() for p in handbook_document.split("\n\n")]
def section_title(chunk):
return chunk.splitlines()[0]
print(f"Ready: {len(section_chunks)} sections.")# A labeled test set: (question, correct section) pairs, roughly two
# per section, including trap questions from Chapters 2, 5, and 7.
test_set = [
("how many vacation days can be carried over into next year?", "Vacation Policy"),
("do unused holidays roll over to next year?", "Vacation Policy"),
("how many sick days can be carried over to next year?", "Sick Leave Policy"),
("do i need a doctor's note if i am sick for several days?", "Sick Leave Policy"),
("how long do i have to submit an expense report?", "Expense Policy"),
("who reviews my purchase receipts?", "Expense Policy"),
("how much is the home office stipend?", "Remote Work Policy"),
("what equipment does the stipend cover?", "Remote Work Policy"),
("what are the standard working hours?", "Working Hours"),
("do i need approval before working overtime?", "Working Hours"),
("how should i treat my colleagues at work?", "Code of Conduct"),
("what happens if i violate the code of conduct?", "Code of Conduct"),
("what department is 4477", "IT Support"),
("who can help me reset my password?", "IT Support"),
]
print(f"{len(test_set)} labeled test questions, covering all {len(section_chunks)} sections.")Recall@k¶
Recall@k: out of every test question, what fraction had the
correct chunk somewhere in the top k results? k=1 asks a strict
question, was the very first result correct. Larger k is more
forgiving, it credits a method for getting the right answer close to
the top, not necessarily first.
def top_k(question, score_fn, k):
return sorted(section_chunks, key=lambda c: score_fn(question, c), reverse=True)[:k]
def recall_at_k(score_fn, k=1):
hits = 0
for question, expected in test_set:
titles = [section_title(c) for c in top_k(question, score_fn, k)]
hits += expected in titles
return hits / len(test_set)
def word_overlap_score(question, candidate):
q_words = set(question.lower().split())
c_words = set(candidate.lower().split())
return len(q_words & c_words)
def bi_encoder_score(question, candidate):
return cosine_similarity(embed_text(question), embed_text(candidate))
print(f"word overlap (Ch1): recall@1 = {recall_at_k(word_overlap_score):.3f}")
print(f"embeddings (Ch2-6): recall@1 = {recall_at_k(bi_encoder_score):.3f}")A real, aggregate confirmation of the whole series’ argument so far: embeddings genuinely do better than word overlap across this test set, not just on Chapter 2’s one carefully chosen example. Now add the other two techniques.
class BM25:
def __init__(self, documents, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.doc_tokens = [re.findall(r"[a-z0-9\']+", d.lower()) for d in documents]
self.doc_lens = [len(t) for t in self.doc_tokens]
self.avgdl = sum(self.doc_lens) / len(self.doc_lens)
self.N = len(documents)
doc_freq = {}
for toks in self.doc_tokens:
for term in set(toks):
doc_freq[term] = doc_freq.get(term, 0) + 1
self.idf = {t: math.log((self.N - n + 0.5) / (n + 0.5) + 1) for t, n in doc_freq.items()}
def score(self, query, doc_index):
term_freq = {}
for t in self.doc_tokens[doc_index]:
term_freq[t] = term_freq.get(t, 0) + 1
dl = self.doc_lens[doc_index]
total = 0.0
for term in re.findall(r"[a-z0-9']+", query.lower()):
if term not in self.idf:
continue
f = term_freq.get(term, 0)
total += self.idf[term] * (f * (self.k1 + 1)) / (f + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
return total
bm25 = BM25(section_chunks)
def rrf_top_k(question, k, rrf_k=60):
embed_ranked = sorted(range(len(section_chunks)), key=lambda i: bi_encoder_score(question, section_chunks[i]), reverse=True)
bm25_ranked = sorted(range(len(section_chunks)), key=lambda i: bm25.score(question, i), reverse=True)
scores = {}
for rank, i in enumerate(embed_ranked, start=1):
scores[i] = scores.get(i, 0) + 1 / (rrf_k + rank)
for rank, i in enumerate(bm25_ranked, start=1):
scores[i] = scores.get(i, 0) + 1 / (rrf_k + rank)
ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
return [section_chunks[i] for i, _ in ranked[:k]]
def late_interaction_score(question, candidate):
q_words = [w for w in tokenize(question) if w in word_index]
c_words = [w for w in tokenize(candidate) if w in word_index]
if not q_words or not c_words:
return 0.0
candidate_vectors = [embedding(w) for w in c_words]
return sum(max(cosine_similarity(embedding(qw), cv) for cv in candidate_vectors) for qw in q_words) / len(q_words)
def hybrid_rerank_top_k(question, k, shortlist_size=3):
shortlist = rrf_top_k(question, shortlist_size)
reranked = sorted(shortlist, key=lambda c: late_interaction_score(question, c), reverse=True)
return reranked[:k]
def recall_at_k_custom(topk_fn, k=1):
hits = 0
for question, expected in test_set:
titles = [section_title(c) for c in topk_fn(question, k)]
hits += expected in titles
return hits / len(test_set)
print(f"word overlap (Ch1): recall@1 = {recall_at_k(word_overlap_score):.3f}")
print(f"embeddings (Ch2-6): recall@1 = {recall_at_k(bi_encoder_score):.3f}")
print(f"hybrid (Ch7): recall@1 = {recall_at_k_custom(rrf_top_k):.3f}")
print(f"hybrid + rerank (Ch7+8): recall@1 = {recall_at_k_custom(hybrid_rerank_top_k):.3f}")Hybrid search is the best performer here, a real jump over plain embeddings. But look at the last line: hybrid plus re-ranking is worse than hybrid alone, not a tie, a genuine drop. That’s not what Chapter 8 would have predicted on its own, re-ranking fixed a real problem there. It’s worth actually looking at why, instead of taking the number’s word for it.
# Find a question hybrid alone got right, that hybrid+rerank got wrong.
for question, expected in test_set:
hybrid_got = section_title(rrf_top_k(question, 1)[0])
reranked_got = section_title(hybrid_rerank_top_k(question, 1)[0])
if hybrid_got == expected and reranked_got != expected:
print(f"Question: {question!r}")
print(f" hybrid alone got it right: {hybrid_got!r}")
print(f" hybrid + rerank got it wrong: {reranked_got!r}")
print()
shortlist = rrf_top_k(question, 3)
print(" Re-ranker's view of the shortlist:")
for c in shortlist:
print(f" {late_interaction_score(question, c):.3f} {section_title(c)}")
breakThe correct answer was sitting at rank 1 after hybrid search, already right. Re-ranking then looked at just the top 3 and, using its own token-level scoring, confidently moved a different section above it. The hand-built re-ranker from Chapter 8 fixed one specific failure (dilution from averaging) but it isn’t a strictly-better general signal, it has its own blind spots, and those blind spots can demote a correct answer just as easily as they can promote one. There’s no way to know this from a single crafted example, only from measuring across many.
print(f"word overlap (Ch1): recall@3 = {recall_at_k(word_overlap_score, k=3):.3f}")
print(f"embeddings (Ch2-6): recall@3 = {recall_at_k(bi_encoder_score, k=3):.3f}")
print(f"hybrid (Ch7): recall@3 = {recall_at_k_custom(rrf_top_k, k=3):.3f}")The story shifts at k=3: word overlap and embeddings catch up to
each other, both often have the right answer nearby even when they
don’t rank it first. Which k actually matters depends on what happens
next in the pipeline: if only the top-1 chunk gets handed to a
generation step, recall@1 is the number that matters. If the top 3 get
handed over together, recall@3 is closer to the truth. There’s no
single “the” accuracy number for a retrieval system, only a number for
a specific k, chosen for a specific reason.
print(f"word overlap: {recall_at_k(word_overlap_score):.3f}")
print(f"embeddings: {recall_at_k(bi_encoder_score):.3f}")
print(f"hybrid: {recall_at_k_custom(rrf_top_k):.3f}")
print(f"hybrid + rerank: {recall_at_k_custom(hybrid_rerank_top_k):.3f}")Quick check¶
Before moving on, answer for yourself (no peeking ahead):
Why did adding re-ranking on top of hybrid search make recall@1 worse instead of better? Was Chapter 8 wrong that re-ranking helps?
Check your reasoning ▸
Chapter 8 wasn’t wrong, its example was real and correctly diagnosed, but re-ranking fixes one specific failure mode (dilution from averaging) and has its own blind spots elsewhere. On this test set, the hand-built re-ranker demoted at least one already-correct answer, showing that a fix which helps in one crafted example isn’t guaranteed to help in aggregate, exactly why this chapter measures instead of assuming.
Recall@1 and recall@3 told noticeably different stories for word overlap and embeddings. Which one would you trust more if you knew the next step in the pipeline only ever looks at a single retrieved chunk?
Check your reasoning ▸
If only the top-1 chunk ever reaches the next step, recall@1 is the number that actually matters, recall@3 measures something the pipeline never uses. A good answer explicitly connects the right metric to what happens downstream, rather than picking whichever number looks better.
This chapter’s test set has 14 questions and was written by one person. What specific kinds of mistakes could that introduce, and how would you notice them?
Check your reasoning ▸
A strong answer names concrete risks: the author’s own assumptions about what counts as “correct” could be wrong or debatable, the questions could unintentionally favor phrasing similar to the handbook’s own wording, and 14 questions is too few to catch rare failure patterns. Noticing these requires a second reviewer, a larger and more diverse set, or comparing against real user questions rather than author-invented ones.
If you had to pick exactly one configuration from this chapter to ship in a real system, based purely on the recall@1 numbers, which would you pick, and what would you still want to check before trusting that choice completely?
Check your reasoning ▸
Based purely on these numbers, hybrid search alone is the strongest recall@1 performer here. A careful answer still flags what these numbers don’t cover: only 14 questions, one author’s labels, and recall@k measuring retrieval only, not whether generation and citation downstream actually use the retrieved chunk correctly.
The test set includes “what department is 4477” as a trap question specifically because Chapter 7 showed embeddings failing on it. What would you expect word overlap alone to score on that specific question, and why?
Check your reasoning ▸
Word overlap should actually do reasonably well here, since “4477” is a literal, shared token between the question and the IT Support section, exactly the kind of exact-match signal word overlap is built to catch. This is a good moment to notice that no single technique is uniformly worst either: word overlap’s weakness is paraphrase, a different weakness than embeddings’ weakness on this same question.
You can now: measure retrieval techniques against an actual labeled test set instead of arguing from one example at a time, and you’ve traced a result the series’ own earlier chapters wouldn’t have predicted back to its real cause.
But one problem remains: this series has built one technique after another, but never asked what happens when you try to combine everything at once, and how many of these fixes actually survive contact with each other.
Next → Chapter 10: The Capstone