Sentence Transformers

sentence-transformers is the library that made text embeddings practical. It grew out of the 2019 Sentence-BERT paper from the UKP Lab at TU Darmstadt, and it answers one question well, how do you turn a transformer that was built to compare a pair of texts into a function that maps a single text to one fixed-size vector, so that meaning becomes geometry and finding related text becomes nearest-neighbor search. This chapter is three things at once, a practical tutorial for encoding text and building semantic search, a walkthrough that follows one encode() call from raw strings down through the module pipeline to a numpy array, and a staged guide to reading the repository, its pooling module, its contrastive losses, and its cross-encoder rerankers. It ends with runnable labs, understanding checks with model answers, and a compact framework for keeping the whole system in your head.

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-transformers

The 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 tokens

The 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 mean

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Gao et al., Scaling Deep Contrastive Learning Batch Size under Memory Limited Setup, 2021. The GradCache technique behind CachedMultipleNegativesRankingLoss and its huge effective batches.
  6. Nogueira and Cho, Passage Re-ranking with BERT, 2019. The paper that established the cross-encoder reranker this library packages as CrossEncoder.
  7. 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.
  8. 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.
  9. 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.
  10. Kusupati et al., Matryoshka Representation Learning, 2022. The truncatable embeddings behind MatryoshkaLoss, which shrink the indexes built in the FAISS walkthrough.
  11. 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.
  12. 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.

Key takeaway: sentence-transformers made text embeddings practical by inverting the cross-encoder, encode each text once into a pooled, contrastively trained vector, and comparison becomes geometry you can precompute and index. Mean pooling turns tokens into a sentence, in-batch contrastive loss turns that sentence into a good point in a metric space, and a cross-encoder reranks the top of the list, which together are the retrieval engine under modern semantic search and RAG.