<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Bge-M3 on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/bge-m3/</link><description>Recent content in Bge-M3 on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 11 Jun 2026 09:40:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/bge-m3/index.xml" rel="self" type="application/rss+xml"/><item><title>Serving embeddings and rerankers with TEI in production</title><link>https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/</link><pubDate>Thu, 11 Jun 2026 09:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/</guid><description>&lt;blockquote>
&lt;p>Sixth piece in an operational series about squeezing a generic on-premise LLM cluster of &lt;strong>4×H100 SXM 80 GB&lt;/strong>. If &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on the CPU&lt;/a> argued &lt;em>where&lt;/em> the data plane runs and &lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">the embedder zoo&lt;/a> decided &lt;em>which model&lt;/em> you serve, this post looks at &lt;em>the engine&lt;/em> that serves them: the embeddings and rerankers server. The sisters in this batch, &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion from PDF to indexed chunk&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets for the sovereign stack&lt;/a>, build the rest of the system around this piece. The closer of the series, the end-to-end sovereign assistant with LibreChat + LiteLLM + RAG, is still in draft.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving embeddings is not &lt;code>model.encode(text)&lt;/code> scattered through the ingestion code. Just as you do not put a &lt;code>transformers.generate()&lt;/code> inside your API and call it vLLM, you do not put the embedder inline either: you put it &lt;strong>behind a dedicated server&lt;/strong> that does batching, controls concurrency, emits metrics and exposes a single HTTP contract that both ingestion and query-time reuse. In the 2026 open source ecosystem that server is &lt;strong>TEI, Text Embeddings Inference&lt;/strong> from Hugging Face: a Rust binary that serves embedding, reranker (cross-encoder) and sequence classification models, with &lt;strong>token-based dynamic batching&lt;/strong>, and that exposes OpenAI-compatible endpoints (&lt;code>/v1/embeddings&lt;/code>) on top of the native &lt;code>/embed&lt;/code>, &lt;code>/embed_sparse&lt;/code>, &lt;code>/rerank&lt;/code> and &lt;code>/predict&lt;/code> (&lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI repo&lt;/a>, &lt;a href="https://huggingface.co/docs/text-embeddings-inference">docs&lt;/a>). It runs on CPU (ONNX Runtime, Intel MKL) and on CUDA (with FlashAttention and cuda graphs), with the same contract on both sides, so the rest of the system does not know, or care, what silicon sits behind it. The technical piece that gives almost all the performance is dynamic batching: TEI groups requests that arrive separately into a batch that shares the fixed cost of the forward, controlled by &lt;code>--max-batch-tokens&lt;/code> (how many tokens fit per batch) and &lt;code>--max-concurrent-requests&lt;/code> (backpressure). Grouping raises throughput, going from serving 1 sequence to 32 multiplies tokens/s at almost constant cost until the hardware saturates, in exchange for some latency while the tray fills. The numbers: a 568M encoder (&lt;code>bge-m3&lt;/code>, &lt;code>bge-reranker-v2-m3&lt;/code>) takes ~1.1 GB in fp16 and ~0.57 GB in int8, so several replicas fit even in a small H100 slice; embedding a short query online costs tens of ms, but reranking the top-50 costs &lt;strong>linearly in candidates&lt;/strong> because a cross-encoder evaluates each (query, doc) pair separately. The split across the 4×H100 cluster: TEI-CPU on the fleet for batch ingestion (no SLA, throughput/€), TEI-GPU in a MIG slice of one H100 for high-QPS online embeddings and rerank, talking to ingestion and to the LiteLLM gateway through the OpenAI contract.&lt;/p>
&lt;h2 id="the-analogy-the-stamping-press-that-only-stamps-vectors">The analogy: the stamping press that only stamps vectors&lt;/h2>
&lt;p>Picture a workshop with a specialised &lt;strong>stamping press&lt;/strong>. It does not make varied parts; it does one single thing: it takes flat material and, in one stroke, stamps a shape into it. In our case it takes text and stamps a vector. And like every industrial press, it has one property that defines all its behaviour: &lt;strong>the cost of the stroke is almost fixed&lt;/strong>, no matter how much material you put on the tray.&lt;/p>
&lt;p>Lowering the press, heating the die, aligning, pressing and lifting costs, say, one second. If you put a single sheet in, you spend that whole second stamping one part: a waste. If you fill the tray with thirty sheets and lower the press once, you spend nearly the same second and come out with thirty stamped parts. The cost per part collapses. The press &lt;strong>performs far better when it fills the tray before pressing&lt;/strong>.&lt;/p>
&lt;p>This is exactly the &lt;strong>dynamic batching&lt;/strong> of an embeddings server. The &amp;ldquo;stroke&amp;rdquo; is the model&amp;rsquo;s forward pass on the GPU or the CPU: an operation that loads the weights, multiplies them by the activations and emits the vectors, with a large fixed cost that does not scale with how many sequences you process at once, until you saturate memory bandwidth or the compute units. Serving requests one at a time is stamping one sheet per stroke. Grouping them into a batch is filling the tray.&lt;/p>
&lt;p>But the press has an operational dilemma, and here is the heart of the post. If you wait until the tray is completely full before pressing, the first sheets that arrived sit waiting for the rest: &lt;strong>latency&lt;/strong>. If you press as soon as one sheet drops so nobody has to wait, you go back to the waste of one stroke per part: &lt;strong>low throughput&lt;/strong>. The embeddings server solves this with a &lt;strong>bounded wait window&lt;/strong>: it gathers whatever arrives within a window of microseconds to milliseconds, or until the batch token budget is full, and then it presses. That is the policy that turns a pile of independent requests into a few efficient strokes without anyone waiting too long.&lt;/p>
&lt;p>The rest of the post is, at bottom, about configuring that press properly: how many sheets fit on the tray (&lt;code>max-batch-tokens&lt;/code>), how much queue is tolerated before work is rejected (&lt;code>max-concurrent-requests&lt;/code>), whether the press lives on the cheap CPU fleet (nightly ingestion, pure throughput) or in a slice of the expensive GPU (online, bounded latency), and why the reranker is a different press whose cost does grow with the number of parts.&lt;/p>
&lt;h2 id="what-tei-is-exactly">What TEI is, exactly&lt;/h2>
&lt;p>&lt;strong>Text Embeddings Inference (TEI)&lt;/strong> is Hugging Face&amp;rsquo;s inference server for the family of models that &lt;strong>does not generate tokens&lt;/strong>: embedders, cross-encoder rerankers and sequence classifiers. It is to encoders what vLLM is to generative LLMs: a high-performance server, written in Rust, that handles tokenisation, batching, concurrency, metrics and the HTTP contract, leaving the application code to only make calls (&lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI repo&lt;/a>).&lt;/p>
&lt;p>Three model types, three jobs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embedding&lt;/strong> (bi-encoder): turns a text into a reusable vector. &lt;code>bge-m3&lt;/code>, &lt;code>Snowflake Arctic Embed&lt;/code>, &lt;code>multilingual-e5&lt;/code>. The vector is stored in the index and reused on every search.&lt;/li>
&lt;li>&lt;strong>Reranker&lt;/strong> (cross-encoder): takes a pair (query, document) and emits a relevance score, not a vector. &lt;code>bge-reranker-v2-m3&lt;/code>. It produces nothing reusable: every pair is evaluated again.&lt;/li>
&lt;li>&lt;strong>Sequence classification&lt;/strong>: emits labels/probabilities over a text. Useful for lightweight guardrails, routing, language or toxicity detection.&lt;/li>
&lt;/ul>
&lt;h3 id="the-endpoints">The endpoints&lt;/h3>
&lt;p>TEI exposes both an &lt;strong>OpenAI-compatible&lt;/strong> endpoint and its native endpoints, verified against the current documentation (&lt;a href="https://huggingface.co/docs/text-embeddings-inference/quick_tour">Quick Tour&lt;/a>, &lt;a href="https://deepwiki.com/huggingface/text-embeddings-inference">DeepWiki&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>What for&lt;/th>
&lt;th>Model type&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>&lt;code>/v1/embeddings&lt;/code>&lt;/strong>&lt;/td>
&lt;td>Embeddings with the &lt;strong>OpenAI contract&lt;/strong> (drop-in for clients that already speak OpenAI)&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/embed&lt;/code>&lt;/td>
&lt;td>Dense embeddings, TEI&amp;rsquo;s native API&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/embed_sparse&lt;/code>&lt;/td>
&lt;td>Sparse embeddings (lexical head of models like &lt;code>bge-m3&lt;/code>/SPLADE)&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/rerank&lt;/code>&lt;/td>
&lt;td>Orders a list of texts by relevance against a query&lt;/td>
&lt;td>reranker (cross-encoder)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/predict&lt;/code>&lt;/td>
&lt;td>Sequence classification (labels/scores)&lt;/td>
&lt;td>classifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/embed_all&lt;/code>&lt;/td>
&lt;td>Per-token embeddings (no pooling)&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/similarity&lt;/code>&lt;/td>
&lt;td>Direct similarity between texts&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/metrics&lt;/code>, &lt;code>/health&lt;/code>, &lt;code>/info&lt;/code>, &lt;code>/tokenize&lt;/code>, &lt;code>/decode&lt;/code>&lt;/td>
&lt;td>Operation, observability and utilities&lt;/td>
&lt;td>all&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational key is in the first row: TEI speaking the OpenAI contract on &lt;code>/v1/embeddings&lt;/code> means the embedder is &lt;strong>swappable without touching client code&lt;/strong>. The same code that called OpenAI&amp;rsquo;s &lt;code>text-embedding-3-large&lt;/code> points at your on-prem TEI by changing the &lt;code>base_url&lt;/code>, and the gateway (&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">LiteLLM&lt;/a>) routes it as one more provider. &lt;code>/rerank&lt;/code>, by contrast, is not part of the OpenAI standard, no such endpoint exists in their API, so the gateway treats it as a reranking-specific endpoint (&lt;a href="https://github.com/BerriAI/litellm/issues/8372">LiteLLM TEI rerank&lt;/a>).&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>TEI runs on CPU and on GPU with the same HTTP contract, which is exactly the property the &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">CPU/GPU split of RAG&lt;/a> needs (&lt;a href="https://huggingface.co/docs/text-embeddings-inference/index">TEI docs, hardware&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>CPU: &lt;code>cpu-*&lt;/code> image. ONNX Runtime (recommended) or Intel MKL backend. It exploits the integer compute paths (AVX-512+VNNI, AMX on 4th-gen Xeon) when the model is in int8. It is the batch ingestion engine.&lt;/li>
&lt;li>CUDA: architecture-specific images (&lt;code>cuda-*&lt;/code>, with variants for different compute capabilities). It uses FlashAttention and cuda graphs to squeeze the GPU. It requires compute capability ≥ 7.5 (Volta is out) and CUDA 12.2+ compatible drivers. It is the high-QPS online engine.&lt;/li>
&lt;li>There is also Metal support (Apple Silicon) and experimental ROCm (AMD Instinct), less relevant to the on-prem case of this post.&lt;/li>
&lt;/ul>
&lt;p>The golden rule: &lt;strong>the same &lt;code>bge-m3&lt;/code> served by TEI-CPU and by TEI-GPU exposes an identical &lt;code>/v1/embeddings&lt;/code>&lt;/strong>. The client does not notice the silicon. That is what lets you put ingestion on CPU and online on GPU without rewriting anything.&lt;/p>
&lt;h2 id="why-a-dedicated-server-and-not-calling-the-model-inline">Why a dedicated server and not calling the model inline&lt;/h2>
&lt;p>The temptation, especially in the prototype, is to load the embedder into the application process and call &lt;code>model.encode()&lt;/code> directly. It works in the demo and breaks in production for five reasons, all solved by the dedicated server:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Batching.&lt;/strong> Your ingestion code processes documents; your query code embeds one query at a time. Inline, each &lt;code>encode()&lt;/code> is an independent forward, one press stroke per sheet. A dedicated server joins requests from different origins (ingestion + several concurrent queries) into a single batch and amortises the fixed cost. This is what really multiplies throughput, and you cannot do it if the model lives inside each isolated process.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Concurrency and backpressure.&lt;/strong> Under load, what happens when 10,000 requests arrive at once? Inline, you run out of memory or queue without control. TEI has &lt;code>--max-concurrent-requests&lt;/code>: above that limit it rejects instead of degrading everyone, which is the correct way to manage overload (&lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/docs/source/en/cli_arguments.md">TEI CLI args&lt;/a>).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Metrics.&lt;/strong> TEI exposes &lt;code>/metrics&lt;/code> in Prometheus format: latencies, batch size, tokens/s, queued requests. Inline you have none of this without hand-instrumenting every &lt;code>encode()&lt;/code>. To &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">observe the data plane&lt;/a> you need that endpoint.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>A reusable HTTP contract.&lt;/strong> The same server is consumed by batch ingestion, by online query-time and by any other service that needs vectors. One copy of the model in memory, one version point, one place to change the model. Inline, each service loads its own copy and the version drifts on its own.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Lifecycle separation.&lt;/strong> The embedder is updated, restarted or scaled without touching the application. If tomorrow you swap &lt;code>bge-m3&lt;/code> for &lt;code>Snowflake Arctic&lt;/code>, you change the server container, not the code of N services. (Remember the &lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">embedder drift&lt;/a> trap: changing embedder forces a reindex; having a single server makes that change atomic and auditable.)&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>It is the same argument for why nobody serves an LLM with &lt;code>transformers.generate()&lt;/code> in production: you serve it with a dedicated engine. The embedder is no different.&lt;/p>
&lt;h2 id="dynamic-batching-the-mechanics-of-the-press">Dynamic batching: the mechanics of the press&lt;/h2>
&lt;p>Here is the performance engine. TEI does &lt;strong>token-based dynamic batching&lt;/strong>: instead of processing requests one at a time or grouping them by a fixed number of requests, it groups them by token budget (&lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/README.md">TEI README&lt;/a>, &lt;a href="https://github.com/huggingface/text-embeddings-inference/discussions/367">Discussion #367&lt;/a>).&lt;/p>
&lt;p>Two knobs rule:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>--max-batch-tokens&lt;/code>&lt;/strong> (the default depends on the build, typically 16384): the total tokens that fit in a batch. With &lt;code>max-batch-tokens=1000&lt;/code> you get 10 requests of 100 tokens, or one of 1000. The docs say it clearly: this number &lt;em>should be as large as possible until the model becomes compute-bound&lt;/em>. It is the size of the press tray, &lt;strong>measured in tokens, not in parts&lt;/strong>, because the real cost scales with tokens, not with number of texts.&lt;/li>
&lt;li>&lt;strong>&lt;code>--max-concurrent-requests&lt;/code>&lt;/strong> (default 512): how many requests it admits in flight before rejecting. It is the backpressure control, not the batching one: it protects against surges by returning an error instead of queuing indefinitely.&lt;/li>
&lt;/ul>
&lt;p>The policy: TEI keeps an internal queue. When there is work, it fills a batch up to the &lt;code>max-batch-tokens&lt;/code> budget (or until there are no more requests waiting) and sends it to the model in one stroke. Requests that arrive while the previous batch is being processed wait in the queue and go into the next one. Watch out for a real operational detail: if &lt;code>max-batch-tokens&lt;/code> is smaller than the model&amp;rsquo;s maximum input length, you can trigger a loop where a long request never fits into a batch (&lt;a href="https://github.com/huggingface/text-embeddings-inference/issues/723">Issue #723&lt;/a>); the token budget has to be at least as large as the longest sequence you accept.&lt;/p>
&lt;h3 id="the-number-throughput-with-batching-vs-without-batching">The number: throughput with batching vs without batching&lt;/h3>
&lt;p>Let us model the press. Let $C$ be the fixed cost of a forward pass (loading weights, launching kernels) and $c$ the marginal cost per sequence inside the batch. For a batch of size $B$, the stroke time is roughly:&lt;/p>
$$t(B) \approx C + c \cdot B$$
&lt;p>as long as the batch fits in the hardware (memory-bandwidth-bound, which is the normal regime of a small encoder on GPU). The throughput, sequences per second, is:&lt;/p>
$$\text{throughput}(B) = \frac{B}{t(B)} = \frac{B}{C + c \cdot B}$$
&lt;p>Let us put in concrete numbers, indicative but of the right order for &lt;code>bge-m3&lt;/code> (568M) on a GPU serving ~256-token sequences. Say $C = 4$ ms of fixed cost per stroke and $c = 0.5$ ms per marginal sequence.&lt;/p>
&lt;p>&lt;strong>Without batching&lt;/strong> ($B=1$):&lt;/p>
$$t(1) = 4 + 0.5 = 4.5 \text{ ms} \quad\Rightarrow\quad \text{throughput} = \frac{1}{4.5\text{ms}} \approx 222 \text{ sec/s}$$
&lt;p>&lt;strong>With batching&lt;/strong> ($B=32$):&lt;/p>
$$t(32) = 4 + 0.5 \times 32 = 20 \text{ ms} \quad\Rightarrow\quad \text{throughput} = \frac{32}{20\text{ms}} = 1600 \text{ sec/s}$$
&lt;p>The batch of 32 multiplies throughput by ~7.2× compared with serving one at a time, because the fixed cost $C$, which at $B=1$ was 89% of the time, is spread across 32. And the asymptotic limit, as $B \to \infty$, is $1/c = 2000$ sec/s: the press cannot go faster than its marginal cost, and we are already at 80% of that ceiling with $B=32$. Going up to $B=128$ would give ~1969 sec/s, a marginal improvement in exchange for a good deal more latency and memory. &lt;strong>Batching has diminishing returns&lt;/strong>: most of the gain is in going from 1 to a few dozen.&lt;/p>
&lt;p>The &lt;strong>latency trade-off&lt;/strong> is the other face. The request that arrived first in the batch of 32 does not see its response until the whole stroke finishes: it waits for the tray to fill plus the 20 ms of the forward. If the 32 requests arrived within a window of, say, 5 ms, the first request suffers ~5 ms of batching wait + 20 ms of compute = 25 ms of latency, against the 4.5 ms it would have had being served alone. The &lt;strong>p99 latency gets worse&lt;/strong>, that is the price of the full tray, but in exchange the system absorbs 7× more load with the same silicon. That is why the correct configuration &lt;strong>depends on the use case&lt;/strong>: in batch ingestion (no user waiting) you maximise &lt;code>max-batch-tokens&lt;/code> and latency does not matter; online (user waiting) you bound the wait window to keep p99 under control even at the cost of some throughput.&lt;/p>
&lt;div class="diagram" style="max-width:840px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 840 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dynamic batching in TEI: incoming requests, queue, token-based batch, forward, responses">
&lt;text x="420" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Token-based dynamic batching in TEI&lt;/text>
&lt;text x="80" y="60" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Requests&lt;/text>
&lt;text x="80" y="76" text-anchor="middle" font-size="10" fill="currentColor">arrive separately&lt;/text>
&lt;rect x="30" y="92" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="107" text-anchor="middle" font-size="10" fill="currentColor">query A · 80 tok&lt;/text>
&lt;rect x="30" y="120" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="135" text-anchor="middle" font-size="10" fill="currentColor">query B · 120 tok&lt;/text>
&lt;rect x="30" y="148" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="163" text-anchor="middle" font-size="10" fill="currentColor">ingestion · 256 tok&lt;/text>
&lt;rect x="30" y="176" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="191" text-anchor="middle" font-size="10" fill="currentColor">ingestion · 256 tok&lt;/text>
&lt;rect x="30" y="204" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="219" text-anchor="middle" font-size="10" fill="currentColor">query C · 60 tok&lt;/text>
&lt;path d="M135,150 L185,150" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#tb)"/>
&lt;rect x="190" y="80" width="150" height="160" rx="6" fill="#f59e0b" opacity="0.12" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="265" y="102" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Internal queue&lt;/text>
&lt;text x="265" y="120" text-anchor="middle" font-size="10" fill="currentColor">fills the tray up to&lt;/text>
&lt;text x="265" y="134" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">max-batch-tokens&lt;/text>
&lt;rect x="210" y="146" width="110" height="18" rx="3" fill="none" stroke="#f59e0b" stroke-width="1"/>
&lt;rect x="210" y="168" width="110" height="18" rx="3" fill="none" stroke="#f59e0b" stroke-width="1"/>
&lt;rect x="210" y="190" width="110" height="18" rx="3" fill="none" stroke="#f59e0b" stroke-width="1"/>
&lt;text x="265" y="226" text-anchor="middle" font-size="10" font-style="italic" fill="currentColor">bounded window or full budget&lt;/text>
&lt;path d="M345,160 L395,160" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#tb)"/>
&lt;text x="370" y="152" text-anchor="middle" font-size="9" fill="currentColor">batch&lt;/text>
&lt;rect x="400" y="110" width="180" height="100" rx="6" fill="#ef4444" opacity="0.10" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="490" y="134" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">ONE forward pass&lt;/text>
&lt;text x="490" y="152" text-anchor="middle" font-size="10" fill="currentColor">GPU (CUDA, FlashAttn)&lt;/text>
&lt;text x="490" y="166" text-anchor="middle" font-size="10" fill="currentColor">or CPU (ONNX/AMX int8)&lt;/text>
&lt;text x="490" y="184" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">cost C + c·B&lt;/text>
&lt;text x="490" y="200" text-anchor="middle" font-size="9" font-style="italic" fill="currentColor">"one press stroke"&lt;/text>
&lt;path d="M585,160 L635,160" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#tb)"/>
&lt;text x="730" y="100" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Responses&lt;/text>
&lt;rect x="660" y="112" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="126" text-anchor="middle" font-size="10" fill="currentColor">vector A&lt;/text>
&lt;rect x="660" y="138" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="152" text-anchor="middle" font-size="10" fill="currentColor">vector B&lt;/text>
&lt;rect x="660" y="164" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="178" text-anchor="middle" font-size="10" fill="currentColor">ingestion vectors&lt;/text>
&lt;rect x="660" y="190" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="204" text-anchor="middle" font-size="10" fill="currentColor">vector C&lt;/text>
&lt;line x1="30" y1="270" x2="800" y2="270" stroke="currentColor" stroke-width="0.8"/>
&lt;text x="30" y="292" font-size="11" font-weight="700" fill="currentColor">The trade-off:&lt;/text>
&lt;text x="30" y="312" font-size="11" fill="currentColor">large batch → high throughput (fixed cost C spread over B) but worse p99 (the 1st waits for the fill)&lt;/text>
&lt;text x="30" y="330" font-size="11" fill="currentColor">batch=1 → minimum latency per request but dreadful throughput (one stroke per sheet)&lt;/text>
&lt;text x="30" y="352" font-size="11" font-style="italic" fill="currentColor">Ingestion: maximise max-batch-tokens. Online: bound the wait window to protect p99.&lt;/text>
&lt;defs>&lt;marker id="tb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;/div>
&lt;h2 id="deployment-compose-for-embedder-and-reranker">Deployment: compose for embedder and reranker&lt;/h2>
&lt;p>TEI is deployed as a container. The key arguments: &lt;code>--model-id&lt;/code> (the model from the Hub), &lt;code>--pooling&lt;/code> (how it aggregates tokens into the vector: &lt;code>cls&lt;/code>, &lt;code>mean&lt;/code>, &lt;code>splade&lt;/code>, &lt;code>last-token&lt;/code>), &lt;code>--dtype&lt;/code> (precision), and the batching knobs already seen. Two separate services, since the embedder and the reranker are &lt;strong>different models and different endpoints&lt;/strong>, each with its own container.&lt;/p>
&lt;h3 id="embedder-bge-m3-on-gpu">Embedder (&lt;code>bge-m3&lt;/code>) on GPU&lt;/h3>
&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"># docker-compose: TEI serving bge-m3 as an embedder, on GPU&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">services&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">tei-embed&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">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:cuda-1.9&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">command&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">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">--max-batch-tokens=16384 # big tray&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">the press performs&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">--max-concurrent-requests=512 # backpressure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">reject above this&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8081:80&amp;#34;&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">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./hf-cache:/data&amp;#34;&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">deploy&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">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">reservations&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">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">driver: nvidia, count: 1, capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">gpu]}]&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="c"># exposes /v1/embeddings (OpenAI), /embed, /embed_sparse, /metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="reranker-bge-reranker-v2-m3-on-gpu">Reranker (&lt;code>bge-reranker-v2-m3&lt;/code>) on GPU&lt;/h3>
&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="w"> &lt;/span>&lt;span class="nt">tei-rerank&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">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:cuda-1.9&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">command&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-reranker-v2-m3 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cross-encoder, ~568M&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="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=256&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8082:80&amp;#34;&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">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./hf-cache:/data&amp;#34;&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">deploy&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">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">reservations&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">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">driver: nvidia, count: 1, capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">gpu]}]&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="c"># exposes /rerank — invoked ONLY over top-k (20/50), not over hundreds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>bge-reranker-v2-m3&lt;/code> is a &lt;strong>~568M cross-encoder&lt;/strong> built on &lt;code>bge-m3&lt;/code>, multilingual, that takes (query, document) and emits a relevance score directly, without producing a reusable vector (&lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">model card&lt;/a>, &lt;a href="https://bge-model.com/bge/bge_reranker_v2.html">BGE docs&lt;/a>). It carries no &lt;code>--pooling&lt;/code> because it emits no embedding: it emits a scalar.&lt;/p>
&lt;h3 id="cpu-for-batch-ingestion">CPU for batch ingestion&lt;/h3>
&lt;p>For the CPU fleet, it is enough to change the image and the dtype; the HTTP contract is identical:&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="w"> &lt;/span>&lt;span class="nt">tei-embed-cpu&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">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:cpu-1.6&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">command&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">dtype=float16 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># on CPU, ONNX int8 if the export supports it&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">--max-batch-tokens=8192 # smaller tray&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">the CPU saturates sooner&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8083:80&amp;#34;&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">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./hf-cache:/data&amp;#34;&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="c"># ONNX/MKL backend: exploits AVX-512+VNNI / AMX on 4th-gen Xeon&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any OpenAI client embeds against the endpoint without knowing what is behind it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://tei-embed:80/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># api_key ignored&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embeddings&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">input&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;user query&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">vec&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embedding&lt;/span> &lt;span class="c1"># same contract as OpenAI; the silicon is invisible&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the rerank, through its native (non-OpenAI) endpoint:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">requests&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">docs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;retrieved chunk 1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;retrieved chunk 2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;...top-20 from retrieval...&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">requests&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;http://tei-rerank:80/rerank&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">json&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;texts&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">docs&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ranked&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># [{index, score}, ...] ordered by relevance&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="integration-how-ingestion-and-the-gateway-consume-it">Integration: how ingestion and the gateway consume it&lt;/h2>
&lt;p>The TEI server has &lt;strong>two clients&lt;/strong> with opposite profiles, and that is precisely the reason to centralise it:&lt;/p>
&lt;p>&lt;strong>Ingestion&lt;/strong> (batch, no SLA) calls &lt;code>/v1/embeddings&lt;/code> or &lt;code>/embed&lt;/code> with &lt;strong>large lots&lt;/strong> of chunks. Here throughput is what matters: ingestion pushes thousands of texts and TEI groups them into large batches against the high &lt;code>max-batch-tokens&lt;/code>. It is the case of the press with a full tray. Ingestion then writes the vectors (dense + sparse) into the vector DB; the specific PDF-to-indexed-chunk pipeline is the sister piece of this series in preparation, and meanwhile the &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant schema in microservices&lt;/a> covers the structure of the pipeline.&lt;/p>
&lt;p>&lt;strong>The gateway&lt;/strong> (query-time, online) consumes TEI through two different routes:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Embedding the query&lt;/strong>: a single call to &lt;code>/v1/embeddings&lt;/code> with a short text. The gateway, &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">LiteLLM&lt;/a> or another L7 router, treats it as one more OpenAI embeddings provider, routed by model. That embedded query goes to the vector DB for the hybrid search.&lt;/li>
&lt;li>&lt;strong>Reranking the top-k&lt;/strong>: after retrieving 20-50 candidates from the retriever, the gateway calls &lt;code>/rerank&lt;/code> on the reranker server with the query and the candidate list, and keeps the best ones. Since &lt;code>/rerank&lt;/code> is not an OpenAI standard, LiteLLM exposes it through its own reranking contract (&lt;a href="https://github.com/BerriAI/litellm/issues/8372">LiteLLM rerank with TEI&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>The full query-time pattern: &lt;em>embed query (TEI dense) → search (vector DB, &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a>) → rerank top-k (TEI rerank) → send query + top-5 to the LLM&lt;/em>. Two different TEI endpoints, one for dense and another for rerank, both behind the gateway, both swappable.&lt;/p>
&lt;h2 id="sizing-memory-replicas-and-latency-budget">Sizing: memory, replicas and latency budget&lt;/h2>
&lt;h3 id="footprint-of-a-568m-encoder">Footprint of a 568M encoder&lt;/h3>
&lt;p>The weight footprint of &lt;code>bge-m3&lt;/code> (568M params) depends only on precision:&lt;/p>
$$\text{fp16: } 568 \times 10^6 \times 2 \text{ B} \approx 1.14 \text{ GB} \qquad \text{int8: } 568 \times 10^6 \times 1 \text{ B} \approx 0.57 \text{ GB}$$
&lt;p>On top of this comes the space for the batch&amp;rsquo;s &lt;strong>temporary activations&lt;/strong> (proportional to &lt;code>max-batch-tokens&lt;/code> × hidden dimension) and, in TEI, the FlashAttention/cuda graphs structures. In practice, a &lt;code>bge-m3&lt;/code> in fp16 is served comfortably in &lt;strong>~6 GB of VRAM&lt;/strong> even at high batch, and in int8 on CPU the whole model (0.57 GB) fits in the cache and RAM of any server without blinking, activating the AMX/VNNI paths that make it viable, which is exactly the argument of &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on the CPU&lt;/a>.&lt;/p>
&lt;p>&lt;strong>How many replicas fit?&lt;/strong> If we reserve a &lt;strong>10 GB&lt;/strong> slice of an H100 for serving embeddings online, with an effective footprint of ~2.5 GB per replica (fp16 weights + activations + overhead), around &lt;strong>3-4 replicas&lt;/strong> of &lt;code>bge-m3&lt;/code> fit in that slice, splitting the QPS between them. In int8 the footprint drops and more fit, at the cost of a full point of quality. For the reranker, the weight footprint is the same (568M), but its batch pattern is different (query-doc pairs), so it is worth sizing it separately.&lt;/p>
&lt;h3 id="latency-budget-short-query-vs-top-50-rerank">Latency budget: short query vs top-50 rerank&lt;/h3>
&lt;p>Here is the fundamental asymmetry between embedder and reranker, and it is what decides the sizing.&lt;/p>
&lt;p>&lt;strong>Embedding a short query&lt;/strong> (online): it is &lt;strong>one&lt;/strong> forward over a text of tens of tokens. On GPU, inside a dynamic batch, it is &lt;strong>tens of ms&lt;/strong> including the batching wait, the order of the earlier numerical example, ~5-25 ms. It is cheap and constant: a query is a query, no matter how many documents are in the corpus. That is why embedding online fits easily into an interactive latency budget.&lt;/p>
&lt;p>&lt;strong>Reranking the top-50&lt;/strong> (online): a cross-encoder does &lt;strong>not&lt;/strong> produce a reusable vector; it evaluates &lt;strong>every pair&lt;/strong> (query, document) in a forward. The cost is &lt;strong>linear in the number of candidates&lt;/strong>:&lt;/p>
$$\text{cost}_{\text{rerank}} \propto k \times \text{forward}(\text{query} + \text{doc})$$
&lt;p>Reranking a top-50 is &lt;strong>50 forwards&lt;/strong> of (query + doc) pairs, where each pair is longer than the query alone (query + ~256-token chunk). TEI groups them into batches, and batching helps again there, but the &lt;strong>total work&lt;/strong> is ~50× that of embedding one query. If embedding a query costs of the order of 10-20 ms, reranking 50 candidates costs of the order of &lt;strong>hundreds of ms&lt;/strong> depending on the batch and the hardware. Hence the operational rule repeated throughout the series: &lt;strong>broad, cheap recall in the retriever, precision rerank over FEW candidates&lt;/strong>. Reranking a top-20/50 fits in the online budget; reranking hundreds at high QPS does not, and that is where the reranker demands a dedicated GPU or a larger slice. The detail of why the cross-encoder is expensive but precise is in &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker and hybrid retrieval&lt;/a>.&lt;/p>
&lt;p>The query-time latency budget, added up:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Typical cost (order)&lt;/th>
&lt;th>Scales with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Embed query (TEI dense)&lt;/td>
&lt;td>~10-20 ms&lt;/td>
&lt;td>constant (1 short text)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hybrid search (vector DB, HNSW+sparse)&lt;/td>
&lt;td>single-digit ms&lt;/td>
&lt;td>index size (sublinear)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rerank top-k (TEI cross-encoder)&lt;/td>
&lt;td>~100-300 ms&lt;/td>
&lt;td>&lt;strong>linear in k&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Generation (LLM, outside TEI)&lt;/td>
&lt;td>seconds&lt;/td>
&lt;td>output tokens&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reranker is the most expensive stage of the data plane, and the only one that scales badly with the width of retrieval. Size it as the critical component.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us come down to the cluster of the series: &lt;strong>4×H100 SXM 80 GB&lt;/strong> plus a generic CPU fleet (Xeon with AMX, NUCs). TEI lives in &lt;strong>two places&lt;/strong> depending on the load profile, and the identical OpenAI contract is what makes each place swappable.&lt;/p>
&lt;p>&lt;strong>TEI-CPU on the fleet → batch ingestion.&lt;/strong> Re-embedding the corpus is throughput-bound work with no latency SLA: its place is the CPU fleet with &lt;code>bge-m3&lt;/code> in int8 (ONNX/AMX). Here you maximise &lt;code>max-batch-tokens&lt;/code> because nobody is waiting, and the press performs with the tray brimming. No H100 should spend a cycle reindexing a corpus that changes once a day, which is exactly the bad split that &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on the CPU&lt;/a> takes apart. The CPU fleet scales horizontally and cheaply; the corpus is chopped up and spread across nodes.&lt;/p>
&lt;p>&lt;strong>TEI-GPU in a MIG slice → high-QPS online embeddings and rerank.&lt;/strong> Query-time has a latency SLA and, if the system serves many requests per second, it needs the GPU&amp;rsquo;s throughput and low latency. But a 568M encoder &lt;strong>does not deserve a whole H100&lt;/strong>: it takes ~2.5 GB effective, and leaving it 80 GB wastes 97% of the card. The right answer is a &lt;strong>MIG slice&lt;/strong>: partition an H100 into hardware-isolated instances and give TEI one of the small slices (a &lt;code>1g.10gb&lt;/code>, for example), leaving the large slices for generation or for other tenants. The online embedder and reranker live there, with memory and compute isolation guaranteed by the partitioning. The how of the partitioning, MIG, MPS, time-slicing and when to use each, is in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a>; the reading for TEI is direct: &lt;strong>MIG gives the hard isolation that an online service with bounded latency wants&lt;/strong>, while batch ingestion on CPU does not even touch the GPU.&lt;/p>
&lt;p>The split, in one sentence: &lt;strong>ingestion squeezes throughput/€ on CPU with a full tray; online squeezes latency/QPS in a MIG slice of an H100; both speak the same &lt;code>/v1/embeddings&lt;/code> and &lt;code>/rerank&lt;/code>, so moving load from one side to the other is changing a URL in the gateway.&lt;/strong>&lt;/p>
&lt;p>If at some point you need a &lt;strong>7B embedder&lt;/strong> (gte-Qwen2, NV-Embed) whose quality &lt;code>bge-m3&lt;/code> cannot reach, that is no longer small-encoder work: it drags an LLM&amp;rsquo;s cost profile with it and is served where 7Bs are served, with vLLM &lt;code>--task embed&lt;/code> in a large GPU slice, not with TEI-CPU. But it is the occasional exception, not the base load.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>TEI is the stamping press of the RAG data plane: a specialised server that only stamps vectors and scores, and that &lt;strong>performs far better when it fills the tray before pressing&lt;/strong>. The technical piece that gives the performance is token-based dynamic batching, controlled by &lt;code>max-batch-tokens&lt;/code> and &lt;code>max-concurrent-requests&lt;/code>, with a clear trade-off between throughput and p99 latency that is resolved differently in ingestion (tray brimming, no SLA) and online (bounded window, p99 protected). The architectural virtue is the OpenAI-compatible contract on &lt;code>/v1/embeddings&lt;/code>, which makes the embedder swappable without touching code, plus the native &lt;code>/embed_sparse&lt;/code> and &lt;code>/rerank&lt;/code> endpoints for the sparse head of &lt;code>bge-m3&lt;/code> and for the &lt;code>bge-reranker-v2-m3&lt;/code> cross-encoder. The numbers drive the sizing: a 568M encoder fits in gigabytes, not tens of gigabytes, so several replicas live in a MIG slice; embedding a query is cheap and constant, but reranking is linear in candidates and is the critical stage of the latency budget. On the 4×H100 cluster, the right split is TEI-CPU for batch ingestion and TEI-GPU in a MIG slice for high-QPS online: two presses, two silicons, one contract.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&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 embeddings ingestion goes to the CPU fleet; TEI-CPU is the concrete engine of that plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: dense, sparse and multivector&lt;/a> — which model you serve with TEI and why &lt;code>bge-m3&lt;/code> is the multilingual default; this post serves what that one chooses.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval: fundamentals&lt;/a> — why the cross-encoder is expensive but precise, and the rule of reranking few candidates that sizes TEI&amp;rsquo;s &lt;code>/rerank&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments: from the H100 cluster to the NUC&lt;/a> — the concrete hardware of the CPU fleet (Xeon AMX, NUC) where TEI-CPU runs for ingestion.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic cache for RAG&lt;/a> — the layer that avoids calling TEI when the query has already been answered, saving even the cost of the online embedding.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — the MIG partitioning where online TEI-GPU lives, coexisting with generation on the same H100.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">End-to-end document ingestion: from PDF to indexed chunk&lt;/a> — the pipeline that consumes TEI&amp;rsquo;s &lt;code>/v1/embeddings&lt;/code> to populate the index (sister piece).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hugging Face — &lt;em>Text Embeddings Inference (TEI)&lt;/em>, repo y docs (Rust, backends CPU ONNX/MKL y CUDA, endpoints OpenAI-compatibles). &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a> · &lt;a href="https://huggingface.co/docs/text-embeddings-inference">https://huggingface.co/docs/text-embeddings-inference&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>TEI Quick Tour&lt;/em> (endpoints &lt;code>/embed&lt;/code>, &lt;code>/embed_sparse&lt;/code>, &lt;code>/rerank&lt;/code>, &lt;code>/predict&lt;/code>, &lt;code>/v1/embeddings&lt;/code>). &lt;a href="https://huggingface.co/docs/text-embeddings-inference/quick_tour">https://huggingface.co/docs/text-embeddings-inference/quick_tour&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>TEI CLI arguments&lt;/em> (&lt;code>--model-id&lt;/code>, &lt;code>--pooling&lt;/code>, &lt;code>--dtype&lt;/code>, &lt;code>--max-batch-tokens&lt;/code>, &lt;code>--max-concurrent-requests&lt;/code>). &lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/docs/source/en/cli_arguments.md">https://github.com/huggingface/text-embeddings-inference/blob/main/docs/source/en/cli_arguments.md&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>TEI README / Discussion #367&lt;/em> (token-based dynamic batching; semántica de &lt;code>max-batch-tokens&lt;/code>). &lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/README.md">https://github.com/huggingface/text-embeddings-inference/blob/main/README.md&lt;/a> · &lt;a href="https://github.com/huggingface/text-embeddings-inference/discussions/367">https://github.com/huggingface/text-embeddings-inference/discussions/367&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>BGE-M3 model card&lt;/em> (568M, XLM-RoBERTa, dense+sparse+colbert, MIT). &lt;a href="https://huggingface.co/BAAI/bge-m3">https://huggingface.co/BAAI/bge-m3&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>bge-reranker-v2-m3 model card&lt;/em> (cross-encoder ~568M sobre bge-m3, multilingüe). &lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">https://huggingface.co/BAAI/bge-reranker-v2-m3&lt;/a> · &lt;a href="https://bge-model.com/bge/bge_reranker_v2.html">https://bge-model.com/bge/bge_reranker_v2.html&lt;/a>&lt;/li>
&lt;li>Chen, J., et al. &lt;em>BGE M3-Embedding&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>BerriAI — &lt;em>LiteLLM: proveedor TEI para &lt;code>/rerank&lt;/code>&lt;/em>. &lt;a href="https://github.com/BerriAI/litellm/issues/8372">https://github.com/BerriAI/litellm/issues/8372&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Taking RAG to the CPU: separating the data plane from the generation plane</title><link>https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/</link><pubDate>Thu, 11 Jun 2026 03:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/</guid><description>&lt;blockquote>
&lt;p>Third piece in an operational series about squeezing a generic on-premise LLM cluster of &lt;strong>4×H100 SXM 80 GB&lt;/strong>. Its siblings: &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing one GPU between workloads&lt;/a> (time-slicing, MPS, MIG) and &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">serving several models on one GPU&lt;/a> (swap + sleep) attack the split &lt;em>inside&lt;/em> the GPU. This one attacks the split &lt;em>outside&lt;/em>: which parts of RAG never need to touch the GPU at all. The closing piece of the series, the end-to-end sovereign assistant (fourth instalment, in preparation), assembles the complete system where these pieces fit together.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG system is not one thing, it is three phases with opposing compute profiles, and putting them all on the GPU &amp;ldquo;because it is AI&amp;rdquo; is an allocation mistake. (1) &lt;strong>Build/ingest&lt;/strong>, embedding the corpus and building the index, is batch, throughput-bound work with no latency SLA: its natural home is the CPU. (2) &lt;strong>Query-time retrieval&lt;/strong>, embedding the query, HNSW search, RRF fusion, lightweight rerank, is mostly CPU, with caveats only for heavy reranking; vector search always ran on CPU, even in stacks sold as &amp;ldquo;GPU&amp;rdquo;. (3) &lt;strong>Generation&lt;/strong>, the LLM producing the answer, is latency-bound, and there the GPU is irreplaceable: a 7B on CPU gives a &lt;em>time to first token&lt;/em> of seconds, unacceptable for chat. The technical key to why (1) and (2) fit on CPU: the embedder is not an LLM. &lt;code>bge-m3&lt;/code> is &lt;strong>around 568M parameters&lt;/strong> (an XLM-RoBERTa encoder), not 7B+; in int8 it occupies about 580 MB and activates fast integer compute paths (Intel &lt;strong>AVX-512 + VNNI + AMX&lt;/strong> on 4th-gen Xeon and later; &lt;strong>NEON SDOT/UDOT&lt;/strong> on ARM). The runtimes are ready: &lt;strong>TEI&lt;/strong> with a CPU backend (same OpenAI API &lt;code>/v1/embeddings&lt;/code> and &lt;code>/rerank&lt;/code>), Qdrant&amp;rsquo;s &lt;strong>fastembed&lt;/strong> (ONNX-CPU), &lt;code>bge-m3&lt;/code> in ONNX int8 with its three heads (dense/sparse/ColBERT). The Intel + Hugging Face blog post with Optimum Intel and fastRAG reports up to around 10× on indexing for BGE-large int8 on a 4th-gen Xeon (their benchmark figure, encoding-only; I quote it and qualify it below). The operational conclusion: &lt;strong>separate the data plane (CPU) from the generation plane (GPU)&lt;/strong>. In the 4×H100 cluster, no H100 should be spent re-indexing a corpus that changes once a day. That goes to the generic CPU fleet (Xeon AMX, NUCs), and the H100s are reserved for generation and, at most, for rerank peaks or large 7B embedders. What does not go down to CPU: interactive generation, massive reranking at high QPS, re-indexing with a strict real-time SLA, and 7B embedders (gte-Qwen2, NV-Embed).&lt;/p>
&lt;h2 id="the-analogy-the-library-and-the-librarian">The analogy: the library and the librarian&lt;/h2>
&lt;p>Picture a serious research library. There are three distinct jobs, done by different people, on different clocks.&lt;/p>
&lt;p>The first is &lt;strong>cataloguing&lt;/strong>. Boxes of new books arrive; someone opens them, classifies them, assigns a shelf mark, indexes them in the catalogue and puts them on the right shelf. It is patient background work, done at night or between other tasks. Nobody is standing over you with a stopwatch waiting for today&amp;rsquo;s batch to be catalogued: what matters is that tomorrow it is done and done well. It is pure throughput: how many books you catalogue per hour, not how long you take on one specific book. This is &lt;strong>ingestion&lt;/strong>.&lt;/p>
&lt;p>The second is &lt;strong>handling an enquiry at the desk&lt;/strong>. A reader turns up and asks about a topic. The librarian goes to the catalogue, which already exists, finds half a dozen relevant shelf marks, fetches them and puts the books on the desk. It is fast, light, and consists of searching an index that already exists, not building it. This is &lt;strong>retrieval&lt;/strong>.&lt;/p>
&lt;p>The third is &lt;strong>writing a reasoned report&lt;/strong> from those books. The reader, or an expert you commission, reads the six books, compares them, synthesises, and writes an argued answer with citations. This is slow, demands a very well-trained mind, and the reader is waiting: here there is a human stopwatch. This is &lt;strong>generation&lt;/strong>, the LLM.&lt;/p>
&lt;p>The moral is about staff allocation. You do not put your star writer, expensive, scarce, with a queue of people waiting for reports, to catalogue boxes of books at dawn. Cataloguing is done by a large, cheap team that works through the night without rushing. The star writer touches only what genuinely needs their mind: writing. In our system, the star writer is the H100, and cataloguing at dawn is corpus ingestion. Spending the H100 on re-indexing is exactly the error of putting the writer to work labelling boxes.&lt;/p>
&lt;p>The rest of the post is, essentially, which parts of the library work the cheap CPU team can do (almost all of them) and which is irreducibly the GPU writer&amp;rsquo;s job (only the last one).&lt;/p>
&lt;h2 id="the-three-phases-and-their-compute-profiles">The three phases and their compute profiles&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The three RAG phases: build and retrieval on CPU, generation on GPU">
&lt;text x="410" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">The three RAG phases and where each one runs&lt;/text>
&lt;rect x="20" y="50" width="250" height="190" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="145" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">1 · BUILD / INGEST&lt;/text>
&lt;text x="145" y="94" text-anchor="middle" font-size="11" fill="currentColor">corpus chunking&lt;/text>
&lt;text x="145" y="111" text-anchor="middle" font-size="11" fill="currentColor">dense embedding (bge-m3)&lt;/text>
&lt;text x="145" y="128" text-anchor="middle" font-size="11" fill="currentColor">sparse / SPLADE head&lt;/text>
&lt;text x="145" y="145" text-anchor="middle" font-size="11" fill="currentColor">build HNSW index&lt;/text>
&lt;rect x="45" y="162" width="200" height="26" fill="#22c55e" stroke="none"/>
&lt;text x="145" y="180" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">[CPU]&lt;/text>
&lt;text x="145" y="208" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">throughput-bound · batch&lt;/text>
&lt;text x="145" y="224" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">no latency SLA · overnight&lt;/text>
&lt;rect x="285" y="50" width="250" height="190" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="410" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">2 · RETRIEVAL (query-time)&lt;/text>
&lt;text x="410" y="94" text-anchor="middle" font-size="11" fill="currentColor">query embedding&lt;/text>
&lt;text x="410" y="111" text-anchor="middle" font-size="11" fill="currentColor">HNSW search (dense)&lt;/text>
&lt;text x="410" y="128" text-anchor="middle" font-size="11" fill="currentColor">sparse search + RRF&lt;/text>
&lt;text x="410" y="145" text-anchor="middle" font-size="11" fill="currentColor">rerank top-20/50 (light)&lt;/text>
&lt;rect x="310" y="162" width="200" height="26" fill="#3b82f6" stroke="none"/>
&lt;text x="410" y="180" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">[CPU] (rerank: caveat)&lt;/text>
&lt;text x="410" y="208" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">low but tolerable latency&lt;/text>
&lt;text x="410" y="224" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">tens of ms · HNSW always CPU&lt;/text>
&lt;rect x="550" y="50" width="250" height="190" fill="none" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="675" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">3 · GENERATION&lt;/text>
&lt;text x="675" y="98" text-anchor="middle" font-size="11" fill="currentColor">the LLM produces the answer&lt;/text>
&lt;text x="675" y="115" text-anchor="middle" font-size="11" fill="currentColor">prefill of the augmented context&lt;/text>
&lt;text x="675" y="132" text-anchor="middle" font-size="11" fill="currentColor">token-by-token decode&lt;/text>
&lt;rect x="575" y="162" width="200" height="26" fill="#ef4444" stroke="none"/>
&lt;text x="675" y="180" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">[GPU]&lt;/text>
&lt;text x="675" y="208" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">latency-bound · TTFT matters&lt;/text>
&lt;text x="675" y="224" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">7B on CPU = TTFT in seconds&lt;/text>
&lt;path d="M270,145 L285,145" stroke="currentColor" stroke-width="1.6" fill="none"/>
&lt;path d="M535,145 L550,145" stroke="currentColor" stroke-width="1.6" fill="none"/>
&lt;line x1="20" y1="270" x2="800" y2="270" stroke="currentColor" stroke-width="1"/>
&lt;text x="20" y="290" font-size="12" font-weight="700" fill="currentColor">DATA PLANE (CPU)&lt;/text>
&lt;text x="520" y="290" font-size="12" font-weight="700" fill="currentColor">GENERATION PLANE (GPU)&lt;/text>
&lt;rect x="20" y="300" width="510" height="20" fill="#22c55e" opacity="0.25" stroke="#22c55e" stroke-width="1"/>
&lt;rect x="550" y="300" width="250" height="20" fill="#ef4444" opacity="0.25" stroke="#ef4444" stroke-width="1"/>
&lt;text x="275" y="314" text-anchor="middle" font-size="11" fill="currentColor">phases 1 and 2 · generic CPU fleet · cheap and horizontal&lt;/text>
&lt;text x="675" y="314" text-anchor="middle" font-size="11" fill="currentColor">phase 3 · scarce GPU&lt;/text>
&lt;text x="410" y="345" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">The system boundary falls between retrieval and generation, not between "AI" and "non-AI"&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The confusion that GPU over-provisioning feeds on is treating &amp;ldquo;RAG&amp;rdquo; as a monolithic block that &amp;ldquo;uses AI, therefore goes on the GPU&amp;rdquo;. No. RAG is a data pipeline with a generative model plugged in at the end. The correct architectural boundary does not separate &amp;ldquo;what uses models&amp;rdquo; from &amp;ldquo;what does not&amp;rdquo;, because both sides use models, but throughput-bound from latency-bound, which is the same as separating the data plane from the generation plane.&lt;/p>
&lt;h2 id="why-ingestion-fits-on-cpu-the-embedder-is-not-an-llm">Why ingestion fits on CPU: the embedder is not an LLM&lt;/h2>
&lt;p>The whole argument rests on a size asymmetry that gets overlooked. People hear &amp;ldquo;embeddings&amp;rdquo; and &amp;ldquo;generation&amp;rdquo; and put them in the same bag of &amp;ldquo;big models that need a GPU&amp;rdquo;. But the embedding encoder and the generative LLM are two orders of magnitude apart in parameters.&lt;/p>
&lt;p>&lt;code>bge-m3&lt;/code>, the reference multilingual embedder, is an &lt;strong>XLM-RoBERTa of around 568M parameters&lt;/strong> (&lt;a href="https://huggingface.co/BAAI/bge-m3">model card&lt;/a>, &lt;a href="https://arxiv.org/abs/2402.03216">paper arXiv:2402.03216&lt;/a>). Its sibling reranker, &lt;code>bge-reranker-v2-m3&lt;/code>, is built on the same base and lands at roughly the same 568M parameters (&lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">model card&lt;/a>). Compare that with an entry-level generative LLM: a Llama 3.1 &lt;strong>8B&lt;/strong> has around 14× more parameters, and the big production models sit at 70B+. A 568M encoder is, in compute budget terms, a different animal.&lt;/p>
&lt;p>Two structural differences make that encoder comfortable on CPU:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>It is an encoder, not an autoregressive decoder.&lt;/strong> It processes the whole sequence in a single forward pass and emits the vector. There is no token-by-token decode, no growing KV cache, none of the memory-bound generation phase that kills the CPU. It is a dense matrix pass and that is it.&lt;/li>
&lt;li>&lt;strong>It quantises to int8 with almost no loss.&lt;/strong> In int8, &lt;code>bge-m3&lt;/code> occupies on the order of 580 MB and, above all, activates the integer compute paths that a modern CPU executes quickly: matrix instructions such as &lt;strong>Intel AMX&lt;/strong> (Advanced Matrix Extensions, 4th-generation Xeon and later), &lt;strong>AVX-512 with VNNI&lt;/strong> (Vector Neural Network Instructions) on earlier Xeons, and &lt;strong>NEON SDOT/UDOT&lt;/strong> on ARM. The quality loss from going FP32 to int8 on these models usually stays below 1% of retrieval recall, practically invisible (&lt;a href="https://huggingface.co/blog/intel-fast-embedding">Intel + Hugging Face, &lt;em>CPU Optimized Embeddings&lt;/em>&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>Let us quantify the int8 size. For $P = 568 \times 10^6$ parameters at 1 byte each:&lt;/p>
$$\text{size}_{\text{int8}} \approx 568 \times 10^6 \text{ params} \times 1 \text{ byte/param} \approx 568 \text{ MB}$$
&lt;p>In other words, the model fits in the cache and RAM of any server or NUC without blinking, and the bottleneck is integer compute, exactly what AMX/VNNI accelerate. There is nothing in this profile that asks for a GPU.&lt;/p>
&lt;h3 id="runtimes-that-already-do-this-effortlessly">Runtimes that already do this effortlessly&lt;/h3>
&lt;p>Nothing needs inventing. The CPU ecosystem for the data plane is mature:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Text Embeddings Inference (TEI)&lt;/strong> from Hugging Face: a Rust server with &lt;strong>CPU backends&lt;/strong> via ONNX Runtime (recommended) or Intel MKL, and &lt;strong>OpenAI-compatible&lt;/strong> endpoints (&lt;code>/v1/embeddings&lt;/code>) plus &lt;code>/rerank&lt;/code> (&lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI repo&lt;/a>). That is, the data plane on CPU exposes exactly the same HTTP contract as a GPU server; the rest of the system never learns which silicon is behind it.&lt;/li>
&lt;li>&lt;strong>fastembed&lt;/strong> from Qdrant: a lightweight library that loads embedders on &lt;strong>ONNX-CPU&lt;/strong> and generates dense, sparse and ColBERT vectors (&lt;a href="https://github.com/qdrant/fastembed">fastembed repo&lt;/a>). Designed from the start to run without a GPU.&lt;/li>
&lt;li>&lt;strong>&lt;code>bge-m3&lt;/code> in ONNX int8&lt;/strong> with its three heads (dense / sparse-lexical / ColBERT multivector) exported and quantised, ready for ONNX Runtime CPU.&lt;/li>
&lt;/ul>
&lt;p>The Intel and Hugging Face figure that anchors viability: in their benchmark with &lt;strong>Optimum Intel + fastRAG&lt;/strong> on a 4th-generation Xeon (8480+, 56 cores, 1 socket), the int8 variant of &lt;strong>BGE-large&lt;/strong> reaches up to around 10× indexing throughput against FP32 (&lt;a href="https://huggingface.co/blog/intel-fast-embedding">HF blog&lt;/a>, &lt;a href="https://haystack.deepset.ai/blog/cpu-optimized-models-with-fastrag">Haystack/deepset&lt;/a>). The small print needs reading, so I read it: that ~10× is encoding-only (tokenisation excluded), at sequence length 256, comparing int8 against FP32 on the same CPU. It is not &amp;ldquo;CPU 10× faster than GPU&amp;rdquo;, it is &amp;ldquo;int8 10× faster than FP32 on CPU&amp;rdquo;. It is still the relevant figure: it tells you that with quantisation the CPU goes from unworkable to perfectly useful for batch ingestion.&lt;/p>
&lt;h2 id="viability-table-cpu-for-each-component">Viability table: CPU for each component?&lt;/h2>
&lt;p>This is the operational table. The column that matters is the &lt;strong>caveat&lt;/strong>, because a bare &amp;ldquo;yes&amp;rdquo; or &amp;ldquo;no&amp;rdquo; lies.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>CPU viable?&lt;/th>
&lt;th>Caveat&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Chunking&lt;/strong> (slicing the corpus)&lt;/td>
&lt;td>Yes, always&lt;/td>
&lt;td>It is regex, parsing and windows; it never had anything to do with the GPU.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Ingest embedding&lt;/strong> &lt;code>bge-m3&lt;/code> dense&lt;/td>
&lt;td>Yes, its best case&lt;/td>
&lt;td>Overnight batch, int8 + AMX/VNNI. This is exactly what the CPU shines at.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sparse / SPLADE / BM25 head&lt;/strong>&lt;/td>
&lt;td>Yes, CPU-native&lt;/td>
&lt;td>Lexical work is pure inverted index; the GPU adds nothing here.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Building the HNSW index&lt;/strong> (Qdrant, pgvector)&lt;/td>
&lt;td>Yes, always CPU&lt;/td>
&lt;td>The HNSW graph build is CPU by design in these engines.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Query embedding&lt;/strong> (online)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>A single short text; tens of ms on CPU, more than enough for chat.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Dense + sparse search + RRF&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Vector search always ran on CPU, even in &amp;ldquo;GPU&amp;rdquo; stacks. RRF is sorting lists.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cross-encoder reranker&lt;/strong> &lt;code>bge-reranker-v2-m3&lt;/code> top-20/50&lt;/td>
&lt;td>Yes, with care&lt;/td>
&lt;td>A cross-encoder evaluates $k$ query-doc pairs: cost $\propto k$. Over 20-50 candidates it works; over hundreds at high QPS, no.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ColBERT late interaction&lt;/strong>&lt;/td>
&lt;td>Marginal on CPU&lt;/td>
&lt;td>The token-by-token matrix product of late interaction is heavy; viable at low volumes, suffers with QPS.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LLM generation&lt;/strong>&lt;/td>
&lt;td>No, in practice&lt;/td>
&lt;td>A 7B on CPU gives TTFT in seconds. Interactive latency = GPU.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two rows deserve underlining because they dismantle myths.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Vector search needs a GPU.&amp;rdquo;&lt;/strong> False from the start. The &lt;strong>HNSW&lt;/strong> index, the navigable small-world graph used by Qdrant, pgvector with &lt;code>vector&lt;/code>/&lt;code>halfvec&lt;/code>, Milvus in CPU mode and almost everything else, was always built and traversed on CPU. Even the stacks advertised as &amp;ldquo;GPU-accelerated RAG&amp;rdquo; do the embedding on GPU but the ANN search stays on CPU in the vast majority of deployments; the GPU variants of the index (CAGRA and similar) are the expensive exception, not the norm, and are justified only with billions of vectors and extreme QPS. For a corporate corpus of millions of chunks, HNSW on CPU resolves in single-digit milliseconds.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The reranker is a model, therefore GPU.&amp;rdquo;&lt;/strong> The &lt;code>bge-reranker-v2-m3&lt;/code> reranker is a cross-encoder of around 568M: it runs on CPU. The caveat is the &lt;strong>number of pairs&lt;/strong>. A cross-encoder does not produce a reusable vector; it evaluates the (query, document) pair jointly, so its cost grows linearly with the number of candidates $k$:&lt;/p>
$$\text{cost}_{\text{rerank}} \propto k \times \text{forward}(\text{query} + \text{doc})$$
&lt;p>Reranking the top-20 or top-50 coming out of hybrid retrieval is perfectly affordable on CPU. Reranking hundreds of candidates at high QPS is not: there the linear cost explodes and the GPU wins. The rule of thumb: broad cheap recall in the retriever, precision rerank over few candidates. (The detail of hybrid retrieval and reranking is in the fundamentals piece linked below.)&lt;/p>
&lt;h2 id="the-numbers-with-honest-methodology">The numbers, with honest methodology&lt;/h2>
&lt;p>Here comes the part where a lot of people lie by omission. I am going to give throughput ranges, but they are literature ranges and orders of magnitude, not my own measurements on this hardware. Take them as such: the right decision does not depend on nailing the number, it depends on understanding the allocation.&lt;/p>
&lt;p>For &lt;code>bge-m3&lt;/code> dense, sequence ≈256 tokens, embedding throughput moves roughly like this:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Platform&lt;/th>
&lt;th>Dense throughput (order of magnitude)&lt;/th>
&lt;th>Reading&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>High-end GPU&lt;/strong> (5090 fp16, TEI)&lt;/td>
&lt;td>~12k tok/s+ (indicative)&lt;/td>
&lt;td>The ceiling; expensive and scarce.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Large server CPU&lt;/strong> (Xeon ~56 cores, int8 ONNX)&lt;/td>
&lt;td>low band of thousands tok/s&lt;/td>
&lt;td>~1/5–1/10 of the GPU, but horizontally scalable and cheap.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Edge CPU / NUC&lt;/strong> (4-8 cores, int8)&lt;/td>
&lt;td>tens to low hundreds tok/s&lt;/td>
&lt;td>Enough for overnight ingestion of a local corpus.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The temptation is to read the second row as &amp;ldquo;CPU is 5-10× slower, discarded&amp;rdquo;. That is the wrong reading for ingestion. For batch work with no SLA, what rules is not absolute tok/s but throughput per euro and throughput per watt, and there the arithmetic changes sign.&lt;/p>
&lt;p>Take a numerical example of allocation. Suppose a corpus of 2 million chunks of around 256 tokens that has to be re-indexed once a day (the corpus changes, the embeddings have to be redone). That is:&lt;/p>
$$2 \times 10^6 \text{ chunks} \times 256 \text{ tok/chunk} \approx 5.1 \times 10^8 \text{ tokens}$$
&lt;p>At a conservative CPU throughput of, say, 3,000 tok/s per int8 Xeon server:&lt;/p>
$$t_{\text{ingest}} \approx \frac{5.1 \times 10^8 \text{ tok}}{3000 \text{ tok/s}} \approx 1.7 \times 10^5 \text{ s} \approx 47 \text{ hours on a single server}$$
&lt;p>47 hours on one box sounds bad until you remember two things. First, this is embarrassingly parallel: the corpus is sliced and distributed; with 8 CPU servers it drops to around 6 hours, with 16 to around 3 hours, comfortably inside the overnight window. Second, and more importantly: that same work on the GPU blocks the GPU. If the H100 does 12k tok/s, it takes around 12 hours, but they are 12 hours of the H100, the resource the whole organisation fights over in order to generate. Spending the scarce, expensive resource on re-indexing a corpus that changes once a day is bad allocation, even if it is &amp;ldquo;faster&amp;rdquo;: you are optimising the wrong tok/s.&lt;/p>
&lt;p>The mental rule: for ingestion, optimise throughput/€ and throughput/W; absolute tok/s belongs to the generation plane, where the human stopwatch really is running.&lt;/p>
&lt;h2 id="decision-tree-cpu-or-gpu-for-this-piece">Decision tree: CPU or GPU for this piece?&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="CPU vs GPU decision tree per RAG component">
&lt;defs>&lt;marker id="dt" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="24" text-anchor="middle" font-size="14" font-weight="700" fill="currentColor">Does this RAG piece go to CPU or GPU?&lt;/text>
&lt;rect x="270" y="44" width="240" height="40" rx="6" fill="#f59e0b" opacity="0.18" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="390" y="68" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Is the user waiting (interactive SLA)?&lt;/text>
&lt;path d="M390,84 L200,120" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="270" y="104" text-anchor="middle" font-size="11" fill="currentColor">no (batch)&lt;/text>
&lt;path d="M390,84 L580,120" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="510" y="104" text-anchor="middle" font-size="11" fill="currentColor">yes (online)&lt;/text>
&lt;rect x="60" y="124" width="280" height="38" rx="6" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="200" y="148" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Ingest/index → [CPU] cheap fleet&lt;/text>
&lt;rect x="440" y="124" width="290" height="40" rx="6" fill="#f59e0b" opacity="0.18" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="585" y="142" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Does it generate tokens (is it the LLM)?&lt;/text>
&lt;text x="585" y="157" text-anchor="middle" font-size="11" fill="currentColor">or is it retrieval/rerank?&lt;/text>
&lt;path d="M585,164 L460,210" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="500" y="190" text-anchor="middle" font-size="11" fill="currentColor">retrieval&lt;/text>
&lt;path d="M585,164 L690,210" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="680" y="190" text-anchor="middle" font-size="11" fill="currentColor">generates&lt;/text>
&lt;rect x="330" y="214" width="250" height="40" rx="6" fill="#f59e0b" opacity="0.18" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="455" y="232" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">embed query / HNSW / RRF / rerank&lt;/text>
&lt;text x="455" y="248" text-anchor="middle" font-size="11" fill="currentColor">how many candidates and at what QPS?&lt;/text>
&lt;rect x="600" y="214" width="150" height="38" rx="6" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-width="1.4"/>
&lt;text x="675" y="238" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">[GPU] generation&lt;/text>
&lt;path d="M455,254 L250,300" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="320" y="280" text-anchor="middle" font-size="11" fill="currentColor">embed/HNSW/RRF, or rerank top-20/50&lt;/text>
&lt;path d="M455,254 L600,300" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="600" y="280" text-anchor="middle" font-size="11" fill="currentColor">rerank hundreds&lt;/text>
&lt;text x="600" y="293" text-anchor="middle" font-size="11" fill="currentColor">high QPS / ColBERT&lt;/text>
&lt;rect x="110" y="304" width="280" height="38" rx="6" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="250" y="328" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">[CPU] data plane&lt;/text>
&lt;rect x="510" y="304" width="200" height="38" rx="6" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-width="1.4"/>
&lt;text x="610" y="322" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">[GPU] heavy rerank&lt;/text>
&lt;text x="610" y="336" text-anchor="middle" font-size="10" fill="currentColor">(or large 7B embedder)&lt;/text>
&lt;text x="390" y="372" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">Rule: only what is latency-bound (generation) and massively online (rerank at high QPS) crosses to GPU.&lt;/text>
&lt;text x="390" y="392" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">Everything else, which is almost everything, stays in the CPU data plane.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="reference-architecture-a-cpu-only">Reference architecture (a): CPU-only&lt;/h2>
&lt;p>The first case is a node with no GPU: a NUC, an office Xeon, a sovereign edge server in a branch or in an air-gapped environment. The whole data plane lives there; generation is delegated to a remote GPU endpoint or done in batch with an SLM when latency is not pressing.&lt;/p>
&lt;p>The stack:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TEI-CPU&lt;/strong> serving &lt;code>bge-m3&lt;/code> int8 with dense + sparse (same OpenAI contract &lt;code>/v1/embeddings&lt;/code>, plus &lt;code>/rerank&lt;/code> for the reranker).&lt;/li>
&lt;li>&lt;strong>Qdrant&lt;/strong> with a dense &lt;strong>HNSW&lt;/strong> index plus &lt;strong>sparse&lt;/strong> vectors, native &lt;strong>RRF&lt;/strong> fusion.&lt;/li>
&lt;li>&lt;strong>Reranker&lt;/strong> &lt;code>bge-reranker-v2-m3&lt;/code> over the top-k (via TEI&amp;rsquo;s &lt;code>/rerank&lt;/code>).&lt;/li>
&lt;li>&lt;strong>Gateway&lt;/strong> that orchestrates and, for generation, calls an external endpoint.&lt;/li>
&lt;/ul>
&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"># docker-compose: full RAG data plane on CPU (no GPU)&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">services&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">tei-embed&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">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:cpu-latest&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">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;--model-id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--pooling&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cls&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--dtype&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;int8&amp;#34;&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8081:80&amp;#34;&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="c"># ONNX/MKL backend: exploits AVX-512+VNNI / AMX if the Xeon supports it&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tei-rerank&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">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:cpu-latest&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">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;--model-id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-reranker-v2-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--dtype&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;int8&amp;#34;&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8082:80&amp;#34;&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="c"># exposes /rerank — invoked ONLY over top-20/50, never over hundreds&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">qdrant&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">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qdrant/qdrant:latest&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;6333:6333&amp;#34;&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">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./qdrant_storage:/qdrant/storage&amp;#34;&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="c"># HNSW dense + sparse vectors + RRF, all CPU&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hybrid search with RRF fusion in Qdrant (dense + sparse in a single query):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">models&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://qdrant:6333&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># query embedding: dense and sparse from TEI-CPU (HTTP wiring omitted)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">hits&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">query_points&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefetch&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Prefetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dense_vec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">using&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;dense&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Prefetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sparse_vec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">using&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sparse&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">FusionQuery&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fusion&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Fusion&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">RRF&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="c1"># native RRF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">points&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -&amp;gt; then: POST /rerank (TEI) over these 20, keep the top-5&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -&amp;gt; then: the gateway sends query + top-5 to the GENERATION endpoint (GPU)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Generation, on this CPU-only node, leaves the node: the gateway builds the augmented prompt and sends it to a vLLM endpoint on the GPU cluster (or, if there is no interactive SLA, to an SLM on CPU in batch mode, accepting a TTFT of seconds). The entire data plane, everything above, runs without a single GPU.&lt;/p>
&lt;h2 id="reference-architecture-b-the-recommended-hybrid">Reference architecture (b): the recommended hybrid&lt;/h2>
&lt;p>This is the one I recommend for the general case with a GPU cluster available: &lt;strong>data plane on CPU, generation plane on GPU&lt;/strong>, connected by OpenAI-compatible HTTP contracts so that each side is replaceable.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Hybrid architecture: CPU data plane + GPU generation plane">
&lt;defs>&lt;marker id="hy" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="24" text-anchor="middle" font-size="14" font-weight="700" fill="currentColor">Hybrid architecture: two planes, two silicons&lt;/text>
&lt;rect x="30" y="50" width="470" height="210" rx="8" fill="#22c55e" opacity="0.08" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="265" y="72" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">DATA PLANE — CPU fleet (Xeon AMX / NUC)&lt;/text>
&lt;rect x="50" y="88" width="130" height="50" rx="5" fill="none" stroke="#3b82f6" stroke-width="1.3"/>
&lt;text x="115" y="108" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Batch ingest&lt;/text>
&lt;text x="115" y="124" text-anchor="middle" font-size="10" fill="currentColor">chunk + embed int8&lt;/text>
&lt;rect x="200" y="88" width="130" height="50" rx="5" fill="none" stroke="#3b82f6" stroke-width="1.3"/>
&lt;text x="265" y="108" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Qdrant&lt;/text>
&lt;text x="265" y="124" text-anchor="middle" font-size="10" fill="currentColor">HNSW+sparse+RRF&lt;/text>
&lt;rect x="350" y="88" width="130" height="50" rx="5" fill="none" stroke="#3b82f6" stroke-width="1.3"/>
&lt;text x="415" y="108" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">TEI rerank&lt;/text>
&lt;text x="415" y="124" text-anchor="middle" font-size="10" fill="currentColor">light top-20/50&lt;/text>
&lt;rect x="50" y="160" width="430" height="44" rx="5" fill="none" stroke="#22c55e" stroke-width="1.3"/>
&lt;text x="265" y="180" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">TEI-CPU /v1/embeddings + /rerank · int8 · AVX-512/AMX&lt;/text>
&lt;text x="265" y="196" text-anchor="middle" font-size="10" fill="currentColor">OpenAI-compatible contract: the GPU does not know this is CPU&lt;/text>
&lt;rect x="540" y="50" width="250" height="210" rx="8" fill="#ef4444" opacity="0.08" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="665" y="72" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">GENERATION PLANE — GPU&lt;/text>
&lt;rect x="560" y="100" width="210" height="60" rx="5" fill="none" stroke="#ef4444" stroke-width="1.3"/>
&lt;text x="665" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">vLLM · LLM 7B+&lt;/text>
&lt;text x="665" y="142" text-anchor="middle" font-size="10" fill="currentColor">interactive prefill + decode&lt;/text>
&lt;rect x="560" y="172" width="210" height="44" rx="5" fill="none" stroke="#ef4444" stroke-width="1.3"/>
&lt;text x="665" y="192" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">optional: heavy rerank / 7B embedder&lt;/text>
&lt;text x="665" y="207" text-anchor="middle" font-size="10" fill="currentColor">only peaks the CPU cannot absorb&lt;/text>
&lt;path d="M480,182 L540,150" stroke="currentColor" stroke-width="1.8" fill="none" marker-end="url(#hy)"/>
&lt;text x="510" y="155" text-anchor="middle" font-size="10" fill="currentColor">query+top-k&lt;/text>
&lt;text x="410" y="284" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">The gateway speaks OpenAI over HTTP to both sides; each plane scales separately.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Generation server, minimal, on GPU:&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"># vLLM on the GPU cluster — generation ONLY&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">services&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">vllm-gen&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">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:latest&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">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --model meta-llama/Llama-3.1-8B-Instruct
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --dtype bfloat16 --max-model-len 8192
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --gpu-memory-utilization 0.85&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="c"># exposes /v1/chat/completions — the gateway sends it query + the top-5 already retrieved&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">deploy&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">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">reservations&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">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">driver: nvidia, count: 1, capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">gpu]}]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The virtue of the design: because &lt;strong>both sides speak the OpenAI contract over HTTP&lt;/strong>, the CPU data plane and the GPU generation plane scale separately and are replaceable. If tomorrow you want to move the rerank to GPU because QPS went up, you change one URL. If you want to add more CPU ingest nodes, you add them without touching generation. The whole stack is OSS and licence-clean: &lt;code>bge-m3&lt;/code> and &lt;code>bge-reranker-v2-m3&lt;/code> are &lt;strong>MIT&lt;/strong> (&lt;a href="https://huggingface.co/BAAI/bge-m3">bge-m3&lt;/a>, &lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">reranker&lt;/a>), Qdrant is Apache-2.0, TEI and vLLM are OSS.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring this down to the cluster in the series: &lt;strong>4×H100 SXM 80 GB&lt;/strong> plus a generic CPU fleet (Xeon with AMX, NUCs). The correct allocation:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Build and indexing → CPU fleet.&lt;/strong> No H100 should spend a cycle re-embedding the corpus. That goes to the Xeon AMX machines (large servers, throughput in the thousands of tok/s in int8) or, for small local corpora, to the NUCs overnight. Overnight re-indexing of a corpus that changes once a day is the textbook case of &amp;ldquo;unhurried CPU work&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The H100s → generation.&lt;/strong> All four cards are reserved for what only they do well: producing tokens at interactive latency. This is what the sibling pieces in the series, &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">several models on one GPU&lt;/a>, help to squeeze: once ingestion stops competing for the GPU, all the expensive silicon is free to generate and is shared better between models and tenants.&lt;/li>
&lt;li>&lt;strong>The H100s, at most, → rerank peaks or large embedders.&lt;/strong> If at some point you need a &lt;strong>7B&lt;/strong> embedder (gte-Qwen2, NV-Embed) for a domain where &lt;code>bge-m3&lt;/code> does not reach, or a massive rerank at a QPS the CPU cannot absorb, those peaks can indeed visit the GPU. But they are the occasional exception, not the base load.&lt;/li>
&lt;/ul>
&lt;h3 id="the-auditability-angle-ens--nis2">The auditability angle: ENS / NIS2&lt;/h3>
&lt;p>There is a compliance argument that rarely gets mentioned and that the CPU/GPU split gives you almost for free.&lt;/p>
&lt;p>A &lt;strong>CPU-only node with no proprietary driver&lt;/strong> is easier to audit. There is no closed NVIDIA kernel stack, no CUDA and firmware versions to reconcile with the supply chain, no proprietary driver surface to document for an ENS or a NIS2 assessment. The whole data plane, chunking, embeddings, index, search, runs on OSS software on generic CPU with standard instructions. For a sovereign or classified environment, being able to say &amp;ldquo;the plane that touches the corpus depends on no proprietary binary&amp;rdquo; is a real argument, not marketing.&lt;/p>
&lt;p>And there is a second auditability angle intrinsic to RAG done well: &lt;strong>source traceability&lt;/strong>. A RAG system that retrieves identifiable chunks and cites them is auditable, since you can reconstruct which document each claim came from, as opposed to &lt;em>context-stuffing&lt;/em> or the model&amp;rsquo;s opaque parametric knowledge, where there is no way to know where a fact came from. That traceability lives in the &lt;strong>data plane&lt;/strong> (what was retrieved, from which source, with what score), precisely the plane we are putting on auditable CPU. The two arguments reinforce each other: the auditable silicon and the auditable chain of evidence are the same plane.&lt;/p>
&lt;h2 id="when-not-to-take-it-to-cpu">When NOT to take it to CPU&lt;/h2>
&lt;p>For honesty, and to avoid falling into the mirror image of the hype, the cases where CPU is not the answer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Generation at interactive latency.&lt;/strong> The obvious case. A 7B on CPU gives TTFT in seconds: unacceptable for chat. If the user is waiting, generation goes to GPU. With no practical exceptions today.&lt;/li>
&lt;li>&lt;strong>Massive reranking at high QPS.&lt;/strong> A cross-encoder or ColBERT over hundreds of candidates, multiplied by many requests per second, saturates the CPU. The cost $\propto k \times \text{QPS}$ crosses the threshold where the GPU pays off. Keep CPU rerank bounded to top-20/50; if you need more breadth at higher QPS, move up to GPU.&lt;/li>
&lt;li>&lt;strong>Real-time re-indexing with a strict SLA.&lt;/strong> If the corpus changes continuously and freshness is measured in seconds (not hours), CPU throughput may not fit the window. There, ingest embedding may need a GPU, but note that this is rare: most corporate corpora change at the pace of hours or days, not seconds.&lt;/li>
&lt;li>&lt;strong>Large embedders (7B).&lt;/strong> &lt;code>bge-m3&lt;/code> (568M) is comfortable on CPU; a &lt;strong>gte-Qwen2&lt;/strong> or &lt;strong>NV-Embed&lt;/strong> at 7B is LLM-class again and drags the same cost profile as generation. If your retrieval quality demands a 7B embedder, that embedder lives where 7Bs live: on the GPU.&lt;/li>
&lt;/ul>
&lt;p>The sentence that sums it all up: &lt;strong>the CPU is the default home of the data plane; the GPU is the justified exception for what is latency-bound and massively online.&lt;/strong> Start by putting everything on CPU and move up to GPU only what proves it does not fit, not the other way round.&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/end-to-end-document-ingestion-pdf-to-indexed-chunk/">End-to-end document ingestion: from PDF to indexed chunk&lt;/a> — the ingestion pipeline that runs on that CPU data plane.&lt;/p>
&lt;/li>
&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> — the engine (TEI) that serves the embeddings and rerankers of that data plane.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — the sibling piece on the split &lt;em>inside&lt;/em> the GPU; once ingestion leaves the GPU, this squeezes what remains.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings 2026: dense, sparse and multivector&lt;/a> — the three heads of &lt;code>bge-m3&lt;/code> that the data plane serves on CPU, and when a 7B embedder that does demand a GPU is justified.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval: fundamentals&lt;/a> — the detail of RRF and of the cross-encoder rerank whose linear cost decides the CPU/GPU boundary for the top-k.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">Ingestion with PostgreSQL and Qdrant in microservices&lt;/a> — how the ingestion pipeline we put on the CPU fleet here is structured.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments: from the H100 cluster to the NUC&lt;/a> — the concrete hardware of the CPU fleet (Xeon AMX, NUC) that sustains the data plane.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic cache for RAG&lt;/a> — another layer that lives in the CPU data plane and avoids touching the GPU when the query has already been answered.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/">Aggressive RAG on small models&lt;/a> — the generation side of this coin: how a curated data plane unloads the model in the generative phase.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Chen, J., et al. &lt;em>BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings&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>BAAI — &lt;em>BGE-M3 model card&lt;/em> (568M params, XLM-RoBERTa, 8192 tokens, MIT). &lt;a href="https://huggingface.co/BAAI/bge-m3">https://huggingface.co/BAAI/bge-m3&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>bge-reranker-v2-m3 model card&lt;/em> (cross-encoder on top of bge-m3, ~568M). &lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">https://huggingface.co/BAAI/bge-reranker-v2-m3&lt;/a>&lt;/li>
&lt;li>Intel + Hugging Face — &lt;em>CPU Optimized Embeddings with Optimum Intel and fastRAG&lt;/em> (~10× indexing for BGE-large int8, 4th-gen Xeon). &lt;a href="https://huggingface.co/blog/intel-fast-embedding">https://huggingface.co/blog/intel-fast-embedding&lt;/a>&lt;/li>
&lt;li>deepset / Haystack — &lt;em>CPU-Optimized Embedding Models with fastRAG and Haystack&lt;/em>. &lt;a href="https://haystack.deepset.ai/blog/cpu-optimized-models-with-fastrag">https://haystack.deepset.ai/blog/cpu-optimized-models-with-fastrag&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>Text Embeddings Inference (TEI)&lt;/em>, CPU ONNX/MKL backends, OpenAI-compatible endpoints. &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a>&lt;/li>
&lt;li>Qdrant — &lt;em>fastembed&lt;/em> (ONNX-CPU, dense/sparse/ColBERT) and &lt;em>Hybrid Search with RRF&lt;/em>. &lt;a href="https://github.com/qdrant/fastembed">https://github.com/qdrant/fastembed&lt;/a> · &lt;a href="https://qdrant.tech/documentation/beginner-tutorials/hybrid-search-fastembed/">https://qdrant.tech/documentation/beginner-tutorials/hybrid-search-fastembed/&lt;/a>&lt;/li>
&lt;/ul></description></item><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>