Structured LLM reasoning over collaborative filtering evidence. A plan-execute-reflect agent on Gemma-4/vLLM that produces ranked recommendations with verifiable traces, contrastive explanations, and hard constraint support.
Docs: Findings & evaluation results · Design notes · Testing · Operations/runbook
Three candidate engines share one interface (fit / recommend(matrix, user_idx, n)):
| kind | implementation | notes |
|---|---|---|
als |
implicit.ALS |
weighted matrix factorisation, the classic baseline |
user |
recagent.cf.UserBasedCF |
memory-based, implemented from scratch in numpy |
item |
recagent.cf.ItemBasedCF |
memory-based, implemented from scratch in numpy |
The default engine is user-based. Choose with --cf on train.
Both are classic neighbourhood methods over the explicit 1–5 rating matrix, implemented from scratch (no sklearn/surprise).
User-based (Pearson correlation) — ratings are mean-centred per user
(r_ui - μ_u); user similarity is cosine on the centred rows, which equals
Pearson correlation restricted to co-rated items:
sim(u, v) = Σ_i (r_ui - μ_u)(r_vi - μ_v) / (‖r_u - μ_u‖ · ‖r_v - μ_v‖)
r̂_ui = μ_u + Σ_v sim(u, v)·(r_vi - μ_v) / Σ_v |sim(u, v)|
Negatives are floored to 0 (only positively correlated users vote). A single
sparse row-normalised matmul scores every item; score_all() batches the same
math into a dense (n_users × n_items) matrix for eval.
Item-based (adjusted cosine) — columns are mean-centred per item, then
S = ĈᵀĈ on the column-normalised matrix:
sim(i, j) = Σ_u (r_ui - μ_i)(r_uj - μ_j) / (‖r_i - μ_i‖ · ‖r_j - μ_j‖)
r̂_ui = Σ_j∈rated(u) sim(i, j)·r_uj / Σ_j∈rated(u) |sim(i, j)|
Prediction uses only the user's own ratings as evidence, falling back to their mean when no similar item is in the profile.
recagent chat / RecClient.recommend run a plan-then-execute agent
(Gemma-4 via pydantic-ai) instead of in-loop tool calling:
build_plan— parseuser_id,k, and any hard constraints (e.g. genre).build_evidence— deterministicToolRegistrycalls: user profile, collaborative-filtering candidates, item metadata, preference history (from past feedback), current session context, and — for cold-start users or rare genres — popularity priors andsimilar_itemschains.- one structured-output LLM call emits a ranked
RankedItemslist with optional tags (comfort, discovery, rewatch, mood-light, mood-dark, context-late-night). - reflection loop (opt-in
reflect=True) — deterministic post-ranking checks (too few items, constraint violations, evidence coverage, genre diversity); a second targeted LLM call fixes issues when found. - MMR diversity re-ranking (
diversity=True,lambda_param=0.5) — Jaccard genre similarity prevents the top-k from being all-one-genre. _clean_itemsguardrails drop hallucinations, constraint violations and duplicates, capping the list atk.
Two memory layers feed the agent's reasoning:
-
UserMemory (
recagent/memory.py) — persistent JSON-backed preference buckets per user. Categories includeloved,disliked,comfort,discovery,mood:<name>,context:<name>,genre:<name>. Populated from explicit user feedback (save_preference) or bulk-imported fromfeedback.jsonlat startup. The summary appears in the evidence block so the agent reasons about past taste. -
SessionMemory (
recagent/session.py) — in-conversation context tracking. Records what was recommended, liked, and disliked within the current session. Prevents repetition and enables follow-up requests ("more like that"). Not persisted to disk — lives only for the session lifetime.
The enhanced SYSTEM_PROMPT teaches the agent to handle natural-language modifiers:
- mood requests — "something light", "late night watch" → the agent interprets intent and adjusts ranking.
- discovery requests — "surprise me", "something different" → items outside the user's dominant genres but still relevant.
- memory-aware — avoids items similar to disliked ones, leans toward genres in the loved bucket, varies from recent recommendations.
This sidesteps vLLM's flaky in-loop tool template (repeated identical calls, empty parts) while keeping the reasoning signal.
RecClient retries transient vLLM failures (HTTP 502/503/504, connection
errors) with exponential backoff and jitter. A built-in circuit breaker opens
after 5 consecutive failures, preventing cascading timeouts when the endpoint
is down.
python -m pip install -e .
python -m recagent.cli train --cf user # user-based (default)
python -m recagent.cli recommend 196 # engine + top-n
python -m recagent.cli explain 196 64 # why an item for a user
python -m recagent.cli train --cf als # switch engines
python -m recagent.cli eval --all-cf --sample 100 --agent
python -m recagent.cli serve --port 8000 # REST gateway (docs at /docs)from recagent.client import RecClient
client = RecClient()
# save user preferences
client.deps.save_preference(196, "loved", [64, 12], note="favourites")
# recommend — agent sees preference history + session context
resp = client.recommend(196, k=5)
print(resp.items[0].title, resp.items[0].tags)
# follow-up — session memory prevents repeats
resp2 = client.recommend(196, k=3)
print(client.session.session_summary())POST /save_preference {"user_id": 196, "category": "loved", "item_ids": [64]}
POST /get_preferences {"user_id": 196}
POST /get_preference_summary {"user_id": 196}
POST /ingest_feedback {"feedback_path": "artifacts/feedback.jsonl"}
POST /recommend {"user_id": 196, "k": 5, "filters": {"mood": "light"}}
LLM config lives in .env (ATHAR_AGENT__*, see .env.example); without it
RecClient degrades to pure CF.
from recagent_sdk import RecommendClient
with RecommendClient("http://127.0.0.1:8000") as client:
print(client.recommend(user_id=196, k=5, filters={"genre": "Sci-Fi"}))| engine | HR@1 | HR@3 | HR@5 | HR@10 |
|---|---|---|---|---|
| als | 0.04 | 0.09 | 0.12 | 0.24 |
| user | 0.03 | 0.11 | 0.12 | 0.16 |
| item | 0.00 | 0.00 | 0.00 | 0.01 |
| agent | 0.04 | 0.09 | 0.15 | — |
Honest read: ALS remains the strongest raw LOO predictor; the from-scratch user-based method is competitive at HR@3/5; item-based collapses because the held-out item is the only evidence for its neighbours. The agentic reranker matches ALS at HR@1/3 and edges ahead at HR@5 — and is the only path that can honour hard constraints (verified 5/5 genre compliance live).
Every recommendation comes with a verifiable "why". recagent/explain.py
derives the evidence deterministically from the trained artefacts — no LLM in
the loop — so the explanation is always grounded:
- genre affinity — the item's genres against the user's rating-weighted dominant genres;
- engine score and its boost over the user's mean rating;
- similar-taste — items the user already rated that are item-item similar to the recommendation;
- contrastive comparison — when
recommended_idsis provided, the explanation includes "why this over the next-best alternative?" (the user's highest-rated genre-sharing item that was NOT recommended); - popularity and quality fallbacks for cold-start users.
python -m recagent.cli explain 196 64 # CLI (LLM prose when enabled)
POST /explain {"user_id": 196, "item_id": 64} # REST
client.explain(user_id=196, item_id=64) # SDK
client.explain(user_id=196, item_id=64, recommended_ids={64, 12, 77}) # contrastive
The LLM restates the evidence as fluent prose in a single structured-output call, held to the facts by an explicit no-invention rule; an empty response falls back to the deterministic one-liner, so an explanation always exists. Live example (user 196, Shawshank Redemption):
You might enjoy The Shawshank Redemption (1994), as it is a Drama and you have previously liked Stand by Me (1986). — evidence: liked Secrets & Lies (5.0), English Patient (5.0), Stand by Me (5.0)
Three offline protocols, all reproducible from recagent (no external
recommender library), on ml-100k:
python -m recagent.cli eval --protocol all --baselines --bootstrap --sample 200
| engine | RMSE | MAE |
|---|---|---|
| item | 0.9874 | 0.7856 |
| mf (ours) | 0.9920 | 0.7614 |
| user | 1.0090 | 0.8029 |
| item-mean | 1.0276 | 0.8184 |
| user-mean | 1.0420 | 0.8351 |
| global-mean | 1.1256 | 0.9447 |
mf is a from-scratch unit-weight explicit ALS (recagent/mf.py), tuned to
factors=6 / iterations=15 / reg=1.0. Published default-parameter 5-fold numbers
on the same data (Surprise benchmark.py): k-NN 0.980, Centered k-NN 0.951,
k-NN Baseline 0.931, NMF 0.963, Slope One 0.946, SVD 0.934, SVD++ 0.919. Our
engines sit inside the memory-based family's published range; the 0.89–0.95
SVD-class figures come from biased SGD objectives, not unit-weight ALS.
| engine | HR@10 | HR@5 | NDCG@10 | MRR |
|---|---|---|---|---|
| als | 0.2853 | 0.1919 | 0.1658 | 0.1297 |
| popular | 0.1368 | 0.0838 | 0.0700 | 0.0498 |
| user | 0.1166 | 0.0753 | 0.0635 | 0.0473 |
| mf | 0.0467 | 0.0276 | 0.0225 | 0.0152 |
| random | 0.0042 | 0.0011 | 0.0021 | 0.0015 |
| item | 0.0021 | 0.0011 | 0.0008 | 0.0004 |
This reproduces the classic Cremonesi–Koren–Turrin (RecSys 2010) findings:
a non-personalized popularity baseline is surprisingly strong under plain
leave-one-out, correlation-based item-kNN performs extremely poorly, and RMSE
gains do not translate into top-N accuracy. Paired bootstrap (2000 resamples,
results/eval_bootstrap.json): ALS is significantly better than every engine
on hit@5 and MRR (all p<0.001); user-vs-popularity is a statistical tie
(p=0.47).
The fix that finding calls for is implemented: debiased long-tail LOO
excludes the top-2% most-popular head items from the test set
(--exclude-head, results/eval_ranking_longtail.json). 170 of 943 held-out
targets were head items; on the remaining 773 users:
| engine | HR@10 | HR@5 | NDCG@10 | MRR |
|---|---|---|---|---|
| als | 0.2186 | 0.1358 | 0.1202 | 0.0908 |
| user | 0.0556 | 0.0285 | 0.0251 | 0.0160 |
| mf | 0.0401 | 0.0207 | 0.0174 | 0.0106 |
| popular | 0.0078 | 0.0013 | 0.0026 | 0.0012 |
| random | 0.0078 | 0.0052 | 0.0038 | 0.0026 |
| item | 0.0026 | 0.0013 | 0.0009 | 0.0005 |
Popularity collapses to random parity (and below random at HR@5), exposing
its earlier strength as an artifact of head items being the held-out targets.
ALS stays the strongest ranker on the strictly harder long-tail task; the
memory-based user and explicit mf engines now beat raw popularity.
| metric | agent (v1) | agent (v2) | als (v2) | delta v2 (paired bootstrap) |
|---|---|---|---|---|
| HR@5 | 0.070 | 0.160 | 0.230 | −0.070, 95% CI [−0.115, −0.030], p=0.002 |
| MRR | 0.036 | 0.092 | 0.138 | −0.046, 95% CI [−0.079, −0.016], p=0.005 |
Context engineering v2 more than doubles the agent's absolute hit rate (0.070 → 0.160) but it remains significantly below ALS at raw LOO ranking. Where it earns its place is explicit constraints — with the genre-lookup artifact fixed, pure CF is only ~14.5% sci-fi on this sample, so the sci-fi row is a clean win: agent precision 1.0 vs 0.145 (and film-noir 1.0 vs 0.043). The win reproduces on ml-20m (1.0 vs 0.179–0.184, lists never copies of CF). Bootstrap p-values in this section are recomputed with per-user aligned arrays; see §4 in FINDINGS.
Same protocol scaled to 20M ratings / 138k users / 27k items. Raw LOO on a
uniform 200-user sample: agent HR@5 0.140 vs ALS 0.200, p=0.021
(eval_agent200_ml20m_uniform.json) — the deficit is significant here too.
On the strict long tail (exclude_head=0.2) both engines score 0.000 at all
top-k, so the tail task is below top-k resolution at this catalog size.
The from-scratch engines are within the published default-parameter range on ml-100k rating prediction, and the implicit ALS is the strongest LOO ranker. The agentic layer does not help raw ranking — the deficit vs ALS is statistically significant in most cohorts (ml-100k v1/v2, ml-20m uniform) — but it provides the one thing pure CF cannot: verifiable constraint compliance (1.0 vs 0.043–0.184 across datasets, zero list-copying).