Recommender Systems

Two-tower retrieval models

A two-tower model turns users and items into vectors in one shared space with two separate encoders, so item vectors can be computed once and the top matches found by nearest-neighbour search.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. Why "two towers"
  5. The best thing about it
  6. The honest part
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

A two-tower model places every person and every item as a point on one shared map, then recommends whatever is closest to you.

The analogy you have already lived

Walk into a big library. The books are arranged by subject: history in one corner, cookery in another, physics on the far wall. Nobody has to explain the layout — similar books sit near each other.

Now imagine the librarian looks at everything you have borrowed and puts a small marker for you on that same floor plan. Not on a separate map. The same one.

Finding your next book becomes a matter of walking to the nearest shelf. That is a two-tower model. Two different ways of deciding where something goes, both agreeing on one floor plan.

Why it exists

A large catalogue has millions of items. A person opens the app and expects a page in under a second.

Scoring every item for every visit with a big model is impossible. Even a small model, run ten million times per user per visit, is far too slow and far too expensive.

But watch what happens if you split the work in two.

The item's position on the map depends only on the item. So you can work it out once, overnight, for the whole catalogue, and store it.

The person's position depends only on the person. So you work that out once per visit, when they open the app.

Then the only live work is "which stored points are nearest to this one". That question has extremely fast answers, and it stays fast as the catalogue grows. See vector databases for how the search is done.

Why "two towers"

A tower here is a small network that reads one kind of input and produces a position on the map.

     who you are                          what the item is
   (history, city,                        (title, tags,
    time of day)                           language, creator)
         |                                       |
   [ user tower ]                          [ item tower ]
         |                                       |
         v                                       v
   your position  ------- closeness ------>  item position
                    (a plain dot product)

The two towers never see each other's input. They only meet at the very end, in a single cheap comparison. That separation looks like a limitation, and it is the entire reason the system is fast.

A model that read the user and the item together would be more accurate. It would also have to run once per item, which puts it back at ten million runs per visit. That richer model still gets used — later, on a shortlist of a few hundred. See what is a recommender system for where each stage sits.

The best thing about it

Look at the item tower again. It reads the item's tags and text, not the item's identity.

So a film added five minutes ago, that nobody has watched, still gets a position on the map. The tower has learned what "drama plus sports" means from thousands of other films, and it applies that knowledge to the new one immediately.

That is a direct answer to half of the cold-start problem, and it is why this design replaced plain matrix factorisation almost everywhere.

The honest part

Two towers meeting only at a dot product is a real constraint, not a free lunch.

The model can never learn a rule like "recommend an umbrella to this person only when it is raining and they are in Mumbai". Rules that need the user and the item considered together cannot fit in a design where each side is described separately.

So a two-tower model is deliberately built to be a good, fast filter rather than a good judge. Getting from a million items down to a few hundred is the job it is designed for. Picking the final order belongs to another model.

There is a second limitation the developer block makes visible. If two items have identical descriptions, the item tower gives them identical positions and cannot tell them apart. Real systems add a per-item learned code alongside the features to fix this, which brings back a smaller version of the cold-start problem.

Remember this

  • Two separate encoders put users and items on one shared map.
  • Item positions are computed once and stored; only the nearest-neighbour search is live.
  • Meeting only at a dot product is what makes it fast, and what limits what it can learn.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy

NumPy only, on purpose. Everything below is fifty lines of arithmetic, and writing the gradients by hand once makes the in-batch-negatives trick permanent in a way that loss.backward() never does. The PyTorch version is at the end.

A two-tower model with in-batch negatives

The user tower is a lookup: one learned vector per person. The item tower is a linear map from tags to a vector, so an item the model has never seen still gets a position.

two_tower.py
import numpy as np

USERS = ["Aarav", "Bhavna", "Chetan", "Divya", "Esha", "Farhan"]
FEATURES = ["drama", "horror", "comedy", "sports", "action", "fantasy"]
ITEMS = {                     # every item is described by tags, never by an id
    "Lagaan":    [1, 0, 0, 1, 0, 0],
    "Dangal":    [1, 0, 0, 1, 0, 0],
    "3 Idiots":  [1, 0, 1, 0, 0, 0],
    "Sholay":    [1, 0, 0, 0, 1, 0],
    "Tumbbad":   [0, 1, 0, 0, 0, 1],
    "Stree":     [0, 1, 1, 0, 0, 0],
    "Bhoot":     [0, 1, 0, 0, 1, 0],
    "Naya-Film": [1, 0, 0, 1, 0, 0],      # added after training, never seen by the model
}
TITLES = list(ITEMS)
F = np.array([ITEMS[t] for t in TITLES], dtype=float)

# (user, item) pairs the model may learn from. Naya-Film appears in none of them.
PAIRS = [(0, 0), (0, 1), (0, 3), (1, 0), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3),
         (3, 4), (3, 5), (4, 4), (4, 6), (5, 5), (5, 6)]
u_idx = np.array([u for u, _ in PAIRS])
i_idx = np.array([i for _, i in PAIRS])
N = len(PAIRS)

D, LR, EPOCHS, TEMP = 4, 0.5, 400, 0.2
rng = np.random.default_rng(0)
U = rng.normal(0, 0.1, (len(USERS), D))      # user tower: one vector per person
W = rng.normal(0, 0.1, (len(FEATURES), D))   # item tower: tags -> vector


def normalise(M):
    return M / np.maximum(np.linalg.norm(M, axis=1, keepdims=True), 1e-9)


def through_normalise(M, dM):
    """Push a gradient back through the row-normalisation step."""
    n = np.maximum(np.linalg.norm(M, axis=1, keepdims=True), 1e-9)
    Mh = M / n
    return (dM - (dM * Mh).sum(axis=1, keepdims=True) * Mh) / n


print("epoch    loss")
for epoch in range(EPOCHS + 1):
    Vall = F @ W                             # the whole item tower, one matrix multiply
    Ub, Vb = normalise(U[u_idx]), normalise(Vall[i_idx])
    logits = Ub @ Vb.T / TEMP                # each pair scored against every other pair
    logits -= logits.max(axis=1, keepdims=True)
    P = np.exp(logits)
    P /= P.sum(axis=1, keepdims=True)
    if epoch % 100 == 0:
        loss = -np.mean(np.log(P[np.arange(N), np.arange(N)] + 1e-12))
        print(f"{epoch:5d}  {loss:.4f}")
    if epoch == EPOCHS:
        break

    G = (P - np.eye(N)) / (N * TEMP)         # gradient of the loss wrt the logits
    dU_raw = through_normalise(U[u_idx], G @ Vb)
    dV_raw = through_normalise(Vall[i_idx], G.T @ Ub)
    dU = np.zeros_like(U)
    np.add.at(dU, u_idx, dU_raw)             # several pairs share a user row
    U -= LR * dU
    W -= LR * (F[i_idx].T @ dV_raw)

Un, Vn = normalise(U), normalise(F @ W)
SEEN = {u: {i for uu, i in PAIRS if uu == u} for u in range(len(USERS))}

print("\ntop 2 items for each person, out of everything including the unseen film:")
for u in range(len(USERS)):
    scores = Vn @ Un[u]
    order = sorted(range(len(TITLES)), key=lambda i: (-scores[i], TITLES[i]))
    picks = [i for i in order if i not in SEEN[u]][:2]
    print(f"  {USERS[u]:<7} " + "   ".join(f"{TITLES[i]} {scores[i]:+.3f}" for i in picks))

print("\nNaya-Film was never in the training pairs. Its score anyway:")
j = TITLES.index("Naya-Film")
for u in range(len(USERS)):
    print(f"  {USERS[u]:<7} {float(Vn[j] @ Un[u]):+.3f}")
Output
epoch    loss
    0  3.3444
  100  1.8388
  200  1.8211
  300  1.8169
  400  1.8147

top 2 items for each person, out of everything including the unseen film:
  Aarav   Naya-Film +0.785   3 Idiots +0.110
  Bhavna  Dangal +0.757   Naya-Film +0.757
  Chetan  Lagaan +0.547   Naya-Film +0.547
  Divya   Bhoot -0.154   3 Idiots -0.302
  Esha    Stree -0.112   Dangal -0.417
  Farhan  Tumbbad +0.135   3 Idiots -0.063

Naya-Film was never in the training pairs. Its score anyway:
  Aarav   +0.785
  Bhavna  +0.757
  Chetan  +0.547
  Divya   -0.889
  Esha    -0.417
  Farhan  -0.359

The last block is the whole point of the design

Naya-Film never appeared in a single training pair. The model has no idea it exists as an entity.

It still scores +0.785 for Aarav and -0.889 for Divya. The item tower read its tags, and the tags were enough. Compare that with matrix factorisation, where an item with no interactions has a factor vector of exactly zero and scores zero for everybody.

That difference is why this architecture won.

Three things in the output that you should not gloss over

The loss stops at 1.81, not near zero. That is not a bug and it is not a badly tuned learning rate. The batch contains fifteen pairs, and Sholay is a positive for three different users. When Aarav's row treats Bhavna's positive as a negative, the model is being told something false.

These are false negatives, and they are unavoidable with in-batch negatives. The floor on the loss is the price. Popular items suffer most, because they appear in the most rows.

Bhavna scores Dangal and Naya-Film at exactly +0.757. Look at their tag rows: identical. A tower that reads only features cannot distinguish two items with the same features. Production towers therefore concatenate a learned per-item vector onto the feature vector, which restores the distinction — and reintroduces a cold-start gap for that component.

Divya's best candidate scores -0.154, a negative number. Retrieval scores are not probabilities. Their sign and scale carry no meaning on their own; only the ordering does. Anyone who puts a threshold like score > 0.5 on a retrieval model's output has misread what the number is.

Line by line, for the parts that are not obvious

logits = Ub @ Vb.T / TEMP — the in-batch negatives trick. An (N, D) @ (D, N) multiply scores every user in the batch against every item in the batch. Row n has one correct answer, at column n, and N-1 free negatives you did not have to sample. Bigger batches give harder training, which is why retrieval models are trained with batch sizes in the thousands.

/ TEMP — temperature. Dividing the logits sharpens the softmax. With cosine similarities capped at 1.0, the logits are tiny and the gradient is nearly flat without it. Set TEMP = 1.0 and training barely moves. Values between 0.05 and 0.2 are typical.

logits -= logits.max(axis=1, keepdims=True) — the standard numerically stable softmax. Skip it and large logits overflow np.exp. It changes nothing mathematically, because softmax is shift-invariant.

(P - np.eye(N)) / (N * TEMP) — the gradient of softmax cross-entropy is the predicted distribution minus the one-hot target. The identity matrix is the target here, since row n's answer is column n.

np.add.at(dU, u_idx, dU_raw) — accumulation, not assignment. Aarav appears in three pairs, so three gradients must be summed into his row. Writing dU[u_idx] = dU_raw keeps only the last one, and the bug is silent.

F[i_idx].T @ dV_raw — the chain rule through the item tower. Since V = F @ W, the gradient with respect to W is F.T @ dV. Every item sharing a tag pushes the same column of W, which is exactly the sharing that lets an unseen item inherit a sensible vector.

The same model in PyTorch

two_tower_torch.py
# pip install torch  (CPU wheel, roughly 200 MB)
import torch, torch.nn as nn, torch.nn.functional as Fn

TEMP = 0.2
user_tower = nn.Embedding(6, 4)
item_tower = nn.Linear(6, 4, bias=False)
opt = torch.optim.Adam(list(user_tower.parameters()) + list(item_tower.parameters()), lr=0.05)

feats = torch.tensor([[1,0,0,1,0,0],[1,0,0,1,0,0],[1,0,1,0,0,0],[1,0,0,0,1,0],
                      [0,1,0,0,0,1],[0,1,1,0,0,0],[0,1,0,0,1,0]], dtype=torch.float)
pairs = torch.tensor([[0,0],[0,1],[0,3],[1,0],[1,2],[1,3],[2,1],[2,2],[2,3],
                      [3,4],[3,5],[4,4],[4,6],[5,5],[5,6]])

for step in range(400):
    u = Fn.normalize(user_tower(pairs[:, 0]), dim=1)
    v = Fn.normalize(item_tower(feats[pairs[:, 1]]), dim=1)
    logits = u @ v.T / TEMP
    loss = Fn.cross_entropy(logits, torch.arange(len(pairs)))   # the diagonal is the target
    opt.zero_grad(); loss.backward(); opt.step()

print(round(loss.item(), 4))

No output block for this one, deliberately. The initialisation and the optimiser differ from the NumPy version, so the printed number depends on your PyTorch version. What should match is the shape of the result: a loss that falls quickly and then flattens well above zero, for the false-negative reason above.

Common mistakes

Forgetting to normalise. Without unit-length vectors, popular items grow long vectors and win every dot product regardless of direction. Normalising plus a temperature is the standard pairing, and neither works properly alone.

Ignoring the popularity bias in in-batch negatives. Negatives are sampled from the traffic distribution, so popular items appear as negatives far more often and get systematically pushed down. The standard correction subtracts log(sampling_probability) from each logit during training — see the researcher block.

Using retrieval scores as final scores. This model is a filter. Its job is recall of a few hundred candidates. Ordering the page is a separate, richer model's job.

Rebuilding the entire item index on every deploy. Item vectors change whenever the tower changes, so a new model version invalidates the whole index. Plan for that: version the index alongside the model, and never serve a user tower against an index built by a different one. Mismatched versions produce results that look plausible and are nonsense.

Judging it with nDCG. Retrieval is measured by recall@k for k in the hundreds or thousands. Whether the right item is at position 3 or 300 within the candidate set does not matter, because the ranker re-orders them anyway.

Try it yourself

Set TEMP = 1.0 and re-run. The loss flattens at 2.1442 instead of 1.8147, and it stops improving after about a hundred epochs. Six users and seven items are easy enough that the recommendations survive anyway — the loss gap is the honest signal, and temperature is consistently one of the highest-impact hyperparameters in a retrieval model.

Then give Naya-Film the tags [0, 1, 0, 0, 0, 1] — horror and fantasy — and re-run. Every score flips sign: Aarav goes from +0.785 to -0.477, Divya from -0.889 to +0.699. Not one training pair changed.

Watching a model's opinion of an item move purely through its description is the clearest demonstration of what a feature-based tower buys you.

What to learn next

Researcher — Mathematics and papers.

The architecture

$$ s(u, i) = \frac{f_\theta(\mathbf{x}u)^\top g\phi(\mathbf{x}i)}{\tau}, \qquad f\theta : \mathcal{X}U \to \mathbb{R}^d, \quad g\phi : \mathcal{X}_I \to \mathbb{R}^d $$

Where $\mathbf{x}_u$ collects user features (history, context, demographics), $\mathbf{x}_i$ collects item features (content, category, creator, id embedding), $\tau > 0$ is the temperature, and both encoders output into the same $d$-dimensional space. Outputs are usually L2-normalised, making $s$ a scaled cosine.

The bilinear-at-the-top structure is a hard architectural constraint. No interaction between $\mathbf{x}_u$ and $\mathbf{x}i$ can be represented except through the inner product of their independent encodings. That is precisely the property that permits precomputation of ${g\phi(\mathbf{x}i)}{i \in I}$ and sublinear maximum inner product search at serving time.

Sampled softmax and the logQ correction

The exact objective is a softmax over the full catalogue:

$$ \mathcal{L} = -\frac{1}{|B|}\sum_{(u,i) \in B} \log \frac{\exp(s(u,i))}{\sum_{j \in I} \exp(s(u,j))} $$

The denominator is $O(|I|)$ and infeasible. In-batch negatives approximate it with the batch's other positives, which means negatives are drawn from the traffic distribution, not uniformly. Item $j$ appears as a negative with probability roughly proportional to its popularity $q_j$, so popular items are penalised systematically.

Yi et al. (2019), Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations, correct this by subtracting the log sampling probability from each logit:

$$ s^{\text{corr}}(u, j) = s(u, j) - \log q_j $$

with $q_j$ estimated online by a streaming frequency estimator, since item popularity is non-stationary. This correction is not optional at scale. Without it, the retrieval stage under-retrieves head items in a way that is invisible offline and visible online.

False negatives are a separate issue that the correction does not address: an in-batch negative may genuinely be relevant to that user. Mitigations are all partial — mask items appearing as positives elsewhere in the batch, use a mixed negatives strategy with uniformly sampled negatives alongside in-batch ones (Yang et al., 2020), or accept the bias.

Temperature

With normalised embeddings, $s \in [-1/\tau, 1/\tau]$. At $\tau = 1$ the logit range is $[-1, 1]$, the softmax is nearly uniform, and gradients are small. Small $\tau$ sharpens the distribution and concentrates gradient on the hardest in-batch negatives.

The effect is the same one analysed in contrastive learning: Wang and Liu (2021), Understanding the Behaviour of Contrastive Loss, show $\tau$ controls a uniformity-versus-tolerance trade-off, with small $\tau$ penalising close negatives heavily and thereby spreading the representation. Values in $[0.02, 0.2]$ are standard, and $\tau$ is worth tuning before almost any architectural change.

Negative mining

Ranked by cost and by effect:

StrategyCostEffect
In-batchfreenegatives biased towards popular items
Uniform from cataloguecheapnegatives that are too easy, weak gradient
Mixed (in-batch + uniform)cheapcorrects part of the popularity skew
Hard negatives from the current indexexpensivelarge gains, risk of collapse and false negatives

Huang et al. (2020), Embedding-based Retrieval in Facebook Search, report that the choice of negatives dominated architectural choices in their system, and that using only hard negatives degraded performance — a blend was required. Treat negative sampling as the primary hyperparameter of a retrieval model.

Serving

The trained item tower is run over the catalogue offline, producing $|I|$ vectors indexed for approximate nearest-neighbour search: HNSW for high recall at moderate memory, IVF-PQ when the index must be compressed. Query cost is roughly $O(\log |I|)$ for graph indexes.

Two operational facts follow, and both cause outages:

  1. The index and the user tower are one artefact. Serving user vectors from model version $t$ against an index built by version $t-1$ yields plausible-looking nonsense with no error raised. Version them together.
  2. ANN recall is a hyperparameter that trades against latency. Measure end-to-end recall against exact search on a sample, not only the model's offline recall. A model improvement can be entirely eaten by an index tuned for speed.

Relationship to matrix factorisation

Set $f_\theta$ and $g_\phi$ to pure id-embedding lookups, drop the normalisation, and use squared loss: the model is matrix factorisation. Two-tower models generalise it in exactly two directions — arbitrary feature inputs to each side, and a ranking-shaped loss instead of a reconstruction loss.

The first direction is what solves item cold start. The second is what aligns the objective with retrieval. Rendle et al. (2020) remain the reminder that the dot product itself was never the weak part.

Papers

  • Huang et al. (2013), Learning Deep Structured Semantic Models for Web Search (DSSM), CIKM — the two-tower design, in web search.
  • Covington, Adams and Sargin (2016), Deep Neural Networks for YouTube Recommendations, RecSys.
  • Yi et al. (2019), Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations, RecSys — the logQ correction.
  • Huang et al. (2020), Embedding-based Retrieval in Facebook Search, KDD — arxiv.org/abs/2006.11632
  • Yang et al. (2020), Mixed Negative Sampling for Learning Two-tower Neural Networks in Recommendations, WWW Companion.
  • Wang and Liu (2021), Understanding the Behaviour of Contrastive Loss, CVPR — arxiv.org/abs/2012.09740

What to learn next