<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Mteb on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/mteb/</link><description>Recent content in Mteb on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Wed, 03 Jun 2026 03:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/mteb/index.xml" rel="self" type="application/rss+xml"/><item><title>Embeddings in 2026: the three families (dense, sparse, multi-vector), the model zoo and the decision that matters in production</title><link>https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/</link><pubDate>Wed, 03 Jun 2026 03:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/</guid><description>&lt;blockquote>
&lt;p>This post opens the data sub-saga inside the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> by going into the piece that holds up &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">three-layer retrieval&lt;/a>: &lt;strong>the embedder&lt;/strong>. If the &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">librarian of curation&lt;/a> decided what enters the index and the &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">faculty committee&lt;/a> decided what reaches the model&amp;rsquo;s face, this post looks at the one in the middle: &lt;strong>the cartographer who draws the map you search over&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The conversation about embeddings has been simplified in production to the point where &amp;ldquo;which embedder do you use&amp;rdquo; always gets the same answer: &lt;em>OpenAI text-embedding-3-large&lt;/em> in the demo, &lt;em>bge-m3&lt;/em> in the &amp;ldquo;sovereign-ready&amp;rdquo; version. That simplification hides the fact that an embedder is &lt;strong>three different models at once&lt;/strong>, dense single-vector, learned sparse (SPLADE) and multi-vector late-interaction (ColBERT), and that in 2026 the leading models do not compete within the same family: &lt;code>gte-Qwen2-7B-instruct&lt;/code> and &lt;code>NV-Embed-v2&lt;/code> break MTEB in dense single-vector, &lt;code>SPLADE-v3&lt;/code> and the sparse head of &lt;code>bge-m3&lt;/code> dominate the learned lexical descriptor, &lt;code>Jina-ColBERT-v2&lt;/code> and &lt;code>ColNomic-7B&lt;/code> are the strongest in multilingual multi-vector, and &lt;code>Snowflake Arctic Embed L 2.0&lt;/code> has slipped in as the small multilingual favourite with decent Matryoshka. This post takes apart the three families with their maths (InfoNCE with &lt;code>τ&lt;/code>, MaxSim, SPLADE&amp;rsquo;s FLOPS regularization, Matryoshka Representation Learning), goes through the open source model zoo with the dimension, licence and niche of each one, poses the specific problem of &lt;strong>multilingual Spanish&lt;/strong>, which cuts the list of viable models to fewer than six, gives the real storage cost per million chunks with &lt;code>int8&lt;/code> / binary / TurboQuant, describes how they are served on-premise with &lt;code>TEI&lt;/code>, &lt;code>Infinity&lt;/code> and &lt;code>vLLM --task embed&lt;/code>, sets the minimum hardware at an RTX 4090 and the good hardware at a 4×H100 cluster, lists the seven operational traps that sink quality without warning (corpus drift, forgotten normalisation, badly chosen Matryoshka dimension, missing hard negatives, chat template slipped into the embedder, tokenizer drift, MTEB overfit) and closes with a license-clean stack for sovereign production.&lt;/p>
&lt;h2 id="the-analogy-three-librarians-cataloguing-the-same-book">The analogy: three librarians cataloguing the same book&lt;/h2>
&lt;p>A technical library receives a new book and, before putting it on the shelves, has to generate a searchable identifier for it. Three librarians with three different trades live in the library, and all three catalogue the same book at once:&lt;/p>
&lt;p>&lt;strong>Librarian A, the thematic one.&lt;/strong> He reads the whole book and gives it &lt;em>a single&lt;/em> rich RFID tag. That tag is a vector of 1,024 numbers where each coordinate encodes a latent semantic axis (one the librarian never verbalises: he has learned it by reading a hundred million previous books). Two books about Kubernetes in production will end up with RFID tags very close together in the space even if one talks about Linkerd and the other about Cilium, because they share thematic axes. To search, you compare the question&amp;rsquo;s tag with the book&amp;rsquo;s and return the nearest ones by cosine. It is fast, it scales to millions, and it loses fine nuance. This is the &lt;strong>dense single-vector embedder&lt;/strong>: &lt;code>bge-m3&lt;/code> in dense mode, &lt;code>gte-Qwen2-7B-instruct&lt;/code>, &lt;code>Snowflake Arctic Embed L 2.0&lt;/code>, &lt;code>multilingual-e5-large-instruct&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Librarian B, the lexical one.&lt;/strong> He summarises nothing. What he does is write on a card the &lt;em>weighted list of relevant terms&lt;/em> from the book, &lt;strong>expanded with synonyms from the field&lt;/strong>. The Kubernetes book carries on its card &amp;ldquo;kubernetes 4.2, linkerd 3.8, cilium 3.7, service-mesh 4.1, sidecar 3.2, mtls 2.9, ebpf 2.6, k8s 4.0…&amp;rdquo;, each term with a weight. The trick is that the model does the expansion: if your question says &amp;ldquo;service mesh&amp;rdquo; and the original book only said &amp;ldquo;Linkerd&amp;rdquo;, librarian B&amp;rsquo;s card did record &amp;ldquo;service-mesh 4.1&amp;rdquo; because he understood the relationship. To search, you intersect the question&amp;rsquo;s card with the book&amp;rsquo;s the old-fashioned way: inverted index, posting lists. It is decisive when the reader writes few, very specific words (product names, errors, jargon). This is the &lt;strong>learned sparse embedder&lt;/strong>: &lt;code>SPLADE-v3&lt;/code> or the sparse head of &lt;code>bge-m3&lt;/code>. It is the modern successor to BM25, not its rival; we will see why.&lt;/p>
&lt;p>&lt;strong>Librarian C, the copyist.&lt;/strong> He gives up on summarising. He takes every word on every page of the book and gives it a 128-dimension mini-RFID. He ends up with a 30,000-token book turned into 30,000 mini-RFIDs. When you search for something, the librarian compares &lt;em>every word of your question with every word of the book&lt;/em> and keeps the maximum for each word of the question, adding them up. It captures nuance the other two lose by construction (proper nouns, numbers, specific phrasings), but its filing system is an order of magnitude larger in disk space. This is ColBERT-v2 / Jina-ColBERT-v2 / ColNomic-7B: late interaction, MaxSim.&lt;/p>
&lt;p>And then there is &lt;strong>the all-rounder librarian who does all three jobs in a single pass&lt;/strong>: &lt;code>bge-m3&lt;/code>. A single 568 M parameter model that simultaneously returns the thematic RFID tag (1,024-d), the weighted list of terms (sparse) and the set of per-token mini-RFIDs (128-d). That is why it has settled in as the standard embedder for multilingual on-premise RAG: a single &lt;code>model.encode(chunk)&lt;/code> produces the three outputs that feed &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a> without orchestrating three different models.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The three librarians: dense, sparse and multi-vector">
&lt;style>
.ebox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.ehead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.eden{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.espa{fill:#a8d5a8;stroke:#444;stroke-width:1.4;rx:8}
.emul{fill:#ffb86b;stroke:#444;stroke-width:1.4;rx:8}
.epol{fill:#d8a8ff;stroke:#444;stroke-width:1.4;rx:8}
.eout{fill:#ffe18a;stroke:#444;stroke-width:1.4;rx:8}
.eblt{font:600 13px sans-serif;fill:#222}
.esub{font:400 11px sans-serif;fill:#444}
.earr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#me1)}
.elbl{font:600 11px sans-serif;fill:#555}
&lt;/style>
&lt;defs>&lt;marker id="me1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="20" y="160" width="170" height="60" class="ehead"/>
&lt;text x="105" y="186" text-anchor="middle" class="eblt">Corpus chunk&lt;/text>
&lt;text x="105" y="204" text-anchor="middle" class="esub">"Linkerd 3.8 brings mTLS by default…"&lt;/text>
&lt;rect x="240" y="20" width="190" height="80" class="eden"/>
&lt;text x="335" y="44" text-anchor="middle" class="eblt">Dense (single-vector)&lt;/text>
&lt;text x="335" y="62" text-anchor="middle" class="esub">1 vector × 1024 d (fp16)&lt;/text>
&lt;text x="335" y="78" text-anchor="middle" class="esub">cosine similarity, HNSW&lt;/text>
&lt;text x="335" y="92" text-anchor="middle" class="esub">bge-m3 · gte-Qwen2 · e5&lt;/text>
&lt;rect x="240" y="140" width="190" height="80" class="espa"/>
&lt;text x="335" y="164" text-anchor="middle" class="eblt">Learned sparse&lt;/text>
&lt;text x="335" y="182" text-anchor="middle" class="esub">~80 weighted terms ⊂ vocab 30k&lt;/text>
&lt;text x="335" y="198" text-anchor="middle" class="esub">inverted index, posting lists&lt;/text>
&lt;text x="335" y="212" text-anchor="middle" class="esub">SPLADE-v3 · bge-m3 sparse&lt;/text>
&lt;rect x="240" y="260" width="190" height="80" class="emul"/>
&lt;text x="335" y="284" text-anchor="middle" class="eblt">Multi-vector (late int.)&lt;/text>
&lt;text x="335" y="302" text-anchor="middle" class="esub">N tokens × 128 d (fp16)&lt;/text>
&lt;text x="335" y="318" text-anchor="middle" class="esub">MaxSim per query token&lt;/text>
&lt;text x="335" y="332" text-anchor="middle" class="esub">ColBERT-v2 · Jina-ColBERT-v2&lt;/text>
&lt;rect x="490" y="20" width="280" height="80" class="eout"/>
&lt;text x="630" y="44" text-anchor="middle" class="eblt">Storage / 1 M docs (d=1024, fp16)&lt;/text>
&lt;text x="630" y="62" text-anchor="middle" class="esub">dense fp16: 2 GB · int8: 1 GB · binary: 128 MB&lt;/text>
&lt;text x="630" y="78" text-anchor="middle" class="esub">SPLADE: ~25 MB (posting lists)&lt;/text>
&lt;text x="630" y="94" text-anchor="middle" class="esub">ColBERT fp16 (256 tok×128 d): 64 GB&lt;/text>
&lt;rect x="490" y="140" width="280" height="80" class="epol"/>
&lt;text x="630" y="164" text-anchor="middle" class="eblt">bge-m3 (3-in-1)&lt;/text>
&lt;text x="630" y="182" text-anchor="middle" class="esub">one forward → dense + sparse + colbert&lt;/text>
&lt;text x="630" y="198" text-anchor="middle" class="esub">568 M params · XLM-RoBERTa-large&lt;/text>
&lt;text x="630" y="214" text-anchor="middle" class="esub">100+ languages · 8192 tokens · MIT&lt;/text>
&lt;rect x="490" y="260" width="280" height="80" class="ebox"/>
&lt;text x="630" y="284" text-anchor="middle" class="eblt">The 2026 open source zoo&lt;/text>
&lt;text x="630" y="302" text-anchor="middle" class="esub">jina-v3/v4 · nomic-v2 MoE · Snowflake Arctic L 2.0&lt;/text>
&lt;text x="630" y="318" text-anchor="middle" class="esub">gte-Qwen2-7B · multilingual-e5-large-instruct&lt;/text>
&lt;text x="630" y="334" text-anchor="middle" class="esub">SPLADE-v3 · Jina-ColBERT-v2 · ColNomic-7B&lt;/text>
&lt;path class="earr" d="M190 188 L240 60"/>
&lt;path class="earr" d="M190 190 L240 180"/>
&lt;path class="earr" d="M190 192 L240 300"/>
&lt;/svg>
&lt;p style="text-align:center;font-size:0.9rem;color:#555;margin-top:0.5rem;">The same chunk indexed by three librarians. The all-rounder &lt;code>bge-m3&lt;/code> runs all three in a single 568 M parameter pass.&lt;/p>
&lt;/div>
&lt;h2 id="what-an-embedding-really-is">What an embedding really is&lt;/h2>
&lt;p>A text embedding is a function &lt;code>f : text → ℝᵈ&lt;/code> trained so that two &amp;ldquo;semantically similar&amp;rdquo; texts produce nearby vectors in that space. The important part is not &amp;ldquo;vector&amp;rdquo;, any fixed-length hash is one, but &lt;strong>what closeness means&lt;/strong>. Closeness is defined implicitly by the loss the model is trained with.&lt;/p>
&lt;p>Almost every modern embedder is trained with InfoNCE (also called &lt;em>Multiple-Negatives Ranking Loss&lt;/em> in &lt;code>sentence-transformers&lt;/code>):&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\mathcal{L}_{\text{InfoNCE}} = -\log \frac{\exp(\text{sim}(q, d^+)/\tau)}{\sum_{d \in \mathcal{B}} \exp(\text{sim}(q, d)/\tau)}
&lt;/code>&lt;/pre>&lt;p>For each &lt;code>(query, doc⁺)&lt;/code> pair the model has to assign the positive more similarity than &lt;em>all the other documents in the batch&lt;/em> &lt;code>B&lt;/code>, where the other documents act as free in-batch negatives. The temperature &lt;code>τ&lt;/code> (typically &lt;code>0.02&lt;/code>–&lt;code>0.07&lt;/code>, almost always &lt;code>0.05&lt;/code>) controls how sharp the distribution gets: lower &lt;code>τ&lt;/code> means the model becomes more demanding about the positive but less stable. A large batch size &lt;code>|B|&lt;/code> means many more negatives per gradient, so a better informed model. That is why embedders are trained with &lt;code>batch ≥ 1,024&lt;/code> on H100 clusters with AllGather across nodes to stack every negative in the cluster into a single effective batch.&lt;/p>
&lt;p>On top of the in-batch negatives comes hard negatives mining: documents deliberately picked because they are &amp;ldquo;almost in the answer&amp;rdquo; (typically the next 10-100 neighbours from a previous BM25 / dense retrieval). Without hard negatives the model learns to discriminate the trivial and real quality on BEIR / MTEB drops 5-10 points.&lt;/p>
&lt;p>This matters because &lt;strong>the embedder family depends on what you feed into &lt;code>sim(·,·)&lt;/code>&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>&lt;code>sim(q, d)&lt;/code>&lt;/th>
&lt;th>Model output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dense single-vector&lt;/td>
&lt;td>dot product of two normalised 1024-d vectors&lt;/td>
&lt;td>&lt;code>f(q), f(d) ∈ ℝ^d&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Learned sparse&lt;/td>
&lt;td>dot product of two 30,522-d vectors (≈80 non-zero each)&lt;/td>
&lt;td>&lt;code>f(q), f(d) ∈ ℝ^V&lt;/code>, `V =&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-vector&lt;/td>
&lt;td>&lt;code>Σᵢ maxⱼ ⟨qᵢ, dⱼ⟩&lt;/code> (MaxSim)&lt;/td>
&lt;td>`f(q) ∈ ℝ^{&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The loss is the same in all three cases, InfoNCE, but the geometry of the space changes, and with it quality, storage cost and search latency.&lt;/p>
&lt;h2 id="the-three-families-in-detail">The three families in detail&lt;/h2>
&lt;h3 id="dense-single-vector-the-cartographer">Dense single-vector, the cartographer&lt;/h3>
&lt;p>The embedder reads the full chunk, passes it through an encoder transformer (XLM-RoBERTa, BERT, a Mistral decoder with a prompt) and &lt;strong>aggregates the token representations into a single vector&lt;/strong> by:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>CLS pooling&lt;/strong>: uses the embedding of the &lt;code>[CLS]&lt;/code> token. Standard in BERT-base.&lt;/li>
&lt;li>&lt;strong>Mean pooling&lt;/strong>: plain average of the embeddings of all tokens. Standard in &lt;code>multilingual-e5-large-instruct&lt;/code>, &lt;code>bge-m3&lt;/code> dense, &lt;code>Snowflake Arctic Embed L 2.0&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Last-token pooling&lt;/strong>: for decoder-LLM based embedders (&lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>gte-Qwen2-7B-instruct&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>) that take the final token as the aggregate. It works because the model is causal and the last token &amp;ldquo;has seen&amp;rdquo; the whole context.&lt;/li>
&lt;li>&lt;strong>Latent-attention pooling&lt;/strong>: new in &lt;code>NV-Embed-v2&lt;/code>. A learnable attention layer that weights tokens instead of averaging them. +2-3 MTEB points over mean pooling.&lt;/li>
&lt;/ul>
&lt;p>After pooling the vector is normalised to norm 1: &lt;code>v ← v / ‖v‖₂&lt;/code>. With normalised vectors, &lt;strong>cosine and dot product coincide&lt;/strong>:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\cos(q, d) = \frac{q \cdot d}{\|q\|\|d\|} = q \cdot d, \qquad \|q - d\|^2 = 2 - 2\,(q \cdot d)
&lt;/code>&lt;/pre>&lt;p>That is why almost every vector DB indexes by &lt;em>inner product&lt;/em> and leaves it to the user to normalise beforehand (Qdrant, Faiss IP, Milvus). If you forget to normalise the query vector but the corpus vectors are normalised, &lt;strong>retrieval degrades silently&lt;/strong>: the query&amp;rsquo;s magnitude distorts the ranking. It is the number one bug in RAG in production.&lt;/p>
&lt;p>A minimal numerical example, two normalised 4-d vectors:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">q = [0.5, 0.5, 0.5, 0.5] ‖q‖ = 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">d₁ = [0.6, 0.4, 0.5, 0.5] ‖d₁‖ = 1.005, normalised [0.597, 0.398, 0.498, 0.498]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">q · d₁ = 0.5·0.597 + 0.5·0.398 + 0.5·0.498 + 0.5·0.498 = 0.995
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Closeness of nearly 1, as expected. If &lt;code>d₁&lt;/code> had not been normalised, &lt;code>q · d₁ = 1.0&lt;/code> and it would appear closer than any &lt;code>d&lt;/code> that is perfectly aligned but with a norm &lt;code>&amp;lt; 1.005&lt;/code>. &lt;strong>Normalisation is not a detail: it is the contract of the space.&lt;/strong>&lt;/p>
&lt;h3 id="learned-sparse-the-lexical-descriptor">Learned sparse, the lexical descriptor&lt;/h3>
&lt;p>&lt;code>SPLADE-v3&lt;/code> (Naver, March 2024) has consolidated the modern version of the lexical librarian. Internally it is a small BERT (~110 M parameters, DistilBERT/BERT base) that produces, for each input token, a distribution over the whole vocabulary (30,522 dimensions in BERT WordPiece), and then does &lt;code>max-pool&lt;/code> over the tokens:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">w_j = \max_{i \in \text{seq}} \log\bigl(1 + \text{ReLU}(W_{ij})\bigr)
&lt;/code>&lt;/pre>&lt;p>where &lt;code>Wᵢⱼ&lt;/code> is the logit of input token &lt;code>i&lt;/code> for vocabulary term &lt;code>j&lt;/code>. The &lt;code>log(1+ReLU)&lt;/code> saturates high logits (it stops a single word from dominating the vector) and the &lt;code>ReLU&lt;/code> cuts the negatives. The result is a 30,522-dimension vector of which typically 50-200 entries stay non-zero.&lt;/p>
&lt;p>The key part is the &lt;strong>FLOPS regularization&lt;/strong> added to the loss during training:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\mathcal{L}_{\text{FLOPS}} = \lambda \cdot \sum_{j=1}^{V} \bar{w}_j^2, \qquad \bar{w}_j = \frac{1}{|B|}\sum_{i \in B} w_{ij}
&lt;/code>&lt;/pre>&lt;p>It penalises the expected cost of the posting lists: if a vocabulary word appears on average in many documents, adding it to a new document costs double. The model learns to generate sparse vectors by construction.&lt;/p>
&lt;p>What does this do to the text &amp;ldquo;Linkerd 3.8 brings mTLS by default&amp;rdquo;? The model does not only write the literal terms, it also writes, with a smaller but non-zero weight, &amp;ldquo;service-mesh&amp;rdquo;, &amp;ldquo;kubernetes&amp;rdquo;, &amp;ldquo;tls&amp;rdquo;, &amp;ldquo;sidecar&amp;rdquo;, &amp;ldquo;envoy&amp;rdquo;, &amp;ldquo;istio&amp;rdquo; (its competitor, also semantically related), &amp;ldquo;encryption&amp;rdquo;, &amp;ldquo;k8s&amp;rdquo;. That &lt;strong>semantic expansion of the document is what sets SPLADE apart from BM25&lt;/strong>. BM25 only knows what was literally in the text; SPLADE knows what an expert would add as a descriptor.&lt;/p>
&lt;p>In practice SPLADE-v3 beats BM25 by 3-6 MRR@10 points on MS MARCO and dominates BEIR zero-shot. The cost is ~2-4× the query latency of BM25 over the same inverted index, which static pruning can mitigate.&lt;/p>
&lt;p>For the multilingual case, &lt;strong>&lt;code>bge-m3&lt;/code> with its sparse head is the only maintainable option&lt;/strong>: SPLADE-v3 is trained in English and the multilingual ports are in an experimental state.&lt;/p>
&lt;h3 id="multi-vector-the-copyist">Multi-vector, the copyist&lt;/h3>
&lt;p>&lt;code>ColBERT-v2&lt;/code> (Stanford, NAACL 2022) introduced the &lt;strong>late interaction&lt;/strong> paradigm. Instead of compressing the document to a single vector, it leaves it as a matrix &lt;code>(|d|, k)&lt;/code> with a &lt;code>k&lt;/code>-dimension vector per token. The similarity between query and document is computed token by token and aggregated with MaxSim:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">s(q, d) = \sum_{i \in q} \max_{j \in d} \langle q_i, d_j \rangle
&lt;/code>&lt;/pre>&lt;p>What is being computed: for each word of the query, find the document token that fits it best and add that similarity. The sum is over the query, not over the document. This lets a 30,000-token document compete fairly with a 200-token one, because the query always adds &lt;code>|q|&lt;/code> terms.&lt;/p>
&lt;p>Why does it give more quality than dense single-vector? Because compressing to one vector loses information about where each idea was. If the query is &amp;ldquo;which version brought mTLS by default in Linkerd&amp;rdquo;, the dense summary of the document only knows that the chunk is about &amp;ldquo;Linkerd and mTLS&amp;rdquo;; ColBERT&amp;rsquo;s copyist can match &amp;ldquo;which version&amp;rdquo; with &amp;ldquo;3.8&amp;rdquo; because it stores the embedding of the &lt;code>3.8&lt;/code> token separately. On BEIR / out-of-domain, late interaction beats single-vector by between +2 and +6 nDCG@10 with the same backbone.&lt;/p>
&lt;p>The price is storage. Per document, a 1024-dimension dense single-vector in fp16 takes &lt;code>1024 × 2 = 2 KB&lt;/code>. ColBERT-v2 with 128-dimension tokens for a 256-token chunk takes &lt;code>256 × 128 × 2 = 65,536 B ≈ 64 KB&lt;/code>: &lt;strong>32× more space&lt;/strong>. With ColBERT-v2&amp;rsquo;s &lt;code>nbits=2&lt;/code> residual compression it drops to ~16 KB (8×). &lt;code>Jina-ColBERT-v2&lt;/code> adds &lt;strong>Matryoshka over the token dimensions&lt;/strong> (truncatable to 128 / 96 / 64), cutting another 50%.&lt;/p>
&lt;p>For 1 million chunks:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Per doc&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dense fp32 (1024-d)&lt;/td>
&lt;td>4,096 B&lt;/td>
&lt;td>4.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense fp16 / halfvec&lt;/td>
&lt;td>2,048 B&lt;/td>
&lt;td>2.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense int8 (SQ)&lt;/td>
&lt;td>1,024 B&lt;/td>
&lt;td>1.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense binary (1 bit/d)&lt;/td>
&lt;td>128 B&lt;/td>
&lt;td>&lt;strong>128 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SPLADE (≈ 80 terms × 8 B)&lt;/td>
&lt;td>~640 B&lt;/td>
&lt;td>~640 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ColBERT fp16 (256 tok × 128 d)&lt;/td>
&lt;td>65,536 B&lt;/td>
&lt;td>&lt;strong>64 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ColBERT residual &lt;code>nbits=2&lt;/code>&lt;/td>
&lt;td>~16,000 B&lt;/td>
&lt;td>~16 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Jina-ColBERT-v2 (MRL token 64)&lt;/td>
&lt;td>~8,000 B&lt;/td>
&lt;td>~8 GB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>ColBERT in &lt;strong>on-premise&lt;/strong> production is reserved for corpora of up to a few million chunks, or applied only as a reranker over the top-100 from the first committee (dense + sparse), as described in the &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker post&lt;/a>.&lt;/p>
&lt;h2 id="matryoshka-the-truncatable-dimension">Matryoshka, the truncatable dimension&lt;/h2>
&lt;p>An operational lever that changed the conversation about embeddings between 2024 and 2026 is &lt;strong>Matryoshka Representation Learning&lt;/strong> (Kusupati et al., NeurIPS 2022). The trick: during training, on top of the loss over the full &lt;code>D&lt;/code>-dimension vector, the &lt;strong>same loss is computed over prefixes&lt;/strong> of the vector:&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-math" data-lang="math">\mathcal{L}_{\text{MRL}} = \sum_{k \in \{64, 128, 256, 512, 1024\}} \alpha_k \cdot \mathcal{L}_{\text{InfoNCE}}\bigl(\text{emb}[:k]\bigr)
&lt;/code>&lt;/pre>&lt;p>The first &lt;code>k&lt;/code> dimensions of the embedding are trained to be, on their own, a valid embedding. At inference time, if you want a cheaper embedding, &lt;strong>you truncate the vector&lt;/strong>: the first quarter is already a usable embedding. Without Matryoshka, truncating wrecks the geometry: the first 256 dimensions of an embedding trained only at 1024-d encode nothing coherent.&lt;/p>
&lt;p>Typical degradation in MTEB nDCG@10 when truncating an MRL embedder:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Truncation&lt;/th>
&lt;th>Average loss&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1024 → 512&lt;/td>
&lt;td>-1 to -2 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1024 → 256&lt;/td>
&lt;td>-3 to -5 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1024 → 128&lt;/td>
&lt;td>-5 to -8 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1024 → 64&lt;/td>
&lt;td>-8 to -12 points&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Native MRL models in 2026 (the ones that let you pick the dimension at runtime without retraining):&lt;/p>
&lt;ul>
&lt;li>&lt;code>jina-embeddings-v3&lt;/code> (1024 → 32, fine steps, CC-BY-NC-4.0)&lt;/li>
&lt;li>&lt;code>jina-embeddings-v4&lt;/code> (2048 → 128, multimodal text+image, CC-BY-NC-4.0)&lt;/li>
&lt;li>&lt;code>nomic-embed-text-v2-moe&lt;/code> (768 → 256, Apache 2.0)&lt;/li>
&lt;li>&lt;code>Snowflake-arctic-embed-l-v2.0&lt;/code> (1024 → 256, Apache 2.0)&lt;/li>
&lt;li>&lt;code>mxbai-embed-large-v1&lt;/code> and &lt;code>mxbai-embed-2d-large-v1&lt;/code> (the latter also truncatable in layer depth)&lt;/li>
&lt;li>&lt;code>Stella_en_1.5B_v5&lt;/code> (multiple steps 512 / 768 / 1024 / 2048 / 4096 / 6144 / 8192, MIT, English only)&lt;/li>
&lt;li>&lt;code>text-embedding-3-large&lt;/code> (OpenAI, 3072 → 256, API only)&lt;/li>
&lt;li>&lt;code>voyage-3&lt;/code> family (1024 → 256 / 512 / 1024 / 2048, API only)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Production recommendation&lt;/strong>: always use an MRL model even if you do not truncate at the start, because it simplifies the future quantisation decision. And above all, &lt;strong>evaluate truncation on your real corpus&lt;/strong>: the average MTEB degradation of &amp;ldquo;-3 points&amp;rdquo; becomes &amp;ldquo;-12 points&amp;rdquo; in a niche domain.&lt;/p>
&lt;h2 id="the-2026-open-source-model-zoo">The 2026 open source model zoo&lt;/h2>
&lt;p>What follows is a spec sheet per model. Verified as of 2026-06: HuggingFace cards + reference papers + MTEB / MMTEB leaderboard.&lt;/p>
&lt;h3 id="dense-single-vector">Dense single-vector&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Params&lt;/th>
&lt;th>Dim&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;th>Languages&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Distinctive&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code> (dense)&lt;/td>
&lt;td>568 M&lt;/td>
&lt;td>1024&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Tri-mode (dense + sparse + colbert) in one forward. De facto standard for multilingual on-prem.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Snowflake/snowflake-arctic-embed-l-v2.0&lt;/code>&lt;/td>
&lt;td>568 M&lt;/td>
&lt;td>1024 (MRL → 256)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Trained for multilingual + English without degrading either. MIRACL 55.8.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>intfloat/multilingual-e5-large-instruct&lt;/code>&lt;/td>
&lt;td>560 M&lt;/td>
&lt;td>1024&lt;/td>
&lt;td>512&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Veteran multilingual baseline. Short window.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>intfloat/e5-mistral-7b-instruct&lt;/code>&lt;/td>
&lt;td>7.1 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>First decoder-as-embedder to break MTEB. English.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Alibaba-NLP/gte-Qwen2-7B-instruct&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>3584&lt;/td>
&lt;td>32,768&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>The only one with 32k context. Top MTEB-en, strong multilingual.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nvidia/NV-Embed-v2&lt;/code>&lt;/td>
&lt;td>7.85 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>32,768&lt;/td>
&lt;td>English&lt;/td>
&lt;td>CC-BY-NC-4.0&lt;/td>
&lt;td>Latent-attention pooling. Top quality. &lt;strong>Licence blocker in prod&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Linq-AI-Research/Linq-Embed-Mistral&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>English&lt;/td>
&lt;td>CC-BY-NC-4.0&lt;/td>
&lt;td>Top MTEB retrieval Aug 2024. &lt;strong>Non-commercial&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>NovaSearch/stella_en_1.5B_v5&lt;/code>&lt;/td>
&lt;td>1.54 B&lt;/td>
&lt;td>8192 (multiple MRL)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Small + rich MRL. English.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-multilingual-gemma2&lt;/code>&lt;/td>
&lt;td>9 B&lt;/td>
&lt;td>3584&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>Gemma&lt;/td>
&lt;td>High quality, &lt;strong>the Gemma licence restricts redistribution&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-en-icl&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>4096&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT-style&lt;/td>
&lt;td>In-context learning from examples in the prompt.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixedbread-ai/mxbai-embed-large-v1&lt;/code>&lt;/td>
&lt;td>335 M&lt;/td>
&lt;td>1024 (MRL)&lt;/td>
&lt;td>512&lt;/td>
&lt;td>English&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>MRL + native binary. Short window.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-embeddings-v3&lt;/code>&lt;/td>
&lt;td>570 M&lt;/td>
&lt;td>1024 (MRL → 32)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>89&lt;/td>
&lt;td>CC-BY-NC-4.0&lt;/td>
&lt;td>Per-task LoRA. &lt;strong>Non-commercial without a licence&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nomic-ai/nomic-embed-text-v2-moe&lt;/code>&lt;/td>
&lt;td>475 M / 305 M active&lt;/td>
&lt;td>768 (MRL → 256)&lt;/td>
&lt;td>512&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>First general-purpose MoE in embeddings.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="learned-sparse">Learned sparse&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Params&lt;/th>
&lt;th>Vocab&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;th>Languages&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Distinctive&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>naver/splade-v3&lt;/code>&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>30,522&lt;/td>
&lt;td>512&lt;/td>
&lt;td>English&lt;/td>
&lt;td>CC-BY-NC-SA-4.0&lt;/td>
&lt;td>SOTA learned sparse. &lt;strong>Non-commercial&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code> (sparse head)&lt;/td>
&lt;td>568 M&lt;/td>
&lt;td>XLM-R vocab&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>100+&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>The only license-clean multilingual option.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="multi-vector-late-interaction">Multi-vector (late interaction)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Params&lt;/th>
&lt;th>Dim/token&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;th>Languages&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Distinctive&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>colbert-ir/colbertv2.0&lt;/code>&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>128&lt;/td>
&lt;td>512&lt;/td>
&lt;td>English&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>The original paper, the basis for everything.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-colbert-v2&lt;/code>&lt;/td>
&lt;td>560 M&lt;/td>
&lt;td>128 / 96 / 64 (MRL)&lt;/td>
&lt;td>8192&lt;/td>
&lt;td>89&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>&lt;strong>The license-clean multilingual multi-vector&lt;/strong>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nomic-ai/colnomic-embed-multimodal-7b&lt;/code>&lt;/td>
&lt;td>7 B&lt;/td>
&lt;td>3584 (Qwen2-VL)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Multimodal text+image multi-vector. Vidore-v2 open SOTA.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-leaderboard-with-caution">The leaderboard, with caution&lt;/h3>
&lt;p>MTEB / MMTEB (Massive Multilingual Text Embedding Benchmark, Enevoldsen et al., arxiv 2502.13595) is the standard thermometer. Top retrieval on MMTEB in mid-2026 is dominated by &lt;code>Qwen3-Embedding-8B&lt;/code> (~70.6 multilingual avg) and &lt;code>Llama-Embed-Nemotron-8B&lt;/code>. Below them, the 7B models (&lt;code>gte-Qwen2-7B&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>) and the 568M ones (&lt;code>bge-m3&lt;/code>, &lt;code>Snowflake-Arctic-L-2.0&lt;/code>) compete task by task.&lt;/p>
&lt;p>&lt;strong>Trap&lt;/strong>: MTEB has started to saturate through &lt;strong>dataset contamination&lt;/strong>. The higher the ranking, the more likely it is that the model saw subsets of the evaluation datasets during training. The rule in production: the leaderboard is for &lt;strong>discarding&lt;/strong> bad models, not for picking the best one. The final decision is made on your own domain eval set, generated with the &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a> or &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> recipe.&lt;/p>
&lt;h2 id="the-specific-problem-of-multilingual-spanish">The specific problem of multilingual Spanish&lt;/h2>
&lt;p>For a Spanish customer serving corporate, legal or support documentation in Spanish (and often with Catalan / Portuguese / English mixed in), the embedder zoo narrows to &lt;strong>fewer than six viable models&lt;/strong>. The operational exclusions:&lt;/p>
&lt;ol>
&lt;li>English-only models: &lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>stella-en-1.5B-v5&lt;/code>, &lt;code>Linq-Embed-Mistral&lt;/code>, &lt;code>mxbai-embed-large-v1&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>, &lt;code>SPLADE-v3&lt;/code>. They cut Spanish performance below an acceptable level: translating the query into English before searching is one route, but it introduces latency, tokenisation drift and another model dependency.&lt;/li>
&lt;li>Models with a non-commercial licence: &lt;code>jina-embeddings-v3&lt;/code>, &lt;code>jina-embeddings-v4&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>, &lt;code>Linq-Embed-Mistral&lt;/code>. They work for a PoC, but commercial production needs an explicit agreement with the vendor. Unless you have the licence signed, they have to be excluded.&lt;/li>
&lt;li>Models with a Gemma licence: &lt;code>bge-multilingual-gemma2&lt;/code>. Allowed for internal use, awkward to redistribute weights to a customer.&lt;/li>
&lt;/ol>
&lt;p>The ones left, ordered by practical choice in sovereign production:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>BAAI/bge-m3&lt;/code>&lt;/strong> — MIT, 568 M, 100+ languages (including Spanish and Catalan, explicitly trained), 8,192 tokens, tri-mode dense+sparse+colbert. &lt;strong>A reasonable default&lt;/strong>. Fits in an RTX 4090. &lt;code>TEI&lt;/code> and &lt;code>Infinity&lt;/code> serve it natively.&lt;/li>
&lt;li>&lt;strong>&lt;code>Snowflake/snowflake-arctic-embed-l-v2.0&lt;/code>&lt;/strong> — Apache 2.0, same size, explicit Matryoshka, better MIRACL/CLEF than bge-m3 on some multilingual tasks, no sparse head. &lt;strong>If the priority is pure MMTEB in Spanish&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>&lt;code>intfloat/multilingual-e5-large-instruct&lt;/code>&lt;/strong> — MIT, 560 M, veteran baseline. Its 512-token window is the big limitation: long documents have to be split first. &lt;strong>If what you already have in production works, do not migrate out of fashion&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>&lt;code>Alibaba-NLP/gte-Qwen2-7B-instruct&lt;/code>&lt;/strong> — Apache 2.0, 32k context, high quality in Spanish (Qwen2 is well trained on Spanish). &lt;strong>If the chunks are long&lt;/strong> (more than 4k tokens) and you have the GPU to serve it (it does not fit in a 4090; it does in an H100). It fits alongside an LLM on an H100 80GB with care.&lt;/li>
&lt;li>&lt;strong>&lt;code>nomic-ai/nomic-embed-text-v2-moe&lt;/code>&lt;/strong> — Apache 2.0, 305 M active, MRL, ~100 languages. &lt;strong>If latency and cost per token rule&lt;/strong>: the MoE gives it disproportionate throughput for its quality.&lt;/li>
&lt;li>&lt;strong>&lt;code>jinaai/jina-colbert-v2&lt;/code>&lt;/strong> — Apache 2.0, multilingual multi-vector, &lt;strong>as a reranker&lt;/strong> or as the main retrieval on a small corpus (&amp;lt; 1 M chunks). The only license-clean multi-vector in Spanish.&lt;/li>
&lt;/ol>
&lt;p>The rule of thumb: &lt;strong>&lt;code>bge-m3&lt;/code> as dense+sparse on the front line, &lt;code>jina-colbert-v2&lt;/code> as a third reranking layer when the use case warrants it&lt;/strong>, and &lt;code>Snowflake Arctic L 2.0&lt;/code> as the alternative if the corpus-specific eval prefers its geometry.&lt;/p>
&lt;h2 id="serving-embeddings-on-premise">Serving embeddings on-premise&lt;/h2>
&lt;p>Three engines split the on-prem embedding serving landscape in 2026, with different profiles.&lt;/p>
&lt;h3 id="text-embeddings-inference-tei-the-standard">Text Embeddings Inference (TEI), the standard&lt;/h3>
&lt;p>&lt;code>huggingface/text-embeddings-inference&lt;/code> is a server written in Rust with a Candle / ONNX backend, integrated FlashAttention and &lt;em>dynamic batching by tokens&lt;/em>. It exposes an OpenAI-compatible &lt;code>/v1/embeddings&lt;/code> API and supports the three &lt;code>bge-m3&lt;/code> modes simultaneously from version 1.5 onwards. For multilingual production it is the obvious default.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml — TEI serving multilingual bge-m3 on an RTX 4090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:1.5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model-id=BAAI/bge-m3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">pooling=cls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-batch-tokens=16384&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-concurrent-requests=512&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">dtype=float16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Indicative throughput with &lt;code>bge-m3&lt;/code>, &lt;code>fp16&lt;/code>, 512-token sequence, batch 32:&lt;/p>
&lt;ul>
&lt;li>RTX 4090 (24 GB): ~8–15 k tokens/s&lt;/li>
&lt;li>A100 80 GB: ~60 k tokens/s sustained&lt;/li>
&lt;li>H100 80 GB: ~40–80 k tokens/s, with &lt;code>fp8&lt;/code> about 50% more&lt;/li>
&lt;/ul>
&lt;p>(The ranges are approximate and depend on the real batch, the average sequence length and compilation with FA2/FA3.)&lt;/p>
&lt;h3 id="infinity-the-flexible-one">Infinity, the flexible one&lt;/h3>
&lt;p>&lt;code>michaelfeil/infinity&lt;/code> (MIT) is a multi-model FastAPI server able to load &lt;code>bge-m3&lt;/code>, &lt;code>Snowflake Arctic&lt;/code>, &lt;code>Jina-v3&lt;/code>, &lt;code>Nomic&lt;/code>, &lt;code>ColPali&lt;/code>, &lt;code>CLAP&lt;/code> and rerankers simultaneously behind the same OpenAI-style API. Backend &lt;code>PyTorch + Optimum (ONNX/TensorRT)&lt;/code> or &lt;code>CTranslate2&lt;/code>. Useful when you need to serve several different embedders (one for text, another for code, another for images) behind a single endpoint, or when the model does not yet have TEI support.&lt;/p>
&lt;h3 id="vllm---task-embed-for-the-7b-embedders">vLLM &lt;code>--task embed&lt;/code>, for the 7B embedders&lt;/h3>
&lt;p>When the embedder is really an LLM decoder turned into an embedder (&lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>gte-Qwen2-7B-instruct&lt;/code>, &lt;code>NV-Embed-v2&lt;/code>, &lt;code>Stella-1.5B&lt;/code>), the natural place to serve it is &lt;code>vLLM&lt;/code>, which already has the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a> stack in production:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Alibaba-NLP/gte-Qwen2-7B-instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --task embed &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dtype bfloat16 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">32768&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --trust-remote-code
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>vLLM&lt;/code> detects the correct pooling (last-token on the Qwen / Mistral based ones) and exposes an OpenAI-compatible &lt;code>/v1/embeddings&lt;/code>. For inference clusters already running vLLM with an LLM on another port, &lt;strong>it is the natural way to serve the embedder without standing up another stack&lt;/strong>.&lt;/p>
&lt;h3 id="fastembed-the-lightweight-one">fastembed, the lightweight one&lt;/h3>
&lt;p>&lt;code>qdrant/fastembed&lt;/code> loads &lt;code>bge-small&lt;/code>, &lt;code>MiniLM&lt;/code>, &lt;code>ColBERT&lt;/code> and &lt;code>BM25/SPLADE&lt;/code> sparse in ONNX-CPU. It is not competitive on throughput against TEI/Infinity with a GPU, but it is the right option when you have to serve embeddings on a NUC node without a GPU (see &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed NVIDIA + Intel environments&lt;/a>) or when the embedder is part of the client (a preview in a UI, prior scoring at the edge).&lt;/p>
&lt;h2 id="storage-quantisation-and-the-corpus-arithmetic">Storage, quantisation and the corpus arithmetic&lt;/h2>
&lt;p>The embedding does not stay in the embedder&amp;rsquo;s memory: it lives in the vector DB index and is materialised every time you ingest a new chunk. &lt;strong>The storage cost calculation is what decides the final dimension&lt;/strong>, not MTEB quality. For 1 million chunks with a dense embedder at 1024-d:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">fp32 : 1,024 dims × 4 B × 1 M = 4,096 MB ≈ 4.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">fp16/halfvec: 1,024 dims × 2 B × 1 M = 2,048 MB ≈ 2.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">int8 (SQ) : 1,024 dims × 1 B × 1 M = 1,024 MB ≈ 1.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">binary : 128 B × 1 M ≈ 128 MB
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The quantisation options in order of real use (mid-2026):&lt;/p>
&lt;ol>
&lt;li>&lt;strong>halfvec (&lt;code>fp16&lt;/code>)&lt;/strong>: the default in pgvector 0.7+ and in any serious vector DB. Zero MTEB loss, 2× compression. &lt;strong>Always turn it on.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Scalar Quantization int8 (SQ)&lt;/strong>: each vector component is mapped to &lt;code>int8&lt;/code> with a global min/max. Typical recall@10 loss: 0–1%. 4× compression. &lt;strong>The Qdrant default&lt;/strong>, supported in Milvus and Weaviate.&lt;/li>
&lt;li>&lt;strong>Binary quantisation&lt;/strong>: &lt;code>bit = sign(v_i - μ_i)&lt;/code>. 32× raw compression. Cold loss 5–15%. Mitigated with &lt;strong>a Hadamard / TurboQuant pre-rotation&lt;/strong> (Qdrant 1.18, Dec 2025): it pre-multiplies by a random orthogonal matrix that spreads the energy across dimensions before binarising. After TurboQuant the loss drops to 1–3%. It also combines with rescoring over the original &lt;code>fp16&lt;/code> for the top-100 candidates.&lt;/li>
&lt;li>&lt;strong>Product Quantization (PQ)&lt;/strong>: the FAISS classic. Up to 64× compression, 2–5% loss. More complex to operate (it needs a trained codebook); in 2026 it has given ground to binary + rescoring.&lt;/li>
&lt;/ol>
&lt;p>A corpus of 100 million chunks (a real figure for a large corporate RAG) with dense &lt;code>bge-m3&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>fp32&lt;/td>
&lt;td>400 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>fp16&lt;/td>
&lt;td>200 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>int8&lt;/td>
&lt;td>100 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>binary + Hadamard&lt;/td>
&lt;td>&lt;strong>12.5 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The difference between 200 GB and 12.5 GB &lt;strong>is the difference between needing a dedicated vector DB node with 8 NVMe in RAID and fitting in the RAM of a single node&lt;/strong>. For large corpora, quantisation is no longer an optimisation: it is the only way to operate.&lt;/p>
&lt;h2 id="the-vector-db-integration">The vector DB integration&lt;/h2>
&lt;p>The vector DBs of 2026 have become hybrid DBs that index all three types at once. The quick map:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vector DB&lt;/th>
&lt;th>Native hybrid&lt;/th>
&lt;th>Multi-vector / ColBERT&lt;/th>
&lt;th>Quantisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Qdrant ≥1.10&lt;/td>
&lt;td>RRF/DBSF in &lt;code>query_points&lt;/code> with dense + sparse + colbert in one collection&lt;/td>
&lt;td>Yes, native (one-shot MaxSim)&lt;/td>
&lt;td>SQ int8, binary, &lt;strong>TurboQuant&lt;/strong> 1.18&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Weaviate&lt;/td>
&lt;td>&lt;code>hybrid(alpha=0.75)&lt;/code> BM25 + dense, multi-target &lt;em>named vectors&lt;/em>&lt;/td>
&lt;td>Yes, as a multi-vector named vector&lt;/td>
&lt;td>PQ, SQ, rotational 8-bit BBQ&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Milvus ≥2.4&lt;/td>
&lt;td>Multi-vector + sparse in the schema; 2.5 adds native BM25 full-text&lt;/td>
&lt;td>Multi-vector field, MaxSim orchestrated from the client&lt;/td>
&lt;td>SQ, PQ, CAGRA GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>pgvector 0.7+/0.8&lt;/td>
&lt;td>&lt;code>halfvec&lt;/code>, &lt;code>sparsevec&lt;/code>, &lt;code>bit&lt;/code>; HNSW for all three&lt;/td>
&lt;td>Not native (separate-table workaround)&lt;/td>
&lt;td>&lt;code>binary_quantize()&lt;/code>, halfvec, rescoring with exact &lt;code>&amp;lt;#&amp;gt;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Elasticsearch / OpenSearch&lt;/td>
&lt;td>&lt;code>sparse_vector&lt;/code> (ELSER, SPLADE) + &lt;code>dense_vector&lt;/code> HNSW; RRF&lt;/td>
&lt;td>OpenSearch 3.x yes&lt;/td>
&lt;td>ES 9: &lt;code>int8_hnsw&lt;/code> by default, &lt;strong>BBQ&lt;/strong> binary quantization&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For sovereign &lt;strong>on-prem production in Spanish&lt;/strong>, the easiest combination to operate in 2026 is &lt;strong>Qdrant + &lt;code>bge-m3&lt;/code>&lt;/strong>: a single collection indexes the three modes of the same model, the hybrid query with RRF is done in one call, TurboQuant quantisation brings the corpus down to manageable levels, and the operator is a Go binary with simple backups to S3/MinIO. &lt;strong>pgvector + &lt;code>bge-m3&lt;/code>&lt;/strong> is the other reasonable option when you already have Postgres with HA and do not want to add a second DB to the operational inventory; you lose native multi-vector, but you gain cross-cutting SQL over the chunks.&lt;/p>
&lt;p>The HNSW parameters you absolutely have to touch:&lt;/p>
&lt;ul>
&lt;li>&lt;code>M&lt;/code>: connections per node in the graph. 16–32 typical. Higher means more recall, more RAM. For small corpora (&amp;lt;1 M) &lt;code>M=16&lt;/code>; for medium corpora (&lt;code>10 M&lt;/code>) &lt;code>M=24&lt;/code>; for large corpora &lt;code>M=32+IVF-PQ&lt;/code> or &lt;code>M=32+binary&lt;/code>.&lt;/li>
&lt;li>&lt;code>ef_construction&lt;/code>: search width during construction. 100–400. Higher means a better graph, slower construction. &lt;strong>Build with &lt;code>ef_construction=400&lt;/code> even if it is slow; you pay it once.&lt;/strong>&lt;/li>
&lt;li>&lt;code>ef_search&lt;/code>: width during the query. 50–200. &lt;strong>The main knob for the recall/latency trade-off at runtime.&lt;/strong> Start at 64 and measure.&lt;/li>
&lt;/ul>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The embedder does not share hardware with the LLM as comfortably as it might seem. The numbers:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>bge-m3&lt;/code> (568 M)&lt;/strong> takes about &lt;code>568 × 2 = 1,136 MB&lt;/code> in &lt;code>fp16&lt;/code> for the weights, plus the batch KV cache, plus temporary activations. In practice it is served comfortably in &lt;strong>6–8 GB of VRAM&lt;/strong> even at a high batch. It fits &lt;strong>alongside a 7B-Q4 LLM&lt;/strong> on an RTX 4090.&lt;/li>
&lt;li>&lt;strong>&lt;code>gte-Qwen2-7B-instruct&lt;/code>&lt;/strong> needs &lt;code>~14 GB fp16&lt;/code> for weights alone. It does not fit next to a 7B LLM on a 4090; on an H100 80 GB it does, with care over simultaneous batching.&lt;/li>
&lt;li>&lt;strong>&lt;code>jina-colbert-v2&lt;/code> (560 M)&lt;/strong> takes &lt;code>~1.1 GB&lt;/code> of weights, but the storage of the multi-vector index is the real cost: 8 GB per million chunks even with Matryoshka and compression.&lt;/li>
&lt;/ul>
&lt;h3 id="on-the-rtx-4090-24-gb">On the RTX 4090 (24 GB)&lt;/h3>
&lt;p>A realistic minimum stack for a Spanish RAG with a corpus under 1 M chunks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">GPU 24 GB ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ TEI bge-m3 (dense + sparse + colbert) │ ~6 GB VRAM, ~12 k tok/s
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ vLLM Qwen2.5-7B-Instruct AWQ Q4 │ ~8 GB VRAM, ~80 tok/s
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">CPU/RAM ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ Qdrant with bge-m3 dense + sparse + colbert │ ~3 GB RAM per M chunks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ FastAPI gateway (LiteLLM)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It serves a few tens of RAG QPS with decent multilingual quality. It is the PoC configuration and the deployment for a small site.&lt;/p>
&lt;h3 id="on-the-4h100-80-gb-cluster">On the 4×H100 80 GB cluster&lt;/h3>
&lt;p>For the production case with several million chunks and an SLO of p99 &amp;lt; 500 ms:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">H100 #1 (80 GB) ── vLLM Qwen3-72B-Instruct AWQ + Qwen2.5-7B speculative ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #2 (80 GB) ── vLLM gte-Qwen2-7B-instruct (embedding 32k ctx) │ LLM + large embed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #3 (80 GB) ── TEI bge-m3 multi-tenant + jina-colbert-v2 reranker │ medium embed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">H100 #4 (80 GB) ── Hold-out for canary / shadow │ see canary post
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Qdrant cluster (3 CPU nodes + NVMe) ── 100 M chunks indexed (binary + TurboQuant + rescoring)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This configuration &lt;strong>separates the large LLM from the large embedder&lt;/strong> (they share the Qwen2 architecture but compete for VRAM if put on the same GPU) and leaves a whole H100 for variants in &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary&lt;/a>. &lt;code>bge-m3&lt;/code> fits with room to spare alongside the reranker on a single H100, serving tens of thousands of requests/min.&lt;/p>
&lt;h2 id="the-seven-operational-traps-of-the-embedder">The seven operational traps of the embedder&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Not normalising the query vector.&lt;/strong> Cosine and dot product coincide only when both vectors are unit vectors. If you forget &lt;code>v ← v / ‖v‖₂&lt;/code> in the client, the results are &amp;ldquo;almost right&amp;rdquo;, the top-1 is still correct on trivial queries, the top-10 no longer is, and nobody notices until RAG quality drops 8 points. &lt;strong>Fix&lt;/strong>: bake normalisation into the embedder adapter, not into the client.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Chat template slipped into the embedder.&lt;/strong> Some LLM-based embedders (&lt;code>e5-mistral-7b-instruct&lt;/code>, &lt;code>gte-Qwen2-7B&lt;/code>) expect a specific instruction prompt before the text to embed (&lt;code>&amp;quot;Instruct: Retrieve relevant passages\nQuery: ...&amp;quot;&lt;/code>). Forgetting it leaves performance ~5 MTEB points lower. &lt;strong>Fix&lt;/strong>: read the &lt;code>usage_template&lt;/code> in the model card and put it into the embedder wrapper.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Badly chosen Matryoshka dimension.&lt;/strong> The default in many Qdrant / pgvector clients is &lt;code>dim=768&lt;/code>. If your embedder is natively MRL at 1024 → 768, fine. If it is 1024 without MRL, truncating to 768 wrecks the space (typical loss -8 MTEB points). &lt;strong>Fix&lt;/strong>: use the model&amp;rsquo;s native &lt;code>dim&lt;/code> and truncate only when storage demands it, and only on MRL models.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Missing hard negatives in fine-tuning.&lt;/strong> When the embedder is fine-tuned with your own data (which should be standard practice for corporate RAG), if the mini-batch only carries positives and in-batch negatives &lt;em>from the same domain&lt;/em>, the model learns that anything outside the domain is negative, but inside the domain it does not discriminate. &lt;strong>Fix&lt;/strong>: mine hard negatives with BM25 / dense from your own corpus before fine-tuning.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Corpus drift without reindexing.&lt;/strong> When you retrain or replace the embedder but only apply the new model to new chunks, &lt;strong>you end up with the index mixing two incompatible geometries&lt;/strong>. Chunks from the old and the new model are not comparable by cosine. &lt;strong>Fix&lt;/strong>: every change of embedder is a full reindexing of the corpus, planned as an operational &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Tokenizer drift between client and model.&lt;/strong> The Python client that prepares the queries uses its own tokenizer (sometimes &lt;code>tiktoken&lt;/code> by default) and truncates at 8,192 tokens. The embedder uses XLM-R with sentencepiece and truncates at 8,192 &lt;em>of its own tokenizer&lt;/em>. Long queries are truncated differently; the corpus embeddings are consistent but the query ones are not. &lt;strong>Fix&lt;/strong>: use the model&amp;rsquo;s tokenizer in the client or in the wrapper.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>MTEB overfit as a selection guide.&lt;/strong> The MTEB leaderboard has become a contaminated metric: there is evidence that leading models saw subsets of the evaluation datasets during training. The model that is +0.5 points above the second is not necessarily better for your domain. &lt;strong>Fix&lt;/strong>: your own domain eval set (100-300 labelled query-doc pairs) run with the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">evals&lt;/a> recipe decides.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="license-clean-stack-for-sovereign-production">License-clean stack for sovereign production&lt;/h2>
&lt;p>Let us write down the recommended ending. For a Spanish organisation serving corporate RAG on-prem under &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS / ISO 42001 / EU AI Act&lt;/a>, with a corpus of 1-50 M chunks in Spanish + English + Catalan:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Rationale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dense embedder&lt;/td>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Robust multilingual, 8k tokens, license-clean, served by TEI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sparse embedder&lt;/td>
&lt;td>&lt;code>bge-m3&lt;/code> sparse head&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Same pass as the dense one, no second model needed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Layer 2 reranker&lt;/td>
&lt;td>&lt;code>BAAI/bge-reranker-v2-m3&lt;/code>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Multilingual cross-encoder from the same team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Layer 3 reranker (optional)&lt;/td>
&lt;td>&lt;code>jinaai/jina-colbert-v2&lt;/code>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>License-clean multilingual multi-vector&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embed server&lt;/td>
&lt;td>&lt;code>TEI&lt;/code> + &lt;code>Infinity&lt;/code> for multi-model&lt;/td>
&lt;td>Apache 2.0 / MIT&lt;/td>
&lt;td>Supported stack&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector DB&lt;/td>
&lt;td>&lt;code>Qdrant&lt;/code> (preferred) or &lt;code>pgvector 0.8&lt;/code>&lt;/td>
&lt;td>Apache 2.0 / PostgreSQL&lt;/td>
&lt;td>Native hybrid + quantisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantisation&lt;/td>
&lt;td>&lt;code>int8 SQ&lt;/code> + &lt;code>binary + TurboQuant + rescoring&lt;/code>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Cuts the corpus 16×–32× with &amp;lt; 3% loss&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Minimum hardware&lt;/td>
&lt;td>RTX 4090 24 GB&lt;/td>
&lt;td>—&lt;/td>
&lt;td>For PoC and small sites&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Production hardware&lt;/td>
&lt;td>4×H100 80 GB cluster&lt;/td>
&lt;td>—&lt;/td>
&lt;td>For RAG with an SLO of p99 &amp;lt; 500 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The alternative stack, if explicit MMTEB in Spanish weighs more than the tri-modality of &lt;code>bge-m3&lt;/code>: replace &lt;code>bge-m3&lt;/code> with &lt;strong>&lt;code>Snowflake/snowflake-arctic-embed-l-v2.0&lt;/code>&lt;/strong> (Apache 2.0, MRL → 256) and explicitly add &lt;code>SPLADE-v3&lt;/code> or plain BM25 for the sparse layer. It loses the elegance of the single forward and gains 1-2 points on Spanish MIRACL.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The embedder is the easiest piece of a RAG to oversimplify and the one that decides real quality the most once everything else is in place. The three families (dense, sparse, multi-vector) are not three options to choose between but three trades that &lt;strong>&lt;code>bge-m3&lt;/code> performs in a single pass&lt;/strong> and that &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a> consumes in parallel. The maths that matters is modest, InfoNCE with &lt;code>τ&lt;/code>, MaxSim, FLOPS regularization, MRL, but the operational traps are many and quiet: forgotten normalisation, missing chat template, badly chosen Matryoshka dimension, tokenizer drift. For sovereign production in Spanish the list of viable models fits in fewer than ten, and the real decision comes down to &amp;ldquo;&lt;strong>&lt;code>bge-m3&lt;/code> or &lt;code>Snowflake Arctic L 2.0&lt;/code>&lt;/strong>&amp;rdquo;, with &lt;code>jina-colbert-v2&lt;/code> added as layer three when the fine-grained quality justifies the cost. The license-clean stack fits on an RTX 4090 for a PoC and scales to a 4×H100 cluster for real production.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">Serving embeddings and rerankers with TEI in production&lt;/a> — how to serve these models in production with batching and an OpenAI-compatible API.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">Taking RAG to the CPU: separating the data plane from the generation plane&lt;/a> — why these encoders (~500M) are the ideal case for running on CPU and freeing up the GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — where the data / retrieval piece fits.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> — what enters the index before the embedder sees it.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — what the committee that consumes the embeddings does.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> — the type layer that enriches the embedding with queryable metadata; chunks are not just vectors but typed instances against a TBox.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs&lt;/a> — how you decide whether an embedder is really better than the current one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for LLM inference&lt;/a> — sizing the GPU to serve embedder + LLM on the same cluster.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Chen et al. &lt;em>M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation&lt;/em>. arXiv:2402.03216. &lt;a href="https://arxiv.org/abs/2402.03216">https://arxiv.org/abs/2402.03216&lt;/a>&lt;/li>
&lt;li>Sturua et al. &lt;em>Jina Embeddings v3: Multilingual Embeddings With Task LoRA&lt;/em>. arXiv:2409.10173. &lt;a href="https://arxiv.org/abs/2409.10173">https://arxiv.org/abs/2409.10173&lt;/a>&lt;/li>
&lt;li>Günther et al. &lt;em>Jina Embeddings v4: Universal Embeddings for Multimodal Multilingual Retrieval&lt;/em>. arXiv:2506.18902. &lt;a href="https://arxiv.org/abs/2506.18902">https://arxiv.org/abs/2506.18902&lt;/a>&lt;/li>
&lt;li>Nussbaum et al. &lt;em>Nomic Embed v2: Multilingual Mixture of Experts&lt;/em>. arXiv:2502.07972. &lt;a href="https://arxiv.org/abs/2502.07972">https://arxiv.org/abs/2502.07972&lt;/a>&lt;/li>
&lt;li>Wang et al. &lt;em>Improving Text Embeddings with Large Language Models (E5-Mistral)&lt;/em>. arXiv:2401.00368.&lt;/li>
&lt;li>Yu et al. &lt;em>Arctic-Embed 2.0: Multilingual Retrieval Without Compromise&lt;/em>. Snowflake, 2024-12. &lt;a href="https://www.snowflake.com/blog/arctic-embed-2-multilingual/">https://www.snowflake.com/blog/arctic-embed-2-multilingual/&lt;/a>&lt;/li>
&lt;li>Lee et al. &lt;em>NV-Embed: Improved Techniques for Training LLMs as Generalist Embedding Models&lt;/em>. arXiv:2405.17428.&lt;/li>
&lt;li>Khattab y Zaharia. &lt;em>ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT&lt;/em>. SIGIR 2020.&lt;/li>
&lt;li>Santhanam et al. &lt;em>ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction&lt;/em>. arXiv:2112.01488.&lt;/li>
&lt;li>Jha et al. &lt;em>Jina-ColBERT-v2: A General-Purpose Multilingual Late Interaction Retriever&lt;/em>. arXiv:2408.16672.&lt;/li>
&lt;li>Lassance et al. &lt;em>SPLADE-v3&lt;/em>. arXiv:2403.06789.&lt;/li>
&lt;li>Kusupati et al. &lt;em>Matryoshka Representation Learning&lt;/em>. NeurIPS 2022, arXiv:2205.13147.&lt;/li>
&lt;li>Enevoldsen et al. &lt;em>MMTEB: Massive Multilingual Text Embedding Benchmark&lt;/em>. arXiv:2502.13595.&lt;/li>
&lt;li>Hugging Face Text Embeddings Inference. &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a>&lt;/li>
&lt;li>Michael Feil. &lt;em>Infinity&lt;/em>. &lt;a href="https://github.com/michaelfeil/infinity">https://github.com/michaelfeil/infinity&lt;/a>&lt;/li>
&lt;li>Qdrant. &lt;em>TurboQuant 1.18 release notes&lt;/em>. &lt;a href="https://qdrant.tech/articles/turboquant-quantization/">https://qdrant.tech/articles/turboquant-quantization/&lt;/a>&lt;/li>
&lt;li>pgvector. Release notes 0.7 / 0.8. &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>&lt;/li>
&lt;li>Hugging Face. &lt;em>Embedding Quantization&lt;/em>. &lt;a href="https://huggingface.co/blog/embedding-quantization">https://huggingface.co/blog/embedding-quantization&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>