Part I: The mental model
text ("what is a vector database", "FAISS is a similarity search library")
|
v
SentenceTransformer SentenceTransformer.py: an nn.Sequential of modules
|
v
[0] Transformer models/Transformer.py: HF AutoModel -> token embeddings
|
v
[1] Pooling models/Pooling.py: mean over tokens (attention-masked)
|
v
[2] Normalize (optional) models/Normalize.py: L2-normalize to the unit sphere
|
v
one fixed-size vector e.g. 384 floats for all-MiniLM-L6-v2
|
v
cosine similarity / ANN util.semantic_search, or a FAISS index, or a vector DB
|
v
top-k candidates
|
v
CrossEncoder rerank (opt.) cross_encoder/: score (query, doc) pairs jointly
The one-sentence identity. sentence-transformers is a bi-encoder, a model that reads each text once, on its own, and emits a single dense vector whose geometry encodes meaning, so that comparing a query against a million documents is a million cheap dot products against vectors you precomputed, not a million forward passes. That inversion is the whole point. A vanilla BERT can judge whether two sentences are similar, but only by reading them together as one concatenated input, which the field calls a cross-encoder. A cross-encoder is accurate and completely unscalable, because there is no reusable representation to cache. The Sentence-BERT paper measured exactly this gap. Finding the most similar pair among 10,000 sentences with a BERT cross-encoder needs roughly 50 million forward passes, about 65 hours on the GPU of the day, while embedding all 10,000 sentences once and comparing the vectors takes about 5 seconds. The library exists to make the second number the normal one.
Two ideas carry the design. First, an embedding is an interface. A
SentenceTransformer is not a monolithic model, it is a
small pipeline of modules ending in a pooling step, and its output
is just a vector, so it plugs into cosine similarity, a FAISS
index, a vector database, or a reranker without any of them knowing
what produced it. Second, the quality of that vector is made, not
found. A raw BERT averaged over its tokens gives embeddings that
score worse than averaging GloVe vectors, which is the surprising
result the SBERT paper opens with. What produces good embeddings is
contrastive fine-tuning, teaching the model to pull matching texts
together and push everything else apart, and the library's losses,
above all MultipleNegativesRankingLoss, are where that
happens. Everything here is checked against the library as of mid
2026. The project moves quickly and had a large training-API
rewrite in v3 and a sparse and cross-encoder rewrite in v4 and v5,
so where a detail is likely to shift I say so and stay at concept
level.
Part II: Using it
The library is pure Python and installs from PyPI. It pulls in
Hugging Face transformers,
torch, and huggingface_hub, and it runs
on CPU or GPU. On a laptop CPU the small models are perfectly
usable for tens of thousands of texts.
pip install sentence-transformersThe whole quickstart is four lines. Name a model, encode some text, compare the vectors. The first call downloads the model from the Hugging Face Hub and caches it, later calls are offline.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2") # 384-dim, ~22M params, fast
texts = [
"The cat sits on the mat.",
"A feline rests on the rug.",
"Quarterly revenue grew twelve percent.",
]
emb = model.encode(texts) # numpy array, shape (3, 384)
print(emb.shape)
sim = model.similarity(emb, emb) # (3, 3) cosine-similarity matrix
print(sim) # rows 0 and 1 are close, row 2 is far
all-MiniLM-L6-v2 is the workhorse first model, small,
fast, and good enough for most tasks. When you want more quality and
can pay for it, all-mpnet-base-v2 produces 768-dim
vectors and scores higher. For non-English text,
paraphrase-multilingual-MiniLM-L12-v2 covers fifty-plus
languages in one shared space. The similarity() method
uses whatever similarity function the model was configured with,
cosine for nearly all of them, and returns a full matrix so you can
compare many-against-many in one call.
The reason to embed at all is search. The util module
ships a batched exact search that is the right tool up to a few
hundred thousand documents before you reach for an approximate
index.
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer("all-MiniLM-L6-v2")
corpus = [
"Python is a programming language.",
"The Eiffel Tower is in Paris.",
"Pandas is a data analysis library.",
"France's capital is a popular tourist city.",
]
corpus_emb = model.encode(corpus, convert_to_tensor=True) # embed once, keep
query = "Where is the Eiffel Tower?"
query_emb = model.encode(query, convert_to_tensor=True)
hits = util.semantic_search(query_emb, corpus_emb, top_k=2)
for hit in hits[0]:
print(round(hit["score"], 3), corpus[hit["corpus_id"]])The corpus is embedded once and reused for every query, which is the entire economic argument for bi-encoders. When exact search stops fitting in memory or in your latency budget, the corpus embeddings go into an approximate nearest-neighbor index instead, and the query side is unchanged. That index is usually FAISS or a vector database built on the same ideas.
Bi-encoder retrieval is fast but a little blurry, because the query and the document never see each other. The standard fix is to retrieve a generous top-k with the bi-encoder, then rerank those few candidates with a cross-encoder that reads each pair jointly.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
query = "How do I reset my password?"
candidates = [
"Go to settings and choose Forgot Password.",
"Our office is open from nine to five.",
"Click the reset link we email you.",
]
ranked = reranker.rank(query, candidates, top_k=3)
for r in ranked:
print(round(r["score"], 3), candidates[r["corpus_id"]])
The mistakes beginners make. First, forgetting the prompt. Many
modern embedding models were trained with a short instruction prefix
such as "query: " and "passage: ", and they
silently lose accuracy if you skip it, so read the model card and use
model.encode(..., prompt_name="query") or the newer
encode_query and encode_document helpers
where the model provides them. Second, mixing similarity functions.
If a model was trained with cosine similarity, compare with cosine,
not raw dot product, unless you normalize first, and note that once
embeddings are L2-normalized cosine and dot product agree. Third,
comparing embeddings from two different models, which is meaningless
because each model owns its own space. Fourth, and most important for
cost, never run a cross-encoder over your whole corpus. The
cross-encoder has no reusable vector, so scoring a million documents
is a million forward passes, which is precisely the 65-hour problem
the bi-encoder was built to avoid. Use it only to rerank the handful
the bi-encoder already found.
Part III: When it is the right tool
sentence-transformers is the right tool when you need dense text representations you control, semantic search over your own data, retrieval for a RAG pipeline, deduplication and clustering, paraphrase mining, or a similarity signal inside a larger system. It is also the shortest path from a published embedding checkpoint on the Hub to running code, because it standardizes loading, pooling, prompting, and similarity so you do not reimplement them per model. And it is the standard place to train or fine-tune an embedding model on your domain, which is often the single highest-leverage change you can make to a retrieval system.
The honest cases for alternatives. If you want a hosted embedding API
and never want to run a model, the closed offerings from OpenAI,
Cohere, Voyage, and Google are a call away, at the cost of sending
your text out and paying per token. If you want the current top of
the public leaderboards, several strong open families such as BGE
from BAAI, GTE from Alibaba, E5 from Microsoft, Nomic, and the Qwen
embedding models often lead the
Massive Text Embedding Benchmark, and
the good news is that most of them ship in the sentence-transformers
format and load with the same SentenceTransformer("...")
line, so the library is usually the runtime even when it is not the
model. If you only need lexical matching, BM25 in
Lucene is cheaper, needs no model, and is a
strong baseline that hybrid systems still keep alongside embeddings.
And if you are building the vector index itself rather than the
embeddings, FAISS and the vector databases
are complements to this library, not competitors, one produces the
vectors and the other stores and searches them.
The shape-of-the-problem warning is about bi-encoders versus cross-encoders, because choosing wrong is the classic mistake in this domain. They sit at opposite ends of an accuracy-versus-scalability tradeoff, and a retrieval system usually wants both, in sequence.
bi-encoder cross-encoder encode(query) -> vector model(query, doc) -> one score encode(doc) -> vector no reusable vector, reads the pair compare with cosine / ANN must run once per (query, doc) pair cheap, precomputable, scalable accurate, order-sensitive, expensive use to search millions use to rerank the top ~100 right pipeline: bi-encoder retrieves top-k -> cross-encoder reranks them -> answer
Reaching for a cross-encoder as your search engine is the NFS-mounted-SQLite of retrieval, it works on the demo and melts on the corpus. Reaching for a bare bi-encoder when precision at the top matters leaves easy accuracy on the table. The retrieve-then-rerank pattern is not a compromise, it is the intended architecture, and this one library gives you both halves.
Part IV: The full life of one encode() call
The specimen. A single call,
model.encode(["A feline rests on the rug."]), on a model
loaded from all-MiniLM-L6-v2. Most of what follows runs
identically for a batch of a million texts, only the batching loop
repeats.
Stage 1: loading assembles a module pipeline
Before any encoding, SentenceTransformer("all-MiniLM-L6-v2")
resolves that name on the Hub, downloads the model folder, and reads
modules.json, a small ordered list that names each stage
and the subfolder holding its weights and config. For this model the
list is a Transformer then a Pooling then a
Normalize. Each entry is imported by its class path and
constructed from its folder, and the result is stored as an
nn.Sequential. This is the load-bearing structural
idea, a SentenceTransformer is not one model but a short pipeline of
modules, so the embedding recipe is data on disk rather than code, and
you can inspect it, rebuild it, or swap a stage. The class lives
in sentence_transformers/SentenceTransformer.py and the
stages in sentence_transformers/models/.
Stage 2: encode sorts, batches, and tokenizes
encode() is the front door and it does more housekeeping
than people expect. It normalizes the input to a list, optionally
prepends a configured prompt, then sorts the texts by length. Sorting
groups similar lengths into the same batch so that padding is
minimal, which is a real speedup on mixed-length corpora, and it
remembers the permutation to undo it at the very end. For each batch,
it calls the pipeline's tokenize, which delegates to the
first module. The Transformer module runs the Hugging
Face tokenizer with padding and truncation to max_seq_length,
256 tokens for this model, and returns a features dict with
input_ids and attention_mask. That
attention mask, one for real tokens and zero for padding, is the
quiet hero of the next stage.
Stage 3: the Transformer produces token embeddings
The features dict flows into the pipeline's forward, which simply
calls each module in turn, letting every module read the dict and add
its own keys. The Transformer module wraps an
AutoModel and runs it, producing the last hidden state, a
tensor of shape (batch, sequence, hidden), and stores it under the key
token_embeddings, alongside the pooled CLS vector under
cls_token_embeddings. At this point every token has its
own contextual vector, but there is still one vector per token, not
one per sentence, and the padding positions carry meaningless values
that must not leak into the average.
Stage 4: Pooling collapses tokens into one vector
The Pooling module reads token_embeddings
and attention_mask and reduces the sequence axis to a
single vector. In the default and recommended mean mode it computes an
attention-masked average, sum the token vectors where the mask is one,
divide by the number of real tokens, and never let padding contribute.
It writes the result under sentence_embedding. This is the
step that turns a per-token model into a sentence model, and the
module can also do CLS pooling, max pooling, mean-of-square-root-length,
or a concatenation of several modes, but mean is the default for good
reason, examined in the deep dive below.
Stage 5: Normalize, then unsort and convert
For this model a Normalize module follows and divides the
vector by its L2 norm, placing every embedding on the unit sphere so
that dot product equals cosine similarity and downstream indexes can
use the cheaper inner product. Back in encode(), the batch
of sentence_embedding vectors is collected, the
length-sort permutation from Stage 2 is inverted so outputs line up
with the caller's inputs, and the result is converted to the requested
type, a numpy array by default, a torch tensor with
convert_to_tensor=True. That closes the loop of one encode
call, text in, a fixed-size vector out, ready to compare against any
other vector this model made.
The reranking path is worth naming as a contrast. A
CrossEncoder.predict call on a list of (query, document)
pairs concatenates each pair into one sequence, runs the transformer
once per pair, and reads a single score off a classification head.
There is no sentence_embedding key and no reusable vector,
which is exactly why it is accurate and exactly why it cannot scale.
Part V: Internals deep dives
Deep dive: the module pipeline
The reason the library composes so cleanly is that a
SentenceTransformer is an nn.Sequential of
interchangeable modules, each a small nn.Module that
takes a features dict and returns a features dict. The common stages
all live in sentence_transformers/models/. The
Transformer wraps any Hugging Face encoder and emits
token embeddings. Pooling reduces tokens to one vector.
Normalize L2-normalizes. Dense is an optional
linear projection with activation, used to change the output dimension
or to add a small trained head. Because the contract between stages is
just a dict, you can assemble a model by hand instead of downloading
one.
from sentence_transformers import SentenceTransformer, models
backbone = models.Transformer("bert-base-uncased", max_seq_length=256)
pooling = models.Pooling(
backbone.get_word_embedding_dimension(),
pooling_mode="mean",
)
model = SentenceTransformer(modules=[backbone, pooling])
# an untrained sentence embedder; it is contrastive training that makes it good
When you save a model, each stage writes its own subfolder and the
ordered modules.json records the sequence, so loading is
just replaying that list. Making the embedding recipe a small
declarative pipeline, rather than a bespoke class per model, is why a
checkpoint from BGE, GTE, E5, or your own fine-tune all load with the
identical one-liner, the differences are weights and config, not
code. The same abstraction is how the library added CLIP support,
a CLIPModel module encodes both text and images into one
shared space, which is the bridge to the ideas in the
multimodal foundation models
class, and in v5 how it added sparse encoders that emit high-dimensional
lexical vectors instead of dense ones.
Deep dive: mean pooling and why it beats CLS
The pooling step looks trivial and is quietly essential. The whole computation, stripped to its core, is an attention-masked average.
# Pooling in mean mode, in essence
token_emb = features["token_embeddings"] # (batch, seq, dim)
mask = features["attention_mask"].unsqueeze(-1).float() # (batch, seq, 1)
summed = (token_emb * mask).sum(dim=1) # ignore padding tokens
counts = mask.sum(dim=1).clamp(min=1e-9) # real-token count, safe
sentence_embedding = summed / counts # mean over real tokensThe multiply by the mask before summing is what keeps padding out of the average, and the clamp keeps an all-padding edge case from dividing by zero. The interesting question is why average the tokens at all rather than take BERT's CLS vector, which was designed to summarize the sequence. The Sentence-BERT paper tested both and found that for producing a similarity-friendly sentence embedding, mean pooling consistently beat CLS pooling and max pooling. The intuition is that CLS was optimized during pretraining for the next-sentence and fine-tuning classification objectives, not to place semantically similar sentences near each other, whereas the mean of contextual token vectors is a stable, order-robust summary that fine-tuning can shape into a good metric space. The load-bearing lesson is that a good sentence embedding is not read off a special token, it is pooled from all of them and then trained, and mean pooling is the default the evidence supports. This does not make CLS wrong everywhere, some models are trained to use it and their configs say so, which is exactly why the pooling mode lives in the model's own config rather than being hardcoded.
Deep dive: contrastive training and MultipleNegativesRankingLoss
Pooling gives you a vector, but a random backbone's vectors are not a
useful metric space. What makes them useful is contrastive training,
and the dominant loss is
MultipleNegativesRankingLoss, often abbreviated MNRL and
equivalent to the
InfoNCE objective
used across representation learning.
It lives in sentence_transformers/losses/. The setup is
beautifully cheap. You need only pairs of texts that belong together,
a question and its answer, a query and its relevant passage, a sentence
and its paraphrase. You never have to label negatives, because the loss
manufactures them from the batch.
# MultipleNegativesRankingLoss, in essence
# a: (batch, dim) anchor embeddings, p: (batch, dim) positive embeddings
scores = util.cos_sim(a, p) * scale # (batch, batch), scale default 20.0
labels = torch.arange(len(scores)) # for row i, the positive is column i
loss = cross_entropy(scores, labels) # every off-diagonal p_j is a negative
Read the score matrix row by row. Row i holds the
similarity of anchor i to every positive in the batch. The
true match sits on the diagonal, and every other entry is some other
example's positive, treated as a negative for this anchor. Cross-entropy
with the diagonal as the label pushes each anchor toward its own
positive and away from all the others at once. The in-batch trick
is the whole efficiency story, a batch of N pairs yields N positives and
N times N minus one negatives for free, so bigger batches mean more and
harder negatives and better embeddings, which is why embedding training
is unusually batch-size hungry. The scale of 20 is a
temperature in disguise, it multiplies cosine similarities that live in
the range from minus one to one up to logits sharp enough for
cross-entropy to separate, an effective temperature near 0.05.
Two refinements matter in practice. First, you can hand the loss
explicit hard negatives by giving it triples of anchor, positive, and
one or more mined negatives, and those negatives join the in-batch pool,
which sharpens the model on the confusable cases retrieval actually
fails on. The library ships a mine_hard_negatives utility
that uses an existing embedder to find plausible-but-wrong passages to
attach to each positive. Second, because the loss loves large batches
but GPUs have finite memory, CachedMultipleNegativesRankingLoss
implements the GradCache technique, it splits the batch into
sub-batches for the forward and backward while still computing the loss
as if the whole batch were in memory at once, which buys effective batch
sizes in the thousands on a single GPU. MNRL is not the only loss.
CosineSimilarityLoss regresses toward graded similarity
labels for STS-style data, TripletLoss and
ContrastiveLoss predate the in-batch trick,
SoftmaxLoss is the original SBERT NLI head that classifies
over the concatenation of the two embeddings and their difference, and
MarginMSELoss distills a cross-encoder's scores into a
bi-encoder. The rule of thumb the docs give is simple, if your data is
positive pairs, reach for MNRL first.
Deep dive: the SentenceTransformerTrainer
Training used to be a bespoke model.fit(...) loop. Version
3 rebuilt it on top of the Hugging Face Trainer and the
datasets library, which brought along everything that
ecosystem already had, multi-GPU and distributed training, mixed
precision, gradient checkpointing, logging integrations, and callbacks,
for free. The modern shape is a model, a dataset, a loss, and arguments.
from datasets import Dataset
from sentence_transformers import (
SentenceTransformer,
SentenceTransformerTrainer,
SentenceTransformerTrainingArguments,
losses,
)
model = SentenceTransformer("microsoft/mpnet-base") # a base encoder to fine-tune
train = Dataset.from_dict({
"anchor": ["what is a vector db", "capital of france"],
"positive": ["a store for embeddings", "paris is the capital of france"],
})
loss = losses.MultipleNegativesRankingLoss(model) # loss must match the columns
args = SentenceTransformerTrainingArguments(
output_dir="out/my-embedder",
num_train_epochs=1,
per_device_train_batch_size=64, # bigger is better for MNRL
warmup_ratio=0.1,
fp16=True,
)
trainer = SentenceTransformerTrainer(
model=model, args=args, train_dataset=train, loss=loss,
)
trainer.train()
The subtle contract is that the loss and the dataset columns must
agree, and the library matches columns by position, not by name. MNRL
wants the first column to be the anchor and the second the positive,
with an optional third for a hard negative, and there must be no label
column. A loss like CosineSimilarityLoss instead wants two
text columns and a numeric label. Pick the loss for the
data you have, arrange the columns to match, and the trainer does the
rest. You can pass a dict of datasets to train on several tasks at once,
and an evaluator to measure progress, the
sentence_transformers/evaluation/ package supplies an
EmbeddingSimilarityEvaluator for STS correlation and an
InformationRetrievalEvaluator that reports the retrieval
metrics that actually matter for search, NDCG, MRR, and recall at k. The
older model.fit still exists and now calls through to this
trainer, so old tutorials keep working while the new API is what to
learn. The theory behind all of this, from token embeddings through
contrastive objectives, is the material of the
NLP with deep learning class.
Deep dive: cross-encoders and retrieve-then-rerank
The CrossEncoder lives in
sentence_transformers/cross_encoder/ and is a different
animal from the bi-encoder, even though it wraps the same kind of
transformer. It takes a text pair, joins them into a single sequence
with a separator, runs the model once, and reads one number off a
classification head, a relevance score for a (query, document) pair.
Because the two texts attend to each other from the first layer, it
catches interactions a bi-encoder's independent encoding cannot, which
is why it reranks so well. The cost is that there is nothing to
precompute, every pair is a fresh forward pass.
So the two models are used together. The bi-encoder is the coarse,
scalable filter that turns a huge corpus into a short candidate list,
and the cross-encoder is the precise, expensive judge that reorders that
short list. A typical pipeline retrieves the top hundred by embedding
similarity, reranks them with a cross-encoder, and keeps the top five.
The division of labor is the point, the bi-encoder makes retrieval
possible at all, the cross-encoder makes the top of the list correct,
and neither replaces the other. This same two-stage shape is the
retrieval half of RAG, embeddings and a reranker find the passages, and
a generator such as a model served by vLLM writes
the answer over them. The cross-encoder training path was itself
rewritten in v4 to match the new trainer, with a
CrossEncoderTrainer, so if you are fine-tuning a reranker
the modern API mirrors the bi-encoder one.
Deep dive: Matryoshka embeddings and quantization
Two features address the cost of storing millions of vectors.
Matryoshka representation learning
trains an embedding so that its first
coordinates are already a good embedding on their own, meaning you can
truncate a 768-dim vector to 128 dims and keep most of the quality. In
the library this is MatryoshkaLoss, a wrapper you place
around a base loss like MNRL that evaluates the objective at several
truncation lengths at once, so one trained model serves many dimension
budgets. Separately, quantize_embeddings compresses the
stored vectors after training, to int8 for roughly a quarter of the
memory or to binary for a thirty-second of it, which lets a vector index
hold far more documents with a small, often recoverable, hit to recall.
The two compose, a Matryoshka model truncated to a modest dimension and
then binary-quantized is a dramatic reduction in index size, and the
retrieve-then-rerank pattern hides the accuracy cost because the exact
cross-encoder cleans up the top of the list anyway. Both are recent and
still evolving, so treat the exact knobs as things to check against the
current docs at sbert.net.
Part VI: Reading the repository
The sentence_transformers package is small enough to read
in a sitting, which is much of its teaching value. Paths are given by
role since the tree does shift between major versions.
Stage 0, orientation. Read the top-level
README.md and the quickstart on sbert.net, then open
sentence_transformers/SentenceTransformer.py and find two
methods, encode and forward. Questions to hold,
what does encode do besides call the model, why does it sort
by length, and where does the pipeline of modules come from.
Stage 1, the module pipeline. Read
models/Transformer.py, then models/Pooling.py,
then models/Normalize.py and models/Dense.py.
Questions, what keys does each module add to the features dict, how does
mean pooling use the attention mask, and how does modules.json
let a saved model reconstruct itself.
Stage 2, the losses. Read
losses/MultipleNegativesRankingLoss.py first and trace the
score matrix and the diagonal labels, then skim
losses/CosineSimilarityLoss.py,
losses/SoftmaxLoss.py, and
losses/CachedMultipleNegativesRankingLoss.py. Questions, why
are off-diagonal entries negatives, what is the scale for, and how does
the cached variant get a huge effective batch out of a small GPU.
Stage 3, training. Read trainer.py and
training_args.py to see the thin layer over the Hugging Face
Trainer, then the evaluation/ package,
especially InformationRetrievalEvaluator. Questions, how is
a loss attached to a dataset, why does column order matter, and what
metrics does the retrieval evaluator compute.
Stage 4, search and cross-encoders. Read
util.py for cos_sim, dot_score,
semantic_search, mine_hard_negatives, and
community_detection, then
cross_encoder/CrossEncoder.py for predict and
rank. Questions, how does semantic search chunk the corpus
to bound memory, and where exactly does the cross-encoder differ from the
bi-encoder in the forward pass.
Stage 5, the frontier. The sparse encoders added in v5, the ONNX and OpenVINO backends for faster inference, the quantization helpers, and the CLIP and image support. These are the parts most likely to have moved, so read them last and against the changelog.
Where not to start. Do not begin with the training internals or the backend and quantization code, they assume you already picture the module pipeline and the encode path. And do not read the losses folder top to bottom, it is a museum of a fast-moving field, start with MNRL and branch out only when a specific data shape sends you there.
Part VII: Hands-on labs
Every lab here runs on CPU. A GPU only makes them faster. Log and metric details vary with the version.
Lab 1: encode and compare. Concept: embeddings as geometry.
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("all-MiniLM-L6-v2")
e = m.encode(["a dog runs", "a canine sprints", "the stock market fell"])
print(m.similarity(e, e).round(3))
Observe that rows 0 and 1 are close and row 2 is far, and that the shape
is (3, 384). Now change the model to all-mpnet-base-v2 and
watch the dimension become 768 and the separations sharpen. The point is
that meaning became a distance you can measure.
Lab 2: build semantic search by hand. Concept: embed once, query many.
from sentence_transformers import SentenceTransformer, util
m = SentenceTransformer("all-MiniLM-L6-v2")
corpus = ["python is a language", "the eiffel tower is in paris",
"pandas analyzes data", "france's capital draws tourists"]
c = m.encode(corpus, convert_to_tensor=True)
q = m.encode("where is the eiffel tower", convert_to_tensor=True)
for h in util.semantic_search(q, c, top_k=2)[0]:
print(round(h["score"], 3), corpus[h["corpus_id"]])Observe that the tower and the capital sentences win even though the query shares few words with the second one, which is the difference between semantic and lexical match. Time embedding the corpus once versus re-encoding per query to feel why the bi-encoder scales.
Lab 3: add a reranker. Concept: retrieve-then-rerank.
from sentence_transformers import CrossEncoder
r = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
q = "how do I reset my password"
docs = ["click the reset link we email you",
"our office opens at nine",
"go to settings, choose forgot password"]
for x in r.rank(q, docs, top_k=3):
print(round(x["score"], 3), docs[x["corpus_id"]])
Observe the two password sentences rise above the office-hours line, and
note that this only makes sense on a short candidate list. Try imagining
the cost if docs held a million entries, that thought
experiment is the entire argument for the bi-encoder in front.
Lab 4: watch mean pooling ignore padding. Concept: the attention mask.
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("all-MiniLM-L6-v2")
# same sentence alone and in a batch with a much longer one
a = m.encode("a short sentence")
b = m.encode(["a short sentence", "a considerably longer sentence with more tokens"])[0]
import numpy as np
print(np.allclose(a, b, atol=1e-5)) # True: padding did not change the meanObserve that the short sentence's embedding is unchanged whether it is encoded alone or padded up to the length of a longer batch-mate. If mean pooling counted padding tokens this would fail, and the masked average is exactly what keeps it true.
Lab 5: fine-tune with MNRL on a toy dataset. Concept: contrastive training.
from datasets import Dataset
from sentence_transformers import (SentenceTransformer, SentenceTransformerTrainer,
SentenceTransformerTrainingArguments, losses)
model = SentenceTransformer("all-MiniLM-L6-v2")
data = Dataset.from_dict({
"anchor": ["reset password", "cancel subscription", "download invoice"],
"positive": ["how to change my password", "end my monthly plan", "get a copy of my bill"],
})
loss = losses.MultipleNegativesRankingLoss(model)
args = SentenceTransformerTrainingArguments(output_dir="out/toy",
num_train_epochs=5,
per_device_train_batch_size=3)
SentenceTransformerTrainer(model=model, args=args, train_dataset=data, loss=loss).train()Observe the loss fall over the epochs even on three pairs, then re-encode the anchors and their positives and check that each anchor's nearest positive is its own. Now swap the two dataset columns and watch the loss still work but the semantics blur, a hands-on reminder that column order is the contract MNRL reads.
Lab 6: quantize and measure. Concept: cheaper vectors.
from sentence_transformers import SentenceTransformer
from sentence_transformers.quantization import quantize_embeddings
m = SentenceTransformer("all-MiniLM-L6-v2")
e = m.encode(["a dog runs", "a canine sprints"]) # float32, 384 dims
q = quantize_embeddings(e, precision="int8")
print(e.dtype, e.nbytes, "->", q.dtype, q.nbytes) # ~4x smaller
Observe the byte count drop by about four for int8 and compare the
similarity ranking before and after to see how little order changes. Try
precision="binary" for the extreme case and feel the recall
tradeoff that a reranker is meant to absorb.
Part VIII: Questions and model answers
Understanding checks. Answer aloud before reading.
1. What is sentence-transformers, in one sentence?
A library that turns a transformer into a bi-encoder, a function from a single text to one fixed-size dense vector whose geometry encodes meaning, so similarity becomes a cheap dot product over precomputed vectors and semantic search over millions of documents becomes practical.
2. What is the difference between a bi-encoder and a cross-encoder?
A bi-encoder encodes each text independently into a reusable vector and compares vectors, which is cheap and scalable. A cross-encoder reads a pair of texts together and outputs one score, which is more accurate because the texts interact, but has no reusable representation so it must run once per pair and cannot scale. You retrieve with the first and rerank with the second.
3. Why mean pooling instead of the CLS token?
The Sentence-BERT paper found mean pooling over token embeddings produces better similarity spaces than the CLS vector or max pooling. The CLS token was optimized for pretraining and classification objectives, not to place similar sentences nearby, whereas the attention-masked mean is a stable summary that contrastive fine-tuning can shape into a good metric.
4. Why does the attention mask matter in pooling?
Padding tokens carry meaningless vectors, and an unmasked average would let the amount of padding change the embedding. Multiplying by the mask before summing and dividing by the count of real tokens makes the embedding depend only on content, so the same sentence embeds identically alone or padded inside a longer batch.
5. How does MultipleNegativesRankingLoss get negatives for free?
It uses in-batch negatives. For a batch of anchor-positive pairs it builds the full similarity matrix between anchors and positives, treats the diagonal as the correct match, and treats every other example's positive as a negative, then applies cross-entropy per row. A batch of N pairs yields N positives and N times N minus one negatives with no negative labeling required.
6. Why is embedding training so sensitive to batch size?
Because in-batch negatives come from the batch, a larger batch supplies more and harder negatives, which directly improves the learned space. That is why practitioners push batch size high and why CachedMultipleNegativesRankingLoss exists, it uses GradCache to reach effective batch sizes in the thousands without the memory a naive forward would need.
7. What is the scale parameter in MNRL doing?
It is a temperature. Cosine similarities live between minus one and one, which is too flat for cross-entropy to separate, so multiplying by a scale near 20 sharpens them into usable logits, equivalent to a softmax temperature around 0.05.
8. What is in a saved SentenceTransformer, and how does it reload?
A folder with one subfolder per module and a modules.json
listing the ordered stages by class and path. Loading reads that list and
reconstructs the nn.Sequential, which is why a checkpoint from
almost any embedding family loads with the same one-line constructor, the
recipe is data on disk, not code.
9. How does encode() decide the order of its output?
It sorts the inputs by length so batches pad minimally, encodes them in that order, then inverts the permutation so the returned vectors line up with the caller's original order. The sorting is a transparent optimization the caller never sees.
10. When would you not use this library?
When you want a fully hosted embedding API and never to run a model, when plain BM25 lexical search already meets your needs, or when your task is building the vector index rather than the vectors, where FAISS or a vector database is the tool. Even then the library is often still the runtime, because most leading open models ship in its format.
11. How do bi-encoders and cross-encoders combine in RAG?
The bi-encoder embeds the corpus once and retrieves a top-k candidate set for the query by vector similarity, an approximate index like FAISS makes this scale, then the cross-encoder reranks that small set for precision, and the surviving passages are fed to a generator to write the answer. It is the retrieve-then-rerank-then-generate pipeline.
12. Why did training move onto the Hugging Face Trainer in v3?
To inherit a mature training stack, distributed and multi-GPU training, mixed precision, gradient checkpointing, logging, and callbacks, instead of maintaining a custom loop. The library adds the pieces specific to embeddings, the loss-to-dataset contract and the retrieval evaluators, and leans on the shared machinery for everything else.
13. What does column order have to do with correctness in training?
The trainer matches dataset columns to the loss by position, not by name, so the first column is the anchor and the second the positive for MNRL. Getting the order wrong trains on the wrong relationship, which is a silent bug because the loss still decreases. The loss you choose and the columns you provide are one coupled decision.
14. What do Matryoshka embeddings and quantization buy you?
Both cut the cost of storing and searching many vectors. Matryoshka training makes the leading coordinates a usable embedding on their own so you can truncate the dimension, and quantization shrinks each stored value to int8 or binary. They compose, and a downstream cross-encoder rerank absorbs much of the small recall cost.
Part IX: Design lessons
Turn comparison into a lookup by precomputing a representation. The bi-encoder's entire value is that it moves the expensive part, running the model, off the query path and onto an offline embedding step, so querying is arithmetic over cached vectors. The same instinct underlies build indexes not full scans, materialized views, and content-addressed caches, precompute the reusable thing and the hot path gets cheap.
Make the model a small declarative pipeline. Representing
an embedder as an ordered list of dict-to-dict modules, recorded in
modules.json, is why one constructor loads a whole ecosystem
of models. Wherever a format is data rather than code, plugins,
middleware chains, shader graphs, the ecosystem composes and the loader
stays trivial.
Get your negatives for free from the batch. In-batch negatives turned embedding training from a labeling problem into a batching problem, and made batch size a first-class quality knob. The broader lesson is that the structure already present in your data, other examples in the same batch, often is the supervision you were about to go collect.
Match the tool to where it sits on the cost curve. The bi-encoder and cross-encoder are not rivals, they are a cheap wide filter and an expensive narrow judge composed in series. Coarse-to-fine shows up everywhere that matters, broad-phase then narrow-phase collision, a bloom filter before a disk read, a cheap heuristic before an exact solver, spend little to shrink the problem, then spend a lot on what remains.
Stand on the ecosystem instead of rebuilding it. Moving training onto the Hugging Face Trainer and datasets, and loading models straight from the Hub, let the library keep its own surface small and its focus on the embedding-specific parts. Knowing what not to own is a design decision, and it is why this library stayed legible while the field around it exploded.
Part X: Memorization framework
The one-sentence summary. sentence-transformers pools a transformer's token embeddings into one vector with an attention-masked mean, trains that vector with in-batch contrastive loss so similar texts land near each other, and serves the result as a bi-encoder for precompute-and-search, backed by a cross-encoder that reranks the top of the list.
encode(text) -> Transformer (HF AutoModel) -> token embeddings -> Pooling (masked mean) -> one sentence vector -> Normalize (optional) -> unit sphere -> compare: util.semantic_search / FAISS / vector DB -> CrossEncoder rerank (optional) -> final order
The pieces mapped to the package:
model sentence_transformers/SentenceTransformer.py (encode, forward) modules sentence_transformers/models/ (Transformer, Pooling, Normalize, Dense) losses sentence_transformers/losses/ (MultipleNegativesRankingLoss, ...) training sentence_transformers/trainer.py + training_args.py (HF Trainer) evaluation sentence_transformers/evaluation/ (InformationRetrievalEvaluator) search + utils sentence_transformers/util.py (cos_sim, semantic_search, mine_hard_negatives) reranker sentence_transformers/cross_encoder/ (CrossEncoder: predict, rank)
Memorize these blocks:
- Bi-encoder vs cross-encoder: encode each text to a reusable vector and compare, versus read a pair together and score, scalable versus accurate, retrieve then rerank.
- Pooling: attention-masked mean over token embeddings, default and evidence-backed, beats CLS and max for similarity, padding never contributes.
- MNRL: in-batch negatives, diagonal is the positive, cross-entropy per row, scale near 20 is a temperature, bigger batch is better, cached variant for huge effective batches.
- Training: model plus dataset plus loss plus args on the Hugging Face Trainer, columns matched by position, loss chosen to fit the data shape.
- Workhorse models: all-MiniLM-L6-v2 at 384 dims for speed, all-mpnet-base-v2 at 768 dims for quality, multilingual MiniLM for many languages.
Part XI: Papers and further reading
The ideas in this walkthrough come from a small set of papers, and each one rewards a direct read. Where this site derives the same idea in depth, the companion link points there.
- Reimers and Gurevych, Sentence-BERT, Sentence Embeddings using Siamese BERT-Networks, 2019. The paper this library grew out of, the bi-encoder with mean pooling and the 65-hour measurement that motivates everything here. The surrounding theory is the material of the NLP with deep learning class on this site.
- Devlin et al., BERT, Pre-training of Deep Bidirectional Transformers for Language Understanding, 2018. The pretrained encoder the Transformer module wraps, covered in the transformers walkthrough.
- Henderson et al., Efficient Natural Language Response Suggestion for Smart Reply, 2017. The dot-product response model trained with in-batch negatives, the source the library names for MultipleNegativesRankingLoss.
- van den Oord et al., Representation Learning with Contrastive Predictive Coding, 2018. Introduces InfoNCE, the same softmax-over-similarities objective MNRL applies to text pairs.
- Gao et al., Scaling Deep Contrastive Learning Batch Size under Memory Limited Setup, 2021. The GradCache technique behind CachedMultipleNegativesRankingLoss and its huge effective batches.
- Nogueira and Cho, Passage Re-ranking with BERT, 2019. The paper that established the cross-encoder reranker this library packages as CrossEncoder.
- Muennighoff et al., MTEB, Massive Text Embedding Benchmark, 2022. The benchmark embedding models compete on, and the leaderboard to consult before picking one. The task families it spans are surveyed in the natural language understanding class.
- Wang et al., Text Embeddings by Weakly-Supervised Contrastive Pre-training, 2022. The E5 family, contrastive pretraining at scale with the query and passage prefixes Part II warns about. Embeddings like these feed the retrieval pipelines of the LlamaIndex walkthrough.
- Xiao et al., C-Pack, Packed Resources For General Chinese Embeddings, 2023. The BGE models and their training recipe, one of the open families that load with the same one-line constructor.
- Kusupati et al., Matryoshka Representation Learning, 2022. The truncatable embeddings behind MatryoshkaLoss, which shrink the indexes built in the FAISS walkthrough.
- Wang et al., MiniLM, Deep Self-Attention Distillation for Task-Agnostic Compression of Pre-Trained Transformers, 2020. The distilled backbone inside the workhorse all-MiniLM-L6-v2.
- Song et al., MPNet, Masked and Permuted Pre-training for Language Understanding, 2020. The backbone of all-mpnet-base-v2, the quality pick of Part II.
Part XII: Final takeaway
If the pieces underneath this library are the gap, the token embeddings,
attention, and contrastive objectives, the
NLP with deep learning and
multimodal foundation models
classes build them, and the vector-search machinery that consumes its
output is the subject of the FAISS and
Lucene chapters. Then come back and read
encode and MultipleNegativesRankingLoss once
more, they will read like plain PyTorch, which is the entire point.