Build a Recommendation Feed With Embeddings
12 min read · updated August 4, 2026
An embedding feed is four lines of arithmetic: average the vectors of what someone engaged with, find the nearest unseen items, remove the near-duplicates, and reserve a slot for something different. The first two lines are the tutorial everyone writes. The last two are the difference between a feed people keep opening and one that shows the same article five times.
The design in one paragraph
Every item gets an embedding of its title plus a summary. Every user gets a vector that is a decayed weighted average of the items they engaged with. Ranking is cosine similarity between the two, then a diversity pass, then a fixed exploration slot. No training, no model server, and it runs in SQLite until you have millions of items.
What this buys over collaborative filtering is the cold start: a brand-new item has a usable vector the moment it exists, because its vector comes from its text rather than from who clicked it. Cold start is the defining weakness of interaction-based recommenders, and content embeddings are the standard patch for it.
Item and user vectors
# feed.py
import json, math, sqlite3, time
HALF_LIFE_DAYS = 21.0
WEIGHTS = { # engagement signal -> weight in the user vector
"click": 1.0,
"read_30s": 2.0,
"save": 4.0,
"share": 5.0,
"hide": -6.0, # negative signals matter more than people expect
"skip": -0.5,
}
def user_vector(user_id, dim):
acc = [0.0] * dim
now = time.time()
rows = DB.execute(
"SELECT e.kind, e.at, i.vec FROM engagement e "
"JOIN item i ON i.id = e.item_id WHERE e.user_id = ? "
"ORDER BY e.at DESC LIMIT 300", (user_id,))
total = 0.0
for kind, at, vec in rows:
age_days = (now - at) / 86400.0
decay = 0.5 ** (age_days / HALF_LIFE_DAYS)
w = WEIGHTS.get(kind, 0.0) * decay
if w == 0.0:
continue
v = json.loads(vec)
for i in range(dim):
acc[i] += w * v[i]
total += abs(w)
if total == 0.0:
return None # cold user
return normalise(acc)Three decisions in there are worth arguing about, so here is the argument.
- Negative weights, and large ones. A hide is a much stronger signal than a click, because clicking is cheap and hiding requires effort. Weighting them equally produces a feed that chases headlines.
- Exponential decay by half-life rather than a fixed window. A window makes interests vanish overnight on the day they fall out of it; a half-life lets a long-standing interest fade gracefully while last week dominates.
- A cap of 300 events. The average is dominated by recent behaviour anyway after decay, and the cap bounds the query. Raise it if your half-life is long.
One average vector is a real limitation: a person interested in machine learning and in cycling averages to a point that is neither. If that matters, cluster their engagement into two or three centroids and take the union of each centroid’s neighbours. It is k-means over a few hundred vectors and it is worth the hour.
Cold start, three cases
| Case | Description |
|---|---|
| New item | Solved by construction — its vector comes from its text. Give new items a small recency boost so they get their first impressions at all. |
| New user, no signal | Show the popularity ranking, diversified hard. Do not show a random sample; random is worse than popular and feels broken. |
| New user, some signal | Blend: score = a x similarity + (1-a) x popularity, with a rising from 0 to 1 over the first ~20 engagements. |
def blend_alpha(n_events, full_at=20):
return min(1.0, n_events / float(full_at))
def score(item, uvec, alpha, pop_z):
sim = dot(uvec, item.vec) if uvec else 0.0
return alpha * sim + (1.0 - alpha) * pop_zpop_z should be a normalised popularity score on a comparable scale to cosine similarity — z-scoring the log of recent engagement counts works and takes one pass. Mixing a raw count with a similarity in [-1, 1] means popularity wins every time, which is the most common bug in a hand-rolled blend.
Diversity: maximal marginal relevance
Nearest-neighbour ranking returns near-duplicates, because five articles about the same event genuinely are the five nearest points. Maximal marginal relevance fixes it by selecting greedily, penalising each candidate by how similar it is to what has already been selected.
def mmr(candidates, uvec, k=20, lam=0.7):
"""candidates: list of items with .vec (unit vectors).
lam = 1.0 is pure relevance; lam = 0.0 is pure novelty."""
selected, pool = [], list(candidates)
while pool and len(selected) < k:
best, best_score = None, -1e9
for c in pool:
rel = dot(uvec, c.vec)
red = max((dot(c.vec, s.vec) for s in selected), default=0.0)
s = lam * rel - (1.0 - lam) * red
if s > best_score:
best, best_score = c, s
selected.append(best)
pool.remove(best)
return selectedRun it over the top few hundred by raw similarity, not the whole catalogue — it is quadratic in the pool size. λ = 0.7 is a sensible starting point: at 1.0 you get the duplicate feed, at 0.3 the feed feels arbitrary. Tune it by looking at twenty real feeds, not by optimising a metric, because the metric that would tell you is the one you cannot compute offline.
Add a hard rule alongside it: at most two items from the same source, author or cluster in a page of twenty. MMR handles semantic redundancy; it does not know that six of the items came from the same publisher.
The exploration slot
A feed built only from what someone already liked converges. The user vector is a weighted average of past engagement, engagement comes from what was shown, and what was shown came from the vector — a loop with no input from outside it. Nothing in the maths pushes back, so the pushback has to be explicit.
- Reserve a fixed number of positions per page — two of twenty is a reasonable start — for items that are not chosen by similarity.
- Fill them from the far side: sample from items whose similarity to the user vector is in the middle of the distribution, not the top and not the bottom. The bottom is genuinely irrelevant; the middle is adjacent.
- Log exploration impressions with a flag. Their engagement rate will be lower — that is the price — and the number you want is how often an exploration item becomes a new sustained interest.
- Never let exploration items be excluded from the user vector when they succeed. That is the entire point: it is the only path by which a new interest can enter.
Two of twenty is 10 per cent of impressions spent on discovery. If that seems expensive, the alternative is a feed whose engagement decays slowly for months while every offline metric says it is fine.
A feedback loop that does not eat itself
- Log impressions, not just clicks. Without impressions you cannot compute a rate, and without a rate you cannot distinguish an item nobody liked from an item nobody saw. This is the most common missing piece in a first version.
- Discount position. Position one gets clicked far more than position ten regardless of content. If you feed raw click counts back in, you are mostly learning your own ranking. Divide by the historical click-through rate of the position before using it as a signal.
- Cap any single item’s influence. One viral item can dominate every user vector for a week. Clip its weight.
- Let users see and edit the model of themselves. A “less like this” control that visibly works buys more trust than a percentage point of accuracy, and it produces the cleanest negative signal you will ever get.
Serving it inside a page load
A feed is rendered while somebody waits, so the whole ranking has to finish in tens of milliseconds. The design above does not, if you run it naively: recomputing a user vector from 300 engagements and scoring a hundred thousand items on every request is seconds, not milliseconds.
- Precompute the user vector. Recompute it on engagement — cheaply, as an incremental update to the stored vector — rather than on read. A feed is read far more often than it is changed.
- Precompute a candidate set. Nearest 500 items per user, refreshed on a schedule and on significant engagement. Serving then means MMR over 500 candidates, which is milliseconds, plus a freshness pass over items published since the last refresh.
- Keep a hot pool. The few thousand recent items are the ones most feeds mostly draw from. Hold their vectors in memory as one matrix and the scoring step disappears entirely.
- Serve stale rather than slow. If the candidate set is being refreshed, serve the previous one. Nobody can tell; everyone notices a spinner.
Incremental user-vector update on engagement, O(dim) not O(history): u_new = normalise( u_old * total_old * decay_since_last + w * v_item ) total_new = total_old * decay_since_last + |w| decay_since_last = 0.5 ** (days_since_last_update / HALF_LIFE_DAYS) This is exactly the decayed weighted average recomputed from scratch, because the decay factor is the same for every earlier term — which is the property that makes exponential decay cheap and a fixed window expensive.
That identity is worth checking rather than trusting: with exponential decay, scaling the accumulated sum by the elapsed decay is algebraically the same as re-decaying each historical term. A sliding window has no such identity, which is a second reason to prefer a half-life.
Knowing whether it works
Offline metrics on a recommender are weak, because you can only score what was shown and the system chose what was shown. Precision at 10 computed on logged impressions rewards a system for agreeing with itself.
What is worth measuring instead:
- Coverage — what fraction of the catalogue is shown to anyone in a week. Falling coverage is the earliest visible sign of collapse, and it is computable from logs alone.
- Intra-list diversity — mean pairwise distance within a served page. Set an alert; a deploy that quietly drops it has made the feed narrower.
- Return rate at seven days, split by whether the user engaged with an exploration item. This is the only number that speaks to the thing you actually care about, and it needs a real experiment.
Embedding-based recommendation and diversity in ranked results both cover the trade-offs above in more depth than a build page can.