System Design Primer · llms.txt
Beyond the primer: design questions
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
Open the home screen of YouTube, Netflix, Spotify or TikTok and you get a ranked list built for you, from a catalog of hundreds of millions of items, using your long-term taste, what you tapped in the last few minutes and the time of day. It has to come back in about a tenth of a second.
In an interview it's tempting to treat this as a black box: "train a model on user data and predict what they like". The trouble is the arithmetic. A good ranking model is expensive per item, and you can't run it on a billion items for every request. The design question is how to get from a billion items to twenty without ever doing a billion expensive things.
Production systems answer it with a funnel of stages, each cheaper per item than the next: candidate retrieval (embeddings and approximate nearest neighbor search, plus a few simpler sources), ranking (a heavier multi-task model fed from a feature store), and re-ranking (diversity rules and exploration for new items). This chapter walks through each stage and the infrastructure behind it. It builds on database 101, cache 101 and event streaming.
Agree on scope first. A reasonable set:
GET /api/v1/feed returns the top 20 items for the user who asked. The feed reacts to what the user does in the current session: after two cooking videos, cooking should show up within seconds, not tomorrow. It mixes familiar interests with new or trending items, so new uploads get a chance and the feed doesn't collapse onto one topic. It balances several signals (clicks, watch time, likes, shares), not one.Then size it. These are assumptions for a large consumer app, not figures from any real product:
A latency budget for the 100 ms keeps each stage honest. These numbers are a starting point to negotiate, not measurements:
| Step | Budget |
|---|---|
| Gateway and auth | 5 ms |
| Stage 1: candidate retrieval | 25 ms |
| Feature lookup (feature store) | 10 ms |
| Stage 2: ranking | 35 ms |
| Stage 3: re-ranking | 10 ms |
| Network and serialization | 15 ms |
| Total | 100 ms |
See estimation for more on this kind of arithmetic.
Scoring a billion items with a deep model on every request doesn't fit in 100 ms on any realistic amount of hardware. So serving is split into stages, each working on fewer items with a more expensive model. YouTube's 2016 paper describes the two-stage version (candidate generation, then ranking), and most large feeds add a third stage for diversity and business rules.
An interactive version of this diagram lets you pan, zoom and trace the request paths.
The path of one request:
GET /feed. The feed service (an orchestrator with no state of its own) loads the user's last few in-session interactions from a fast session store, usually Redis.Each stage has a fallback. If retrieval times out, serve a cached copy of the user's last feed or a popular-items list for their region. If the ranker is overloaded, skip it and order by retrieval score. A slightly worse feed is far better than an error, which is how the availability target is met.
Retrieval cuts 1 billion items to about 1,000 in roughly 25 ms. The main tool is an embedding model plus approximate nearest neighbor search.
The classic approach is collaborative filtering by matrix factorization. Picture a huge, mostly empty matrix with a row per user and a column per item, holding interactions (watched, rated, clicked). Factorization learns a short vector pᵤ for each user and qᵢ for each item so that their dot product predicts the matrix entry:
predicted affinity(u, i) = pᵤ · qᵢ = pᵤ₁·qᵢ₁ + pᵤ₂·qᵢ₂ + … + pᵤd·qᵢd
Users who liked the same things end up with similar vectors, and so do items liked by the same people. This is the model popularized by the Netflix Prize. Its weakness is that a vector exists only for users and items seen in training, and it ignores everything else you know about them, such as an item's title or a user's country.
A two-tower model generalizes it. Two neural networks map features into the same d-dimensional space, typically d = 64 to 256:
The towers are trained on logged interactions: a (user, item they engaged with) pair is a positive, and other items in the same training batch serve as negatives. Because new items get a vector from their features alone, an upload with zero views can still be retrieved, which matrix factorization can't do.
Separating the towers is the whole trick. The expensive part, scoring an item, is done ahead of time. At request time you run one small network and then a search.
Finding the 1,000 item vectors with the highest dot product by brute force means 10⁹ dot products of 256 dimensions each: about 2.6 × 10¹¹ multiply-adds per request. That's on the order of seconds of CPU time per request, and you have 116,000 requests a second. Exact search is out.
Approximate nearest neighbor (ANN) indexes trade a little recall for orders of magnitude less work. Libraries and databases such as Faiss, ScaNN, Milvus and Vespa implement several kinds. The most common is HNSW (Hierarchical Navigable Small World):
ef parameter) to improve recall.ef and the number of links per node.At a billion items, memory is the real constraint. Raw vectors are about 1 TB, and the graph links add more: with 32 links per node at 4 bytes each, another 10⁹ × 32 × 4 B ≈ 128 GB. Two common responses:
Most inner-product search is done this way too: normalize the vectors so maximum dot product and nearest neighbor by cosine are the same thing, or apply a small transform that turns one into the other.
One embedding model has blind spots, so production systems merge candidates from several sources. An example split, as an assumption:
The sources overlap, so after removing duplicates and already-watched items the union is somewhat under 1,000 candidates. Each source can be dropped if it's slow, which also helps availability.
With about 1,000 candidates, the ranker can afford a much richer model: hundreds of features per (user, item) pair, including cross features such as "how often this user finishes videos from this creator".
A ranker trained only on click-through rate learns to promote clickbait: a sensational thumbnail gets the click, the viewer leaves after a few seconds, and over time people trust the feed less. So rankers predict several outcomes at once:
The final score combines them with weights the product team tunes through A/B tests:
score = w₁ × P(click) + w₂ × P(long watch) + w₃ × P(like) − w₄ × P(dislike)
The weights encode what the product values, and changing them is a product decision as much as a modeling one.
Training one network per objective wastes data, because the objectives share most of what they need to learn. Training one network with several output heads works, but objectives that conflict (clicks vs long watches) pull the shared layers in different directions. Multi-gate Mixture-of-Experts (MMoE), from Google (Ma et al., 2018), sits in between:
Tasks that agree end up using the same experts; tasks that conflict can lean on different ones. YouTube described using MMoE for multiple ranking objectives in its 2019 RecSys paper.
A ranking model is only as good as its features, and those features come from very different places:
| Feature kind | Examples | Computed by | Served from |
|---|---|---|---|
| User profile (changes rarely) | country, language, account age | daily batch job | online feature store |
| Item metadata and counters (slow) | duration, category, creator, 30-day views | hourly or daily batch job | online feature store |
| Real-time (fast) | topics the user clicked in the last 10 minutes, an item's CTR in the last hour | stream processor (Flink or similar) | online feature store, updated within seconds |
A feature store framework such as Feast keeps one definition of each feature and fills both stores from it. That prevents training-serving skew, where the value a model saw in training is computed differently from the value it sees in production, so a model that looked good offline underperforms live.
The ranker sorts the candidates and passes the top 100 to the re-ranker. Two problems remain. If you just take the top 20, you may get 20 clips of the same match from the same channel, because similar items get similar scores. And new items, which have no engagement history, rarely score high enough to be shown, so they never collect the history they'd need. That second one is the cold-start problem.
Maximal Marginal Relevance (MMR) builds the feed one slot at a time. At each step it picks, from the candidates not yet chosen, the one with the best balance of relevance and novelty:
next item = the candidate d, not yet selected, that maximizes
λ × relevance(d) − (1 − λ) × max similarity(d, s) over selected items s
With 100 candidates and 20 slots, MMR does at most 20 × 100 × 20 = 40,000 similarity checks, which is trivial. Production systems often add simple hard rules on top: no more than two items from one creator in the first ten slots, for example.
To give new items a fair chance, the re-ranker treats some slots as an experiment. This is the multi-armed bandit problem: each item is an arm, and you balance showing what you know works (exploitation) against learning about what you don't (exploration). Two common methods:
Upper confidence bound (UCB1). Score each item by its observed engagement rate plus a bonus that shrinks as it's shown more:
UCB score(i) = μ̂ᵢ + c × √(ln N ÷ nᵢ)
Here μ̂ᵢ is item i's observed engagement rate, nᵢ is how many times it has been shown, N is the total number of impressions across the items being compared, and c sets how much to explore. An item with few impressions gets a large bonus; as nᵢ grows the bonus fades and the item stands on its measured rate.
Thompson sampling. Keep a probability distribution over each item's true engagement rate (for example a Beta distribution updated with clicks and non-clicks), draw one sample per item, and rank by the samples. Uncertain items sometimes draw high values and get shown.
In practice exploration is budgeted: for example, one or two of the 20 slots, or a small share of traffic, go to exploration candidates. That fraction is a product choice, not a law. If people engage, the item's counters rise and it competes in normal ranking; if they don't, its bonus shrinks and it drops out.
Cost. Suppose the ranking model takes 0.1 ms per item. Scoring 10⁹ items is 10⁹ × 0.1 ms = 100,000 seconds, about 28 hours, for one request. Batching on accelerators cuts the per-item cost a lot, but you'd still be doing 116,000 × 10⁹ scorings a second at peak.
The two-tower model moves item scoring offline. Item vectors are computed in batch and indexed. At request time you run the user tower once and do an approximate nearest neighbor search, which visits a tiny fraction of the vectors. That cuts the pool to about 1,000, which the ranker can score with hundreds of features within its 35 ms budget.
Matrix factorization learns one vector per item ID from that item's interactions, so a brand-new item has no vector until it has history and the model is retrained. The item tower builds the vector from features (title, tags, creator, duration), so the new item gets a reasonable vector as soon as it's processed and can be retrieved right away. Its engagement-based features are still empty, so the ranker will score it cautiously; that's where bandit exploration in re-ranking gives it impressions to learn from.
The offline store keeps the full history of features and events, often terabytes or more, in object storage and a warehouse. It's built for big scans when training models, and queries take seconds to minutes. The online store keeps only the latest value of each feature, keyed by user or item ID, in memory, so a batched lookup for a thousand items takes a few milliseconds. It can't answer questions about history.
Training needs the history, and serving needs the speed. Defining each feature once and filling both stores from that definition prevents training-serving skew: the model is served the same features it was trained on, computed the same way.
MMR picks items one at a time. The first pick is the highest-scoring item. For each later slot, every remaining candidate's relevance is reduced by its similarity to the most similar item already picked. A second clip of the same tennis match has high similarity to the first, so it takes a large penalty, and a slightly less relevant item on another topic can beat it.
λ sets the balance. At λ = 1 there's no penalty and you get the ranker's order. As λ falls, similarity costs more and the feed gets more varied. Teams tune it with A/B tests against engagement and retention.
Degrade instead of failing. Set a deadline on the ranking call; if it misses, return the candidates ordered by their retrieval scores, or a cached recent feed for the user, after a light re-rank. Shed ranking load further by scoring fewer candidates per request (say 300 instead of 1,000). Then scale the fleet. Users notice a slightly less relevant feed far less than they notice an error.
Previous: Design a web search engine · Next: Design a distributed key-value store