Natural Language Processing & Speech 11 Aug 2026

Scaling Semantic Search: From BERT Cross-Encoders to SBERT Bi-Encoders

#Sentence-BERT #Sentence Embeddings #Siamese Networks #BERT #Semantic Textual Similarity #Natural Language Processing #Transfer Learning #Vector Space Model

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

flowchart TD subgraph Inputs ["Input Stage"] S1["Sentence A"] S2["Sentence B"] end subgraph Tokenization ["Preprocessing"] T1["Tokenizer (A)"] T2["Tokenizer (B)"] end subgraph SiameseNetwork ["SBERT Bi-Encoder (Siamese Network)"] direction TB subgraph BERT_A ["BERT Branch A"] B1["BERT Base Model"] P1["Pooling Layer (Mean/CLS/Max)"] end subgraph BERT_B ["BERT Branch B (Tied Weights)"] B2["BERT Base Model"] P2["Pooling Layer (Mean/CLS/Max)"] end B1 -.->|"Shared Weights"| B2 end subgraph Objective ["Objective / Head"] direction TB Concat["Concatenation Layer: (u, v, |u-v|)"] Classifier["Linear Classifier"] Cosine["Cosine Similarity"] end subgraph Outputs ["Final Output"] Label["Class Label (e.g., Entailment)"] Score["Similarity Score"] end %% Data Flow S1 --> T1 S2 --> T2 T1 --> B1 T2 --> B2 B1 --> P1 B2 --> P2 P1 -->|"Embedding u"| Concat P2 -->|"Embedding v"| Concat P1 -->|"Embedding u"| Cosine P2 -->|"Embedding v"| Cosine Concat --> Classifier Classifier --> Label Cosine --> Score %% Styling style SiameseNetwork fill:#f9f9f9,stroke:#333,stroke-width:2px style BERT_A fill:#e1f5fe,stroke:#01579b style BERT_B fill:#e1f5fe,stroke:#01579b style Objective fill:#fff3e0,stroke:#ef6c00

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.

PYTHON
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.