Trade-offs¶
Technique × {quality, speed, interpretability, cost, cold-start, complexity}, with a one-line "when to prefer it". Filled in as each phase lands; for measured numbers see RESULTS.md and the leaderboard.
| Technique | Quality (cross-list) | Speed | Interpretability | Cost | Cold-start | Code complexity | Prefer when |
|---|---|---|---|---|---|---|---|
| TF-IDF + cosine | High on near-duplicate text; blind to synonymy | Fit ~0.3 s; query ~3 ms (uni), ~32 ms (bi) | High — score is shared-term weight | None (local, no API) | Fine — needs only text | Low | A fast, explainable baseline; exact-vocabulary overlap |
| BM25 (Okapi) | ≈ TF-IDF here (CIs overlap); better TF saturation + length norm | Fit ~0.3 s; query ~3 ms | High — additive idf·tf terms | None (local) | Fine — needs only text | Low–medium (sparse weight matrix) | Same as TF-IDF, with length/saturation control; the stronger lexical default |
| LSA (TruncatedSVD) | ≈ lexical on cross-list (CIs overlap); some synonymy robustness from shared latent axes | Fit ~seconds; query ~0.3 ms (dense k=200) | Medium — signed topic–term loadings, readable but ± | None (local) | Fine — needs only text | Medium (SVD + topic-space cosine) | A compact dense reducer; a denoised cosine that still answers in sub-ms |
| NMF | Top point estimate (0.960) but CI overlaps all; additive parts | Fit ~seconds; query ~0.2 ms (dense k=50) | High — non-negative additive topics read as clean themes | None (local) | Fine — needs only text | Medium | Want interpretable topics + diverse lists; the most readable topic model |
| LDA | Lowest topic model here; weakest same-subject coherence | Fit slowest (variational, raw counts); query ~0.2 ms | High — probabilistic topic mixtures | None (local) | Fine — needs only text | Medium–high | Want a principled generative topic model / per-doc topic distributions |
SBERT MiniLM (all-MiniLM-L6-v2) |
Top on both lenses (xlist 0.971; text 0.682) — text lead over best lexical now decisive (CI [0.615,0.746] vs [0.412,0.585]); perfect xlist Recall@10 | Fit ~9 s (encode 11k on MPS); query ~0.3 ms (exact FAISS) | Low — opaque 384-d vector, no term table | None at query (local); torch dep | Fine — pretrained, needs only text | Medium–high (encoder + cache + FAISS) | Free-text / synonymy where wording differs from titles; the default semantic model |
SBERT MPNet (all-mpnet-base-v2) |
≈ MiniLM (xlist 0.971, text 0.635); larger 768-d model didn't beat the small one here | Fit ~170 s (encode 11k); query ~0.5 ms | Low — opaque 768-d vector | None at query (local); torch dep | Fine — pretrained | Medium–high | When a bigger encoder is warranted — not demonstrably here; MiniLM is the better speed/quality trade |
API embeddings (text-embedding-3-small) |
Unmeasured — skipped (no key; runs local-only) | Network-bound; cost-logged | Low — opaque vector | $ per token (logged) | Fine — pretrained | Medium | A managed encoder when a key exists and local compute is constrained |
Rerank (SBERT retrieve → ms-marco-MiniLM cross-encoder → MMR) |
Did not beat the bi-encoder here (xlist 0.960, text 0.610 at λ=1.0 vs MiniLM 0.971/0.682) — domain-mismatched reranker, twins already rank first | Query ~70–80 ms (50 cross-encoder passes) — slowest by far | Low — cross-encoder logit + MMR trade-off score | None at query (local); torch dep | Fine — reuses base retriever | High (two stages + MMR) | You need a diversity knob (MMR λ moves intra-list diversity), or a domain-tuned cross-encoder is available; not for raw relevance on this catalog |
| Graph (PPR) (RWR over cross-listing + subject/dept aux nodes) | Far below text on held-out twins (NDCG@10 0.131 vs SBERT 0.913) — recovers only ~23%; isolated twin pairs are unrecoverable once their edge is withheld | Fit < 0.05 s; query ~0.4 ms (meta=off) / ~2.6 ms (meta=on) | Medium — proximity is a walk over an inspectable graph | None (local); zero new deps (pure scipy.sparse) |
Poor — needs cross-listing edges; a course with none gets nothing | Medium (graph build + power-iteration RWR) | Edges encode signal absent from text (prereqs, sequence, co-enrollment) — not this catalog, where twin text is near-identical |
| Metadata fusion (one-hot subject+dept+level+units ⊕ TF-IDF, weight λ) | Loses to plain TF-IDF, monotone in λ (xlist 0.948→0.936→0.909 vs tfidf 0.955) — 99.7% of twins span subjects, so the facet block pushes them apart; text-lens identical to TF-IDF (a query has no facets) | Fit ~0.4 s; query ~ms (sparse mat-vec) | High — facet indicators + TF-IDF terms, both readable | None (local) | Fine — text + facets; sparse-text rows ride on metadata | Medium (per-block L2-norm + weighted hstack) | Browse/filter coherence ("more grad ME courses like this") — not the cross-listing target, which it actively hurts |
| LLM tags (Ollama qwen3:8b → topics/skills/prereqs, TF-IDF cosine) | Not competitive at full (100%) enrichment: ties lexical on xlist (0.957) and below tfidf on text (0.404 vs 0.461) — distilling a description to ~6–12 tags loses more detail than synonym-normalization adds; the subset-run "win" (0.960/0.585) was a vocabulary-separation artifact | Enrich ~5 s/course (one-time, cached, resumable; full catalog ~5 h); fit reads cache (fast); query ~ms + 1 Ollama call for text | High — tags are human-readable topics/skills | None (local Ollama, no API key, zero new deps via stdlib urllib) |
Poor for un-enriched courses (raw-text fallback); needs an enrichment pass | Medium–high (HTTP client + tag cache + enrich pass) | Not as a ranker here — the cached tags' value is for the (future) reranker + "why this fits" UI, which use the full text, not lossy distillation |
| LLM reranker (SBERT retrieve top-20 → qwen3:8b reorders over full text) | Measured — does not beat the base (xlist 0.965 vs MiniLM 0.971, text 0.656 vs 0.682; both Δ negative and inside CIs, recall@10 drops). Reorders full candidate text (no distillation, unlike LLM tags) but its ceiling is the base's recall@20 — and SBERT's top-20 is already near-ceiling (twins rank first), so there's nothing to fix and only room to hurt | Query = base retrieval + 1 Ollama call (~4 s) on a cold cache; cached by sha1(model+query+candidate-ids) → warm reruns offline ~ms |
Medium — yields a reordered list, no per-term reason (the "why this fits" explainer is separate) | None (local Ollama, no API key, zero new deps) | Fine — reuses the base retriever; needs Ollama up only on a cold cache (else falls back to base order) | High (retrieve + LLM reorder + permutation reconcile + rerank cache) | A weaker base (TF-IDF, real reorder headroom) or a larger model — not on top of a strong retriever at a near-ceiling task, where ~4 s/query buys nothing |
Notes by dimension¶
- Quality. Two lenses now. On the cross-listing lens everything is
statistically tied (overlapping bootstrap CIs) — SBERT has the top point
estimate and perfect Recall@10, but the lens is near-trivial for any method
that nails near-duplicate twin text, so it tests correctness, not quality. The
judged free-text lens (now 44 hand-labeled paraphrase-extreme queries,
see the project plan) measures
recommend_by_text: it discriminates (NDCG@10 from ~0.68 down to ~0.06), cleanly separating lexical/semantic from the topic models, which collapse on short queries at k=50. On this larger, harder set SBERT MiniLM now beats the best TF-IDF config decisively (CI [0.615,0.746] vs [0.412,0.585], non-overlapping) — the synonymy advantage the lens was built to detect is real, not within-noise. Growing and hardening the query set was the lever that proved it. - Speed. Bigram TF-IDF is ~10× slower per query than unigram/BM25 for no NDCG gain — the vocabulary explosion is pure cost here.
- Interpretability. Both are bag-of-words: a recommendation is explainable as the terms two courses share. BM25 weights are additive idf·tf contributions.
- Cost / cold-start. Fully local, no API key, no training data beyond the
catalog text; handle any course with a title (sparse-text rows fall back to
title). Fitted vectors persist to
artifacts/<name>/and reload on next run. - Complexity. TF-IDF is one scikit-learn
fit_transform. BM25 adds a custom sparse doc-term weight matrix (bm25_weight_matrix) but stays a single mat-vec at query time. Topic models add a factorization step (SVD / NMF / LDA) and store a dense, L2-normalized doc–topic matrix so the query is again one mat-vec — but dense overktopics, which is faster than the lexical sparse mat-vec here (~0.2 ms vs ~3 ms). -
Diversity. Topic models return more varied top-k lists (intra-list diversity ~0.71–0.83) than the lexical methods (~0.74), and NMF/LDA in particular pull in fewer same-subject neighbours (same-subject@10 ~0.08–0.11 vs ~0.19) — they generalise past exact vocabulary into shared themes. Whether that is better recommendation can't be judged on the cross-listing lens.
-
Semantic-specific. Embeddings trade interpretability and a heavy
torchdependency for synonymy robustness and the fastest queries (exact FAISS over normalized vectors ≈ cosine, ~0.3 ms). Cold fit is the cost: encoding 11k courses is ~9 s (MiniLM) to ~170 s (MPNet) versus ~ms for a vectorizer — paid once, then a two-layer cache (per-textsha1store + fitted artifact) makes warm runs instant. The bigger MPNet did not beat MiniLM on either lens here, so MiniLM is the better trade. The API backend stays skipped+flagged with no key, honoring the local-only guarantee. -
Rerank-specific. The two-stage technique buys a diversity knob, not relevance: MMR λ (1.0→0.5→0.3) raises intra-list diversity monotonically on both lenses (xlist 0.73→0.82→0.89) while NDCG falls. The
ms-marco-MiniLMcross-encoder is domain-mismatched to course text and reranks only the top 50 — where the bi-encoder already places the twin first — so pure rerank (λ=1.0) trails plain SBERT at ~70–80 ms/query. Value is diversity control + a hook for a domain-tuned cross-encoder; see ADR-0005. -
Graph-specific. The graph is the one technique allowed to read
Cross-Listed Course(s), so it is scored only on a held-out edge split (its own leaderboard, leaderboard_heldout.md; numbers there are a harder task than the full-truth file and not comparable across files). The honest result: personalized-PageRank proximity recovers only ~23% of withheld twins (NDCG@10 0.131) while text methods, which never needed the edge, score ~0.89–0.91 — twin text is near-identical, so structure adds nothing text didn't already have. Metadata aux nodes (meta=on) raise coverage/diversity (same-subject@10 0.00→0.82, diversity 0.01→0.87) but not twin recovery, since twins span subjects. A graph would pay off only on edges text can't see (prereqs, curricular sequence); see ADR-0006. -
Clustering-specific. Not a ranker and not in the table above — a diagnostic over the SBERT vectors (
cluster.py), so it has no leaderboard row. Cost/complexity: zero new required deps (KMeans / Ward / HDBSCAN all from scikit-learn;matplotlib+umap-learnare the optionalvizextra, and the map falls back from UMAP to t-SNE). Reuses the cached embeddings (no re-encode). Finding: forced k=100 partitions score low silhouette (~0.08–0.12) and HDBSCAN labels ~90% of courses noise — the space is a smooth manifold, not tidy clusters — while subject purity ~0.32 (no metadata) shows coherent-but-blended neighborhoods. This frames the diversity/coverage story (no hard cluster walls) rather than adding a ranking; see ADR-0007. -
Metadata-fusion-specific. The fused one-hot subject+dept+level+units block (score
λ²·cos_text + (1−λ)²·cos_meta, λ=1 ≡ the TF-IDF baseline) is a clean ablation — and the ablation says metadata hurts the cross-listing target: NDCG@10 drops monotonically as λ falls because 99.7% of cross-listing edges connect different subjects, so the facet block separates twins. On free text it is identical to TF-IDF (a query carries no facets). Its genuine value is browse coherence (a weak proxy the harness warns against optimizing) plus sparse-text robustness; see ADR-0008. -
LLM-specific. A local LLM (Ollama qwen3:8b, no API key, zero new deps — stdlib
urllib) distills each course into tags; the technique ranks by TF-IDF cosine over the tag profile with raw-text fallback.fitonly reads a cache that the resumablescripts/enrich_catalog.pypass fills, so evaluation stays fast and the repo's offline guarantee holds (skips+flags when Ollama is down and nothing is cached). The subset run (12.5%) looked like a win on both lenses (xlist 0.960, text 0.585); full-catalog enrichment overturned it — at 100% coverage llm_tags ties lexical on cross-listing (0.957) and falls below plain TF-IDF on free text (0.404). The subset lift was a target/distractor vocabulary-separation artifact, and distilling text to a handful of tags loses more discriminative signal than the LLM's normalization recovers. The honest verdict: not competitive as a ranker — the value of the 100% tag cache is for the future reranker + "why this fits" UI, which use the full candidate text. See ADR-0009. -
"Why this fits" explainer (closing out the LLM enrichment track). The two LLM ranking techniques both lost to SBERT (tags ADR-0009, reranker ADR-0010), and that is exactly the signal for where the LLM does pay off: not producing a ranking, but justifying one SBERT already produced.
RecommendationExplainertakes a (query, recommended-candidate) pair and returns one short sentence naming the shared topic/skill, for the interactive Streamlit UI. It is not aRecommenderand is not on the leaderboard — a free-text justification has no ground-truth ordering to score, so it is assessed by inspection, not a metric. Same machinery as the other LLM techniques (deterministic, cached bysha1(model+query+candidate-id), zero new deps); the one difference is graceful degradation — because the line is optional, every unavailable path returnsNone(UI omits the line) instead of skipping a fit. Validated live (qwen3:8b) producing concrete on-topic one-liners. See ADR-0011.