Dense vs sparse, bi-encoder vs cross-encoder, ColBERT late interaction, Matryoshka embeddings, MTEB/BEIR evals, and every model family you need to know in 2025.
An embedding is a real-valued vector that encodes the semantic position of a token sequence in a high-dimensional space. The key property is metric: similar meanings live close together under cosine similarity or dot product, enabling sub-linear nearest-neighbour search instead of full corpus comparison.
There are two competing traditions:
BM25, SPLADE, TF-IDF. The vector dimensionality equals the vocabulary size (≈30,000–250,000). Most entries are zero. Exact term match. Fast. Fragile to synonyms and paraphrases. SPLADE v2 learns importance weights per term via a BERT-like MLM head, recovering some semantic generalisation.
BERT-derived encoders, collapsed to a single fixed-size vector (typically 256–4096 dims) via CLS-token or mean pooling. No term-overlap required. Captures paraphrases. Slower to build index, faster per-query via ANN. Sensitive to domain shift — the core challenge this deck addresses.
The pooling choice is load-bearing. CLS pooling works well when the encoder was pre-trained with a CLS objective (vanilla BERT). Mean pooling outperforms CLS on most retrieval benchmarks when the model is fine-tuned with a contrastive objective (sentence-transformers default since 2021). Weighted mean (by token importance) is used in some SPLADE-family hybrids.
The most important architectural fork. Nearly every production RAG pipeline uses both: bi-encoder for retrieval, cross-encoder for reranking.
| Property | Bi-Encoder | Cross-Encoder |
|---|---|---|
| Input | Query and document encoded separately | Query and document concatenated with [SEP]; encoded jointly |
| Score | Cosine / dot-product of two fixed vectors | Single logit from classification head |
| Index-time work | Pre-compute document embeddings offline | None — reranker can't pre-compute |
| Query-time cost | One encoder pass + ANN lookup: ~5–20 ms | N forward passes over top-k docs: ~50–500 ms for k=100 |
| Quality ceiling | Lower: no cross-attention between Q and D | Higher: full attention sees both Q and D tokens |
| Typical use | First-stage retrieval (recall@k) | Second-stage reranking (precision@k) |
| Example models | BGE-M3, E5-large-v2, text-embedding-3-large | BGE-reranker-v2-m3, Cohere Rerank 3.5, mxbai-rerank-large-v1 |
Many production bi-encoders use asymmetric encoding: query and document use different prompt prefixes (e.g. query: ... vs passage: ... for E5 models). This is not cosmetic — the encoder's representation space is shaped differently for short questions vs long passages. Forgetting the prefix prefix is one of the most common production bugs: NDCG@10 can drop 3–8 points silently.
As of early 2025, the landscape has consolidated around a handful of open and proprietary families. Knowing the lineage matters: a model trained with contrastive InfoNCE loss behaves very differently from one trained with MNRL (Multiple Negatives Ranking Loss).
BGE-M3 is trained with a hybrid objective that produces three retrieval modes from a single model: dense embedding, sparse (lexical weight) output, and ColBERT-style multi-vector output. The BGEM3FlagModel Python class exposes all three simultaneously; each adds latency but raises recall. For most English-only production use, dense-only BGE-M3 at 1024 dims is the default choice.
ColBERT (Contextualised Late Interaction over BERT, Khattab & Zaharia 2020) occupies a middle ground: it is cheaper than a cross-encoder and more expressive than a bi-encoder. The insight is to defer the query–document interaction until after both sequences are encoded, but to keep per-token vectors rather than collapsing to a single pool.
The MaxSim operator: for each query token vector q𝑖, find the maximum dot-product over all document token vectors dⱼ. Sum the per-token maxima. This yields a score that respects the term-level alignment between query and document without requiring all tokens to interact in a single attention matrix.
import numpy as np
def maxsim_score(Q: np.ndarray, D: np.ndarray) -> float:
# Q: [n_q, 128] D: [n_d, 128], both L2-normalised
scores = Q @ D.T # [n_q, n_d]
return float(scores.max(axis=1).sum())
# Index storage: one 128-dim vector per token per document
# ColBERTv2 + PLAID compresses to ~4 bits/dim via residual quantisation
PLAID (Performance-optimised Late Interaction Driver, Santhanam et al. 2022) makes ColBERT practical at >100 M documents. It uses residual vector quantisation (centroids + residuals, ≈4 bits/dim) and a two-stage candidate generation: (1) IVF over centroid cluster assignments to find candidate documents, (2) full MaxSim on GPU over the short-list. On MS MARCO, PLAID achieves MRR@10 of 39.7 with <100 ms end-to-end on a single A100. The RAGatouille library (pip install ragatouille) wraps ColBERTv2 + PLAID for production use.
Matryoshka Representation Learning (MRL, Kusupati et al. 2022) trains a single encoder such that any prefix of the output vector is itself a valid, useful embedding. You can truncate a 768-dim vector to 64 dims and still get reasonable retrieval, with quality scaling smoothly as you increase dimension.
Standard contrastive loss is applied simultaneously at multiple granularities: full dimension D, D/2, D/4, … down to some minimum (e.g. 64). The loss is summed across all resolutions with equal or geometrically-decreasing weights. This forces the model to pack the most important dimensions first.
Deploy one model but run it at different precisions depending on latency budget. Index tier-1 docs at 1536 dims; tier-2 at 256 dims. OpenAI's text-embedding-3-large (3072 d) and text-embedding-3-small (1536 d) both use MRL — the API accepts a dimensions parameter to truncate server-side, saving egress bandwidth and index memory.
| Dimensions | text-embedding-3-large MTEB avg | Relative to 3072d | Storage (1M docs) |
|---|---|---|---|
| 3072 | 64.6 | 100% | 12.3 GB (float32) |
| 1536 | 64.1 | 99.2% | 6.1 GB |
| 256 | 62.0 | 96.0% | 1.0 GB |
| 64 | 55.1 | 85.3% | 256 MB |
nomic-embed-text-v1.5 is a fully open-weights MRL model (Apache 2.0) with 768 dims, trained on a public dataset (nomic-supervised-contrastive v1). It supports matryoshka_dim in the sentence-transformers API: model.encode(texts, matryoshka_dim=128). At 128 dims it beats OpenAI text-embedding-ada-002 on MTEB English while being runnable locally on a single CPU.
Off-the-shelf models trained on MS MARCO or NLI datasets often degrade significantly on specialised domains (legal, biomedical, financial, customer support). Three techniques address this without requiring manual annotation at scale.
Transformation-based Sequential Denoising Auto-Encoder (Wang et al. 2021). Corrupt input sentences (delete 60% of tokens), encode, reconstruct from the CLS embedding. No labels needed — just raw domain text. 10–20k sentences, ~1 GPU-hour on A100. BEIR domain-transfer gains: +2–5 NDCG@10 on out-of-domain tasks compared to fine-tuning with weak signals.
Generative Pseudo Labelling (Wang et al. 2021). Use T5-based query generation (BeIR/query-gen-msmarco-t5-base-v1) to synthesise queries for each domain passage. A cross-encoder scores the synthetic (query, passage) pair. The score becomes the label for margin-MSE training on the bi-encoder. GPL closes 70–80% of the gap between zero-shot and supervised fine-tuning with human labels.
The most impactful signal. Mine false-positive documents that score high under BM25 or the current bi-encoder but are semantically wrong (e.g. same keyword, different meaning). Training with hard negatives prevents the model from relying on lexical overlap. AugSBERT uses cross-encoder scores to identify hard negatives. Library: sentence-transformers >= 2.2 has MultipleNegativesRankingLoss with mine support.
from sentence_transformers import SentenceTransformer, losses
from sentence_transformers.datasets import NoDuplicatesDataLoader
model = SentenceTransformer("BAAI/bge-base-en-v1.5")
# GPL-generated triplets: (query, positive_passage, negative_passage)
train_loss = losses.MultipleNegativesRankingLoss(model)
# warm-up 10 % of steps, then cosine LR decay
model.fit(
train_objectives=[(dataloader, train_loss)],
epochs=1,
warmup_steps=100,
output_path="./domain-adapted-bge",
)
Never trust a model ranking from a provider's blog post. Understand what each benchmark measures before picking a model for production.
| Benchmark | Tasks | Primary Metric | Coverage |
|---|---|---|---|
| MTEB v2 (2024) | 7 task types: Retrieval, Reranking, STS, Clustering, Classification, Summarisation, BitextMining | Average over all tasks; retrieval uses NDCG@10 | 129 datasets, 113 languages; ~100 models on the leaderboard |
| BEIR (Thakur et al. 2021) | 18 retrieval datasets: MSMARCO, TREC-COVID, NFCorpus, Scifact, HotpotQA, FiQA… | NDCG@10 on each dataset separately | English; out-of-domain generalisation focus |
| MIRACL | Multilingual information retrieval; 18 languages | NDCG@10 | Evaluates multilingual bi-encoders |
| In-domain eval | Your own queries × your own corpus | Recall@k, MRR@k, manual review | Ground truth: generate from annotators or use LLM-as-judge |
A model that ranks #1 on MTEB Retrieval English may be mediocre on your internal helpdesk corpus because MTEB's retrieval tasks are predominantly Wikipedia-style questions. Always measure in-domain Recall@100 (the input to your reranker) before committing to a model. The fastest way: embed 500–1000 manually labelled QA pairs from your domain and compute recall across candidate models in an afternoon.
Embedding infrastructure is often the largest ongoing compute cost in a RAG system. The decisions compound: higher dimension → larger index → slower ANN → more RAM → higher hosting cost.
| Model | Dims | Params | GPU throughput* | CPU throughput* | 1M-doc index (float32) |
|---|---|---|---|---|---|
| all-MiniLM-L6-v2 | 384 | 22 M | ~14 k tokens/s (T4) | ~2 k tokens/s | 1.5 GB |
| BGE-base-en-v1.5 | 768 | 109 M | ~9 k tokens/s (T4) | ~700 tokens/s | 3.1 GB |
| BGE-large-en-v1.5 | 1024 | 335 M | ~5 k tokens/s (A10) | ~200 tokens/s | 4.1 GB |
| text-embedding-3-large (API) | 3072 | — | API-limited | — | 12.3 GB |
| GTE-Qwen2-7B-instruct | 3584 | 7 B | ~1.5 k tokens/s (A100) | impractical | 14.3 GB |
* batch size 64, max 512 tokens, fp16 where supported. Approximate; varies with sequence length.
INT8 quantisation of the encoder (via optimum + ONNX Runtime) typically halves memory and increases throughput 1.5–2× with < 0.5% NDCG@10 regression on BEIR for models ≥ 100 M params. The sentence-transformers SentenceTransformer constructor now accepts model_kwargs={"torch_dtype": torch.float16} for fp16 deployment. Never store vectors in float64 — float32 is the standard index precision; float16 is acceptable for cosine similarity with modern FAISS builds.
Deck 02 covers vector databases — HNSW vs IVF, quantisation, filtered search, and how to choose between pgvector, Qdrant, Weaviate, LanceDB, Pinecone, and Milvus. Deck 03 digs into hybrid search and reranking.