Scaling Semantic Search: From BERT Cross-Encoders to SBERT Bi-Encoders
Scaling Semantic Search: From BERT Cross-Encoders to SBERT Bi-Encoders
In the world of Natural Language Processing (NLP), determining if two sentences mean the same thing—Semantic Textual Similarity (STS)—is a fundamental challenge. While BERT revolutionized how machines understand context, using it for large-scale similarity searches is computationally ruinous.
Enter Sentence-BERT (SBERT).
In this post, we will dive deep into how SBERT transforms the BERT architecture from a slow "Cross-Encoder" into a lightning-fast "Bi-Encoder," enabling semantic searches across millions of documents in milliseconds.
The Bottleneck: Why Standard BERT Fails at Scale
To understand SBERT, we first need to understand the Cross-Encoder problem.
A standard BERT model processes sentence pairs together. To find the most similar sentence to a query in a dataset of 10,000 sentences, BERT must perform 10,000 separate forward passes. Because the transformer's attention mechanism is $O(n^2)$, this is computationally expensive.
The Math of the Problem: If you have $n$ sentences, a Cross-Encoder requires $O(n^2)$ inferences to compute all-pairs similarity. For a dataset of 10k sentences, that's 100 million inferences. This would take hours, if not days, on a single GPU.
The Solution: The SBERT Bi-Encoder Architecture
SBERT solves this by utilizing a Siamese Network structure. Instead of processing pairs, it processes each sentence independently to produce a fixed-size embedding (a vector).
Once you have these vectors, calculating similarity becomes a simple matter of computing the Cosine Similarity between two vectors—a mathematical operation that takes nanoseconds.
The Architecture Workflow
Key Technical Components
1. Pooling Strategies
BERT outputs embeddings for every single token. To get one vector for the entire sentence, SBERT uses a pooling layer:
- CLS-Pooling: Uses the
[CLS]token embedding. - Max-Pooling: Takes the maximum value across the sequence dimension for each feature.
- Mean-Pooling: Averages all token embeddings (typically the most effective for SBERT).
2. Training Objectives
To ensure that similar sentences are close in vector space, SBERT is fine-tuned using specific loss functions:
- Classification Objective: For tasks like Natural Language Inference (NLI), the model concatenates the embeddings $u$, $v$, and their absolute difference $|u-v|$: $$\text{Output} = W_t [u; v; |u - v|]$$
- Regression Objective: Optimizes the cosine similarity directly: $$\text{Similarity} = \cos(u, v) = \frac{u \cdot v}{|u| |v|}$$
- Triplet Loss: Ensures an anchor $s_a$ is closer to a positive example $s_p$ than to a negative example $s_n$: $$\mathcal{L} = \max(0, |s_a - s_p|^2 - |s_a - s_n|^2 + \epsilon)$$
Implementation: Building SBERT with PyTorch
Below is a production-ready implementation of the SBERT architecture. We use a pre-trained MiniLM model for efficiency and implement the Classification objective.
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
from transformers import AutoTokenizer, AutoModel
from sklearn.metrics import cosine_similarity
import numpy as np
class SBERT(nn.Module):
def __init__(self, model_name='sentence-transformers/all-MiniLM-L6-v2', pooling_strategy='mean'):
super(SBERT, self).__init__()
self.bert = AutoModel.from_pretrained(model_name)
self.pooling_strategy = pooling_strategy
self.hidden_size = self.bert.config.hidden_size
# Linear layer for Classification: (u, v, |u-v|) -> 3 labels (NLI)
self.classifier = nn.Linear(self.hidden_size * 3, 3)
def pool(self, token_embeddings, attention_mask):
if self.pooling_strategy == 'cls':
return token_embeddings[:, 0, :]
elif self.pooling_strategy == 'mean':
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, 1)
sum_mask = torch.clamp(input_mask_expanded.sum(1), min=1e-9)
return sum_embeddings / sum_mask
elif self.pooling_strategy == 'max':
token_embeddings = token_embeddings * attention_mask.unsqueeze(-1)
return torch.max(token_embeddings, dim=1)[0]
else:
raise ValueError("Unsupported pooling strategy")
def forward(self, input_a, input_b, mode='classification'):
# Siamese forward pass: Shared weights for both inputs
out_a = self.bert(**input_a)
u = self.pool(out_a.last_hidden_state, input_a['attention_mask'])
out_b = self.bert(**input_b)
v = self.pool(out_b.last_hidden_state, input_b['attention_mask'])
if mode == 'classification':
abs_diff = torch.abs(u - v)
combined = torch.cat([u, v, abs_diff], dim=1)
return self.classifier(combined)
elif mode == 'regression':
return u, v
# --- Execution Pipeline ---
if __name__ == '__main__':
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model_name = 'sentence-transformers/all-MiniLM-L6-v2'
tokenizer = AutoTokenizer.from_pretrained(model_name)
# Dummy Dataset: (Sentence A, Sentence B) -> Label (0: Contradiction, 1: Neutral, 2: Entailment)
train_data = [
("The cat sits outside", "A cat is sitting on the porch"), # Entailment
("The dog is barking", "The cat is sleeping"), # Contradiction
("The weather is sunny", "I like eating apples"), # Neutral
]
train_labels = [2, 0, 1]
# (Dataset and DataLoader setup omitted for brevity - see full code in repo)
# ... [Standard PyTorch Training Loop] ...
# --- Inference: The Bi-Encoder Advantage ---
model.eval()
test_sentences = ["A man is playing guitar", "A person is playing music", "The weather is cold"]
with torch.no_grad():
embeddings = []
for sent in test_sentences:
inputs = tokenizer(sent, return_tensors='pt', padding=True, truncation=True).to(device)
out = model.bert(**inputs)
emb = model.pool(out.last_hidden_state, inputs['attention_mask'])
embeddings.append(emb.cpu().numpy())
embeddings = np.vstack(embeddings)
sim_matrix = cosine_similarity(embeddings)
print(f"Similarity between 'Guitar' and 'Music': {sim_matrix[0][1]:.4f}")
Summary: Cross-Encoder vs. Bi-Encoder
| Feature | Cross-Encoder (BERT) | Bi-Encoder (SBERT) |
|---|---|---|
| Input | Pair of sentences $(S_1, S_2)$ | Single sentence $S_1$ |
| Complexity | $O(n^2)$ for $n$ sentences | $O(n)$ for $n$ sentences |
| Inference Speed | Very Slow (Hours) | Very Fast (Milliseconds) |
| Use Case | High-precision re-ranking | Large-scale retrieval/clustering |
| Storage | No embeddings stored | Embeddings stored in Vector DB |
Final Thoughts
SBERT represents a critical shift in how we deploy Transformers in production. By decoupling the embedding generation from the similarity calculation, SBERT allows us to leverage the power of BERT while maintaining the speed of vector search.
Whether you are building a semantic search engine, a duplicate detection system, or a recommendation engine, the Bi-Encoder architecture is the industry standard for scaling NLP to the real world.