09 — Two-Tower Retrieval: The Workhorse of Modern Candidate Generation
“YouTube, Pinterest, Amazon, Spotify, ByteDance — they all run two towers.”
Introduction
Modern recommenders are multi-stage pipelines: retrieval narrows a billion-item catalog to hundreds; ranking picks the final order. The dominant retrieval architecture is the two-tower neural network — a user encoder and an item encoder that produce embeddings whose dot product approximates relevance. With an ANN index (Blog 16), this scales to billions of items at single-digit-millisecond latency.
1. The Two-Tower Idea
flowchart LR
A[User features<br/>history, demographics, context] --> B[User Tower<br/>DNN]
C[Item features<br/>id, content, popularity] --> D[Item Tower<br/>DNN]
B --> E[u in R^d]
D --> F[v in R^d]
E -->|dot product| G[score]
F --> G
At training time both towers update. At serving time:
- Item tower runs offline for the entire catalog → embeddings indexed in ANN (FAISS/ScaNN/HNSW).
- User tower runs online per request → produces query vector.
- ANN search returns top-K candidates.
2. The Loss: Sampled Softmax
| The objective is to maximize $p(\text{positive item} | u)$ out of all items. Full softmax is intractable when $ | I | > 10^6$. Solutions: |
2.1 In-Batch Negatives
Treat all other items in the mini-batch as negatives:
\[\mathcal L = -\log \frac{e^{u^\top v^+}}{\sum_{v \in \text{batch}} e^{u^\top v}}\]Fast, but biased toward popular items (which appear in batches more often). Fix via logQ correction (Yi et al. 2019 — YouTube):
\[s'_{ij} = u_i^\top v_j - \log Q(v_j)\]where $Q(v_j)$ is the sampling probability (estimated by streaming counts).
2.2 Sampled Softmax / Negative Sampling
Sample $k$ items per positive from a proposal distribution (uniform or popularity^0.75).
2.3 Triplet / Margin Losses
Pairwise hinge: \(\mathcal L = \max(0, m - u^\top v^+ + u^\top v^-)\)
Often used with hard-negative mining for fine-tuning after warm in-batch training.
3. Architecture Variants
| Variant | Difference |
|---|---|
| YouTubeDNN (2016) | Single user tower; output layer = softmax over items |
| MIND (Alibaba 2019) | User has multiple interest vectors via capsules |
| ComiRec (Cen 2020) | Multi-interest via self-attention |
| Mixed-negative-sampling (Yang 2020) | Combines in-batch + uniform sampled |
| DAT (Yu 2021) | Dual augmented two-tower for sparse signals |
4. PyTorch Implementation Skeleton
import torch, torch.nn as nn, torch.nn.functional as F
class Tower(nn.Module):
def __init__(self, n_ids, n_dense, d=64, hidden=(256, 128)):
super().__init__()
self.id_emb = nn.Embedding(n_ids, 32)
in_dim = 32 + n_dense
layers = []
for h in hidden:
layers += [nn.Linear(in_dim, h), nn.ReLU(), nn.Dropout(0.1)]
in_dim = h
layers += [nn.Linear(in_dim, d)]
self.mlp = nn.Sequential(*layers)
def forward(self, ids, dense):
x = torch.cat([self.id_emb(ids), dense], 1)
return F.normalize(self.mlp(x), dim=-1) # unit vectors → cosine
class TwoTower(nn.Module):
def __init__(self, n_users, n_items, d=64):
super().__init__()
self.user_tower = Tower(n_users, n_dense=4, d=d)
self.item_tower = Tower(n_items, n_dense=4, d=d)
def forward(self, uid, udense, iid, idense):
u = self.user_tower(uid, udense)
v = self.item_tower(iid, idense)
return u, v
def in_batch_loss(u, v, temperature=0.07):
logits = (u @ v.T) / temperature # B x B; diagonal = positives
labels = torch.arange(u.size(0), device=u.device)
return F.cross_entropy(logits, labels)
For logQ correction, subtract $\log \hat Q(j)$ from each column before softmax.
5. Serving Pipeline
flowchart TB
A[Offline: nightly] --> B[Item Tower Inference<br/>all items]
B --> C[ANN Index Build<br/>FAISS / ScaNN]
C --> D[Serving Cluster<br/>read-only]
E[Online request] --> F[User Tower Inference]
F --> G[Query Vector]
G --> H[ANN Search top-K]
H --> I[Ranking Stage]
- Item embeddings rebuilt daily, hot items hourly.
- User tower is small enough (a few hundred KFLOPs) to run on CPU.
- ANN index served sharded across nodes; brute-force fallback for new items.
6. Pros & Cons
| Pros | Cons |
|---|---|
| Scales to billions of items | Limited expressivity (no late interaction) |
| Decouples user/item inference | Loses fine-grained feature crosses |
| Pluggable into any ANN | Sampling bias if not corrected |
| Easy multi-task extension | Cold start needs content-rich item tower |
7. Training Tips
- Normalize embeddings; use cosine + temperature instead of raw dot product.
- Temperature $\tau \in [0.05, 0.2]$ — critical hyperparam.
- Mix easy + hard negatives (Yang 2020) for stable training.
- Stop-gradient on item tower for the negative branch of contrastive loss can stabilize.
- Use gradient cache (Gao 2021) to scale batch size beyond GPU memory — bigger batch = better in-batch negatives.
8. Cold-Start Friendly Design
Item tower should never depend solely on item_id. Include:
- Text/title embeddings
- Image embeddings (CLIP)
- Category, brand, price
- Time-since-creation
A new item gets a useful embedding the moment it’s published.
9. End-to-End: MovieLens Two-Tower with TF-Recommenders
# pip install tensorflow tensorflow-recommenders
import tensorflow as tf
import tensorflow_recommenders as tfrs
import pandas as pd
ratings = pd.read_csv("ratings.csv") # MovieLens 25M
ds = tf.data.Dataset.from_tensor_slices({
"user_id": ratings["userId"].astype(str).values,
"movie_id": ratings["movieId"].astype(str).values,
}).batch(4096)
user_vocab = tf.keras.layers.StringLookup()
user_vocab.adapt(ratings["userId"].astype(str).unique())
item_vocab = tf.keras.layers.StringLookup()
item_vocab.adapt(ratings["movieId"].astype(str).unique())
class UserTower(tf.keras.Model):
def __init__(self):
super().__init__()
self.emb = tf.keras.Sequential([user_vocab, tf.keras.layers.Embedding(user_vocab.vocab_size(), 64)])
def call(self, x): return self.emb(x)
class ItemTower(tf.keras.Model):
def __init__(self):
super().__init__()
self.emb = tf.keras.Sequential([item_vocab, tf.keras.layers.Embedding(item_vocab.vocab_size(), 64)])
def call(self, x): return self.emb(x)
item_ds = ds.map(lambda x: x["movie_id"])
task = tfrs.tasks.Retrieval(metrics=tfrs.metrics.FactorizedTopK(item_ds.batch(128).map(ItemTower())))
class Model(tfrs.Model):
def __init__(self):
super().__init__()
self.user = UserTower()
self.item = ItemTower()
self.task = task
def compute_loss(self, x, training=False):
return self.task(self.user(x["user_id"]), self.item(x["movie_id"]))
m = Model()
m.compile(optimizer=tf.keras.optimizers.Adagrad(0.1))
m.fit(ds, epochs=3)
10. Pitfalls
- No log-Q correction → biased toward popular items.
- Item embeddings staled because retraining frequency too low.
- Forgetting to L2-normalize before pushing to ANN — distance metric mismatch.
- Coupling user features that change every request (e.g., current query) into the offline-cached embedding.
- Different feature pipelines for training vs serving — feature store solves this (Blog 22).
11. Public Datasets
- MovieLens — https://grouplens.org/datasets/movielens/ (great for TF-Recommenders demos)
- Amazon Reviews 2018 — https://nijianmo.github.io/amazon/ (rich item features)
- Yelp — https://www.yelp.com/dataset
- MIND News — https://msnews.github.io/ (large, two-tower friendly)
- Spotify MPD — https://www.aicrowd.com/challenges/spotify-million-playlist-dataset-challenge
12. Further Reading
- Covington et al., Deep Neural Networks for YouTube Recommendations (RecSys 2016) — foundational
- Yi et al., Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations (RecSys 2019)
- Yang et al., Mixed Negative Sampling for Learning Two-Tower Neural Networks in Recommendations (WWW 2020)
- Li et al., MIND: Multi-Interest Network with Dynamic Routing (CIKM 2019)
- Karpukhin et al., Dense Passage Retrieval (DPR) (EMNLP 2020) — related QA two-tower
- Gao et al., GradCache: Scaling Deep Contrastive Learning (2021)