ml-models / Retrieval and recommendation / Two-tower models core

Two-tower models

Recommending from ten million items means scoring ten million items, for every user, in milliseconds. A two-tower model makes that possible by encoding the query and the item separately, so every item vector can be computed in advance and searched with a vector index. This page covers the two encoders, training them with in-batch negatives and the sampled softmax (with its popularity correction), hard negatives, and the retrieve-then-rank pipeline they feed.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Separate query and item encoders
  5. In-batch negatives and the sampled softmax
  6. Popularity bias and the logQ correction
  7. Hard negatives
  8. Serving with a vector index
  9. Retrieval, then ranking
  10. Worked example
  11. Two towers on five items
  12. The in-batch softmax
  13. The logQ correction
  14. One hard negative
  15. Serving cost
  16. Implementation
  17. Tradeoffs
  18. Connections
  19. Read first
  20. Read next
  21. References

Problem

Matrix factorization learns one vector per user and one per item from the interaction matrix, and scores a pair with a dot product. That is fast, but it knows nothing beyond IDs: a new user or item has no row, and the user's recent history, the time of day, an item's text or category cannot be used. A model that reads all of those features and scores a (user, item) pair jointly, with a ranking network, is far more accurate, and far too slow to run against the whole catalogue: at 2,000,000 multiply-adds per pair, 10,000,000 items cost 20,000,000,000,000 multiply-adds per request.

The way out is to give up on modelling the pair jointly for a first, cheap stage: score with a dot product between a query vector and an item vector, where each vector is a rich function of its own side's features. Then item vectors are fixed between model updates, and finding the best ones is nearest-neighbour search.

Intuition

Two networks, two "towers", map different kinds of input into one shared embedding space. The query tower reads who is asking and in what context: user ID, the items they recently watched, their language, the time. The item tower reads what an item is: its ID, title, category, age. Training pulls each observed (query, item) pair together and pushes the query away from other items, so that geometric closeness comes to mean "this person would engage with this item".

The only place the two sides meet is the final dot product. That restriction is what makes serving cheap, and it is also the model's main weakness: no feature of the query can change how an item's features are read. So two-tower models do retrieval, narrowing millions to hundreds, and a heavier ranker that may cross every feature does the final ordering of those few.

Two towers meeting only at a dot product Left, the query tower: user ID, a bag of recently watched item IDs averaged by EmbeddingBag, and context features are concatenated, passed through an MLP and L2-normalized into a vector q. Right, the item tower: item ID, category and text features go through an Embedding and an MLP and are L2-normalized into a vector v. The two vectors meet only in the dot product q dot v, divided by a temperature, which is the score. Item vectors can be computed offline for the whole catalogue. user ID Embedding watch history EmbeddingBag context time, device concat → MLP query tower q = normalize(·) d numbers, online item ID Embedding category Embedding title text encoder concat → MLP item tower v = normalize(·) d numbers, offline q · v / τ score no other path between the two sides

Mechanics

Separate query and item encoders

Each tower is an ordinary network. Categorical features enter through nn.Embedding, a lookup table of shape [num_embeddings, embedding_dim] initialized from a standard normal. Variable-length sets of IDs, such as a watch history, enter through nn.EmbeddingBag, which looks up every ID in a bag and reduces them with mode="mean" (the default), "sum" or "max" without materializing the per-ID vectors. The pooled and dense features are concatenated and passed through an MLP, and the output is usually L2-normalized with F.normalize, so the dot product is a cosine similarity in [−1, 1].

The score is s(q, v) = q · v / τ. With normalized vectors the temperature τ sets how sharp the softmax over items can get; values around 0.05 are common. The towers need not share anything; when both sides are the same kind of object (two sentences, a question and a passage) they often share weights, as in DSSM or DPR's dual encoder.

In-batch negatives and the sampled softmax

Training data is usually implicit: a log of (query, item) pairs that happened, with no record of what the user disliked. The natural objective treats retrieval as classification over the whole catalogue, a softmax over every item with the observed one as the label:

L = −log [ exp s(q, v+) / Σj ∈ catalogue exp s(q, vj) ]

The denominator runs over millions of items, each needing a pass through the item tower. A sampled softmax replaces it with the positive plus a sample of negatives. The cheapest sample is free: in-batch negatives. A batch of B pairs gives B query vectors and B item vectors; the B × B matrix of scores has the positives on its diagonal, and every off-diagonal entry is another query's positive, used as a negative. The loss is F.cross_entropy on that matrix with targets 0, 1, …, B − 1. Each query gets B − 1 negatives for the cost of one matrix multiply, so larger batches give a better approximation of the full softmax. This is the InfoNCE loss of contrastive learning.

Popularity bias and the logQ correction

In-batch negatives are not a uniform sample: an item appears in the batch as often as it appears in the log, so popular items are used as negatives far more than rare ones and the model learns to under-score them. Yi et al. correct the sampled softmax by subtracting the log of each item's sampling probability from its logit:

sc(q, vj) = s(q, vj) − log pj

where pj is the probability that item j appears in a batch, estimated from its frequency in the training stream. Rare items get a large boost and popular items a small one. This is the importance-sampling correction long used for sampled softmax (Jean et al.), which removes the sampling bias from the estimate of the full softmax. The correction is applied only in training; at serving time the raw score s is used. A related problem is the false negative: if the same item is the positive for two queries in a batch, each query's positive also appears as its own negative, so duplicates are usually masked out.

Hard negatives

Most random negatives are easy: their score is already far below the positive, their softmax probability is close to zero, and since the gradient of the loss with respect to a negative's logit is exactly that probability, they teach almost nothing. Hard negatives are items the model currently scores high that are not the positive: same category, retrieved by the current index, or retrieved by a lexical method like BM25. DPR added one BM25 hard negative per question to its in-batch negatives; ANCE mines them from an index of the model being trained, refreshed asynchronously. The risk is that many "hard negatives" are unlabeled positives, so the hardest ones are often skipped, and training on only hard negatives tends to collapse; mixing them with easy in-batch negatives is the usual recipe.

Serving with a vector index

After training, run the item tower over the whole catalogue once and load the vectors into a vector index. A request runs the query tower once, then asks the index for the top k items by inner product. Normalizing both sides makes maximum inner product equal to maximum cosine, which every index supports. Exact search over N items costs N · d multiply-adds; IVF, HNSW and product quantization trade a little recall for orders of magnitude less work and memory. Item vectors are recomputed when the item tower is retrained or an item's features change; new items need only a forward pass, not retraining, which is how two-tower models soften the cold-start problem of ID-only factorization.

Retrieval, then ranking

Covington et al.'s YouTube system split recommendation into candidate generation, which narrows millions of videos to hundreds, and ranking, which scores those hundreds with many more features and a different objective. Most large systems keep that funnel, often with several retrievers (two-tower, co-occurrence, popular in your region) merged before a ranker and a final re-ranking stage for diversity and business rules. Each stage is judged differently: retrieval by recall@k (is the item the user wanted among the candidates at all?), ranking by NDCG or similar on the order.

ComponentShape/ParamsMeaningSource
nn.Embeddingweight [num_embeddings, embedding_dim]ID lookup table for users, items, categories.sparse.py 14–264
Embedding.__init__padding_idx, max_norm, sparse=Falsesparse=True gives sparse gradients for huge tables.134–177
Embedding.reset_parametersinit.normal_(self.weight)Default init N(0, 1); the padding row is zeroed.179–181
Embedding.forwardIDs [*] → [*, embedding_dim]Calls F.embedding.188–197
nn.EmbeddingBagmode="mean" by defaultPools a variable-length bag of IDs, such as a watch history.sparse.py 267–543
EmbeddingBag.forward(input, offsets=None, per_sample_weights=None)Flat IDs plus bag start offsets → [bags, dim].425–473
F.embedding(input, weight, padding_idx, max_norm, …)The lookup itself; renormalizes rows in place if max_norm is set.functional.py 2509–2621
F.embedding_bagmode, offsets, per_sample_weightsLookup and reduce in one kernel.2624–2839
F.normalizep=2.0, dim=1, eps=1e-12v / max(‖v‖, ε): makes dot products cosines.6143–6177
F.cross_entropylogits [B, B], targets arange(B)The in-batch softmax loss.3478–3571
Serving: offline item vectors, online query, retrieve then rank Top row, offline: the item tower runs over the whole catalogue of ten million items, and the vectors are built into an approximate nearest-neighbour index. Bottom row, per request: the query tower produces q, the index returns about a thousand candidates by inner product, a ranker that sees query and item features together scores those, and a re-ranker chooses the ten shown. Each stage handles fewer items with a more expensive model. offline, when the item tower or catalogue changes catalogue 10,000,000 items item tower batch inference ANN index IVF / HNSW / PQ per request, in milliseconds query tower one forward q top-k search max q · v 1,000 candidates recall@k matters ranker crosses all features 10 shown after re-rank 10,000,000 → 1,000 → 10: each stage sees fewer items with a costlier model

Worked example

Every number below comes from tested helpers in ml_models_docs.py (tt_bag_mean, tt_normalize, tt_in_batch, tt_full_softmax, tt_hard_negative, tt_serving). The towers are tiny and hand-set so each step can be checked; the serving numbers are an invented, labelled configuration, not a measured system.

Two towers on five items

The item tower is an embedding table followed by normalization; its five 2-d rows are already unit length. The query tower averages a separate history table over each user's watched items, like EmbeddingBag(mode="mean"), then normalizes. τ = 0.2.

UserHistoryMean of history rowsq (normalized)Positive item
u0i2, i4[1.5, -0.5][0.95, -0.32]i4 = [0.8, -0.6]
u1i1, i3[-0.5, 1.5][-0.32, 0.95]i1 = [0, 1]
u2i0, i2[1.5, 0.5][0.95, 0.32]i0 = [1, 0]
u3i3[-1, 1][-0.71, 0.71]i3 = [-0.8, 0.6]

The in-batch softmax

The batch is the four (user, positive) pairs. Its score matrix has one row per query and one column per positive item in the batch, q · v / τ; the diagonal is the label. Each cell shows the logit and, below it, the softmax probability across the row.

Queryi4i1i0i3Loss −log pdiag
u04.74
0.500
-1.58
0.001
4.74
0.500
-4.74
0.000
0.694
u1-4.11
0.000
4.74
0.652
-1.58
0.001
4.11
0.347
0.427
u22.85
0.126
1.58
0.035
4.74
0.838
-2.85
0.000
0.176
u3-4.95
0.000
3.54
0.196
-3.54
0.000
4.95
0.804
0.218

The mean in-batch loss is 0.379. Over the full five-item catalogue the same queries give 0.502: the batch is missing i2, which is no query's positive in this batch, so it is never used as a negative. With a catalogue of millions and a batch of thousands, the batch is always a small, biased sample of the denominator. Row u0 shows a tie: its history makes i4 and i0 equally similar, so the loss is about ln 2.

The logQ correction

Suppose the training log has these item frequencies, so in a batch of 1,024 pairs each item appears about B · p times and is a negative for nearly every query in it:

Itemplog pExpected appearances per batch of 1,024
i00.4-0.916409.6
i10.1-2.303102.4
i20.2-1.609204.8
i30.1-2.303102.4
i40.2-1.609204.8

Subtracting log p from each column's logit changes the toy batch's losses:

QueryPositive (p)Loss, plainLoss, corrected
u0i4 (0.2)0.6940.408
u1i1 (0.1)0.4270.426
u2i0 (0.4)0.1760.386
u3i3 (0.1)0.2180.218
Mean0.3790.360

u0's tie breaks toward its rarer positive i4 (loss 0.694 → 0.408): it no longer has to beat the popular i0 on raw score alone. u2's loss rises (0.176 → 0.386) because its positive is the popular item, whose logit gets the smallest boost; the larger gradient pushes that item's raw score up, offsetting how often it is pushed down as everyone else's negative.

One hard negative

For u2 (positive i0), mine i2, which the model scores 4.11, and add it as a fifth column:

QuantityValue
Mean softmax probability of u2's three in-batch negatives0.054
Softmax probability of the hard negative0.308
u2's loss with in-batch negatives only0.176
u2's loss with the hard negative added0.545

The gradient of the loss with respect to a negative's logit is its softmax probability, so this one negative carries about 6 times the signal of an average in-batch one. Adding it also makes u2's loss equal its full-catalogue loss, 0.545: here, the hard negative was the only item the batch was missing.

Serving cost

An invented but typical configuration: 20,000,000 users, 10,000,000 items, d = 64, an MLP of widths 256 → 128 → 64 per tower, and a ranker costing 2,000,000 multiply-adds per candidate.

QuantityValue
Parameters in the user and item ID tables1,920,000,000
Parameters in one tower's MLP41,152
Item vectors, float322.56 GB
Item vectors, PQ at 8 bytes each80 MB
Exact top-k: multiply-adds per query640,000,000
IVF, 4,096 lists, 32 probed: vectors scanned78,125
IVF: multiply-adds per query (centroids + scanned)5,262,144
Ranker on every item20,000,000,000,000
Ranker on 1,000 retrieved candidates2,000,000,000

Nearly all parameters are embedding rows, which is why recommendation models are memory-bound and use sparse gradients and sharded tables. Retrieval cuts the ranker's work 10,000-fold, and an IVF index cuts the retrieval itself about 122-fold more, at some loss of recall.

Why can't a two-tower model use a feature like "has this user already bought this item's brand?"

The feature depends on the query and the item together, and the towers only meet at the dot product, so neither tower can compute it. That is a cross feature, which belongs in the ranker; the retriever can only approximate it if brand affinity is encoded in both vectors.

Doubling the batch size from 1,024 to 2,048 changes what about in-batch training, and what does it leave alone?

Each query gets 2,047 negatives instead of 1,023, a closer approximation of the full softmax, for a score matrix four times larger. The negatives still follow item popularity, so the logQ correction is still needed.

Implementation

Both towers, the in-batch loss with the logQ correction and duplicate masking, and top-k retrieval. Item frequencies log_p are estimated from the training stream and passed in.

import torch
import torch.nn as nn
import torch.nn.functional as F


class QueryTower(nn.Module):
    def __init__(self, num_users, num_items, dim=64):
        super().__init__()
        self.user = nn.Embedding(num_users, dim)
        self.history = nn.EmbeddingBag(num_items, dim, mode="mean")
        self.mlp = nn.Sequential(nn.Linear(2 * dim, 128), nn.ReLU(), nn.Linear(128, dim))

    def forward(self, user_ids, history_ids, offsets):
        x = torch.cat([self.user(user_ids), self.history(history_ids, offsets)], dim=-1)
        return F.normalize(self.mlp(x), dim=-1)


class ItemTower(nn.Module):
    def __init__(self, num_items, num_categories, dim=64):
        super().__init__()
        self.item = nn.Embedding(num_items, dim)
        self.category = nn.Embedding(num_categories, dim)
        self.mlp = nn.Sequential(nn.Linear(2 * dim, 128), nn.ReLU(), nn.Linear(128, dim))

    def forward(self, item_ids, category_ids):
        x = torch.cat([self.item(item_ids), self.category(category_ids)], dim=-1)
        return F.normalize(self.mlp(x), dim=-1)


def in_batch_loss(q, v, item_ids, log_p, tau=0.05):
    logits = q @ v.T / tau - log_p[item_ids]
    same = item_ids[:, None] == item_ids[None, :]
    diagonal = torch.eye(len(q), dtype=torch.bool, device=q.device)
    logits = logits.masked_fill(same & ~diagonal, float("-inf"))
    return F.cross_entropy(logits, torch.arange(len(q), device=q.device))


@torch.no_grad()
def retrieve(query_tower, item_vectors, user_ids, history_ids, offsets, k=1000):
    q = query_tower(user_ids, history_ids, offsets)
    return (q @ item_vectors.T).topk(k, dim=-1)

retrieve is exact search; at catalogue scale item_vectors lives in an approximate index instead (vector indexes). log_p is subtracted only in the training loss. For tables with millions of rows, nn.Embedding(..., sparse=True) with a sparse-aware optimizer avoids updating every row on every step.

Tradeoffs

  • Separable scoring is what makes millisecond retrieval over millions of items possible, and it caps accuracy: no query–item interaction before the dot product. Late-interaction models (ColBERT) and rankers recover some of that at higher cost.
  • In-batch negatives are free but biased toward popular items and limited by batch size; logQ correction fixes the bias, and gradient caching or cross-device batches raise the count (see gradient accumulation).
  • Hard negatives speed learning and sharpen fine distinctions, and add false negatives and the cost of mining from a stale index.
  • Feature-based towers handle new users and items better than ID-only factorization, but ID embeddings still dominate for items with history, and their tables dominate memory.
  • Approximate indexes trade recall for latency; the index must be rebuilt or updated whenever the item tower changes, and query and item vectors must come from compatible model versions.
  • Training on logged interactions learns what the previous system chose to show, a feedback loop that retrieval of fresh or rare items needs exploration to break.

Connections

References

  • Covington, Adams and Sargin. Deep Neural Networks for YouTube Recommendations. RecSys 2016. doi:10.1145/2959100.2959190
  • Yi et al. Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations. RecSys 2019. doi:10.1145/3298689.3346996
  • Huang et al. Learning Deep Structured Semantic Models for Web Search using Clickthrough Data (DSSM). CIKM 2013. doi:10.1145/2505515.2505665
  • Jean et al. On Using Very Large Target Vocabulary for Neural Machine Translation (sampled softmax). ACL 2015. arXiv:1412.2007
  • van den Oord, Li and Vinyals. Representation Learning with Contrastive Predictive Coding (InfoNCE). 2018. arXiv:1807.03748
  • Karpukhin et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP 2020. arXiv:2004.04906
  • Xiong et al. Approximate Nearest Neighbor Negative Contrastive Learning for Dense Text Retrieval (ANCE). ICLR 2021. arXiv:2007.00808
  • Johnson, Douze and Jégou. Billion-scale similarity search with GPUs (Faiss). 2017. arXiv:1702.08734
  • Khattab and Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. SIGIR 2020. arXiv:2004.12832
  • PyTorch v2.15.0: Embedding, EmbeddingBag, normalize, cross_entropy.