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.
- 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
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
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.
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)
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.
How, where, and why it is used
Time-Series Forecasting
Not applicableThe 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
AdaptedEmbed 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.
- Near-duplicate and novel-content detection in large catalogues
- Flagging queries with no good match in the corpus, as a retrieval-quality signal
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.
- L2-normalize so distances are comparable across the space
- Fit the threshold on a known-clean corpus, since distance scales are arbitrary
Precision@k against confirmed novel items, with the threshold from the distance distribution of a clean holdout.
- 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 applicableRetrieval 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.
Recommendation & Ranking
Primary fitThe 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.
- Candidate generation over catalogues of millions of items
- Personalized feed and homepage retrieval under strict latency budgets
- Semantic search where lexical matching misses paraphrase
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.
- 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
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.
- 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 fitDense 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.
- Retrieval-augmented generation, grounding a language model in a corpus
- Semantic search and FAQ matching where wording differs from the query
- Duplicate-question detection
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.
- 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
Recall@k on held-out question-passage pairs, plus end-to-end answer quality, since retrieval recall does not always translate into better generation.
- 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
ViableImage-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.
- Visual and text-to-image search over large catalogues
- Near-duplicate detection and reverse image search
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.
- Augment the image tower but not the text tower; they have different invariances
- Very large batches matter more here than in text retrieval
Recall@k on held-out pairs plus zero-shot accuracy on a benchmark the corpus did not contain.
- Enormous batch requirements make training expensive and hardware-bound
- The space inherits the biases of the web-scale pairs it was trained on
What it costs to run
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.
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.
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.
- 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
- 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
Pros and cons, in context
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.
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.
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.
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 / batchO(B²·d) for the similarity matrix, built with a Python loop per pair.
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.
O(B²·d) as one GEMM plus a cross-entropy reduction.
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 indexWhat 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.