Purpose

Two-tower retrieval is the workhorse first stage in large recommendation systems. One tower encodes the user or query. The other encodes the item. The score is usually a dot product. That sounds restrictive. It is restrictive. The reason people use it anyway is that the restriction buys a serving path that scales.

The Basic Form

Let be the user embedding and the item embedding. Retrieval uses

or a nearby similarity such as cosine similarity.

At training time, both towers are learned jointly from historical interactions. At serving time, item embeddings are precomputed and indexed. A request only has to:

  1. build the user embedding
  2. query an ANN index
  3. return the top items

That turns catalog-sized search into sublinear nearest-neighbor search.

flowchart TD
    subgraph UT["User tower (online)"]
        UF["User features<br/>history, queries, context"] --> UE["Encoder f"]
        UE --> UV["User embedding f(u)"]
    end

    subgraph IT["Item tower (offline)"]
        IF["Item features<br/>ID, metadata, content"] --> IE["Encoder g"]
        IE --> IV["Item embeddings g(i)"]
    end

    IV -->|precompute and index| ANN["ANN index"]
    UV -->|"dot product f(u)·g(i)"| ANN
    ANN --> K["Top-k candidates"]

    style UT fill:#e3f2fd,stroke:#1565c0
    style IT fill:#e8f5e9,stroke:#2e7d32

Why It Became the Default

The main gain is decomposition. The user and item encoders can be evaluated separately. Since the item tower is offline, online retrieval costs almost nothing beyond user encoding and ANN lookup.

That is the key systems trade:

  • richer cross-feature interactions are sacrificed
  • catalog-scale retrieval becomes feasible

If the item tower had to read user features at scoring time, or the user tower had to read item features for every candidate, the whole trick would collapse.

Training

There are two common training views, and they are closer than they look.

Softmax View

The YouTube paper frames candidate generation as multiclass classification over the video vocabulary. The model predicts which next item the user will consume, usually with sampled softmax because the full catalog is too large.

Contrastive View

The same setup can be read as contrastive learning. Given a positive pair and sampled negatives , make the positive item’s dot product larger than the negatives’.

In both cases the model learns an embedding space where relevant items sit near the user vector.

Feature Design

The user tower often absorbs:

  • recent interactions
  • search queries
  • demographics or locale
  • device and temporal context

The item tower often absorbs:

  • item ID
  • metadata
  • text
  • image or audio features
  • creator or taxonomy information

The tension is always the same. The more context you want in the score, the more pressure there is to break the tower decomposition. Once you break it too much, retrieval stops being cheap.

What It Gives Up

Two-tower models cannot express arbitrary user-item feature crosses at retrieval time. The score is bottlenecked through the embedding dimension and the similarity function. That means:

  • memorized exception rules are hard
  • calibration is usually poor
  • business constraints are awkward to encode directly
  • exposure balancing and multi-objective tradeoffs are usually deferred to later stages

This is why first-stage retrieval and final ranking are almost never the same model.

Hard Negatives Matter

A weak negative sampler teaches the model only coarse separation. The embeddings become good at telling a watched item from random catalog junk, while still failing to separate one plausible item from another plausible item.

The common fix is to train with harder negatives:

  • in-batch negatives
  • co-exposed but skipped items
  • items retrieved by a previous model and rejected downstream
  • semantically close items

Very hard negatives become false negatives

Items that are semantically close to the positive, or co-exposed and skipped, are often items the user would have liked if they had noticed them. Training against them as negatives pushes apart pairs that should stay close.

The downside is that very hard negatives can become false negatives. You can end up pushing apart items the user would have liked if they had seen them.

How It Fits the Pipeline

Two-tower retrieval is usually not asked to produce the final order. Its job is recall. A good retrieval stage finds most of the items the ranker would have wanted, while staying inside the latency budget.

That changes how you judge it. Absolute calibration barely matters here. Candidate recall, diversity of candidate sources, and tail coverage matter a lot more.

Executable Experiment

The companion notebook turns MovieLens 100K ratings into implicit positives, trains user and item towers with sampled negatives, and evaluates retrieval with Recall at . It also inspects nearest items in the learned embedding space.

Goal

Build a small retrieval model on the real MovieLens 100K data set. We will treat ratings of at least (4) as positive implicit feedback and train a two-tower model with sampled negatives.

The scoring function is the dot product

where (q_u) is the user embedding and (k_i) is the item embedding. Retrieval quality will be measured with Recall@K and MRR@K on a held-out item per user.

In [1]:
## %pip install -q pandas numpy torch matplotlib
 
from pathlib import Path
from urllib.request import urlopen
 
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
 
SEED = 7
np.random.seed(SEED)
_ = torch.manual_seed(SEED)
In [2]:
repo_root = next((path for path in [Path.cwd(), *Path.cwd().parents] if (path / ".git").exists()), Path.cwd())
root = repo_root / "work/notebook-data/movielens-100k"
root.mkdir(parents=True, exist_ok=True)
 
ratings_url = "https://files.grouplens.org/datasets/movielens/ml-100k/u.data"
items_url = "https://files.grouplens.org/datasets/movielens/ml-100k/u.item"
 
ratings_path = root / "u.data"
items_path = root / "u.item"
 
for url, path in [(ratings_url, ratings_path), (items_url, items_path)]:
    if not path.exists():
        path.write_bytes(urlopen(url).read())
 
ratings = pd.read_csv(
    ratings_path,
    sep="\t",
    names=["user_id", "item_id", "rating", "timestamp"],
    encoding="latin-1",
)
 
items = pd.read_csv(
    items_path,
    sep="|",
    header=None,
    encoding="latin-1",
    usecols=[0, 1],
    names=["item_id", "title"],
)
 
ratings = ratings.merge(items, on="item_id", how="left")
ratings.head()
Out[2]:
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
user_iditem_idratingtimestamptitle
01962423881250949Kolya (1996)
11863023891717742L.A. Confidential (1997)
2223771878887116Heavyweights (1994)
3244512880606923Legends of the Fall (1994)
41663461886397596Jackie Brown (1997)
In [3]:
positives = ratings[ratings["rating"] >= 4].copy()
positives = positives.sort_values(["user_id", "timestamp"])
 
heldout = positives.groupby("user_id").tail(1)
train = positives.drop(index=heldout.index)
 
user_ids = sorted(positives["user_id"].unique())
item_ids = sorted(positives["item_id"].unique())
user_to_idx = {u: i for i, u in enumerate(user_ids)}
item_to_idx = {i: j for j, i in enumerate(item_ids)}
 
train_user = torch.tensor(train["user_id"].map(user_to_idx).to_numpy(), dtype=torch.long)
train_item = torch.tensor(train["item_id"].map(item_to_idx).to_numpy(), dtype=torch.long)
test_pairs = list(
    zip(
        heldout["user_id"].map(user_to_idx).to_numpy(),
        heldout["item_id"].map(item_to_idx).to_numpy(),
    )
)
 
positives_by_user = {
    user_to_idx[u]: set(group["item_id"].map(item_to_idx).tolist())
    for u, group in positives.groupby("user_id")
}
 
num_users = len(user_ids)
num_items = len(item_ids)
print(f"train positives={len(train):,} heldout users={len(test_pairs):,}")
Out[3]:
train positives=54,433 heldout users=942
In [4]:
class TwoTower(nn.Module):
    def __init__(self, num_users: int, num_items: int, dim: int = 64):
        super().__init__()
        self.user = nn.Embedding(num_users, dim)
        self.item = nn.Embedding(num_items, dim)
        self.user_bias = nn.Embedding(num_users, 1)
        self.item_bias = nn.Embedding(num_items, 1)
 
    def score(self, users, items):
        dot = (self.user(users) * self.item(items)).sum(dim=-1)
        bias = self.user_bias(users).squeeze(-1) + self.item_bias(items).squeeze(-1)
        return dot + bias
In [5]:
device = "cuda" if torch.cuda.is_available() else "cpu"
model = TwoTower(num_users, num_items, dim=64).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4)
 
batch_size = 2048
steps = 2500
losses = []
 
train_user_device = train_user.to(device)
train_item_device = train_item.to(device)
 
for step in range(steps):
    idx = torch.randint(0, len(train_user_device), (batch_size,), device=device)
    u = train_user_device[idx]
    pos_i = train_item_device[idx]
    neg_i = torch.randint(0, num_items, (batch_size,), device=device)
 
    pos_score = model.score(u, pos_i)
    neg_score = model.score(u, neg_i)
    logits = torch.cat([pos_score, neg_score], dim=0)
    labels = torch.cat([torch.ones_like(pos_score), torch.zeros_like(neg_score)], dim=0)
 
    loss = F.binary_cross_entropy_with_logits(logits, labels)
    loss_value = loss.item()
    opt.zero_grad()
    loss.backward()
    opt.step()
    losses.append(loss_value)
 
    if step % 250 == 0:
        print(f"step={step:04d} loss={loss_value:.4f}")
Out[5]:
step=0000 loss=3.2362
step=0250 loss=2.2360
step=0500 loss=1.4879
step=0750 loss=0.9925
step=1000 loss=0.7536
step=1250 loss=0.5870
step=1500 loss=0.5085
step=1750 loss=0.4019
step=2000 loss=0.3955
step=2250 loss=0.3531
In [6]:
def evaluate(model: TwoTower, test_pairs, positives_by_user, k: int = 10):
    user_emb = model.user.weight.detach()
    item_emb = model.item.weight.detach()
    user_bias = model.user_bias.weight.detach().squeeze(-1)
    item_bias = model.item_bias.weight.detach().squeeze(-1)
 
    recalls = []
    rr = []
    for u, target in test_pairs:
        scores = user_emb[u] @ item_emb.T + user_bias[u] + item_bias
        seen = positives_by_user[u] - {target}
        if seen:
            scores[list(seen)] = -1e9
        topk = scores.topk(k).indices.tolist()
        if target in topk:
            recalls.append(1.0)
            rr.append(1.0 / (topk.index(target) + 1))
        else:
            recalls.append(0.0)
            rr.append(0.0)
    return float(np.mean(recalls)), float(np.mean(rr))
 
 
recall_at_10, mrr_at_10 = evaluate(model.cpu(), test_pairs, positives_by_user, k=10)
print({"recall@10": recall_at_10, "mrr@10": mrr_at_10})
Out[6]:
{'recall@10': 0.041401273885350316, 'mrr@10': 0.01159682202675833}
In [7]:
plt.figure(figsize=(8, 4))
plt.plot(losses)
plt.title("Two-tower retrieval training loss")
plt.xlabel("step")
plt.ylabel("BCE loss")
plt.show()
Out[7]:
<Figure size 800x400 with 1 Axes>
Notebook output
In [8]:
title_to_item = {row.title: item_to_idx[row.item_id] for row in items.itertuples() if row.item_id in item_to_idx}
item_lookup = {item_to_idx[row.item_id]: row.title for row in items.itertuples() if row.item_id in item_to_idx}
 
anchor = "Star Wars (1977)"
anchor_idx = title_to_item[anchor]
anchor_vec = model.item.weight.detach()[anchor_idx]
sims = model.item.weight.detach() @ anchor_vec
top = sims.topk(10).indices.tolist()
[item_lookup[i] for i in top]
Out[8]:
['Star Wars (1977)',
 'Return of the Jedi (1983)',
 'War, The (1994)',
 'Alien (1979)',
 'Miracle on 34th Street (1994)',
 'Rosencrantz and Guildenstern Are Dead (1990)',
 'Happy Gilmore (1996)',
 'Prophecy II, The (1998)',
 'Mother (1996)',
 'Delicatessen (1991)']

Where To Push Next

This notebook is the retrieval stage only. Good follow-ons:

  1. Add side features from u.user and genre indicators from u.item.
  2. Replace pointwise BCE with BPR or sampled softmax.
  3. Add a reranker and compare Recall@K against NDCG@K.

When Not to Use It

Do not force a two-tower model into places where the business needs rich slate-level reasoning or precise feature interactions. If the catalog is small enough to score directly with a richer model, or if every decision depends on inventory, auction dynamics, or complex constraints, the tower decomposition may cost more than it saves.

Sources