Recommendation systems
Matching people to items at scale.
A recommender chooses the handful of items to put in front of one person, out of a catalogue of millions, in a few milliseconds. It does it in two stages: retrieve a few hundred plausible candidates cheaply, then rank those carefully. The signals are what this person did before and what people who behaved similarly did next.
Where the catalogue is large, what gets shown is what gets bought, watched or read, so the ranking is the product. The failures are specific: a new item or a new user has no history to learn from, and a system trained on its own past clicks keeps narrowing what anyone is ever offered until the catalogue might as well be a hundred items.
It builds on embeddings and model evaluation. It is not a legacy area — it remains one of the largest employers of ML engineers and a very common system design question — but much of the classical matrix factorisation literature is. Current practice is embeddings plus vector search for the retrieval stage and gradient boosting or a neural ranker on top, which puts it closer to the retrieval stack than to classical ML.
Retrieve cheaply, rank carefully, and believe a live test over an offline metric.
In more detailCandidate generation followed by ranking, using collaborative and content signals. The interesting problems are cold start, feedback loops that narrow what anyone ever sees, and the gap between offline metrics and what users actually do.
Learn first
Matching people to items
By the end: You can design a recommender and discuss the problems it creates.
- Retrieval then ranking
- Collaborative and content signals
- Cold start
- Feedback loops and offline metrics
Recommenders are among the largest machine learning systems in production anywhere, and the central constraint is one you have already met in retrieval: you cannot score a million items for every request. This section covers the two-stage shape that follows — cheap candidate generation, then expensive ranking — the signals you can match on, and what to do about a user or an item with no history at all. It also covers the uncomfortable part, that the system trains on what it previously recommended, so offline metrics can rise while the experience narrows.
What you have about this user or item What can actually score it
years of their clicks collaborative: people like them
three clicks, from today their session, plus popularity
nothing at all, new account popularity, by locale and device
item live for a year collaborative: people who took it
item uploaded ten minutes ago content: its own features
item nobody has ever been shown exploration, or it stays invisibleTwo stages#
You cannot score a million items per request. Candidate generation narrows to a few hundred cheaply; ranking scores those precisely. The same two-stage shape as retrieval in a RAG system, for the same reason.
millions of items
| candidate generation, cheap and broad
v
~500 candidates
| ranking, expensive and precise
v
20 shownThe reason is arithmetic rather than taste. Suppose the ranking model takes 200 microseconds per item, which is fast for anything with real features in it. Scoring two million items is 400 seconds for one request. Scoring five hundred is a tenth of a second, and that fits in a page load. There is no model that is both accurate enough to rank with and cheap enough to run over the whole catalogue, so you need two models with different jobs.
stage items in items out budget what it may use
retrieval 2,000,000 ~500 10 ms one vector per item, ANN index
ranking 500 ~50 50 ms full features, gradient boosting
re-ranking 50 20 5 ms diversity, freshness, rulesThe two stages fail differently and that difference decides what you work on. Retrieval fails by omission: a good item never reaches the ranker and no amount of ranking improvement can recover it, so retrieval is measured by recall — did the set of five hundred contain the things this user would have liked. Ranking fails by ordering: the right items were there and the wrong one went first, so ranking is measured by position-aware metrics. If your feed is dull, find out which of the two it is before touching either, because tuning the ranker on candidates that never contained anything interesting is a month spent reordering the same twenty items.
Collaborative filtering#
The collaborative signal is that people who behaved alike in the past will behave alike next. It needs nothing about what the items are — no titles, no genres, no descriptions — which is its defining strength: it discovers that two items belong together because the same people take both, not because somebody tagged them the same.
The item-to-item form is the one worth being able to write out. Build a matrix of users against items, treat each item as its column, and measure how similar two columns are. Recommending then means summing the similarities of everything the user already took.
import numpy as np
# Rows are users, columns are items. A one means that user took that item.
R = np.array([
[1, 1, 0, 0, 0],
[1, 1, 1, 0, 0],
[0, 1, 1, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
], dtype=float)
norms = np.linalg.norm(R, axis=0)
S = (R.T @ R) / np.outer(norms, norms) # cosine similarity between items
np.fill_diagonal(S, 0) # an item is not its own recommendation
def recommend(user, k=1):
scores = S @ R[user] # sum similarity to what they took
scores[R[user] > 0] = -np.inf # never recommend what they have
return np.argsort(-scores)[:k], scores
picks, scores = recommend(0)
print(np.round(scores, 2)) # [-inf -inf 1.32 0. 0. ]
print(picks) # [2] -- item 2, which users 2 and 3 took alongside item 1Three things it fails at, all of them structural rather than fixable by tuning. It cannot score an item nobody has taken, which is every item on its first day. It is biased towards the popular, because a column with ten thousand ones overlaps with everything and a column with four ones overlaps with almost nothing, so the tail stays invisible even when it is exactly right for someone. And it has no idea why, so when the product team asks for an explanation next to each recommendation there is nothing honest to write.
Normalise, or the answer is always the most popular item
Raw co-occurrence counts rank by popularity wearing a similarity costume, because the biggest number of shared users belongs to whatever everyone takes. Dividing by the norms, as above, is the minimum. Systems that care divide by something stronger still, penalising popular items further, and the effect on the tail is larger than most model changes.
Content-based filtering#
The content signal is that this item resembles things the user already liked, where resemblance is computed from the item itself: its text, its images, its category, its creator, its price. The user is represented as a summary of the items they took, and scoring a new item means comparing it to that summary. An embedding model does this well for anything with text or images attached.
What it buys you is the entire cold-item problem solved. An item uploaded ten minutes ago has features, so it has an embedding, so it can be retrieved. That is not a small thing: for a news site, a marketplace or a video platform, most of what matters was created this week.
What it fails at is everything collaborative filtering is for. It recommends more of the same, because more of the same is what similarity means, so a user who read three articles about interest rates gets a fourth and never the one thing outside that they would actually have loved. It is capped by the quality of the features, and item metadata in the real world is entered by whoever uploaded it. And it cannot find the non-obvious pairing — the two products that have nothing in common except that the same people buy both — which is exactly the recommendation that feels like magic and is worth the most.
Collaborative Content
needs interaction history item features
new item cannot score it fine, day one
new user cannot score them fine, after one action
long tail buried by popularity reachable
surprise finds non-obvious pairs more of the same
explainable not really yes, by feature
degrades when the catalogue turns over metadata is poorCold start#
A new user has no history and a new item has no interactions, so collaborative filtering has nothing to work with. There are four cases and they need different answers, which is why one paragraph about cold start is never enough.
- New user. Fall back to popularity conditioned on whatever you do know: country, language, device, referrer. Then switch to their session as soon as it exists, because two clicks in the current session is a stronger signal than a demographic prior. Asking them to pick three interests at sign-up works, and costs you some of them at the door.
- New item. Retrieve it by its content embedding, and give it a deliberate budget of impressions so it acquires the interaction history it needs. Without the budget it is invisible forever: no impressions means no clicks, which means no collaborative signal, which means no impressions.
- New system, no data at all. Ship content-based retrieval plus popularity and start logging. Matrix factorisation on three weeks of interactions is worse than sorting by popularity, and the logs are the asset you are actually building.
- The returning user who has been away a year. Technically warm, practically cold: their taste has moved and the last thing they clicked is about a job they no longer have. Weight recent interactions much more heavily than old ones, and treat a long gap as a partial reset.
The mechanism that makes the new-item case work is shrinkage. An item with two clicks from five impressions does not have a forty percent click rate; it has almost no information, and a greedy ranker that believes the raw ratio will put it at the top of everyone's feed on the strength of two clicks. Pull the estimate towards the catalogue average, with the pull weakening as evidence accumulates.
def smoothed_rate(clicks, impressions, prior_rate, strength=200):
"""Shrink towards the catalogue average. strength is how many impressions
of evidence it takes before the item's own data dominates the prior."""
return (clicks + prior_rate * strength) / (impressions + strength)
prior = 0.04 # the catalogue-wide click rate
for imps, clicks in [(5, 2), (200, 8), (5000, 150), (5000, 400)]:
raw = clicks / imps
print(imps, clicks, round(raw, 3), round(smoothed_rate(clicks, imps, prior), 4))
# 5 2 raw 0.4 smoothed 0.0488 two clicks prove nothing
# 200 8 raw 0.04 smoothed 0.04 average, and known to be average
# 5000 150 raw 0.03 smoothed 0.0304 genuinely below average
# 5000 400 raw 0.08 smoothed 0.0785 genuinely good, and allowed to say soFeedback loops#
The system creates the data it learns from
You only observe interactions with what you showed. Train on that and you reinforce what the system already favours, narrowing what anyone ever sees. Deliberate exploration is not a nicety here; without it the system slowly collapses onto a small set of popular items.
It is worth watching that happen rather than taking it on trust. Below, two hundred items have a fixed true click rate that the system never observes. One run always shows the current best estimates; the other gives two of its ten slots to items chosen at random. Everything else is identical.
import numpy as np
def simulate(explore_slots, rounds=400, n_items=200, slots=10, seed=0):
rng = np.random.default_rng(seed)
quality = rng.beta(2, 20, n_items) # true click rates, never observed
clicks, shown = np.zeros(n_items), np.zeros(n_items)
for _ in range(rounds):
estimate = (clicks + 1) / (shown + 25) # smoothed, as above
picks = list(np.argsort(-estimate)[: slots - explore_slots])
if explore_slots:
picks += list(rng.choice(n_items, explore_slots, replace=False))
picks = np.array(picks)
shown[picks] += 1
clicks[picks] += rng.random(len(picks)) < quality[picks]
return quality, shown, clicks
for explore in (0, 2):
quality, shown, clicks = simulate(explore)
top = np.argsort(-shown)[:10]
print(
"explore", explore,
"| distinct items shown", int((shown > 0).sum()),
"| mean true rate of the 10 most-shown", round(quality[top].mean(), 4),
"| clicks", int(clicks.sum()),
)
# explore 0 | distinct 182 | mean true rate of the 10 most-shown 0.1086 | clicks 481
# explore 2 | distinct 195 | mean true rate of the 10 most-shown 0.1562 | clicks 643
# The best ten items in the catalogue average 0.2302, so neither run found them.Offline metrics mislead#
Offline you can only measure whether you would have predicted what the user actually did, under the old system. A genuinely better recommender shows different things, which offline evaluation scores as wrong. This is why recommender work depends on live experiments more than most machine learning.
# The old system only ever showed this user items 0 to 49.
exposed = set(range(50))
would_like = {3, 7, 41, 120, 155} # unobservable: what they actually enjoy
logged = would_like & exposed # {3, 7, 41}: all the offline test can see
def offline_recall(recs, k=5):
return len(set(recs[:k]) & logged) / len(logged)
old_model = [3, 7, 41, 12, 19]
new_model = [3, 120, 155, 7, 88] # surfaces two items they would love
print(round(offline_recall(old_model), 2), round(offline_recall(new_model), 2))
# 1.0 0.67That does not make offline evaluation useless, and treating it as useless is the opposite mistake. It is reliable for the things that do not depend on what was shown: whether a change broke something, whether retrieval recall against known-relevant items went up, whether latency regressed, whether a feature has predictive value at all. Use it as a filter that kills bad ideas cheaply. It is unreliable for the size of an improvement, for anything involving items the old system rarely showed, and for the ordering of two models that are close — which is precisely when someone wants an answer from it.
- Offline predicts, roughly: this change is broken, this change does nothing, retrieval now covers items it used to miss.
- Offline does not predict: how much engagement moves, whether the feed feels repetitive, whether the new items get discovered, what happens in week three when the model has retrained on its own output.
- The last one is the one that surprises people. Every metric can improve in a two-week test and the system still degrade over a quarter, because the training data is now the product of the thing being measured.
Where people go wrong
Training only on observed interactions with no exploration. The system reinforces its own past choices, diversity collapses, and the offline metrics look fine throughout.
Build this: Build a simple recommender, then measure how many distinct items it surfaces across a thousand users. Add exploration and measure again.
Open this section on its own pageRanking models and how to measure them
By the end: You can build the ranking stage and evaluate it with the right metrics.
- Two-tower models for retrieval
- Ranking with rich features
- Ranking metrics
- Beyond clicks
You have the two-stage shape. This is what goes inside each stage. Retrieval is usually a two-tower model, one network for users and one for items, which lets every item embedding be precomputed and turns retrieval into nearest-neighbour search — the same architecture as dense retrieval in a RAG system. Ranking then scores the survivors with much richer features. Measurement matters as much as the model: ranking metrics care where a correct item appeared and not merely whether it did, and optimising clicks alone reliably produces a system nobody enjoys using.
Two-tower retrieval#
One network embeds users, another embeds items, and relevance is the similarity between the two. Item embeddings can be precomputed, so retrieval becomes nearest-neighbour search over millions of items. This is the same architecture as dense retrieval in a RAG system.
The reason it must be two separate towers is the whole design. A single model taking the user and the item together can express far more — how this user reacts to this price, at this hour — but it has to be run once per item, which is the million-item problem the two-stage shape exists to avoid. Keeping the towers apart forces the score to be a dot product of two vectors computed independently, and a dot product is what an approximate nearest-neighbour index can search in sublinear time. You give up the interaction terms in exchange for being able to search the catalogue at all, and then you buy the interaction terms back in the ranking stage where there are only five hundred items left.
import numpy as np
def in_batch_softmax_loss(user_vecs, item_vecs, temperature=0.05):
"""Each row is a user and the item they actually took. Every other item in
the batch is a negative, which is why a large batch matters here."""
u = user_vecs / np.linalg.norm(user_vecs, axis=1, keepdims=True)
v = item_vecs / np.linalg.norm(item_vecs, axis=1, keepdims=True)
logits = (u @ v.T) / temperature # batch x batch
logits -= logits.max(axis=1, keepdims=True) # stability, changes nothing else
probs = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True)
return float(-np.log(np.diag(probs)).mean()) # the diagonal is the true pair
rng = np.random.default_rng(0)
u = rng.normal(size=(8, 16))
matched = u + 0.1 * rng.normal(size=(8, 16)) # items close to their user
random_items = rng.normal(size=(8, 16))
print(round(in_batch_softmax_loss(u, matched), 3),
round(in_batch_softmax_loss(u, random_items), 3))
# 0.0 8.699 -- the true pair is separated from its seven in-batch negatives
# so cleanly that the loss rounds to zero; random pairings do notTwo operational facts follow from precomputing item vectors. Item embeddings go stale: the tower is retrained, and until you re-embed the catalogue and rebuild the index, the vectors in the index came from a different model than the one embedding the user, and the scores are meaningless. So retraining the retrieval model is a rebuild of the whole index, usually nightly. And the user tower has to run at request time, which is why it is kept small and why its features are ones you can look up in single-digit milliseconds.
The ranking stage#
Retrieval narrows to a few hundred candidates. Ranking scores each with far richer features: the user's recent history, the item's context, time of day, interactions between them. It can afford to be expensive because it runs on few items.
Richer means the features that could not be split into two independent towers. The count of times this user has taken something from this creator. The gap since they last saw this item. Whether the price is above or below what they usually pay. Position of the candidate in the retrieval list, which is itself informative. Gradient boosting handles these well and remains the default for anything that is not video or image heavy, because the features are tabular and there are not many candidates to score.
Feature skew between training and serving is the standard ranking bug
A feature computed in a training job from a warehouse table and recomputed at serving time from a cache will differ, and the model was fitted to the first version. The symptom is an offline score that does not appear online, with nothing broken anywhere. The fix is to log the feature values as they were at serving time and train on those, rather than recomputing history.
Ranking metrics#
All four metrics below take the same input — a ranked list, and the set of items that turned out to be relevant — and disagree about what matters. Write them out once and the disagreements become obvious.
import numpy as np
def precision_at_k(recs, relevant, k=10):
"""Of what you showed, how much was worth showing."""
return len(set(recs[:k]) & relevant) / k
def recall_at_k(recs, relevant, k=10):
"""Of what was worth showing, how much you found."""
return len(set(recs[:k]) & relevant) / len(relevant)
def mrr(recs, relevant):
"""How far the user scrolls before the first good thing."""
for i, r in enumerate(recs):
if r in relevant:
return 1 / (i + 1)
return 0.0
def ndcg_at_k(recs, relevant, k=10):
"""Discounted by position, normalised by the best possible ordering."""
gains = [1 / np.log2(i + 2) for i, r in enumerate(recs[:k]) if r in relevant]
ideal = [1 / np.log2(i + 2) for i in range(min(k, len(relevant)))]
return sum(gains) / sum(ideal)
relevant = {"a", "b", "c"}
front = ["a", "b", "c", "x", "y", "z", "w", "v", "u", "t"]
back = ["x", "y", "z", "w", "v", "u", "t", "a", "b", "c"]
for name, recs in [("good order", front), ("same items, at the bottom", back)]:
print(f"{name:28}",
"P@10", round(precision_at_k(recs, relevant), 2),
"R@10", round(recall_at_k(recs, relevant), 2),
"MRR", round(mrr(recs, relevant), 2),
"NDCG@10", round(ndcg_at_k(recs, relevant), 3))
# good order P@10 0.3 R@10 1.0 MRR 1.0 NDCG@10 1.0
# same items, at the bottom P@10 0.3 R@10 1.0 MRR 0.12 NDCG@10 0.425- Precision at k is the right measure when the slot count is fixed and expensive: five results on a phone screen, three items in an email. It is a poor measure when the number of relevant items is smaller than k, because the ceiling is then below one and the number is uninterpretable on its own.
- Recall at k is the right measure for the retrieval stage, where the job is to not lose anything and order does not matter yet. It is the wrong measure for the final list, where it says nothing about what the user sees first.
- MRR is the right measure when there is one correct answer and the user stops at it: a search for a specific film, a lookup. It ignores everything after the first hit, so it is the wrong measure for a feed.
- NDCG is the right measure for a ranked list with several relevant items of varying value, which is most feeds. It takes graded relevance, so a purchase can count more than a click. It is harder to explain to a product manager than the other three, which is a real cost.
Where each of these lies
All four are computed against logged interactions, so all four inherit exposure bias: items the old system never showed have no recorded relevance and are scored as wrong when a new model surfaces them. NDCG has a second problem specific to it — the normalising denominator depends on how many relevant items that user had, so averaging NDCG across users with one relevant item and users with fifty compares numbers that were not measuring the same thing. Report it by segment, or at least look at the distribution before quoting the mean.
Beyond clicks#
Clicks are a proxy, and a gameable one
Optimising for clicks rewards sensational items that disappoint. Stronger systems optimise for downstream signals such as time spent, return visits, or explicit satisfaction, and treat raw click-through as one input rather than the goal.
In practice that means predicting several things and combining them, rather than predicting one thing well. Each head predicts a probability — click, a long dwell, a save, a complaint — and the score is a weighted sum. The weights are a product decision written down as numbers, which is uncomfortable for everyone involved and considerably better than the alternative of pretending the objective is neutral.
def score(p):
"""One weight per predicted outcome. Negative weights are the point."""
return (
1.0 * p["click"]
+ 3.0 * p["long_dwell"]
+ 5.0 * p["save"]
- 20.0 * p["hide"]
- 50.0 * p["report"]
)
clickbait = {"click": 0.30, "long_dwell": 0.02, "save": 0.00, "hide": 0.08, "report": 0.01}
solid = {"click": 0.12, "long_dwell": 0.09, "save": 0.04, "hide": 0.00, "report": 0.00}
print(round(score(clickbait), 3), round(score(solid), 3))
# -1.74 0.59The other correction worth making is position bias. Users click the first result more because it is first, not because it is better, so a model trained on raw clicks learns to reproduce the previous ranking. The cheap fix is to include the position the item was shown at as a training feature and set it to a constant at serving time, which lets the model attribute some of the click to the position and the rest to the item.
Where people go wrong
Optimising click-through rate alone. It rewards clickbait, and users disengage while the metric looks healthy.
Build this: Evaluate a recommender with precision at 10 and NDCG at 10, then reorder its results so precision is unchanged and NDCG moves, to see what each measures.
Open this section on its own pageBuild a recommender end to end
By the end: You can build and evaluate a recommender from interaction data, beginning with baselines that are hard to beat.
- Interaction data and a time-based split
- Popularity: the baseline
- Matrix factorisation
- Recall at k and NDCG
- Where a two-tower model fits
Recommenders are the largest application of machine learning by revenue, and the first thing worth knowing is how good the trivial approaches are. This section builds one from interaction data: splitting by time so the model cannot learn from the future, establishing a popularity baseline that is harder to beat than it sounds, then matrix factorisation, then measuring with metrics that suit a ranked list of suggestions rather than a single prediction.
Data and split#
MovieLens, a public research dataset of film ratings, is the standard place to start. Treat any rating as an interaction. Split by time: train on everything before a date and test on what each user did after it. A random split lets the model learn from the future.
There are two time splits and they answer different questions. A global cutoff picks one date and puts everything before it in training, which is what production actually looks like: you train on Monday and serve on Tuesday. Leave-one-out holds back each user's single most recent interaction, which gives every user a test case including the ones who joined last week, and is what most papers report. The global split is harder and more honest. Use it, and if you also report leave-one-out, say which is which, because the two differ by a lot and a comparison across them is meaningless.
import numpy as np
rng = np.random.default_rng(0)
n_users, n_items = 400, 150
# A synthetic catalogue with three tastes, so there is real structure to find.
user_taste = rng.integers(0, 3, n_users)
item_taste = rng.integers(0, 3, n_items)
appeal = rng.beta(1.5, 4, n_items) # some items are widely liked
prob = 0.5 * (user_taste[:, None] == item_taste[None, :]) * appeal[None, :]
took = rng.random((n_users, n_items)) < prob
users, items = np.nonzero(took)
when = rng.random(len(users)) # stands in for a timestamp
train = when < 0.8 # the cutoff date
R = np.zeros((n_users, n_items))
R[users[train], items[train]] = 1.0 # what the model may see
future = [set() for _ in range(n_users)]
for u, i in zip(users[~train], items[~train]):
future[u].add(i) # what it must predict
print(int(took.sum()), "interactions,", int(train.sum()), "in training")
# 2887 interactions, 2356 in trainingPopularity#
def recall_at_10(score_matrix):
"""One row of scores per user. Of everything a user took after the
cutoff, what fraction did we put in their top ten?"""
hits, users_scored = 0.0, 0
for u in range(n_users):
if not future[u]:
continue
s = score_matrix[u].copy()
s[R[u] > 0] = -np.inf # exclude what they already took
top = np.argsort(-s)[:10]
hits += len(set(top) & future[u]) / len(future[u])
users_scored += 1
return hits / users_scored
popularity = np.tile(R.sum(axis=0), (n_users, 1)) # same row for everyone
print("popularity ", round(recall_at_10(popularity), 3))
print("random ", round(recall_at_10(rng.random((n_users, n_items))), 3))
# popularity 0.173
# random 0.069Recommending the most popular unseen items is embarrassingly strong. Every model must be compared with it, and many published models barely beat it once it is tuned. Tuned means at least three things: exclude what the user has already taken, restrict popularity to a recent window so last year's hit does not dominate forever, and condition it on whatever segment you cheaply know, such as country. Each of those is a line of code and each moves the baseline enough to sink a model that was about to be declared a success.
Matrix factorisation#
import torch, torch.nn as nn
class MF(nn.Module):
def __init__(self, n_users, n_items, d=64):
super().__init__()
self.u = nn.Embedding(n_users, d)
self.i = nn.Embedding(n_items, d)
def forward(self, users, items):
return (self.u(users) * self.i(items)).sum(-1) # a score per pair
# Train with observed pairs as positives and random unobserved items as negatives,
# using a binary cross-entropy or pairwise ranking loss.Each user and item gets a vector; the dot product scores how well they match. Items a user interacted with get pulled towards that user's vector. Negative sampling is needed because unobserved does not mean disliked, but you have to show the model something it should rank lower.
How you draw those negatives changes the model more than most hyperparameters do, and there are three choices with different consequences. Uniform sampling over the catalogue is easy and produces negatives that are trivially easy, because a random item from two million is obviously irrelevant and the model learns nothing from ranking it last. Sampling in proportion to popularity gives harder negatives, since a popular item the user did not take is genuine evidence of a preference, and it also corrects the bias that makes popular items score highly simply for appearing often. Sampling from the items that were actually shown and not clicked is the best signal available, and it needs impression logging that most systems only add after someone asks for it.
import numpy as np
def sample_negatives(rng, taken, n_items, k, popularity=None, alpha=0.75):
"""Draw k items the user did not take. alpha=0 is uniform, 1 is fully
popularity-proportional; 0.75 is the usual compromise."""
if popularity is None:
weights = np.ones(n_items)
else:
weights = np.power(popularity, alpha)
weights = weights.copy()
weights[list(taken)] = 0.0 # never sample a true positive
weights /= weights.sum()
return rng.choice(n_items, size=k, replace=False, p=weights)
rng = np.random.default_rng(0)
popularity = np.array([500.0, 300, 50, 20, 5, 2])
print(sorted(int(i) for i in sample_negatives(rng, {0}, 6, 3, popularity)))
print(sorted(int(i) for i in sample_negatives(rng, {0}, 6, 3)))
# [1, 2, 3] popularity-weighted: it drew the two next-most-popular items
# [2, 4, 5] uniform: it drew from the tail, where the model learns leastThe simplest factorisation needs no training loop at all. A truncated singular value decomposition of the interaction matrix gives exactly the same thing — a vector per user, a vector per item, scores as their dot product — in one call, which makes it the right way to see the idea working before adding machinery.
U, sv, Vt = np.linalg.svd(R, full_matrices=False)
for k in (4, 8, 16, 32):
mf = (U[:, :k] * sv[:k]) @ Vt[:k] # rank-k reconstruction
print("factors", k, " ", round(recall_at_10(mf), 3))
# factors 4 0.432
# factors 8 0.365
# factors 16 0.381
# factors 32 0.339That last line is the reason plain SVD is a teaching tool rather than the production answer. It treats every unobserved cell as a confident zero, so it spends capacity explaining absences. The implicit-feedback methods that replace it — alternating least squares with confidence weights, or the pairwise BPR loss — all encode the same correction: an observed interaction is evidence, an unobserved one is weak evidence at best, and they must not be weighted the same.
Measure ranking#
import numpy as np
def recall_at_k(recs, relevant, k=10):
return len(set(recs[:k]) & relevant) / min(k, len(relevant))
def ndcg_at_k(recs, relevant, k=10):
dcg = sum(1 / np.log2(i + 2) for i, r in enumerate(recs[:k]) if r in relevant)
ideal = sum(1 / np.log2(i + 2) for i in range(min(k, len(relevant))))
return dcg / idealReport the average by segment as well as overall, because the overall number is dominated by heavy users who have the most interactions and are the easiest to predict. The segment that decides whether the system is any good is users with fewer than twenty interactions, and it is routinely twice as bad as the headline while nobody looks.
recall@10 share of users
all users 0.43 100%
20+ interactions 0.58 31%
5 to 19 interactions 0.34 44%
under 5 interactions 0.11 25%Two towers#
Matrix factorisation is a two-tower model with the simplest possible towers: a lookup table. Replace each lookup with a network that takes user features and item features, and new items with no history can still be embedded from their features. That is how large systems do retrieval over millions of items, with approximate nearest neighbour search over item vectors.
Where people go wrong
Splitting interactions randomly. The model is tested on predicting the past from the future, and scores are inflated.
Build this: On MovieLens, build popularity and matrix factorisation recommenders with a time-based split and report recall at 10 and NDCG at 10 for both, overall and for users with fewer than 20 interactions.
Open this section on its own pageThe recommender system design interview
By the end: You can lead a system design discussion for a feed or recommendation product from requirements to experiments.
- Clarifying the objective
- Candidate generation from several sources
- Ranking and re-ranking
- Serving within a latency budget
- Logging, feedback and experiments
A feed or a recommendation product is a standard system design question, and it has a standard shape: cut millions of candidate items down to a few hundred cheaply, rank those carefully, re-rank for the things the ranker cannot see, and do all of it inside a latency budget. This section walks that structure from the objective down to logging and experiments. The objective comes first, because watch time and long-term retention lead to genuinely different systems.
Start with the objective#
Ask what success means before drawing boxes. Watch time, purchases, long-term retention and user satisfaction lead to different systems. Ask about scale: users, items, requests per second, and how fast new items appear.
If the objective is the system leans towards
watch time long items, autoplay, strong session modelling
purchases price and stock features, heavy re-ranking on margin
retention over months diversity and exploration, engagement weighted down
subscriptions breadth of catalogue surfaced, not depth of one taste
creator ecosystem health an impression floor for new creators, by designThe shape of the answer#
request
-> candidate generation (several sources, ~1,000 items, tens of ms)
two-tower ANN retrieval | recent interactions | trending | followed creators
-> ranking (rich features, a heavier model, ~1,000 -> ~100)
-> re-ranking (diversity, freshness, business rules, remove seen items)
-> response
-> logging impressions, clicks, dwell, skips -> training data for tomorrowPut a number on each arrow. A feed request that must return in 200 milliseconds at the ninety-ninth percentile does not have 200 milliseconds of model time in it, and being able to say where the budget goes is what separates a design answer from a diagram.
fetch user features from the cache 5 ms
user tower forward pass 3 ms
ANN search over the item index 8 ms -- and the other sources, parallel
fetch features for ~1,000 candidates 25 ms -- one batched call, never 1,000
ranking model over 1,000 candidates 40 ms
re-ranking and business rules 5 ms
------
86 ms leaving room for the network,
serialisation and one retryRe-ranking, and why it is a separate stage#
The ranker scores each item on its own, so it cannot see that the top ten are nearly the same item. Re-ranking is where the list is judged as a list: diversity, freshness, not showing the same creator four times, honouring whatever rules the business has. Maximal marginal relevance is the standard mechanism, and it is short enough to write out.
import numpy as np
def mmr(scores, similarity, k=5, lam=0.7):
"""Pick items that are good and unlike what has already been picked.
lam=1 is pure relevance; lam=0 is pure diversity."""
chosen = []
remaining = list(range(len(scores)))
while remaining and len(chosen) < k:
best, best_value = None, -np.inf
for i in remaining:
penalty = max((similarity[i][j] for j in chosen), default=0.0)
value = lam * scores[i] - (1 - lam) * penalty
if value > best_value:
best, best_value = i, value
chosen.append(best)
remaining.remove(best)
return chosen
scores = np.array([0.90, 0.89, 0.88, 0.60, 0.55])
# The first three are near-duplicates of each other; the last two are unrelated.
similarity = np.array([
[1.0, 0.95, 0.93, 0.05, 0.02],
[0.95, 1.0, 0.94, 0.03, 0.04],
[0.93, 0.94, 1.0, 0.02, 0.03],
[0.05, 0.03, 0.02, 1.0, 0.10],
[0.02, 0.04, 0.03, 0.10, 1.0],
])
print(mmr(scores, similarity, k=3, lam=1.0)) # [0, 1, 2] three of the same thing
print(mmr(scores, similarity, k=3, lam=0.7)) # [0, 3, 4] one of eachThe honest tradeoff is that diversity costs engagement today and buys it back over months, and the second half of that sentence is far harder to measure than the first. A two-week test will usually show diversity losing. That is the argument for holdback groups that run for a quarter, and it is also why this decision is made by whoever owns the product rather than by whoever owns the metric.
Logging, or there is no tomorrow#
The training data for next month's model is what today's system writes down, so the logging schema is a modelling decision, not an operational detail. Log the impression, not only the click: an item shown and ignored is the negative example the ranker needs, and a system that logs only clicks can never learn what people skip.
per impression request_id, user_id, item_id, position, timestamp
the model version and the ranking score it gave
the candidate source that produced it
the feature values as used, not recomputed later
per outcome click, dwell seconds, save, share, hide, report
joined back on request_id and item_idPoints interviewers look for#
- Several candidate sources, so one weak source does not starve the feed.
- The ranking objective as a combination of predicted actions, such as click, long watch and share, with weights tied to the business goal.
- Logging impressions, not only clicks, so non-clicks become negatives.
- Cold start for new users and new items, with exploration.
- Feedback loops: the model trains on what it chose to show. Some exploration keeps the data honest.
- Online experiments with guardrails, and a time-based offline evaluation.
Where people go wrong
Starting with the ranking model architecture before asking what the product is trying to optimise.
Build this: Write a two-page design doc for recommendations in a product you use: objective, scale, candidate sources, ranking signals, re-ranking rules, logging and the first experiment you would run.
Open this section on its own pageNow actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Watch diversity collapse
10hBuild a simple recommender, measure how many distinct items it surfaces across a thousand users, then simulate training on its own outputs for several rounds.
Done when: You can show the narrowing, then show exploration preventing it.
2Handle cold start
10hAdd new users and new items with no history and make the system behave sensibly for both.
Done when: A brand new item can be surfaced at all, which it could not before.
20 hours of practice
Interview questions
7 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
Matching people to items
How would you design a recommender system?
Two stages: cheap candidate generation narrowing millions to hundreds, then expensive ranking of those. Combine collaborative and content signals, handle cold start with content features and popularity, and include deliberate exploration.
What is the cold start problem?
New users have no history and new items have no interactions, so collaborative filtering has nothing to work from. Content features, popularity defaults and exploration cover the gap until enough signal accumulates.
Why do offline metrics mislead for recommenders?
You only observe interactions with what was shown, under the previous system. A better recommender surfaces different items, which offline scoring counts as wrong. Live experiments are necessary rather than optional here.
Ranking models and how to measure them
What is a two-tower model?
Separate networks embed users and items, and relevance is their similarity. Item embeddings are precomputed, so retrieval is a fast nearest-neighbour search. It is the same design as dense retrieval in RAG.
Why use NDCG rather than precision for ranking?
NDCG rewards placing the most relevant items at the top, since users mostly see the first few results. Precision at k treats every position in the top k equally.
Build a recommender end to end
How would you evaluate a recommender offline?
A time-based split so the model never sees the future, ranking metrics such as recall at k and NDCG against each user's later interactions, and comparison against a tuned popularity baseline. Then treat offline results as a filter and confirm with an online test, since offline data reflects what the old system chose to show.
Then they push further
Why is offline evaluation biased?
Users could only interact with what the previous system showed them. Items it never surfaced have no interactions, so a new model that recommends them looks wrong offline even if users would love them.
What gets marked up. Marked up for time-based splits, a popularity baseline, and naming exposure bias.
The recommender system design interview
Design the recommendation system for a short-video app's home feed.
Clarify the objective and scale. Then describe multi-source candidate generation, including two-tower retrieval with approximate nearest neighbour search, a ranking model predicting several engagement signals combined by weights tied to the goal, re-ranking for diversity and freshness, logging of impressions and outcomes, cold-start handling, exploration, and evaluation with time-split offline metrics and online A/B tests.
Then they push further
Engagement went up but users report the feed feels repetitive. What do you change?
Add diversity in re-ranking, penalise near-duplicates and over-represented creators, include longer-term signals such as return visits in the objective, and track a diversity metric and user surveys as guardrails.
How do you handle a brand-new video with no interactions?
Embed it from content features such as visuals, audio, text and creator, give it a small exploration budget of impressions, and let early engagement quickly update its ranking.
What gets marked up. Strong answers start from the objective, separate retrieval from ranking, and address feedback loops and cold start without prompting.
Resources
Free to watch and read, picked for this topic.
For matching people to items
For ranking models and how to measure them
For build a recommender end to end
For the recommender system design interview
Full courses: the whole topic, start to finish