ADR-0010: Zero-shot LLM reranker (Phase 7 / B.8b)¶
Date: 2026-06-19 Status: Accepted
Context¶
Track B rung 8 has three parts (plan §2.8 / §5): (a) tag extraction — shipped and settled as not competitive (ADR-0009); (b) a zero-shot LLM reranker; (c) a "why this fits" explanation. This ADR covers (b). ADR-0009's honest finding pointed straight here: the LLM's value lives in operations over the full candidate text, not in lossy tag distillation. A reranker is exactly that — keep a strong first-stage ranker, and spend the LLM only on reordering its top-N candidates against the query, reading each candidate's full text. It is the cross-encoder pattern from ADR-0005 with a zero-shot LLM in place of a trained cross-encoder. Load-bearing questions:
- What does the LLM see, and what does it return? Free text it must reorder, not regenerate — and the reply must be parseable and bounded.
- Determinism + cost. A leaderboard run must stay fast and reproducible; an uncached LLM call per query at eval time risks both.
- Graceful degradation. The repo runs offline with no key (plan §1). What happens when Ollama is down — skip, or fall back?
- Cache identity. The HANDOFF fixed the key as
sha1(model + query + candidate-ids); the candidate set is part of the query.
Decision¶
- Retrieve-then-reorder, base reused wholesale. A fitted base ranker
(default MiniLM
SbertRecommender, anyRecommenderinjectable) retrieves the topretrieve_n(default 20) candidates — seed already excluded by the base — and the LLM only reorders that small set. The catalog is never compressed (the tag rung's flaw); the LLM judges already-relevant candidates over their full text. - Numbered listing → integer permutation, structured + validated. Candidates
are presented as a numbered listing (
[1] <text>…, each text truncated tocandidate_chars, default 1000, to bound prompt size on long descriptions). Ollama'sformatfield carries_RERANK_SCHEMA({"ranking": [int, …]}), so the model returns validated integers, not prose to regex. Determinism pinned (temperature=0,seed=RANDOM_SEED,think=False). Indices, not ids, so a malformed reply is trivially range-checked — the model can't invent a plausible-looking wrong course id. - Reconcile to a full permutation, always. The model's indices are mapped
back to ids, then
_reconciledrops out-of-range/duplicate picks and appends any candidate the model omitted in base order. The output therefore ranks every candidate exactly once however the model (mis)behaved — a partial or garbled reply degrades toward the base order rather than dropping candidates. - Rank-based scores. The reranked list is scored
len - position(strictly descending), honoring the interface contract independent of the base's own score scale. - Cache by
sha1(model + normalized-query + candidate-ids). Stored atartifacts/llmcache/<model>/reranks.json(_RerankCache). The candidate ids (in retrieval order) are part of the key, so any change to what the base retrieves invalidates the entry rather than reusing a stale order. One deterministic call per (query, candidate-set), ever. - Fall back, don't fail — skip only when cold + offline. When Ollama is
unreachable the rung returns the base order for any uncached query (worst
case "no better than the base," never a crash).
fitraisesLLMUnavailable(harness skips + flags) only in the cold case — Ollama down and the rerank cache empty — where every query would reproduce the base and the leaderboard row would be a useless duplicate. With a warm cache it runs fully offline. - Zero new dependencies. Reuses the stdlib-
urllibOllamaClientfrom ADR-0009, adding onlyrank_candidates.
Alternatives considered¶
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Model returns ranked ids | Readable cache/reply | Model can emit plausible-but-wrong ids; hard to validate | Integers are range-checkable; reconcile is trivial |
| Free-text "list them best first" | No schema needed | Brittle parsing, nondeterministic format | format schema gives a validated permutation |
| Per-candidate 0–1 relevance score | Fine-grained; sums to a blend | N separate calls or a wide schema; scale drift across queries | One call returning an order is cheaper and sufficient for ranking |
| Skip the rung when Ollama is down (like the tag rung) | Consistent with ADR-0009 | A reranker can serve the base order offline; skipping discards a warm cache | Fall back to base; skip only cold + offline |
fit reranks the whole eval set eagerly |
Warms cache in one place | Couples fit to the query set; slow, query-dependent | Reranking is query-time; cache fills lazily |
| Drop omitted candidates from the model's reply | Honors the model literally | Returns < k, violates "rank every candidate" intent | Reconcile appends omissions in base order |
Consequences¶
Positive: The LLM is spent where ADR-0009 said its value is — over full
candidate text, not distilled tags. No catalog compression, so no
information-loss failure mode. Deterministic and cached, so a leaderboard run
stays fast and reproducible; offline runs reuse a warm cache and otherwise
degrade to the base order instead of failing. Reuses the existing client + cache
machinery (zero new deps) and the base's own artifact, and _reconcile makes the
rung robust to any malformed model reply.
Measured verdict (2026-06-19, qwen3:8b, warm-cache run_eval). The reranker
does not beat the SBERT MiniLM base on either ranking lens — it is a documented
negative result, joining the tag rung (ADR-0009):
| Lens | base NDCG@10 (CI) | reranker NDCG@10 (CI) | Δ | recall@10 | latency/query |
|---|---|---|---|---|---|
| Cross-listing (1072 seeds) | 0.9710 [0.9645, 0.9770] | 0.9649 [0.9574, 0.9718] | −0.006 | 1.000 → 0.9921 | 0.33 ms → 4428 ms |
| Judged free-text (44 q) | 0.6821 [0.6153, 0.7456] | 0.6559 [0.5861, 0.7289] | −0.026 | 0.7056 → 0.6671 | 8 ms → 3666 ms |
Both deltas are negative and well inside the bootstrap CIs (no significant
difference), and the reranker drops recall@10 on both lenses — it occasionally
reorders a true twin out of the top-10. NDCG@5 on the text lens is the one flat
bright spot (0.6420 → 0.6457, +0.004, also within noise). The cause is structural,
not a bug: the reranker can only reorder the base's top-retrieve_n, and SBERT's
top-20 is already at/near the recall ceiling (recall@20 = 1.000 cross-list,
0.8686 text — identical for base and reranker, confirming pure reorder), so there
is essentially nothing to fix and only room to hurt. The cost is ~13000× the base
latency on a cold cache. The mechanism is sound and robust (_reconcile, fallback,
determinism all verified live); it just has no headroom to exploit here. SBERT
MiniLM stays the top ranking-lens rung. The warm cache reruns fully offline and
the metric columns are byte-identical across runs (reproducibility confirmed).
Other honest caveats: (1) The verdict is qwen3:8b-specific; a larger model
(qwen3:32b) or a weaker base (TF-IDF, leaving real reorder headroom) could shift
it — both are obvious follow-up sweeps, not run here. (2)
candidate_chars=1000 truncates the longest descriptions (catalog max ~181 words
≈ within budget for most, but not all) — a deliberate prompt-size bound, not a
free choice. (3) retrieve_n=20, candidate_chars, and the prompt wording are
chosen defaults, not tuned — obvious future sweeps (none moved the needle enough to
try here given the negative headroom above). (4) Latency: one LLM call per distinct
(query, candidate-set) on a cold cache (~4 s each, measured); the eval sets are
bounded, so the one-time cost was minutes, and warm reruns are offline + instant.
Neutral: Default base is MiniLM SBERT (the top ranking-lens rung); a TF-IDF base is a one-arg swap to isolate the rerank's contribution from the retriever's.
Implementation notes¶
src/courserec/recommenders/llm.py: OllamaClient.rank_candidates (numbered
listing → integer permutation), _RerankCache
(sha1(model+query+candidate-ids) → reranks.json), and LLMRerankRecommender
(base retrieval, _reconcile to a full permutation, rank-based scores,
base-order fallback, cold+offline skip). Wired into scripts/run_eval.py
alongside LLMTagRecommender under the existing LLMUnavailable graceful-skip
except. Tests: tests/test_llm.py (FakeBase + FakeRerankClient, no daemon)
— reorder, seed-exclusion, sort/cap, cache reuse, cold-offline skip, warm-offline
cache, uncached-offline base fallback, malformed-output reconcile, by-text,
empty-query, unknown-seed, bad-config. Builds on ADR-0005
(retrieve→rerank) and ADR-0009 (Ollama client +
cache); the "why this fits" explanation (B.8c) remains deferred.