ADR-0005: Retrieve → cross-encoder rerank → MMR diversity (Phase 4)¶
Date: 2026-06-06 Status: Accepted
Context¶
Phase 4 adds the two-stage rung (recommenders/rerank.py): a fast bi-encoder
retrieves candidates, a cross-encoder reranks them, and Maximal Marginal
Relevance (MMR) re-orders for diversity (plan §2.4, §5). Several decisions were
load-bearing.
- Where the cross-encoder runs. A cross-encoder scores
(query, candidate)pairs jointly — accurate butO(n)forward passes per query, far too slow over all ~11k courses. It can only run over a retrieved shortlist. - Diversity space. The phase-4 acceptance test is "intra-list diversity moves with λ" (plan §5). Diversity is measured in a technique-agnostic TF-IDF reference space (ADR-0002); MMR must demonstrably move that external metric, not just an in-model one.
- Score monotonicity. The interface contract requires results sorted by descending score. MMR selects greedily; the returned scores must still be non-increasing.
- Reuse + graceful degradation. Retrieval should reuse the existing semantic
rung (its cache + FAISS artifact), and the cross-encoder — another
sentence-transformersmodel — must skip gracefully with nosemanticextra.
Decision¶
- Wrap a fitted base retriever; rerank at query time.
RerankRecommenderholds any_EmbeddingRecommender(default MiniLMSbertRecommender), calls itsrecommend_*for the topretrieve_n(default 50), then reranks. No new precomputed artifact — the base persists/reloads its embeddings + FAISS index exactly as before; cross-encoder scoring is inherently query-time. - Cross-encoder =
cross-encoder/ms-marco-MiniLM-L-6-v2. A small, standard MS-MARCO reranker from the samesemanticextra; loaded lazily, seed-pinned (torch.manual_seed(RANDOM_SEED)) for determinism. - MMR with one knob
mmr_lambda∈ [0, 1].MMR(c) = λ·rel(c) − (1−λ)·max_{s∈S} sim(c,s).relis the min-max-normalized cross-encoder score (so it is comparable to cosine regardless of the encoder's raw scale);simis cosine in the bi-encoder space (vectors already L2-normalized → a dot product).λ=1is pure cross-encoder relevance; lowering λ raises diversity. - Greedy MMR returns its own marginal value as the score. The greedy MMR
value is provably non-increasing across selections (each pick adds to
S, which can only raise themax simpenalty for the rest), so the emitted scores are sorted descending — the contract holds without a re-sort that would discard information. - Skip gracefully.
fitcalls_ensure_availablefirst; with no extra it raisesEmbeddingsUnavailable, whichrun_eval.pyalready catches to skip + flag the row.
Alternatives considered¶
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Rerank the whole catalog (no retrieval stage) | No recall loss from retrieval | O(11k) cross-encoder passes per query — minutes per query |
Two-stage retrieve-then-rerank is the entire point |
| MMR similarity in the reference TF-IDF space | Same space the metric uses | Would game the acceptance metric directly; not the model's own notion of similarity | Use the bi-encoder space; let the external metric move on its own |
| Assign rank-based descending scores | Trivially monotonic | Discards the MMR value (relevance/diversity signal) | Greedy MMR is already monotonic; keep the real values |
| Precompute a rerank artifact | Warm-run speed | Cross-encoder output depends on the (arbitrary) query — nothing to precompute | Reuse the base's artifact; rerank stays query-time |
| Bake λ into one fixed value | Fewer rows | Hides the diversity trade-off the phase is meant to show | Sweep λ ∈ {1.0, 0.5, 0.3} on the leaderboard |
Consequences¶
Positive: The MMR knob works as specified — λ = 1.0 → 0.5 → 0.3 raises intra-list diversity monotonically on both lenses (cross-listing 0.734 → 0.823 → 0.894; free-text 0.745 → 0.822 → 0.870) while NDCG@10 falls, the phase-4 acceptance criterion. The technique drops in behind one interface and reuses the semantic rung's cache wholesale.
Negative (honest finding): the cross-encoder reranker does not beat the bi-encoder on this catalog/task. At λ=1.0 (pure rerank) it trails plain SBERT MiniLM on both lenses — free-text NDCG@10 0.610 vs 0.682, cross-listing 0.960 vs 0.971. Two reasons: (a) the MS-MARCO cross-encoder is trained on web-search query→passage relevance, a domain mismatch with course-catalog text and especially with twin-matching; (b) bi-encoder retrieval already places the cross-listed twin at rank 1, so reranking within the shortlist can only demote it. Latency is ~70–80 ms/query versus sub-ms for the bi-encoder. The value delivered here is the diversity control, not a relevance gain — documented, not hidden (the repo treats limitations as first-class).
Neutral: A domain-tuned or fine-tuned cross-encoder might reverse the
relevance result; out of scope for this phase. MMR sim uses the base's private
_embeddings/_row — acceptable coupling within the package, but it ties the
reranker to embedding-style bases.
Implementation notes¶
src/courserec/recommenders/rerank.py: RerankRecommender (_ensure_available,
lazy _load_model, _cross_scores, _candidate_vectors, _mmr, _rerank).
Sweep entries (λ ∈ {1.0, 0.5, 0.3}) in scripts/run_eval.py. Contract +
acceptance (λ↓ ⇒ diversity↑) tests in tests/test_rerank.py. Builds on the
semantic rung of ADR-0004 and the diversity space of
ADR-0002.