TAI BUI
Back to curriculum
CapstonePython

Literature Retrieval

A hypothesis is cheap. Knowing whether someone already proved it is the expensive part. Build the retrieval layer that answers that question before the runner spins up a sandbox.

TIME: ~90 minutesPREREQUISITES: Phase 19 Track A lessons 20-29

Learning Objectives

  • Model a small paper record with the fields the loop will read downstream.
  • Build a BM25 index over paper titles and abstracts with stdlib data structures only.
  • Walk a citation graph to surface papers the lexical search missed.
  • Deduplicate hits across the lexical and graph passes by stable paper id.
  • Wrap two mock external APIs behind a single client so the upstream call site stays the same when real endpoints land.

Why two retrieval passes

A keyword search over abstracts returns the papers that share vocabulary with the query. That covers most of the surface, and it misses two cases. In the first, the foundational paper uses different vocabulary: a query for "sparse attention" misses a paper titled "block selection in transformer routing." In the second, the relevant paper is a follow-up that cites a known anchor, and finding the anchor then walking forward is more efficient than brute-forcing the abstract pool.

So this lesson builds two passes. BM25 over titles and abstracts catches the lexical hits. A citation graph traversal takes those hits as a seed set and expands it forward and backward by one or two hops. The union is deduplicated by paper id and ranked by a small combined score.

Check: Why two retrieval passes0/1
1. Why does the retrieval client run both a BM25 pass and a citation graph pass?Explain

The Paper shape

text
Paper
  id          : str           (stable identifier, "p001" for the mock corpus)
  title       : str
  abstract    : str
  year        : int
  authors     : list[str]
  references  : list[str]     (paper ids this paper cites)
  citations   : list[str]     (paper ids that cite this paper)
  source      : str           (which mock api supplied it, "arxiv" or "s2"; every result from RetrievalClient.search carries "merged")

The references and citations fields together form the directed citation graph. The two mock APIs return overlapping but not identical fields, and id is the key that identifies the same paper across them.

Check: The Paper shape0/2
1. In the Paper record, what does the citations field hold?Define
2. Beyond id, title, and abstract, which fields does the Paper record carry?Define

Architecture

Mermaid diagram

The retrieval client owns both passes and the merge. It builds the BM25 index and the citation graph directly from the corpus the two mock clients wrap. You hand it a query and get back a ranked list. Each entry carries per-paper score fields (bm25_score, graph_distance, recency_score, final_score) that explain where it landed.

Check: Architecture0/2
1. A RetrievalClient is constructed once and then asked 1,000 different queries. How often are the BM25 index and the citation graph built?Explain
2. Real arXiv and Semantic Scholar endpoints become available. Which design keeps the runner and evaluator stages unchanged?Design

BM25 from scratch

BM25 is a lexical ranking function: it scores a document against a query by summing, over the query terms, how rare each term is across the corpus and how often it appears in that document, adjusted for document length. The implementation here is standard Okapi BM25 with default parameters k1=1.5 and b=0.75.

The index is three structures: _term_freq (term -> {doc_id: term_count}), _doc_freq (a Counter of how many documents contain each term), and _doc_lens (token count per document). Each document is its title and abstract joined by a space, so document length is the token count of that text, and the average document length is computed once, when the index is built. Scoring a query sums idf * tf_norm over the query terms, where tf_norm is the standard length-normalised BM25 term frequency.

The tokeniser lowercases, then splits on non-alphanumeric characters. It does no stemming. A production system would swap in a small stemmer, and the interface would stay the same.

text
idf(t)      = log((N - df + 0.5) / (df + 0.5) + 1.0)
tf_norm(t)  = (f * (k1 + 1)) / (f + k1 * (1 - b + b * dl / avgdl))
score(d, q) = sum over t in q of idf(t) * tf_norm(t)
Check: BM25 from scratch0/8
1. What is BM25?Define
2. In BM25, what does idf(t) measure for a query term t?Define
3. What does the tokeniser do to text before BM25 sees it?Define
4. In tf_norm = f * (k1 + 1) / (f + k1 * (1 - b + b * dl / avgdl)), what does setting b to 0 do?Explain
5. What does idf evaluate to for a term that appears in every document?Apply
6. Using idf(t) = log((N - df + 0.5) / (df + 0.5) + 1.0) with N = 100 documents, what is idf for a term found in 20 of them?Apply
7. A user searches "pruned heads" and gets zero results, though the corpus has papers about head pruning. What is the most likely cause?Analyze
8. You want "pruned" to match papers that say "pruning". Which change fits the lesson's design with the least disruption?Design

Citation graph traversal

The citation graph is a directed graph with papers as nodes and citations as edges. It is built once from the corpus. Forward edges go from a paper to its references, and backward edges go from a paper to its citations. Traversal is a breadth-first search seeded by the top BM25 hits and capped at two hops.

Two hops is a deliberate ceiling. One hop is too shallow, since the agent often wants the immediate ancestor or descendant. Three hops blows up the result size on a connected graph and tends to drift off topic. The hop limit is exposed as a config knob so a downstream loop can tighten it. RetrievalConfig clamps any max_hops above two back to two, so the knob only goes down.

Check: Citation graph traversal0/6
1. What is the citation graph in this client?Define
2. What is the seed set that starts the citation graph traversal?Define
3. Why is the citation graph traversal capped at two hops?Explain
4. A caller builds RetrievalConfig(max_hops=4) hoping for broader recall. How many hops does the traversal use?Apply
5. A paper shares no query term and is not among the lexical top-k hits, but it cites one of those hits. What do its bm25_score and graph_distance show?Apply
6. A query shares no term with any paper. Why is the result empty even though the citation graph has 100 papers and many edges?Analyze

Dedup and ranking

Deduplication here means collapsing the lexical and graph results into one entry per paper. The two passes return overlapping sets, so the merge keys on paper id. Each paper's final score is a weighted blend.

text
final_score = w_bm25 * bm25_score_norm
            + w_graph * graph_score
            + w_recency * recency_score

bm25_score_norm is the BM25 score divided by the maximum BM25 score among the lexical top-k hits, which keeps the field between zero and one. graph_score is 1.0 at distance zero, which is the seed set of lexical top-k hits (top_k_lexical, default 10, and 5 in the demo), then 0.6 for one hop, 0.3 for two hops, and zero otherwise. Those constants are hardcoded in _graph_score. recency_score is a linear ramp from zero at the corpus minimum year to one at the maximum.

The default weights are 0.5, 0.3, and 0.2. Only the three weights live in config (w_bm25, w_graph, w_recency): a stale topic might tune recency down, while a fast-moving topic raises it.

Check: Dedup and ranking0/11
1. Which field does the merge step key on to collapse lexical and graph hits into one entry per paper?Define
2. Under the default schedule, what does graph_score return for a paper two hops from the seed set?Define
3. How is recency_score computed for a paper in the result set?Define
4. How is a paper's final_score formed?Define
5. The lexical top-k and the graph traversal both return papers. Why must the merge collapse them by id?Explain
6. bm25_score_norm divides each paper's BM25 score by which maximum?Explain
7. With the default weights of 0.5, 0.3 and 0.2, and every component scaled to between zero and one, which component can move final_score most and which least?Explain
8. With the default weights, a paper has bm25_score_norm 0.5, graph distance 2 and recency_score 0.5. What is its final_score?Apply
9. In the demo run, p008 sits at graph distance 1, has the same bm25_score as the top seeds (so bm25_score_norm is 1.0), and has recency_score 1.0 with the default weights. What final_score does it get?Apply
10. A developer drops the division by the maximum lexical BM25 and feeds raw scores (the demo's top is about 8.49) into the default blend. What is the likely effect?Analyze
11. You search a fast-moving topic where the newest work matters most, and you cannot edit the scoring code. Which change fits the lesson's design?Design

Mock corpus

The corpus is one hundred papers, generated by build_corpus(). Each paper belongs to one of five topics (attention sparsity, retrieval augmentation, low rank adapters, dataset distillation, and evaluation harnesses), twenty papers per topic. Titles and abstracts are templated from the topic label (a title looks like "Attention Sparsity Study 1"), not hand-written. Every paper from the third onward in a topic cites that topic's first two papers, and each topic has exactly one cross-topic edge: its sixth paper also cites the first paper of the next topic.

The two mock API clients (ArxivMockClient, SemanticScholarMockClient) read from the same corpus but expose different fields. Arxiv returns title, abstract, year, and authors. Semantic Scholar adds references and citations. RetrievalClient.search does not call either mock client. It works from the corpus directly, and every paper it returns has source="merged". Reconciling fields where the two clients disagree is deferred to a follow-up lesson.

Check: Mock corpus0/5
1. Why does the lesson wrap two mock external APIs behind one client?Explain
2. How do the two mock API clients differ in what they return for the same corpus?Explain
3. Corpus ids run p001 to p100 in topic order with twenty papers per topic, and a topic's sixth paper also cites the first paper of the next topic. Seeding the walk at p021, which paper from topic one is exactly one hop away?Apply
4. What does ArxivMockClient.search("retrieval augmentation") return for each hit's source and references?Apply
5. A developer filters search results with source == "s2" to find papers that carry reference data, and gets nothing. Why?Analyze

What lessons 52 and 53 read

Two later stages consume this output. The runner in lesson fifty-two reads paper.id, paper.title, and the top three sentences of the abstract as context for the experiment. The evaluator in lesson fifty-three reads paper.year and paper.references to attribute a baseline to a specific paper.

The retrieval client returns a RetrievalResult with the ranked list and the per-query metrics: hit count, average score, top score, and total wall time. The runner logs these, so a downstream observability pass can plot quality over time.

Check: What lessons 52 and 53 read0/2
1. Besides the ranked list, which per-query metrics does the RetrievalResult carry for the runner to log?Define
2. Why does the retrieval output keep each paper's year and references intact for the next stage?Explain
Author's Note

My bet is that the part of this client worth protecting is the score fields on each result. I'd keep bm25_score, graph_distance, recency_score, and final_score visible to the caller, because a ranking you can take apart is one you can argue with.

I'd also treat the default weights of 0.5, 0.3, and 0.2 as starting guesses. They are config for a reason, and I'd retune them against the topic you are searching before trusting the order they produce.

On the hop limit, I would start at two and tighten it if results drift off topic. The config knob exists for that direction.

How to read the code

code/main.py defines Paper, ArxivMockClient, SemanticScholarMockClient, BM25Index, CitationGraph, RetrievalClient, and a deterministic demo. RetrievalClient takes the two mock clients in its constructor, but the search path uses only the corpus. The mock clients and the corpus live in the same file to keep it portable. The BM25 implementation is one class of about sixty lines, and the graph traversal is one method.

code/tests/test_retrieval.py covers the lexical path, the graph path, the merge, the dedup, and the empty query.

Check: How to read the code0/2
1. Paper a references b and is cited by c. What does CitationGraph.expand(["a"], max_hops=0) return?Apply
2. Paper a references b and is cited by c. What does CitationGraph.expand(["a"], max_hops=1) return?Apply

Where this slots in

Lesson fifty produces a hypothesis. Lesson fifty-one searches the literature to see whether that hypothesis is already settled. Lesson fifty-two runs the experiment if it is not, and lesson fifty-three reads both the retrieval result and the experiment metrics to write the verdict. The retrieval client is the cheapest of the four stages and runs first in the orchestrator.

Warm-up0/3
1. A keyword search engine ranks documents by the query words they contain. Which relevant document is it most likely to miss?Warm-up
2. In a directed graph, what does a breadth-first search limited to two hops from a start node visit?Warm-up
3. Paper A's bibliography lists paper B. Which statement is correct?Warm-up

Code