Nils Reimers, Iryna Gurevych (UKP Lab, TU Darmstadt) — arXiv:1908.10084, EMNLP-IJCNLP 2019

Sentence-BERT: Siamese Networks for Sentence Embeddings

BERT could compare two sentences beautifully and could not compare ten thousand of them at all. The fix was not a better model — it was moving the place where the two sentences meet.

Prerequisites: what a dot product is + roughly what a transformer does to a sequence of tokens. Pooling, cosine similarity, contrastive and triplet losses, Spearman correlation, and anisotropy are all built from zero.
10
Chapters
4
Interactive Sims
65h → 5s
The Whole Paper
1M
NLI Training Pairs

Chapter 0: Sixty-Five Hours

You have ten thousand support tickets from last quarter sitting in a table. Your job for the week is small and clear: find the duplicates. Not exact string matches — those you caught with a hash years ago — but the pairs where one person wrote "the app crashes when I open settings" and another wrote "settings screen force-closes the application." Same bug. Different words. Zero characters in common that matter.

You already know what to reach for. It is 2019, BERT is a year old, and BERT is extraordinary at exactly this. On the Semantic Textual Similarity benchmark you feed it both sentences at once, separated by a special token, and it returns a similarity score better than anything before it. The recipe is four lines of code.

input = [CLS] the app crashes when I open settings [SEP] settings screen force-closes the application [SEP]
score = σ( wT BERT(input)[CLS] )

So you write the loop. Every ticket against every other ticket. And then, because you are a careful engineer, you estimate the runtime before you press go.

The arithmetic that ends the plan

How many pairs are there in a set of n items? Pick the first: n choices. Pick the second: n−1 choices. That double-counts, because (A, B) and (B, A) are the same pair, so divide by two.

pairs = n(n − 1) / 2

Put in the number. n = 10,000:

10,000 × 9,999 = 99,990,000  →  99,990,000 / 2 = 49,995,000 pairs

If the formula feels abstract, enumerate it for n = 5. Label the tickets A–E. The pairs are AB, AC, AD, AE, BC, BD, BE, CD, CE, DE — four, then three, then two, then one, which is 4+3+2+1 = 10 = 5×4/2. The pattern is a triangle, and the area of a triangle with side n is n2/2. That "/2" is the only mercy in the whole problem, and it is a constant factor, so it does not help.

Just under fifty million forward passes. Now, how fast is one? A BERT-base forward pass over a pair of sentences padded to 128 tokens takes a modern V100 GPU roughly five milliseconds when batched well — call it about 214 pairs per second. Divide:

49,995,000 / 214 ≈ 233,600 seconds
233,600 / 3,600 ≈ 64.9 hours

The paper opens with this exact number. In its own words: finding, in a collection of n = 10,000 sentences, the pair with the highest similarity requires 49,995,000 inference computations, and "on a modern V100 GPU, this requires about 65 hours."

Sixty-five hours is not the problem. The exponent is. Nobody would ship a job that takes three days, but you could tolerate it once. What you cannot tolerate is that the cost grows with n2. Ten times more tickets is a hundred times more work. And the job never gets done — every new ticket that arrives has to be compared against all ten thousand old ones, which is another 47 seconds of GPU, forever, per ticket.

Watch the exponent eat you

Here is the same calculation at five corpus sizes. The pair count is exact; the time assumes the same 214 pairs per second, which is generous.

Corpus size nPairs = n(n−1)/2Cross-encoder timeIn human units
1,000499,5002,334 s39 minutes — a coffee
10,00049,995,000233,600 s65 hours — a long weekend
100,0004,999,950,0002.34 × 107 s271 days — a research project
1,000,0004.99995 × 10112.34 × 109 s74 years — a career
10,000,0005.0 × 10132.34 × 1011 s7,400 years — a civilisation

Read the last column slowly. A million sentences is not a large corpus. It is one mid-sized company's help desk, or a week of one product's reviews, or the paragraphs of the English Wikipedia's first few thousand articles. The best sentence-comparison model in the world in 2019 could not be pointed at it. Not slowly — at all.

The tell: there is no index. Every database you have ever used answers a query in less than linear time because it built a structure in advance — a B-tree, an inverted index, a hash bucket. A cross-encoder cannot build one, because it has nothing to store. Its output is a score about a pair. There is no artefact belonging to a single sentence that you could sort, bucket, or cache. That is the deep reason the cost is quadratic and non-amortisable, and it is the thing Sentence-BERT fixes.

"Just use keyword search" — why the cheap answer fails

Before accepting that you need a neural model at all, price the classical option. BM25 and TF-IDF cosine build an inverted index: for each word, a list of the documents containing it. A query touches only the documents sharing a word with it, which is a tiny fraction of the corpus, so search is milliseconds over billions of documents. It scales magnificently. It has one flaw.

Return to the two tickets. Tokenise and lowercase, dropping stop-words:

TicketContent words
A{app, crashes, open, settings}
B{settings, screen, force-closes, application}
Intersection{settings} — one word

Jaccard similarity is |A ∩ B| / |A ∪ B| = 1/7 = 0.143. Under TF-IDF the situation is worse, because "settings" is a common word in a support corpus and therefore carries a low inverse-document-frequency weight, while the two words that do carry the meaning — "crashes" and "force-closes" — contribute nothing at all, since they never co-occur. The lexical model scores this duplicate pair near the bottom of the corpus.

This is the vocabulary mismatch problem, and it is not fixable with more index engineering. Stemming does not connect "crash" and "force-close." A thesaurus is hand-built, finite, and domain-blind. The whole point of a learned embedding is to make "crashes" and "force-closes" land near each other because of how they are used, not because someone wrote them on the same line of a synonym file.

ApproachHandles synonyms?Handles paraphrase?Search cost over 1M docs
Exact match / hashNoNoO(1)
BM25 / TF-IDFOnly via shared wordsNoMilliseconds (inverted index)
Cross-encoder BERTYes, superblyYes~83 minutes per query
Bi-encoder (SBERT)YesYesTens of milliseconds

Read the table as a gap in the top-right quadrant. Something that understands paraphrase and can be indexed did not exist for sentences before 2019, and the last row is the hole being filled. (In practice the right answer is often both — BM25 and embeddings retrieve different failures, and fusing their rankings beats either. That is called hybrid search, and it is standard today.)

What the alternative would have to look like

Suppose instead each sentence could be turned into a fixed list of numbers — call it a sentence embedding: a single vector of, say, 768 floating-point numbers that stands in for the whole sentence's meaning. And suppose similarity between two sentences were just the cosine similarity of their vectors — the cosine of the angle between them, which is the dot product after both have been scaled to unit length.

cos(u, v) = (u · v) / (‖u‖ ‖v‖),   u · v = ∑i uivi

Now redo the estimate. Ten thousand sentences, one encoder pass each — not fifty million passes, ten thousand. At roughly 2,000 sentences per second on the same V100, that is five seconds. Then the fifty million comparisons: each is a 768-dimensional dot product, which is 768 multiplications and 767 additions, about 1,536 floating-point operations. All of them together:

49,995,000 × 1,536 ≈ 7.7 × 1010 FLOPs ≈ 0.077 TFLOP

A V100 does around 15 TFLOP/s in fp32 on a dense matrix multiply, and this is a dense matrix multiply — stack the 10,000 vectors into a matrix E of shape (10,000, 768) and every pair score is one entry of E ET. So call it ten milliseconds, plus memory traffic. The whole job:

5 seconds (encode) + 0.01 seconds (compare) ≈ 5 seconds,  versus 233,600 seconds
speed-up ≈ 233,600 / 5 ≈ 46,700×

That is the paper's headline, and it is worth reading in the authors' phrasing because they are careful about the second half: SBERT "reduces the effort for finding the most similar pair from 65 hours with BERT / RoBERTa to about 5 seconds with SBERT, while maintaining the accuracy from BERT." The speed is the easy part. The clause after the comma is the paper.

Where did the four orders of magnitude come from?

Not from a smaller model. Both designs use the same BERT-base underneath. The saving is entirely structural, and you can see it by asking one question: where do the two sentences meet?

Cross-encoder — they meet inside the network
Both sentences enter as one token sequence. Self-attention lets token 4 of sentence A look at token 9 of sentence B at every one of the 12 layers. The comparison is the computation, so the computation must be repeated for every pair. Cost per comparison: one full BERT forward pass.
↓ move the meeting point to the very end
Bi-encoder — they meet in a dot product
Each sentence goes through BERT alone and collapses to one vector. The comparison happens afterwards, outside the network, as arithmetic on two lists of numbers. The network runs n times; the comparison runs n2 times but costs almost nothing each.

Quantify "almost nothing". A transformer forward pass costs roughly 2 × (number of parameters) × (number of tokens) floating-point operations. For BERT-base's ~85M non-embedding parameters over 128 tokens:

2 × 85 × 106 × 128 ≈ 2.2 × 1010 FLOPs per pair (cross-encoder)
2 × 768 ≈ 1.5 × 103 FLOPs per pair (bi-encoder, once vectors exist)
ratio ≈ 2.2×1010 / 1.5×1031.4 × 107

Fourteen million times cheaper per comparison. The bi-encoder still pays the full BERT cost — but only n times, not n(n−1)/2 times, and it pays it once ever, because a vector can be stored. That storage is the index the cross-encoder could never build.

Cross-encoder vs bi-encoder — cost explorer

Both axes are logarithmic. Drag the corpus size and watch the two curves separate: the cross-encoder's line has slope 2 (quadratic), the bi-encoder's has slope 1 (linear) and sits four orders of magnitude lower before it even starts. The second slider adds new sentences after the first run — the cross-encoder pays full price again, the bi-encoder pays only for what is new.

Corpus size n 10,000
New items added 0

Two things to notice while you play. First, in one query vs corpus mode the cross-encoder is linear, not quadratic — a single search over 10,000 tickets takes 47 seconds rather than 65 hours. That is still hopeless for an interactive product, but it tells you the cross-encoder is not universally unusable; it is unusable at the scale where the comparison set is large. Hold on to that, because Chapter 2 turns it into an architecture.

Second, watch the new items added slider. Adding 100 tickets to a 10,000-ticket corpus costs the bi-encoder 100 encodes (0.05 s) plus a thin strip of the similarity matrix. It costs the cross-encoder 100 × 10,000 = 1,000,000 forward passes, or 78 minutes. Incrementality is not a bonus feature of embeddings; it falls out of the same structural change.

Trace one query through both designs

The abstraction "where do the sentences meet" becomes obvious once you walk a single user action through each stack. A support agent types a new ticket; you want the five most similar past tickets, out of 10,000.

tCross-encoderBi-encoder
Before the queryNothing can be done. There is no per-ticket artefact to precomputeAll 10,000 tickets already encoded: a (10000, 768) fp32 matrix, 29 MB, sitting in RAM
Step 1Build 10,000 token sequences: [CLS] new [SEP] oldi [SEP]Tokenise the one new ticket
Step 210,000 BERT forward passes, batched 32 at a time = 313 batches1 BERT forward pass
Step 310,000 scalar scores1 vector (768,), normalise it
Step 4Sort, take 5One matrix-vector product: (10000, 768) @ (768,) = 7.7 MFLOP. Sort, take 5
Latency~47 seconds~6 ms
AfterwardsThe 10,000 scores are for this ticket only. The next ticket repeats everythingAppend the new ticket's vector to the matrix. The corpus is now 10,001 and cost nothing

Row 1 is where the whole difference lives, and it is a row about time, not about arithmetic. The bi-encoder is allowed to do work before the question is asked. The cross-encoder is not, because its unit of work is a pair, and half of every pair only exists at query time.

The generalisable idea: precomputation requires factorisation. Any time you want to move work from query time to index time, you must find a way to write the answer as a combination of things that each depend on only one input. That is what makes the bi-encoder possible, and it is the same principle behind materialised views, feature stores, and the separation of a compiled query plan from its parameters. It is also exactly what you give up — Chapter 2 makes the loss precise.

So why did nobody just do this in 2018?

They did. That is the uncomfortable part. You could always take BERT's output vectors and average them — three lines of code, available the day BERT was released. The reason the field did not consider the problem solved is that the resulting vectors were bad. Not slightly worse. Worse than averaging GloVe word vectors, a method from 2014 that involves no neural network at inference at all.

MethodAverage Spearman correlation with human similarity judgements, across 7 STS datasets
Average of GloVe word vectors (2014)61.32
Average of BERT token vectors (2018)54.81
BERT's [CLS] token vector29.19
SBERT-base, this paper74.89

Look at the third row. The [CLS] vector — the one everybody's diagram labels "the sentence representation" — correlates with human judgement at 29 out of 100. That is closer to noise than to GloVe. Chapter 1 is entirely about why, because if you understand that number you understand what a sentence embedding actually is, and the rest of the paper becomes obvious.

The claim this lesson will hold to account. If you fine-tune BERT inside a siamese structure — two copies with tied weights, one sentence each — with a loss that operates on the two output vectors, then the resulting vectors become genuinely comparable by cosine similarity. Correlation with human similarity judgements goes from 54.81 (naive mean pooling) to 74.89. Cost of the fine-tune: about twenty minutes on one V100, on data that already existed. The comparison cost at inference drops by 46,700×.

The same quadratic, in three other fields

This is not an NLP problem. It is the all-pairs problem, and every field that hits it has invented the same escape, which is worth knowing because it tells you what SBERT is in general terms.

FieldThe all-pairs taskThe classical escapeWhat the escape is
DatabasesEntity resolution: which customer records are the same person?Blocking — compare only records sharing a key (same postcode, same surname initial)A cheap, precomputable function that partitions the space
Web crawlingNear-duplicate detection across billions of pagesMinHash / SimHash — a fingerprint per page; compare fingerprintsA cheap, precomputable function whose collisions correlate with similarity
Computational biologyWhich of these sequences are homologous?Seed-and-extend (BLAST) — find exact short matches first, run the expensive alignment only thereA cheap filter followed by an expensive scorer
Computer visionWhich of these faces are the same person?Face embeddings (FaceNet, 2015) — one vector per face, compare by distanceExactly SBERT's move, four years earlier, in another modality
NLP, 2019Which of these sentences mean the same?Sentence embeddings

Every row is the same two-step: replace an expensive pairwise predicate with a cheap per-item function, then either compare the cheap outputs directly or use them to shortlist. Blocking, MinHash and BLAST all do it with hand-designed functions; the embedding row does it with a learned one. That is the only difference, and it is what buys the ability to handle paraphrase, which no hand-designed key can.

Which also predicts SBERT's weakness, before we have seen any of its numbers. Blocking misses records whose key differs. MinHash misses pages that share meaning but no shingles. BLAST misses homologies with no exact seed. Every cheap filter has a recall failure mode — things it will never surface, no matter what runs afterwards. Chapter 2's retrieve-then-rerank inherits it exactly, and Chapter 7's cross-topic result is what it looks like when measured.

The specification, before the solution

It is worth writing down what a fix has to satisfy, because the list is short and it rules out almost everything.

RequirementWhyWhat it rules out
The score must factorise through a per-sentence functionOtherwise there is no artefact to store, and you are back to n2 forward passesEvery cross-encoder, however fast
The per-sentence artefact must be fixed-sizeSo a corpus is a matrix, and comparison is a matmul that hardware is good atVariable-length representations (unless you accept ColBERT's storage bill)
The comparison must be parameter-freeA learned scorer that must run per pair reintroduces the n2 cost, in miniatureAnything but a dot product, cosine, or Euclidean distance
Similarity must survive paraphraseOtherwise BM25 already wins on costBag-of-words methods
Adding a sentence must be O(1) in corpus sizeCorpora grow continuously; a re-sweep per insert is not a systemAnything that scores against the existing corpus at write time

Only one shape satisfies all five: encode each sentence independently into a fixed vector, and compare with a fixed geometric function. Once you write the specification, the architecture is forced. What is not forced — and what Chapter 1 shows was the actual open problem — is how to make such vectors any good.

The three things the paper contributes

1. A diagnosis
Naive BERT sentence vectors are bad, and the paper measures exactly how bad against a strong 2014 baseline. Nobody had put that number in a table before.
2. A structure
A siamese / triplet network around BERT, with a pooling layer and one of three objective functions, that produces vectors whose cosine means something.
3. An evaluation
Seven STS datasets, SentEval's transfer suite, argument similarity, Wikipedia section triplets — plus the ablations on pooling and on the concatenation features that tell you which design choices carried the result.

Notice what is not on that list: a new loss function, a new architecture, a new pretraining corpus. The siamese network dates to 1993 (Bromley et al., signature verification). Training sentence encoders on natural-language-inference data was InferSent's idea in 2017. Triplet loss is from face recognition. Reimers and Gurevych assembled known parts and measured carefully, and the result is one of the most-used models in applied NLP. Novelty and impact are different axes; this paper is the cleanest example in the field.

Sanity-check the 214 pairs per second

Never accept a throughput figure you have not sanity-checked, because it is the number every other estimate in this chapter inherits. BERT-base has about 85M non-embedding parameters. A forward pass costs roughly 2 FLOPs per parameter per token:

2 × 85 × 106 × 128 tokens ≈ 2.2 × 1010 FLOPs per pair
at 214 pairs/s → 214 × 2.2 × 10104.7 × 1012 FLOP/s = 4.7 TFLOP/s

A V100 delivers about 15.7 TFLOP/s in fp32 and up to 125 TFLOP/s using tensor cores in mixed precision. So 4.7 TFLOP/s is roughly 30% of the fp32 peak — a completely ordinary utilisation for a transformer with attention, layer norms, and softmaxes that are memory-bound rather than compute-bound. The number is consistent.

Which also tells you the ceiling on optimising your way out. Suppose you push utilisation to 100% of fp32 peak: 65 hours becomes 20. Add mixed precision for another 4×: 5 hours. Buy eight GPUs: 40 minutes. You have spent significant money and engineering to make a 10,000-item job tolerable, and the moment the corpus reaches 100,000 the quadratic eats all of it and you are back to 68 hours. Constant factors cannot fix an exponent. That sentence is the entire reason this paper exists.

What the five seconds is actually made of

One more decomposition, because "5 seconds" hides which part is amortisable and that is the whole economics of the design.

ComponentCost for n = 10,000Paid how often?
Tokenisation~0.2 s (CPU)Once per document, ever
Encoder forward passes4.9 sOnce per document, ever — 98% of the total
L2 normalisation~0.001 sOnce per document
Storage write (29 MB fp32)~0.03 sOnce
All-pairs matmul~0.01 sEvery time you ask
Threshold + collect~0.05 sEvery time you ask

Ninety-eight percent of the five seconds is in a row you pay once. Ask the same question tomorrow and the job costs sixty milliseconds. Change the threshold and re-run: sixty milliseconds. Ask a different question — "cluster these", "find this one's neighbours", "which pairs exceed 0.9" — sixty milliseconds, because all of them read the same stored matrix.

That is the property the cross-encoder can never have, stated as an economic fact. Its 65 hours is 100% recurring. Every question is a fresh 65 hours, every threshold change is a fresh 65 hours, and a second kind of question is a second 65 hours. The bi-encoder converts a recurring cost into a one-off one plus a rounding error — and once the cost is one-off, entire categories of feature (clustering, mining, live search, deduplication) become affordable together rather than one at a time.

The paper at a glance

FieldValue
AuthorsNils Reimers, Iryna Gurevych — UKP Lab, TU Darmstadt
VenueEMNLP-IJCNLP 2019 (arXiv 1908.10084, August 2019)
Base encoderBERT-base / BERT-large / RoBERTa, unmodified
Added parametersZero at inference. 6,912 during training, then discarded
Training dataSNLI (570k) + MultiNLI (430k), one epoch
Training costUnder 20 minutes on one V100
Headline speed result65 hours → ~5 seconds for the all-pairs task on 10,000 sentences
Headline quality resultAverage STS Spearman 54.81 → 74.89
Honest counter-resultThe cross-encoder still wins STS-B by 2.98 points, and by ~7 under domain shift
Lasting artefactThe sentence-transformers library — arguably more influential than the paper

That last row is not a joke. The paper's ideas are assembled from prior work; what made them universal is that model.encode(sentences) became a single line that returned good vectors, with a hub of pretrained checkpoints behind it. Research becomes infrastructure when the API is short enough, and a great deal of the modern retrieval stack traces back to that one method signature.

What each chapter does

Chapters 1–3 — diagnose, then build
Why raw BERT vectors fail (with the numbers) → cross-encoder versus bi-encoder as a design space, not a ranking → the siamese structure and the pooling ablation that decided MEAN
Chapters 4–6 — the machinery, derived
All three objective functions with their gradients worked out → why entailment data teaches meaning → then the whole pipeline by hand on 4-dimensional vectors, every number visible
Chapters 7–9 — interrogate it
Every results table including the ones where the cross-encoder still wins → the production patterns SBERT unlocked → where it breaks, and the SimCSE line that grew out of the break

Why August 2019, and not earlier

The ingredients were all available before this paper, which raises a fair question about timing. Lay out the dates:

DateEventWhat it made possible
1993Siamese networks (Bromley et al., signature verification)The structure
2015SNLI releasedThe data
2015FaceNet: triplet loss on embeddingsThe objective, proven in vision
2017InferSent: siamese BiLSTM on SNLIThe exact recipe, on a weaker encoder
Oct 2018BERT releasedThe missing piece: a strong pretrained encoder
Aug 2019Sentence-BERTTen months later

Ten months is roughly how long it took the field to work through BERT's obvious applications and reach the non-obvious one. And the reason it was non-obvious is Chapter 1's finding — the naive attempt (mean-pool BERT and compare) fails, and fails badly enough to look like a dead end rather than a missing ingredient. A great many people tried it in early 2019, saw a number worse than GloVe, and concluded that BERT's vectors were unsuitable for similarity.

That is the reusable lesson about negative results. The naive attempt failing is evidence about the naive attempt, not about the idea. The correct next question is why it failed, and here the answer — no pair-level pressure was ever applied — contains the fix in its statement. A paper's contribution is often not a new mechanism but a correct diagnosis of why an obvious thing did not work, plus the twenty-minute repair that follows.

Three ways this chapter gets misread

"Cross-encoders are obsolete." They are not, and Chapter 7 prints the table where one beats SBERT by three points on STS-B and about seven under domain shift. What is obsolete is using a cross-encoder as your only stage over a large corpus. As a second stage over 100 candidates it is the highest-value 50 ms in most retrieval systems.

"The bi-encoder is faster because it is a smaller model." It is the same BERT-base, the same 110M parameters, the same twelve layers. Encoding one sentence costs about what half a cross-encoder pass costs. The saving is not per-call; it is that the call happens n times instead of n2/2 times, and that its result can be kept.

"Five seconds is the model being fast." Five seconds is 10,000 encodes at 2,042 per second. The comparison part — fifty million similarity scores — is the ten milliseconds at the end. Almost all of the remaining cost is in building the index, which is exactly the cost that amortises: run the same job tomorrow on the same corpus and it takes ten milliseconds, because the encodes are already done.

Which suggests the right mental model for the rest of the lesson. Do not think of SBERT as "a faster way to compare two sentences." Think of it as a compiler: it turns each sentence, once, into a form in which comparison is nearly free. Compilation is expensive and happens once; execution is cheap and happens constantly. Every design decision in the following chapters — pooling, normalising, which objective, what to store — is a decision about the compiled representation, and its quality determines what execution can possibly achieve.
A cross-encoder and a bi-encoder both wrap the same BERT-base. Why is the bi-encoder roughly four orders of magnitude cheaper for an all-pairs job?

Chapter 1: BERT's Bad Vectors

Chapter 0 left a puzzle on the table. BERT is the best sentence-pair model in the world, and its own sentence vectors are worse than averaging word vectors from 2014. Both statements are true simultaneously. Resolving that contradiction is the whole intellectual content of this paper, so we are going to take it slowly.

What "a BERT sentence vector" even means

BERT does not output a sentence vector. It outputs a sequence of vectors — one per token, each 768-dimensional for the base model. Feed it a 9-token sentence and you get back a tensor of shape (9, 768). To get one vector you have to pool: collapse the token axis. Two obvious ways were in universal use.

tokens → BERT → H ∈ RL×768

CLS pooling:   u = H0  (the vector above the [CLS] token)
MEAN pooling:  u = (1/L) ∑i=1L Hi

The [CLS] token is a special symbol BERT prepends to every input. During pretraining, the vector above it is fed to a classifier for the next-sentence-prediction task — a binary question, "did these two segments actually appear next to each other in the corpus?" So [CLS] is the one position that was explicitly trained to summarise the whole input. Every tutorial diagram in 2019 labelled it "the sentence representation." It seemed obvious.

The number that should stop you

Here is the paper's Table 1 in full: Spearman rank correlation between each method's cosine similarities and human similarity ratings, multiplied by 100, over the seven standard Semantic Textual Similarity datasets. None of these models were trained on STS data — this is pure out-of-the-box quality.

ModelSTS12STS13STS14STS15STS16STS-BSICK-RAvg.
Avg. GloVe embeddings55.1470.6659.7368.2563.6658.0253.7661.32
Avg. BERT embeddings38.7857.9857.9863.1561.0646.3558.4054.81
BERT [CLS] vector20.1630.0120.0936.8838.0816.5042.6329.19
InferSent — GloVe52.8666.7562.1572.7766.8768.0365.6565.01
Universal Sentence Encoder64.4967.8064.6176.8373.1874.9276.6971.22
SBERT-NLI-base70.9776.5373.1979.0974.3077.0372.9174.89
SBERT-NLI-large72.2778.4674.9080.9976.2579.2373.7576.55

The paper's own summary is blunt: using the output of BERT directly "leads to rather poor performances… the resulting sentence embeddings are worse than averaging GloVe embeddings."

Follow one MLM gradient, with numbers

The claim "the loss never sees a pair" is easy to assert. Compute it instead, on a vocabulary of four words so every number fits on the page. Suppose the model must fill the blank in "the cat sat on the ___", the true word is mat, and the hidden vector at that masked position is H = (0.5, −0.2, 0.3). The output matrix Wvocab has one row per word:

WordRow of WvocabLogit = row · H
mat(1.0, 0.0, 0.5)0.50 + 0.00 + 0.15 = 0.65
floor(0.8, 0.2, 0.4)0.40 − 0.04 + 0.12 = 0.48
sky(−0.5, 1.0, 0.0)−0.25 − 0.20 + 0.00 = −0.45
run(0.0, −0.6, −1.0)0.00 + 0.12 − 0.30 = −0.18
e0.65 = 1.9155 ,  e0.48 = 1.6161 ,  e−0.45 = 0.6376 ,  e−0.18 = 0.8353 ,  sum = 5.0045
p = (0.3827, 0.3229, 0.1274, 0.1669) ,   L = −ln(0.3827) = 0.9605

Now the gradient into the hidden vector. With δ = p − y = (−0.6173, 0.3229, 0.1274, 0.1669):

∂L/∂H = WvocabTδ
dim 0: 1.0(−0.6173) + 0.8(0.3229) + (−0.5)(0.1274) + 0.0(0.1669) = −0.6173 + 0.2583 − 0.0637 = −0.4227
dim 1: 0.0(−0.6173) + 0.2(0.3229) + 1.0(0.1274) + (−0.6)(0.1669) = 0.0646 + 0.1274 − 0.1001 = 0.0919
dim 2: 0.5(−0.6173) + 0.4(0.3229) + 0.0(0.1274) + (−1.0)(0.1669) = −0.3087 + 0.1292 − 0.1669 = −0.3464

Look hard at what that vector is made of: rows of the vocabulary matrix, weighted by how much probability mass each word currently holds. Descent moves H toward the "mat" row and away from the others. Every quantity involved — H, Wvocab, y — belongs to one sentence. There is no second sentence anywhere in the computation, so there is no term that could possibly reward or punish the distance between two sentence representations.

Say it as a physical claim. Backpropagation only changes weights in directions that reduce the loss. If a quantity never appears in the loss, no gradient ever points along it. The distance between two sentence embeddings is exactly such a quantity for MLM — it could be anything at all and L would not move by one bit. So the resulting geometry is not "wrong," it is unconstrained: whatever fell out of optimising a different objective. The surprise is not that it is bad. The surprise is that it is as good as 54.81.

Compare the same calculation for GloVe. Its loss is J = ∑ij f(Xij)(wi · w̃j + bi + b̃j − log Xij)2 — and there, right in the middle, is wi · w̃j: a dot product between two vectors, fit to a target. The geometry is directly supervised at the word level. That single structural difference is worth 6.51 STS points to a method with no neural network at inference time.

Notice how much worse the [CLS] row is. On STS-B it scores 16.50 — a correlation so low that shuffling the model's answers would barely change it. The position everyone pointed at as "the sentence vector" is the worst of all the options tested. That is not a small empirical detail; it is a sign that our mental model of what BERT was doing was wrong.

The core realisation, stated once and then justified three ways. BERT's pretraining objective never once asks it to compare two sentences as points in a space. Masked language modelling asks "what word is missing here?" Next-sentence-prediction asks "were these adjacent?" Neither loss is ever a function of the distance between two sentence representations. So nothing during pretraining exerts any pressure on that distance to be meaningful. It is not that BERT learned a bad geometry — it is that BERT was never asked to learn a geometry at all.

Justification 1: follow the gradient of the MLM loss

Be concrete about what masked language modelling optimises. You take a sentence, replace 15% of tokens with [MASK], and predict the originals. For a masked position i with true token t:

LMLM = − log p(t | context) ,   p = softmax(Wvocab Hi) ,   Wvocab ∈ R30522×768

Every gradient the encoder receives arrives through Hi, the vector at a masked position, and it says exactly one thing: "make this vector line up better with the row of Wvocab for the word that belongs here." The loss is a function of one sentence. It is never a function of two.

So ask: what would make a token vector good under this loss? Being predictive of the missing word. That rewards representations that carry local syntax, collocation, and word identity strongly — the very things that let you guess a blank. It does not reward, or punish, anything about how the sentence as a whole relates to a different sentence. A geometry in which "the app crashes" sits near "the application force-closes" is neither encouraged nor discouraged. It is simply outside the loss's field of view.

Contrast with what a similarity space needs. For cosine to mean "same meaning", the training signal must at some point be a function of a pair of sentence representations, and it must penalise the model when two sentences that mean the same thing sit far apart. That single sentence is the entire design of Sentence-BERT. Everything else — siamese weights, pooling, three objectives — is plumbing to make that pressure applicable.

Justification 2: the [CLS] slot was trained for a task nobody kept

Why is [CLS] so much worse than the mean? Its pretraining signal came from next-sentence-prediction, which asks a binary question about adjacency. A model can score well on NSP by detecting topic continuity — do these segments share a subject? — which the literature later showed is a weak, partly-trivial task. RoBERTa dropped NSP entirely a few months after BERT, with no loss in downstream quality.

So the [CLS] vector is optimised to answer a shallow topical question, and it is optimised for consumption by a trained classifier head sitting on top of it, not for direct geometric comparison. A vector can be a perfect input to a learned linear map and still have a useless metric structure: the classifier can undo any rotation, rescaling, or coordinate-wise weighting you like. Cosine similarity cannot. It takes the coordinates literally.

Consumer of the vectorWhat it can compensate forWhat it therefore does not force the encoder to fix
A trained linear classifier (fine-tuning)Arbitrary rotation, per-dimension scaling, unused dimensions, constant offsetsAlmost everything about the geometry — only linear separability of the target labels matters
Cosine similarity (no parameters)Nothing. Every coordinate contributes to the dot product as-isNothing — direction, relative scale between dimensions, and the common offset all matter

This table is the crux. Fine-tuning is forgiving; cosine is not. BERT was built to be fine-tuned, so its representations were never held to the standard cosine imposes. Sentence-BERT's whole contribution is to hold them to it, during training, so that at inference nothing has to.

An aside that saves you a week: which layer?

A natural first reaction to "BERT's vectors are bad" is "you took them from the wrong layer." It is a good instinct and it has been tested exhaustively. The short version: pooling from the second-to-last layer is usually marginally better than the last, averaging the last four layers is usually marginally better still, and all of these variants land within a few points of each other — nowhere near closing the 20-point gap to a fine-tuned model.

The reason is the one we have already established. Every layer of BERT was shaped by the same objective, and that objective never looked at a pair of sentences. Changing which un-supervised layer you read from is choosing between shades of the same absence. The paper does not chase this, and neither should you: it is the highest-effort, lowest-return knob in the whole area.

A useful diagnostic, though. The last layer is the most specialised toward the pretraining task — its job is to feed Wvocab and predict masked words — so it carries a measurable amount of "which token was here" information that is useless for sentence meaning. That is why layer −2 sometimes edges out layer −1. If you ever find yourself using a frozen encoder with no fine-tuning at all, take the mean of layers −1 through −4 and centre the result; it is the strongest thing you can do without gradients.

Justification 3: anisotropy — the crowded cone

There is a third, purely geometric reason, and it is worth deriving because it explains a bug you will hit yourself. Contextual embedding spaces are anisotropic: instead of spreading out in all directions, the vectors occupy a narrow cone. Ethayarajh (2019) measured this — in BERT's upper layers, two randomly chosen words from random sentences have an average cosine similarity far above zero, often 0.4 to 0.6, when a well-spread space would give roughly 0.

Here is why a common component wrecks cosine, worked in numbers. Take two genuinely unrelated 4-dimensional "meanings" a and b, plus a large shared component c that every vector has because the space is coned:

a = (1, 0, −1, 0) ,  b = (0, 1, 0, −1) ,  c = (3, 3, 3, 3)

Their meanings are orthogonal: a · b = 1(0) + 0(1) + (−1)(0) + 0(−1) = 0, so cos(a, b) = 0. Perfect. Now what the model actually emits is u = c + a and v = c + b:

u = (4, 3, 2, 3) ,   v = (3, 4, 3, 2)

Dot product: 4(3) + 3(4) + 2(3) + 3(2) = 12 + 12 + 6 + 6 = 36. Norms: ‖u‖ = √(16+9+4+9) = √38 = 6.164, and ‖v‖ = √(9+16+9+4) = √38 = 6.164. So:

cos(u, v) = 36 / (6.164 × 6.164) = 36 / 38 = 0.947

Two sentences with orthogonal meanings score 0.947. The shared offset c has ‖c‖2 = 36 and dominates the dot product completely; the meaning-carrying parts contribute exactly 0 of the 36. Every pair in your corpus will score between roughly 0.9 and 1.0, and the ranking among them is decided by the tiny residual — which is exactly the regime where noise wins.

Realisation note — you will meet this as a bug. The first symptom of anisotropy in production is not bad results, it is a useless threshold. You compute similarities, find that everything is between 0.88 and 0.99, and pick 0.95 as "duplicate". It works on your dev set and fails on the next batch, because the mean similarity of the space drifts with sentence length, domain, and model version. If you ever find yourself tuning a cosine threshold in the third decimal place, you are measuring a cone, not a meaning. Subtracting the corpus mean (centring) removes c and typically fixes half the problem for free — a trick the later BERT-flow and whitening papers turned into a method.

Run the centring on our example to see it. The corpus mean here is (u+v)/2 = (3.5, 3.5, 2.5, 2.5). Subtract:

u' = (0.5, −0.5, −0.5, 0.5) ,   v' = (−0.5, 0.5, 0.5, −0.5)
u' · v' = −0.25 − 0.25 − 0.25 − 0.25 = −1 ,   ‖u'‖ = ‖v'‖ = 1
cos(u', v') = −1

Now they are maximally dissimilar — an overcorrection caused by having only two points to estimate the mean from, but the direction of the fix is unmistakable. Removing the common component restored the signal that the raw cosine had buried.

What the cosine of two random vectors should be

To know that 0.947 is pathological you need a baseline, and it is derivable. Take two vectors whose coordinates are independent draws from any zero-mean distribution in d dimensions. Their dot product is a sum of d independent zero-mean terms, so its expectation is 0 and its standard deviation grows like √d. Each norm grows like √d as well. So:

E[ cos(u, v) ] = 0 ,    sd[ cos(u, v) ] ≈ 1 / √d

Put d = 768 in: 1/√768 = 0.036. In a healthy, isotropic 768-dimensional space, two unrelated sentences should score about 0.00 with a typical deviation of 0.04, so essentially everything unrelated lands in [−0.11, +0.11]. That is the yardstick.

SpaceTypical cosine of two unrelated sentencesDiagnosis
Ideal isotropic, d = 7680.00 ± 0.04The whole [−1, 1] range is available for signal
Raw mean-pooled BERT~0.6–0.8Severe cone. Usable range is a sliver
SBERT-NLI~0.25–0.40Much better, still not centred — Chapter 9's limit 2
SimCSE / modern contrastive~0.05–0.20The uniformity term in InfoNCE explicitly pushes this down

This table is also the practical reason a similarity threshold cannot be copied between models. A cosine of 0.72 is a strong match in a SimCSE space and an unremarkable one in raw BERT. Always measure your own model's floor before choosing a number, a discipline Chapter 8 turns into a procedure.

What people tried instead, in 2019

AttemptIdeaWhy it fell short
Better poolingMax instead of mean, weighted by IDF, attention-pooledReshuffles the same unsupervised geometry. Worth a couple of points at most
Different layerSecond-to-last, or an average of the last fourSame objection — every layer was trained by the same pair-blind loss
Remove top principal componentsArora et al.'s post-processing for word vectors, applied to sentencesGenuinely helps (it is centring plus a bit), but cannot add information
Use the cross-encoder anywayPrecompute all pair scores offline overnightChapter 0: 65 hours for 10k, and re-run on every insert
Fine-tune on pairsPut a pair-level loss on the pooled vectors+20 points, twenty minutes — this paper

The first three rows share a shape: they treat the problem as a post-processing problem. The insight of the paper is that it is a training problem, and that the training required is astonishingly cheap once you notice.

Why averaging GloVe does better than averaging BERT

This is the detail that makes the table feel unfair, so let us be precise. GloVe vectors are trained on a co-occurrence objective: the dot product of two word vectors is fit to the log of how often those words appear together. The loss is a function of a dot product between two vectors. So GloVe's geometry was directly supervised, at the word level, and the space is far more isotropic as a result.

Averaging is then a surprisingly good sentence operation for such a space: it is a low-variance estimator of "what this sentence is about", it inherits the word-level geometry, and it degrades gracefully. It throws away word order entirely — "dog bites man" and "man bites dog" get identical vectors — which caps it at 61.32, but 61.32 of honest signal beats 54.81 of coned noise.

MethodWas a distance/dot product ever in the loss?Word order?Result
GloVe averageYes — at the word level, directlyLost61.32 — honest but shallow
BERT mean-poolNoKept inside the encoder54.81 — rich but ungeometric
BERT [CLS]No, and its own task (NSP) was weakKept29.19 — near noise
InferSentYes — NLI pairs through a shared BiLSTMKept65.01
Universal Sentence EncoderYes — multi-task, incl. conversational input-responseKept71.22
SBERTYes — NLI pairs through a shared BERTKept74.89

Read the second column top to bottom and the ranking in the last column stops looking mysterious. Every method that ever had a pair-level signal in its loss beats every method that did not, regardless of how good the underlying encoder is. Encoder quality is worth something — SBERT beats InferSent by 10 points using the same NLI data, because BERT is a better encoder than a BiLSTM — but only after the pair-level pressure is applied.

Two more diagnostics worth knowing

The mean random cosine is the first thing to measure, and two others tell you about failure modes it misses.

Hubness. In high-dimensional spaces, some points become the nearest neighbour of an unreasonable number of other points — they are hubs. Count, for each vector, how many other vectors have it in their top-10. In a healthy space, that count is roughly 10 for everyone. In a coned space, a handful of vectors near the cone's axis will appear in hundreds of top-10 lists while others appear in none.

The user-visible symptom is unmistakable once you know it: the same three documents come back for everything. Teams usually diagnose this as "those documents are too generic" and try to delete them. The real cause is geometric — those documents sit closest to the mean of the space, so they are near-ish to everything. Centring fixes a good deal of it; a stronger objective fixes more.

Intrinsic dimension. Your vectors have 768 coordinates, but how many directions do they actually use? Run a PCA over a corpus sample and ask how many components explain 95% of the variance.

Components for 95% variance (of 768)Interpretation
< 20Severe collapse. The model is using a tiny subspace — usually a positives-only loss with too few negatives
50–150Typical for a well-trained sentence encoder. Most of 768 is genuinely redundant
> 400Either an excellent space or noise. Check alignment on known-positive pairs to tell which

The middle row is why 384-dimensional models work nearly as well as 768-dimensional ones, and why Matryoshka truncation is possible at all: the extra coordinates were mostly carrying variance nobody was using. It is also a fast sanity check after any fine-tune — if the intrinsic dimension collapsed, so did your model, whatever the loss curve said.

Reading the columns, not just the average

Averages hide things. Two columns in Chapter 1's table deserve individual attention, because each teaches something the average erases.

SICK-R. Mean-pooled BERT scores 58.40 here — and averaged GloVe scores 53.76. This is the one dataset where naive BERT beats the GloVe baseline. Why? SICK-R was built by taking image-caption sentences and applying controlled linguistic transformations: passivisation, negation, quantifier substitution, word-order changes. Pairs differ by syntax at fixed vocabulary. A bag of word vectors is blind to that by construction — "a man is not playing guitar" and "a man is playing guitar" produce nearly the same GloVe average — while BERT's contextual vectors carry at least some of it. So on the one benchmark where structure matters more than vocabulary, the contextual encoder's advantage shows through even without pair supervision.

STS12. The hardest column for every model: GloVe 55.14, mean-BERT 38.78, SBERT 70.97. STS12 is the oldest and most heterogeneous of the sets — it mixes MSRpar news paraphrases with SMTeuroparl machine-translation outputs and WordNet gloss pairs, so a model must handle several genres with one metric. The 32-point spread between mean-BERT and SBERT on this column alone is the single strongest evidence for the paper's thesis, because heterogeneity is precisely what an unsupervised geometry handles worst.

ObservationWhat it tells you
mean-BERT beats GloVe only on SICK-RThe contextual encoder's advantage is real but is about structure, and it is swamped by geometric problems everywhere else
The [CLS] row is uniformly terrible — 16.50 to 42.63Not a dataset quirk. The slot itself is the problem
SBERT's gain is largest on the most heterogeneous setPair supervision buys generality, not just calibration
SBERT's smallest advantage is on SICK-R (72.91 vs USE's 76.69 — it loses)NLI training does not teach negation or quantifier scope. Chapter 9's limit 4, visible in 2019

That last row is the one to remember. SBERT loses to Universal Sentence Encoder on SICK-R by 3.78 points, in the paper's own headline table. The dataset that tests negation and syntactic transformation is the dataset where NLI-trained SBERT is weakest — and negation blindness is still, six years later, the most reliable complaint about sentence embeddings. The failure was visible on the day of publication, in a column most readers skip.

A note on what "worse than GloVe" does and does not mean

The headline comparison is rhetorically powerful and it can be over-read, so bound it carefully.

ClaimTrue?
Mean-pooled BERT vectors correlate with human similarity worse than averaged GloVe vectorsYes — 54.81 vs 61.32, and the [CLS] variant far worse still
BERT is a worse language model than GloVeNo, and nothing here suggests it. On every task with a trained head, BERT wins enormously
BERT contains less information about sentence meaningNo — the SentEval probe table in Chapter 7 shows mean-BERT at 84.94 against GloVe's 81.52. The information is there
BERT's unsupervised output geometry is unsuitable for cosine comparisonYes — this is the precise claim, and it is the one the paper makes

The distinction between rows 3 and 4 is the whole diagnosis. A representation can contain a fact and still not expose it to a particular readout. Cosine similarity is an extremely restricted readout — no parameters, no training, every coordinate weighted as given — and pretraining had no reason to accommodate it.

Which reframes what the fine-tune is doing. It is not adding knowledge; twenty minutes on a million pairs cannot teach a model what "guitar" means. It is reorganising knowledge the encoder already had into a form a parameter-free comparison can read. That is why it is so cheap, and why it works from a checkpoint rather than from scratch.

Two predecessors worth naming

InferSent (Conneau et al., 2017) is the direct ancestor. A BiLSTM with max-pooling, trained on the Stanford Natural Language Inference corpus, producing 4096-dimensional sentence vectors. It established the finding SBERT relies on: NLI is unreasonably good supervision for general-purpose sentence embeddings. SBERT is, in one sentence, "InferSent with BERT instead of the BiLSTM, plus a careful ablation of everything else."

Universal Sentence Encoder (Cer et al., 2018) is the strong contemporary baseline, and the one that makes SBERT's win non-trivial. USE trains a transformer (or a deep averaging network, for the fast variant) with multi-task supervision: SNLI plus a conversational input-response prediction task mined from web forums plus supervised classification data. It reaches 71.22 average STS — genuinely good, and it shipped as an easy TensorFlow Hub module, so it was the default choice at the time.

SBERT beats it by 3.67 points on average, and by 2.11 points on STS-B, using less supervision (NLI only) and about twenty minutes of fine-tuning on a pretrained encoder. The lesson generalises past this paper: when a strong pretrained encoder exists, the winning move is usually a small, well-shaped fine-tune, not a bigger multi-task training run from scratch.

Inline concept check — answer before reading on. If the problem is only that BERT's space is coned and anisotropic, why not just centre and whiten the vectors and skip the fine-tuning entirely?  …  Because that fixes the metric and not the content. Whitening removes the common component and equalises variance across directions, which does help — BERT-flow and BERT-whitening (2020–2021) recover several STS points this way. But no invertible linear transform can create information the encoder never encoded. Mean-pooled BERT does not strongly represent whether two sentences agree, because nothing ever asked it to; whitening cannot conjure that axis. Fine-tuning on pair supervision changes what is in the vectors, not just how they are scaled.

The 2×2 that explains the whole table

There are exactly two factors at work in Chapter 1's results: how good the encoder is, and whether a pair-level loss was ever applied. Cross them:

No pair supervisionPair supervision (NLI)
Weak encoder (word vectors, BiLSTM)Avg. GloVe — 61.32InferSent — 65.01
Strong encoder (BERT)Mean-pooled BERT — 54.81SBERT — 74.89

Read it as an experiment with an interaction term. Along the top row, adding pair supervision to a weak encoder buys +3.69. Along the bottom row, it buys +20.08. Down the left column, upgrading the encoder costs you 6.51 points. Down the right column, it gains 9.88.

So the two factors are not additive at all — they multiply. A strong encoder without pair supervision is worse than useless: it has more capacity to arrange its space in a way nothing constrains, so it arranges it worse. Add the constraint and that same capacity becomes the largest gain in the table.

This is the paper's actual scientific finding, and it generalises past sentence embeddings. "Better encoder" and "better objective" are not two knobs you can turn independently and add up. The value of representational capacity is conditional on having a training signal that constrains it. Whenever you see a stronger model underperforming a weaker one on a task, this 2×2 is the first hypothesis to test — and the fix is usually a loss change costing twenty minutes, not a bigger model costing a month.

The five-minute experiment that makes all of this yours

Everything in this chapter is checkable on your own machine, and running it once is worth more than reading it twice. The measurement is: what does my model think two unrelated sentences score?

python — measure your own model's noise floor and coneimport numpy as np
from sentence_transformers import SentenceTransformer

sents = [# 200+ sentences sampled from YOUR corpus, not from a benchmark]
E = model.encode(sents, normalize_embeddings=True)        # (N, d)

S = E @ E.T                                                # (N, N) all-pairs cosine
off = S[~np.eye(len(E), dtype=bool)]                      # drop the diagonal of 1.0s
print("mean cosine of random pairs :", off.mean())        # the CONE. 0.0 would be ideal
print("sd                          :", off.std())         # the usable dynamic range
print("95th percentile             :", np.percentile(off, 95))  # your noise FLOOR

Ec = E - E.mean(axis=0, keepdims=True)                    # centre: subtract the corpus mean
Ec = Ec / np.linalg.norm(Ec, axis=1, keepdims=True)
Sc = Ec @ Ec.T
print("after centring              :", Sc[~np.eye(len(E), dtype=bool)].mean())

Three numbers come out, and each one answers a question you would otherwise guess at.

NumberWhat it tells youWhat to do about it
Mean cosine of random pairsHow coned your space is. Compare against the ideal 0.00 ± 1/√dIf above ~0.5, centre before comparing, and treat absolute scores as meaningless
Standard deviationYour usable dynamic range. If it is 0.03, then a "0.02 improvement" in a similarity score is one standard deviation of noiseSets how finely you can threshold at all
95th percentile of random pairsYour noise floor: 5% of genuinely unrelated pairs score above this by chanceAny duplicate-detection threshold must sit above it, or your false-positive rate is at least 5% of all pairs — which on a quadratic number of pairs is enormous

That last row deserves its own arithmetic, because it is where a mining job goes wrong. With 10,000 sentences there are 49,995,000 pairs. If 5% of unrelated pairs clear your threshold, that is roughly 2.5 million false positives — against perhaps a few thousand genuine duplicates. Precision would be well under one percent. On quadratic problems, a noise floor you would call "quite good" on a per-pair basis is a catastrophe in aggregate, and the only defence is to know exactly where the floor sits.

Where this leaves us

We now have a precise statement of the problem, in two halves. The speed half from Chapter 0: comparisons must happen after the network, not inside it. The quality half from this chapter: vectors compared after the network must have been trained under a loss that saw pairs. Chapter 2 puts the two halves into one design space and shows that the choice between them is not a ranking — it is a decision that depends on how many comparisons you need and how much latency you have.

BERT's [CLS] vector scores 29.19 average Spearman on STS — worse than a GloVe average at 61.32. What is the best explanation?

Chapter 2: Cross-Encoder vs Bi-Encoder

It is tempting to read Chapter 0 as "cross-encoders are slow, use bi-encoders." That reading will cost you accuracy in production, because the cross-encoder is not merely faster-at-being-worse. On the hardest comparison tasks in this very paper, the cross-encoder wins, sometimes by a lot. This chapter maps the design space properly so that Chapter 8 can put both to work in the same system.

The cross-encoder, in tensor shapes

A cross-encoder takes both sentences as a single input sequence and outputs a score. The data flow, with real shapes for BERT-base scoring one pair:

StageShapeWhat happens
Tokenised input(1, L) with L = La + Lb + 3[CLS] A-tokens [SEP] B-tokens [SEP]; the +3 is the three special tokens
Segment ids(1, L)0 for the A span, 1 for the B span — BERT adds a learned vector per segment so the model can tell them apart
After embeddings(1, L, 768)token + position + segment embeddings summed
Each of 12 layers(1, L, 768), attention (1, 12, L, L)Every token attends to every other token, across the [SEP] boundary. This is where the sentences interact
Pooled output(1, 768)the [CLS] position, through a tanh dense layer
Head(1, 1) or (1, k)a linear layer to a similarity score or class logits

The row that matters is the fourth. In layer 1 already, the token "crashes" in sentence A can attend to the token "force-closes" in sentence B, compare them, and write the result of that comparison into its own representation. By layer 12 the model has had twelve rounds of arbitrarily fine-grained cross-sentence reasoning: alignment of phrases, detection of negation on either side, resolution of "it" in B to "the app" in A.

The source of both the accuracy and the cost is one and the same thing. Cross-attention over the concatenated pair is what lets the model notice that "not" in sentence B flips the relationship. It is also what makes the computation inseparable: the intermediate activations are functions of both sentences, so nothing computed for pair (A, B) can be reused for pair (A, C). You cannot cache a joint representation of a pair that has not been formed yet. There are n2 of them.

The bi-encoder, in tensor shapes

A bi-encoder — also called a dual encoder, or two-tower model — runs each sentence through the encoder independently and compares the outputs.

StageShapeWhat happens
Two tokenised inputs(1, La) and (1, Lb)Each gets its own [CLS] and [SEP]. No segment ids needed — there is only one segment
Two encoder passes(1, La, 768), (1, Lb, 768)Same weights, run twice. Attention is (1, 12, La, La) — strictly within a sentence
Pooling(1, 768) eachcollapse the token axis; Chapter 3 is about how
Scorescalarcos(u, v), computed outside the network
Storable artefact(768,) per sentence3 KB in fp32, 1.5 KB in fp16. Write it to disk. Never encode that sentence again

The last row is the entire economic argument. A bi-encoder produces a per-sentence artefact. Artefacts can be stored, sorted, indexed by approximate-nearest-neighbour structures, shipped between services, and reused across every query for the rest of the model's life. That is what an index is.

Precomputability: the property that actually matters

Let us define the distinction cleanly, because "slow vs fast" is the wrong axis. Write the score function of each architecture:

cross-encoder:  s(A, B) = g(φ(A, B))  — one inseparable function of the pair
bi-encoder:    s(A, B) = ⟨ f(A), f(B) ⟩  — factorises through a per-item map f

The bi-encoder's score factorises: it is an inner product of two things that each depend on only one input. Every good scaling story in machine-learning systems is a factorisation story, and every factorisation buys speed by giving up expressiveness. The cross-encoder can represent any function of the pair. The bi-encoder can only represent functions expressible as an inner product of two independently-computed vectors — a strictly smaller family.

What the bi-encoder structurally cannot do. The vector for sentence A is computed before sentence B has been seen. So A's representation must be a summary that is useful against every possible B — it cannot decide which of its details to emphasise based on the question being asked. A cross-encoder can: given B about pricing, it can foreground A's pricing clause and ignore its shipping clause. This is the real accuracy gap, and it is why the gap grows on long documents (more details to compress) and on subtle tasks (which detail matters depends on the pair).

The expressiveness gap, proved

That last point can be made exactly, not just intuitively, and the proof is two lines of linear algebra worth knowing.

Consider the full score matrix S over a corpus: Sij = s(Ai, Bj), an (n × m) table of every score the model would give. For a bi-encoder, stack the embeddings into EA ∈ Rn×d and EB ∈ Rm×d. Then:

S = EA EBT  ⇒   rank(S) ≤ d

The rank of a product is at most the smaller inner dimension. So no matter how good your encoder is, a bi-encoder with d = 768 can only ever produce score matrices of rank 768. A cross-encoder's score matrix has no such constraint — it can be full rank, up to min(n, m).

Make that concrete with the smallest possible counterexample. Suppose you want a scorer where A1 matches B1 only, A2 matches B2 only, and so on for n sentences — the identity matrix, rank n. With d = 2 and n = 3 you cannot do it: three points on a 2-D plane cannot each be closest to their own partner and far from the other two while satisfying the geometry, because the required score matrix has rank 3 and the achievable one has rank 2. The constraint is not about training difficulty. It is a dimension count.

Concept + realisation: this is why d matters, and why it does not matter as much as you would expect. The rank bound says embedding dimension is a hard ceiling on how many independent "match patterns" the space can express. In practice you are far from that ceiling — real relevance matrices are highly structured and low-rank, which is exactly why a 384-dimensional MiniLM performs within a point or two of a 768-dimensional model. But the bound explains something real: when you push a bi-encoder toward fine-grained, per-detail matching over a large corpus, you eventually hit a wall no amount of training fixes, and that is the moment to add a reranker or go to late interaction.

The cost model, term by term

Let n be the corpus size, q the number of queries, d the embedding dimension, and F the cost of one encoder forward pass. Then:

WorkloadCross-encoderBi-encoder
Index build (one-off)Impossible — nothing to buildn · F, then store n · d floats
One query against the corpusn · F1 · F + n · d multiply-adds (or log-ish with an ANN index)
All-pairs over the corpusn(n−1)/2 · Fn · F + n2d/2 multiply-adds
Add one new itemn · F (compare it to everything)1 · F + n · d
Change the similarity thresholdFree — you already have all scores … if you kept themFree — recompute from stored vectors in milliseconds
Swap in a better modelRerun everythingRe-encode everything (n · F) — and your index is now silently incompatible with old vectors

Put numbers on the second row, because that is the row a product manager cares about. Interactive search over n = 1,000,000 documents, F ≈ 5 ms per pair on a V100:

cross-encoder: 1,000,000 × 5 ms = 5,000 s ≈ 83 minutes per query
bi-encoder: 5 ms (encode query) + 1,000,000 × 768 × 2 FLOPs ≈ 1.5 GFLOP ≈ 30–60 ms
    → total ≈ 40–70 ms per query, and an ANN index takes it under 5 ms

Note the honest detail in the bi-encoder line: the brute-force scan is memory-bandwidth bound, not compute bound. One million fp32 vectors of 768 dimensions is 1,000,000 × 768 × 4 = 3.07 GB, and you have to stream all of it through the ALUs. At 50 GB/s of usable bandwidth that is 61 ms, which matches the estimate above. Store in fp16 and you halve it; store in int8 and you quarter it. This is the kind of realisation detail that decides whether the feature ships.

Batching, and why the reranker's cost is not linear in k

One more piece of realism, because "5 ms per pair" is not how a GPU behaves. A GPU is a throughput machine: it processes a batch of 32 pairs in barely more time than a batch of 1, because the fixed costs — kernel launches, weight loads from HBM — dominate until the arithmetic units are saturated.

Batch sizeWall timePer-pairWhat is limiting
1~7 ms7.0 msKernel launch and weight-load overhead. The arithmetic units are nearly idle
8~9 ms1.1 msStill mostly overhead — weights get loaded once and reused eight times
32~12 ms0.4 msApproaching compute-bound. The sweet spot for a reranker
128~40 ms0.31 msCompute-bound. Per-pair cost has stopped improving much
512~158 ms0.31 msPurely linear now — and your p95 is ruined for one request's benefit

Three consequences follow. First, reranking 32 candidates costs almost the same as reranking 1, so if you are going to pay the overhead at all, take k = 32 rather than k = 5 — the extra 27 candidates are nearly free. Second, going from k = 32 to k = 128 is a genuine 3× in latency, so that is where the real tradeoff sits. Third, the same effect makes index building vastly cheaper than a per-sentence estimate suggests: encode with batch 128 and sorted lengths (smart batching, Chapter 7) and you approach the model's peak throughput rather than its per-call latency.

The p95 trap in one sentence. A large batch improves average throughput and damages tail latency, because every request in the batch waits for the slowest. Under bursty traffic, a reranker batching aggressively across concurrent requests can post an excellent mean and a terrible p99, and users experience the p99. Cap the batch by time ("collect for at most 5 ms, then run"), not by count.

Eight real products, and which design each needs

The abstract rule is "how many comparisons, and how much latency." Applied:

ProductComparisons per user actionLatency budgetDesign
Duplicate-question detection while typing (a forum)~5M existing questions200 msBi-encoder + ANN. No alternative exists
Legal contract clause comparison, two documents~200 × 200 = 40,000 clause pairsOvernightCross-encoder — 40k × 5 ms = 3 minutes, and precision is everything
Support-ticket routing to 12 queues12 label comparisons1 sEither. Bi-encoder is simpler and lets the label set change at runtime
RAG over a 200-page manual~2,000 chunks500 msBi-encoder retrieve → cross-encoder rerank top 20. Both fit easily
Plagiarism check of one essay against a corpus10M documentsMinutesBi-encoder recall → cross-encoder on the top few hundred
Deduplicating a 50M-row product catalogue1.25 × 1015 pairsDaysBi-encoder + blocking. A cross-encoder is off by ten orders of magnitude
Grading 500 student answers against one reference500MinutesCross-encoder — 2.5 s total, and the nuance matters
Real-time semantic cache for an LLM API~100k cached prompts10 msBi-encoder, fp16, in-process. A cross-encoder would cost more than the LLM call it is trying to avoid

Two patterns fall out. When the comparison set is small and fixed — a handful of labels, one reference answer, two documents — the cross-encoder is not merely allowed, it is the correct default, because the quadratic never engages and you are leaving accuracy on the table by not using it. When the comparison set is the corpus, the bi-encoder is not one option among several; it is the only thing that runs.

So when does each win?

SituationUseWhy
Score 200 pairs offline, accuracy is everythingCross-encoder200 × 5 ms = 1 s. The quadratic never bites. Take the accuracy
Search 1M documents in 50 msBi-encoderThe cross-encoder is 100,000× over budget. No amount of engineering closes that
Cluster 100k sentencesBi-encoderClustering algorithms need a vector per item; a pair-score function is not a vector
Rank the top 50 candidates a retriever returnedCross-encoder50 × 5 ms = 250 ms of extra latency for several points of accuracy
BothRetrieve then rerankSee below — this is the standard production answer
Stage 1 — retrieve (bi-encoder)
Query → one encoder pass → ANN search over 1M stored vectors → top 100 candidates. Budget: ~10 ms. Optimised for recall: it is fine to return junk, it is fatal to miss the answer
↓ 1,000,000 → 100  (a 10,000× reduction in the comparison set)
Stage 2 — rerank (cross-encoder)
Score each of the 100 candidates jointly with the query. Budget: 100 × 5 ms = 500 ms batched down to ~50 ms. Optimised for precision at the top of the list
Result
Near-cross-encoder accuracy at near-bi-encoder latency. The bi-encoder made the comparison set small enough for the expensive model to afford it

Worked latency budget. Suppose your p95 budget is 120 ms and the cross-encoder costs 5 ms per pair with a batch of 32 running in 12 ms total (batching wins hard here). Then reranking k candidates costs about ⌈k/32⌉ × 12 ms. With k = 100 that is 4 batches × 12 = 48 ms. Add 10 ms for retrieval and 5 ms for the query encode: 63 ms. You have 57 ms of headroom, so you could push k to 200 (7 batches, 84 ms → 99 ms total). Beyond that you are out of budget, and each doubling of k buys less because the retriever's recall curve flattens.

Concept + realisation: recall is the retriever's only job. If the true best answer is not in the retriever's top 100, no reranker can recover it — the pipeline's ceiling is the retriever's recall@k. So you tune the bi-encoder for recall@100, not for precision@1, and you tune the cross-encoder for NDCG@10. Measuring the two stages with the same metric is one of the most common mistakes in retrieval systems, and it usually shows up as "our reranker doesn't help" when in fact recall@k was already 0.6.

Where the cross-encoder's cost actually goes

One more detail decides real budgets. A transformer layer has two cost terms: the position-wise linear projections, which are linear in sequence length L, and the attention matrix, which is quadratic in L.

FLOPs per layer ≈ 12 · L · h2  (projections and MLP)  +  2 · L2 · h  (attention)
h = 768 hidden size

Now compare two designs on the same two 64-token sentences. The bi-encoder runs two passes at L = 64. The cross-encoder runs one pass at L = 128. The linear terms come out equal — 2 × 64 = 128 tokens either way. The attention terms do not:

bi-encoder attention: 2 × 642 = 8,192     cross-encoder attention: 1282 = 16,384
ratio = , and it grows with length: at L = 256 per sentence it is 2 × 2562 = 131k vs 5122 = 262k

So even for a single pair the cross-encoder costs more, by the factor that the quadratic term contributes. That is a second-order effect — the first-order disaster is still that it runs n2 times instead of n — but it is the reason long-document cross-encoding is punishing, and the reason rerankers truncate aggressively.

What a query costs in money. Take a rented A10G at roughly $1/hour, and a reranker throughput of about 800 pairs per second at 128 tokens:

DesignPairs scored per queryGPU-seconds per queryCost per million queries
Cross-encoder over 1M docs1,000,0001,250~$347,000 — and 21 minutes of latency each
Bi-encoder retrieve only0 (one encode + a scan)~0.01~$3
Retrieve + rerank top 1001000.135~$38

Four cents per thousand queries for near-cross-encoder quality, against a number with six digits in it. The architecture decision in this chapter is usually the largest single lever on the cost of a search product.

The cross-encoder you will actually build

Since the recommended architecture is "both," it is worth knowing what the second stage looks like in practice. A reranker is a smaller model than you expect and its training data is a different shape from the retriever's.

python — training and using a rerankerfrom sentence_transformers import CrossEncoder, InputExample

# Training data is (query, passage) -> relevance. The NEGATIVES must come from
# your retriever's own top-k, not from random sampling: the reranker only ever
# sees what stage 1 sends it, so that is the distribution it must be good on.
train = [InputExample(texts=[q, pos], label=1.0) for q, pos in positives] + \
        [InputExample(texts=[q, neg], label=0.0) for q, neg in retriever_top_k_wrong]

model = CrossEncoder('microsoft/MiniLM-L6-H384-uncased', num_labels=1, max_length=256)
model.fit(train_dataloader=loader, epochs=1, warmup_steps=1000)

# Inference: the scores are NOT probabilities and NOT comparable across queries.
scores = model.predict([(query, p) for p in candidates])   # use for ORDERING only
order  = np.argsort(-scores)
DecisionWhy
Negatives from the retriever's top-kTrain on the distribution you will serve. Random negatives make a reranker that is excellent at a job stage 1 already did
max_length=256, aggressively truncatedAttention is quadratic in the concatenated length; the reranker's latency is your entire remaining budget
A 6-layer model, not 12You run it 100 times per query. Halving depth halves the p95 and typically costs under a point of NDCG
num_labels=1 with a regression headYou want a scalar to sort by, not a calibrated class probability
Never threshold the raw scoreIt is an uncalibrated logit whose scale drifts between checkpoints. If you need "is this relevant at all", fit a calibration on held-out data — and refit on every retrain
The staging mistake worth naming. A reranker trained on random negatives will look excellent offline — it separates relevant passages from unrelated ones nearly perfectly — and do almost nothing in production. The reason is that by the time it runs, stage 1 has already removed every unrelated passage. Its real job is to separate plausible from correct among 100 items that are all on-topic, and that is a task it has never been shown. Mining stage-2 negatives from stage-1 output is not a refinement; it is the difference between the component working and not.

The middle ground, for completeness

The design space is not actually binary. Two families sit between the extremes, and knowing they exist keeps you from treating SBERT's tradeoff as a law of nature.

FamilyWhere sentences meetCost per comparisonStorable?
Bi-encoder (SBERT)One dot product at the very endd multiply-adds (768)Yes — one vector
Late interaction (ColBERT, 2020)Token-level dot products at the end: MaxSim over all token pairsLa · Lb · dYes — but L vectors per item, so ~30–100× the storage
Cross-encoderEvery layer, every token pairOne full forward passNo

ColBERT keeps a vector per token and defines the score as the sum over query tokens of the maximum similarity to any document token. That recovers much of the fine-grained alignment a cross-encoder gets, while still factorising — the document's token vectors can be precomputed. The price is storage: a 100-token document costs 100 vectors instead of 1. The design space is a smooth trade between how much interaction you keep and how much you must store.

A vocabulary note, because the names are a mess

NameMeansUsed by
Bi-encoderTwo independent encoder passes, compared at the endThe IR and sentence-embedding literature
Dual encoderIdentical meaningGoogle's papers (GTR, USE)
Two-towerIdentical meaning, usually with untied weightsRecommender systems
Siamese networkThe bi-encoder with tied weights, emphasising the sharingThis paper; the metric-learning literature
Cross-encoderOne pass over the concatenated pairEverywhere
RerankerA cross-encoder used as a second stageIR — a role, not an architecture
Late interactionPer-token vectors compared after encodingColBERT and descendants
Dense retrievalRetrieval using a bi-encoder, as opposed to sparse/lexicalIR — names the system, not the model

Four names for one thing, and the distinctions that do carry information are: tied versus untied weights, and whether the comparison happens before or after the encoder finishes. Everything else is which conference the author attends.

They also differ in what they need from you

One last axis, which decides more projects than accuracy does: the two architectures have different data requirements, and different failure modes when that data is thin.

AspectCross-encoderBi-encoder
Training signalLabelled pairs with a target score or classThe same — but it also benefits enormously from many negatives per positive
Batch sizeIrrelevant to the objective; purely a memory/throughput choiceUnder an in-batch contrastive loss, batch size is the number of negatives, so it changes the objective itself
Data efficiencyHigh — every pair is a full-resolution comparisonLower — the model must learn a global map, which takes more examples
Failure with little dataDegrades gracefully toward the pretrained model's priorsDegrades into a topic detector: everything on-topic looks similar
Inference-time flexibilityCan score any pair, including inputs of a kind never seenMust map new inputs into a fixed space; unseen domains land badly (Chapter 7's cross-topic result)

Row 2 is the one that surprises people, and it is worth stating plainly because it inverts a normal intuition. For most models, batch size is a systems parameter: bigger is faster, and you adjust the learning rate. For a bi-encoder trained with in-batch negatives, doubling the batch doubles how many wrong answers each example is contrasted against, which makes the task strictly harder and the resulting space strictly better-separated. It is the one place where "we ran out of GPU memory" is a modelling problem, not an infrastructure one. Chapter 9 returns to it.

Cross-domain bridge
This is the same trade as a database join strategy
A nested-loop join evaluates an arbitrary predicate on every pair of rows — maximally expressive, O(n·m). A hash join requires the predicate to factorise into "compute a key from each side, then match keys" — strictly less expressive (only equality works), and O(n+m). A cross-encoder is a nested-loop join with a neural predicate; a bi-encoder is a hash join whose key is a 768-dimensional vector and whose matching is approximate. The query planner's rule applies here too: use the cheap factorised operator to shrink the candidate set, then apply the expensive predicate to what survives. Retrieve-then-rerank is predicate pushdown.

A worked recall/latency tradeoff

Chapter 2's central decision is "how many candidates does stage 1 hand to stage 2." Here is that decision as numbers, using a retriever whose recall curve has the shape every retriever's does — steeply rising, then flat.

kRetriever recall@kRerank latency (batch 32 at 12 ms)End-to-end ceilingMarginal gain per 12 ms
100.7212 ms0.72
320.8512 ms0.85+0.13 for free
640.9024 ms0.90+0.05
1280.9448 ms0.94+0.04 per 24 ms
2560.9696 ms0.96+0.02 per 48 ms
10000.98~375 ms0.98+0.02 per 279 ms

Two shapes to read off. Going from 10 to 32 is free, because of the batching effect above — if you are reranking at all, never rerank fewer than one full batch. And beyond about 128 you are buying hundredths of recall with tens of milliseconds, which is where the curve says stop.

The subtler point is in the fourth column's heading: ceiling. Reranking cannot exceed the retriever's recall@k, so this table bounds what the whole pipeline can do no matter how good stage 2 becomes. If your product needs 0.95 end-to-end and your retriever tops out at 0.90 for any affordable k, the fix is a better retriever — or a hybrid that adds BM25's recall to the dense recall, which is usually the cheapest way to raise a ceiling.

The stage nobody puts in the diagram: caching

Retrieve-then-rerank is usually drawn as two stages. Production systems have three, and the missing one is free.

Stage 0 — the semantic cache
Embed the incoming query. If its cosine to a previously-seen query exceeds ~0.97, serve that query's stored results. Cost: one encode plus a scan of ~100k cached queries — under 2 ms
↓ on a miss
Stage 1 — retrieve
Stage 2 — rerank

An exact-string cache catches "how do i reset my password" twice. A semantic cache also catches "password reset" and "cant log in need new password" — and query distributions are heavily skewed, so hit rates of 30–60% are ordinary. Every hit skips both expensive stages entirely.

The threshold is the whole design, and it is asymmetric in consequence: too low and users get someone else's answer, which is a correctness bug; too high and you merely lose hits, which is a cost. So set it high (0.97–0.99 measured against your own noise floor from Chapter 1's experiment), and note that this is one of the few places where an absolute cosine threshold is genuinely the right instrument — because both sides are queries, drawn from the same distribution, encoded by the same function, so the comparison is as apples-to-apples as this geometry ever gets.

The cousin: when you should untie the towers

Chapter 3 will argue hard for weight tying. It is worth knowing now when the opposite is right, because the same two-tower diagram appears in recommender systems with the weights deliberately separate.

Tied (siamese) — SBERTUntied (two-tower) — retrieval / recsys
InputsTwo objects of the same kind (two sentences)Two different kinds (a user and an item; a short query and a long passage)
RelationSymmetric — "how similar"Asymmetric — "would this user like this item", "does this passage answer this query"
Which tower at inference?The question does not ariseQuery tower for queries, document tower for documents. Getting it backwards silently ruins retrieval
ParametersOne encoderTwo, often of very different sizes — a tiny query tower for latency, a large document tower run offline
RiskNone specificThe towers can drift into a private code that fits the training pairs and generalises poorly

The fourth row hides a real engineering advantage. Document encoding happens offline, so it can use a large model; query encoding happens in the request path, so it wants a small one. Untying lets you spend asymmetrically, exactly matching where the latency is.

Modern text embedders take a third option that is neither: tied weights with an asymmetric prefix. One encoder, but queries are prepended with "query: " and documents with "passage: ", so the same parameters compute two different functions selected by a token. You get the asymmetry without doubling the model or risking divergent towers — and, as Chapter 9 notes, it is worth several points of recall on retrieval tasks that SBERT-NLI handles poorly.

Your retrieval pipeline uses a bi-encoder for top-100 retrieval and a cross-encoder to rerank. You improve the cross-encoder substantially and end-to-end accuracy barely moves. What is the most likely cause?

Chapter 3: Twins and the Pooling Layer

We know what we want: an encoder f whose outputs can be compared by cosine. We know why plain BERT does not give it: no pair-level pressure was ever applied. So apply some. The structure that applies it is sixty lines of code and one idea from 1993.

What a siamese network is

A siamese network (Bromley, Guyon, LeCun, Säckinger, Shah, 1993 — built to verify handwritten signatures) is a network applied twice, to two inputs, with the same weights both times, followed by a loss computed on the two outputs. The word "siamese" refers to twins, and the essential property is that the twins are not merely identical — they are the same object. There is one set of parameters, one gradient buffer, one saved checkpoint.

u = fθ(A) ,   v = fθ(B) ,   L = ℓ(u, v, y)

∂L/∂θ = (∂L/∂u)(∂u/∂θ) + (∂L/∂v)(∂v/∂θ)  — both paths accumulate into one θ

That last line is the whole mechanism, so read it as an instruction rather than an equation. Sentence A's pass says "adjust θ so my vector moves toward B's." Sentence B's pass says "adjust θ so my vector moves toward A's." Because θ is shared, the two demands are resolved inside a single parameter update. The encoder cannot satisfy them by specialising — it has to find a representation function under which semantically related inputs land near each other in general.

Why weight tying is non-negotiable here. Untie the towers and you get two functions fA and fB. They can trivially satisfy the training loss by agreeing on an arbitrary private code — fA maps everything to (1,0,…) and fB maps everything to (1,0,…) for positives — and, fatally, at inference you would not know which tower to use for a corpus sentence. Symmetric similarity requires one function. (Two-tower recommender systems do untie the towers, precisely because a user and an item are different kinds of object and the relation is asymmetric. Different problem, different choice, and Chapter 9 shows what SBERT loses by being symmetric.)

Where the pooling layer goes, with shapes

SBERT is BERT plus one parameter-free layer. Here is the full forward pass for a batch of b sentences padded to length L:

StepTensorShape (b = 16, L = 64)
input_idstoken indices(16, 64)
attention_mask1 for real tokens, 0 for [PAD](16, 64)
BERT outputtoken vectors H(16, 64, 768)
Poolingcollapse the length axis(16, 64, 768) → (16, 768)
Optional normalisedivide each row by its L2 norm(16, 768), every row on the unit sphere
Similarityu vT for the pair-halves of the batchscalar per pair

The pooling layer has zero parameters. That is a deliberate and slightly surprising choice: you might expect a learned attention-pooling head. The paper's ablation (below) says the simplest option wins, and there is a good reason — a parameterised pooler is one more thing that has to be learned from a small amount of pair data, and one more thing that can overfit the training genre.

The three pooling strategies, written out

Let H ∈ RL×768 be the token vectors and m ∈ {0,1}L the attention mask.

CLS:   u = H0

MEAN:  u = ( ∑i mi Hi ) / ( ∑i mi )

MAX:   uj = maxi : mi=1 Hij  (independently per dimension j)

Read the MAX definition carefully, because the subscripts hide something important. The maximum is taken per dimension, independently. Dimension 0 of the output might come from token 3, dimension 1 from token 7, dimension 2 from token 3 again. The output vector is a Frankenstein assembled from different tokens' coordinates and corresponds to no token at all. That is fine — InferSent used exactly this and it worked well for a BiLSTM — but it is a stranger operation than it looks.

The bug you will write if you skip the mask. Note the mi in the MEAN formula. If you average over all L positions including [PAD], the pooled vector depends on how much padding the batch happened to have — which depends on the longest other sentence in the batch. The same sentence then gets a different embedding depending on what it was batched with. Your index becomes non-deterministic, your unit tests pass (they use batch size 1), and similarity scores drift by a few percent in production. For MAX the failure is different but equally real: [PAD] vectors are not zero, and a padding position can win the max in some dimension. Always mask — set padded positions to −∞ before a max, and to 0 before a sum.

The ablation that chose MEAN

The paper tests all three, under both of its training regimes, and reports Spearman correlation on the STS benchmark dev set:

Pooling strategyTrained on NLI (classification objective)Trained on STS-B (regression objective)
MEAN80.7887.44
MAX79.0769.92
CLS79.8086.62

Two very different stories in these two columns, and the difference is more instructive than the winner.

Under NLI training, the three are close — 80.78, 79.80, 79.07, a spread of 1.7 points. The classification objective (Chapter 4) puts a trainable matrix on top of the pooled vectors, and that matrix can compensate for a mediocre pooling choice. It is the "forgiving consumer" from Chapter 1 all over again.

Under STS-B regression training, MAX collapses — 69.92 against MEAN's 87.44, a 17.5-point hole. The regression objective optimises cosine similarity directly, with no trainable head to absorb anything. So this column is the one that measures pooling quality honestly, and it says MAX produces a geometry that cosine cannot read.

Why MAX breaks under a cosine objective — the derivation. Cosine cares about the direction of the whole vector, which means every coordinate's magnitude matters relative to the others. A coordinate-wise max is an extreme-value statistic: it is dominated by outliers and it is biased upward by sentence length. Take L samples from any distribution; the expected maximum grows with L. So a 40-token sentence gets systematically larger coordinates than a 6-token one, in every dimension, purely from length. That inflates the shared component — exactly the anisotropy problem of Chapter 1, manufactured by the pooling layer — and cosine is helpless against it. MEAN is an unbiased estimator of the mean token vector; its expectation does not depend on L at all. Under a trainable head (the NLI column) the head can learn a length correction, which is why MAX only loses 1.7 points there and 17.5 points here.

Make that concrete with arithmetic. Suppose each coordinate of each token vector is roughly standard normal. The expected maximum of L standard normal draws grows like √(2 ln L):

Sentence length LE[max] of L standard normalsE[mean] of L standard normalssd of that mean
61.270.001/√6 = 0.41
151.740.000.26
402.160.000.16
1282.600.000.09

Read down the second column: the max-pooled vector of a 128-token sentence has coordinates twice the size of a 6-token sentence's, purely from length, in every dimension at once — which is exactly a shared component. Read down the third: the mean's expectation does not move at all. Only its variance shrinks with length, which is a benign effect (longer sentences get more stable estimates, not systematically bigger ones).

Verify the direction of the bias with a two-token toy. Tokens (2, 0) and (0, 2). MEAN gives (1, 1), norm √2 = 1.41. MAX gives (2, 2), norm 2.83. Add a third token (1, 1): MEAN becomes (1, 1) still — unchanged — while MAX stays (2, 2). Add a fourth token (2.5, 0.5): MEAN goes to (1.375, 0.875), MAX jumps to (2.5, 2). The max only ever moves up, and it moves up every time you add a token. That is a ratchet, and it is pointed at the wrong quantity.

And why does CLS trail MEAN even under regression (86.62 vs 87.44)? Because [CLS] is a single position that has to have learned to aggregate. Fine-tuning does teach it to, which is why it is only 0.8 points behind rather than 30 points behind as it was without fine-tuning (Chapter 1's 29.19). But MEAN gets aggregation for free, from arithmetic rather than from parameters, and free things do not need to be learned from your small pair dataset.

Pooling playground — the length axis, collapsed three ways

The grid is one sentence's token vectors: rows are tokens (including [CLS], [SEP] and any [PAD]), columns are the first 8 of 768 dimensions. Pick a pooling strategy and the contributing cells light up; the pooled vector appears below, together with its cosine against a second sentence pooled the same way. Toggle the padding mask off and watch the pooled vector move — that is the bug from the callout above, live.

Pooling:

Three experiments worth running before moving on. (1) Switch to MAX and add padding with the mask off — the cosine jumps, because both sentences inherit the same padding-driven outliers. (2) Switch to CLS and add padding: nothing changes, because [CLS] is position 0 and padding is at the end. CLS is the one strategy that is immune to the masking bug, which is a genuine argument in its favour that the accuracy table does not show. (3) Compare MEAN with and without the mask on the 2-pad setting; the shift is small here with 8 dimensions and 2 pads, and in a real batch with 40 pads out of 64 positions it is not small at all.

Masked mean, computed by hand

The formula is short enough that it hides its own subtlety, so do one all the way through. Three real tokens and two padding positions, in three dimensions:

PositionTokenmask miHi
0[CLS]1(0.4, 0.2, −0.2)
1rain1(1.0, −0.4, 0.6)
2[SEP]1(0.1, 0.2, 0.0)
3[PAD]0(0.9, 1.2, −0.8)
4[PAD]0(0.9, 1.2, −0.8)
Correct (masked):  numerator = (0.4+1.0+0.1, 0.2−0.4+0.2, −0.2+0.6+0.0) = (1.5, 0.0, 0.4)
    denominator = ∑mi = 3  →   u = (0.500, 0.000, 0.133)

Buggy (unmasked):  numerator = (1.5 + 1.8, 0.0 + 2.4, 0.4 − 1.6) = (3.3, 2.4, −1.2)
    denominator = 5  →   ũ = (0.660, 0.480, −0.240)

Compare them with the tool we care about:

u · ũ = 0.330 + 0.000 − 0.032 = 0.298
‖u‖ = √(0.250 + 0 + 0.0177) = 0.5174 ,   ‖ũ‖ = √(0.4356 + 0.2304 + 0.0576) = 0.8504
cos(u, ũ) = 0.298 / (0.5174 × 0.8504) = 0.677

The same sentence, encoded two ways, at cosine 0.677 — which is roughly where an unrelated sentence would sit in a well-trained space. And there were only two padding positions. In a real batch padded from 6 tokens to 128, the buggy vector is 95% padding and the cosine to the correct one approaches whatever the [PAD] vector's own direction happens to be.

The complete SBERT module, in code

Nothing in this chapter needs a framework to understand, so here it is as it actually exists. The pooling layer is nine lines.

python — the entire SBERT moduleclass Pooling(nn.Module):
    def forward(self, token_embeddings, attention_mask):
        # token_embeddings: (b, L, 768)   attention_mask: (b, L)
        mask = attention_mask.unsqueeze(-1).float()      # (b, L, 1) - broadcasts over 768
        if self.mode == 'mean':
            summed = (token_embeddings * mask).sum(dim=1)    # (b, 768)
            counts = mask.sum(dim=1).clamp(min=1e-9)          # (b, 1) - never divide by zero
            return summed / counts
        if self.mode == 'cls':
            return token_embeddings[:, 0]                   # (b, 768)
        # max: kill the padded positions before the max, do not just ignore them
        masked = token_embeddings.masked_fill(mask == 0, -1e9)
        return masked.max(dim=1).values                    # (b, 768)

# The whole model:
model = nn.Sequential(Transformer('bert-base-uncased'), Pooling(mode='mean'))

And because the pooling mode is a contract rather than a weight, it is serialised alongside the model. A published sentence-transformer is a directory whose modules.json lists the pipeline in order:

modules.json — the model IS this list[
  {"idx": 0, "name": "0", "path": "",          "type": "models.Transformer"},
  {"idx": 1, "name": "1", "path": "1_Pooling", "type": "models.Pooling"},
  {"idx": 2, "name": "2", "path": "2_Normalize", "type": "models.Normalize"}
]
# 1_Pooling/config.json
{"pooling_mode_mean_tokens": true, "pooling_mode_cls_token": false,
 "pooling_mode_max_tokens": false, "word_embedding_dimension": 768}

Module 2 is worth noticing: a Normalize layer that divides by the L2 norm. Whether a checkpoint includes it decides whether model.encode() hands you unit vectors, and therefore whether a raw dot product equals cosine downstream. Two checkpoints that differ only in that third entry will behave identically on ranking within one query and differently the moment you compare scores across queries or apply a threshold. It is the highest ratio of consequence to visibility anywhere in this stack.

Count the new parameters: zero. SBERT-base has exactly the parameter count of BERT-base (110M), plus, during training only, the small classification matrix of Chapter 4 which is thrown away afterwards. The published model is a BERT with different weights and a documented pooling convention. That is why it drops into any BERT-shaped inference stack without changes — and why forgetting to apply the same pooling at query time silently ruins retrieval, since nothing errors.

Realisation note: pooling is part of the model contract. An index built with MEAN pooling and queried with CLS pooling will return plausible-looking garbage — scores in a normal range, ordering meaningless. There is no exception, no shape mismatch, no warning. Ship the pooling mode and the model revision in the same metadata record as the vectors, and refuse to serve if they disagree. Chapter 8 lists the other three members of this family of silent failures.

What happens to the norms

Pooling determines not just direction but length, and length has consequences even though cosine is supposed to ignore it. Take a sentence of L tokens whose token vectors have typical norm r and are only partially correlated with each other. Two extremes bracket the answer:

if all L token vectors were identical:   ‖mean‖ = r  (no cancellation)
if all L token vectors were independent, zero-mean:   ‖mean‖ ≈ r/√L  (full cancellation)

Real sentences sit between: tokens share a large common component (the cone) plus individual content. So the pooled norm shrinks with length, but slowly — and it shrinks more for sentences whose tokens are semantically diverse than for repetitive ones.

SentenceToken diversityPooled norm (relative)Effect on an unnormalised dot product
"error error error error"Very low — near-identical vectorsHighRanks artificially high against everything
A focused single-topic sentenceModerateMediumFair
A long paragraph covering five topicsHigh — contributions cancelLowRanks artificially low, despite containing more relevant content

Read the third row against the first: the paragraph that actually covers your query's topic is penalised for also covering four others, while a repetitive fragment is rewarded for saying one thing four times. That is a ranking driven by variance, not relevance, and it disappears entirely the moment you L2-normalise. It is also the deep reason chunking helps — a chunk has low internal diversity, so its pooled vector is both longer and more sharply directed.

So normalisation is doing more work than "making dot products equal cosines." It removes a length-and-diversity confound that would otherwise ride along in every score. Normalise at write time, assert it at read time, and the entire class of problem is gone for the cost of one division per vector.

The masking bug, in numbers

Chapter 3's callout claims that an unmasked mean makes an embedding depend on its batch-mates. Price it exactly. Take a 6-token sentence whose real tokens have mean vector r, batched with a 64-token sentence so it is padded to 64. Let p be the (nonzero, learned) [PAD] vector. The masked and unmasked means are:

correct:   u = (1/6) ∑i=16 Hi = r
buggy:     ũ = (6r + 58p) / 64 = 0.094 r + 0.906 p

Ninety-one percent of the "sentence embedding" is the padding vector. Every short sentence in that batch converges toward the same point, which is p — so all of them become mutually similar and nearly identical, and their cosine to each other approaches 1 regardless of content. If the same sentence is later encoded alone (L = 6, no padding) it gets u = r instead. Two encodings of one sentence, cosine between them possibly below 0.3.

The unit test that catches it, and that almost nobody writes. Encode a short sentence twice: once alone, once in a batch alongside a very long sentence. Assert the two vectors are identical to within floating-point tolerance. Three lines, and it catches the single most damaging silent bug in this entire pipeline — one that makes your retrieval quality depend on how your ingestion job happened to shard the corpus.

What the pooling layer does to the gradient

A parameter-free layer still shapes learning, because it decides how the loss's gradient is distributed back over the tokens. Differentiate each strategy with respect to the token vectors:

MEAN:  ∂u/∂Hi = (mi / ∑m) · I  — every real token gets an equal 1/L share

CLS:   ∂u/∂H0 = I ,  ∂u/∂Hi = 0 for i > 0  — only position 0, directly

MAX:   ∂uj/∂Hij = 1 if i = argmax, else 0  — a sparse routing, one token per dimension

Three genuinely different learning dynamics fall out of three one-line derivatives.

MEAN spreads the signal evenly, so every token in the sentence is nudged a little on every step. Dense gradients, stable, no token is privileged — and no token can be ignored either, which is precisely why negation is hard: "not" gets 1/L of the correction, exactly like "the".

CLS routes everything through position 0. Note the subtlety though: the gradient reaching other tokens is not zero, it just arrives indirectly, through the self-attention that built H0 from them. So the model must learn to aggregate as well as to represent, which is more to learn from the same pair data. That extra burden is the 0.8-point gap in the ablation table.

MAX gives each dimension's entire gradient to a single token, and the winner takes everything. Early in training the argmax flips constantly as the encoder shifts, so the gradient path is discontinuous — a token that received a large update at step t may receive nothing at step t+1. Sparse, high-variance updates, which combine badly with the length bias already derived.

StrategyGradient per tokenVarianceConsequence
MEAN1/L, uniform, denseLowStable; no token can dominate, including the important ones
CLSAll to position 0, redistributed by attentionMediumMust learn aggregation from the pair data itself
MAXWinner-takes-all per dimensionHigh — argmax flips between stepsSparse, unstable, and length-biased
The uniform 1/L is also SBERT's most fundamental blind spot, and it is right here in the derivative. "The treatment was not effective" has one token carrying the entire meaning of the sentence, and MEAN pooling gives that token 1/6 of the vector and 1/6 of the gradient. Nothing in the architecture can weight it higher, because the pooling layer has no parameters with which to learn that "not" matters more than "was". Chapter 9's negation failure is not an accident of the training data — it is visible in a one-line derivative of the pooling layer.

Two pooling variants you will be offered, and when to take them

VariantWhat it doesVerdict
Attention poolingLearn a query vector q; weight tokens by softmax(q · Hi) and take the weighted sumAdds 768 parameters that must be learned from your pair data. Occasionally worth a point on large training sets; usually not worth the extra thing that can overfit
Weighted mean by positionWeight later tokens more (used by some decoder-based embedders)Correct for causal models, where only the last position has seen the whole sentence. Wrong for BERT, which is bidirectional — every position has seen everything
CLS + MEAN concatenatedTake both, giving a 1536-d vectorDoubles storage for a fraction of a point. The two are highly correlated after fine-tuning

The second row is worth internalising because it explains a real difference between model families. In a causal LLM used as an embedder, token i has only seen tokens 1…i, so the mean over positions averages a lot of half-formed representations — which is why last-token pooling is standard there. BERT is bidirectional, so position 3 has attended to position 40 since layer 1, and every position is a legitimate view of the whole. That is why MEAN is a good idea here and a poor one there. The pooling choice is downstream of the attention mask used in pretraining.

The pooling layer's real job, restated

Step back from the ablation for a moment. What is pooling actually being asked to do?

BERT gives you L vectors of 768 numbers — for a 64-token sentence, 49,152 numbers describing every token in its context. You must produce 768. That is a 64:1 compression, performed with no parameters and no knowledge of what will be asked.

(64, 768) → (768,)    49,152 numbers → 768 numbers

Stated that way, the surprise is not that pooling loses word order and negation. The surprise is that it preserves anything useful at all. And the reason it does is that the token vectors are contextual: by layer 12, the vector above "bank" has already absorbed "river" or "money" from elsewhere in the sentence. The averaging is not over words, it is over 64 partially-redundant views of the whole sentence, each written from a different position's vantage. Averaging redundant estimates is exactly what averaging is good at.

Which tells you precisely where mean pooling will fail. It fails when the information is not redundant across positions — when one token carries something no other token echoes. Negation ("not"), quantity ("$4.99"), a single named entity in a long sentence, a lone identifier in a log line. In each case the meaning lives in one position and the average dilutes it by 1/L. Everything in this lesson's list of SBERT weaknesses is an instance of that one sentence.

Triplet structure: the same idea with three towers

For the triplet objective (Chapter 4) the network runs three times instead of two — anchor, positive, negative — still with one shared θ. Nothing else changes. It is the same siamese principle; "siamese" and "triplet" describe how many passes share the weights, not different architectures.

Siamese — 2 passes
u = fθ(A), v = fθ(B). Used with the classification and regression objectives
↓ add one more pass through the same weights
Triplet — 3 passes
sa = fθ(anchor), sp = fθ(positive), sn = fθ(negative). One loss on all three

Where the 768 comes from, and whether it matters

One number in this architecture is inherited rather than chosen, and it is worth saying so. SBERT's embedding dimension is 768 because BERT-base's hidden size is 768; the pooling layer cannot change it, having no parameters. BERT-large gives 1024 for the same reason.

So the dimension was picked in 2018 for reasons about transformer capacity that have nothing to do with similarity search. It is not tuned, not optimal, and not sacred:

DimensionWhere it comes fromStorage per 1M vectors (fp32)Retrieval quality
384MiniLM's hidden size1.54 GBWithin ~1–2 points of 768 on most tasks
768BERT-base's hidden size3.07 GBThe reference
1024BERT-large's hidden size4.10 GBMarginal gain, real cost
4096LLM-based embedders16.4 GBBetter on hard retrieval; storage becomes the design constraint

Chapter 2's rank bound says dimension is a hard ceiling on expressiveness, and Chapter 1's intrinsic-dimension measurement says real spaces use far less than they have. Both are true, which is why the practical answer is "as small as your recall target tolerates" — and why Matryoshka training, which lets one model serve several dimensions, was such a natural idea once someone thought of it.

Five assertions for a pooling implementation

Pooling is nine lines and every one of its failure modes is silent, which is an unusually bad combination. These five checks take ten minutes to write and cover all of them.

AssertionCatches
encode(s) == encode_in_batch(s, long_filler)The masking bug — batch-composition dependence
encode(s) == encode(s) exactly, twice in a rowDropout left on at inference. It is a one-line model.eval() and it silently randomises your index
abs(norm(encode(s)) - 1) < 1e-5A missing Normalize module — whether your dot products are cosines
cos(encode(s), encode(s + " " * 50)) > 0.99Whitespace or trailing tokens changing the vector materially
encode(long_doc) != encode(long_doc[:500])Silent truncation at max_seq_length — if these are equal, everything past the limit is being discarded

The second row is worth dwelling on. model.eval() disables dropout; forget it and every encode of the same sentence differs, typically at cosine 0.97–0.99 — close enough that nothing looks broken and far enough that your near-duplicate threshold becomes meaningless. It is the same mechanism SimCSE exploits deliberately to manufacture positive pairs, which is a pleasing symmetry: one paper's bug is another's training signal, and the only difference is whether you meant it.

Why no learned projection?

A design contrast worth drawing, because the neighbouring literature made the opposite choice. CLIP and CLAP — the vision-language and audio-language contrastive models — put a learned linear projection after their encoders, mapping each modality into a shared space of a chosen dimension. SBERT does not. Why?

CLIP / CLAPSBERT
InputsTwo different modalities, encoded by two different networksOne modality, one network
Output dimensions before projectionMismatched — e.g. 2048 (vision) and 768 (text)Identical by construction — both are 768
Do the two spaces share a geometry?No — they were trained separately and share no coordinate systemYes — it is literally the same function
Projection needed?Yes, to reconcile dimension and to build a joint coordinate systemNo — there is nothing to reconcile

The projection in CLIP exists to solve a problem SBERT does not have. Two encoders that have never met need a learned map into a common frame; two applications of one encoder are already in a common frame. Adding a projection anyway would only give the model an easy place to undo its own geometry — and would introduce a layer that has to be learned from the pair data, with everything that implies about overfitting.

The generalisable rule: add a projection when you are joining spaces, not when you are shaping one. Multimodal, multilingual-with-separate-encoders, and asymmetric two-tower setups all need one. A single tied encoder does not. If you find yourself adding a projection head to a siamese model, ask what it is reconciling — and if the answer is "nothing", it is a free way to overfit.
Under the STS-B regression objective, MAX pooling scores 69.92 while MEAN scores 87.44 — but under the NLI classification objective the gap is only 1.7 points. Why does the gap depend on the objective?

Chapter 4: Three Objective Functions

The siamese structure delivers two vectors, u and v, and holds one set of weights responsible for both. Now we need a loss — a number to minimise that encodes what "these two sentences are related" should mean geometrically. The paper defines three, and it matters that they are three, because they attach to three different shapes of supervision you might have lying around.

ObjectiveWhat your labels look likeWhat it directly optimisesTrained in the paper on
ClassificationA discrete label per pair (entailment / neutral / contradiction)Separability of the concatenated feature vectorSNLI + MultiNLI
RegressionA graded score per pair (0.0 to 5.0)Cosine similarity itselfSTS benchmark
TripletA grouping: "a and p belong together, n does not"Relative distance — a ranking, not a valueWikipedia section triplets

Objective 1 — classification, and the feature that carries it

Given u and v (each 768-dimensional after pooling), form a single feature vector by concatenating three things: u, v, and the element-wise absolute difference |u − v|. Multiply by a trainable matrix and softmax:

o = softmax( Wt · [ u ; v ; |u − v| ] ) ,   Wt ∈ R3n × k
n = 768 (embedding dimension), k = 3 (labels). So Wt is 2304 × 3 = 6,912 parameters

Then cross-entropy against the gold label. Six thousand nine hundred and twelve parameters, on top of a 110-million-parameter encoder, and they are discarded after training — Wt exists only to create gradient pressure on the encoder. The shipped model is the encoder and the pooling layer, nothing else.

First, where δ = p − y comes from. This identity is used everywhere and derived almost nowhere, so derive it once. With logits z, probabilities pi = ezi/∑jezj, and loss L = −log pc for the true class c:

L = −zc + log ∑j ezj

∂L/∂zi = −[i = c] + ezi/∑jezj = pi − yi

Two terms: the first pulls the true class's logit up, the second pushes every logit down in proportion to how much probability it currently holds. That is the entire behaviour of softmax cross-entropy, and it is why a confidently-wrong class absorbs most of the correction while a class at p = 0.001 absorbs almost none. Chapter 9's InfoNCE inherits exactly this property, which is where its automatic hard-negative weighting comes from.

Now, why |u − v| is the load-bearing feature. Write the concatenated feature as x = [u; v; d] with d = |u − v|, split Wt into three column blocks Wu, Wv, Wd (each 768×3), and use δ = (p − y). We need ∂d/∂u: since di = |ui − vi|, and |x| has derivative sign(x), the Jacobian is diagonal with entries sign(ui − vi). So the gradient arriving at u is:

∂L/∂u = Wuδ + sign(u − v) ⊙ (Wdδ)

Two terms with completely different characters. The first, Wuδ, is the same vector whatever v is — it pushes u in a direction that depends only on the label. It says "sentences with an entailment partner should live over there," which is a weak, absolute instruction.

The second term is the interesting one. The factor sign(u − v) is +1 in every coordinate where u exceeds v and −1 where it does not. So the update is relative: in each coordinate it pushes u toward or away from v depending on which side of v it currently sits. That is a distance-shaping instruction — the first thing in this entire lesson that actually moves two sentences relative to each other.

Without a difference feature the model can cheat, and it does. With only [u; v] the classifier's job is "given two independent summaries, name the relation" — and the easiest way to score well is to memorise which kinds of sentence tend to be premises and which tend to be hypotheses. NLI datasets are notoriously full of such artefacts: hypotheses containing "nobody" or "sleeping" are contradictions far more often than chance. The encoder can serve that shortcut by encoding surface style rather than meaning, and the geometry never improves. The difference term removes the shortcut's advantage because it is a function of both vectors jointly, coordinate by coordinate.

The paper's ablation settles it. With MEAN pooling, trained on NLI, evaluated on STS-B dev:

ConcatenationFeature dimensionSpearmanReading
(u, v)153666.04The baseline. No joint term at all — the shortcut regime
(|u − v|)76869.78The difference alone beats both raw vectors together, at half the width
(u ∗ v)76870.54Element-wise product alone: also joint, also better than (u, v)
(|u − v|, u ∗ v)153678.37Two joint terms together: +8 over either alone
(u, v, u ∗ v)230477.44Product with the raw vectors
(u, v, |u − v|)230480.78The paper's choice
(u, v, |u − v|, u ∗ v)307280.78Adding the product on top buys exactly nothing

Read rows one and two together, twice if necessary. Dropping u and v entirely and keeping only their absolute difference — halving the feature width, throwing away every absolute fact about either sentence — improves the result by 3.74 points. The information the classifier needed was never in the individual vectors; it was in their relationship.

And the last row is a lovely negative result: the model has all the information from row 6 plus the element-wise product, and it does no better at all. The reason is an identity. Coordinate-wise:

(ui − vi)2 = ui2 − 2uivi + vi2  ⇒   uivi = ( ui2 + vi2 − (ui − vi)2 ) / 2

Given u, v, and |u − v|, the product u ∗ v is recoverable by squaring and rearranging — not by a linear map, which is why the ablation is not exactly zero-information, but by a computation the network above easily has. Adding a feature the model can already derive buys nothing, and the table confirms it to two decimal places (80.78 either way).

The general principle worth carrying out of this table. When you concatenate features for a pair, ask of each one: is this a function of the pair, or of one member? Only joint features can teach a metric. Individual features can only teach the classifier about the marginal distribution of sentences, and in a dataset with annotation artefacts — which is every crowdsourced dataset — that is a shortcut with real predictive power and zero geometric value. The 66.04 row is what a shortcut looks like on a scoreboard.
Concept + realisation: train-time and test-time do not match here, and that is fine. This objective never mentions cosine. It trains a classifier on a 2304-dimensional concatenation; at inference you throw the classifier away and take cos(u, v). Why does that transfer? Because the only generalisable way for the encoder to make |u − v| informative about the label is to make small differences mean "related" and large differences mean "unrelated" — that is, to arrange a metric. The classifier is scaffolding: it forces the geometry into existence and is then removed. It also explains the residual gap in Chapter 7 — scaffolding leaves marks, and directly optimising cosine (objective 2) does better on cosine-scored tasks.

Objective 2 — regression on cosine, and its gradient

When your labels are graded — STS pairs come with human scores from 0 (unrelated) to 5 (equivalent) — you can dispense with the classifier entirely:

ŷ = cos(u, v) = (u · v) / (‖u‖ ‖v‖) ,   L = ( ŷ − y )2
with y the human score rescaled to [0, 1] (or [−1, 1]); mean-squared-error over the batch

Zero new parameters. Train-time and test-time are now identical, which is the cleanest possible situation.

Derive the gradient, because its shape is genuinely surprising and it explains what cosine training does to a space. Let a = ‖u‖, b = ‖v‖, and c = cos(u,v) = (u·v)/(ab). Differentiate the quotient with respect to u, using ∂‖u‖/∂u = u/a:

∂c/∂u = v/(ab) − (u · v) · u / (a3b) = (1/a) · [ v/b − c · u/a ] = (1/a) · [ v̂ − c · û ]

where û and v̂ are the unit vectors. Now check something: what is the component of this gradient along u itself? Dot it with û:

û · ∂c/∂u = (1/a) · [ û·v̂ − c (û·û) ] = (1/a) · [ c − c ] = 0

Exactly zero, always. The cosine gradient is orthogonal to u. It can only rotate u; it can never lengthen or shorten it. That is a deep and useful fact: the regression objective sculpts directions and is completely blind to magnitudes. Vector norms drift under it as an unconstrained side effect, which is one reason practitioners L2-normalise at index time regardless of what the training did.

Note also the 1/a factor. A long vector receives a proportionally smaller angular update from the same error — longer vectors are harder to steer. If a subset of your sentences (say, long ones) systematically has larger norms, they will move more slowly during training. Normalising before the loss removes this asymmetry entirely, and is a small, real improvement over the paper's default.

Why MSE on cosine and not, say, cross-entropy on a binarised label? Because the supervision is graded and the grades are meaningful: a pair at 3.5 is genuinely more similar than one at 2.5. Binarising throws that away, and it forces you to pick a threshold that has no principled value. MSE also has a helpful gradient shape here — ∂L/∂ŷ = 2(ŷ − y) is largest exactly where the model is most wrong, and vanishes when it is right, so already-correct pairs stop consuming capacity.

Objective 3 — triplet, and the geometry of a margin

Sometimes you have neither labels nor grades, only structure: three sentences from the same Wikipedia section are related, one from another section is not. The triplet objective takes an anchor a, a positive p, and a negative n, and demands that the anchor be closer to the positive than to the negative by a margin:

L = max( ‖sa − sp‖ − ‖sa − sn‖ + ε , 0 )
Euclidean distance; the paper sets the margin ε = 1

Unpack the hinge. If the positive is already closer than the negative by more than ε, the bracket is negative, the max returns 0, and the gradient is exactly zero — that triplet is solved and contributes nothing. If it is not, the loss is the shortfall, and the gradient pulls sp toward sa while pushing sn away.

Why a margin at all? Without ε, the loss ‖sa−sp‖ − ‖sa−sn‖ is minimised by making the second term enormous, and there is a degenerate solution where the encoder simply blows up the scale of everything. The margin makes the objective satisfiable: once the ordering holds with a gap of ε, stop. It converts an unbounded optimisation into a constraint, and the hinge is what turns "solved" into "silent."

The relation to cosine, which ties this objective back to inference. If all vectors are L2-normalised, then:

‖sa − sp2 = ‖sa2 + ‖sp2 − 2 sa·sp = 1 + 1 − 2cos = 2(1 − cos)

So on the unit sphere, squared Euclidean distance is a strictly decreasing function of cosine similarity: ranking by one is identical to ranking by the other. Training with Euclidean triplets and serving with cosine is therefore consistent — provided you normalise. Without normalisation the two rankings genuinely differ, and this mismatch is a classic silent bug in retrieval systems.

Worked numbers on a margin. Take normalised vectors with cos(a, p) = 0.80 and cos(a, n) = 0.50. Then:

‖sa−sp‖ = √(2(1−0.80)) = √0.40 = 0.632
‖sa−sn‖ = √(2(1−0.50)) = √1.00 = 1.000
L = max(0.632 − 1.000 + 1, 0) = max(0.632, 0) = 0.632

Still a positive loss, even though the ordering is already correct by 0.368. With ε = 1 on unit vectors the largest achievable separation is ‖a−n‖ ≤ 2, so the constraint "positive distance + 1 ≤ negative distance" demands a very wide gap indeed. This is a real subtlety: a margin of 1 is aggressive for normalised embeddings and mild for unnormalised ones. The margin's meaning depends entirely on the scale of your space, which is why triplet training is famously fiddly, and why in-batch softmax contrastive losses (which are scale-free after normalisation, up to a temperature) largely replaced it after 2020.

The cosine gradient, on numbers. Take 2-dimensional unit vectors u at 0° and v at 60°, with gold y = 1. Then cos = 0.5, and:

û = (1, 0) ,  v̂ = (0.5, 0.866) ,  a = ‖u‖ = 1
∂c/∂u = (1/a)[ v̂ − c û ] = (0.5, 0.866) − 0.5(1, 0) = (0.0, 0.866)
check orthogonality:  û · (0.0, 0.866) = 0 ✓

∂L/∂u = 2(c − y) · ∂c/∂u = 2(0.5 − 1)(0.0, 0.866) = (0.0, −0.866)

Descent moves u by −η∂L/∂u, i.e. in the direction (0, +0.866) — straight up, perpendicular to u, which rotates it toward v at 60°. Exactly as the derivation promised: no component along u, so the length is untouched and only the angle changes. Take η = 0.1 and u becomes (1, 0.0866), whose angle is 4.95° — it has rotated about 5° of the 60° gap in one step, and its norm has grown to 1.004 only because a finite step leaves the tangent line, not because the gradient had a radial part.

That last detail is a real effect at scale: repeated finite steps along tangents slowly inflate norms even though every gradient is orthogonal. It is harmless if you normalise before comparing, and confusing if you do not.

Objective playground — three losses on the same three vectors

Anchor u is fixed. Move the positive v and the negative n around the circle, and watch all three objectives evaluate the same configuration differently. The arrows show the gradient direction each loss would apply to v: the cosine-regression arrow is always tangential (it can only rotate, never stretch — the derivation above, drawn); the triplet arrow vanishes the instant the margin is satisfied; the classification bars show the three concatenation features and how much of each is joint.

Positive angle 35°
Negative angle 95°
Margin ε 1.00
Objective:

Set the positive to 35° and the negative to 95° and step through the three objectives. Under regression with a gold score of 1.0 the loss is (cos 35° − 1)2 = (0.819 − 1)2 = 0.0327 and the gradient rotates v toward u. Under triplet with ε = 1 the loss is positive even though the ordering is correct — drag the negative out to 150° and watch it hit exactly zero, at which point that triplet stops teaching the model anything at all. That dead zone is why triplet training needs hard negative mining: as the model improves, a randomly chosen negative satisfies the margin almost always, gradients go to zero, and learning stalls. You must actively search for negatives that are still confusable.

Why the product feature alone reaches 70.54

One ablation row deserves an explanation rather than a shrug: (u ∗ v) alone scores 70.54, beating |u − v| alone at 69.78 and both raw vectors together at 66.04. What does an element-wise product know?

Sum its coordinates and you get the dot product: ∑i uivi = u · v. So the product feature is an un-summed dot product — it hands the classifier every term of the similarity separately, letting it learn which dimensions should count and how much. It is cosine similarity with per-dimension learned weights, which is strictly more expressive than cosine.

The difference feature carries related but not identical information. Compare what each says about a single coordinate:

uivi|ui − vi|uiviWhat each one sees
2.02.00.04.0Difference: "agree". Product: "agree, strongly"
0.10.10.00.01Difference: "agree" — identical to the row above. Product: "agree, weakly"
2.0−2.04.0−4.0Both: "disagree strongly". The product also gets the sign
3.01.02.03.0Difference: "some disagreement". Product: "both positive, moderately aligned"

Rows 1 and 2 are the crux: the difference cannot distinguish agreement-at-high-magnitude from agreement-at-low-magnitude, and the product can. Conversely, the product cannot distinguish (3, 1) from (1, 3) — it is symmetric — while the difference at least records the gap. They are complementary views, which is why the two together reach 78.37, well above either alone.

And yet the paper's choice, (u, v, |u − v|), beats that pair at 80.78. Why? Because with u and v both present, the classifier can recover magnitude information itself — the thing the difference was missing. The winning combination is not "the two best features"; it is a set that is jointly sufficient. Feature selection is about coverage, not about individual scores, and this ablation is an unusually clean demonstration.

The objective that replaced all three

Everything above is 2019. It is worth seeing the successor now, because it makes the three objectives' tradeoffs legible and because it is what you would actually write today. The multiple-negatives ranking loss (also called InfoNCE, or in-batch softmax) needs only positive pairs: (a1, b1), …, (aN, bN) in a batch. For row i, bi is the positive and every other bj is a negative.

Li = −log [ exp(cos(ai, bi)/τ) / ∑j=1N exp(cos(ai, bj)/τ) ]   τ ~ 0.05, so 1/τ = 20

Work a batch of three. Suppose the cosines of a1 against the three candidates are (0.62, 0.55, 0.10) — the true partner first, a confusable one second, an easy one third. Multiply by 1/τ = 20:

z = (12.4, 11.0, 2.0)
subtract the max for numerical stability: (0, −1.4, −10.4)
e0 = 1.0000 ,  e−1.4 = 0.2466 ,  e−10.4 = 0.0000304 ,  sum = 1.2466
p = (0.8022, 0.1978, 0.0000244) ,   L1 = −ln(0.8022) = 0.2204

Now read the gradient allocation from δ = p − y = (−0.1978, 0.1978, 0.0000244). The confusable negative absorbs 0.1978 of repulsive force; the easy negative absorbs 0.0000244 — eight thousand times less. Nobody mined those negatives, nobody set a margin, and nobody sorted them by difficulty. The softmax did it, because its denominator weights each competitor by the probability it currently holds. That is the property triplet loss lacks, and the reason triplet was abandoned.

QuestionTriplet (2019)In-batch softmax (2020–)
Negatives per example1, hand-chosenN − 1, free
Hard negativesA separate mining pipelineEmergent — the softmax weights them automatically
Hyperparameter to tuneMargin ε, whose meaning depends on the norm scaleTemperature τ, on scale-free cosines
Gradient when already doing wellExactly zero — learning stallsSmall but never zero
Effect of batch sizeNone on the objectiveBatch size is the negative count — bigger is a harder, better task
Labels neededTriplesPositive pairs only

Notice that temperature does the job the margin was doing, but on a quantity with a fixed scale. Cosine lives in [−1, 1] whatever your embeddings do, so τ = 0.05 means the same thing in every model — unlike ε = 1, which as the arithmetic above showed is aggressive on unit vectors and mild otherwise. Choosing a hyperparameter on a normalised quantity is a small design decision with an outsized effect on how transferable a recipe is.

Which objective should you use? Match the objective to the supervision you actually possess, then to the scoring function you will actually serve. Graded pairs and cosine at inference → regression, every time; it is the only one with no train-test gap. Discrete relational labels → classification with (u, v, |u−v|). Only groupings, no labels → triplet, and budget engineering time for negative mining. If you have positives only — question-answer pairs, title-body pairs, duplicate reports — use the multiple-negatives-ranking loss that came after this paper: treat the other items in the batch as negatives and apply softmax cross-entropy. It is triplet loss with N−1 negatives at once and no margin to tune, and it is what almost every modern embedding model trains with.

The three objectives, compared as gradient machines

Set the losses side by side by the only property that matters during training: which examples still produce a gradient.

PropertyClassificationCosine regressionTriplet
New parameters2304 × 3 = 6,912, discarded after trainingNoneNone
Train / test metric matchNo — trains a classifier, serves cosineExactPartial — Euclidean, equals cosine ranking iff normalised
Gradient when already correctSmall but nonzero (softmax never saturates fully)Small but nonzero (MSE → 0 smoothly)Exactly zero once the margin is met
Negatives per exampleImplicit — one contradiction pair at a timeImplicit — low-scored pairsExactly one, explicitly chosen
Scale sensitivityLow — the classifier can rescaleNone — cosine is scale-freeHigh — ε means nothing without knowing the norm scale
Main failure modeExploits dataset artefacts if the joint feature is missingNeeds graded labels, which are expensiveGradient starvation — needs hard-negative mining

Row 3 is the operational one. A loss that goes exactly to zero is a loss that stops teaching, and as the encoder improves, an ever-larger fraction of randomly-sampled triplets fall into that dead zone. Late in training you can be computing thousands of forward passes per step that contribute literally nothing to the gradient — burning GPU on satisfied constraints. That is the specific pathology in-batch softmax losses were invented to avoid: their denominator always has something in it, so every example keeps contributing, weighted by how threatening it is.

What all three objectives have in common

Strip away the details and the same skeleton is underneath each one, which is the thing to remember when you meet a fourth.

1. Bring two (or three) sentences into the same space
Necessarily the same function, so the comparison is meaningful
2. Compute something that depends on all of them jointly
|u − v|, or cos(u, v), or the two distances in a triplet. This is the load-bearing step — without it there is no metric pressure
3. Compare that quantity to what the supervision says it should be
A class, a graded score, or an inequality. Any of the three works
4. Backpropagate into the shared weights
One parameter set absorbing constraints from every pair it ever sees — which is what forces a general function rather than memorised answers

Step 2 is the whole content of Chapter 1's diagnosis, appearing here as a design rule. Masked language modelling has steps 1, 3 and 4 and no step 2 — and that missing step is worth twenty Spearman points. Any new objective you encounter can be read against this skeleton: find its step 2, and you know what geometry it is building.

Training configuration, from the paper

SettingValueWhy it is what it is
ObjectiveClassification, 3-wayMatches the NLI label shape
PoolingMEANChapter 3's ablation
Epochs1One million pairs is plenty; more epochs overfit the NLI genre
Batch size16Small — there are no in-batch negatives in this objective, so batch size is only about gradient noise
OptimiserAdam, lr 2e-5The standard BERT fine-tuning rate; larger rates destroy pretrained features
Warm-upLinear over the first 10% of stepsAdam's second-moment estimates are unreliable early; a full-rate step at t = 1 can wreck the encoder
Wall-clock< 20 minutes on one V1001,000,000 / 16 = 62,500 steps — a very short run by any standard

Twenty minutes. That is the entire training cost of the fix, on top of a pretrained encoder. Chapter 0's 46,700× speed-up and Chapter 1's 20-point quality jump are both bought with a single-GPU job shorter than a lunch break, which is the strongest possible evidence that the bottleneck was never compute — it was that nobody had applied pair-level pressure to a good encoder.

Where the gradient goes after u and v

Everything derived so far stops at the pooled vectors. One paragraph on what happens below them, because it explains the learning-rate choice in the next section.

∂L/∂θ = ∑i (∂L/∂ui)(∂ui/∂θ) + ∑i (∂L/∂vi)(∂vi/∂θ)

Pooling divides ∂L/∂u evenly among the L token positions (Chapter 3's derivative), so each token vector receives 1/L of it. That signal then flows back through twelve transformer layers to every weight in the model — and it arrives twice, once from each siamese pass, into one shared θ.

Layer groupWhat the pair loss asks of itHow much it should move
Embeddings (word/position)Almost nothing — token identity is already correctBarely. Some recipes freeze them outright
Lower layers (1–4)Syntax and local composition, already well learnedA little
Upper layers (9–12)The task-specific arrangement — where the reorganisation happensThe most
PoolingNothing — no parameters

A uniform learning rate of 2e-5 across all of them is a compromise: small enough that the lower layers are not damaged, large enough that the upper ones can move. Layer-wise decaying rates (larger at the top, smaller at the bottom) are a standard refinement worth a fraction of a point — and a good illustration that "one learning rate" is a simplification everyone accepts rather than a principled choice.

Choosing the one hyperparameter each objective has

Each objective exposes exactly one scale knob, and the procedure for setting it is the same in all three cases: relate it to a quantity you can measure in your own data, rather than copying a number from a paper trained on different vectors.

ObjectiveThe knobHow to set it
ClassificationNone, effectively — the classifier learns its own scaleThe reason this objective is forgiving, and why the pooling ablation is flat under it
RegressionThe rescaling of the gold labels into [0, 1] or [−1, 1]Match it to your model's achievable cosine range. If unrelated pairs bottom out at 0.3, asking the model to output 0.0 for them is asking for a rotation it cannot make without wrecking other pairs
TripletMargin εMeasure the current distribution of ‖a − p‖ and ‖a − n‖ on a sample, and pick ε near the overlap of the two distributions — roughly one standard deviation of the negative distances. On normalised vectors that is usually 0.2–0.5, not 1.0
InfoNCETemperature τ0.05 is a genuinely good default because cosine has a fixed range. Lower sharpens (more weight on the hardest negative, more instability); higher softens

Row two is the one people get wrong most often, and the second sentence explains why a naive regression fine-tune can make a model worse. Gold labels rescaled to [0, 1] tell the model that unrelated pairs must reach cosine 0.0. In an anisotropic space where the floor is 0.3, that instruction is unsatisfiable for most pairs, so the optimiser spends its capacity flattening the space in a way that damages the pairs it was getting right. Mapping the gold range onto the model's achievable range instead — or simply centring first — removes an impossible demand.

What the paper did not try

Reading a paper for what is absent is often more instructive than reading it for what is present, and in this case each gap became somebody's follow-up.

Not triedWhat it would have beenWho did it, and what happened
A temperature on the cosineDivide cosine by τ before the loss, sharpening the comparisonSimCSE and every contrastive model since. Worth several points — it controls how hard the model pushes on near-misses
In-batch negativesContrast each pair against the rest of the batch rather than one negative at a timeDPR (2020) for retrieval, then universally. The single largest objective improvement over this paper
L2-normalising during trainingForce unit vectors before computing the lossStandard now. Removes the 1/‖u‖ gradient-scale asymmetry derived above
Ranking-consistent regressionOptimise the order of similarities rather than their values, matching SpearmanCoSENT and related losses (2022). MSE on cosine optimises values while evaluation measures ranks — a real, if small, mismatch
Hard-negative mining for the triplet objectiveSearch for negatives that violate the marginWas already standard in face recognition; the paper's Wikipedia triplets use random section negatives

Row 4 is the subtlest and worth a moment. Chapter 7 evaluates with Spearman — a rank correlation — while objective 2 minimises squared error on cosine values. A model could rank every pair perfectly and still carry loss, because its values are systematically compressed; and a model could match the values well while inverting a few near-ties. The mismatch is small, which is why it took three years for anyone to attack it, but it is the same species of train-test gap the paper explicitly calls out for the classification objective. Every objective in this chapter has one; the question is only how big.

How to read any paper's method section, given this table. For each design choice, ask: is this load-bearing, or is it the first thing that worked? Here, the siamese structure and the difference feature are load-bearing — the ablations prove it. The specific loss functions are the first things that worked, borrowed from adjacent literature, and every one of them was improved within three years. The distinction tells you which parts of a paper to trust and which to treat as a starting point.
In the concatenation ablation, using only |u − v| (768 dims) beats using (u, v) together (1536 dims) — 69.78 vs 66.04. What does that tell you?

Chapter 5: Why Entailment Teaches Meaning

Here is a thing that should strike you as odd. To build a model that answers "do these two sentences mean the same?", the paper trains on a dataset that answers a different question entirely: "does the first sentence imply the second?" Those are not the same relation. Entailment is directional and asymmetric; similarity is neither. And yet NLI supervision produces the best general-purpose sentence embeddings of its era, and continues to do so — it is still in the training mix of modern models six years later. This chapter is about why that works.

What the data is

CorpusSizeSource of premisesHow hypotheses were written
SNLI (Bowman et al. 2015)570,000 pairsFlickr30k image captionsCrowdworkers saw a caption and wrote three sentences: one definitely true, one maybe true, one definitely false
MultiNLI (Williams et al. 2018)430,000 pairsTen genres: fiction, government reports, telephone speech, letters, 9/11 report…Same protocol, deliberately across domains
Combined (SBERT)1,000,000 pairsThree labels: entailment, neutral, contradiction

A concrete triple, of the kind that fills SNLI. Premise: "A man is playing a guitar on stage."

LabelHypothesisLexical overlap with the premise
Entailment"A man is playing an instrument."High — "a man is playing an"
Neutral"A man is playing his favourite song."High — "a man is playing"
Contradiction"A man is sleeping."High — "a man is"
This table is the whole reason NLI works, and it is about the negatives. Look at the contradiction row. It shares "a man is" with the premise, it is about the same subject, it is the same length, the same register, the same genre. A bag-of-words model, a TF-IDF cosine, an average of GloVe vectors — all of them score it as fairly similar to the premise. It is a hard negative: superficially close, semantically opposite. And crucially, the crowdworkers who wrote it were looking at the premise while they wrote. The dataset's construction protocol manufactures hard negatives at industrial scale, which is precisely the expensive part of building contrastive training data.

Compare with the alternative supervision available in 2019. Paraphrase corpora (positives only, and often near-duplicate positives that teach nothing). Question-answer pairs (positives only). Random negatives sampled from the corpus (trivially easy — a random other sentence is about a different topic, so the model learns topic matching and stops). NLI hands you a million examples with hard negatives already written by humans who were trying to be adversarial. That is an unusual gift.

What the model is forced to learn

Put the classification objective from Chapter 4 next to this data and follow the pressure. For the contradiction pair, the classifier sees [u; v; |u − v|] and must output "contradiction." For the entailment pair it must output "entailment." The two hypotheses have nearly identical surface forms. So the difference vector |u − v| must come out substantially different in the two cases, and the only way to achieve that is for the encoder to place "playing an instrument" near "playing a guitar" and "sleeping" far from it.

Ask what would happen if the encoder took the lazy route and encoded surface form. Then |u − v| would be small for all three pairs, since all three hypotheses look alike, and the classifier would face three different labels on nearly identical inputs. Its loss cannot go down. Backpropagation's only route to a lower loss runs through the encoder learning to separate the sentences by what they assert.

If the encoder represents surface form
"playing an instrument", "playing his favourite song", "sleeping" all sit near each other (shared words, shared structure). Three different labels on near-identical features → irreducible loss
↓ the only way down
The encoder represents assertion
"playing an instrument" moves toward the premise; "sleeping" moves away. |u−v| now differs sharply between the pairs, the labels become separable, loss drops
↓ and this is a general function, applied to every sentence
Side effect: a similarity space
Nothing asked for symmetric similarity. It is what "small |u−v| means related" generalises to, once the encoder must satisfy a million such constraints with one set of weights

Real examples, across MultiNLI's genres

Abstract descriptions of a dataset are much less useful than a page of it. These are representative of what the encoder is being asked to separate:

GenrePremiseHypothesisLabel
Fiction"He turned and smiled at Vrenna.""He smiled at Vrenna who was walking slowly behind him."Neutral
Government"At the same time, top experts predict the deficit will shrink.""Experts believe the deficit will grow."Contradiction
Telephone"yeah i mean it's it's just it takes a lot of time""It is time consuming."Entailment
Travel"The tower is open daily from nine until dusk.""You cannot visit the tower in the morning."Contradiction
Letters"Your gift will help us continue this vital work.""Donations fund the organisation's programmes."Entailment

Read the telephone row carefully. The premise is disfluent spoken transcript with no punctuation and a stammer; the hypothesis is clean written English. They mean the same thing and share almost no surface form. That is a hard positive, and it is the mirror image of the hard negatives — supervision that forces the encoder past style and register into content. SNLI alone, being all image captions, contains nothing like it. It is a concrete instance of why the genre mixture matters.

And the travel row shows the reasoning depth that is occasionally required: to see the contradiction you must know that "daily from nine" includes mornings. No amount of lexical overlap detects that, and honestly, no bi-encoder reliably will either — but the pressure to try is what pushes the representation toward content.

What happens if you train longer

One epoch looks like an under-training decision until you see what more does. The qualitative pattern, consistent across the literature on fine-tuning pretrained encoders on a narrow objective:

Training lengthNLI accuracySTS transferWhat is happening
0 (mean-pooled BERT)chance54.81No pair pressure at all
~0.2 epochRising fastMost of the gain already realisedThe geometry reorganises early; this is a coarse reshaping, not a slow fit
1 epochGood74.89 — the paper's numberThe chosen stopping point
3–5 epochsBetter stillFlat, then decliningThe model is now fitting NLI's artefacts and its particular genre mixture
10+ epochsBest on NLIClearly worseCatastrophic forgetting: pretrained linguistic knowledge overwritten by a narrow task

The shape of that table — target-task metric rising monotonically while transfer peaks early and then falls — is the signature of overfitting to a proxy. NLI accuracy was never the goal; it is scaffolding, exactly like the classifier matrix Wt. Optimising a proxy past the point where it correlates with the real objective is one of the most common ways to make a model worse while every number on your dashboard improves.

Which means you must validate on the real thing. If you fine-tune an embedder on NLI-style data, your early-stopping signal should be STS or your own retrieval metric — never the training objective's accuracy. Checkpoint every few hundred steps, evaluate on the downstream metric, and keep the best. It costs a few minutes of evaluation and it is the difference between the paper's 74.89 and something noticeably worse arrived at by training "properly" for longer.

The artefact problem, and why the siamese structure survives it

Now the complication. NLI datasets are famous for annotation artefacts: statistical regularities in the hypotheses that a model can exploit without reading the premise at all. Because crowdworkers producing contradictions reach for negation, and workers producing entailments reach for generic superordinates, the hypothesis alone leaks the label.

Signal in the hypothesis aloneWhich label it predictsWhy the worker wrote it
"nobody", "no", "never", "sleeping"ContradictionNegating is the fastest way to make something definitely false
"animal", "instrument", "outdoors", "person"EntailmentGeneralising is the fastest way to make something definitely true
"tall", "sad", "first", "favourite", "because"NeutralAdding an unverifiable detail is the fastest way to make something maybe-true
Hypothesis is much shorter than the premiseEntailmentGeneralisations drop detail

The effect is large: a model given only the hypothesis, never seeing the premise, reaches roughly 67% on SNLI where chance is 33%. Two-thirds of the task is solvable without doing the task.

So why does SBERT learn anything real? Because of the architecture, not the data. A cross-encoder can route hypothesis-only features straight to the classifier — it sees both sentences' tokens in one stream and can simply ignore half of them. The siamese bi-encoder cannot: the classifier consumes [u; v; |u − v|], and the difference block is a function of both. To exploit "this hypothesis contains 'nobody'" the encoder must write that fact into v, where it also affects |u − v| against every possible premise — including the many premises that are themselves negative. The shortcut becomes self-defeating under weight sharing. The structure launders the dataset's flaws, which is a much better reason than "the data is clean," because the data is not clean.

The ablation from Chapter 4 is the receipt: (u, v) alone scores 66.04, which is the shortcut regime — and the model is still reading a hypothesis-only signal for a large part of that. Adding |u − v| takes it to 80.78. The 14.74-point gain is the shortcut being closed off.

Why not paraphrase corpora, or web pairs?

An obvious alternative in 2019 was to train on data that matches the target task directly: paraphrase pairs. Here is the accounting of why NLI wins anyway.

Data sourcePositivesNegativesProblem
Paraphrase corpora (MRPC, PPDB)Real but often near-identical stringsNone suppliedPositives too easy (high lexical overlap = trivially learnable), negatives must be sampled randomly, and random negatives are about different topics — the model learns topic detection and stops
Question–answer pairsReal and non-trivialNone suppliedExcellent, but asymmetric (short question, long answer) and only available at scale for some domains
Adjacent sentences in a documentFree, unlimitedNoneTeaches topical continuity, which is NSP's weak signal all over again
Back-translation pairsFree, unlimitedNoneTeaches surface invariance, not meaning — the two versions really do mean the same thing, so there is no hard case
NLIEntailment pairs, non-trivially wordedContradictions, written adversarially against the premiseCostly to produce — but it already existed

Every row but the last is missing the same thing: supplied hard negatives. Random negatives from a corpus are almost always about a different topic, so the model can satisfy the objective by learning coarse topic separation, at which point the gradient dies and the fine structure never gets built. NLI's contradiction column is a million hand-written, same-topic, opposite-meaning negatives. That is the asset, and it is the reason a dataset built for a completely different research question turned out to be the best sentence-embedding corpus of its decade.

The transfer that shouldn't work, and roughly why it does

Be honest about the mismatch, because it is real. Entailment is asymmetric: "a man is playing a guitar" entails "a man is playing an instrument", but not conversely. Cosine similarity is symmetric by construction — cos(u, v) = cos(v, u), always. So the training relation has a property the inference metric structurally cannot express.

What survives the collapse is the part of entailment that is symmetric: topical and propositional compatibility. If A entails B then A and B are about the same situation and do not conflict. If A contradicts B they are about the same situation and do conflict. The direction is lost; the compatibility is kept. And compatibility, averaged over a million examples spanning ten genres, is a very good proxy for what humans mean by "these sentences are similar."

The honest cost of the collapse, measurable. Because direction is discarded, SBERT cannot distinguish "A entails B" from "B entails A", and it handles negation poorly: "the treatment was effective" and "the treatment was not effective" share nearly all their tokens and their situation, differing only in the operator that flips the assertion. Their cosine typically lands around 0.85–0.95 in NLI-trained SBERT. If your application is fact-checking, contract compliance, or medical claim verification, this is disqualifying — and it is exactly the case where you keep a cross-encoder, which can see the "not" against the other sentence's verb inside its attention.

Why the classification objective, and not regression, on this data

A fair question, given Chapter 4 argued that regression has no train-test gap: NLI labels are ordered — entailment, neutral, contradiction runs from most to least compatible. You could map them to 1.0, 0.5, 0.0 and regress on cosine, closing the gap. Why did the authors not?

ConsiderationClassificationRegression on mapped labels
Are the gaps equal?Does not assume soAssumes entailment–neutral and neutral–contradiction are the same distance. They are not
AchievabilityThe classifier absorbs any scale mismatchDemands cosine 0.0 for contradictions, which an anisotropic space cannot deliver — the unsatisfiable-target problem
Feature accessSees u, v and |u − v| through a trained matrixSees one scalar
Robustness to label noiseHigher — a flipped label costs one misclassificationLower — a flipped label is a large squared error pulling hard in the wrong direction

Rows 1 and 2 are the substantive ones. Contradiction does not mean "similarity zero" — a contradiction pair is about the same situation, so a sensible space places it moderately close, and demanding 0.0 fights the very structure you want. The classification objective sidesteps the question by never committing to a number, which is exactly the forgivingness Chapter 3's flat pooling ablation revealed.

The modern resolution is neither: use entailment pairs as positives and contradiction pairs as hard negatives in an InfoNCE loss, which asks only that the positive outscore the negatives — a ranking constraint, not a value one. Supervised SimCSE does precisely this on the same data and gains about 6.7 points. Same labels, third framing, best result.

What NLI does not teach

Symmetric across the earlier list of what NLI supplies, here is what it leaves out — and each gap predicts a specific production complaint.

Not in the dataConsequenceComplaint you will hear
Long documents — NLI sentences average ~14 tokensThe model was never trained to summarise a paragraph into one vector"It works on titles but not on article bodies"
Asymmetric pairs — both sides are single sentencesNo notion of query-versus-document"Short queries never match long answers"
Specialist vocabularyTechnical terms sit wherever pretraining left them, unshaped by any pair"It thinks these two error codes are the same"
Numbers and quantities"$4.99" and "$499" are near-identical strings and were never contrasted"Price filters do not work through search"
Structured text — code, tables, logsOut of distribution entirely"Code search returns nonsense"
Anything after 2018The encoder's world model has a cut-off, and NLI does not update it"It has never heard of our product"

Every row is fixable by the same move — include pairs of that kind in training — which is why modern embedders train on a deliberately heterogeneous mixture: web pairs, question-answer pairs, code, titles-and-bodies, multilingual, and NLI. The mixture is the model. And it is why "this embedder is bad at X" almost always means "X was not in the mixture" rather than anything about capacity.

The labels are not clean, and that is fine

SNLI's gold labels come from a vote. Each pair was shown to five annotators; the gold label is the majority, and pairs without a majority were discarded. Even among the kept pairs, agreement is imperfect — individual annotators match the gold label roughly 88% of the time, so something like one label in eight is contested by a competent human.

That should worry you more than it does, and the reason it does not is worth understanding.

Where the label noise landsEffect on training
Entailment vs neutral boundaryThe most contested boundary — "is this definitely implied or only likely?" — and also the one that matters least for a similarity space, since both mean "compatible and related"
Contradiction vs the othersMuch higher agreement. Humans concur about incompatibility, and this is the boundary that carries the geometric signal
Random errorsAveraged out across a million examples and 62,500 gradient steps. Cross-entropy is fairly robust to symmetric label noise
Systematic errors (the artefacts)Not averaged out — these are the ones the siamese structure has to defend against, and does

So SBERT is comparatively lucky: its target geometry depends mostly on the well-agreed boundary, and the noisy boundary separates two labels it does not much care to distinguish. A model trained to do NLI is limited by the 88% ceiling. A model using NLI to shape a space is not, because it only needs the coarse structure the labels reliably encode.

The transferable version. When repurposing a dataset, ask which of its distinctions your objective actually depends on — not how accurate the labels are overall. Noise on a distinction you do not use is free. Noise on the one you do use is your ceiling. It is the difference between "this dataset is 88% accurate" and "this dataset is 88% accurate on the axis I need," and only the second number matters.

Directionality, made concrete

The asymmetry is easy to wave at and worth pinning down with an example you can check. Take:

DirectionStatementTrue?
A → B"A poodle is running in the park" implies "a dog is outside"Yes, necessarily
B → A"A dog is outside" implies "a poodle is running in the park"No — it could be a beagle, asleep, in a garden
cos(A, B)One numberNecessarily the same in both directions

So one bit of the relation — which sentence is more specific — cannot survive into the embedding space at all. What does survive is the shared situation, and that is enough for similarity but not for inference. If your application needs the direction (does this document support this claim? does this log line satisfy this alert rule?) then a symmetric metric is the wrong instrument regardless of how good the encoder is.

There is a research line that fixes it — order embeddings and hyperbolic embeddings represent entailment as containment or as depth in a tree rather than as proximity, so asymmetry is native. They are a genuinely different geometry and outside this paper's scope, but knowing they exist prevents the mistake of expecting cosine to do a job it is mathematically incapable of.

Counting the hard negatives you get for free

Chapter 9 will claim that hard negatives are the highest-leverage variable in embedding training. Quantify what NLI hands you. Of roughly one million pairs, the label distribution is close to uniform across the three classes:

contradictions ≈ 1,000,000 / 3 ≈ 333,000 hard negatives
each one: same subject, high lexical overlap, opposite assertion, written by a human looking at the premise

Now price the alternative. To mine negatives of that quality yourself you would retrieve top-k candidates for each positive with a bootstrap model, filter out the true positives (which requires labelling, or you poison your training set with false negatives), and repeat as the model improves so the negatives stay hard. That is a multi-week engineering effort producing lower-quality negatives, per domain.

Concept + realisation: this is why a 2015 dataset built to study logical inference became the backbone of an unrelated field. Its value was never the entailment labels. It was that three hundred thousand humans were paid to write, for each of a third of a million sentences, a sentence that looks like it and means the opposite. Nothing else in NLP had that property at that scale. When you evaluate a dataset for embedding training, ask first: how good are the negatives, and who wrote them?

The three labels, and what each one contributes

It is tempting to think the three-way label is incidental — that you could binarise to "related / not related" and lose nothing. Each class is doing a distinct job.

LabelGeometric instructionWhat is lost without it
EntailmentPull these togetherEverything. Without positives there is no notion of "close" at all
ContradictionPush these apart — despite the shared topicThe hard negatives. The model would satisfy the loss with topic detection and stop improving
NeutralKeep these at a middling distance — related but not equivalentThe gradation. Without it the space becomes binary: things are identical or unrelated, with no middle

The neutral class is the underrated one, and it is what makes the resulting space usable for ranking rather than just matching. STS asks "which of these two pairs is more similar," a question that only has an answer if the space has intermediate distances. A model trained on positives and hard negatives alone tends toward a bimodal similarity distribution — a spike near 1 and a spike near the floor — which scores fine on binary duplicate detection and poorly on Spearman.

Modern supervised contrastive setups reproduce this by mixing in-batch negatives (easy, the "unrelated" end) with explicit hard negatives (the contradiction end) while positives supply the top. The three-way label is one way of specifying that mixture; it is not the only way, but the mixture itself is not optional.

Why MultiNLI matters as much as SNLI

SNLI's premises are all image captions. That is a peculiar and narrow genre: present tense, concrete, visually grounded, short, third person. A model trained on SNLI alone learns a space beautifully organised for describing photographs and less so for anything else.

MultiNLI was built to fix this — the same annotation protocol over ten genres including telephone transcripts, government reports, fiction and letters. Adding it to the mix is what makes SBERT a general-purpose encoder rather than a caption encoder. Chapter 9's failure mode — domain shift — is exactly what you get when the training genres do not cover your deployment genre, and the SNLI/MultiNLI mix is a partial, not complete, defence.

Training dataWhat the space is good atWhere it degrades
SNLI onlyConcrete descriptions of scenes and actionsAbstract argumentation, technical text, dialogue
SNLI + MultiNLIGeneral English across ten written and spoken genresSpecialist vocabulary (legal, biomedical, code), and any domain where similarity means something task-specific
+ in-domain pairsYour domainWhatever you did not include — but now you own the tradeoff explicitly

What one epoch actually changes

It is worth forming a picture of what the space looks like before and after, because "the geometry improves" is vague and the change is quite specific.

Property of the spaceBefore (mean-pooled BERT)After (SBERT-NLI)Mechanism
Mean cosine of random pairs~0.6–0.8 — a tight cone~0.25–0.40Contradiction pairs are pushed apart, which stretches the whole space
Cosine of a true paraphrase pair~0.85 — barely above the floor~0.85, but the floor is now far belowWhat changed is the contrast, not the absolute value
Sensitivity to sentence lengthHigh — length affects the shared componentLowerNLI pairs vary in length, so length becomes uninformative about the label
Sensitivity to topicDominant — nearly all the varianceStill strong, but no longer sufficientContradictions share the topic and must be separated anyway
Sensitivity to negationVery lowSlightly better, still poorSome contradictions use negation, but "not" remains one token in a mean

Row two is the one that reframes everything. The fine-tune does not make paraphrases score dramatically higher — they were already near the top of a compressed range. It makes non-paraphrases score lower. Chapter 1's five-minute experiment measures exactly this, and it is why Spearman (a rank metric) captures the improvement while a naive look at "similar pairs score 0.85 either way" would miss it entirely.

The arithmetic of one epoch

Something worth checking, because it is easy to assume the fine-tune must be doing something enormous:

1,000,000 pairs / batch of 16 = 62,500 optimiser steps
each step: 2 encoder passes (siamese) × 16 sentences = 32 forward passes
total forward passes ≈ 2,000,000, at ~2,000 sentences/s ≈ 1,000 s of pure forward compute
with backward (roughly 2× forward) and overhead → < 20 minutes on a V100

Sixty-two thousand steps at learning rate 2e-5. For comparison, BERT's own pretraining was about a million steps at a much larger batch. This is a light touch — a nudge that reorganises the geometry without destroying the linguistic knowledge underneath. Push it harder (more epochs, larger learning rate) and you get catastrophic forgetting: STS scores keep improving on the NLI-flavoured evaluation while transfer to anything else collapses. The one-epoch choice is not laziness; it is regularisation by early stopping.

Worth pausing on the learning rate, because 2e-5 looks arbitrary and is not. Fine-tuning a pretrained encoder is a search for a nearby minimum, not a fresh optimisation. At 1e-3 — a normal rate for training from scratch — the first few hundred steps move the weights far enough that the pretrained features are destroyed, and you spend the rest of the run relearning English from a million sentence pairs, which is not enough data to do it. At 2e-5 the parameters travel a short distance and the linguistic knowledge survives. The warm-up is the same argument applied to the first few steps specifically: Adam's variance estimate is built from almost no samples at t = 1, so its step size is unreliable exactly when the model is most fragile.

The two-stage recipe, and why the order matters

Chapter 7's best SBERT numbers come from a two-stage sequence: fine-tune on NLI, then fine-tune on the STS benchmark's graded pairs with the regression objective. Both stages are cheap; the order is not arbitrary.

Stage 1 — NLI, classification objective, 1M pairs
Builds the coarse geometry: what "related" and "unrelated" mean at all. Lots of data, weak per-example signal, hard negatives supplied by the annotation protocol
↓ the space now has structure; refine it
Stage 2 — STS-B, regression objective, ~5.7k pairs
Calibrates the fine structure: graded human similarity, optimised on cosine directly, with no train-test gap. Little data, strong per-example signal

Reverse the order and stage 1's million coarse examples would overwrite stage 2's few thousand fine ones. This general shape — lots of weak pairs first, few strong pairs last — became the standard multi-stage recipe for every modern embedding model, where stage 1 is now hundreds of millions of mined web pairs and stage 2 is a curated supervised mixture. The pattern in this paper is the two-stage version of a pipeline that now has four.

ModelSTS-B test SpearmanWhat it shows
SBERT-NLI-base (no STS training at all)77.03Zero-shot on the target task
SBERT-STSb-base (STS only)84.67In-task supervision alone is worth +7.6
SBERT-NLI-STSb-base (both, in order)85.35NLI pretraining adds a further +0.68 on top

Note how small that last gain is on this benchmark — +0.68 — and do not conclude that NLI was unnecessary. STS-B's test set resembles its training set; the NLI stage buys generality, which this table cannot see. It shows up in the 77.03 row (usable with no in-domain data at all) and in every downstream task the model was never tuned for. Benchmarks that share a distribution with their training data systematically undervalue pretraining, which is worth remembering whenever an ablation says a stage "does not help".

Realisation note — the recipe generalises, and this is how you use it. The pattern is: take the best pretrained encoder you can afford, put it in a siamese structure, and fine-tune briefly on pairs whose negatives are hard. The hardness of the negatives is the single highest-leverage variable. If you are building a domain embedder and have no NLI-style data, spend your effort mining hard negatives — retrieve the top-20 candidates for each positive with a weak model and use those as negatives — rather than gathering more positives. Every serious embedding model since 2021 (SimCSE, GTR, E5, BGE) is a variation on that sentence.

If you have no NLI data

Which is the normal case: a specialist domain, a language other than English, or a company corpus. The substitution to make is not "find NLI data" but "find the two properties NLI supplied" — non-trivial positives and same-topic negatives. Both can usually be manufactured from structure you already have.

Structure you already havePositive pairWhere the hard negatives come from
A ticket system with "duplicate of" linksThe two linked ticketsOther tickets in the same product area — retrieve top-20 with a weak model, drop the true duplicates
Documentation with titles and bodies(title, body-chunk)Chunks from sibling pages under the same parent — same topic, wrong page
A search log with clicks(query, clicked result)The results shown and not clicked. Free, plentiful, and exactly the confusions your users face
A code repository(docstring, function body)Other functions in the same file or module
Products with variants(product title, its description)Other variants of the same product — nearly identical text, different SKU: the hardest negatives you will ever get
Nothing at allThe same sentence twice, with dropout (SimCSE)The rest of the batch

Row three is the richest source most companies already own and almost none use. A search log's non-clicked impressions are same-query, same-topic, human-judged-irrelevant — the definition of a hard negative, generated for free by traffic. The caveats are real (position bias, clicks are noisy relevance) and manageable with standard debiasing, and it is still far better data than anything you can buy.

The rule to carry away from this chapter. When designing training data for an embedder, spend your effort on the negatives. Positives are usually obtainable from structure — things that are linked, nested, or co-occurring — and are usually easy. Negatives determine what the model must learn to distinguish, and distinguishing is the entire job. NLI's contribution to this paper was not a million entailments; it was a third of a million same-topic contradictions written by humans trying to be tricky.
Why is natural-language inference data unusually good supervision for a symmetric similarity model, despite entailment being an asymmetric relation?

Chapter 6: The Whole Pipeline, By Hand

Everything so far has been described. Now we compute it. Three toy sentences, four tokens each, four dimensions instead of 768, and every single number visible. If you can follow this chapter with a pen, you understand Sentence-BERT completely — the real model differs only in that 4 becomes 768 and the token vectors come from twelve transformer layers instead of from this page.

How to read this chapter. Do the arithmetic yourself. Every division is by a number you can do in your head or with two digits of scratch work, and the deliberately small dimension means nothing is hidden. The three sentences are chosen so that A and B are near-paraphrases and C is unrelated — the geometry should end up reflecting that, and we will check whether it does at every stage.

Step 1 — the token vectors coming out of BERT

Pretend BERT has emitted these. Each sentence is four tokens: [CLS], two content words, [SEP]. Each token vector is 4-dimensional.

Sentence A — "a dog barks":

Tokendim 0dim 1dim 2dim 3
[CLS]0.6−0.20.40.2
dog1.20.4−0.60.8
barks0.81.00.2−0.4
[SEP]0.20.40.00.2
column sum2.81.60.00.8

Sentence B — "a puppy is barking":

Tokendim 0dim 1dim 2dim 3
[CLS]0.40.00.60.2
puppy1.00.8−0.40.6
barking0.60.80.0−0.2
[SEP]0.40.40.20.2
column sum2.42.00.40.8

Sentence C — "the server crashed":

Tokendim 0dim 1dim 2dim 3
[CLS]0.4−0.20.60.4
server−0.40.61.2−0.2
crashed0.2−0.80.61.0
[SEP]0.20.40.40.4
column sum0.40.02.81.6

Step 2 — MEAN pooling

All four positions are real tokens, so the mask is all ones and the denominator is 4. Divide each column sum by 4:

u = (2.8, 1.6, 0.0, 0.8)/4 = (0.70, 0.40, 0.00, 0.20)
v = (2.4, 2.0, 0.4, 0.8)/4 = (0.60, 0.50, 0.10, 0.20)
w = (0.4, 0.0, 2.8, 1.6)/4 = (0.10, 0.00, 0.70, 0.40)

Already you can see the structure in the numbers: A and B both put their mass in dimensions 0 and 1; C puts its mass in dimensions 2 and 3. If dimensions 0–1 encoded "animal / sound" and 2–3 encoded "software / failure", this is exactly what a working sentence encoder would produce.

Step 3 — cosine similarity, computed twice

The paraphrase pair first. Dot product:

u · v = (0.70)(0.60) + (0.40)(0.50) + (0.00)(0.10) + (0.20)(0.20)
      = 0.42 + 0.20 + 0.00 + 0.04 = 0.66

Norms:

‖u‖ = √(0.49 + 0.16 + 0.00 + 0.04) = √0.69 = 0.8307
‖v‖ = √(0.36 + 0.25 + 0.01 + 0.04) = √0.66 = 0.8124
cos(u, v) = 0.66 / (0.8307 × 0.8124) = 0.66 / 0.6748 = 0.9780

Now the unrelated pair:

u · w = (0.70)(0.10) + (0.40)(0.00) + (0.00)(0.70) + (0.20)(0.40) = 0.07 + 0 + 0 + 0.08 = 0.15
‖w‖ = √(0.01 + 0.00 + 0.49 + 0.16) = √0.66 = 0.8124
cos(u, w) = 0.15 / (0.8307 × 0.8124) = 0.15 / 0.6748 = 0.2223

0.9780 against 0.2223. The pooled vectors do the job: the paraphrase scores four times higher than the unrelated pair. This is what "the embeddings work" means, made of nothing but sums and square roots.

Notice the anisotropy, live in your own numbers. The unrelated pair scored 0.2223, not 0. There is no semantic reason for that — the vectors were designed to be about different things. It happens because every mean-pooled vector here has all-positive-ish coordinates, so every pair of them has a positive dot product. That is a four-dimensional version of Chapter 1's cone. Fix it by subtracting the corpus mean m = (u+v+w)/3 = (0.4667, 0.3000, 0.2667, 0.2667) and re-measuring:
u' = (0.2333, 0.1000, −0.2667, −0.0667) ,  v' = (0.1333, 0.2000, −0.1667, −0.0667) ,  w' = (−0.3667, −0.3000, 0.4333, 0.1333)
cos(u', v') = 0.1000 / (0.3742 × 0.3000) = 0.8909    cos(u', w') = −0.2400 / (0.3742 × 0.6557) = −0.9782
The gap widens from 0.756 to 1.869. Centring costs one subtraction per vector and, on real corpora, is worth several STS points for free.

Step 3b — the same sentences under CLS and MAX pooling

Chapter 3 argued from statistics that MEAN should win. Here is the argument as arithmetic on these exact numbers.

CLS pooling takes row 0 of each table and nothing else:

uCLS = (0.6, −0.2, 0.4, 0.2) ,   vCLS = (0.4, 0.0, 0.6, 0.2) ,   wCLS = (0.4, −0.2, 0.6, 0.4)

u·v = 0.24 + 0 + 0.24 + 0.04 = 0.52 ,  ‖u‖ = √0.60 = 0.7746 ,  ‖v‖ = √0.56 = 0.7483
cos(uCLS, vCLS) = 0.52 / 0.5797 = 0.897

u·w = 0.24 + 0.04 + 0.24 + 0.08 = 0.60 ,  ‖w‖ = √0.72 = 0.8485
cos(uCLS, wCLS) = 0.60 / 0.6572 = 0.913

The unrelated pair scores higher than the paraphrase — 0.913 against 0.897. Ranking inverted. Nothing is wrong with the arithmetic; the [CLS] rows simply do not carry the content, because in an untuned model they were never asked to. This is Chapter 1's 29.19 reproduced in four dimensions.

MAX pooling takes the largest value in each column:

uMAX = (1.2, 1.0, 0.4, 0.8) ,   vMAX = (1.0, 0.8, 0.6, 0.6)
u·v = 1.20 + 0.80 + 0.24 + 0.48 = 2.72 ,  ‖u‖ = √3.24 = 1.800 ,  ‖v‖ = √2.36 = 1.536
cos = 2.72 / 2.765 = 0.984

The ranking is preserved here, but look at the norms: 1.800 and 1.536, against MEAN's 0.831 and 0.812. Max-pooled vectors are roughly twice as long, and — crucially — the ratio between the two sentences' norms has grown from 1.02 to 1.17 for no semantic reason. Add two more tokens to sentence A and the gap widens further, because the max can only ratchet up. Under a cosine objective that drift is uncorrectable, which is the 69.92 in Chapter 3's table.

Step 4 — the classification features

The training objective does not use cosine. It builds the concatenation [u; v; |u − v|]. First the difference:

u − v = (0.70−0.60, 0.40−0.50, 0.00−0.10, 0.20−0.20) = (0.10, −0.10, −0.10, 0.00)
|u − v| = (0.10, 0.10, 0.10, 0.00),   coordinate sum = 0.30

And for the unrelated pair, so we can compare:

u − w = (0.60, 0.40, −0.70, −0.20) ,   |u − w| = (0.60, 0.40, 0.70, 0.20),   coordinate sum = 1.90

0.30 versus 1.90 — a factor of 6.3. That is the signal the classifier gets to work with, and it is far starker than the cosine gap. This is the practical answer to "why does a difference feature help": the difference amplifies the distinction the raw vectors only imply.

The full 12-dimensional feature vector for the paraphrase pair is:

x = [ 0.70, 0.40, 0.00, 0.20 | 0.60, 0.50, 0.10, 0.20 | 0.10, 0.10, 0.10, 0.00 ]
←  u  →      ←  v  →     ← |u − v| →

In the real model this is 2304-dimensional (768 × 3). Here it is 12, and Wt is 3×12 instead of 3×2304.

Step 5 — the classifier and its logits

Take a toy Wt that has already learned something sensible, written as three blocks of four plus a bias. (The paper's equation omits a bias; every real implementation uses nn.Linear, which carries one, and we need it here to keep the numbers readable.)

RowWu blockWv blockWd block (on |u−v|)bias
entailment(0.1, 0, 0, 0)(0.1, 0, 0, 0)(−2.0, −2.0, −2.0, −2.0)+1.2
neutral(0, 0, 0, 0)(0, 0, 0, 0)(−0.5, −0.5, −0.5, −0.5)+0.6
contradiction(0, 0.1, 0, 0)(0, 0.1, 0, 0)(+1.5, +1.5, +1.5, +1.5)−0.4

Read the Wd column as a sentence: "the bigger the difference, the less entailment and the more contradiction." That is the geometry the classifier is enforcing on the encoder. Now compute the three logits for the paraphrase pair, where the difference coordinates sum to 0.30:

zent = 0.1(0.70) + 0.1(0.60) − 2.0(0.30) + 1.2 = 0.07 + 0.06 − 0.60 + 1.2 = 0.73
zneu = 0 + 0 − 0.5(0.30) + 0.6 = −0.15 + 0.6 = 0.45
zcon = 0.1(0.40) + 0.1(0.50) + 1.5(0.30) − 0.4 = 0.04 + 0.05 + 0.45 − 0.4 = 0.14

Step 6 — softmax and cross-entropy

e0.73 = 2.0751 ,  e0.45 = 1.5683 ,  e0.14 = 1.1503 ,  sum = 4.7937
p = (2.0751, 1.5683, 1.1503) / 4.7937 = (0.4329, 0.3272, 0.2400)

Gold label is entailment, so the loss is the negative log of the probability assigned to entailment:

L = −ln(0.4329) = 0.8373

For calibration, a model guessing uniformly over three classes would score −ln(1/3) = 1.0986. Our toy classifier is doing better than chance but is far from confident — 43% on the right answer. Good: that means there is a gradient to follow, which is what we want for the next step.

Run the same machinery on the unrelated pair (u, w), gold label contradiction, with difference sum 1.90:

zent = 0.07 + 0.01 − 2.0(1.90) + 1.2 = −2.52
zneu = −0.5(1.90) + 0.6 = −0.35
zcon = 0.04 + 0.00 + 1.5(1.90) − 0.4 = 2.49

e−2.52 = 0.0805 ,  e−0.35 = 0.7047 ,  e2.49 = 12.0613 ,  sum = 12.8465
p = (0.0063, 0.0549, 0.9389) ,   L = −ln(0.9389) = 0.0631

0.0631 against 0.8373. The model is already confident and correct on the easy pair and unsure on the hard one, which is exactly how a healthy mid-training loss distribution looks. Almost all of the learning signal is coming from the pair that is nearly right.

Step 7 — one gradient step, all the way through

The error signal for softmax with cross-entropy is beautifully simple: δ = p − y. For the entailment pair with y = (1, 0, 0):

δ = (0.4329 − 1, 0.3272 − 0, 0.2400 − 0) = (−0.5671, 0.3272, 0.2400)

Now push it back into u using the gradient we derived in Chapter 4:

∂L/∂u = WuTδ + sign(u − v) ⊙ (WdTδ)

The first term, coordinate by coordinate. Only the entailment row has a nonzero Wu entry in dimension 0 (0.1), and only the contradiction row in dimension 1 (0.1):

WuTδ = ( 0.1(−0.5671), 0.1(0.2400), 0, 0 ) = (−0.0567, 0.0240, 0, 0)

The second term. Every column of Wd is identical — (−2.0, −0.5, +1.5) down the three rows — so WdTδ is the same scalar in all four coordinates:

(−2.0)(−0.5671) + (−0.5)(0.3272) + (1.5)(0.2400) = 1.1342 − 0.1636 + 0.3600 = 1.3306

And the sign vector: u − v = (0.10, −0.10, −0.10, 0.00), so sign(u − v) = (+1, −1, −1, 0). Multiply coordinate-wise and add:

∂L/∂u = (−0.0567, 0.0240, 0, 0) + (+1.3306, −1.3306, −1.3306, 0)
        = (1.2739, −1.3066, −1.3306, 0.0000)

Read that gradient against u − v = (0.10, −0.10, −0.10, 0). In every coordinate, the gradient points in the same direction as the difference — and gradient descent moves against the gradient, so u will move to reduce each coordinate's gap with v. The difference feature is doing precisely what Chapter 4 promised.

By symmetry (swap the roles, the sign vector flips):

∂L/∂v = (−0.0567, 0.0240, 0, 0) + (−1.3306, +1.3306, +1.3306, 0) = (−1.3873, 1.3546, 1.3306, 0.0000)

Take a step with learning rate η = 0.05 — u ← u − η(∂L/∂u):

u' = (0.70 − 0.0637, 0.40 + 0.0653, 0.00 + 0.0665, 0.20) = (0.6363, 0.4653, 0.0665, 0.2000)
v' = (0.60 + 0.0694, 0.50 − 0.0677, 0.10 − 0.0665, 0.20) = (0.6694, 0.4323, 0.0335, 0.2000)

(In the real model this step lands on θ and propagates back through twelve transformer layers to every weight; here we take the shortcut of updating the pooled vectors directly, which is what the encoder would be nudged to produce.)

Step 8 — did the geometry improve?

The objective never mentioned cosine. Let us check what it did to cosine anyway.

u' · v' = (0.6363)(0.6694) + (0.4653)(0.4323) + (0.0665)(0.0335) + (0.2)(0.2)
        = 0.4259 + 0.2011 + 0.0022 + 0.0400 = 0.6693
‖u'‖ = √0.6658 = 0.8160 ,   ‖v'‖ = √0.6761 = 0.8223
cos(u', v') = 0.6693 / (0.8160 × 0.8223) = 0.6693 / 0.6709 = 0.9976
0.9780 → 0.9976 in one step of a loss that never mentions cosine. This is the paper's central mechanism, verified with arithmetic you just did yourself. The classification objective shapes |u − v|; shaping |u − v| moves the vectors together; vectors that are together have high cosine. Train-time and test-time metrics differ, and the transfer still happens, because there is only one geometry and both metrics read it.

And the loss, recomputed on the updated vectors. New difference: |u' − v'| = (0.0331, 0.0330, 0.0330, 0), summing to 0.0991 instead of 0.30. The logits become 1.1324, 0.5505, −0.1616, the softmax gives pent = 0.5456, and:

L = −ln(0.5456) = 0.6060  (was 0.8373)

Step 9 — the other two objectives on the same numbers

Same three vectors, evaluated under the alternatives, so you can feel the difference between them concretely.

Cosine regression. With gold scores y = 1.0 for the paraphrase pair and y = 0.0 for the unrelated one:

LAB = (0.9780 − 1.0)2 = (−0.0220)2 = 0.000484
LAC = (0.2223 − 0.0)2 = 0.049417

Under this objective, almost all the gradient comes from the unrelated pair — 0.0494 against 0.0005, a hundredfold difference. Compare with the classification objective, where the paraphrase pair carried almost all the loss (0.8373 against 0.0631). Same vectors, same data, opposite allocation of learning effort. Which objective you pick decides which examples your model spends its capacity on, and that is a much more consequential choice than it appears.

Triplet. Anchor u, positive v, negative w, margin ε = 1, Euclidean:

‖u − v‖ = √(0.01 + 0.01 + 0.01 + 0.00) = √0.03 = 0.1732
‖u − w‖ = √(0.36 + 0.16 + 0.49 + 0.04) = √1.05 = 1.0247
L = max(0.1732 − 1.0247 + 1.0 , 0) = max(0.1485, 0) = 0.1485

The ordering is already correct by 0.85 — the positive is five times closer than the negative — and the loss is still positive, because ε = 1 demands a gap of a full unit. Drop ε to 0.8 and the loss becomes max(−0.0515, 0) = 0 and this triplet goes silent. Chapter 4's warning about margin scale, in two lines of arithmetic.

Step 9b — one step of the regression objective, for contrast

We took a gradient step under the classification objective and watched cosine rise as a side effect. Do the same under objective 2, where cosine is the target directly, and compare the character of the two updates.

Use the unrelated pair (u, w) with gold y = 0, since that is where this objective puts its gradient. Recall cos(u, w) = 0.2223, ‖u‖ = 0.8307, ‖w‖ = 0.8124. The chain rule gives:

∂L/∂u = 2(c − y) · (1/‖u‖) [ ŵ − c û ]
2(c − y) = 2(0.2223 − 0) = 0.4446

Unit vectors: û = (0.8427, 0.4815, 0.0000, 0.2408) and ŵ = (0.1231, 0.0000, 0.8617, 0.4924). So:

ŵ − c û = (0.1231 − 0.1873, 0.0000 − 0.1070, 0.8617 − 0.0000, 0.4924 − 0.0535)
           = (−0.0642, −0.1070, 0.8617, 0.4389)

∂L/∂u = 0.4446 × (1/0.8307) × (−0.0642, −0.1070, 0.8617, 0.4389)
         = 0.5352 × (−0.0642, −0.1070, 0.8617, 0.4389) = (−0.0344, −0.0573, 0.4612, 0.2349)

Sanity-check the orthogonality claim by dotting with û:

0.8427(−0.0344) + 0.4815(−0.0573) + 0.0000(0.4612) + 0.2408(0.2349)
= −0.0290 − 0.0276 + 0.0000 + 0.0566 = 0.0000 ✓

Zero to four decimal places. The update cannot change ‖u‖ at all; it only rotates u away from w. Step with η = 0.05:

u″ = (0.70, 0.40, 0.00, 0.20) − 0.05(−0.0344, −0.0573, 0.4612, 0.2349)
     = (0.7017, 0.4029, −0.0231, 0.1883)

u″ · w = 0.0702 + 0.0000 − 0.0162 + 0.0753 = 0.1293 ,  ‖u″‖ = 0.8322
cos(u″, w) = 0.1293 / (0.8322 × 0.8124) = 0.1912  (was 0.2223)

Down by 0.031, moving toward the gold value of 0. Note also ‖u″‖ = 0.8322 against the original 0.8307 — a 0.2% growth caused entirely by taking a finite step along a tangent, not by any radial component in the gradient. That is the norm-drift effect Chapter 4 mentioned, visible in the fourth decimal place.

The two objectives have visibly different personalities, in the same four dimensions. Classification produced a large update dominated by the sign(u − v) term, which pushed coordinates toward each other and happened to change the norms as well. Regression produced a smaller, purely rotational update on the pair that was furthest from its target. The first sculpts by coordinate; the second sculpts by angle. Both build a metric; only the second is building the exact metric you will query with.

Step 10 — check the normalised-distance identity

Chapter 4 claimed that on unit vectors, ‖a − b‖2 = 2(1 − cos). Verify it here rather than trusting it. Normalise u and v by their norms 0.8307 and 0.8124:

û = (0.70, 0.40, 0.00, 0.20)/0.8307 = (0.8427, 0.4815, 0.0000, 0.2408)
v̂ = (0.60, 0.50, 0.10, 0.20)/0.8124 = (0.7386, 0.6155, 0.1231, 0.2462)

Difference: (0.1041, −0.1340, −0.1231, −0.0054). Squared length:

0.01084 + 0.01796 + 0.01515 + 0.00003 = 0.04398
and  2(1 − cos) = 2(1 − 0.9780) = 2(0.0220) = 0.04400  ✓

They agree to four decimal places, the residual being our rounding. So on the unit sphere the two metrics are the same metric wearing different clothes, and a k-means that minimises squared distance is maximising cosine, exactly as Chapter 8 will assume.

Confirm the converse too — that the identity fails without normalisation. Unnormalised, ‖u − v‖2 = 0.03 while 2(1 − cos) = 0.0440. Different numbers, and for pairs with very different norms they can rank differently. That is the whole content of "always normalise before you compare," made checkable.

The whole pipeline on one line

#OperationToy shapeReal shapeValue here
1BERT token vectors(4, 4)(64, 768)the three tables above
2Masked mean pool(4, 4) → (4,)(64, 768) → (768,)u = (0.70, 0.40, 0.00, 0.20)
3Difference feature(4,)(768,)|u−v| = (0.1, 0.1, 0.1, 0)
4Concatenate(12,)(2304,)x above
5Linear + softmax(3, 12) → (3,)(3, 2304) → (3,)p = (0.433, 0.327, 0.240)
6Cross-entropyscalarscalar0.8373
7Backward & step110M paramscos: 0.9780 → 0.9976
8At inferencesteps 1–2 onlysteps 1–2 onlycos(u, v), classifier discarded

Row 8 is the one to memorise. Everything from step 3 onward exists only during training. At inference SBERT is a BERT forward pass and a mean.

Step 9c — and one triplet step

For completeness, the third objective's update on the same vectors: anchor u, positive v, negative w, ε = 1. The loss was 0.1485, so it is active and there is a gradient.

Differentiate ‖u − v‖ with respect to v. Writing d = u − v:

∂‖d‖/∂v = −d / ‖d‖  →   ∂L/∂v = −(u − v)/‖u − v‖

u − v = (0.10, −0.10, −0.10, 0.00) ,  ‖u − v‖ = 0.1732
∂L/∂v = −(0.5774, −0.5774, −0.5774, 0.0000)

Descent moves v by −η∂L/∂v, i.e. along +(0.5774, −0.5774, −0.5774, 0) — straight toward u along the connecting line. With η = 0.05:

v‴ = (0.60 + 0.0289, 0.50 − 0.0289, 0.10 − 0.0289, 0.20) = (0.6289, 0.4711, 0.0711, 0.2000)
new ‖u − v‴‖ = ‖(0.0711, −0.0711, −0.0711, 0)‖ = 0.1232  (was 0.1732)

Distance fell by exactly η = 0.05, which is not a coincidence: the gradient of a Euclidean norm is a unit vector, so a step of size η moves the distance by exactly η regardless of how far apart the points are. Contrast that with the cosine gradient, whose magnitude scaled as 2(c − y)/‖u‖ and therefore shrank as the model got closer to correct.

Three objectives, three step characters, on identical inputs. Classification took a step proportional to how wrong the classifier was. Regression took a step proportional to the remaining error, vanishing smoothly as it approached the target. Triplet took a step of constant size until the hinge closed and then stopped dead. Constant-then-zero is the worst of the three shapes for optimisation, and it is why margin losses need careful learning-rate schedules while softmax-based ones largely do not.

Check yourself on a second pair

Do this one with a pen before reading the answers. Sentence D — "the dog is silent" — pools to:

x = (0.65, −0.10, 0.05, 0.15)

Using u = (0.70, 0.40, 0.00, 0.20) from sentence A ("a dog barks"), compute: (1) cos(u, x); (2) |u − x| and its coordinate sum; (3) the three logits under the toy Wt; (4) the softmax and the loss if the gold label is contradiction.

Answers. (1) u · x = 0.455 − 0.040 + 0.000 + 0.030 = 0.445; ‖x‖ = √(0.4225 + 0.01 + 0.0025 + 0.0225) = √0.4575 = 0.6764; cos = 0.445 / (0.8307 × 0.6764) = 0.445 / 0.5619 = 0.792.

(2) u − x = (0.05, 0.50, −0.05, 0.05), so |u − x| = (0.05, 0.50, 0.05, 0.05), sum = 0.65.

(3) zent = 0.1(0.70) + 0.1(0.65) − 2.0(0.65) + 1.2 = 0.07 + 0.065 − 1.30 + 1.2 = 0.035; zneu = −0.5(0.65) + 0.6 = 0.275; zcon = 0.1(0.40) + 0.1(−0.10) + 1.5(0.65) − 0.4 = 0.04 − 0.01 + 0.975 − 0.4 = 0.605.

(4) e0.035 = 1.0356, e0.275 = 1.3166, e0.605 = 1.8313; sum = 4.1835. p = (0.2476, 0.3147, 0.4377). Loss = −ln(0.4377) = 0.8262.

Now interpret it, which is the actual exercise. Cosine says 0.792 — high, because "dog" is shared and the vector still lives in the animal region. The difference sum says 0.65, more than double the paraphrase pair's 0.30, so the classifier leans contradiction at 43.8%. The two views disagree in exactly the way you would hope: raw proximity is fooled by the shared subject, and the per-coordinate difference is not, because dimension 1 — where "barks" put its mass — differs by 0.50 all by itself. That single coordinate carries most of the signal, and it is precisely what a coordinate-wise feature can see and a single cosine cannot.

The same computation at full scale

Everything above used d = 4 so the numbers would fit on a page. Nothing changes at d = 768 except the size of the loops — which is worth stating precisely, because it is where the intuition "the real model must be doing something more complicated" gets corrected.

OperationToy (d = 4, L = 4)Real (d = 768, L = 64, batch 16)What grew
Mean pooling16 additions16 × 64 × 768 ≈ 786k additionsOnly the loop bounds
Difference feature4 subtractions + 4 absolute values768 of each, per pairLoop bounds
Classifier3 × 12 matrix3 × 2304 matrix = 6,912 paramsLoop bounds
Softmax + cross-entropy3 exponentials3 exponentialsNothing — identical
δ = p − yA 3-vectorA 3-vectorNothing — identical
Backward through the encoderWe shortcut it12 transformer layers, ~110M parameters, ~3× the forward costThis is the only genuinely new part

Only the last row is qualitatively different, and it is the part every autograd framework writes for you. Everything you computed by hand — the pooling, the difference, the logits, the softmax, the error signal, and its route back into u and v — is exactly what runs in production, at a different loop bound.

Worth noting what the toy scale hides, though, in fairness. At d = 4 there is no room for anisotropy to be a subtle effect — we saw it as an obvious 0.2223 floor. At d = 768 the cone is a statistical property of thousands of near-orthogonal directions, and your intuition from four dimensions will mislead you about how much room there is up there. The rule 1/√d from Chapter 1 is the bridge: in four dimensions random pairs scatter with sd 0.5, in 768 with sd 0.036. High dimensions are mostly empty, and everything is nearly orthogonal to everything, unless a training objective has arranged otherwise.

In the worked example, the classification loss on the paraphrase pair was 0.8373 while the cosine-regression loss on the same pair was 0.000484. What does this difference imply?

Chapter 7: The Numbers, Including the Bad Ones

A paper that only reports the results where it wins is an advertisement. This one reports a table in which the architecture it is arguing against beats it by three points, and that table is the most useful one in the paper. Let us go through the evaluation properly — what is being measured, why that measure, and what each row means for a decision you might make.

What Spearman correlation is, and why not Pearson

STS datasets give each sentence pair a human score, typically 0 to 5. A model gives each pair a cosine. You need a single number for "do these agree." Spearman rank correlation is Pearson correlation computed on the ranks rather than the raw values, which makes it sensitive only to ordering.

ρ = 1 − 6∑di2 / ( n(n2 − 1) )   where di is the rank difference for pair i (no ties)

Work it on five pairs. Human scores and model cosines:

PairHumanHuman rankCosineCosine rankdd2
14.810.91100
24.020.743−11
32.530.802+11
41.240.55400
50.350.41500
∑d2 = 2 ,   n = 5 ,   n(n2−1) = 5(25−1) = 120
ρ = 1 − (6 × 2)/120 = 1 − 0.10 = 0.90  →  reported as 90.00

One adjacent swap out of five pairs costs ten points. Now you can read the tables with a feel for the scale: SBERT's 74.89 versus mean-BERT's 54.81 is not a marginal improvement, it is a different quality of ordering.

Why the field uses Spearman and not Pearson here. Pearson would ask whether cosine is a linear function of the human score. There is no reason it should be — a cosine of 0.9 is not "twice as similar" as 0.45, and different models compress the range differently (Chapter 1's cone squashes everything into [0.85, 1.0]). Reimers et al. (2016) argued this explicitly, and the STS community standardised on Spearman as a result. It is also the reason a raw cosine threshold does not transfer between models even when their Spearman scores are identical: rank agreement says nothing about calibration.

Result 1 — unsupervised STS (no STS training at all)

This is Chapter 1's table, and it measures the thing that matters most in practice: how good are the embeddings if you just download them and use them?

ModelSTS-BAvg. over 7 STS setsReading
Avg. GloVe58.0261.32The 2014 baseline that raw BERT could not beat
Avg. BERT46.3554.81A better encoder, no pair supervision, worse result
BERT [CLS]16.5029.19Near noise
InferSent — GloVe68.0365.01BiLSTM + NLI: pair supervision on a weak encoder
Universal Sentence Encoder74.9271.22Transformer + multi-task, the strong 2019 default
SBERT-NLI-base77.0374.89BERT + NLI pair supervision: +20.08 over mean-BERT
SBERT-NLI-large79.2376.55Same recipe on BERT-large: +1.66
SRoBERTa-NLI-large79.1076.68RoBERTa instead of BERT: within noise of SBERT-large

Three separate lessons live in this table, and it is worth pulling them apart.

The supervision is worth about 20 points; the encoder upgrade is worth about 2. Mean-BERT to SBERT-base is +20.08. SBERT-base to SBERT-large — three times the parameters — is +1.66. If you have a fixed budget, the order of operations is unambiguous: fix the objective before you enlarge the model.

RoBERTa does not help. SRoBERTa-large (76.68) versus SBERT-large (76.55) is a 0.13-point difference on a benchmark whose run-to-run standard deviation is larger than that. The paper says as much. RoBERTa is a clearly better model than BERT on GLUE-style fine-tuning tasks, and none of that advantage survives into sentence-embedding quality — another sign that the bottleneck is the training signal, not the encoder.

Beating USE is the real result. USE was trained on far more and far more varied data. SBERT beats it by 3.67 average points with NLI alone and twenty minutes of fine-tuning, which is the clearest statement of the paper's thesis: the right small intervention on a strong pretrained encoder outperforms a large bespoke training programme.

Result 2 — supervised STS, where the cross-encoder wins

Now train on the STS benchmark itself and evaluate on its test set. Reported as Spearman × 100 with standard deviations over ten random seeds.

ModelTrained onSTS-B testArchitecture
BERT-STSb-baseSTS-B84.30 ± 0.76Cross-encoder
SBERT-STSb-baseSTS-B84.67 ± 0.19Bi-encoder
SRoBERTa-STSb-baseSTS-B84.92 ± 0.34Bi-encoder
BERT-NLI-STSb-baseNLI then STS-B88.33 ± 0.19Cross-encoder
SBERT-NLI-STSb-baseNLI then STS-B85.35 ± 0.17Bi-encoder
BERT-NLI-STSb-largeNLI then STS-B88.77 ± 0.46Cross-encoder
SBERT-NLI-STSb-largeNLI then STS-B86.15 ± 0.35Bi-encoder
Read row 4 against row 5. The cross-encoder wins by 2.98 points, and the paper prints it. This is not a footnote — it is the price of the architecture. Give both models the same data and the same encoder, and the one that can attend across the pair is meaningfully more accurate. SBERT buys a 46,700× speed-up and a storable index, and it pays about three Spearman points for them. Every retrieval system you build is a decision about whether those three points are worth more than the four orders of magnitude, and for a 1M-document corpus the question does not arise, because the cross-encoder simply cannot run. Note also that the gap is small when both train only on STS-B (84.30 vs 84.67 — SBERT even edges ahead, with a much smaller variance); it opens up only when NLI pretraining is added, which the cross-encoder exploits better because it can use the pair-level signal at full resolution.

The other detail worth extracting: look at the standard deviations. SBERT's are 0.17–0.35; the cross-encoder's run as high as 0.81. Bi-encoders trained this way are noticeably more stable across seeds, which is a real operational virtue when you are going to re-train quarterly and need results you can compare.

Result 3 — SentEval, and why the paper is sceptical of it

SentEval evaluates sentence embeddings by freezing them and training a logistic regression classifier on top for seven transfer tasks — sentiment (MR, CR, SST), subjectivity (SUBJ), opinion polarity (MPQA), question type (TREC), and paraphrase detection (MRPC). Accuracy, averaged:

ModelMRCRSUBJMPQASSTTRECMRPCAvg.
Avg. GloVe77.2578.3091.1787.8580.1883.072.8781.52
Avg. BERT embeddings78.6686.2594.3788.6684.4092.869.4584.94
BERT [CLS] vector78.6884.8594.2188.2384.1391.471.1384.66
InferSent — GloVe81.5786.5492.5090.3884.1888.275.7785.59
Universal Sentence Encoder80.0985.1993.9886.7086.3893.270.1485.10
SBERT-NLI-base83.6489.4394.3989.8688.9689.676.0087.41
SBERT-NLI-large84.8890.0794.5290.3390.6687.475.9487.69

SBERT wins here too, but the paper immediately qualifies the result, and the qualification is the interesting part. SentEval trains a classifier on top of the frozen embedding. That means it measures how much information is linearly extractable from the vector — not whether cosine distances in that space mean anything.

The two questions are genuinely different, and this table proves it in one row. Look at Avg. BERT embeddings: 84.94 on SentEval — better than InferSent's 85.59 is close, and comfortably above GloVe's 81.52 — and 54.81 on STS, below GloVe. The same vectors, judged excellent by one protocol and poor by the other. "Does this vector contain the information?" is answered by a trained probe, and mean-pooled BERT contains plenty. "Is the information arranged so that distance means similarity?" is answered by STS, and it is not arranged at all. A model can win on either axis while losing the other, so pick the protocol that matches your deployment: probe if you will train a classifier on the embeddings, STS if you will compare them with cosine. The paper says outright that SentEval is not the right instrument for its purpose, and reports it anyway for completeness.

Where the STS datasets came from

Seven datasets get averaged into one number, and knowing what they are stops you over-reading it.

DatasetYearSentence sourcesCharacter
STS122012News paraphrases, machine-translation output, WordNet glossesThe most heterogeneous. Hardest for everyone
STS132013Headlines, glosses, FrameNet definitionsShort and clean; the easiest column in the table
STS142014Headlines, image descriptions, forum posts, tweetsMixed register including informal text
STS15/162015/16Answers from forums and student assessments, headlines, plagiarism dataLonger, more argumentative
STS-B2017A curated selection from the above, with an official train/dev/test splitThe standard single-number benchmark; the only one with training data
SICK-R2014Image and video captions, systematically transformedTests syntax and negation at fixed vocabulary

Two implications. First, "average STS" is an average over genres, so a model that is superb on headlines and poor on forum posts can post the same number as one that is even. Second, none of the seven contains a query, a document longer than a sentence, or an asymmetric pair — which is precisely the gap BEIR was built to fill, and precisely why an excellent STS score does not guarantee a usable retriever.

What a three-point Spearman gap is actually worth

Correlation numbers are hard to feel. Convert one. Suppose you use the model for top-1 retrieval over 1,000 candidates. A Spearman of 0.85 versus 0.88 does not translate to accuracy by any exact formula — the relationship depends on the score distribution — but the direction is clear from the rank mechanics we derived: a lower ρ means more rank inversions, and inversions near the top of the list are the ones that change your answer.

The practical consequence is that you should measure what you will ship. Spearman weights every pair equally, including the ones both models get obviously right at the bottom of the ranking. Retrieval cares only about the top few. A model that is 3 points worse on Spearman can be 10 points worse on precision@1 if its errors are concentrated at the top, or indistinguishable if they are not.

If you will…MeasureWhy not the other one
Rank all pairs by similaritySpearman on STS
Retrieve top-k from a corpusrecall@k, NDCG@10, MRRSpearman ignores where the errors sit in the ranking
Threshold for duplicate detectionPrecision / recall at your chosen thresholdSpearman is threshold-free and therefore silent about calibration
ClusterAdjusted Rand index, V-measure on labelled dataNeither pairwise metric predicts cluster structure reliably
Train a classifier on the vectorsProbe accuracy (SentEval-style)STS measures geometry you are not going to use

Result 4 — the Wikipedia triplet evaluation

The paper's third evaluation exercises the triplet objective directly, on a dataset from Dor et al. (2018): triples of sentences where the anchor and positive come from the same Wikipedia section and the negative comes from a different section of the same article. The task is to say which is which by distance alone.

ModelAccuracyArchitecture
BERT-WikiSec~76.5%Cross-encoder
SBERT-WikiSec (triplet objective)~72.6%Bi-encoder

Same story as STS-B: the cross-encoder is ahead by a few points, and the bi-encoder is the only one of the two that produces vectors you can put in an index. The value of the experiment is that it validates the third objective — triplet training on structural supervision, with no labels and no graded scores, produces a usable space. That is the recipe you reach for when all you have is "these things go together."

Result 5 — argument similarity, and the domain-shift warning

The paper also evaluates on the Argument Facet Similarity corpus: pairs of arguments about gun control, gay marriage, and the death penalty, scored 0–5 for whether they make the same point. Two evaluation splits, and the difference between them is the point:

SplitWhat it testsCross-encoder BERTSBERT
10-fold cross-validationTrain and test on the same three topics~77~77 — essentially tied
Cross-topicTrain on two topics, test on the third, unseen~58~51 — a gap of roughly 7 points

Why does the gap appear only when the topic is unseen? Because of Chapter 2's structural argument. The cross-encoder gets to look at both arguments together and can reason about their relationship even for a topic it has never encountered — it does not need a good global map, only a good local comparison. SBERT must place an unseen-topic argument somewhere in a fixed space that was organised around other topics, with no chance to consult the other argument while doing so.

The operational lesson. Bi-encoders degrade under domain shift more than cross-encoders do, and the degradation is invisible on in-domain evaluations. If you validate your embedding model only on data resembling its training set, you will systematically overestimate its production quality. Build a held-out domain, not just a held-out split. Chapter 9 returns to this as SBERT's most consequential limitation.

Reproduce Table 1 yourself

Every number in the unsupervised table is reproducible in about twenty lines and a few minutes, and doing it once converts the whole chapter from claims into measurements.

python — reproduce the paper's headline comparisonfrom sentence_transformers import SentenceTransformer, util
from scipy.stats import spearmanr
from datasets import load_dataset

sts = load_dataset('mteb/stsbenchmark-sts', split='test')
s1, s2 = sts['sentence1'], sts['sentence2']
gold   = sts['score']                       # human ratings, 0-5

def evaluate(model):
    a = model.encode(s1, normalize_embeddings=True)
    b = model.encode(s2, normalize_embeddings=True)
    pred = (a * b).sum(axis=1)                 # row-wise cosine, since rows are unit
    return spearmanr(pred, gold).correlation * 100

# The comparison that IS the paper:
print('mean-pooled BERT :', evaluate(SentenceTransformer('bert-base-uncased')))   # ~46
print('SBERT            :', evaluate(SentenceTransformer('all-MiniLM-L6-v2')))   # ~82

Note the first line's subtlety: passing a raw bert-base-uncased to SentenceTransformer constructs a Transformer plus a default MEAN Pooling module — which is exactly the "Avg. BERT embeddings" row, 46.35 on STS-B. Two model names, one function, and the paper's central claim on your own screen.

Two things to try immediately afterwards. Swap normalize_embeddings=True for False and use a raw dot product — Spearman falls, because you have reintroduced the norm confound. And centre the embeddings (subtract the column means over the test set) before scoring the mean-pooled BERT model — it should gain several points for free, which is Chapter 1's anisotropy fix, measured.

The ten-seed discipline

Every supervised number in this paper is reported as a mean over ten random seeds, with a standard deviation. That is not a formality, and the values themselves make the argument: BERT-STSb-base is 84.30 ± 0.76. Two standard deviations is 1.5 points, so a single run of that configuration can plausibly land anywhere from 82.8 to 85.8.

Now consider a hypothetical paper reporting a new method at 85.2, single run, "beating BERT's 84.30." The claim is inside the noise of the baseline. It would be indistinguishable from having run the baseline twice and reported the better number — which, done unintentionally, is how a great many small improvements enter the literature.

ConfigurationMean ± sdWhat a single run could report
BERT-STSb-base (cross-encoder)84.30 ± 0.7682.8 – 85.8
SBERT-STSb-base84.67 ± 0.1984.3 – 85.1
BERT-NLI-STSb-large88.77 ± 0.4687.9 – 89.7
SBERT-NLI-STSb-base85.35 ± 0.1785.0 – 85.7

Read the second column vertically. Cross-encoder variances are two to four times the bi-encoder's. There is a plausible mechanism: a cross-encoder's score depends on a specific, delicate attention pattern learned over the pair, and which pattern the optimiser finds is seed-dependent; a bi-encoder's job is to arrange a global space, which is a more averaged, more constrained objective and therefore a more reproducible one.

Realisation note. When you compare two embedding configurations, run each at least three times and report the spread. If the gap between your candidates is smaller than the within-configuration spread, you have not measured a difference — and the cost of believing you have is a permanent complication in your pipeline that buys nothing. This paper's 2.98-point cross-encoder advantage is credible precisely because it is four standard deviations wide.

Results in this area you should distrust

Having read a careful results section, it is worth calibrating on the careless ones you will meet elsewhere. Four patterns, each of which produces a real-looking improvement out of nothing.

PatternWhy it inflatesHow to check
A single seed, no variance reportedThe ten-seed table above: a 1.5-point range is normalAsk for the spread, or rerun
Evaluated on a dataset in the training mixtureModern embedders train on hundreds of public datasets. Overlap with a benchmark is easy and often accidentalCheck the training data card against the benchmark list
Tuned on the test set by iterationFifty experiments choosing the best test number is selection on noise, even with no per-run leakageAsk what the dev-set protocol was, and how many configurations were tried
Compared against an undertrained baselineBaselines get one run; the new method gets a month of tuningCompare against the baseline's published best, not the author's reimplementation

Notice this paper's defences against all four: ten seeds with standard deviations, evaluation on STS sets that are explicitly excluded from training, a fixed protocol taken from prior work, and baselines quoted from their own papers. That is why its 2.98-point self-criticism is believable in a way most three-point claims are not — the same rigour that makes the wins credible is what makes the loss reportable.

How evaluation changed because of this paper

STS is the right benchmark for the claim the paper makes, and it is the wrong benchmark for what people went on to use SBERT for. That mismatch produced two successor benchmarks worth knowing, because if you evaluate an embedding model today you will be reading their leaderboards.

BenchmarkYearWhat it measuresWhy it exists
STS12–16, STS-B, SICK-R2012–2017Rank correlation with human similarity ratings on sentence pairsPredates embeddings-as-infrastructure. Measures the metric, on short, clean, symmetric pairs
SentEval2018Probe accuracy on 7 transfer classification tasksMeasures information content, not geometry — Chapter 7's Avg-BERT row
BEIR2021Zero-shot retrieval across 18 datasets and many domainsBecause everyone was doing retrieval, which is asymmetric, long-document, and out-of-domain — three things STS does not test
MTEB202258 datasets, 8 task types: retrieval, clustering, reranking, classification, STS, pair classification, summarisation, bitext miningBecause no single task predicts the others, and models were being chosen on the wrong one

BEIR's founding result is the one to internalise: models that topped STS did not top zero-shot retrieval, and in several domains BM25 beat every dense model tested. That is Chapter 7's cross-topic result generalised to a whole benchmark — bi-encoders degrade under domain shift, and a benchmark whose test data resembles its training data cannot see it.

The benchmark trap, stated once. A benchmark is a proxy for a use case. When a community optimises against a proxy for long enough, the proxy stops correlating with the use case — models acquire the benchmark's incidental properties, and the leaderboard becomes a ranking of how well each model fits the benchmark's quirks. The defence is neither cynicism nor a better benchmark; it is to build a small evaluation set from your own data and treat public leaderboards as a way to shortlist candidates rather than pick a winner. Two hundred labelled pairs from your corpus will tell you more about your deployment than 58 public datasets.

An evaluation checklist for your own model

CheckWhyFailure it catches
Evaluate on a held-out domainChapter 7's cross-topic gap is invisible in-domainA model that memorised your training genre
Report the metric your product usesSpearman weights all pairs; retrieval only cares about the topShipping the wrong winner
Include a lexical baseline (BM25)It is free and often surprisingly strongA neural model that is not actually beating grep
Measure the random-pair noise floorChapter 1's five-minute experimentThresholds that cannot work
Multiple seeds, report spreadThis sectionBelieving noise
Test batch-composition invarianceChapter 3's masking bugNon-deterministic embeddings
Compare against the previous index, not just the previous scoreA better model with a different scale breaks every calibrated threshold downstreamA "successful upgrade" that regresses the product

Putting all four results in one sentence each

EvaluationThe findingThe decision it should change
Unsupervised STSPair supervision is worth 20 points; a bigger encoder is worth 2Fix the objective before you enlarge the model
Supervised STS-BThe cross-encoder wins by 2.98 when both are trained on everythingAdd a reranker if your candidate set is small enough to afford it
SentEvalProbing and geometry measure different things — mean-BERT is 84.94 on one and 54.81 on the otherChoose the evaluation that matches how you will consume the vector
AFS cross-topicThe bi-encoder's advantage evaporates on unseen topics; the gap is ~7 pointsHold out a domain, not a split
Wikipedia tripletsStructural supervision with no labels produces a usable space (~72.6% vs the cross-encoder's ~76.5%)You do not need annotations to start — you need structure

Notice that three of the five rows are about the method of evaluation rather than about SBERT. That is characteristic of a paper making a structural argument: most of the work is establishing that the comparison is fair, and the number that follows is almost incidental.

Result 6 — the speed numbers that started it

Sentences encoded per second, from the paper's efficiency table:

ModelCPUGPU
Avg. GloVe embeddings~6,469— (no network to run)
InferSent1371,876
Universal Sentence Encoder671,318
SBERT-base441,378
SBERT-base + smart batching832,042

Smart batching is a simple and generalisable trick: sort the input by length before batching, so that each batch contains sentences of similar length and the padding overhead collapses. Nearly a 2× speed-up on CPU (44 → 83) and about 1.5× on GPU (1,378 → 2,042) for zero modelling change — because attention is quadratic in sequence length and you were computing it over [PAD] tokens.

Do the arithmetic once so the trick sticks. A batch of 16 with lengths {8, 9, 10, …, 120} pads everything to 120, so you compute 16 × 120 = 1,920 token-slots. Sorted into length-homogeneous batches, the same 16 sentences might need 16 × 12 = 192 slots in one batch and 16 × 120 in another — but averaged over the corpus the total slots computed drop toward the total real tokens. The saving is exactly the padding fraction, and on a corpus with a long tail of lengths that fraction is often 50–70%.

What the numbers do not tell you

Four things the results section cannot report, each of which turned out to matter more than a Spearman point.

Inference cost per unit of quality. The tables compare accuracy; they do not compare accuracy-per-millisecond. It emerged later that a 6-layer, 384-dimensional distilled SBERT retains nearly all of the quality at a fifth of the cost, which changed the deployment calculus far more than the base-to-large gain of +1.66 ever did.

How the model fails. Spearman is an average over pairs. It cannot tell you that your model's errors cluster on negation, or on numbers, or on named entities — and those are the errors users notice, because they are the ones a human would never make. Two models with identical Spearman can be very differently annoying.

Sensitivity to input formatting. Nothing in the evaluation says what happens if your text has markdown, HTML entities, inconsistent casing, or a 200-token boilerplate footer on every document. In production, that footer is a shared component added to every vector — Chapter 1's cone, self-inflicted. Strip boilerplate before embedding; it is one of the highest-return preprocessing steps there is.

Behaviour on inputs longer than the model. BERT truncates at 512 tokens, and SBERT's checkpoints often default to 128 or 256. Text beyond the limit is silently discarded — no warning, no error, just a vector that represents the first paragraph of a ten-page document. If your average document is longer than model.max_seq_length, your retrieval quality is being set by a configuration value most people never read.

Check this one right now, on whatever model you use. print(model.max_seq_length). If it says 128 and you are embedding paragraphs, you are indexing roughly the first 90 words of each and throwing away the rest. It is the most common quiet defect in deployed embedding pipelines, and it is one line to detect.
On STS-B with NLI pretraining, the BERT cross-encoder scores 88.33 and SBERT scores 85.35. Why does the paper publish a table where its own method loses?

Chapter 8: SBERT in Production

Sentence-BERT's real legacy is not its STS score — it is that a whole class of product features became buildable in an afternoon. Every one of them follows from the same fact: a sentence is now a point, and points can be indexed. This chapter walks the four canonical patterns with their real costs, then lists the four ways they fail silently.

Pattern 1 — semantic search

The whole system, in shapes:

Offline, once:  corpus of N sentences → encode → E ∈ RN×768 → L2-normalise rows → store
Online, per query:  q → encode → (768,) → normalise → scores = E q ∈ RN → top-k

Note what happened to the similarity computation: because both sides are unit-length, cosine similarity is the dot product, and all N of them together are one matrix-vector product. Your semantic search engine is a single BLAS call.

Memory, worked. For N = 1,000,000 documents:

PrecisionBytes per vectorTotal for 1MQuality cost
fp32768 × 4 = 3,072 B3.07 GBNone (the reference)
fp16768 × 2 = 1,536 B1.54 GBEssentially none for cosine ranking
int8 (scalar quantised)768 × 1 = 768 B0.77 GBTypically <1% recall@10 loss
Binary (sign only)768 / 8 = 96 B0.10 GBSeveral points — use as a first-stage filter, rescore in fp32

Latency, worked. A brute-force scan reads the whole matrix, so it is bound by memory bandwidth rather than arithmetic:

compute: 1,000,000 × 768 × 2 FLOPs = 1.54 GFLOP → ~30 ms at 50 GFLOP/s (one CPU core, vectorised)
memory: 3.07 GB streamed at ~50 GB/s = 61 ms  ← the binding constraint
in fp16: 1.54 GB / 50 GB/s = 31 ms

That is why fp16 storage roughly halves your search latency even though the arithmetic is unchanged: you are moving half as many bytes. Below about a million vectors, brute force is genuinely fine and far simpler than an ANN index. Above it, an approximate nearest neighbour structure (HNSW, IVF-PQ) takes the scan from O(N) to something closer to O(log N) at the price of a small recall loss and a build step.

Realisation note — the asymmetry nobody warns you about. A search query ("battery drains fast") and a document ("The device may experience accelerated power depletion under sustained load…") are different kinds of text: different length, register, and information density. SBERT-NLI was trained on pairs of similar-looking sentences, so it maps both into the same space with the same function — and short queries systematically land in a different region from long documents, which depresses their similarity regardless of relevance. This is why later retrieval models (E5, BGE, GTR) prepend asymmetric instruction prefixes like "query: " and "passage: ". It is a one-token fix for a structural mismatch, and it is worth several points of recall. If your search results feel "topically right but never exactly right", suspect this first.

Pattern 2 — clustering

Clustering needs a vector per item. A cross-encoder cannot participate at all — there is no object to hand to k-means. With SBERT it is three lines.

One subtlety worth deriving. k-means minimises squared Euclidean distance, but you care about cosine. On L2-normalised vectors those are the same objective, by the identity from Chapter 4:

‖u − v‖2 = 2(1 − cos(u, v))

Minimising squared distance is therefore exactly maximising cosine. Running k-means on normalised embeddings is spherical k-means and it is the right algorithm — but only if you renormalise the centroids after each update, since the mean of unit vectors is not itself a unit vector (its length shrinks in proportion to how spread the cluster is). Skip that renormalisation and tight clusters get systematically longer centroids than loose ones, so they win assignments they should not.

Cost. k-means on N = 100,000 vectors of d = 768 with k = 50 clusters and 20 iterations:

per iteration: N × k × d = 100,000 × 50 × 768 = 3.84 × 109 multiply-adds
20 iterations ≈ 1.5 × 1011 FLOPs ≈ a few seconds on a GPU

the same job with a cross-encoder: undefined — there are no points to cluster

Pattern 3 — paraphrase mining and deduplication

This is Chapter 0's original task, and the quadratic has not gone away — it has become cheap. All-pairs similarity over N sentences is E ET, an (N, N) matrix. For N = 100,000 that matrix is 1010 entries = 40 GB in fp32, which does not fit anywhere, so you never materialise it:

python — chunked all-pairs, never materialising the N×N matriximport numpy as np

E = E / np.linalg.norm(E, axis=1, keepdims=True)     # (N, 768), unit rows
pairs, CH = [], 1024
for i in range(0, len(E), CH):
    block = E[i:i+CH] @ E.T                            # (CH, N) - 3 MB per row-block, fine
    r, c = np.where(block > 0.85)                       # threshold once, keep survivors only
    for a, b in zip(r + i, c):
        if a < b: pairs.append((int(a), int(b), float(block[a - i, b])))

The peak memory is one block: 1024 × 100,000 × 4 bytes = 410 MB, and only the survivors above the threshold are ever kept. This is the standard paraphrase_mining routine, and it turns Chapter 0's 65-hour job into a couple of minutes.

Threshold selection is the part that will actually cost you a day. You cannot pick 0.85 from first principles — Chapter 1's anisotropy means the whole similarity distribution shifts with the model, the domain, and the average sentence length. The reliable procedure: sample 200 random pairs from your own corpus and plot their similarity histogram. That is your noise floor. Then hand-label 100 pairs sampled from above the floor and pick the threshold where precision crosses your requirement. Redo it every time you change the model — a new checkpoint with a better STS score can easily have a completely different similarity scale.

Pattern 3b — the blocking trick, revisited

Chapter 0 listed blocking as the database world's escape from the all-pairs problem. It has not been superseded by embeddings — it composes with them, and at large scale you need both.

Deduplicating 50 million product listings gives 1.25 × 1015 pairs. Even at 1010 dot products per second that is 34 hours of pure arithmetic, and the storage for the results does not exist. So block first:

Blocking keyPairs remainingThen
None1.25 × 1015Infeasible
Same top-level category (say 500 of them)~2.5 × 1012Better; still too many
Same category and same brand (~50k blocks)~2.5 × 1010Feasible as chunked matmuls, a few hours
ANN top-50 neighbours per item instead of blocks2.5 × 109 candidate pairsThe modern answer — the index is the blocking

The last row is the elegant one: an ANN index is a learned, soft version of a blocking key. Instead of a human deciding that "same brand" is the right partition, the embedding decides who is worth comparing to whom. Retrieve k neighbours for every item, score only those pairs, and the quadratic becomes O(N · k).

But note what you inherit along with the trick: blocking's recall failure. A duplicate whose two listings never appear in each other's top-50 will never be found, no matter how good the scorer is. Raising k trades compute for recall, and there is no setting of k that guarantees completeness. Exhaustive comparison was the only thing that did, and you gave it up in Chapter 0 for four orders of magnitude. It was the right trade; it is still a trade.

Pattern 4 — zero-shot classification, and RAG

Embed your label names ("billing issue", "shipping delay", "product defect"), embed the incoming text, take the arg-max cosine. That is a classifier with no training data and no gradient steps, whose class set can change at runtime — the same structural move CLIP and CLAP made in vision and audio, arriving here from a different direction.

And the pattern that came to dominate everything: retrieval-augmented generation. Chunk your documents, embed the chunks, store them; at query time embed the question, retrieve the top-k chunks, paste them into an LLM prompt. Every RAG system on earth has a Sentence-BERT-shaped component at its heart, and its quality ceiling is the retriever's recall — the point Chapter 2 made about rerankers applies at full force to generation, since a fact absent from the retrieved chunks cannot be generated except by luck.

Embedding space — search, cluster, and mine the same points

Forty sentence embeddings from three topics, projected to two dimensions. Search picks a query point and rays out to its top-k by cosine. Cluster runs one step of spherical k-means per press — watch centroids migrate and assignments flip. Mine draws an edge between every pair above the threshold, which is the paraphrase-mining job: slide the threshold down and watch the graph go from nothing, to clean topical communities, to a hairball. That middle band is the operating point, and finding it is the day of work the callout above warned you about.

Mode:
Threshold / k 0.75

When brute force stops being enough: ANN indexes

The 61 ms scan above is per query on one core. At 100 queries per second you need six cores doing nothing else, and at 100M vectors the matrix does not fit in RAM at all. That is where an approximate nearest neighbour index earns its complexity.

StructureHow it prunesTypical recall@10Cost you pay
Flat (brute force)Nothing — scans everything100% by definitionO(N) per query; no build step; trivially correct
IVF (inverted file)k-means the corpus into ~√N cells; scan only the nprobe nearest cells90–99% at nprobe 8–32A training pass; recall falls off a cliff if the data drifts away from the cells
HNSW (graph)A navigable small-world graph; greedy descent from an entry point95–99.5%Memory overhead of the graph (often ~1.5× the vectors); slow inserts
IVF-PQIVF plus product quantisation of the residuals80–95%Big compression (10–30×) for a real accuracy cost; rescore survivors in full precision
Realisation note — approximate means approximate, and you must measure it. The recall of an ANN index is a tunable, not a property. Every deployment should record recall@10 of the index against a brute-force ground truth on a sample of real queries, and re-record it after every reindex. The characteristic production incident is: corpus grows, distribution shifts, IVF cells go stale, recall silently drops from 97% to 80%, and search "feels worse" with no error anywhere. A nightly job comparing 200 sampled queries against a flat scan costs almost nothing and turns that into an alert.

The decision rule is simpler than the table suggests. Under about a million vectors, use flat — it is exact, needs no tuning, and 30–60 ms is usually inside budget. Between one and fifty million, HNSW. Above that, or when memory is the binding constraint, quantise. Reaching for an ANN index at 50,000 documents is a very common way to spend a week acquiring a recall bug you did not need.

The four silent failures

None of these throw an exception. All of them return plausible numbers. Each has cost somebody a quarter.

FailureWhat you seeRoot causeGuard
Pooling mismatchResults are topical but ranking is nonsenseIndex built with MEAN, queries pooled with CLS (or a different library default)Store pooling mode + model revision in the index metadata; refuse to serve on mismatch
Half-reindexed corpusOld documents never surface; new ones dominateModel upgraded, only new documents re-encoded. Vectors from two models are not comparable — not even approximatelyVersion the whole index; rebuild atomically and swap
Unnormalised vectorsLong documents win everythingDot product without L2 normalisation ranks by ‖v‖cosθ, and norm correlates with lengthNormalise at write time, assert ‖v‖ ≈ 1 on read
Padding-sensitive embeddingsThe same sentence gets slightly different vectors on different daysMean pooling without the attention mask — Chapter 3's bug. The vector depends on batch compositionA unit test: encode one sentence alone and inside a batch of long ones; assert the vectors are identical

The third row deserves a derivation because it is so common. Ranking by an unnormalised dot product means ranking by ‖u‖ ‖v‖ cosθ. For a fixed query, ‖u‖ is a constant, so you are ranking by ‖v‖ cosθ — the cosine weighted by each document's norm. Since mean-pooled norms grow modestly with content density and length, you have built a length-biased ranker and it will look almost right, which is worse than looking wrong.

Updating the index without downtime

Two of Chapter 8's silent failures are really one operational question: how does an index change while it is being served? There are three patterns and they are not interchangeable.

PatternMechanismSafe forUnsafe for
Incremental upsertEncode the changed document, replace its rowContent edits, additions, deletions — the model is unchanged, so the vectors remain comparableAnything involving a model change
Blue/green rebuildBuild a whole new index beside the live one, verify, then flip a pointerModel upgrades, dimension changes, chunking changesNothing — it is always correct, it just costs double storage briefly
In-place partial re-encodeRe-encode "the documents that changed" with a new modelNothingEverything. This is the bug in the first incident sketch above

The rule is one line: a model change invalidates every vector, not the changed ones. Vectors from two models are coordinates in two different spaces, and a dot product between them is arithmetic without meaning. It will not error. It will return a plausible number, and old documents will quietly stop being competitive.

The cheapest enforcement is to make the model revision part of the index's identity — put it in the index name (docs_v3_bge-small_768), not in a metadata field somebody has to remember to check. Then a partial re-encode with a new model is impossible by construction, because the writer would have to target an index that does not exist yet.

Capacity planning, worked end to end

Take a concrete brief and size it, because the arithmetic is short and almost nobody does it before choosing an architecture.

"Semantic search over 5 million support articles. 50 queries per second at peak. p95 under 150 ms. Documents change: about 20,000 edits a day."

QuantityArithmeticResult
Chunks5M articles, average 4 chunks each20M vectors
Storage (fp16, d = 384)20M × 384 × 2 bytes15.4 GB — fits one machine's RAM
Storage (fp32, d = 768)20M × 768 × 4 bytes61.4 GB — now you are sharding, or paying for a big box
Brute-force scan15.4 GB at ~50 GB/s310 ms per query — over budget. ANN required
HNSW query~10–20 graph hops2–5 ms — comfortable
HNSW memory overhead~1.5× the vectors~23 GB total. Budget for it or you will discover it at 3 a.m.
Query encode50 qps × one MiniLM pass (~1 ms GPU / ~12 ms CPU)One small GPU, or ~1 CPU core with batching
Reindex churn20,000 edits × 4 chunks = 80,000 encodes/day< 1 minute of GPU. Incremental updates are free
Full rebuild (model upgrade)20M chunks at ~2,000/s~2.8 GPU-hours. Plan for it quarterly

Two decisions fall straight out of this table and both are about dimension. Moving from 768 to 384 dimensions and from fp32 to fp16 takes storage from 61 GB to 15 GB — a 4× reduction that decides whether this is one machine or a cluster, for a cost of a point or two of retrieval quality. And the last row is why you version indexes: a full rebuild is hours, so it happens as a background job writing to a new index that you swap in atomically, never as an in-place mutation.

Chunking, and the arithmetic that makes it obvious

Chapter 9's "detail dilution" has a quantitative form worth having in advance. Mean pooling makes the sentence vector an average over tokens, so a single relevant sentence inside a long document contributes in proportion to its share of the tokens.

Unit embeddedTokensShare contributed by one relevant 20-token sentenceChunks for 5M articles
Whole article2,0001%5M
Section5004%20M
Paragraph10020%100M
Sentence20100%500M

Signal share and index size move in opposite directions, and the sweet spot for most corpora is the paragraph — large enough to be self-contained when handed to a reader or a language model, small enough that the answer is a fifth of the vector rather than a hundredth. Two refinements matter in practice: overlap the chunks by a sentence or two, so a fact spanning a boundary is not split in half; and prepend the document title to each chunk, so a paragraph that says "it also supports SAML" still carries the name of the product it is about. Both are cheap, and between them they typically move recall more than swapping the embedding model does.

The filtering problem, briefly, because it bites everyone. Real searches are not "nearest neighbours in the whole corpus" — they are "nearest neighbours among documents this user may see, in this language, published after March." ANN indexes do not natively understand predicates. Post-filtering (retrieve 100, then filter) can return nothing at all when the filter is selective; pre-filtering (build a bitmask, then search within it) is what modern vector databases implement, and it is why they exist as products rather than as a call to numpy.dot. Decide which one you have before you promise a feature that combines search with permissions.

The reference implementation

python — the entire production patternfrom sentence_transformers import SentenceTransformer, util

model = SentenceTransformer('all-MiniLM-L6-v2')   # 384-dim, 6 layers, ~14k sent/s on GPU

# OFFLINE - once per corpus version
corpus_emb = model.encode(corpus, batch_size=128,
                          convert_to_tensor=True,
                          normalize_embeddings=True)   # (N, 384), unit rows

# ONLINE - per query
q = model.encode(query, convert_to_tensor=True, normalize_embeddings=True)
hits = util.semantic_search(q, corpus_emb, top_k=100)[0]   # chunked matmul + top-k

# OPTIONAL STAGE 2 - the cross-encoder from Chapter 2
from sentence_transformers import CrossEncoder
ce = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
scores = ce.predict([(query, corpus[h['corpus_id']]) for h in hits])   # 100 joint passes

One detail in that snippet is a decision, not a default: all-MiniLM-L6-v2 is not SBERT-base. It is a 6-layer, 384-dimensional distilled descendant that is roughly 5× faster and half the storage, and on most retrieval tasks it is within a point or two of the 12-layer model. The lineage this paper started very quickly discovered that sentence-embedding quality is remarkably robust to shrinking the encoder — which is Chapter 7's finding (supervision matters far more than model size) cashed out as an engineering win.

Three incidents, and what each one actually was

The silent-failure table lists causes. Here is what they look like from the outside, because that is how you will first meet them.

"Search got worse after the deploy, but only for old articles." The model was upgraded and the reindex job was written to process documents modified since the last run — sensible for a content sync, catastrophic for embeddings. Old articles kept vectors from the previous model, and vectors from two models share no coordinate system, so old and new documents were being compared in different spaces. Nothing errored; the two populations simply stopped competing on equal terms. Fix: version the index; a model change forces a full rebuild, always.

"Duplicate detection flagged 400,000 pairs overnight." The threshold was tuned on a sample of English tickets at 0.85. A new locale launched, the corpus gained a large body of shorter, template-heavy text, and the mean similarity of that subpopulation was far above the old floor. The threshold had not moved; the distribution under it had. Fix: thresholds must be recomputed per population, from the measured noise floor — and the mining job should alert when its output volume moves by more than a factor of two.

"Latency is fine but the results feel random for long questions." max_seq_length was 128. Long questions were being truncated mid-sentence, so the embedded query was the first two clauses of a four-clause question — frequently the setup rather than the ask. Short questions worked perfectly, which made it look like a quality problem rather than a configuration one. Fix: log the fraction of inputs that hit the truncation limit; if it is above a percent, raise the limit or chunk the query.

What the three have in common. None produced an error, all produced plausible output, and all were detectable by a single number that nobody was recording — index/model version match, similarity distribution, truncation rate. Embedding systems fail by degrading, and degradation is only visible against a baseline you deliberately kept. That is the case for the monitoring below.

The RAG failure taxonomy

Because retrieval-augmented generation is where most people meet SBERT, it is worth naming its failure modes precisely — users report all of them as "the AI got it wrong", and four of the five are retrieval bugs with distinct fixes.

SymptomActual causeWhere in this lessonFix
Answer invents facts not in the corpusRetrieval returned nothing relevant; the model filled the gapChapter 2 — recall@k is the ceilingMeasure recall@k first. Instruct the model to abstain when context is thin
Answer is on-topic but misses the specific factThe chunk containing it ranked below the cutoff, or was split across a boundaryChunking arithmetic aboveSmaller chunks, overlap, and a reranker
Answer cites a stale documentThe index was not rebuilt after an editSilent failure #2Version the index; reindex on write
The same document is retrieved for every questionHubness — it sits near the mean of a coned spaceChapter 1's diagnosticsCentre the embeddings; strip boilerplate; check for a duplicated header on every chunk
Answer contradicts a retrieved documentGenuinely a generation problemNot retrievalPrompting, or a different model

Four of five rows are upstream of the language model entirely, which is why "our RAG is bad, let us try a bigger LLM" so rarely works. The instrumentation that separates them is one number: for a sample of real questions with known answers, did the correct chunk appear in the retrieved set at all? If it did not, nothing downstream can help; if it did, the problem is downstream and you have halved your search space.

Monitoring an embedding system

An embedding index has no natural error signal. It does not 500, it does not time out, it does not log a stack trace — it returns ten documents, always, ranked by a number. So the monitoring has to be built deliberately, and it is four cheap jobs.

SignalHow to compute itWhat a change means
ANN recallNightly: 200 sampled queries, ANN top-10 versus a brute-force top-10 on the same vectorsFalling recall means the index structure has gone stale relative to the data. Rebuild
Score distributionLog the top-1 and top-10 cosine of every production query; chart the percentiles weeklyA shifting distribution means the query mix or the corpus has drifted. Every threshold downstream is now mis-set
Zero-result rateFraction of queries whose top-1 falls below your relevance thresholdRising = new topics arriving that the corpus (or the embedder) does not cover
Click-through at rank 1From product telemetryThe only signal that measures the actual objective. Everything above is a proxy for it
Index/model version matchAn assertion at serve time, not a metricShould be impossible, so alert loudly if it ever fires

Embedding drift deserves its own paragraph because it is subtle. Your model is frozen, so the function does not drift — but the distribution of inputs does. A support corpus in January is about last year's product; by July it is about the new release, with new terminology the encoder has never seen used this way. The vectors are still computed correctly and the neighbourhoods are still geometrically valid. What has changed is that the region of the space your queries land in is no longer the region your evaluation set measured.

The cheapest useful practice in this whole chapter. Freeze 200 (query, correct-document) pairs sampled from real traffic, and re-run them as a regression test on every index rebuild, every model change, and once a week regardless. Chart recall@10 over time. This single number catches stale ANN structures, corpus drift, a bad reindex, a pooling mismatch, and an accidental model downgrade — five distinct failure classes, one chart, an afternoon to build. Almost no team has it, and almost every team has had at least one of the five.
Cross-domain bridge
An embedding index is a lossy hash table with a distance-preserving hash
A hash table gives O(1) lookup by mapping a key to a bucket — but hashes are designed to destroy locality, so "nearly equal" keys land in unrelated buckets. An embedding is the opposite construction: a map into a space where nearness is preserved on purpose, which is exactly what lets an ANN index prune. Chapter 3's pooling function is the hash function, and its contract is just as strict: change it and every stored value becomes unreadable, silently. That is why "pooling mode" belongs in your index metadata for precisely the reason a hash table stores its hash algorithm. See our vector databases and similarity metrics lessons for the indexing structures that sit on top.

Four questions before you add embeddings to a product

Not every search problem needs a vector index, and the failure mode of adding one unnecessarily is a permanent operational burden for no user-visible gain. Ask these first.

QuestionIf the answer is…Then
Do your users and your documents use the same words?Yes (internal jargon, structured catalogue, exact product codes)BM25 may already be at the ceiling. Measure it before building anything
What does "similar" mean for this feature, exactly?You cannot state it in one sentenceStop. Chapter 9's limit 3b — you will build the wrong relation and debug it as a quality problem
Can you get 200 labelled (query, correct-answer) pairs?NoYou will have no way to tell whether it works, or whether a change helped. Get them first; it is a day of work
Who owns reindexing when the model changes?Nobody yetAssign it now. An unowned index becomes stale, then wrong, then quietly load-bearing

When the answers are good, the build order that minimises wasted work is: BM25 baseline → off-the-shelf bi-encoder, brute-force scan, measured against that baseline → cross-encoder reranker if the budget allows → ANN index only when the scan is too slow → fine-tuning only when the off-the-shelf model is demonstrably the bottleneck.

That order is deliberately the reverse of how these projects usually start. The common opening move — fine-tune a model and stand up a vector database — front-loads the two most expensive, most operationally sticky steps before anyone has established that the cheap ones were insufficient. Each stage above is a day or two and produces a number; skipping to the end produces a system nobody can evaluate.

Your semantic search returns results that are on-topic but consistently favour long documents. What is the most likely cause?

Chapter 9: Where It Breaks, and What Came Next

Sentence-BERT is six years old, is still downloaded millions of times a month, and is comprehensively superseded by its own descendants. Understanding exactly which of its limits each descendant attacked is the fastest way to understand the modern embedding landscape — so this chapter is four limitations, each with its consequence and its successor.

Limit 1 — domain shift

The Argument Facet Similarity result from Chapter 7 was the early warning: in-domain, SBERT ties the cross-encoder; cross-topic, it loses by about seven points. The mechanism is structural and we derived it in Chapter 2 — a bi-encoder must commit to a representation of sentence A before it has seen B, so it needs a good global map, while a cross-encoder only needs a good local comparison.

The practical consequence is that "similarity" is not a domain-free notion. Two ICD-10 codes that differ by one digit are near-identical to a general encoder and clinically opposite. Two code snippets differing in a comparison operator are 0.99 apart in a text embedder and are a bug and its fix. Your domain's notion of "same" is a fact about your domain, and a model trained on image captions and telephone transcripts does not know it.

What to do about it, in order of cost. (1) Evaluate on a held-out domain, not a held-out split — otherwise you cannot even see the problem. (2) Try a domain-appropriate off-the-shelf checkpoint before training anything. (3) Fine-tune with the multiple-negatives-ranking loss on whatever weak in-domain pairs you can scrape — a title and its body, a question and its accepted answer, a ticket and its duplicate. A few thousand real in-domain pairs routinely beat a million out-of-domain ones. (4) Only then consider a cross-encoder reranker for the top-k, which recovers most of the gap for the price of Chapter 2's latency budget.

Limit 2 — anisotropy survives the fine-tune

Chapter 1 diagnosed the cone; the NLI fine-tune stretches it but does not remove it. Measure your own model: sample a thousand random pairs from your corpus and take the mean cosine. For SBERT-NLI it typically lands around 0.25–0.40 rather than 0. Everything sits in a wedge.

Three consequences follow directly. Thresholds are not portable across models or corpora (Chapter 8's day of work). Absolute similarity values are uninterpretable — 0.72 means nothing until you know the noise floor. And the usable dynamic range is compressed, so quantisation to int8 or binary loses more than the bit-count suggests, because the interesting variation occupies a small slice of the representable interval.

Two families of fix appeared almost immediately. Post-hoc: BERT-flow (Li et al., 2020) learns an invertible flow to a Gaussian; BERT-whitening (Su et al., 2021) does the same job with a mean subtraction and a linear whitening transform computed in closed form — roughly ten lines of NumPy, recovering several STS points on most models. In-training: make the objective itself punish anisotropy. Which brings us to the successor.

Limit 3 — the supervision bottleneck, and SimCSE

SBERT needs labelled pairs. NLI happens to exist for English; for most languages and nearly all specialist domains it does not, and annotating a million pairs is a serious programme. SimCSE (Gao, Yao, Chen, 2021) removes the requirement with a trick that is almost insultingly simple.

Unsupervised SimCSE, in one sentence: pass the same sentence through the encoder twice, and treat the two outputs as a positive pair. They differ because dropout is active — the standard 10% dropout inside the transformer randomly zeroes different units on each pass, so the two vectors are two slightly different views of the same meaning. Every other sentence in the batch is a negative. That is the entire method: no augmentation, no labels, no data collection. Dropout is the data augmentation.

The loss is InfoNCE — softmax cross-entropy over in-batch similarities with a temperature:

Li = −log [ exp(cos(hi, hi+)/τ) / ∑j=1N exp(cos(hi, hj+)/τ) ]   τ = 0.05

Compare this to Chapter 4's triplet loss and the improvement is visible in the shape. Triplet: one negative, a hand-tuned margin, zero gradient once satisfied. InfoNCE: N−1 negatives at once, no margin, and the softmax automatically weights each negative by how threatening it currently is — a hard negative absorbs most of the repulsive gradient, an easy one almost none. Hard-negative mining stops being a separate engineering project and becomes an emergent property of the loss.

And it directly attacks the anisotropy. Wang and Isola (2020) decomposed contrastive objectives into two forces: alignment (positive pairs should be close) and uniformity (embeddings should spread over the sphere). The denominator of InfoNCE is the uniformity term — it is a sum over all other sentences that is minimised by pushing everything apart. So the cone gets flattened by the objective itself rather than by a post-hoc transform. SimCSE's paper makes exactly this argument, and shows the alignment-uniformity plot to prove it.

ModelSupervisionAvg. STS (7 sets)
Avg. GloVeNone61.32
Mean-pooled BERTNone54.81
SBERT-base1M labelled NLI pairs74.89
Unsupervised SimCSE-BERT-baseNone — dropout only~76.3
Supervised SimCSE-BERT-baseNLI: entailment as positive, contradiction as hard negative~81.6

Read row 4 twice. Unsupervised SimCSE beats SBERT, which used a million human-labelled pairs. The labels were never the essential ingredient — what mattered was applying a pair-level force with enough negatives, and dropout noise is enough to define a positive. Then row 5: feed the same NLI data into the better objective, using contradictions as explicit hard negatives rather than as a third softmax class, and you gain another five points over that. Same data as SBERT, +6.7 points, purely from the loss.

Limit 3b — "similar" is not one relation

There is a limitation more fundamental than any of these, and it is not an engineering defect — it is a category error built into the premise. Take two sentences:

Question being askedShould these be near each other?
A: "How do I reset my password?"  ·  B: "Password reset is under Settings → Security."
Do they mean the same thing?No — one is a question, one is an instruction
Does B answer A?Yes — maximally
Are they about the same topic?Yes
Would they be duplicates in a ticket queue?No

A single vector per sentence and a single cosine cannot answer four different questions with four different answers. SBERT-NLI is trained toward the first — semantic equivalence — and every deployment that actually wanted the second (question–answer relevance) is quietly using the wrong relation and blaming the model.

This is the limitation that instruction-conditioned embedders exist to remove. Modern models (INSTRUCTOR, E5, BGE, and the LLM-based embedders) accept a prefix describing the task: "Represent this question for retrieving supporting documents: ". The vector then depends on the relation you asked for, so one model can serve several notions of similarity from the same weights. It is the single largest conceptual advance over SBERT, and it is worth knowing that "my embeddings do not understand my use case" is usually this, not a quality problem.

Limit 3c — English, and how the fix works

SBERT-NLI is English, because SNLI and MultiNLI are English. The obvious approach — find NLI data in fifty languages — does not scale, so Reimers and Gurevych solved it a different way in a 2020 follow-up, and the trick is elegant enough to state here.

Teacher — frozen English SBERT
Encode the English side of a translation pair. Its vector is the target
↓ ordinary translated sentence pairs — abundant in every language
Student — a multilingual encoder
Trained by mean-squared error to map both the English sentence and its translation onto the teacher's vector
Result
One aligned space across 50+ languages. "El gato duerme" lands where "the cat sleeps" already was — and cross-lingual search works with no cross-lingual labels ever collected

The move is worth generalising: when supervision exists in one setting and not another, look for a cheap correspondence between the two (here, translation pairs) and distil across it rather than annotating again. The same trick multilingualises almost any encoder.

Limit 3d — you cannot ask the vector why

One more limitation, less discussed than the others and increasingly the binding one in products. A cosine of 0.83 is a number with no account of itself. It cannot tell you which part of the document matched, nor why one result outranked another.

Question a user or an auditor asksBM25 can answerA bi-encoder can answer
"Why did this document match?"Yes — the matched terms and their weightsNo. A dot product of two opaque vectors
"Why is this ranked above that?"Yes — term-by-termOnly "the number was larger"
"Show me the passage that matched"Yes — highlight the termsOnly the whole chunk
"Guarantee this document can never be retrieved for that query"Yes — a rule over termsNo. You can filter after the fact, but not constrain the geometry

Row four is the one that surfaces in regulated settings and in trust-and-safety work: there is no way to express a hard constraint over a learned metric. Everything you can do is a filter bolted on afterwards, which means the model can propose anything and your safety property lives entirely in the wrapper.

Partial mitigations exist. ColBERT's late interaction identifies which query token matched which document token, giving something like a highlight. Hybrid search lets the lexical component carry the explanation. And a cross-encoder reranker's attention can be inspected, with the usual caveats about attention as explanation. But none of them recovers the term-level auditability that a sparse index gives for free, and it is a real reason serious systems keep a lexical component rather than going purely dense.

Alignment and uniformity, computed

Wang and Isola's decomposition is worth making numerical, because it turns "the space is better" into two things you can measure on your own model in ten lines. For positive pairs (x, x+) drawn from your data and arbitrary points x, y drawn from the corpus:

Lalign = E [ ‖f(x) − f(x+)‖2 ]  — lower is better (positives are close)
Luniform = log E [ e−2‖f(x) − f(y)‖2 ]  — lower is better (points are spread)

The second one is a Gaussian-kernel energy, and the reason it measures spread is worth seeing. If every point collapses to the same place, all distances are 0, every exponential term is e0 = 1, the mean is 1, and the log is 0 — the worst possible value. If points are spread, distances are large, the exponentials are near 0, and the log is very negative. It is a repulsion energy read as a score.

Three toy configurations on the unit circle, each with four points, to see the two numbers move in opposition:

ConfigurationTypical squared distance between two pointsLuniform ≈ log E[e−2d2]What it is
All four at 0°0log(1) = 0.00Total collapse — perfect alignment, zero information
Clustered within 20°~0.06log(0.887) = −0.12The anisotropic cone
Spread at 0°, 90°, 180°, 270°2.0 averagelog(0.052) = −2.96Well spread

Now the whole story of this lesson in two coordinates. Mean-pooled BERT has decent alignment (related things are somewhat close) and terrible uniformity (everything is close, so "close" carries no information). SBERT improves both, mostly uniformity, because contradiction pairs get pushed apart. SimCSE improves uniformity a lot more, because its InfoNCE denominator is a uniformity term applied at every step to every pair in the batch. And a hypothetical model that maximised uniformity alone would spread everything evenly and destroy alignment — which is why the objective must contain both forces.

The diagnostic this gives you. If your fine-tuned model is worse than the checkpoint you started from, plot these two numbers before and after. Alignment improved and uniformity collapsed means you trained on positives without enough negatives, and the model is shrinking the space toward a point — the classic symptom of a positives-only loss with too small a batch. Uniformity improved and alignment collapsed means your "positives" are not actually positives, and you should audit the pairs. Two scalars, and they separate the two ways contrastive training goes wrong.

Limit 4 — one vector is a very small pipe

A 768-dimensional fp32 vector is 3 KB. A 512-token document is perhaps 2 KB of text carrying dozens of independent facts. Compressing it into 3 KB of floats with no knowledge of what will be asked is a hard information-theoretic bargain, and it produces three characteristic failures:

FailureExampleWhy the bottleneck causes it
Negation blindness"the treatment was effective" vs "the treatment was not effective" → cosine ~0.9One token flips the assertion but barely moves the mean-pooled average; nothing in training made that token's contribution large
Word-order blindness"the dog bit the man" vs "the man bit the dog"Attention does see order, but pooling averages it away; the surviving signal is mostly a bag of contextualised meanings
Detail dilutionA long document mentioning your query term once scores lowThe mean is dominated by the other 500 tokens. The relevant sentence is 1/500th of the vector

The third one has a purely operational fix that is the single most valuable piece of RAG advice: chunk your documents. Embedding paragraphs instead of documents raises the signal fraction from 1/500 to 1/50, and most retrieval quality complaints are chunking complaints wearing a costume.

The architectural fix is late interaction — ColBERT (Khattab & Zaharia, 2020) keeps one vector per token and scores with MaxSim, recovering fine-grained matching while staying precomputable, at 30–100× the storage. Chapter 2's design space, revisited with the benefit of knowing where the bi-encoder hurts.

The line of descent

2017–2018 — before
InferSent (BiLSTM + NLI) and Universal Sentence Encoder (multi-task transformer) establish that pair supervision produces usable sentence vectors
2019 — Sentence-BERT
Siamese BERT + mean pooling + NLI. 65 hours becomes 5 seconds; STS goes 54.81 → 74.89. The sentence-transformers library makes it a one-liner
2020–2021 — better objectives
DPR (in-batch negatives for retrieval), ColBERT (late interaction), BERT-flow and whitening (anisotropy), SimCSE (dropout positives + InfoNCE) — each attacking one limit above
2022–present — scale and instructions
GTR, E5, BGE, GTE and the LLM-based embedders: multi-stage contrastive training on hundreds of millions of mined pairs, asymmetric query:/passage: prefixes, instruction conditioning, Matryoshka dimensions — all measured on MTEB, a benchmark that exists because SBERT made embeddings a product category

Every arrow in that diagram preserves the structure Sentence-BERT established: encode independently, pool to one vector, compare with cosine, index the result. Six years of progress has been about the objective, the negatives, and the data — not the shape. That shape is the paper's real contribution.

What you would change if you wrote this paper today

A useful exercise for any paper, and here the answers are all things the following six years established.

2019 choice2026 choiceWhy
Classification objective on [u; v; |u−v|]InfoNCE with in-batch negatives and τ = 0.05N−1 negatives instead of one, automatic hard-negative weighting, no discarded classifier, and an explicit uniformity force
Batch size 16The largest that fits, with gradient caching if neededUnder an in-batch loss, batch size is the negative count — it changes the objective, not just the throughput
NLI onlyMulti-stage: hundreds of millions of mined web pairs, then a curated supervised mixture including NLIThe two-stage recipe of Chapter 5, extended. NLI is still in the mix — it just is not the whole mix
Symmetric encoding for everythingInstruction prefixes: query: / passage:Limit 3b — "similar" is several relations, and one function cannot serve them all
No normalisation during trainingL2-normalise before the lossRemoves the 1/‖u‖ gradient asymmetry and makes train and test metrics identical
Evaluate on STSEvaluate on MTEB and your own held-out domainSTS is symmetric, short, clean, and in-domain — three of the four wrong for retrieval
Fixed 768 dimensionsMatryoshka trainingOne model serves every storage budget

Notice what is not in that table: the siamese structure, mean pooling, cosine similarity, storing one vector per sentence, and the retrieve-then-rerank pattern. Seven revisions to the training procedure and not one to the architecture. When a paper's method section ages badly and its structure section does not, the structure was the contribution — and that is the shape of a result worth studying six years later.

Which descendant should you actually use?

Reading this lesson should not end with you downloading bert-base-nli-mean-tokens. It is a 2019 checkpoint, and its own authors deprecated it. A short decision guide, expressed in the vocabulary this lesson has built:

SituationReach forWhy, in this lesson's terms
General symmetric similarity, English, latency-sensitiveA small distilled sentence-transformer (6 layers, 384 dims)Chapter 7: supervision dominates model size. A fifth of the cost, a point or two of quality
Retrieval — short queries against long passagesAn instruction-prefixed retrieval model (E5, BGE, GTE family)Chapter 8's asymmetry note: SBERT-NLI maps queries and documents with one symmetric function, which is the wrong relation
Many languages, or cross-lingual searchA multilingual distilled modelLimit 3c: teacher-student distillation across translation pairs, no per-language labels
A specialist domain with in-domain pairs availableFine-tune a good general model with MultipleNegativesRankingLossLimit 1: a few thousand in-domain pairs beat a million out-of-domain ones
Storage or memory is the binding constraintA Matryoshka-trained model, truncatedTrained so that the first k dimensions are themselves a usable embedding — truncate 768 to 128 and lose a little, instead of everything
Accuracy matters far more than latency, small candidate setA cross-encoderChapter 2: when the quadratic never engages, take the accuracy

The Matryoshka row is worth a sentence of its own, because it is a neat idea. Ordinary embeddings distribute information across all 768 dimensions with no ordering, so truncating to 128 destroys them. Matryoshka representation learning applies the training loss at several prefix lengths simultaneously — 64, 128, 256, 768 — so the model is forced to put the most important information first. One model then serves every storage budget, and you can retrieve cheaply at 64 dimensions and rescore the survivors at 768. It is the storage-side analogue of retrieve-then-rerank.

What has not changed since 2019, and is why this lesson is not history. Encode independently. Pool to one vector. Compare with cosine. Train with a pair-level loss whose negatives are hard. Store the vectors and index them. Every model in the table above does exactly those five things — they differ in the objective, the negatives, the data volume, and the prefix. Learn the shape from this paper and every successor reads as a variation.

Cheat sheet

SymbolMeaningShape / value
HBERT token vectors for one sentenceRL×768, L = sequence length
mAttention mask — 1 for real tokens, 0 for [PAD]{0,1}L
u, vPooled sentence embeddingsR768 (base) or R1024 (large)
nEmbedding dimension in the paper's notation768
kNumber of classification labels3 — entailment / neutral / contradiction
WtTraining-only classifier on [u; v; |u−v|]R3n×k = R2304×3 = 6,912 params, discarded after training
εTriplet margin1 (Euclidean)
ρSpearman rank correlation, the STS metric×100 in every table

The four equations.

(1)  u = ( ∑i mi Hi ) / ( ∑i mi )
masked mean pooling — zero parameters, and the mask is not optional
(2)  o = softmax( Wt [ u ; v ; |u − v| ] )
classification objective; the difference term is the one that builds a metric
(3)  L = ( cos(u, v) − y )2
regression objective; its gradient is orthogonal to u, so it only rotates
(4)  L = max( ‖sa − sp‖ − ‖sa − sn‖ + ε , 0 )
triplet objective; goes silent the moment the margin is met

The numbers worth remembering

NumberWhat it is
65 hours → 5 secondsFinding the most similar pair among 10,000 sentences. 49,995,000 forward passes versus 10,000 encodes plus one matmul
54.81 → 74.89Average STS Spearman, mean-pooled BERT to SBERT-base. The fine-tune is worth 20 points
29.19BERT's [CLS] vector on STS — the "sentence representation" everyone pointed at
61.32Averaged GloVe, the 2014 baseline that raw BERT loses to
80.78 / 87.44MEAN pooling under the two objectives; MAX scores 69.92 under regression
66.04 → 80.78Adding |u−v| to (u, v). Using |u−v| alone already gives 69.78
1,000,000SNLI (570k) + MultiNLI (430k) training pairs, one epoch, batch 16, lr 2e-5
< 20 minutesThe entire training run, on one V100
88.33 vs 85.35Cross-encoder versus SBERT on STS-B. The 2.98-point price of the architecture
2,042Sentences per second on GPU with smart batching (1,378 without)

Where to go from here

If you want…Go to
The encoder SBERT wrapsBERT and Attention Is All You Need
The word-vector era it competes withword2vec, negative sampling, GloVe
The contrastive machinery in generalContrastive learning and CLIP
The same move in audioCLAP — two towers, one shared space, classification becomes retrieval
What you build with the vectorsVector embeddings, similarity metrics, vector databases
The system this all feedsRAG and multimodal RAG
Embeddings inside a networkEmbedding layers

Build it yourself — the afternoon recipe

StepWhat to doThe decision that matters
1. PairsScrape a few thousand in-domain positive pairs: title/body, question/accepted-answer, duplicate ticketsIn-domain beats large. A few thousand real pairs beat a million from another genre
2. NegativesRetrieve the top-20 for each positive with a weak model; keep the non-matching onesHardness of negatives is the highest-leverage variable in the whole build
3. EncoderStart from an existing sentence-transformer, not from raw BERTYou are adapting a geometry, not creating one. Much less data needed
4. PoolingMEAN, maskedChapter 3. And write the unit test for batch-composition invariance
5. LossMultipleNegativesRankingLoss (InfoNCE), τ ~ 0.05Chapter 9. Only use triplet if you genuinely have triplets and enjoy tuning margins
6. BatchAs large as memory allowsUnder InfoNCE, batch size is the negative count. This is the one place batch size changes the objective
7. NormaliseL2 at write time; assert on readChapter 8's third silent failure
8. EvaluateHeld-out domain, recall@k for retrieval, not just STSChapter 7's cross-topic result. In-domain evaluation hides the failure you care about
9. ThresholdHistogram 200 random pairs to find the noise floor, then label upward from itChapter 1's anisotropy. Redo it on every model change
10. RerankAdd a cross-encoder over the top-50 if the latency budget allowsChapter 2. It recovers most of the 2.98 points, and more under domain shift

References

  1. Reimers, N. & Gurevych, I. "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks," EMNLP-IJCNLP 2019 — arXiv:1908.10084. The paper this lesson is built on.
  2. Devlin, J., Chang, M.-W., Lee, K., Toutanova, K. "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding," NAACL 2019 — arXiv:1810.04805. The encoder, and the [CLS]/NSP design Chapter 1 dissects.
  3. Cer, D. et al. "Universal Sentence Encoder," 2018 — arXiv:1803.11175. The strong contemporary baseline SBERT beats by 3.67 points.
  4. Conneau, A. et al. "Supervised Learning of Universal Sentence Representations from Natural Language Inference Data" (InferSent), EMNLP 2017 — arXiv:1705.02364. Where "train on NLI" comes from.
  5. Bowman, S. et al. "A large annotated corpus for learning natural language inference" (SNLI), EMNLP 2015 — arXiv:1508.05326; Williams, A., Nangia, N., Bowman, S. "A Broad-Coverage Challenge Corpus for Sentence Understanding through Inference" (MultiNLI), NAACL 2018 — arXiv:1704.05426. The million pairs.
  6. Gao, T., Yao, X., Chen, D. "SimCSE: Simple Contrastive Learning of Sentence Embeddings," EMNLP 2021 — arXiv:2104.08821. Dropout as augmentation; the successor from Chapter 9.
  7. Ethayarajh, K. "How Contextual are Contextualized Word Representations?" EMNLP 2019 — arXiv:1909.00512. The anisotropy measurement behind Chapter 1's cone.
  8. Li, B. et al. "On the Sentence Embeddings from Pre-trained Language Models" (BERT-flow), EMNLP 2020 — arXiv:2011.05864. The post-hoc geometric fix.
  9. Wang, T. & Isola, P. "Understanding Contrastive Representation Learning through Alignment and Uniformity on the Hypersphere," ICML 2020 — arXiv:2005.10242. The lens Chapter 9 uses on InfoNCE.
  10. Khattab, O. & Zaharia, M. "ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT," SIGIR 2020 — arXiv:2004.12832. The middle ground of Chapter 2's design space.
  11. Karpukhin, V. et al. "Dense Passage Retrieval for Open-Domain Question Answering," EMNLP 2020 — arXiv:2004.04906. In-batch negatives for retrieval, the bridge to modern embedders.
  12. Muennighoff, N. et al. "MTEB: Massive Text Embedding Benchmark," EACL 2023 — arXiv:2210.07316. Where the descendants are measured today.
"What I cannot create, I do not understand."
A pretrained encoder, a masked mean, a few thousand in-domain pairs, and an InfoNCE loss. You can have a working domain embedder before dinner — and the 74.89 will stop being a number you read.
Exit gate — teach it back before you leave.

Without scrolling up: (1) derive the 65-hour figure from n = 10,000 and state which term becomes 5 seconds and why; (2) explain in one paragraph why mean-pooled BERT scores 54.81 while GloVe averages score 61.32; (3) write the classification objective and say which of its three feature blocks builds the metric, and why; (4) show that squared Euclidean distance and cosine give the same ranking on normalised vectors; (5) name the two things that go wrong if you mean-pool without the attention mask; (6) explain why unsupervised SimCSE beats SBERT despite using no labels. If any of the six stalls, its chapter is one tap away.

Unsupervised SimCSE, trained with no labels at all, outperforms SBERT trained on a million human-annotated NLI pairs. What does that reveal about Sentence-BERT?