Two-Tower Models

Modern retrieval models with dual encoders and contrastive learning

advanced50 min
On this page

The Big Picture

The core challenge in modern recommendation systems is retrieval at scale: given billions of items and millisecond latency constraints, how do you efficiently find the top-K items relevant to a user?

Two-Tower models (also called dual-encoder or siamese towers) solve this by learning separate embeddings for users and items, then using fast approximate nearest neighbor search to find matches. They're the backbone of production recommendation systems at companies with massive catalogs.

The Two-Tower Concept

The architecture is elegantly simple:

  • User Tower: Takes user features (ID, demographics, behavior history) and produces a user embedding
  • Item Tower: Takes item features (ID, content, metadata) and produces an item embedding
  • Similarity: The two embeddings are in the same space. A dot product (or cosine similarity) between them scores how relevant an item is to a user
User Tower User Embedding Item Tower Item Embedding Dot Product u · v Two-Tower Architecture

Where It Fits in the RecSys Pipeline

A complete recommendation system typically has three stages:

Retrieval (Two-Tower) 1000 candidates Sub-millisecond Ranking (LambdaMART) 100 candidates Complex features Reranking & Filters 10-20 results Diversity, policy
Why Two-Tower for Retrieval? The tower architecture enables fast approximate nearest neighbor (ANN) search via methods like FAISS or HNSW. This is essential: you can't afford to compute similarity with 100M items for every request. Two-tower lets you precompute all item embeddings and index them, then find matches in ~10-50ms.

Key Components

User Tower

Encodes user identity and context. Inputs: user ID, age, location, device, browsing history, past interactions.

Item Tower

Encodes item identity and content. Inputs: item ID, title, category, brand, tags, popularity stats.

Similarity Function

Usually L2-normalized dot product or cosine similarity. Enables fast MIPS (Maximum Inner Product Search).

ANN Index

Indexes all item embeddings for sub-linear retrieval. FAISS IVF-PQ is the industry standard.

Cross-reference: For context on collaborative filtering and matrix factorization predecessors, see collaborative_filtering_guide.html. Two-tower extends these ideas with deep learning and dense features.

A Concrete Example: Ad Platform Retrieval

Let's walk through a real-world scenario: an ad/e-commerce platform needs to retrieve relevant products for a user's search query in milliseconds.

User Features

When a user visits the platform, we collect their context:

User ID: user_12345
Demographic Features:
  - Age: 28
  - Gender: Female
  - Location: New York, NY
  - Device: Mobile

Behavior Features:
  - Last 5 items viewed: [item_001, item_042, item_999, ...]
  - Search history: ["summer dresses", "blue shoes", "workout gear"]
  - Purchase history: [purchase_1, purchase_2, ...]
  - Dwell time avg: 3.2 seconds
  - Click-through rate: 0.12

Item Features

Each item in the catalog has rich metadata:

Item ID: item_042
Content Features:
  - Title: "Lightweight Summer Dress - Navy Blue"
  - Description embedding: [0.2, -0.5, 0.8, ...] (from BERT)

Categorical Features:
  - Category: Apparel
  - Subcategory: Dresses
  - Brand: StyleCo
  - Color: Blue
  - Season: Summer

Popularity Features:
  - Total views: 50,000
  - Total clicks: 5,000
  - CTR: 0.10
  - Age: 15 days (newer items often get a boost)

Computing a Similarity Score

The two towers independently process their inputs:

User Tower Raw Features (ID, Age, Loc...) ↓ Embeddings + Dense [0.12, -0.34, 0.89, ...] ↓ MLP Layers Hidden [512 dims] ↓ Output + L2 Norm User Embedding (256d) Item Tower Raw Features (ID, Title...) ↓ Embeddings + Dense [0.45, 0.12, -0.67, ...] ↓ MLP Layers Hidden [512 dims] ↓ Output + L2 Norm Item Embedding (256d) Similarity Score Score = 0.87 Highly relevant!

Numeric Example

Simplified example with 4-dimensional embeddings:

User embedding (normalized):    u = [0.50, 0.25, -0.50, 0.50]
Item embedding (normalized):    v = [0.60, 0.20, -0.40, 0.60]

Dot product (similarity score):
  score = u · v
  = (0.50)(0.60) + (0.25)(0.20) + (-0.50)(-0.40) + (0.50)(0.60)
  = 0.30 + 0.05 + 0.20 + 0.30
  = 0.85

Interpretation: Score of 0.85 (out of max 1.0) = highly relevant match

Retrieval Pipeline at Query Time

1. User Query "summer dresses" (+ context) 2. User Embedding Generate via User Tower 3. ANN Search Query indexed item embeddings 4. Top-K Results ~1000 candidates in milliseconds ~2ms ~3ms ~10ms ~15ms total Top-1000 Items by Relevance: item_042 (0.87) item_103 (0.84) item_255 (0.81) ...
Key Insight: Notice that retrieval happens in ~15ms even though we're searching 100M+ items. This is the magic of ANN: we don't compute similarity with every item. Instead, we use a smart index (FAISS) to prune the search space.

User Tower Architecture

The user tower transforms heterogeneous user signals into a fixed-size dense embedding suitable for fast similarity computation.

Input Features

A user tower typically consumes:

  • User ID embedding: Learned embedding for the user ID itself (~128-256 dims)
  • Dense features: Age, income level, engagement metrics (normalized to [0,1])
  • Categorical features: Gender, location, device type (converted to embeddings or one-hot)
  • Sequence features: Last N items viewed, search queries (aggregated via pooling)
  • Context features: Time of day, day of week, device type, network quality

Architecture Overview

User Tower Processing Input Features User ID Categorical Demographics Age, gender Behavior Views, clicks Context Device, time, location Lookup / Normalize Concatenated Vector (~1024 dims) MLP Layers Dense(1024 → 512) + ReLU + Batch Norm Dense(512 → 256) + ReLU + Batch Norm Dense(256 → 256) + Dropout(0.2) L2 Normalization

Key Design Choices

L2 Normalization

All embeddings are L2-normalized to unit vectors. This constrains the dot product to [-1, 1] and enables efficient MIPS algorithms for ANN search.

Output Dimensions

Typically 256-512 dimensions. Larger dims = more capacity but slower inference. Smaller dims = faster but less expressiveness.

Batch Normalization

Applied after activation functions. Stabilizes training and reduces internal covariate shift. Often turned off at serving time.

Dropout & Regularization

Dropout at 0.2-0.3 helps prevent overfitting. L2 regularization on weights also common (λ ~1e-5).

Code Structure (Conceptual)

class UserTower:
    def __init__(self, output_dim=256):
        self.user_id_embedding = nn.Embedding(num_users, 128)
        self.fc1 = nn.Linear(1024, 512)  # input concat size
        self.fc2 = nn.Linear(512, 256)
        self.fc3 = nn.Linear(256, output_dim)
        self.bn1 = nn.BatchNorm1d(512)
        self.bn2 = nn.BatchNorm1d(256)
        self.dropout = nn.Dropout(0.2)

    def forward(self, user_id, demographics, behavior, context):
        # Lookup user embedding
        user_emb = self.user_id_embedding(user_id)

        # Concatenate all features
        x = torch.cat([user_emb, demographics, behavior, context], dim=1)

        # MLP
        x = self.fc1(x)
        x = self.bn1(x)
        x = F.relu(x)

        x = self.fc2(x)
        x = self.bn2(x)
        x = F.relu(x)
        x = self.dropout(x)

        # Output layer
        x = self.fc3(x)

        # L2 normalize
        x = F.normalize(x, p=2, dim=1)

        return x
Handling Sequence Features: Past item views can be encoded as average pooling of their embeddings or via attention mechanisms (e.g., simple weighted average by recency). More sophisticated approaches use RNNs or Transformers, but average pooling is common for speed.

Item Tower Architecture

The item tower encodes item identity and content features into a shared embedding space with the user tower.

Input Features

  • Item ID embedding: Learned representation of item ID (~128-256 dims)
  • Title/content embedding: Text embeddings from pre-trained models like BERT or averaged word2vec
  • Categorical features: Category, brand, color, size (embeddings or one-hot)
  • Popularity features: Total views, CTR, age since creation (normalized)
  • Price & quality signals: Price range, rating, review count

Architecture Diagram

Item Tower Processing Item ID Categorical Title BERT Category Embedding Popularity CTR, views, age Lookup / Transform Concatenated Vector (~1024 dims) MLP Layers (Shared Architecture) Dense(1024 → 512) + ReLU + Batch Norm Dense(512 → 256) + ReLU + Batch Norm Dense(256 → 256) + Dropout(0.2) L2 Normalization
BERT Embeddings: For item titles, you can use pre-trained BERT to get a [CLS] token embedding (~768 dims) or average the token embeddings. For production, store these precomputed embeddings rather than computing them at serving time. See bert_guide.html for details.

Shared Embedding Space

The user and item towers both output to the same d-dimensional space (e.g., 256 dims). This is crucial:

  • Dot product between user and item embeddings is meaningful
  • Enables efficient similarity search
  • Allows for unified training on positive and negative pairs

Handling Text Content

For items with rich textual descriptions:

# Option 1: Pre-computed BERT embeddings
item_title_embedding = precomputed_bert_embeddings[item_id]  # Shape: (768,)

# Option 2: Average word embeddings
word_embeddings = [word2vec[word] for word in title.split()]
item_title_embedding = np.mean(word_embeddings, axis=0)  # Shape: (300,)

# Option 3: No text encoder, use category + popularity only
item_title_embedding = category_embedding + popularity_features

# In practice: concatenate with other features
item_input = np.concatenate([
    item_id_embedding,          # 128 dims
    item_title_embedding,       # 768 dims (BERT)
    category_embedding,         # 64 dims
    popularity_features         # 10 dims
])  # Total: ~970 dims

Serving Optimization: Precomputed Embeddings

At serving time, all item embeddings can be precomputed and indexed:

# Offline: Compute all item embeddings once all_item_embeddings = {} for item_id in item_catalog: item_features = load_item_features(item_id) item_embeddings[item_id] = item_tower.forward(item_features).numpy() # Save to disk and load at serving time np.save('item_embeddings.npy', all_item_embeddings) # At serving: zero latency for item encoding! # Only encode the user, then do ANN search
Important: The item embeddings are static (unless you retrain the model or get new item features). So you can precompute them all offline and index them, eliminating the need to encode items at query time.

The Similarity Function

The core operation that makes two-tower efficient is the similarity function. After computing user and item embeddings, we need a fast way to score how well they match.

Dot Product vs Cosine Similarity

When embeddings are L2-normalized (unit vectors), dot product and cosine similarity are equivalent:

Dot Product:
  score = u · v = Σ(u_i * v_i)
  Range: [-1, 1] when L2-normalized

Cosine Similarity:
  score = (u · v) / (||u|| * ||v||)
  Range: [-1, 1]

When ||u|| = 1 and ||v|| = 1 (L2-normalized):
  Dot product = Cosine similarity

Benefit: Dot product is MUCH faster to compute than
cosine (no division/norm needed).

Why L2-Normalize?

L2 normalization is critical for production two-tower systems:

Stable Training

Constrains embeddings to unit hypersphere, preventing magnitude explosion during training.

MIPS Algorithms

Enables Maximum Inner Product Search (MIPS) via algorithms like FAISS IVF-PQ, which require normalized vectors.

Interpretability

A score of 0.95 always means "very similar" regardless of overall embedding magnitude.

Distance Metrics

Can convert easily: ||u - v||^2 = 2 - 2(u·v) for normalized vectors.

Temperature Scaling

In some cases, you might want softer or harder discrimination between similar items:

# Temperature scaling divides the score before softmax
# Lower temperature = sharper distribution
# Higher temperature = softer distribution

score_raw = u · v
score_scaled = score_raw / temperature

# In loss computation:
# temperature = 0.07 (common for contrastive learning)
# makes the model learn sharper distinctions

loss = -log(exp(score_pos / τ) / Σ exp(score_neg / τ))

Dot Product Geometry

Dot Product in Embedding Space Unit hypersphere (all embeddings live here) User Item A (similar) Item B (dissimilar) θ_small θ_large Item A Score: score = cos(θ_small) ≈ 0.92 (Item highly relevant) Item B Score: score = cos(θ_large) ≈ -0.15 (Item not relevant)

Comparison to Cross-Encoders

Two-tower (dual-encoder) scores items independently. Cross-encoders score user-item pairs jointly:

Aspect Two-Tower (Dual-Encoder) Cross-Encoder
Computation u · v (dot product) Network(concat(u, v))
Speed O(d) per comparison, ANN possible O(d²) per comparison, no ANN
Expressiveness Lower (additive similarity) Higher (joint modeling)
Use Case Retrieval (billions of items) Ranking (hundreds of items)
Latency Sub-millisecond 10-100ms per item
Pipeline Design: Use two-tower for retrieval (get top-1000), then use a cross-encoder or complex ranker on that subset. This gives you scale + quality.

Training: Negative Sampling

The critical challenge in training two-tower models is how to select negative examples. Positives (clicked items) are rare, so you must be strategic about which negatives to use.

The Negative Sampling Problem

In a typical e-commerce platform:

  • User sees ~100 items
  • User clicks ~1-2 items (positive examples)
  • ~98 items are not clicked (potential negatives)

If you treat all non-clicks as negatives, the model learns that "almost everything is irrelevant," which is too easy. You need hard negatives.

In-Batch Negatives

The simplest and most efficient approach: treat other items in the batch as negatives.

In-Batch Negative Sampling Batch of Users: u_1 u_2 u_3 u_4 Batch of Items: i_A i_B i_C i_D Similarity Matrix: i_A i_B i_C i_D u_1 0.85 0.22 0.10 0.15 u_2 0.18 0.91 0.19 0.13 u_3 0.25 0.16 0.88 0.21 u_4 0.12 0.20 0.17 0.82 Positive (clicked) Negative (in-batch) For u_1: i_A is positive, i_B, i_C, i_D are negatives Loss encourages: score(u_1, i_A) >> score(u_1, i_B,C,D) Efficient: one forward pass gives 4 × 4 = 16 similarity scores

Hard Negative Mining

In-batch negatives are fast but can include easy negatives. Hard negatives are items the model scores high but the user didn't interact with:

Hard Negative Strategy: Periodically mine items that the model assigns high scores to, but didn't receive clicks. These are challenging examples that push the model to be more discriminative.

InfoNCE Loss (Contrastive Loss)

The standard loss for two-tower training:

InfoNCE Loss (Normalized Temperature-scaled Cross-Entropy):

L = -log(exp(s_pos / τ) / Σ_j exp(s_j / τ))

Where:
  s_pos = u · v_positive  (similarity with positive item)
  s_j = u · v_j           (similarity with negative items)
  τ = temperature (typically 0.07-0.1)

This is equivalent to softmax cross-entropy where:
  - Positive item should have high probability
  - Negative items should have low probability

As τ → 0: sharper distinction (harder training)
As τ → 1: softer distinction (easier training)

Why In-Batch Negatives Work

  • Efficiency: No extra computation. One batch forward pass gives O(batch_size²) similarity scores.
  • Statistical Effect: Larger batch sizes = more negatives = more challenging loss. Typical: batch size 256-4096.
  • Implicit Hard Negative Mining: If someone clicks item A in one request and item B in another, they might appear in the same batch as negatives for each other, which is a natural hard negative.
Batch Size Matters: With batch size 4096, each positive example has 4095 negatives. This creates a very informative loss signal compared to batch size 32 (31 negatives).

Training Pipeline & Data

Building a production two-tower model requires careful data preparation, debiasing, and training infrastructure.

Positive Examples

What counts as a positive interaction?

  • Click: User explicitly clicked an item. Binary signal.
  • Dwell Time: User spent > N seconds on item. Stronger signal than click alone.
  • Purchase: Strongest signal. User bought the item.
  • Add to Wishlist: Implicit preference signal.
  • Interaction Score: Weighted combination (e.g., 1 click + 2 dwell time + 5 purchase).

Negative Examples

  • Impressions without clicks: Item was shown, user didn't click. "Easy" negatives.
  • Hard negatives: Items with high model scores but no click. Require offline mining.
  • Random negatives: Sample uniformly from catalog. Biased but simple.
  • Popularity-weighted negatives: Sample negatives proportional to their popularity. Helps with debiasing.

Debiasing

Logged interactions have several biases:

Common Biases in Logged Data Position Bias Users click top results more often regardless of quality Popularity Bias Popular items shown more often, collect more clicks Selection Bias Old ranker chose which items to show → distribution shift Debiasing Methods: 1. Position Bias Correction: Weight clicks by inverse of position CTR. If position 1 has 2x CTR, down-weight by 0.5. 2. Popularity Debiasing: Sample negatives uniformly; correct for popularity in loss via importance weighting.

Training Data Format

Typical training example:

{
    "user_id": 12345,
    "item_id": 42,
    "label": 1,  # 1 = positive (click), 0 = negative

    "user_features": {
        "age": 28,
        "gender": "F",
        "location": "NY",
        "device": "mobile",
        "historical_items": [item_1, item_2, ...],
        "historical_avg_dwell": 3.2
    },

    "item_features": {
        "title": "Summer Dress Blue",
        "title_embedding": [0.2, -0.5, ...],  # from BERT
        "category": "Apparel",
        "price": 49.99,
        "ctr": 0.10,
        "popularity_score": 0.85,
        "age_days": 15
    }
}

Online vs Offline Training

  • Offline Training: Train on historical logs once per day/week. Fast, reproducible. But model sees stale data.
  • Online Learning: Update model on live interactions continuously. Captures trends but adds complexity.
  • Hybrid: Offline training + online fine-tuning on recent data. Common in production.

Practical Training Loop

def train_two_tower(user_tower, item_tower, train_loader, num_epochs):
    optimizer = Adam(lr=0.001)
    temperature = 0.07

    for epoch in range(num_epochs):
        total_loss = 0

        for batch_idx, batch in enumerate(train_loader):
            user_feats = batch['user_features']    # Shape: (batch_size, user_feat_dim)
            item_feats = batch['item_features']    # Shape: (batch_size, item_feat_dim)
            labels = batch['label']                # Shape: (batch_size,)

            # Forward pass
            user_emb = user_tower(user_feats)      # (batch_size, emb_dim)
            item_emb = item_tower(item_feats)      # (batch_size, emb_dim)

            # Compute all pairwise similarities (batch_size × batch_size)
            scores = torch.mm(user_emb, item_emb.t())  # Dot product

            # Scale by temperature
            scores = scores / temperature

            # Create labels: diagonal is 1 (positives), rest are 0 (in-batch negatives)
            batch_labels = torch.eye(batch_size)  # Ideally also matches actual labels

            # InfoNCE loss
            loss = F.cross_entropy(scores, batch_labels)

            # Backward
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            total_loss += loss.item()

        print(f"Epoch {epoch} - Avg Loss: {total_loss / len(train_loader):.4f}")
Note: In practice, you might not use exact diagonal labels. Instead, match user-item pairs carefully: if user clicked item, it's positive; otherwise negative. Some pairs might be ambiguous (e.g., user already saw the item).

Approximate Nearest Neighbor (ANN) Serving

After training, you have a user tower and item tower. At serving time, you need to find the top-K items most similar to a user's embedding. With 100M+ items, exact search is infeasible.

The Retrieval Challenge

At query time:

  • User comes with features (ID, demographics, context)
  • Encode into embedding: O(1) operation
  • Find top-1000 items from 100M catalog
  • Latency budget: ~10-50ms

Brute-force: compare user embedding with all 100M item embeddings

# Brute force - too slow! scores = user_embedding @ all_item_embeddings.T # (100M,) operation top_k_idx = np.argsort(scores)[-1000:] # Get top 1000 # Time: ~100M × 256 float multiplies = 25B FLOPs # At 10B FLOPs/sec → ~2.5 seconds per request! # Need < 50ms → need 50× speedup

FAISS IVF-PQ: The Industry Standard

FAISS (Facebook AI Similarity Search) IVF-PQ is the most widely used ANN method:

  • IVF (Inverted File): Cluster item embeddings into buckets. Search only relevant buckets.
  • PQ (Product Quantization): Compress embeddings using learned codebooks. Reduces memory 16-32×.
FAISS IVF-PQ Process Step 1: Clustering (Offline) Cluster 1 Cluster 2 Cluster K ...K buckets via K-means Step 2: Quantization (Offline) Each 256-dim embedding → 8 bytes Split into M subvectors (e.g., M=8) Each subvector → 1 byte (256 centroids) Compression: 256 floats (1KB) → 8 bytes! Step 3: Query Search (Online) 1. Compute distances to K cluster centers ~1ms 2. Select top nprobe clusters (e.g., 10) ~1ms, ~10% of items 3. For items in selected clusters: Decompress from bytes → vector Compute similarity to user embedding ~5-10ms 4. Return top-K items ~20ms total Performance: Search 100M embeddings in ~20ms | 1000× faster than brute-force | 32× memory reduction Accuracy vs speed trade-off: increase nprobe for higher recall

HNSW (Hierarchical Navigable Small World)

Alternative to FAISS, increasingly popular for real-time systems:

  • Graph-based: Items connected in hierarchical graph structure
  • Search: Navigate graph from random start, greedily move toward user embedding
  • Advantages: Fast search (O(log N)), dynamic updates (can add items without reindexing)
  • Disadvantages: Higher memory than IVF-PQ, slower bulk insertion

ScaNN (Scalable Nearest Neighbors)

Google's recent ANN method, optimized for modern CPUs:

  • Combines vector quantization with learnable distance metrics
  • Faster than FAISS on modern hardware (AVX-512, GPU support)
  • Good for hybrid retrieval (dense + sparse features)

Recall vs Latency Trade-off

# FAISS search: nprobe controls recall/latency index = faiss.read_index('item_embeddings.index') # nprobe = 10: search ~10% of clusters index.nprobe = 10 distances, indices = index.search(user_embedding, k=1000) # Recall: ~85%, Latency: ~15ms # nprobe = 50: search ~50% of clusters index.nprobe = 50 distances, indices = index.search(user_embedding, k=1000) # Recall: ~97%, Latency: ~50ms # nprobe = 1000: search all clusters (exact search) index.nprobe = 1000 distances, indices = index.search(user_embedding, k=1000) # Recall: 100%, Latency: ~500ms (too slow!)
Production Setup: Typically use FAISS IVF-PQ with nprobe ~20-50, achieving ~90-95% recall in ~30-40ms. Items are precomputed offline, updated nightly or when new items arrive.

Advanced Techniques

Beyond the basic two-tower architecture, several advanced techniques improve performance in production systems.

Asymmetric Loss

All positives are not equal. Items clicked by many users are less informative (everyone likes them). Rare items need stronger signal:

# Asymmetric loss: down-weight easy positives # popular_item_click_count = 100,000 users clicked this # rare_item_click_count = 10 users clicked this def asymmetric_loss(scores, labels, item_click_counts): # Higher click count → lower weight weight = 1.0 / (1.0 + item_click_counts / 1000) loss = F.cross_entropy(scores, labels, reduction='none') weighted_loss = (loss * weight).mean() return weighted_loss

Popularity Debiasing

Correct for the fact that popular items were shown more often and received more clicks:

# Inverse propensity weighting # propensity = P(item shown) ∝ popularity def debias_loss(scores, labels, item_propensity): # Down-weight clicks on popular items weight = 1.0 / item_propensity loss = F.cross_entropy(scores, labels, reduction='none') debiased_loss = (loss * weight).mean() return debiased_loss

Feature Hashing for High-Cardinality Features

User IDs and item IDs can have billions of unique values. Embeddings are large. Solution: hash to fixed size:

# Feature hashing: map any ID to fixed-size vector def hash_feature(feature_value, num_buckets=100000): hash_idx = hash(feature_value) % num_buckets return hash_idx # Usage: user_id = 999999999 # Very large ID hashed_user_id = hash_feature(user_id) # 0-99999 user_embedding = embedding_lookup[hashed_user_id] # Trade-off: saves memory, but collisions (multiple IDs → same embedding)

Multi-Task Two-Tower

One tower, multiple objectives (CTR, CVR, latency):

# Share user/item embeddings, but predict multiple tasks class MultiTaskTwoTower(nn.Module): def __init__(self): self.user_tower = UserTower(output_dim=256) self.item_tower = ItemTower(output_dim=256) # Task-specific heads self.ctr_head = nn.Linear(256, 1) # CTR prediction self.cvr_head = nn.Linear(256, 1) # Conversion prediction self.latency_head = nn.Linear(256, 1) # Expected latency def forward(self, user_features, item_features): user_emb = self.user_tower(user_features) item_emb = self.item_tower(item_features) # Shared similarity score base_score = torch.sum(user_emb * item_emb, dim=1) # Task-specific predictions ctr_logits = self.ctr_head(user_emb + item_emb) cvr_logits = self.cvr_head(user_emb + item_emb) latency = self.latency_head(user_emb) return base_score, ctr_logits, cvr_logits, latency

Item Freshness & Time Decay

New items should get a boost. Add age-based features:

# Time decay in item features import time def compute_item_freshness(item_creation_time): age_hours = (time.time() - item_creation_time) / 3600 freshness = math.exp(-0.1 * age_hours) # Exponential decay return freshness # Add to item features: item_features = torch.cat([ item_id_embedding, title_embedding, category_embedding, freshness_score ]) # Boosts new items in the embedding

Hard Negative Mining Strategies

Offline Mining: After each training epoch, score all items for each user. Hard negatives are high-scoring items that weren't clicked.

# Offline hard negative mining def mine_hard_negatives(user_embeddings, item_embeddings, interaction_matrix, k_negatives=100): """ interaction_matrix: sparse matrix where 1 = clicked, 0 = not clicked Returns: hard_negatives = items with high scores but label=0 """ scores = user_embeddings @ item_embeddings.T # (num_users, num_items) hard_negatives = {} for user_idx in range(len(user_embeddings)): clicked_items = interaction_matrix[user_idx].nonzero()[1] # Get top-K items by score top_items = np.argsort(scores[user_idx])[-1000:] # Filter to items that weren't clicked hard_negs = [item for item in top_items if item not in clicked_items][:k_negatives] hard_negatives[user_idx] = hard_negs return hard_negatives

Streaming Hard Negative Mining: During training, track items with high scores but label=0 and add them to future batches.

When to Use Two-Tower vs Alternatives

Two-tower is powerful, but not always the best choice. Here's how it compares to other recommendation approaches:

Comparison Table

Approach Scalability Real-time Features Interactions Modeling Use Case
Two-Tower Excellent (ANN) Yes (user features) Limited (dot product) Retrieval at scale
Item CF Good No Item-to-item Cold-start, simple
Matrix Factorization Good No User-item latent Classic rating prediction
BM25 Excellent No None (text search) Keyword search baseline
DeepFM Moderate Yes Excellent Ranking, CTR prediction
Cross-Encoder Poor (O(N·d²)) Yes Excellent Ranking top-100 items

Two-Tower Strengths

Scale

Handle 100M+ items with millisecond latency. ANN indexing enables efficient search.

Real-time Context

Encode user demographics, device, time-of-day at serving time. Always fresh.

Simplicity

Easy to train: symmetric towers, in-batch negatives, standard losses.

Serving Latency

User encoding ~5-10ms, ANN search ~10-50ms. Sub-100ms end-to-end.

Two-Tower Limitations

  • Dot Product Interactions: Can only model simple additive interactions. Can't capture complex cross-features (e.g., user age × item price).
  • Static Item Embeddings: Item embeddings precomputed offline. Can't incorporate real-time item popularity or trending signals easily.
  • Symmetry: User and item embeddings from different towers but in same space. Some recommend learning separate interaction matrices instead.

When to Use Alternatives

Use Item Collaborative Filtering: When you have user-item interaction matrix, want fast similarity, and don't need dense features. Cold-start problem requires hybrid approach.
Use Matrix Factorization: For explicit ratings, when you want interpretable latent factors, or have historical data (movies, music). Less common in modern systems due to cold-start.
Use DeepFM / FFM: For ranking (after retrieval), when you need complex feature interactions, CTR/CVR prediction. See fm_deepfm_guide.html for details.
Use Cross-Encoder: For re-ranking top-100 retrieved items. More powerful than two-tower but 100× slower. Use in final ranking stage.

Typical Hybrid Pipeline

Hybrid Recommendation Pipeline Retrieval Two-Tower ~1000 items Ranking DeepFM/LambdaMART ~100 items Re-ranking Cross-Encoder ~20 items Post-processing Diversity/Policy ~10 items User Sees Final List ~20ms ~30ms ~50ms ~5ms Total latency: ~100ms
Key Insight: Use two-tower for fast retrieval, then progressively more expensive models on smaller subsets. This gives you scale + quality + speed.

Python Implementation

A complete, practical two-tower implementation using NumPy and Pandas (no external ML libraries). This demonstrates the core concepts.

Complete Code

import numpy as np import pandas as pd from collections import defaultdict # ============================================================================ # USER TOWER # ============================================================================ class UserTower: """User embedding tower: transforms user features to fixed-size embedding""" def __init__(self, input_dim=128, hidden_dim=64, output_dim=32, dropout_rate=0.1): self.input_dim = input_dim self.hidden_dim = hidden_dim self.output_dim = output_dim self.dropout_rate = dropout_rate # Initialize weights self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01 self.b1 = np.zeros((1, hidden_dim)) self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01 self.b2 = np.zeros((1, output_dim)) def relu(self, x): return np.maximum(0, x) def relu_grad(self, x): return (x > 0).astype(float) def l2_normalize(self, x): """L2 normalize each row""" return x / (np.linalg.norm(x, axis=1, keepdims=True) + 1e-8) def forward(self, x): """Forward pass: x shape (batch_size, input_dim)""" # Hidden layer z1 = np.dot(x, self.W1) + self.b1 a1 = self.relu(z1) a1 = np.dropout(a1, keep_prob=1-self.dropout_rate) \ if np.random.rand() > 0.9 else a1 # Output layer z2 = np.dot(a1, self.W2) + self.b2 # L2 normalize embedding = self.l2_normalize(z2) return embedding def backward(self, dLdz2, x, z1, a1, learning_rate=0.01): """Simple gradient descent (no momentum)""" batch_size = x.shape[0] # Backprop through output layer dW2 = np.dot(a1.T, dLdz2) / batch_size db2 = np.sum(dLdz2, axis=0, keepdims=True) / batch_size dLdz1 = np.dot(dLdz2, self.W2.T) dLdz1 *= self.relu_grad(z1) # Backprop through hidden layer dW1 = np.dot(x.T, dLdz1) / batch_size db1 = np.sum(dLdz1, axis=0, keepdims=True) / batch_size # Update weights self.W1 -= learning_rate * dW1 self.b1 -= learning_rate * db1 self.W2 -= learning_rate * dW2 self.b2 -= learning_rate * db2 # ============================================================================ # ITEM TOWER # ============================================================================ class ItemTower: """Item embedding tower: transforms item features to fixed-size embedding""" def __init__(self, input_dim=128, hidden_dim=64, output_dim=32, dropout_rate=0.1): self.input_dim = input_dim self.hidden_dim = hidden_dim self.output_dim = output_dim self.dropout_rate = dropout_rate # Initialize weights (same architecture as user tower) self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01 self.b1 = np.zeros((1, hidden_dim)) self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01 self.b2 = np.zeros((1, output_dim)) def relu(self, x): return np.maximum(0, x) def relu_grad(self, x): return (x > 0).astype(float) def l2_normalize(self, x): """L2 normalize each row""" return x / (np.linalg.norm(x, axis=1, keepdims=True) + 1e-8) def forward(self, x): """Forward pass: x shape (batch_size, input_dim)""" z1 = np.dot(x, self.W1) + self.b1 a1 = self.relu(z1) z2 = np.dot(a1, self.W2) + self.b2 embedding = self.l2_normalize(z2) return embedding def backward(self, dLdz2, x, z1, a1, learning_rate=0.01): """Simple gradient descent""" batch_size = x.shape[0] dW2 = np.dot(a1.T, dLdz2) / batch_size db2 = np.sum(dLdz2, axis=0, keepdims=True) / batch_size dLdz1 = np.dot(dLdz2, self.W2.T) dLdz1 *= self.relu_grad(z1) dW1 = np.dot(x.T, dLdz1) / batch_size db1 = np.sum(dLdz1, axis=0, keepdims=True) / batch_size self.W1 -= learning_rate * dW1 self.b1 -= learning_rate * db1 self.W2 -= learning_rate * dW2 self.b2 -= learning_rate * db2 # ============================================================================ # TWO-TOWER MODEL # ============================================================================ class TwoTowerModel: """Complete two-tower recommender with training and inference""" def __init__(self, user_feature_dim=128, item_feature_dim=128, embedding_dim=32, temperature=0.07): self.user_tower = UserTower(input_dim=user_feature_dim, output_dim=embedding_dim) self.item_tower = ItemTower(input_dim=item_feature_dim, output_dim=embedding_dim) self.temperature = temperature self.embedding_dim = embedding_dim # Cache for inference self.user_embedding_cache = {} self.item_embeddings = None self.item_ids = None def compute_similarity_scores(self, user_emb, item_emb): """Dot product: shape (batch_size, batch_size)""" return np.dot(user_emb, item_emb.T) def infonce_loss(self, scores): """ InfoNCE loss: each user's positive is on diagonal scores: (batch_size, batch_size) """ batch_size = scores.shape[0] # Scale by temperature scores = scores / self.temperature # Softmax: exp(score) / sum(exp(scores)) exp_scores = np.exp(scores - np.max(scores, axis=1, keepdims=True)) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # Cross-entropy: -log(prob of positive) # Positive is on diagonal loss = -np.log(np.diag(probs) + 1e-10).mean() return loss, probs def train_epoch(self, user_features, item_features, labels, batch_size=32, learning_rate=0.01): """ Train for one epoch user_features: (num_samples, user_feature_dim) item_features: (num_samples, item_feature_dim) labels: (num_samples,) binary {0, 1} """ num_samples = len(user_features) indices = np.arange(num_samples) np.random.shuffle(indices) total_loss = 0 num_batches = 0 for i in range(0, num_samples, batch_size): batch_indices = indices[i:i+batch_size] user_batch = user_features[batch_indices] item_batch = item_features[batch_indices] # Forward pass user_emb = self.user_tower.forward(user_batch) item_emb = self.item_tower.forward(item_batch) # Compute in-batch negatives scores = self.compute_similarity_scores(user_emb, item_emb) # Compute loss loss, probs = self.infonce_loss(scores) total_loss += loss num_batches += 1 # Note: simplified backprop (full implementation requires # storing activations and computing gradients through towers) if num_batches % 10 == 0: print(f"Batch {num_batches} - Loss: {loss:.4f}") return total_loss / num_batches def precompute_item_embeddings(self, all_item_features, all_item_ids): """Precompute all item embeddings for fast retrieval""" self.item_embeddings = self.item_tower.forward(all_item_features) self.item_ids = all_item_ids print(f"Precomputed embeddings for {len(all_item_ids)} items") def retrieve_top_k(self, user_features, k=10): """Retrieve top-K items for a user (brute-force, no ANN)""" user_emb = self.user_tower.forward(user_features.reshape(1, -1)) # Compute similarity with all items scores = np.dot(user_emb, self.item_embeddings.T).flatten() # Get top-K top_k_indices = np.argsort(scores)[-k:][::-1] top_k_items = self.item_ids[top_k_indices] top_k_scores = scores[top_k_indices] return list(zip(top_k_items, top_k_scores)) # ============================================================================ # SYNTHETIC DATA GENERATION # ============================================================================ def generate_synthetic_data(num_users=1000, num_items=500, user_feature_dim=128, item_feature_dim=128, interaction_density=0.01): """ Generate synthetic user-item interaction dataset """ # Generate random user features user_features = np.random.randn(num_users, user_feature_dim).astype(np.float32) user_features = (user_features - user_features.mean()) / (user_features.std() + 1e-8) # Generate random item features item_features = np.random.randn(num_items, item_feature_dim).astype(np.float32) item_features = (item_features - item_features.mean()) / (item_features.std() + 1e-8) # Generate interactions interactions = [] for user_id in range(num_users): # Randomly select items this user interacted with num_interactions = max(1, int(num_items * interaction_density)) interacted_items = np.random.choice(num_items, size=num_interactions, replace=False) for item_id in interacted_items: interactions.append({ 'user_id': user_id, 'item_id': item_id, 'user_features': user_features[user_id], 'item_features': item_features[item_id], 'label': 1 }) # Add negative examples (non-interactions) num_negatives = len(interactions) * 3 for _ in range(num_negatives): user_id = np.random.randint(0, num_users) item_id = np.random.randint(0, num_items) # Skip if already positive if any(i['user_id'] == user_id and i['item_id'] == item_id for i in interactions): continue interactions.append({ 'user_id': user_id, 'item_id': item_id, 'user_features': user_features[user_id], 'item_features': item_features[item_id], 'label': 0 }) return interactions, user_features, item_features # ============================================================================ # MAIN: TRAIN AND EVALUATE # ============================================================================ if __name__ == '__main__': print("Generating synthetic data...") interactions, all_user_features, all_item_features = \ generate_synthetic_data(num_users=100, num_items=50, user_feature_dim=32, item_feature_dim=32, interaction_density=0.05) print(f"Generated {len(interactions)} interactions") # Prepare training data user_feats_train = np.array([i['user_features'] for i in interactions]) item_feats_train = np.array([i['item_features'] for i in interactions]) labels_train = np.array([i['label'] for i in interactions]) # Create and train model model = TwoTowerModel(user_feature_dim=32, item_feature_dim=32, embedding_dim=16) print("\nTraining...") for epoch in range(3): print(f"\n--- Epoch {epoch + 1} ---") avg_loss = model.train_epoch(user_feats_train, item_feats_train, labels_train, batch_size=32) print(f"Avg Loss: {avg_loss:.4f}") # Precompute item embeddings print("\nPrecomputing item embeddings...") item_ids = np.arange(len(all_item_features)) model.precompute_item_embeddings(all_item_features, item_ids) # Inference: retrieve top-5 items for a test user print("\n--- Inference Example ---") test_user_features = all_user_features[0] print(f"Retrieving top-5 items for user 0") top_items = model.retrieve_top_k(test_user_features, k=5) print(f"Top-5 items: {top_items}") # Compute embedding quality metric (simple: average similarity with positives) print("\n--- Quality Metrics ---") user_emb = model.user_tower.forward(all_user_features[:10]) item_emb = model.item_tower.forward(all_item_features[:10]) scores = np.dot(user_emb, item_emb.T) print(f"Sample scores (first 10x10): \n{scores}") print(f"Mean score: {scores.mean():.4f}") print(f"Max score: {scores.max():.4f}") print(f"Min score: {scores.min():.4f}")

Key Implementation Notes

No Framework Dependency

Uses only NumPy and Pandas. Easier to understand core mechanics without framework abstractions.

Simple Optimization

Basic gradient descent (no momentum/Adam). In production, use optimized frameworks (PyTorch, TensorFlow).

L2 Normalization

Applied after output layer. Essential for stable training and MIPS compatibility.

In-Batch Negatives

Diagonal of similarity matrix = positive pairs. Rest = in-batch negatives for contrastive loss.

Running the Code

python two_tower_simple.py # Output: # Generating synthetic data... # Generated 750 interactions # # Training... # --- Epoch 1 --- # Batch 10 - Loss: 0.5823 # Avg Loss: 0.4921 # # --- Epoch 2 --- # Batch 10 - Loss: 0.3412 # Avg Loss: 0.3156 # # Inference Example # Retrieving top-5 items for user 0 # Top-5 items: [(42, 0.82), (17, 0.79), ...]
Next Steps in Production: Replace this with PyTorch/TensorFlow, add proper evaluation (AUC, Recall@K), implement FAISS indexing for ANN, and add real feature engineering (embeddings, categorical encodings, normalization).
End of Two-Tower Models