Building a Recommendation Engine That Actually Explains Itself
Most recommendation engines are black boxes. Ours is a dot product you can read.
FoodNet recommends recipes based on what's in your kitchen, what's about to expire, what you've cooked before, and what you tend to like. The system needs to be fast (<200ms), explainable (why this recipe?), and adaptable (different contexts surface different results).
Here's the full scoring engine.
6 Interpretable Features
Every candidate recipe is scored on 6 dimensions, each normalized to [0, 1]:
1. Pantry Match
pantry_match = ingredients_you_have / total_ingredients
Simple ratio. If you have 8 of 10 ingredients, the score is 0.8. Also returns the list of missing items for expiry/sourcing suggestions.
2. Expiry Urgency
≤3 days expiry: +0.4
4-7 days: +0.2
8-14 days: +0.1
(capped at 1.0)
If a recipe uses your chicken that expires tomorrow, it gets a significant boost. This is the "cook it or lose it" signal.
3. Preference Affinity
A weighted blend of your cooking history:
affinity = cuisine_match * 0.4
+ meal_type_match * 0.25
+ difficulty_match * 0.15
+ saved_before * 0.1
+ cooked_before * 0.1
Affinities use time decay — recent behavior matters more:
| Recency | Weight |
|---|---|
| ≤7 days | 1.0 |
| 8-30 days | 0.7 |
| 31-90 days | 0.4 |
| >90 days | 0.2 |
4. Popularity
popularity = log1p(save_count + cook_count) / log1p(pool_max)
Log-normalized to prevent viral recipes from drowning everything else. The denominator is the max popularity in the current candidate pool, so scores are always relative.
5. Quality (AI Score)
The composite AI score from our multi-persona scoring pipeline, remapped to [0, 1]. Unscored recipes default to 0.5 (neutral). Uses the same centered normalization and human shutoff as search.
6. Time Fit
time_fit = 1.0 - min(|recipe_time - preferred_time| / 60, 1.0)
preferred_time = median(user's recent cook times) or 30 minutes
If you typically cook 20-minute meals, a 3-hour braise scores low. The 60-minute denominator means anything within an hour of your preference gets partial credit.
3 Lane Profiles
The magic: one scoring engine, three weight vectors. The final score for each lane is a dot product:
score = features · weights
| Feature | Cook Now | You Like | Balanced |
|---|---|---|---|
| Pantry Match | 0.35 | 0.10 | 0.20 |
| Expiry Urgency | 0.25 | 0.05 | 0.15 |
| Affinity | 0.10 | 0.40 | 0.20 |
| Popularity | 0.10 | 0.20 | 0.15 |
| Quality | 0.10 | 0.15 | 0.15 |
| Time Fit | 0.10 | 0.10 | 0.15 |
Cook Now prioritizes what you can make right now with expiring ingredients. You Like prioritizes your taste profile and trending recipes. Balanced spreads weight evenly — the "explore" lane.
Every recommendation has a score you can decompose: "this recipe scored 0.82 in Cook Now because you have 9/10 ingredients (0.35 × 0.9 = 0.315) and your chicken expires tomorrow (0.25 × 0.4 = 0.1)."
Diversity Penalties
Without diversity controls, the top 10 recommendations would all be the same cuisine and meal type. We apply per-lane penalties for duplicates:
- -0.15 for duplicate cuisine (no 5 Italian recipes in a row)
- -0.10 for duplicate meal type (mix dinner and lunch)
- -0.05 for duplicate time bucket (vary quick and slow recipes)
These are applied greedily: the highest-scoring recipe is picked first, then penalties adjust remaining scores.
The Engagement Ladder
Every recommendation event follows a state machine:
shown → clicked → saved → cooked
↓
skipped (terminal)
Each transition overwrites the previous state. "Skipped" is terminal only from "shown" — if you clicked and then didn't cook, that's still a "clicked" event, not a skip.
Valid transitions are enforced in code:
_VALID_PRIOR = {
"clicked": {"shown"},
"saved": {"shown", "clicked"},
"cooked": {"shown", "clicked", "saved"},
"skipped": {"shown"},
}
Performance: Precomputed Cache
Computing recommendations from scratch takes 2-5 seconds (inventory lookup, affinity aggregation, scoring 300 candidates across 3 lanes). Too slow for page load.
Solution: precompute and cache as JSONB in the user_recommendations table. Serving is a single row read: <200ms.
Cache invalidation: any inventory mutation (add, delete, restore) triggers a background refresh. A cron job (make recommendations) can also refresh all users.
Confidence thresholds: hero cards require ≥0.25, feed items require ≥0.15. Below these thresholds, we'd rather show nothing than show a bad recommendation.
Next: What I Got Wrong Building an AI System (So Far) — fuzzy alias poisoning, OCR hallucinations, embedding noise, and other mistakes.