ADR-0009: LLM enrichment via local Ollama (Phase 7 / B.8, tag-extraction rung)¶
Date: 2026-06-19 Status: Accepted
Context¶
Track B rung 8 (plan §2.8 / §5) is LLM enrichment in three parts: (a) extract structured tags per course → richer features, (b) a zero-shot LLM reranker, (c) a "why this fits" explanation for the UI. This ADR covers the provider + caching + first rung (tag extraction); (b) and (c) are deferred. Decisions that were load-bearing:
- Provider. The open decision (HANDOFF) was Anthropic vs OpenAI. Both are cloud APIs needing a key — in tension with the repo's hard constraint that it run end-to-end with no API key (CLAUDE.md rule 5, plan §1).
- When the LLM runs. ~11k courses × seconds/call is hours. If
fitgenerated on demand, every eval run risks an hours-long, nondeterministic stall. - How tags become a ranking. Extracted tags are free-form strings; they need to become a comparable feature space without simply re-deriving raw text.
- Graceful degradation. What does "no key" mean when there is no key — when the dependency is a local daemon that may or may not be running?
- Dependency budget. The plan keeps heavy/optional deps behind extras.
Decision¶
- Local Ollama, not a cloud API. The model runs on-device via Ollama's HTTP
API (
localhost:11434) — no key, no per-call cost, no network beyond localhost. This strengthens the local-only guarantee instead of bending it: the graceful-skip trigger becomes "Ollama unreachable / nothing enriched" rather than "no key." Default model qwen3:8b (verified: clean structured JSON, ~5 s/course); larger models (qwen3:32b,gemma4:31b) are a one-flag swap. Chosen because Sandeep runs Ollama locally and asked to use it. - Structured output via JSON schema. Ollama's
formatfield carries_TAG_SCHEMA, so the model returns a validatedCourseTagsobject (topics/skills/level/prereqs_mentioned), not free text to regex. Determinism is pinned (temperature=0,seed=RANDOM_SEED,think=False). - Enrichment is a separate, explicit, resumable pass.
fitnever calls the LLM — it only reads the tag cache. The slow generation lives inenrich_courses/scripts/enrich_catalog.py, which skips already-cached ids (interrupt-and-resume) and by default enriches just the eval-relevant subset (cross-listing seeds + twins + judged-query gold ≈ 1.3k courses, minutes),--allfor the full catalog. So evaluation stays fast and deterministic and the expensive pass is a deliberate one-time cost. - TF-IDF cosine over tag profiles, with raw-text fallback. Each course's
tags flatten to a profile string (topics+skills+prereqs;
levelexcluded — the metadata rung already owns it); profiles are TF-IDF-vectorized and compared by cosine, exactly like the lexical rung but over the LLM's distilled vocabulary. A course with no cached tags falls back to its raw text, so the rung never crashes on a cold course — it just loses distillation for it. - Skip only when it would be a useless duplicate. If no course is enriched
and Ollama is unreachable,
fitraisesLLMUnavailable(harness skips + flags) rather than silently shipping a raw-text TF-IDF clone. With a warm cache it runs offline; with Ollama up but a cold cache it falls back to text and logs a warning. - Zero new dependencies. The client is stdlib
urllib— noopenai/ollama/requests. Cache key issha1(model + normalized_text)(the rule's key), stored atartifacts/llmcache/<model>/tags.json.
Alternatives considered¶
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Anthropic / OpenAI cloud API | Strongest models; trivially parallel | Needs a key + network + spend; the repo must run keyless | Ollama is keyless, local, free, and Sandeep asked for it |
fit generates tags on demand |
One step; always fresh | Hours-long, nondeterministic eval runs; re-generates every run | Cache-read fit + explicit enrich pass keeps eval fast/deterministic |
| Multi-hot over exact tag strings | Interpretable indicators | Free-form tags rarely match verbatim across courses | TF-IDF over profile tokens matches at the token level, more forgiving |
| Embed tag profiles with SBERT | Best cross-course matching | Pulls the semantic extra into the LLM rung; couples two phases |
TF-IDF keeps the rung self-contained; a clean delta vs the tfidf baseline |
ollama / openai python SDK |
Convenience helpers | A dependency for a couple of HTTP POSTs | stdlib urllib is enough; zero new deps |
Include level in the profile |
One more signal | Coarse 4-way band, low discriminative power, already a metadata facet | Keep the profile to conceptual content; avoid double-counting |
Consequences¶
Positive: A keyless, local, zero-cost, zero-new-dependency LLM rung that keeps
the repo's offline guarantee intact and even strengthens it. Enrichment is
cached, resumable, and decoupled from eval, so a leaderboard run is still fast and
deterministic. The same machinery unblocks the deferred pieces — the nomic-embed-text
model already pulled locally can later back an Ollama-embedding rung, and the
zero-shot reranker + "why this fits" reuse this client and cache.
Negative / honest finding — full enrichment overturns the provisional win; the
rung is not competitive. The subset-enriched run (12.5%) had llm_tags(qwen3_8b)
beating every lexical baseline on both lenses (cross-list 0.960, free-text
0.585). We then ran scripts/enrich_catalog.py --all (full catalog, ~5 h, cached)
to enrich the distractors too — exactly to test that number — and the verdict
flips:
| Lens | partial (12.5%) | full (100%) | tfidf baseline |
|---|---|---|---|
| cross-listing NDCG@10 | 0.960 | 0.957 (tied with lexical, mid-pack) | 0.955 |
| free-text NDCG@10 | 0.585 | 0.404 (below tfidf) | 0.461 |
So the apparent lift was almost entirely the target/distractor
vocabulary-separation artifact: with only the eval targets in tag space they were
trivially separable from raw-text distractors; once everything is tag space, the
cross-listing edge vanishes (twins share near-identical tags just as they share
near-identical text) and the free-text number drops below plain TF-IDF. The
mechanism is information loss: compressing a 50–200-term description into ~6–12
abstract tags discards discriminative detail that TF-IDF exploits, and the tag
vocabulary saturates at 11k courses — the loss outweighs the synonym-normalization
the LLM adds. Extraction quality was never the issue (spot checks are clean); the
architecture — lossy distillation then lexical matching — is. The boards now
carry an "LLM enrichment (full)" note (the number is comparable, no caveat). The
takeaway: the LLM's value lives in operations over the full candidate text (the
deferred zero-shot reranker and "why this fits"), not in tag distillation — and
running --all before believing the win was the whole point.
Neutral: qwen3:8b and the profile composition (topics+skills+prereqs, TF-IDF) are chosen defaults, not tuned; model size and profile design are obvious future sweeps. Tag quality is not separately validated against human labels here — the LLM-as-judge piece (plan §3) is the future mechanism for that.
Implementation notes¶
src/courserec/recommenders/llm.py: OllamaClient (stdlib urllib, available +
extract_tags), _TagCache (sha1(model+text) → tags.json), CourseTags,
enrich_courses, and LLMTagRecommender (cache-read fit, TF-IDF profiles,
raw-text fallback). Driver: scripts/enrich_catalog.py (eval-relevant subset by
default, --all / --model). Wired into scripts/run_eval.py with
LLMUnavailable joining the graceful-skip except. Config: OLLAMA_HOST,
DEFAULT_LLM_MODEL in src/courserec/config.py. Tests: tests/test_llm.py
(FakeClient, no daemon). Builds on the lexical rung
(ADR-0002); complements the metadata rung's clean
ablation discipline (ADR-0008).