Back to Deep Learning
AI/ML · Deep Learning

Two-Tower Retrieval

Two separate encoders — one for the query, one for the item — trained so that relevant pairs land close together in a shared vector space. The separation is the entire architectural point: because the item tower never sees the query, every item embedding can be computed offline and indexed. Retrieval then becomes nearest-neighbour search rather than scoring millions of candidates one at a time. A cross-encoder is more accurate and structurally cannot do this.

Self-supervisedSupervisedFeedforwardRankingDimensionality reductionAnomaly detection

Also known as: Dual encoder, Bi-encoder, Siamese retrieval network, Two-Tower Model

Intuition

What it actually does

Two separate encoders — one for the query, one for the item — trained so that relevant pairs land close together in a shared vector space. The separation is the entire architectural point: because the item tower never sees the query, every item embedding can be computed offline and indexed. Retrieval then becomes nearest-neighbour search rather than scoring millions of candidates one at a time. A cross-encoder is more accurate and structurally cannot do this.

Assumptions it makes
  • Relevance is expressible as geometric proximity in a single shared space
  • Item embeddings can be precomputed, meaning the item tower does not depend on the query
  • Enough positive pairs exist to learn the space — this is data-hungry
  • The catalogue is stable enough that an index rebuilt periodically stays current
Objective

Loss function

Softmax cross-entropy over one positive and a set of negatives — InfoNCE. The model is being asked to rank the true item above the distractors, not to predict a score, which is why the negatives define the task. Train against random negatives and the model learns only to separate obviously unrelated things; train against hard negatives and it learns the distinctions that actually matter at serving time.

similarity — cosine or dot product between the two embeddings
the known-relevant item for this query
negatives — the choice of these matters more than the architecture
temperature; sharpens or softens the distribution over candidates
Optimization

How it is minimized

Contrastive training with in-batch negatives, AdamW, large batches

the batch of query and item embeddings, each B x d
the B x B similarity matrix — every query against every item in the batch
the diagonal is the correct label: query i matches item i

In-batch negatives are the trick that makes this trainable at all. Rather than sampling negatives explicitly, every other item in the batch serves as a negative for every query, so one B x B matrix product yields B² training signals from B examples. That is why batch size behaves like a hyperparameter of the *objective* here rather than just of the optimizer — a larger batch is a harder and more informative task. The known failure is false negatives: with a large batch, some in-batch item genuinely is relevant to the query and is being pushed away as if it were not.

Hyperparameters
  • batch size

    Determines the number of in-batch negatives; behaves like an objective parameter (1,024 to 32,768)

  • temperature

    Sharpness of the contrastive distribution; small values emphasise hard negatives (0.01 to 0.1)

  • embedding dimension

    Capacity versus index size and query latency (64 to 768)

  • hard-negative ratio

    Fraction of mined hard negatives mixed with in-batch ones (0 to 4 per positive)

  • normalization

    L2-normalize embeddings so dot product is cosine — required for most ANN indices (on)

Convergence & cost

Trains stably, and the loss is a poor guide to quality. The characteristic failure is representation collapse: all embeddings drift toward the same region, the loss looks acceptable, and retrieval is useless — visible only by monitoring embedding variance, never from the loss curve. The second is the train/serve mismatch that defines this model class: training uses in-batch negatives drawn from the positives distribution, while serving searches the entire catalogue, most of which is nothing like a positive. A model that looks excellent offline can retrieve badly for exactly that reason.

O(B²·d) for the similarity matrix per step, which is why batch size eventually costs quadratically. Serving is O(d) to encode the query plus sublinear ANN lookup — the whole point of the design.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

The model produces a similarity ranking over a fixed catalogue, and forecasting has no catalogue and no query. There is an adjacent technique — retrieving similar historical windows to condition a forecast — but that uses embeddings as a nearest-neighbour lookup rather than as the forecaster, and the forecasting model is whatever consumes them.

Anomaly Detection

Adapted
How

Embed the item, query the index for its nearest neighbours, and score by distance to the k-th neighbour. Works best where the anomaly is genuinely novel content rather than an unusual value.

Where
  • Near-duplicate and novel-content detection in large catalogues
  • Flagging queries with no good match in the corpus, as a retrieval-quality signal
Why

Distance to the nearest neighbour in embedding space is a usable novelty score: an item far from everything known is unusual. But the space was trained to separate *relevant* from *irrelevant*, not normal from anomalous, so the geometry is optimized for the wrong distinction.

Featurization
  • L2-normalize so distances are comparable across the space
  • Fit the threshold on a known-clean corpus, since distance scales are arbitrary
Evaluation

Precision@k against confirmed novel items, with the threshold from the distance distribution of a clean holdout.

Pitfalls
  • The embedding geometry encodes relevance, not normality — a common and rarely stated mismatch
  • Dense regions of the catalogue produce systematically lower distances regardless of novelty

Optimization

Not applicable

Retrieval selects from an existing catalogue by similarity; it has no objective over decision variables, no constraints, and no notion of feasibility. It frequently supplies candidates that a downstream optimizer then allocates over, but the similarity search itself optimizes nothing about the world.

Also applied in

Recommendation & Ranking

Primary fit
How

The retrieval stage of a two-stage system. Precompute every item embedding offline into an ANN index, encode the user or context at request time, and fetch a few hundred candidates for a heavier ranker to reorder.

Where
  • Candidate generation over catalogues of millions of items
  • Personalized feed and homepage retrieval under strict latency budgets
  • Semantic search where lexical matching misses paraphrase
Why

It is the only architecture that makes million-item retrieval tractable in milliseconds, and the reason is structural rather than statistical: because the towers are independent, item embeddings are precomputable. A cross-encoder scores better on any individual pair and would need to run millions of times per request, so the standard pattern pairs them — this retrieves, a cross-encoder or GBM reranks.

Featurization
  • Mine hard negatives from the model's own current retrievals; random negatives plateau quickly
  • Include content features so cold items get a meaningful embedding with no interaction history
  • L2-normalize, since most ANN indices assume cosine geometry
Evaluation

Recall@k at the candidate-set size the reranker actually consumes — not NDCG, since ordering is the reranker's job. Then online A/B, because offline retrieval gains routinely fail to transfer.

Pitfalls
  • Training on in-batch negatives while serving against the full catalogue is a real distribution mismatch
  • False negatives: an in-batch item that genuinely is relevant gets pushed away
  • Popularity bias — frequent items dominate the space unless sampling corrects for it

Natural Language

Primary fit
How

Dense passage retrieval: encode the question with one tower and passages with the other, index the passages, and retrieve by vector similarity. This is the retrieval half of a RAG system.

Where
  • Retrieval-augmented generation, grounding a language model in a corpus
  • Semantic search and FAQ matching where wording differs from the query
  • Duplicate-question detection
Why

Solves the vocabulary-mismatch problem that lexical search cannot: a question and its answer often share almost no words. Dense retrieval matches on meaning. In practice hybrid retrieval — dense plus BM25 — beats either alone, because lexical matching remains better on rare exact terms like identifiers and part numbers.

Featurization
  • Chunk passages to a size the encoder handles well; over-long chunks dilute the embedding
  • Keep query and passage encoders separate unless data is scarce — sharing them helps only in low-data regimes
Evaluation

Recall@k on held-out question-passage pairs, plus end-to-end answer quality, since retrieval recall does not always translate into better generation.

Pitfalls
  • Dense retrieval underperforms lexical search on rare exact strings — hence hybrid
  • Embeddings are corpus-specific; re-embedding the entire index is required whenever the encoder changes

Computer Vision

Viable
How

Image-text or image-image dual encoders trained contrastively, giving a shared space where a text query retrieves images or an image retrieves visually similar ones.

Where
  • Visual and text-to-image search over large catalogues
  • Near-duplicate detection and reverse image search
Why

The contrastive image-text objective also yields zero-shot classification for free — classify by embedding the class names and retrieving the nearest — which is a capability supervised training simply does not offer.

Featurization
  • Augment the image tower but not the text tower; they have different invariances
  • Very large batches matter more here than in text retrieval
Evaluation

Recall@k on held-out pairs plus zero-shot accuracy on a benchmark the corpus did not contain.

Pitfalls
  • Enormous batch requirements make training expensive and hardware-bound
  • The space inherits the biases of the web-scale pairs it was trained on
Deployment

What it costs to run

Training

Hours to days on multi-GPU, and the cost is driven by batch size because the objective needs it — this is one of the few models where the hardware budget directly limits model quality.

Inference

One tower pass to encode the query — single-digit milliseconds — plus a sublinear ANN lookup. Item embeddings are precomputed, which is the entire design goal.

Retraining

Monthly, or whenever the catalogue distribution shifts. Every retrain forces a full re-embed and index rebuild, which is usually the operational bottleneck rather than the training itself.

Drift & monitoring
  • Track embedding-norm and variance distributions — collapse shows up here and nowhere else
  • Monitor recall@k against a labelled probe set on a fixed schedule
  • Watch catalogue coverage: if retrieval concentrates on a shrinking item subset, popularity bias is compounding
Production gotchas
  • Query and item encoders must be versioned together with the index — a mismatch produces plausible, wrong results silently
  • Re-embedding a large catalogue takes hours, so index rebuilds need to be planned as a deployment step
  • ANN indices are approximate; recall degrades with aggressive tuning, and the loss is invisible without a probe set
Trade-offs

Pros and cons, in context

Strengths
  • Item embeddings are precomputable

    The structural property that makes million-item retrieval possible in milliseconds. It is also exactly what a cross-encoder gives up in exchange for accuracy, which is why the two are used together rather than as alternatives.

  • Matches on meaning rather than tokens

    Solves vocabulary mismatch, which lexical search structurally cannot. Loses to BM25 on rare exact strings, which is why hybrid retrieval is standard.

  • One shared space serves many uses

    The same embeddings support retrieval, clustering, deduplication and novelty scoring — real operational leverage from one training run.

  • Handles cold-start items with content features

    An item with no interaction history still gets a meaningful embedding from its content, which collaborative filtering cannot do by construction.

Limitations
  • Less accurate than a cross-encoder on any single pair

    The towers never interact, so fine-grained query-item reasoning is impossible. This is a deliberate trade for precomputability, and the standard mitigation is to rerank.

  • Quality is bounded by negative sampling, not architecture

    Random negatives plateau quickly and hard-negative mining is where the real gains are. Teams often tune the model when they should be tuning the negatives.

  • Batch size is effectively part of the objective

    More in-batch negatives means a harder, better task, so quality is coupled to available hardware in a way most models are not.

  • Every retrain forces a full index rebuild

    Embeddings from different encoder versions are not comparable, so partial updates are impossible. This is the main operational cost and it is easy to underestimate.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""Two-tower retrieval with in-batch negatives - the objective, written out.

Every OTHER item in the batch acts as a negative for each query, so one batch
of B examples yields B*B training signals. That is the trick that makes
contrastive retrieval trainable.
"""

import math


def encode(features, weights):
    """A tower: one linear layer, then L2-normalize.

    Normalizing makes the dot product a cosine, which bounds similarity to
    [-1, 1] and is what most ANN indices assume.
    """
    out = []
    for row in weights:
        out.append(sum(f * w for f, w in zip(features, row)))

    norm = math.sqrt(sum(v * v for v in out)) or 1.0
    return [v / norm for v in out]


def in_batch_loss(query_features, item_features, q_weights, d_weights, temperature=0.05):
    """InfoNCE over the batch. The diagonal is the correct pairing."""
    batch = len(query_features)

    queries = [encode(q, q_weights) for q in query_features]
    items = [encode(d, d_weights) for d in item_features]

    total = 0.0
    for i in range(batch):
        # Similarity of query i against EVERY item in the batch.
        scores = []
        for j in range(batch):
            dot = sum(a * b for a, b in zip(queries[i], items[j]))
            scores.append(dot / temperature)

        # Softmax cross-entropy with the true label being i itself.
        largest = max(scores)
        denominator = sum(math.exp(s - largest) for s in scores)
        total -= (scores[i] - largest) - math.log(denominator)

    return total / batch

O(B²·d) for the similarity matrix, built with a Python loop per pair.

2. IdiomaticPyTorch

How this language actually wants it written.

"""Two-tower retrieval - typed, with the diagnostics the loss will not give."""

from dataclasses import dataclass

import torch
from torch import Tensor, nn


@dataclass(frozen=True)
class TowerConfig:
    input_dim: int
    embedding_dim: int = 128
    hidden_dim: int = 512
    temperature: float = 0.05

    def __post_init__(self) -> None:
        if self.temperature <= 0:
            raise ValueError(f"temperature must be positive, got {self.temperature}")


class Tower(nn.Module):
    """One encoder. Query and item towers are SEPARATE instances - sharing
    weights only helps when data is scarce, and otherwise forces two different
    input distributions through one set of parameters."""

    def __init__(self, config: TowerConfig) -> None:
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(config.input_dim, config.hidden_dim),
            nn.ReLU(inplace=True),
            nn.Linear(config.hidden_dim, config.embedding_dim),
        )

    def forward(self, x: Tensor) -> Tensor:
        # L2-normalize so the dot product IS cosine similarity. Most ANN
        # indices assume this, and skipping it makes distances meaningless.
        return nn.functional.normalize(self.net(x), p=2, dim=-1)


@dataclass(frozen=True)
class ContrastiveStep:
    loss: Tensor
    accuracy: float
    embedding_std: float

    @property
    def has_collapsed(self) -> bool:
        """Representation collapse is invisible in the loss and fatal to
        retrieval - all embeddings drift together and the loss still looks
        fine. Embedding spread is the only signal that catches it."""
        return self.embedding_std < 0.01


def contrastive_step(
    query_tower: Tower,
    item_tower: Tower,
    query_features: Tensor,
    item_features: Tensor,
    temperature: float,
) -> ContrastiveStep:
    if query_features.size(0) != item_features.size(0):
        raise ValueError("queries and items must be paired one-to-one")
    if query_features.size(0) < 2:
        raise ValueError("in-batch negatives need a batch of at least 2")

    queries = query_tower(query_features)
    items = item_tower(item_features)

    # The whole B x B similarity matrix in one product. The diagonal is the
    # positive pair; everything off-diagonal is a negative, for free.
    similarity = (queries @ items.T) / temperature
    labels = torch.arange(similarity.size(0), device=similarity.device)

    loss = nn.functional.cross_entropy(similarity, labels)

    with torch.no_grad():
        accuracy = float((similarity.argmax(dim=1) == labels).float().mean())
        embedding_std = float(items.std(dim=0).mean())

    return ContrastiveStep(loss=loss, accuracy=accuracy, embedding_std=embedding_std)

What changed: The per-pair loop collapses into one B×B matrix product, which is both faster and clearer about what in-batch negatives actually are. Embeddings are L2-normalized so similarity is genuinely cosine, and the step returns embedding spread — because representation collapse is fatal to retrieval and completely invisible in the loss curve.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(B²·d) as one GEMM plus a cross-entropy reduction.

3. OptimizedPyTorch / FAISS

Faster, and the justification for each change.

"""Retrieval at scale - cross-device negatives, hard mining, quantized index."""

import numpy as np
import torch
import torch.distributed as dist
from numpy.typing import NDArray
from torch import Tensor


def gather_across_devices(embeddings: Tensor) -> Tensor:
    """Collect embeddings from every rank to widen the negative pool.

    Batch size IS the objective here - more in-batch negatives is a harder and
    more informative task. Gathering across N devices multiplies the effective
    negatives by N without any device holding a larger batch.
    """
    if not dist.is_initialized():
        return embeddings

    gathered = [torch.zeros_like(embeddings) for _ in range(dist.get_world_size())]
    dist.all_gather(gathered, embeddings)
    # The local slice must keep its graph connection - all_gather detaches,
    # so splicing it back in is what preserves the local gradient path.
    gathered[dist.get_rank()] = embeddings
    return torch.cat(gathered, dim=0)


def mine_hard_negatives(
    query_embeddings: Tensor,
    item_embeddings: Tensor,
    positive_indices: Tensor,
    k: int = 8,
) -> Tensor:
    """Retrieve the model's own top mistakes as negatives.

    Random negatives plateau early - separating a query from an unrelated item
    is trivial. Hard negatives are where the remaining signal is, and mining
    them from the model's CURRENT retrievals is what keeps the task hard as it
    improves.
    """
    with torch.no_grad():
        scores = query_embeddings @ item_embeddings.T
        # Mask the true positive so it cannot be mined as its own negative.
        scores.scatter_(1, positive_indices.unsqueeze(1), float("-inf"))
        return scores.topk(k, dim=1).indices


def build_quantized_index(embeddings: NDArray[np.float32], n_centroids: int = 4096):
    """IVF-PQ index: coarse partition plus product quantization.

    Flat search is O(N·d) per query and does not hold a catalogue of millions
    in memory. IVF probes only a few partitions, and PQ compresses each vector
    from d floats to a handful of bytes - together roughly 100x smaller and
    sublinear to search, at a measurable and TUNABLE recall cost.
    """
    import faiss

    dim = embeddings.shape[1]
    # Contiguous float32 is what FAISS expects; anything else is copied.
    embeddings = np.ascontiguousarray(embeddings, dtype=np.float32)
    faiss.normalize_L2(embeddings)

    quantizer = faiss.IndexFlatIP(dim)
    index = faiss.IndexIVFPQ(quantizer, dim, n_centroids, dim // 4, 8)

    index.train(embeddings)
    index.add(embeddings)
    index.nprobe = 32      # partitions probed per query - the recall/latency dial

    return index

What changed: Three changes aimed at the three real bottlenecks. Cross-device gathering widens the negative pool without any single device holding a bigger batch — which matters because batch size *is* the objective here. Hard-negative mining keeps the task difficult as the model improves, where random negatives plateau. And an IVF-PQ index makes catalogue-scale search sublinear and roughly 100× smaller in memory, at a tunable recall cost.

  • Batch work to amortize interpreter overhead

    all_gather multiplies the effective negative count by the device count, and since more negatives is a strictly harder task, this improves model quality rather than just throughput.

    Trade-off: Communication cost scales with embedding size and device count, and the local-slice splice is easy to get wrong — omitting it silently kills the local gradient.

  • Ensure contiguous memory layout and a single dtype

    FAISS requires contiguous float32; passing anything else triggers a full silent copy of the whole catalogue.

    Trade-off: Forces a materialized copy when embeddings arrive as a view or in float64.

  • Fuse adjacent operations so intermediates are never materialized

    Product quantization stores each vector as a handful of subspace codes rather than d floats, so distance computation reads roughly 100x less memory — ANN search is memory-bound, so that is where the win is.

    Trade-off: Quantization is lossy and recall degrades measurably; without a labelled probe set the loss is completely invisible in production.

O(B²·d) training per device; sublinear ANN search at serve. Illustrative, not a measured benchmark.