<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Rag on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/rag/</link><description>Recent content in Rag 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/rag/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>End-to-end document ingestion: from PDF to indexed chunk</title><link>https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/</link><pubDate>Thu, 11 Jun 2026 09:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/</guid><description>&lt;blockquote>
&lt;p>Fourth piece in an operational series about squeezing a generic on-premise LLM cluster of 4×H100 SXM 80 GB. Its siblings in this batch: &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">serving embeddings and rerankers with TEI in production&lt;/a> details the inference piece that this ingestion feeds; &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux&lt;/a> versions and deploys this whole pipeline; 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> protects the corpus and the credentials this ingestion touches. The final consumer of what we build here, an end-to-end sovereign assistant, is assembled in a later instalment.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG system inherits the quality of its corpus, and the corpus inherits the quality of the &lt;strong>ingestion&lt;/strong> that manufactured it. This is the &lt;strong>garbage-in/garbage-out&lt;/strong> of retrieval: a chunk badly extracted from a table, a page header repeated a thousand times, a scanned PDF from which you only got OCR noise; all of it gets embedded, indexed and reappears as poisoned context in the model&amp;rsquo;s answer. Document ingestion is not an afternoon&amp;rsquo;s script, it is a &lt;strong>six-stage pipeline&lt;/strong> with engineering decisions at every one: (1) &lt;strong>extract/parse&lt;/strong>, where almost everything is decided: layout-aware (Docling, which with Granite-Docling-258M preserves tables, formulas and structure, and which according to IBM avoids classic OCR and is up to ~30× faster) against plain text extraction (PyMuPDF), with OCR or a VLM for scans; (2) &lt;strong>clean/normalise&lt;/strong>, removing boilerplate, normalising Unicode, rebuilding broken paragraphs; (3) &lt;strong>chunk&lt;/strong>, and here 2026 has shifted the consensus: a February 2026 benchmark put &lt;em>recursive&lt;/em> at 512 tokens in the lead (69% accuracy) ahead of semantic chunking (54%), and &lt;em>late chunking&lt;/em> adds global context at no extra storage cost; (4) &lt;strong>enrich metadata&lt;/strong>, with source, page, section, timestamp, ACL/tenant: for filtered retrieval, citation and auditability; (5) &lt;strong>embed&lt;/strong>, through an embeddings server such as TEI; (6) &lt;strong>index&lt;/strong>, in pgvector or Qdrant, with payload, dense + sparse. In between, exact &lt;strong>deduplication&lt;/strong> (hash) and near-duplicate detection (MinHash/LSH or cosine with a threshold), because near-duplicates degrade retrieval recall and diversity. The numbers matter: a corpus of $N_{docs}\times$ pages $\times$ tokens/page gives the total tokens and, at a given CPU throughput, the ingestion time; and $N_{chunks}\times d\times\text{bytes}$ gives the index size, which in int8 falls 4× against fp32. The reading for the 4×H100: &lt;strong>ingestion is CPU work&lt;/strong>, which links to the RAG-on-CPU piece; the GPU only comes in if you parse with a VLM (Granite-Docling) or use a 7B embedder.&lt;/p>
&lt;h2 id="the-analogy-an-archives-cataloguing-chain">The analogy: an archive&amp;rsquo;s cataloguing chain&lt;/h2>
&lt;p>Picture the cataloguing department of a large document archive. It is not one person putting papers into boxes; it is a &lt;strong>chain of workstations&lt;/strong>, each with a different craft and its own quality standard. A document goes in raw at one end and comes out the other turned into a record you can find in seconds. If one station does its job badly, the ones downstream inherit the error and amplify it.&lt;/p>
&lt;p>The &lt;strong>first station is intake and reading&lt;/strong>. A heterogeneous box arrives: typed reports, crooked photocopies, folded tables, microfiche. An expert archivist really reads each item: distinguishes the body of the text from the margins, rebuilds a table that spans two pages, transcribes by hand what the scanner could not read. A novice archivist, by contrast, photocopies everything flat and hands over a slab of text where the table columns are interleaved into nonsense. That is exactly the difference between &lt;strong>layout-aware parsing and plain text extraction&lt;/strong>, and it is where almost all of the quality is won or lost.&lt;/p>
&lt;p>The &lt;strong>second station is weeding out&lt;/strong>. Before filing anything, someone removes the duplicates (three copies of the same memo, two near-identical versions of a report) and cleans off the useless marks: &amp;ldquo;COPY&amp;rdquo; stamps, footers repeated on every sheet, coffee stains. If you do not weed, the archive fills up with copies that, when someone searches, return the same document six times and bury everything else. This is &lt;strong>deduplication and cleaning&lt;/strong>.&lt;/p>
&lt;p>The &lt;strong>third station cuts it into records&lt;/strong>. A 400-page book is not catalogued as one giant record; it is broken down into manageable entries, by chapter, by section, of a size a reader can take in at a glance. Too big and the record mixes topics; too small and it loses context. This is &lt;strong>chunking&lt;/strong>, and the size of the record is the decision that most shapes retrieval.&lt;/p>
&lt;p>The &lt;strong>fourth station labels&lt;/strong>. Each record carries a shelf mark, a date, its provenance, an access level (can anyone see this, or only the legal department?). Without those labels you cannot filter a search or say which document a statement came from. These are the &lt;strong>metadata&lt;/strong>: source, page, section, timestamp, ACL.&lt;/p>
&lt;p>The &lt;strong>fifth and sixth stations place the record on the indexed shelf&lt;/strong>: they translate it into the catalogue&amp;rsquo;s language, a vector, and put it in the right drawer of the filing cabinet, so that a query finds the related records without walking through the whole thing. This is &lt;strong>embedding and indexing&lt;/strong> in the vector store.&lt;/p>
&lt;p>The moral runs through the whole post: &lt;strong>RAG cannot retrieve better than what the cataloguing chain filed&lt;/strong>. If the first station chopped up a table badly, no reranker will fix it afterwards. Ingestion is the quality station for the entire system, and almost all of it, like the archive full of patient people working with nobody timing them, is background work that fits on CPU.&lt;/p>
&lt;h2 id="the-six-stage-pipeline">The six-stage pipeline&lt;/h2>
&lt;div class="diagram" style="max-width:880px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 880 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Six-stage RAG document ingestion pipeline, from PDF to indexed chunk">
&lt;text x="440" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">From heterogeneous document to indexed chunk: six stages&lt;/text>
&lt;rect x="20" y="50" width="120" height="70" rx="6" fill="none" stroke="#6b7280" stroke-width="1.4"/>
&lt;text x="80" y="74" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">INPUT&lt;/text>
&lt;text x="80" y="92" text-anchor="middle" font-size="10" fill="currentColor">PDF · DOCX&lt;/text>
&lt;text x="80" y="106" text-anchor="middle" font-size="10" fill="currentColor">HTML · scan&lt;/text>
&lt;rect x="160" y="50" width="130" height="70" rx="6" fill="none" stroke="#3b82f6" stroke-width="1.4"/>
&lt;text x="225" y="72" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">1 · PARSE&lt;/text>
&lt;text x="225" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">layout-aware&lt;/text>
&lt;text x="225" y="103" text-anchor="middle" font-size="9.5" fill="currentColor">tables · OCR&lt;/text>
&lt;rect x="178" y="108" width="94" height="16" fill="#3b82f6"/>
&lt;text x="225" y="120" text-anchor="middle" font-size="9.5" font-weight="700" fill="#ffffff">[CPU] / [GPU if VLM]&lt;/text>
&lt;rect x="310" y="50" width="120" height="70" rx="6" fill="none" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="370" y="72" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">2 · CLEAN&lt;/text>
&lt;text x="370" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">boilerplate&lt;/text>
&lt;text x="370" y="103" text-anchor="middle" font-size="9.5" fill="currentColor">+ DEDUP&lt;/text>
&lt;rect x="328" y="108" width="84" height="16" fill="#22c55e"/>
&lt;text x="370" y="120" text-anchor="middle" font-size="10" font-weight="700" fill="#ffffff">[CPU]&lt;/text>
&lt;rect x="450" y="50" width="120" height="70" rx="6" fill="none" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="510" y="72" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">3 · CHUNK&lt;/text>
&lt;text x="510" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">recursive · semantic&lt;/text>
&lt;text x="510" y="103" text-anchor="middle" font-size="9.5" fill="currentColor">late chunking&lt;/text>
&lt;rect x="468" y="108" width="84" height="16" fill="#22c55e"/>
&lt;text x="510" y="120" text-anchor="middle" font-size="10" font-weight="700" fill="#ffffff">[CPU]&lt;/text>
&lt;rect x="590" y="50" width="120" height="70" rx="6" fill="none" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="650" y="72" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">4 · METADATA&lt;/text>
&lt;text x="650" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">source · page&lt;/text>
&lt;text x="650" y="103" text-anchor="middle" font-size="9.5" fill="currentColor">ACL · timestamp&lt;/text>
&lt;rect x="608" y="108" width="84" height="16" fill="#22c55e"/>
&lt;text x="650" y="120" text-anchor="middle" font-size="10" font-weight="700" fill="#ffffff">[CPU]&lt;/text>
&lt;rect x="730" y="50" width="130" height="70" rx="6" fill="none" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="795" y="72" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">5 · EMBED&lt;/text>
&lt;text x="795" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">TEI · dense+sparse&lt;/text>
&lt;text x="795" y="103" text-anchor="middle" font-size="9.5" fill="currentColor">batch&lt;/text>
&lt;rect x="748" y="108" width="94" height="16" fill="#f59e0b"/>
&lt;text x="795" y="120" text-anchor="middle" font-size="9" font-weight="700" fill="#ffffff">[CPU] / [GPU if 7B]&lt;/text>
&lt;rect x="360" y="180" width="160" height="70" rx="6" fill="none" stroke="#ef4444" stroke-width="1.4"/>
&lt;text x="440" y="204" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">6 · INDEX&lt;/text>
&lt;text x="440" y="221" text-anchor="middle" font-size="9.5" fill="currentColor">pgvector / Qdrant&lt;/text>
&lt;text x="440" y="235" text-anchor="middle" font-size="9.5" fill="currentColor">HNSW + payload&lt;/text>
&lt;rect x="378" y="238" width="124" height="16" fill="#ef4444"/>
&lt;text x="440" y="250" text-anchor="middle" font-size="10" font-weight="700" fill="#ffffff">[CPU]&lt;/text>
&lt;path d="M140,85 L160,85" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#ar)"/>
&lt;path d="M290,85 L310,85" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#ar)"/>
&lt;path d="M430,85 L450,85" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#ar)"/>
&lt;path d="M570,85 L590,85" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#ar)"/>
&lt;path d="M710,85 L730,85" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#ar)"/>
&lt;path d="M795,120 C795,160 560,170 520,200" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#ar)"/>
&lt;defs>&lt;marker id="ar" 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;rect x="20" y="300" width="840" height="100" rx="8" fill="#22c55e" opacity="0.06" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="40" y="324" font-size="12" font-weight="700" fill="currentColor">Idempotency and incremental re-indexing (cross-cutting):&lt;/text>
&lt;text x="40" y="346" font-size="11" fill="currentColor">upsert by doc-id + content hash → if the hash did not change, nothing is re-embedded.&lt;/text>
&lt;text x="40" y="364" font-size="11" fill="currentColor">CDC (Debezium over the Postgres WAL) propagates additions, deletions and edits to the index without reindexing the whole corpus.&lt;/text>
&lt;text x="40" y="386" font-size="11" font-style="italic" fill="currentColor">Garbage-in/garbage-out is decided at stage 1 (parse): no reranker fixes an extraction error afterwards.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The temptation to treat ingestion as &amp;ldquo;read the PDF and chop it up&amp;rdquo; is the source of 80% of mediocre RAG systems. Each stage has a quality standard and a characteristic failure, and failures &lt;strong>cascade downstream&lt;/strong>: if you parse badly, cleaning does not recover what was lost; if you chunk badly, embedding freezes the error into the vector; if you do not label, you will not be able to filter or cite. The rest of the post walks through the six stations and their decisions.&lt;/p>
&lt;h2 id="stage-1--parsing-layout-aware-vs-plain-text">Stage 1 — Parsing: layout-aware vs plain text&lt;/h2>
&lt;p>Almost everything is won or lost here. A PDF is not text: it is a set of instructions for drawing glyphs on a canvas. &amp;ldquo;Extracting the text&amp;rdquo; from a PDF means reconstructing a reading order that the format does not guarantee, and tables, columns and figures break it systematically.&lt;/p>
&lt;p>There are two philosophies, and the choice shapes the rest of the pipeline.&lt;/p>
&lt;p>&lt;strong>Plain text extraction.&lt;/strong> Tools such as &lt;strong>PyMuPDF&lt;/strong> (fast, robust, no heavy dependencies) read the PDF&amp;rsquo;s text stream and dump it out. For single-column documents, running prose and no tables, it is perfect: extremely fast, faithful and cheap on CPU. Its limit shows up with structure: a two-column table comes out with the cells interleaved, a double-column document mixes the end of one with the start of the other, and a scanned invoice does not come out at all because there is no text layer. PyMuPDF can also segment by the table of contents (TOC) when the PDF carries one, which helps with chunking by section (&lt;a href="https://www.omdena.com/blog/document-parsing-for-rag">Omdena, &lt;em>Document Parsing for RAG: A Complete Guide for 2026&lt;/em>&lt;/a>).&lt;/p>
&lt;p>&lt;strong>Layout-aware parsing.&lt;/strong> Tools such as &lt;strong>Docling&lt;/strong> (an open source project driven by IBM Research) and unstructured.io first understand the layout, identifying titles, paragraphs, tables, figures, lists and formulas, and only then extract the content while respecting that structure. Docling captures table structure (rows, columns, multi-level headers) and, in its 2026 evolution, does so with a VLM: &lt;strong>Granite-Docling-258M&lt;/strong>, released by IBM in January 2026 under Apache 2.0, a compact vision-language model (~258M parameters, Granite 3 backbone + SigLIP2 visual encoder) that converts pages, whether PDF, slides or scans, directly into a structured format called DocTags, preserving tables, code, inline and block mathematics, and the document&amp;rsquo;s hierarchy (&lt;a href="https://www.ibm.com/new/announcements/granite-docling-end-to-end-document-conversion">IBM, &lt;em>Granite-Docling: End-to-end document understanding&lt;/em>&lt;/a>; &lt;a href="https://huggingface.co/ibm-granite/granite-docling-258M">model card on Hugging Face&lt;/a>; &lt;a href="https://github.com/docling-project/docling">Docling repo&lt;/a>). IBM states that the VLM route avoids classic OCR and that this &amp;ldquo;reduces errors and speeds up the solution by up to 30×&amp;rdquo; against a traditional OCR pipeline. That is an IBM Research figure; I quote it and treat it as indicative, not as an independent benchmark reproduced here.&lt;/p>
&lt;p>unstructured.io offers partitioning strategies graded by document complexity: &lt;code>fast&lt;/code> (plain text, quick), &lt;code>hi_res&lt;/code> (identifies the layout, recommended when classifying tables and elements properly matters), VLM and &lt;code>auto&lt;/code>, balancing speed, cost and accuracy (&lt;a href="https://unstructured.io/blog/mastering-pdf-transformation-strategies-with-unstructured-part-2">Unstructured, &lt;em>PDF Parsing Strategies for RAG&lt;/em>&lt;/a>). The practical rule: &lt;strong>&lt;code>fast&lt;/code> for prose, &lt;code>hi_res&lt;/code> or VLM for documents with tables and structure&lt;/strong>.&lt;/p>
&lt;h3 id="ocr-for-scans">OCR for scans&lt;/h3>
&lt;p>When the document has no text layer (a scan, a photo, a digitised microfiche), you have to &lt;strong>recognise the characters&lt;/strong>. Three routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Classic OCR&lt;/strong> (Tesseract, PaddleOCR, EasyOCR, which is what Docling integrates with when explicit OCR is needed). Mature, CPU-friendly, good with clean text; it struggles with tables, handwriting and complex layouts.&lt;/li>
&lt;li>&lt;strong>End-to-end VLM&lt;/strong> (Granite-Docling and similar). The model &amp;ldquo;looks&amp;rdquo; at the page and emits structure directly, with no separate OCR stage. Better with complex layouts; this is where the GPU does come in if the VLM is large or the volume is high.&lt;/li>
&lt;li>&lt;strong>Hybrid&lt;/strong>: OCR for character transcription, a layout model for the structure.&lt;/li>
&lt;/ul>
&lt;p>The honest criterion: for a corpus of native PDFs with text, PyMuPDF or unstructured &lt;code>fast&lt;/code> handle it on CPU and cheaply. For a corpus with dense tables, forms or scans, &lt;strong>layout-aware Docling/Granite-Docling earns its cost&lt;/strong> in chunk quality, and it is the only stage of the pipeline where the GPU can be justified.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Document case&lt;/th>
&lt;th>Recommended tool&lt;/th>
&lt;th>Silicon&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Native PDF, single column, prose&lt;/td>
&lt;td>PyMuPDF / unstructured &lt;code>fast&lt;/code>&lt;/td>
&lt;td>CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PDF with tables, double column, hierarchy&lt;/td>
&lt;td>Docling / unstructured &lt;code>hi_res&lt;/code>&lt;/td>
&lt;td>CPU (layout models)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scan, form, handwriting, complex layout&lt;/td>
&lt;td>Granite-Docling (VLM) or OCR+layout&lt;/td>
&lt;td>GPU if large VLM / high volume&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HTML, DOCX, PPTX&lt;/td>
&lt;td>Docling (multi-format) / native parsers&lt;/td>
&lt;td>CPU&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="stage-2--cleaning-normalising-and-deduplicating">Stage 2 — Cleaning, normalising and deduplicating&lt;/h2>
&lt;p>Freshly parsed text comes in dirty. Cleaning means removing what adds nothing and normalising what is represented in a thousand ways:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Boilerplate&lt;/strong>: headers and footers repeated on every sheet, page numbers, watermarks, HTML navigation menus, cookie banners. If you do not remove them, they get embedded a thousand times over and contaminate both the index and the answers.&lt;/li>
&lt;li>&lt;strong>Unicode normalisation&lt;/strong> (NFC/NFKC), whitespace and dashes: the same character represented in several ways breaks exact matching and dirties the embeddings.&lt;/li>
&lt;li>&lt;strong>Paragraph reconstruction&lt;/strong>: undoing the hard line breaks the PDF inserted mid-sentence, without merging paragraphs that were meant to stay separate.&lt;/li>
&lt;/ul>
&lt;h3 id="deduplication-why-it-matters">Deduplication: why it matters&lt;/h3>
&lt;p>RAG suffers two ills from duplicates. The exact kind, the same document uploaded three times, inflates the index and makes a search return the same answer repeatedly, wasting the &lt;code>top-k&lt;/code> context slots. The near-duplicate kind, two near-identical versions of a report, a document and its draft, is worse: they look different to a hash but say the same thing, and they &lt;strong>degrade retrieval recall and diversity&lt;/strong>. This is not theory: in the MS MARCO V2 collection a substantial overlap of near-duplicates has been documented which, left untreated, degrades retrieval precision and reduces the diversity of retrieved documents (&lt;a href="https://arxiv.org/pdf/2406.16828">Ragnarök / TREC RAG 2024&lt;/a>).&lt;/p>
&lt;p>Two levels of dedup, complementary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Exact (hash).&lt;/strong> Compute a &lt;code>sha256&lt;/code> of the normalised content of each document (or chunk) and discard the matches. Cost $O(N)$, trivial. It catches byte-for-byte duplicates.&lt;/li>
&lt;li>&lt;strong>Near-dup (MinHash + LSH, or cosine of embeddings with a threshold).&lt;/strong> For those that differ slightly but mean the same thing. MinHash compresses each document into a compact signature such that the probability of two signatures matching at a position equals the Jaccard similarity of the original shingle sets; combined with Locality-Sensitive Hashing (LSH) it finds every near-duplicate pair without comparing all against all, reducing a quadratic problem to almost linear (&lt;a href="https://mbrenndoerfer.com/writing/minhash-algorithm-jaccard-similarity-lsh-deduplication">Brenndoerfer, &lt;em>MinHash, Jaccard, LSH&lt;/em>&lt;/a>). It is the dominant technique in cleaning LLM training corpora (C4 and RefinedWeb use it) and applies just the same to a RAG corpus (&lt;a href="https://zilliz.com/blog/data-deduplication-at-trillion-scale-solve-the-biggest-bottleneck-of-llm-training">Zilliz, &lt;em>Data Deduplication at Trillion Scale&lt;/em>&lt;/a>). The alternative, cosine of embeddings with a threshold (say &amp;gt; 0.97), catches semantic duplicates that MinHash cannot see (paraphrasing), but it requires you to already have the embeddings and is more expensive.&lt;/li>
&lt;/ul>
&lt;p>The practical rule: &lt;strong>exact hash always (it is free); MinHash/LSH for large corpora with versions; cosine with a threshold if paraphrasing is a real problem&lt;/strong>. And deduplicate &lt;strong>before embedding&lt;/strong>: re-embedding a duplicate means spending compute on rubbish you will then have to filter out.&lt;/p>
&lt;h2 id="stage-3--chunking">Stage 3 — Chunking&lt;/h2>
&lt;p>Chunking is the decision that most shapes retrieval, and the one that carries the most myths. The trade-off is threefold: &lt;strong>chunk size ↔ retrieval granularity ↔ context cost&lt;/strong>.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Large chunks&lt;/strong>: each one holds more context and there is less risk of cutting an idea in half, but the search is less precise (one vector represents too many topics) and, on retrieval, you put more tokens into the LLM&amp;rsquo;s prompt, which means more cost and more risk of diluting what is relevant.&lt;/li>
&lt;li>&lt;strong>Small chunks&lt;/strong>: very granular, precise retrieval, but each chunk loses context (a 43-token fragment may mean nothing outside its section) and you need to retrieve more of them to cover an answer.&lt;/li>
&lt;/ul>
&lt;p>The strategies, from least to most sophisticated:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Fixed size + overlap.&lt;/strong> Cut every $N$ tokens with an overlap of $k$ tokens between consecutive chunks so as not to slice a sentence dead. Simple, predictable, a reasonable baseline. The overlap is the insurance against cutting an idea right at the boundary.&lt;/li>
&lt;li>&lt;strong>Recursive&lt;/strong> (LangChain&amp;rsquo;s &lt;code>RecursiveCharacterTextSplitter&lt;/code>). It tries to cut on separators in priority order (paragraph, then sentence, then word) to respect the natural structure as far as possible before falling back to a hard cut. It is the workhorse.&lt;/li>
&lt;li>&lt;strong>Semantic.&lt;/strong> Embed sentences and cut where the similarity between consecutive sentences falls below a threshold, grouping by coherence of meaning. It sounds better on paper; in 2026 practice it has disappointed (see below).&lt;/li>
&lt;li>&lt;strong>Structure/layout-aware (by headings).&lt;/strong> It exploits the hierarchy the layout-aware parser has already extracted: one chunk per section or subsection. unstructured offers the &lt;code>by_title&lt;/code> strategy, which opens a new chunk when a title element appears, avoiding mixing text from different sections (&lt;a href="https://docs.unstructured.io/">Unstructured docs&lt;/a>). If you parsed with Docling/&lt;code>hi_res&lt;/code>, this strategy is almost free and is usually the best one for well-structured documents.&lt;/li>
&lt;li>&lt;strong>Late chunking.&lt;/strong> The twist of 2024–2026: instead of chunking and then embedding each chunk separately, embed the whole document first (with a long-context encoder) and then apply the chunk boundaries by &lt;em>mean-pooling&lt;/em> the token embeddings inside each span. The result: every chunk keeps the document&amp;rsquo;s global context (a pronoun or a reference that only makes sense given the previous paragraph is encoded in the vector) and all of it with no extra storage cost, because you end up with one vector per chunk just as before (&lt;a href="https://jina.ai/news/late-chunking-in-long-context-embedding-models/">Jina AI, &lt;em>Late Chunking&lt;/em>&lt;/a>; &lt;a href="https://arxiv.org/abs/2409.04701">arXiv:2409.04701&lt;/a>).&lt;/li>
&lt;/ol>
&lt;h3 id="what-the-2026-benchmarks-say-and-why-semantic-disappoints">What the 2026 benchmarks say (and why semantic disappoints)&lt;/h3>
&lt;p>It is worth being sceptical about fashion. A Vecta benchmark from February 2026 covering 7 strategies over 50 academic papers put recursive at 512 tokens in first place with 69% accuracy, while semantic chunking came in at 54%, partly because it produced tiny fragments, averaging 43 tokens, too small to mean anything (&lt;a href="https://www.firecrawl.dev/blog/best-chunking-strategies-rag">Firecrawl, &lt;em>Best Chunking Strategies for RAG in 2026&lt;/em>&lt;/a>). A systematic analysis from January 2026 also identified a &amp;ldquo;context cliff&amp;rdquo; around 2,500 tokens, where answer quality drops once you feed in contexts that are too long, an extra argument against giant chunks ([ibid.]). The honest reading: &lt;strong>recursive at a moderate size with overlap remains the baseline that is hard to beat&lt;/strong>; late chunking is the improvement with the best cost/benefit ratio when the model supports it; semantic promises more than it delivers.&lt;/p>
&lt;h3 id="a-worked-numerical-example-of-chunking">A worked numerical example of chunking&lt;/h3>
&lt;p>Take a technical document of 30 pages, with ~500 tokens of useful prose per page after cleaning (tables and figures are chunked separately). That is $30 \times 500 = 15{,}000$ tokens of text. We chunk with recursive at 512 tokens and 20% overlap ($0.20 \times 512 \approx 102$ tokens). The effective &amp;ldquo;step&amp;rdquo; between the start of one chunk and the next is:&lt;/p>
$$\text{step} = \text{size} - \text{overlap} = 512 - 102 = 410 \text{ tokens}$$
&lt;p>The number of chunks in the document is then, approximately:&lt;/p>
$$N_{chunks} \approx \left\lceil \frac{T_{doc} - \text{overlap}}{\text{step}} \right\rceil = \left\lceil \frac{15{,}000 - 102}{410} \right\rceil \approx \lceil 36.3 \rceil = 37 \text{ chunks}$$
&lt;p>Without overlap it would have been $\lceil 15{,}000 / 512 \rceil = 30$ chunks; the 20% overlap costs us 7 extra chunks (~23% more) in exchange for not splitting ideas at the boundaries. That is the concrete price of overlap: more vectors to embed, index and store, in exchange for robustness in retrieval. We will use this factor when sizing the full corpus.&lt;/p>
&lt;h2 id="stage-4--enriching-with-metadata">Stage 4 — Enriching with metadata&lt;/h2>
&lt;p>A chunk with no metadata is a record with no shelf mark: it exists but it is no use. Each chunk gets a payload attached with, at a minimum:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Source and location&lt;/strong>: &lt;code>document_id&lt;/code>, document name/URI, page number, section/heading (which the layout-aware parser already gave you). Essential for citation: being able to say &amp;ldquo;this comes from document X, page 12, section 3.2&amp;rdquo; is what separates an auditable RAG from one that hallucinates with no traceability.&lt;/li>
&lt;li>&lt;strong>Timestamp&lt;/strong>: when it was ingested and the document&amp;rsquo;s date. It lets you filter by freshness and detect stale content.&lt;/li>
&lt;li>&lt;strong>ACL / tenant&lt;/strong>: who is allowed to see this chunk. It is &lt;strong>critical&lt;/strong> and it is applied as a &lt;strong>filter at retrieval time&lt;/strong>: a user from department A must not retrieve chunks marked for B only. Without this, the RAG system is a data leak waiting to happen.&lt;/li>
&lt;li>&lt;strong>Embedding model version&lt;/strong> (&lt;code>model_version&lt;/code>): so you know which embedder generated each vector and can migrate without mixing incompatible spaces.&lt;/li>
&lt;/ul>
&lt;p>These metadata are not decoration: they enable filtered retrieval (searching only in what the user can see, or only in documents after a given date), citation (reconstructing the origin of every statement) and auditability (knowing what was retrieved, from where and when). All of it lives in the point&amp;rsquo;s payload in the vector store.&lt;/p>
&lt;h2 id="stages-5-and-6--embedding-and-indexing">Stages 5 and 6 — Embedding and indexing&lt;/h2>
&lt;p>The last two stations translate the chunk into a vector and place it on the shelf.&lt;/p>
&lt;p>Embedding. The chunks are sent in batches to an embeddings server, typically Hugging Face&amp;rsquo;s TEI (Text Embeddings Inference), which exposes the OpenAI &lt;code>/v1/embeddings&lt;/code> contract and runs on CPU or GPU. It is throughput-bound work with no latency SLA: its natural home is the CPU with a small encoder in int8 (the sibling piece in this batch, &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">serving embeddings and rerankers with TEI&lt;/a>, details the how). It is worth emitting dense + sparse at the same time: the dense vector captures semantics, the sparse one (SPLADE/BM25-like) exact lexical overlap, and together they make hybrid retrieval more robust.&lt;/p>
&lt;p>Indexing. The vectors, with their payload, are upserted into the vector store. Two reference options, both current in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>pgvector&lt;/strong> (a PostgreSQL extension). Its great virtue is living inside Postgres: ACID transactions, joins with the relational metadata, a single database to operate. Version 0.8 added &lt;code>halfvec&lt;/code> (half-precision vectors, 2× less storage) and 0.9 (early 2026) added support for sparse vectors and speed improvements. Its known limit: it has no native int8 quantisation, so high-dimensional embeddings consume RAM linearly (&lt;a href="https://encore.dev/articles/pgvector-vs-qdrant">Encore, &lt;em>pgvector vs Qdrant 2026&lt;/em>&lt;/a>; &lt;a href="https://jkatz05.com/post/postgres/pgvector-scalar-binary-quantization/">Katz, &lt;em>Scalar and binary quantization for pgvector&lt;/em>&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Qdrant&lt;/strong> (a dedicated vector engine). It supports int8 scalar quantisation (float32 → int8, 4× less memory) and product quantisation, native sparse vectors and RRF fusion for hybrid search. It is more efficient in memory and in quantisation; the cost is operating one more system alongside Postgres (&lt;a href="https://markaicode.com/vs/pgvector-vs-qdrant/">Markaicode, &lt;em>pgvector vs Qdrant 2026&lt;/em>&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>The practical rule: &lt;strong>pgvector if you already have Postgres and the corpus fits comfortably in RAM&lt;/strong> (a single database to operate and back up); &lt;strong>Qdrant if memory efficiency and int8 quantisation are critical&lt;/strong> because of the corpus size. Synchronisation between the relational truth (Postgres) and the index (Qdrant) when both are used is detailed in &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in microservices&lt;/a>.&lt;/p>
&lt;h2 id="a-cross-cutting-stage--incremental-ingestion-and-idempotency">A cross-cutting stage — Incremental ingestion and idempotency&lt;/h2>
&lt;p>A living corpus changes: documents are added, edited, deleted. Re-indexing everything each night is expensive and causes windows of unavailability. The alternative is &lt;strong>incremental ingestion&lt;/strong> with two pillars:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Idempotency by doc-id + hash.&lt;/strong> Each chunk is identified deterministically (&lt;code>{doc_id}_{chunk_index}&lt;/code>) and each document carries a content hash. On reprocessing, if the hash has not changed, nothing is re-embedded: the compute is saved. If it changed, the old chunks for that &lt;code>doc_id&lt;/code> are deleted and the new ones are upserted. An upsert with a deterministic id is idempotent: reprocessing an event twice does not create duplicates.&lt;/li>
&lt;li>&lt;strong>CDC (Change Data Capture).&lt;/strong> Instead of polling, Debezium reads the PostgreSQL WAL and propagates additions, edits and deletions to the index in near real time. A deletion in Postgres triggers the deletion of that document&amp;rsquo;s chunks in the vector store, avoiding the &amp;ldquo;ghost documents&amp;rdquo; that contaminate retrieval. The deep dive is in &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in microservices&lt;/a> and in the one on &lt;a href="https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/">Debezium and CDC&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="the-arithmetic-sizing-the-corpus-and-the-index">The arithmetic: sizing the corpus and the index&lt;/h2>
&lt;p>Two calculations you need to be able to do before provisioning anything.&lt;/p>
&lt;h3 id="corpus-sizing-and-ingestion-time">Corpus sizing and ingestion time&lt;/h3>
&lt;p>Suppose a corporate corpus of $N_{docs} = 50{,}000$ documents, averaging 20 pages and 500 useful tokens per page after cleaning. The total tokens in the corpus:&lt;/p>
$$T_{corpus} = N_{docs} \times \text{pages} \times \text{tokens/page} = 50{,}000 \times 20 \times 500 = 5 \times 10^{8} \text{ tokens}$$
&lt;p>Five hundred million tokens. Applying the overlap factor from the chunking example (~1.23×, the 23% extra chunks from the 20% overlap) and an effective size of 512 tokens/chunk, the number of chunks is:&lt;/p>
$$N_{chunks} \approx \frac{T_{corpus}}{\text{step}} = \frac{5 \times 10^{8}}{410} \approx 1.22 \times 10^{6} \text{ chunks}$$
&lt;p>That is, ~1.22 million chunks to embed. At a conservative CPU embedding throughput of 3,000 tok/s per Xeon server in int8, the same figure we use in the RAG-on-CPU piece, the ingestion time for the first full corpus on one box is:&lt;/p>
$$t_{ingesta} = \frac{T_{corpus}}{\text{throughput}} = \frac{5 \times 10^{8}}{3{,}000} \approx 1.67 \times 10^{5} \text{ s} \approx 46 \text{ hours}$$
&lt;p>46 hours on a single box sounds bad, but ingestion is embarrassingly parallel: the corpus is shared out. With 8 CPU servers it drops to ~6 hours, comfortably within a weekend window for the initial load; and subsequent incremental ingestions (only what changed) take minutes. Layout-aware parsing adds its own cost, since Docling with a VLM is slower than PyMuPDF, but it is also batch and parallelises the same way.&lt;/p>
&lt;h3 id="vector-index-size">Vector index size&lt;/h3>
&lt;p>Each vector has dimension $d = 1024$ (that of &lt;code>bge-m3&lt;/code>). In float32 (4 bytes/dimension), each vector takes:&lt;/p>
$$\text{bytes}_{fp32} = d \times 4 = 1024 \times 4 = 4096 \text{ B} = 4 \text{ KB}$$
&lt;p>For the 1.22 M chunks, the dense vectors alone in fp32:&lt;/p>
$$\text{size}_{fp32} = N_{chunks} \times d \times 4 = 1.22 \times 10^{6} \times 4096 \text{ B} \approx 5.0 \text{ GB}$$
&lt;p>In int8 (1 byte/dimension), each vector takes 1 KB and the total falls 4×:&lt;/p>
$$\text{size}_{int8} = N_{chunks} \times d \times 1 = 1.22 \times 10^{6} \times 1024 \text{ B} \approx 1.25 \text{ GB}$$
&lt;p>To this you must add the HNSW index (~1.2× the size of the vectors for $m=16$) and the payload (metadata + chunk text, ~500 B/chunk → ~0.6 GB). In round numbers:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Vectors&lt;/th>
&lt;th>HNSW (~1.2×)&lt;/th>
&lt;th>Payload&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>fp32&lt;/strong>&lt;/td>
&lt;td>~5.0 GB&lt;/td>
&lt;td>~6.0 GB&lt;/td>
&lt;td>~0.6 GB&lt;/td>
&lt;td>&lt;strong>~11.6 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>int8&lt;/strong>&lt;/td>
&lt;td>~1.25 GB&lt;/td>
&lt;td>~1.5 GB&lt;/td>
&lt;td>~0.6 GB&lt;/td>
&lt;td>&lt;strong>~3.4 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading: a corpus of 50,000 documents fits in the RAM of a single node even in fp32, and in int8 (Qdrant) it fits with room to spare, which keeps search latency in single-digit milliseconds. int8 quantisation is almost always the balance point, saving 4× with a recall loss typically below 1%. (These numbers are order-of-magnitude, with the constants and assumptions stated; they are for sizing, not for pinning down an invoice.)&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 series&amp;rsquo; cluster: 4×H100 SXM 80 GB plus a generic CPU fleet (Xeon with AMX, NUCs). The correct split for ingestion is almost all CPU, with the GPU as an occasional exception:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Cleaning, dedup, chunking, metadata, ingestion embedding and indexing → the CPU fleet.&lt;/strong> All of this is batch work, throughput-bound, with no latency SLA. It is exactly the &amp;ldquo;data plane&amp;rdquo; the &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on CPU&lt;/a> piece talks about: no H100 should spend a cycle chunking documents or building an HNSW index (which was always CPU by design). The nightly incremental re-indexing of a corpus that changes on the scale of hours is the textbook case of &amp;ldquo;CPU work in no hurry&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The GPU only comes in at two points.&lt;/strong> First, in VLM parsing: if the corpus has scans, dense tables or forms and you choose Granite-Docling-258M or another vision-language model, that parsing can be accelerated on GPU, although at 258M parameters it is light and, at moderate volumes, runs on CPU without drama. Second, in the large embedder: if retrieval quality demands a 7B embedder (gte-Qwen2, NV-Embed) instead of &lt;code>bge-m3&lt;/code> (568M), that embedder is once again an LLM-class model and lives where the 7B models live, on the GPU.&lt;/li>
&lt;li>&lt;strong>The 4×H100 are reserved for generation.&lt;/strong> As throughout the series, the expensive and scarce silicon is kept for what is latency-bound, the LLM producing the answer, and at most for the peaks of VLM parsing or 7B embedding the CPU cannot absorb. To give a sense of the ceiling: a 4×H100 node serving &lt;code>bge-m3&lt;/code> through TEI runs at around ~2,000 chunks/s, against the thousands of tok/s of a Xeon in int8; but using the H100s for daily ingestion means spending the resource the whole organisation fights over on a job the CPU fleet does overnight without anyone missing it.&lt;/li>
&lt;/ul>
&lt;p>The sentence that sums up the split: ingestion is the archive&amp;rsquo;s cataloguing chain, and almost all of it is done by patient, cheap staff (CPU); the star writer (GPU) is only disturbed when there is a page no classic OCR can decipher.&lt;/p>
&lt;h2 id="closing-quality-is-decided-upstream">Closing: quality is decided upstream&lt;/h2>
&lt;p>The recurring error in mediocre RAG is not in the reranker or the prompt: it is in an ingestion that parsed a table badly, did not weed out the duplicates, or chunked at a size that destroys context. &lt;strong>Garbage-in, garbage-out&lt;/strong>: no downstream component fixes what ingestion spoiled upstream. Investing in the cataloguing chain, with layout-aware parsing where it is needed, real dedup, measured chunking, complete metadata, is what moves the needle on system quality the most, and almost all of it fits on the CPU fleet. The GPU, like the star writer, should only touch the corpus when its brain is genuinely required.&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/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise: serving a VLM with vLLM (vision + language)&lt;/a> — the VLM as an alternative to classic OCR for parsing documents with layout, tables or handwriting.&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 sibling piece: the inference server this ingestion feeds at the embedding stage.&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: data plane vs generation plane&lt;/a> — why all of this ingestion is CPU work and should not touch the GPU.&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/">PostgreSQL + Qdrant in RAG ingestion&lt;/a> — the synchronisation, the idempotent upsert and the CDC of incremental ingestion, in detail.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">The curated corpus this ingestion must build&lt;/a> — the curation and filtering fundamentals that precede and guide ingestion.&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> — what the system does with the dense + sparse chunks this ingestion indexed.&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 embedder that translates each chunk into a vector and when it justifies a 7B model on GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating RAG with RAGAS and a golden dataset&lt;/a> — how to measure whether ingestion really improved retrieval, instead of just believing it.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>IBM — &lt;em>Granite-Docling: End-to-end document understanding&lt;/em> (Granite-Docling-258M, Apache 2.0, DocTags, enero 2026). &lt;a href="https://www.ibm.com/new/announcements/granite-docling-end-to-end-document-conversion">https://www.ibm.com/new/announcements/granite-docling-end-to-end-document-conversion&lt;/a>&lt;/li>
&lt;li>ibm-granite — &lt;em>granite-docling-258M model card&lt;/em> (VLM ~258M, Granite 3 + SigLIP2). &lt;a href="https://huggingface.co/ibm-granite/granite-docling-258M">https://huggingface.co/ibm-granite/granite-docling-258M&lt;/a>&lt;/li>
&lt;li>Docling project — &lt;em>Docling: Get your documents ready for gen AI&lt;/em> (layout, tablas, multi-formato). &lt;a href="https://github.com/docling-project/docling">https://github.com/docling-project/docling&lt;/a>&lt;/li>
&lt;li>Unstructured — &lt;em>PDF Parsing Strategies for RAG&lt;/em> (fast / hi_res / VLM / auto; chunking by_title). &lt;a href="https://unstructured.io/blog/mastering-pdf-transformation-strategies-with-unstructured-part-2">https://unstructured.io/blog/mastering-pdf-transformation-strategies-with-unstructured-part-2&lt;/a> · &lt;a href="https://docs.unstructured.io/">https://docs.unstructured.io/&lt;/a>&lt;/li>
&lt;li>Omdena — &lt;em>Document Parsing for RAG: A Complete Guide for 2026&lt;/em> (PyMuPDF, TOC, pipeline). &lt;a href="https://www.omdena.com/blog/document-parsing-for-rag">https://www.omdena.com/blog/document-parsing-for-rag&lt;/a>&lt;/li>
&lt;li>Firecrawl — &lt;em>Best Chunking Strategies for RAG (and LLMs) in 2026&lt;/em> (benchmark Vecta feb-2026: recursive 512 → 69%, semántico 54%; context cliff ~2.500 tok). &lt;a href="https://www.firecrawl.dev/blog/best-chunking-strategies-rag">https://www.firecrawl.dev/blog/best-chunking-strategies-rag&lt;/a>&lt;/li>
&lt;li>Günther, M., et al. — &lt;em>Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models&lt;/em>. arXiv 2409.04701. &lt;a href="https://arxiv.org/abs/2409.04701">https://arxiv.org/abs/2409.04701&lt;/a> · Jina AI: &lt;a href="https://jina.ai/news/late-chunking-in-long-context-embedding-models/">https://jina.ai/news/late-chunking-in-long-context-embedding-models/&lt;/a>&lt;/li>
&lt;li>Brenndoerfer, M. — &lt;em>MinHash: Jaccard Similarity, LSH, and Near-Duplicate Detection&lt;/em>. &lt;a href="https://mbrenndoerfer.com/writing/minhash-algorithm-jaccard-similarity-lsh-deduplication">https://mbrenndoerfer.com/writing/minhash-algorithm-jaccard-similarity-lsh-deduplication&lt;/a>&lt;/li>
&lt;li>Zilliz — &lt;em>Data Deduplication at Trillion Scale&lt;/em> (MinHash LSH dominante en limpieza de corpus). &lt;a href="https://zilliz.com/blog/data-deduplication-at-trillion-scale-solve-the-biggest-bottleneck-of-llm-training">https://zilliz.com/blog/data-deduplication-at-trillion-scale-solve-the-biggest-bottleneck-of-llm-training&lt;/a>&lt;/li>
&lt;li>Ragnarök / TREC RAG 2024 — near-duplicados en MS MARCO V2 degradan recuperación y diversidad. &lt;a href="https://arxiv.org/pdf/2406.16828">https://arxiv.org/pdf/2406.16828&lt;/a>&lt;/li>
&lt;li>Encore — &lt;em>pgvector vs Qdrant in 2026&lt;/em> (halfvec, sparse, límites de cuantización de pgvector). &lt;a href="https://encore.dev/articles/pgvector-vs-qdrant">https://encore.dev/articles/pgvector-vs-qdrant&lt;/a>&lt;/li>
&lt;li>Markaicode — &lt;em>pgvector vs Qdrant (2026 Benchmarks)&lt;/em> (Qdrant int8 scalar quantization 4×). &lt;a href="https://markaicode.com/vs/pgvector-vs-qdrant/">https://markaicode.com/vs/pgvector-vs-qdrant/&lt;/a>&lt;/li>
&lt;li>Katz, J. — &lt;em>Scalar and binary quantization for pgvector&lt;/em>. &lt;a href="https://jkatz05.com/post/postgres/pgvector-scalar-binary-quantization/">https://jkatz05.com/post/postgres/pgvector-scalar-binary-quantization/&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>Text Embeddings Inference (TEI)&lt;/em>, backends CPU/GPU, endpoints OpenAI-compatibles. &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&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>Aggressive RAG in small models: trading parameters for retrieval</title><link>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</link><pubDate>Tue, 09 Jun 2026 02:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</guid><description>&lt;blockquote>
&lt;p>This post belongs to the series on inference performance in small models. Its sibling piece, &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a>, explains why the compute-bound prefill is the bottleneck that shapes the whole discussion here. It is worth reading first: here we assume that &lt;strong>adding more context is not free&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An SLM (say 1B–8B parameters) knows &lt;strong>fewer facts&lt;/strong> than a 70B–700B model, simply because it has fewer weights in which to memorise them. But its ability to &lt;strong>reason over text in front of it&lt;/strong>, following instructions, extracting, synthesising, comparing, degrades far less with size than its encyclopaedic knowledge does. The operational consequence is direct: use the SLM as a &lt;strong>reasoning engine over curated context&lt;/strong>, not as a database. Move knowledge from the weights into the context via retrieval. The problem is that &amp;ldquo;aggressive retrieval&amp;rdquo; is often read as &amp;ldquo;put in lots of chunks&amp;rdquo;, and that collides head-on with three facts about SLMs: shorter context windows, worse use of long context (the &lt;em>lost in the middle&lt;/em> effect is more severe the smaller the model) and a &lt;strong>compute-bound prefill&lt;/strong> whose cost grows with the context length $C$, linearly in the projections and quadratically in attention. You cannot simply add tokens. Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb either cost. The way out is not to retrieve less, but to retrieve &lt;em>better&lt;/em>: reranking for precision over recall, compressing the context before injecting it, prefix caching the stable documents, semantic caching of answers, and structured output with external tools that stand in for internal knowledge. This post works through the maths and gives a TTFT number before and after compressing a context from 4,000 to 1,000 tokens on an RTX 4090.&lt;/p>
&lt;h2 id="the-analogy-the-open-book-exam">The analogy: the open-book exam&lt;/h2>
&lt;p>Two students sit the same exam. The first has a prodigious memory: they have memorised the entire syllabus, paragraph by paragraph. The second has an ordinary memory, forgetting dates, confusing names, but is allowed to bring in a &lt;strong>crib sheet&lt;/strong>.&lt;/p>
&lt;p>If the second student&amp;rsquo;s crib sheet is a chaos of piled-up photocopies, they lose: they take too long to find what they are after, get distracted by irrelevant pages and run out of time. But if their crib sheet is &lt;em>excellent&lt;/em>, trimmed to the essentials, reordered by relevance, with the important things at the top and no filler, then they not only do not lose: they often &lt;em>win&lt;/em>, because they reason just as well as the first student and, on top of that, work from verified material instead of from blurry recollections they may be inventing.&lt;/p>
&lt;p>The moral has three layers, and each maps to an engineering decision:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memorising everything is expensive.&lt;/strong> The first student invested months. A large model invests parameters, and VRAM, and inference FLOPs, in memorising facts.&lt;/li>
&lt;li>&lt;strong>The crib sheet matters more than its size.&lt;/strong> A well-made one-page crib sheet beats ten badly organised pages. More retrieved context is not better context: the material&amp;rsquo;s precision beats volume.&lt;/li>
&lt;li>&lt;strong>Knowing how to search and synthesise is a different skill from knowing.&lt;/strong> It is the one the SLM retains. The whole strategy consists of leaning on that skill and subcontracting the memory.&lt;/li>
&lt;/ul>
&lt;p>The rest of the post is, essentially, how to build an excellent crib sheet under the constraint that the student (the SLM) reads slowly and gets tired with long texts.&lt;/p>
&lt;h2 id="the-capacity-argument-how-many-facts-fit-in-the-weights">The capacity argument: how many facts fit in the weights&lt;/h2>
&lt;p>Let us start by justifying the thesis with an order of magnitude, not with faith. How much factual knowledge really fits in a model&amp;rsquo;s weights?&lt;/p>
&lt;p>There is a recurring empirical estimate in the interpretability and memorisation literature: a dense model can store on the order of &lt;strong>2 bits of memorised information per parameter&lt;/strong> before saturating (the exact figure varies by study and training regime; take it as an order of magnitude, not as a law). An 8B-parameter model then has an information storage ceiling on the order of:&lt;/p>
$$8 \times 10^9 \text{ params} \times 2 \text{ bits/param} = 1.6 \times 10^{10} \text{ bits} \approx 2 \text{ GB of information}$$
&lt;p>And that budget &lt;strong>is not only for facts&lt;/strong>: the vast majority goes on grammar, syntax, reasoning ability, code, formatting, and only a fraction is left for encyclopaedic knowledge. Compare it with the other side: a retrievable corpus of several million documents, a corporate wiki, a document repository, a technical knowledge base, easily takes up &lt;strong>hundreds of GB to terabytes&lt;/strong> of text, indexed and queryable with millisecond latency. The asymmetry is &lt;strong>two or three orders of magnitude&lt;/strong> in favour of the external corpus.&lt;/p>
&lt;p>The conclusion is not that the weights are useless, they are where reasoning lives, which is the expensive thing to replicate, but that &lt;strong>competing with an external index on fact capacity is losing by construction&lt;/strong>. A 70B model has ~9× more memorisation budget than an 8B one, but it is still negligible against the corpus. That is why the large model &lt;em>also&lt;/em> does RAG in production. The difference is that the SLM &lt;em>needs it&lt;/em>: without retrieval, its factual knowledge is too sparse and, worse, &lt;strong>prone to hallucinating&lt;/strong> exactly in the gaps it did not memorise.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Knowledge in weights versus knowledge in context">
&lt;text x="390" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Where knowledge lives&lt;/text>
&lt;text x="200" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the weights (memorised)&lt;/text>
&lt;rect x="90" y="70" width="220" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="200" y="95" text-anchor="middle" font-size="12" fill="#1f3550">~2 GB of useful info in 8B&lt;/text>
&lt;text x="200" y="113" text-anchor="middle" font-size="11" fill="#1f3550">fixed, costly to update, hallucinates in gaps&lt;/text>
&lt;text x="580" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the context (retrieved)&lt;/text>
&lt;rect x="430" y="70" width="300" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="580" y="95" text-anchor="middle" font-size="12" fill="#1c3a26">hundreds of GB – TB indexed&lt;/text>
&lt;text x="580" y="113" text-anchor="middle" font-size="11" fill="#1c3a26">fresh, citable, verifiable, no retraining&lt;/text>
&lt;text x="390" y="165" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The SLM as a reasoning engine&lt;/text>
&lt;rect x="240" y="178" width="300" height="46" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="390" y="200" text-anchor="middle" font-size="12" fill="#5a4500">reasons over the curated context&lt;/text>
&lt;text x="390" y="216" text-anchor="middle" font-size="11" fill="#5a4500">not the database: the one who reads and synthesises it&lt;/text>
&lt;path d="M200,130 L360,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;path d="M580,130 L420,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-central-tension-retrieving-more-is-not-putting-in-more">The central tension: retrieving more is not putting in more&lt;/h2>
&lt;p>This is where most naive designs break. &amp;ldquo;Aggressive retrieval&amp;rdquo; sounds like a large &lt;em>top-k&lt;/em>: if retrieval helps, retrieve 20 chunks instead of 5. But in an SLM that fails for two independent reasons, one of &lt;em>quality&lt;/em> and one of &lt;em>cost&lt;/em>.&lt;/p>
&lt;h3 id="a-slms-make-worse-use-of-long-context">(a) SLMs make worse use of long context&lt;/h3>
&lt;p>The &lt;em>lost in the middle&lt;/em> effect (Liu et al., 2023) is well known: LLMs retrieve information placed at the beginning and end of the context better, and the information in the middle worse. What gets less emphasis is that &lt;strong>the effect is more severe the smaller the model&lt;/strong>. An SLM has fewer attention heads, fewer layers and poorer internal representations for &amp;ldquo;tracking&amp;rdquo; a relevant fact buried at position 14 out of 20 chunks. Its nominal context window also tends to be shorter (4K–32K against the 128K+ of the large ones), and the &lt;strong>effective window&lt;/strong>, the length beyond which quality collapses, is smaller still. Putting in 20 chunks does not mean the model reads all 20: it means it will probably ignore or misread the ones in the middle, while paying the cost of all of them.&lt;/p>
&lt;h3 id="b-prefill-grows-with-the-context-and-is-compute-bound">(b) Prefill grows with the context and is compute-bound&lt;/h3>
&lt;p>This is the blow people underestimate. Prefill, processing the full prompt before emitting the first token, is the &lt;strong>compute-bound&lt;/strong> phase of inference (unlike decode, which is memory-bound; the detail lives in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts&lt;/a>). Its cost &lt;strong>grows with the context length&lt;/strong> $C$, and it determines TTFT (time to first token). More chunks → more prefill tokens → more TTFT and more compute cost per request. In an SLM, where prefill is proportionally more expensive relative to the model, this hurts especially.&lt;/p>
&lt;p>The operational conclusion is uncomfortable but clear: &lt;strong>you cannot compensate for fewer parameters simply by putting in more context.&lt;/strong> Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb both costs. The way out is to retrieve &lt;em>less but better&lt;/em>, and to compress what you retrieve.&lt;/p>
&lt;h2 id="the-maths-of-prefill">The maths of prefill&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;prefill grows with the context&amp;rdquo;. For a context of $C$ tokens, a transformer layer does two classes of work:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Linear projections&lt;/strong> (QKV, attention output, FFN): each token is multiplied by fixed-size weight matrices. The cost is $O(C)$ in FLOPs, linear in the number of tokens.&lt;/li>
&lt;li>&lt;strong>Attention&lt;/strong> ($QK^\top$ and the multiplication by $V$): each token attends to all the others. The cost is $O(C^2)$, quadratic in the number of tokens.&lt;/li>
&lt;/ol>
&lt;p>The total prefill cost per layer has the form:&lt;/p>
$$\text{FLOPs}_{\text{prefill}} \approx \underbrace{a \cdot C}_{\text{projections}} + \underbrace{b \cdot C^2}_{\text{attention}}$$
&lt;p>with $a$ and $b$ constants that depend on the model dimension. For moderate contexts (a few thousand tokens) in an SLM, the linear term still dominates or is comparable to the quadratic one; the quadratic term becomes dominant at long contexts. What matters: &lt;strong>if you compress the context&lt;/strong> $C \to C/k$, the linear term falls $\times k$ and the quadratic one falls $\times k^2$. Compression is the only lever that attacks both terms at once, and it attacks the worse one disproportionately.&lt;/p>
&lt;h3 id="numerical-example-ttft-before-and-after-compressing-rtx-4090">Numerical example: TTFT before and after compressing, RTX 4090&lt;/h3>
&lt;p>Let us model TTFT as the time to process the prefill tokens at a given prefill throughput. Take an RTX 4090 (24 GB, Ada Lovelace) serving a quantised SLM, with a &lt;strong>prefill throughput of ~5,000 tok/s&lt;/strong> (an illustrative figure; the real value depends on the model, the quantisation and the batch, so measure it, do not assume it).&lt;/p>
&lt;p>Let there be a retrieved context of &lt;strong>4,000 tokens&lt;/strong> (8 chunks of ~500 tokens). Approximating TTFT as dominated by the context prefill:&lt;/p>
$$\text{TTFT}_{\text{before}} \approx \frac{4000 \text{ tok}}{5000 \text{ tok/s}} = 0.80 \text{ s}$$
&lt;p>Now we compress that context to &lt;strong>1,000 tokens&lt;/strong> ($k = 4$). Prefill throughput is not constant with $C$, it drops a little at long contexts because of the quadratic term, but taking the conservative linear tokens/throughput approximation:&lt;/p>
$$\text{TTFT}_{\text{after}} \approx \frac{1000 \text{ tok}}{5000 \text{ tok/s}} = 0.20 \text{ s}$$
&lt;p>TTFT falls from &lt;strong>0.80 s to 0.20 s&lt;/strong>, a $4\times$ reduction in the linear part. But the FLOPs arithmetic is even more favourable in the attention component: that part of the work falls $\sim k^2 = 16\times$. In practice total TTFT does not fall 16× because the cost is not purely quadratic at this scale, but the real reduction lies &lt;strong>between 4× and something larger depending on how much attention weighed&lt;/strong>, and the aggregate compute saving (what pays the electricity bill and frees the GPU for another request) is substantially bigger than the plain 4× of the token count.&lt;/p>
&lt;p>The argument generalises: &lt;strong>compressing the context by a factor $k$ reduces TTFT by at least $\sim k\times$ and the attention cost by $\sim k^2\times$.&lt;/strong> For an SLM, where TTFT is often the SLA that matters, this is the difference between an assistant that answers instantly and one that feels slow.&lt;/p>
&lt;h2 id="the-five-levers-to-resolve-the-tension">The five levers to resolve the tension&lt;/h2>
&lt;p>The strategy is not &amp;ldquo;retrieve less and settle&amp;rdquo;. It is &lt;strong>retrieve aggressively from the index and then distil aggressively what you retrieved&lt;/strong> before it reaches the SLM. Five levers, in order of application within the pipeline.&lt;/p>
&lt;h3 id="1-aggressive-reranking-precision-over-recall">1. Aggressive reranking: precision over recall&lt;/h3>
&lt;p>The initial retriever (dense, sparse or hybrid) optimises &lt;em>recall&lt;/em>: it brings back 50–100 candidates so as not to leave anything out. The reranker, a cross-encoder that sees the query and the document together, optimises &lt;em>precision&lt;/em>: it reorders those candidates and you keep the &lt;strong>3–5 best&lt;/strong>. For an SLM this is not a luxury, it is structural: since the model uses long context badly, every chunk that goes in must &lt;em>earn its place&lt;/em>. Better 4 chunks of very high relevance than 15 mediocre ones. The detail of hybrid retrieval and reranking is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Reranking and hybrid retrieval&lt;/a>; here the rule is enough: &lt;strong>maximise recall in the retriever, maximise precision in the reranker, and inject few&lt;/strong>.&lt;/p>
&lt;h3 id="2-context-compression-distilling-the-crib-sheet">2. Context compression: distilling the crib sheet&lt;/h3>
&lt;p>Once you have the best chunks, they still contain filler: padding sentences, redundancy, context irrelevant to the specific query. &lt;strong>Context compression&lt;/strong> trims them before injection:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Extractive compression&lt;/strong> (LLMLingua / LongLLMLingua style, Jiang et al. 2023): a small model scores the &lt;em>perplexity&lt;/em> or relevance of each token/sentence with respect to the query and removes the low-information ones, keeping the densest extractive subset. It reduces tokens without a second large generative model in the loop. LongLLMLingua adds position-aware reordering to mitigate &lt;em>lost in the middle&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Abstractive compression&lt;/strong>: a model summarises the retrieved chunks into shorter text. More aggressive in token reduction, but it introduces a generative step (cost and possible loss of fidelity).&lt;/li>
&lt;li>&lt;strong>Soft prompts / context distillation&lt;/strong>: compressing the retrieved context not into text, but into a handful of &lt;strong>embeddings/soft tokens&lt;/strong> that the model consumes directly. It reduces the number of prefill tokens to a minimum, at the price of a trained, model-specific component.&lt;/li>
&lt;/ul>
&lt;p>The key point connects with the maths above: &lt;strong>compressing what you retrieved by a factor $k$ reduces prefill tokens $\times k$, and therefore TTFT $\sim\times k$ and the attention cost $\sim\times k^2$.&lt;/strong> It is the lever with the best return when long context is the bottleneck.&lt;/p>
&lt;h3 id="3-prefix-caching-of-stable-context">3. Prefix caching of stable context&lt;/h3>
&lt;p>Not all the context changes between requests. System instructions, definitions, recurring reference documents, schemas: these are &lt;strong>stable prefixes&lt;/strong>. &lt;strong>Prefix caching&lt;/strong> stores the already-computed KV cache of those prefixes and reuses it, so that prefill only processes the new part (the query and the specific chunks). If 60 % of your context is stable, you save 60 % of that segment&amp;rsquo;s prefill on every hit. For it to work, &lt;strong>the stable context must go at the start of the prompt&lt;/strong> (the KV cache is prefix-dependent) and it pays to maximise the &lt;em>hit rate&lt;/em>; the hit-rate engineering detail is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Prefix cache hit rate&lt;/a>. It combines especially well with RAG: retrieved documents that repeat across sessions get cached once.&lt;/p>
&lt;h3 id="4-semantic-caching-of-answers">4. Semantic caching of answers&lt;/h3>
&lt;p>A layer in front of the model: if a query is &lt;strong>semantically equivalent&lt;/strong> to one answered before (embedding similarity above a threshold), return the cached answer and &lt;strong>skip the model entirely&lt;/strong>, retrieval, prefill and decode included. In real workloads with long tails of repeated or near-repeated questions (FAQ, support), the saving is enormous because it eliminates the full cost, not just the prefill one. The trap is the threshold: too lax and you serve wrong answers to similar-but-different questions. The design is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Semantic caching for RAG&lt;/a>.&lt;/p>
&lt;h3 id="5-structured-output-and-function-calling-lean-on-tools-not-on-memory">5. Structured output and function calling: lean on tools, not on memory&lt;/h3>
&lt;p>The last lever changes what the SLM depends on. Instead of asking it to &lt;em>know&lt;/em> a fact (its weak point), make it &lt;em>call a tool&lt;/em> that knows it: a database query, an API, a calculator, a validator. &lt;strong>Structured output&lt;/strong> (forcing JSON conforming to a schema) and &lt;strong>function calling&lt;/strong> turn the SLM into an orchestrator that extracts arguments from the context and delegates the computation or the query. A reasonably capable SLM emits a well-formed &lt;em>tool call&lt;/em> far more reliably than it recalls a specific fact. This reduces the pressure on parametric knowledge &lt;em>and&lt;/em> on retrieval: for structured, fresh data (prices, inventory, states), querying beats retrieving text and beats memorising. The fundamentals are in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Structured output&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Function calling&lt;/a>.&lt;/p>
&lt;h2 id="the-complete-pipeline">The complete pipeline&lt;/h2>
&lt;p>The five levers are not alternatives: they chain together. The flow, with the token counter falling at each step:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Retrieve, rerank, compress, SLM pipeline with the token counter falling">
&lt;defs>&lt;marker id="rag1" 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="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Retrieve aggressively, distil aggressively, reason cheaply&lt;/text>
&lt;rect x="20" y="50" width="120" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="80" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#1f3550">Retriever&lt;/text>
&lt;text x="80" y="93" text-anchor="middle" font-size="11" fill="#1f3550">hybrid, recall&lt;/text>
&lt;text x="80" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#1f5fa8">~80 chunks&lt;/text>
&lt;rect x="180" y="50" width="120" height="60" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="240" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#3a1d70">Reranker&lt;/text>
&lt;text x="240" y="93" text-anchor="middle" font-size="11" fill="#3a1d70">precision&lt;/text>
&lt;text x="240" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#5a2db0">5 chunks · 4,000 tok&lt;/text>
&lt;rect x="340" y="50" width="120" height="60" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="400" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#5a4500">Compression&lt;/text>
&lt;text x="400" y="93" text-anchor="middle" font-size="11" fill="#5a4500">extractive k=4&lt;/text>
&lt;text x="400" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a48000">1,000 tok&lt;/text>
&lt;rect x="500" y="50" width="120" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="560" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#1c3a26">Prefix cache&lt;/text>
&lt;text x="560" y="90" text-anchor="middle" font-size="11" fill="#1c3a26">+ semantic&lt;/text>
&lt;text x="560" y="103" text-anchor="middle" font-size="11" fill="#1c3a26">cache&lt;/text>
&lt;text x="560" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#2a7a40">minimal prefill&lt;/text>
&lt;rect x="660" y="50" width="100" height="60" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="710" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#6a1a1a">SLM&lt;/text>
&lt;text x="710" y="90" text-anchor="middle" font-size="11" fill="#6a1a1a">reasons +&lt;/text>
&lt;text x="710" y="103" text-anchor="middle" font-size="11" fill="#6a1a1a">tool calls&lt;/text>
&lt;text x="710" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a52a2a">answer&lt;/text>
&lt;path d="M140,80 L180,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M300,80 L340,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M460,80 L500,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M620,80 L660,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;text x="390" y="180" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The prefill token counter falls along the pipeline&lt;/text>
&lt;rect x="100" y="200" width="560" height="22" fill="none" stroke="currentColor" stroke-width="1"/>
&lt;rect x="100" y="200" width="560" height="22" fill="#d4ecff"/>
&lt;rect x="240" y="200" width="280" height="22" fill="#fff4d6"/>
&lt;rect x="240" y="200" width="70" height="22" fill="#cdebd0"/>
&lt;text x="180" y="216" text-anchor="middle" font-size="11" fill="#1f3550">retrieve: a lot&lt;/text>
&lt;text x="380" y="216" text-anchor="middle" font-size="11" fill="#5a4500">rerank: 4,000 tok&lt;/text>
&lt;text x="275" y="216" text-anchor="middle" font-size="11" fill="#1c3a26">1,000&lt;/text>
&lt;text x="390" y="252" text-anchor="middle" font-size="12" fill="currentColor">TTFT on RTX 4090 at ~5,000 tok/s · 4,000 tok = 0.80 s → 1,000 tok = 0.20 s&lt;/text>
&lt;text x="390" y="272" text-anchor="middle" font-size="11" fill="currentColor">attention falls ~k² = 16× in that part of the compute&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The order matters. Retrieving aggressively (high recall) &lt;em>before&lt;/em> filtering guarantees that the right material is among the candidates; reranking and compressing &lt;em>afterwards&lt;/em> guarantees that only the dense and relevant material pays the prefill toll; caching wraps everything so as not to repeat work. The SLM only sees the final crib sheet, short and ordered.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The mental trap to avoid: treating the SLM as a large model with less quality. It is not. It is a &lt;strong>different cost profile&lt;/strong> that rewards a different design. Three practical consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The token budget is a first-class resource.&lt;/strong> With a large model with a 128K window, &amp;ldquo;putting in a bit more&amp;rdquo; is cheap relative to the model. With an SLM, every context token shows up in TTFT and in quality. Treat context size as a quantity to &lt;strong>minimise subject to covering the answer&lt;/strong>, not to maximise.&lt;/li>
&lt;li>&lt;strong>The investment is worth it precisely because the model is cheap.&lt;/strong> Reranker, compressor and caches add complexity, but the model they serve is economical enough to run many replicas. The bottleneck shifts from the model to the data pipeline, which is exactly where you want it.&lt;/li>
&lt;li>&lt;strong>Retrieving does not replace adapting; they combine.&lt;/strong> For deep, recurring domain knowledge, adapting the SLM with LoRA (see the sibling &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA&lt;/a>) can put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; cheaply, reducing what has to be retrieved. Aggressive RAG and aggressive adaptation do not compete: the first gives freshness and citability, the second gives fluency and domain formatting. A good design uses both.&lt;/li>
&lt;/ul>
&lt;h3 id="on-the-rtx-4090-24-gb-ada-lovelace">On the RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The canonical scenario: a quantised SLM (4B–8B in INT4/FP8) fits with room to spare, leaving VRAM for a generous KV cache, essential for prefix caching, and for the reranker (a cross-encoder of a few hundred MB). The LLMLingua-style extractive compressor runs on a separate small model or on CPU. The TTFT calculation above (0.80 s → 0.20 s compressing 4× at ~5,000 tok/s) is representative of this card. The rule of thumb: if TTFT goes above your SLA, &lt;strong>the first adjustment is to compress the context, not to change the model&lt;/strong>.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>With 320 GB and native FP8 prefill is much faster, so the temptation is to relax the token discipline. That is not quite advisable: the lever changes from TTFT to &lt;em>aggregate throughput&lt;/em>. Compressing the context not only speeds up each request but &lt;strong>frees prefill compute&lt;/strong> to serve more requests per GPU; compute-bound prefill is exactly the resource that saturates first under load. Here prefix caching and semantic caching, shared across replicas, are what pay off most: at high QPS, the prefill work you avoid by caching is pure throughput you gain. The SLM is still the cheap reasoning engine; the difference is that you now run many in parallel and the data pipeline is what decides how many requests fit.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Evaluating compression&lt;/strong>: how to measure that compressing $k=4$ does not throw away correct answers (faithfulness, answer recall over a set of questions with ground truth).&lt;/li>
&lt;li>&lt;strong>Query-aware versus query-agnostic compression&lt;/strong>: compressing before or after knowing the question changes what can be cached and what can be discarded.&lt;/li>
&lt;li>&lt;strong>Chunking and granularity&lt;/strong>: chunk size interacts with reranking and compression; that is left for the corpus curation post.&lt;/li>
&lt;li>&lt;strong>Multi-hop and agents&lt;/strong>: when a question requires several retrieval rounds, the token budget is split across hops and compression discipline becomes critical.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval for RAG&lt;/a> — lever 1 in detail: maximise recall in the retriever and precision in the reranker so as to inject few but excellent chunks, which is what an SLM needs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">Corpus curation for RAG&lt;/a> — a clean, well-chunked corpus reduces the filler the compressor has to remove; the crib sheet&amp;rsquo;s quality starts upstream.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic caching for RAG&lt;/a> — lever 4: skipping the model entirely when a query is semantically equivalent to one already answered.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings 2026: dense, sparse and multivector&lt;/a> — the basis of hybrid retrieval and of the semantic cache threshold; which representation retrieves better with less noise.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — lever 3: how to structure the prompt (stable context first) to maximise reuse of the retrieved context&amp;rsquo;s KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a> — compute-bound prefill is the cost this whole discussion tries to minimise; here are the concrete parameters to speed it up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — lever 5: forcing schema-conforming JSON so the SLM orchestrates tools instead of recalling data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">Function calling and tool-augmented retrieval&lt;/a> — when querying an API or database beats retrieving text and beats memorising; the SLM as a tool orchestrator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a> — why compute-bound prefill is the bottleneck that shapes this whole post: adding more context is not free.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA in SLMs&lt;/a> — the complementary alternative: adapting the SLM per domain to put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; and reduce what has to be retrieved.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Lewis, P., et al. &lt;em>Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks&lt;/em>. NeurIPS 2020. &lt;a href="https://arxiv.org/abs/2005.11401">https://arxiv.org/abs/2005.11401&lt;/a>&lt;/li>
&lt;li>Liu, N.F., et al. &lt;em>Lost in the Middle: How Language Models Use Long Contexts&lt;/em>. TACL 2024. &lt;a href="https://arxiv.org/abs/2307.03172">https://arxiv.org/abs/2307.03172&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LLMLingua: Compressing Prompts for Accelerated Inference of Large Language Models&lt;/em>. EMNLP 2023. &lt;a href="https://arxiv.org/abs/2310.05736">https://arxiv.org/abs/2310.05736&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LongLLMLingua: Accelerating and Enhancing LLMs in Long Context Scenarios via Prompt Compression&lt;/em>. ACL 2024. &lt;a href="https://arxiv.org/abs/2310.06839">https://arxiv.org/abs/2310.06839&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Debezium and CDC: the notary who hears changes before anyone asks</title><link>https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/</link><pubDate>Thu, 04 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Change Data Capture (CDC) with Debezium listens to the PostgreSQL Write-Ahead Log and turns every INSERT, UPDATE and DELETE into a structured Kafka event. Unlike traditional polling (&lt;code>SELECT ... WHERE updated_at &amp;gt; ?&lt;/code>), it detects deletes, has latency in the tens of milliseconds and adds no extra load to the database. In RAG pipelines, this means that when a document is deleted from Postgres, the Qdrant chunks disappear too, automatically and in real time. The supporting infrastructure is modest: the connector uses 2-4 cores and 4-8 GB RAM to process thousands of events per second.&lt;/p>
&lt;hr>
&lt;h2 id="the-master-analogy-the-land-registry-notary">The master analogy: the land registry notary&lt;/h2>
&lt;p>Picture the land registry. Every time a flat is sold, mortgaged, or a mortgage is cancelled, the registrar records the operation in the &lt;strong>registry ledger&lt;/strong>, a chronological and immutable journal. If you want to know what has changed in the registry, you have two options.&lt;/p>
&lt;p>&lt;strong>Option A (polling):&lt;/strong> you send someone every 5 minutes with a list of properties to ask &amp;ldquo;has anything changed?&amp;rdquo;. Problems: if a title was cancelled (DELETE), the property no longer exists by the time your envoy arrives, and there is no trace of it. If 20 different departments are doing the same thing, that is 20 people pestering the registrar every 5 minutes. And the minimum latency is the interval: 5 minutes.&lt;/p>
&lt;p>&lt;strong>Option B (Debezium):&lt;/strong> you hire a &lt;strong>notary&lt;/strong> who sits right at the registrar&amp;rsquo;s desk. Every time the registrar signs an operation into the ledger, the notary notes it down immediately and notifies whoever needs to know. Title cancellations included, because the notary sees them as clearly as any other operation: he was there when it was signed.&lt;/p>
&lt;p>In this analogy:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>registry ledger&lt;/strong> is the PostgreSQL &lt;strong>WAL&lt;/strong> (Write-Ahead Log).&lt;/li>
&lt;li>The &lt;strong>notary&lt;/strong> is the &lt;strong>Debezium connector&lt;/strong>.&lt;/li>
&lt;li>The notary&amp;rsquo;s &lt;strong>bookmark&lt;/strong>, which guarantees he loses no page even if he steps out for a moment, is the &lt;strong>logical replication slot&lt;/strong>.&lt;/li>
&lt;li>The &lt;strong>messenger&lt;/strong> who carries the notifications to interested parties is &lt;strong>Kafka&lt;/strong> (or Redpanda, or NATS JetStream).&lt;/li>
&lt;/ul>
&lt;p>We will pick this thread up in every section. When a technical detail is unclear, go back to the image of the notary.&lt;/p>
&lt;hr>
&lt;h2 id="1-the-problem-cdc-solves">1. The problem CDC solves&lt;/h2>
&lt;p>The most common synchronisation pattern between services sharing PostgreSQL is periodic polling:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&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="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&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="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This pattern has three structural problems.&lt;/p>
&lt;p>&lt;strong>DELETEs are invisible.&lt;/strong> When you delete a row, &lt;code>updated_at&lt;/code> is not updated: the row is gone. The next time the poller queries, the row does not exist and there is no way to know it ever did. In a RAG pipeline, this means &lt;strong>orphan chunks in Qdrant&lt;/strong>: the document no longer exists in Postgres, but its vectors keep polluting search results.&lt;/p>
&lt;p>&lt;strong>The minimum latency is the interval.&lt;/strong> If the poller runs every 5 seconds, average latency is 2.5 seconds. For near-real-time synchronisation (dashboards, alerts, RAG with frequently changing documents), that is too much.&lt;/p>
&lt;p>&lt;strong>Load scales with the number of consumers.&lt;/strong> If 10 services poll the same table every 5 seconds, that is 10 × 12 = 120 queries/minute producing no useful work; they only check whether there is anything new. On large tables with complex indexes, that is real load on the database.&lt;/p>
&lt;p>CDC inverts the model: &lt;strong>the database notifies, the consumers listen&lt;/strong>. Zero polling, zero extra load, DELETEs included, latency in the tens of milliseconds.&lt;/p>
&lt;hr>
&lt;h2 id="2-what-the-postgresql-wal-is">2. What the PostgreSQL WAL is&lt;/h2>
&lt;h3 id="the-operations-journal">The operations journal&lt;/h3>
&lt;p>The Write-Ahead Log (WAL) is the chronological, immutable record of every operation Postgres performs. Before modifying any data page on disk, Postgres writes the operation to the WAL. That sequence, log first then data, is what guarantees durability (the D in ACID) and enables crash recovery: if Postgres dies mid-transaction, on restart it replays the WAL to return the database to a consistent state.&lt;/p>
&lt;p>The WAL is the &lt;strong>registry ledger&lt;/strong> of our analogy: chronological, immutable, complete.&lt;/p>
&lt;h3 id="physical-vs-logical-replication">Physical vs logical replication&lt;/h3>
&lt;p>PostgreSQL supports two WAL-based replication modes:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Physical replication:&lt;/strong> it replicates disk blocks verbatim. The standby receives the same bytes as the primary. It serves high availability and failover, but the target must be an exact copy of Postgres; you cannot send the changes to an external application.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Logical replication:&lt;/strong> instead of disk blocks, it replicates &lt;strong>semantic operations&lt;/strong>: &amp;ldquo;the row with id=42 was inserted into table &lt;code>documents&lt;/code> with these values&amp;rdquo;. The target can be anything that understands the protocol: another Postgres, Debezium, or any custom consumer.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>CDC uses logical replication. It is what lets Debezium understand &amp;ldquo;what changed and in which table&amp;rdquo; rather than &amp;ldquo;which disk block changed at which offset&amp;rdquo;.&lt;/p>
&lt;h3 id="the-replication-slot-the-notarys-bookmark">The replication slot: the notary&amp;rsquo;s bookmark&lt;/h3>
&lt;p>A &lt;strong>logical replication slot&lt;/strong> is a persistent cursor in the WAL. Postgres keeps a record of how far into the WAL each slot has consumed. While a slot exists, Postgres &lt;strong>guarantees it will not discard the WAL segments the slot has not yet read&lt;/strong>.&lt;/p>
&lt;p>This is exactly the notary&amp;rsquo;s bookmark: even if the notary goes out for lunch, the ledger stays open at the last page he read. When he comes back, he carries on from exactly where he left off, having lost nothing.&lt;/p>
&lt;p>The risk is the inverse: &lt;strong>if the notary never comes back&lt;/strong>, the bookmark stops the registrar from archiving the old pages. If the Debezium connector goes down and does not recover for hours, the WAL grows indefinitely on disk until the slot is deleted manually or the consumer starts consuming again. This is called &lt;strong>WAL disk blowup&lt;/strong> and is the most important operational risk of Debezium.&lt;/p>
&lt;p>Mandatory monitoring:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">slot_name&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="n">confirmed_flush_lsn&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="n">pg_current_wal_lsn&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="n">pg_wal_lsn_diff&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pg_current_wal_lsn&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">confirmed_flush_lsn&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">lag_bytes&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_replication_slots&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">slot_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;logical&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-pgoutput-plugin">The pgoutput plugin&lt;/h3>
&lt;p>The WAL stores operations in an internal binary format. For Debezium to understand them, Postgres needs to &lt;strong>decode&lt;/strong> them into a readable format. The &lt;code>pgoutput&lt;/code> decoding plugin, included in the Postgres core since version 10, does exactly that: it translates the binary WAL events into messages with the before/after structure of each row.&lt;/p>
&lt;p>Debezium uses &lt;code>pgoutput&lt;/code> by default. It requires no external extensions (unlike the &lt;code>wal2json&lt;/code> plugin that was popular before Postgres 10).&lt;/p>
&lt;hr>
&lt;h2 id="3-debezium-architecture">3. Debezium architecture&lt;/h2>
&lt;h3 id="the-connector-as-a-kafka-connect-plugin">The connector as a Kafka Connect plugin&lt;/h3>
&lt;p>Debezium is not a standalone service; it is a plugin for the &lt;strong>Kafka Connect&lt;/strong> framework. Kafka Connect manages the connector&amp;rsquo;s lifecycle (start, stop, reconnection, offset tracking) and provides the parallelism and fault tolerance infrastructure.&lt;/p>
&lt;p>The connector talks to Postgres through the logical replication protocol (not over JDBC), using the credentials of a user with the &lt;code>REPLICATION&lt;/code> role.&lt;/p>
&lt;pre tabindex="0">&lt;code>PostgreSQL (WAL + pgoutput)
│
│ logical replication protocol
▼
Debezium Connector (Kafka Connect worker)
│
│ Kafka Producer API
▼
Kafka topic: rag.public.documents
│
▼
Consumer (Qdrant sync, audit log, fine-tuning pipeline...)
&lt;/code>&lt;/pre>&lt;h3 id="structure-of-a-debezium-event">Structure of a Debezium event&lt;/h3>
&lt;p>Each change in the table becomes a JSON message with this structure:&lt;/p>
&lt;p>&lt;strong>INSERT (&lt;code>&amp;quot;op&amp;quot;: &amp;quot;c&amp;quot;&lt;/code> — create):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Contrato de arrendamiento...&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="nt">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&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="nt">&amp;#34;updated_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000000&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="nt">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;c&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="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2.7.0.Final&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="nt">&amp;#34;connector&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgresql&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="nt">&amp;#34;db&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag_db&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="nt">&amp;#34;schema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public&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="nt">&amp;#34;table&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;documents&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="nt">&amp;#34;lsn&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">29823948&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;txId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1047&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ts_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000123&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>DELETE (&lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code>):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Contrato de arrendamiento...&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="nt">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&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="nt">&amp;#34;updated_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000000&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="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;d&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="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;lsn&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">29824102&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;txId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1051&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;ts_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934060200&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;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>before&lt;/code> field holds the row&amp;rsquo;s previous state, available because Postgres can be configured with &lt;strong>REPLICA IDENTITY FULL&lt;/strong> to include the complete row in the WAL on delete or update. Without that setting, &lt;code>before&lt;/code> contains only the primary key.&lt;/p>
&lt;p>&lt;strong>This is the key for the RAG pipeline&lt;/strong>: the DELETE event carries the document &lt;code>id&lt;/code>. The consumer uses it to delete every associated chunk in Qdrant with a &lt;code>doc_id = 42&lt;/code> filter. Without CDC, those chunks would never have been deleted.&lt;/p>
&lt;h3 id="initial-snapshot">Initial snapshot&lt;/h3>
&lt;p>When the connector starts for the first time (or after a reset), it cannot begin consuming the WAL from &amp;ldquo;the beginning of time&amp;rdquo;, only from the moment the slot was created. How does it guarantee the consistency of the initial state?&lt;/p>
&lt;p>Through a &lt;strong>transactional snapshot&lt;/strong>: the connector opens a transaction in &lt;code>REPEATABLE READ&lt;/code> mode, exports the snapshot ID (&lt;code>pg_export_snapshot()&lt;/code>), and does a full &lt;code>SELECT&lt;/code> of the configured tables inside that transaction. It then starts consuming the WAL from the snapshot&amp;rsquo;s LSN. That way there is no gap: the snapshot covers the state up to an instant, and the WAL covers from that instant onwards.&lt;/p>
&lt;h3 id="smt-transformations-single-message-transforms">SMT transformations (Single Message Transforms)&lt;/h3>
&lt;p>Before emitting the event to the Kafka topic, the connector can apply inline transformations called &lt;strong>SMT&lt;/strong>. Common use cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Filtering sensitive columns&lt;/strong> (&lt;code>ReplaceField&lt;/code> with &lt;code>blacklist&lt;/code>): strip &lt;code>password_hash&lt;/code>, &lt;code>phone_number&lt;/code> before they reach the topic.&lt;/li>
&lt;li>&lt;strong>Adding metadata&lt;/strong> (&lt;code>InsertField&lt;/code>): enrich the event with a &lt;code>tenant_id&lt;/code> extracted from the original HTTP header (if it is in the row).&lt;/li>
&lt;li>&lt;strong>Conditional routing&lt;/strong> (&lt;code>Filter&lt;/code>): drop events from rows with &lt;code>status = 'draft'&lt;/code> before emitting them.&lt;/li>
&lt;/ul>
&lt;p>SMTs are pure configuration, requiring no code, and they are applied inside the connector process with no perceptible extra latency.&lt;/p>
&lt;hr>
&lt;h2 id="4-debezium-vs-the-outbox-pattern">4. Debezium vs the Outbox pattern&lt;/h2>
&lt;p>The &lt;strong>Outbox pattern&lt;/strong> is the most common alternative to pure CDC. Instead of emitting events directly to Kafka, the application writes to an &lt;code>outbox&lt;/code> table in Postgres within the same transaction that modifies the data. A separate worker reads that table and publishes the events.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Debezium (pure CDC)&lt;/th>
&lt;th>Outbox pattern&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Event latency&lt;/strong>&lt;/td>
&lt;td>~50-200 ms from commit&lt;/td>
&lt;td>Depends on the worker interval (typically 1-5 s)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DELETE detection&lt;/strong>&lt;/td>
&lt;td>Native (the DELETE event includes &lt;code>before&lt;/code>)&lt;/td>
&lt;td>Only if the app writes to the outbox on delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Setup complexity&lt;/strong>&lt;/td>
&lt;td>High (Kafka Connect, replication slot, permissions)&lt;/td>
&lt;td>Low (an extra table plus a simple worker)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Infrastructure dependency&lt;/strong>&lt;/td>
&lt;td>Requires Kafka/Redpanda/NATS JetStream&lt;/td>
&lt;td>Postgres plus a worker; Kafka optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WAL disk blowup risk&lt;/strong>&lt;/td>
&lt;td>Yes, if the slot stops consuming&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Schema visibility&lt;/strong>&lt;/td>
&lt;td>Reads the table&amp;rsquo;s real schema&lt;/td>
&lt;td>The event schema is defined by the app&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Schema migration&lt;/strong>&lt;/td>
&lt;td>Needs care (events reflect DDL changes)&lt;/td>
&lt;td>More flexible (the event is whatever the app puts there)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to use it&lt;/strong>&lt;/td>
&lt;td>When you need DELETEs, low latency, or you cannot modify the app&lt;/td>
&lt;td>When the app controls the event domain and infrastructure is limited&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Rule of thumb:&lt;/strong> if you control the application code and do not need native DELETEs, the Outbox is simpler. If you do not control the code (legacy database, third-party application) or DELETEs are critical (a RAG pipeline with document deletion), Debezium is the right choice.&lt;/p>
&lt;hr>
&lt;h2 id="5-the-maths">5. The maths&lt;/h2>
&lt;h3 id="throughput">Throughput&lt;/h3>
&lt;p>Debezium on a connector with 4 workers can process between &lt;strong>10,000 and 50,000 events/second&lt;/strong> on modest hardware (4 cores, 8 GB RAM). The real bottleneck is not the connector but the Kafka broker: with 3 brokers and suitable partitions, Kafka can comfortably sustain 500,000 messages/second with 1 KB messages (source: public Confluent benchmarks, 2023).&lt;/p>
&lt;p>For a typical RAG pipeline with 100 documents modified per minute:&lt;/p>
$$\text{events/s} = \frac{100}{60} \approx 1{.}7 \text{ events/s}$$
&lt;p>That is 0.0034% of the connector&amp;rsquo;s capacity. Debezium will not be the bottleneck in any realistic RAG scenario.&lt;/p>
&lt;h3 id="end-to-end-latency">End-to-end latency&lt;/h3>
&lt;p>The path from a commit in Postgres to an upsert in Qdrant has these stages:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Typical latency&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Commit in Postgres → WAL written&lt;/td>
&lt;td>&amp;lt; 1 ms (synchronous with the commit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>WAL written → Debezium reads it (WAL lag)&lt;/td>
&lt;td>10-50 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Debezium → Kafka produce (ack)&lt;/td>
&lt;td>5-20 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kafka → Consumer (poll interval)&lt;/td>
&lt;td>0-100 ms (configurable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consumer → Qdrant upsert/delete&lt;/td>
&lt;td>5-15 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>30-200 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With &lt;code>fetch.min.bytes=1&lt;/code> and &lt;code>fetch.max.wait.ms=10&lt;/code> on the consumer, the Kafka poll latency drops to ~10 ms. The realistic range for an optimised pipeline is &lt;strong>30-100 ms&lt;/strong>.&lt;/p>
&lt;h3 id="wal-disk-blowup-risk">WAL disk blowup risk&lt;/h3>
&lt;p>If the connector stops consuming, Postgres retains the WAL from the slot&amp;rsquo;s &lt;code>confirmed_flush_lsn&lt;/code> onwards. The retained volume grows linearly with time and the write rate:&lt;/p>
$$\text{retained WAL} = \text{write rate} \times \text{average WAL event size} \times \text{time without consuming}$$
&lt;p>Example with a moderate load (50,000 writes/hour, 500 bytes average per WAL event):&lt;/p>
$$50{,}000 \times 500 \text{ B} \times 1 \text{ h} = 25 \text{ MB/h}$$
&lt;p>With a high load (1,000,000 writes/hour):&lt;/p>
$$1{,}000{,}000 \times 500 \text{ B} \times 1 \text{ h} = 500 \text{ MB/h}$$
&lt;p>If the connector is down for 48 hours under high load: &lt;strong>24 GB of retained WAL&lt;/strong>. That can fill the disk and block Postgres completely.&lt;/p>
&lt;p>&lt;strong>Recommended alert:&lt;/strong> set an alert when &lt;code>lag_bytes &amp;gt; 1 GB&lt;/code> or when &lt;code>confirmed_flush_lsn&lt;/code> has not advanced for more than 15 minutes. See the monitoring query in section 2.&lt;/p>
&lt;hr>
&lt;h2 id="6-use-cases-in-llmops--rag">6. Use cases in LLMOps / RAG&lt;/h2>
&lt;h3 id="rag-synchronisation-with-real-deletion">RAG synchronisation with real deletion&lt;/h3>
&lt;p>This is the use case that most clearly justifies Debezium over polling. The flow:&lt;/p>
&lt;ol>
&lt;li>A user deletes document &lt;code>id=42&lt;/code> from the document management interface.&lt;/li>
&lt;li>Postgres runs &lt;code>DELETE FROM documents WHERE id = 42&lt;/code>.&lt;/li>
&lt;li>Debezium detects the DELETE in the WAL and emits the event with &lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code> and &lt;code>&amp;quot;before&amp;quot;: {&amp;quot;id&amp;quot;: 42, ...}&lt;/code>.&lt;/li>
&lt;li>The consumer receives the event and runs:
&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="n">qdrant_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&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;documents&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">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;doc_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">42&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;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>Every chunk with &lt;code>doc_id=42&lt;/code> disappears from Qdrant in ~100 ms.&lt;/li>
&lt;/ol>
&lt;p>Without Debezium, those chunks would stay indefinitely, polluting retrieval results with fragments of documents that no longer exist in the source of truth.&lt;/p>
&lt;h3 id="event-sourcing-for-fine-tuning-datasets">Event sourcing for fine-tuning datasets&lt;/h3>
&lt;p>Every time a human annotator updates a row in the &lt;code>annotations&lt;/code> table (correcting an LLM output), Debezium emits the UPDATE with &lt;code>before&lt;/code> and &lt;code>after&lt;/code>. The consumer writes the pair (original_output, correction) into the dataset curation pipeline, with no need for the annotator to do anything beyond saving in the interface. The fine-tuning pipeline knows exactly what changed and when, with no polling and no risk of duplicates from overlapping time windows.&lt;/p>
&lt;h3 id="immutable-audit-log">Immutable audit log&lt;/h3>
&lt;p>WAL events are, by definition, the most faithful record of what happened in the database: they are the same data Postgres uses for crash recovery. Kafka with long retention (90 days, or size-based retention) serves as an &lt;strong>immutable audit log&lt;/strong> without modifying the application schema or adding triggers. This is particularly useful in regulated environments where traceability of data modifications is required.&lt;/p>
&lt;hr>
&lt;h2 id="7-architecture-diagram">7. Architecture diagram&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Debezium CDC architecture diagram: PostgreSQL, Debezium, Kafka, Consumer and Qdrant">
&lt;defs>
&lt;marker id="arrow-deb" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">&lt;path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>&lt;/marker>
&lt;/defs>
&lt;rect x="0" y="0" width="820" height="340" fill="#f8f9fa" rx="8"/>
&lt;rect x="20" y="60" width="170" height="220" fill="#dbeafe" stroke="#3b82f6" stroke-width="1.5" rx="8"/>
&lt;text x="105" y="84" font-family="monospace" font-size="13" font-weight="bold" fill="#1e40af" text-anchor="middle">PostgreSQL&lt;/text>
&lt;rect x="36" y="96" width="138" height="52" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="116" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">WAL&lt;/text>
&lt;text x="105" y="131" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(registry ledger)&lt;/text>
&lt;rect x="36" y="160" width="138" height="52" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="180" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">Replication slot&lt;/text>
&lt;text x="105" y="195" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(bookmark)&lt;/text>
&lt;rect x="36" y="224" width="138" height="40" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="244" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">pgoutput&lt;/text>
&lt;text x="105" y="257" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(decoding)&lt;/text>
&lt;line x1="190" y1="170" x2="248" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="219" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">logical&lt;/text>
&lt;text x="219" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">replication&lt;/text>
&lt;rect x="248" y="110" width="160" height="120" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5" rx="8"/>
&lt;text x="328" y="134" font-family="monospace" font-size="13" font-weight="bold" fill="#14532d" text-anchor="middle">Debezium&lt;/text>
&lt;text x="328" y="151" font-family="monospace" font-size="10" fill="#14532d" text-anchor="middle">Connector&lt;/text>
&lt;text x="328" y="168" font-family="monospace" font-size="10" fill="#166534" text-anchor="middle">(Kafka Connect)&lt;/text>
&lt;rect x="264" y="178" width="112" height="38" fill="#bbf7d0" stroke="#16a34a" stroke-width="1" rx="4"/>
&lt;text x="320" y="193" font-family="monospace" font-size="9" fill="#14532d" text-anchor="middle">op: c / u / d&lt;/text>
&lt;text x="320" y="207" font-family="monospace" font-size="9" fill="#14532d" text-anchor="middle">before + after + LSN&lt;/text>
&lt;line x1="408" y1="170" x2="464" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="436" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Kafka&lt;/text>
&lt;text x="436" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Producer&lt;/text>
&lt;rect x="464" y="110" width="150" height="120" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5" rx="8"/>
&lt;text x="539" y="134" font-family="monospace" font-size="13" font-weight="bold" fill="#713f12" text-anchor="middle">Kafka&lt;/text>
&lt;text x="539" y="151" font-family="monospace" font-size="10" fill="#713f12" text-anchor="middle">topic:&lt;/text>
&lt;text x="539" y="165" font-family="monospace" font-size="9" fill="#92400e" text-anchor="middle">rag.public.documents&lt;/text>
&lt;rect x="478" y="178" width="122" height="38" fill="#fef08a" stroke="#ca8a04" stroke-width="1" rx="4"/>
&lt;text x="539" y="193" font-family="monospace" font-size="9" fill="#713f12" text-anchor="middle">configurable retention&lt;/text>
&lt;text x="539" y="207" font-family="monospace" font-size="9" fill="#713f12" text-anchor="middle">at-least-once&lt;/text>
&lt;line x1="614" y1="170" x2="668" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="641" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Kafka&lt;/text>
&lt;text x="641" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Consumer&lt;/text>
&lt;rect x="668" y="110" width="132" height="120" fill="#fce7f3" stroke="#db2777" stroke-width="1.5" rx="8"/>
&lt;text x="734" y="134" font-family="monospace" font-size="12" font-weight="bold" fill="#831843" text-anchor="middle">Consumer&lt;/text>
&lt;rect x="682" y="146" width="104" height="36" fill="#fbcfe8" stroke="#db2777" stroke-width="1" rx="4"/>
&lt;text x="734" y="162" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">INSERT/UPDATE&lt;/text>
&lt;text x="734" y="175" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">→ upsert Qdrant&lt;/text>
&lt;rect x="682" y="190" width="104" height="28" fill="#fbcfe8" stroke="#db2777" stroke-width="1" rx="4"/>
&lt;text x="734" y="204" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">DELETE&lt;/text>
&lt;text x="734" y="215" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">→ delete Qdrant&lt;/text>
&lt;line x1="734" y1="230" x2="734" y2="288" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;rect x="668" y="288" width="132" height="40" fill="#ede9fe" stroke="#7c3aed" stroke-width="1.5" rx="8"/>
&lt;text x="734" y="313" font-family="monospace" font-size="12" font-weight="bold" fill="#4c1d95" text-anchor="middle">Qdrant&lt;/text>
&lt;rect x="20" y="295" width="620" height="28" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1" rx="4"/>
&lt;text x="330" y="313" font-family="monospace" font-size="10" fill="#475569" text-anchor="middle">Typical end-to-end latency: 30-100 ms from commit in Postgres to upsert/delete in Qdrant&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="8-minimum-configuration">8. Minimum configuration&lt;/h2>
&lt;h3 id="postgresql-enable-logical-replication">PostgreSQL: enable logical replication&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Requires restarting Postgres
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">wal_level&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">logical&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="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_replication_slots&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&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="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_wal_senders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- Reload configuration (wal_level requires a full restart)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_reload_conf&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- Dedicated user for Debezium
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">debezium&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICATION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LOGIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PASSWORD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;cambiar_esto&amp;#39;&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="k">GRANT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">debezium&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- REPLICA IDENTITY FULL to get a complete &amp;#39;before&amp;#39; on DELETEs and UPDATEs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IDENTITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FULL&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="debezium-connector-kafka-connect-rest-api">Debezium connector (Kafka Connect REST API)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-debezium&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="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.connector.postgresql.PostgresConnector&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="nt">&amp;#34;database.hostname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres&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="nt">&amp;#34;database.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;5432&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="nt">&amp;#34;database.user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium&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="nt">&amp;#34;database.password&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cambiar_esto&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="nt">&amp;#34;database.dbname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag_db&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="nt">&amp;#34;topic.prefix&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag&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="nt">&amp;#34;table.include.list&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public.documents&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="nt">&amp;#34;plugin.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;pgoutput&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="nt">&amp;#34;slot.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium_rag&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="nt">&amp;#34;publication.autocreate.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;filtered&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="nt">&amp;#34;snapshot.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;initial&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="nt">&amp;#34;tombstones.on.delete&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;true&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="nt">&amp;#34;transforms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;unwrap&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="nt">&amp;#34;transforms.unwrap.type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.transforms.ExtractNewRecordState&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="nt">&amp;#34;transforms.unwrap.drop.tombstones&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;false&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="nt">&amp;#34;transforms.unwrap.delete.handling.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rewrite&amp;#34;&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Register the connector:&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">curl -X POST http://kafka-connect:8083/connectors &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&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> -d @connector-config.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Check its status:&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">curl http://kafka-connect:8083/connectors/postgres-debezium/status
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="minimal-consumer-in-python">Minimal consumer in Python&lt;/h3>
&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">confluent_kafka&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Consumer&lt;/span>
&lt;/span>&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">MatchValue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&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">consumer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Consumer&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;bootstrap.servers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;kafka:9092&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="s2">&amp;#34;group.id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;qdrant-sync&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="s2">&amp;#34;auto.offset.reset&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;earliest&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="s2">&amp;#34;enable.auto.commit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&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">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">subscribe&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;rag.public.documents&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">qdrant&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;qdrant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&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="k">while&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">poll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">timeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">msg&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">event&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">op&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;op&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="k">if&lt;/span> &lt;span class="n">op&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;c&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;u&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span> &lt;span class="c1"># INSERT or UPDATE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;after&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="c1"># ... vectorise and upsert into Qdrant&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">elif&lt;/span> &lt;span class="n">op&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;d&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># DELETE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;id&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">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&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;documents&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">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;doc_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">doc_id&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">commit&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="9-on-premise-deployment">9. On-premise deployment&lt;/h2>
&lt;p>The Debezium stack does not compete for GPU. On a node with &lt;strong>4×H100 SXM (320 GB, NVLink)&lt;/strong> serving the inference LLM, the CDC pipeline runs entirely on general-purpose (CPU-only) nodes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Recommended resources&lt;/th>
&lt;th>Role&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kafka Connect + Debezium&lt;/td>
&lt;td>2-4 cores, 4-8 GB RAM&lt;/td>
&lt;td>Read the WAL, emit events&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kafka brokers (×3)&lt;/td>
&lt;td>4 cores, 32 GB RAM each&lt;/td>
&lt;td>High availability, retention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qdrant-sync consumer&lt;/td>
&lt;td>2 cores, 4 GB RAM&lt;/td>
&lt;td>Vectorise plus upsert/delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qdrant&lt;/td>
&lt;td>8 cores, 64 GB RAM&lt;/td>
&lt;td>Vector store&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Debezium connector is notably light: in production at 10,000 events/second, the connector typically uses less than 1 core and 2 GB of RAM. JVM memory (Kafka Connect runs on the JVM) must be capped explicitly with &lt;code>-Xmx4g&lt;/code> to stop the GC from causing pauses.&lt;/p>
&lt;p>For high availability, Kafka Connect supports &lt;strong>distributed&lt;/strong> mode with multiple workers. If a worker dies, the connector is reassigned automatically to another worker within seconds, and the replication slot guarantees no events are lost during the switchover.&lt;/p>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Debezium with MySQL, MongoDB and Oracle&lt;/strong>: each connector uses the native log mechanism (binlog on MySQL, oplog on MongoDB, LogMiner on Oracle). The resulting event API is similar, but the configuration details and limitations differ.&lt;/li>
&lt;li>&lt;strong>Debezium Server&lt;/strong>: standalone mode without Kafka Connect, with direct sinks to HTTP, S3, Redis Streams or NATS. Useful when the Kafka infrastructure is too complex for the use case.&lt;/li>
&lt;li>&lt;strong>Schema Registry&lt;/strong>: how Avro with Confluent Schema Registry or Apicurio manages event schema evolution (adding columns, changing types) without breaking existing consumers.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: why at-least-once is enough for most RAG cases (an idempotent upsert into Qdrant with the same vector does no harm) and when exactly-once is needed (financial counters, inventory deductions).&lt;/li>
&lt;li>&lt;strong>Outbox pattern plus Debezium combined&lt;/strong>: Debezium reading the &lt;code>outbox&lt;/code> table instead of the business table&amp;rsquo;s WAL directly, the Transactional Outbox + CDC pattern that combines the best of both worlds.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant: ingestion through microservices&lt;/a> — the post where CDC with Debezium is used as an alternative to the outbox pattern to keep PostgreSQL and Qdrant in sync.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: fundamentals&lt;/a> — the corpus curation that Debezium keeps fresh in near real time.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline: the six stages&lt;/a> — the Data stage of the master map, where CDC is the continuous ingestion mechanism.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — versioning the corpus that Debezium feeds incrementally.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM and LLM&lt;/a> — monitoring the cluster where the Debezium consumer runs alongside the inference stack.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Debezium Documentation — PostgreSQL Connector. &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html">debezium.io/documentation/reference/stable/connectors/postgresql.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Logical Replication. &lt;a href="https://www.postgresql.org/docs/current/logical-replication.html">postgresql.org/docs/current/logical-replication.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Write-Ahead Logging. &lt;a href="https://www.postgresql.org/docs/current/wal-intro.html">postgresql.org/docs/current/wal-intro.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Replication Slots. &lt;a href="https://www.postgresql.org/docs/current/logicaldecoding-explanation.html">postgresql.org/docs/current/logicaldecoding-explanation.html&lt;/a>&lt;/li>
&lt;li>Confluent — Kafka Performance Benchmarks (2023). &lt;a href="https://www.confluent.io/blog/kafka-fastest-messaging-system/">confluent.io/blog/kafka-fastest-messaging-system&lt;/a>&lt;/li>
&lt;li>Gunnar Morling — Outbox Pattern. &lt;a href="https://www.morling.dev/blog/sending-messages-as-part-of-database-transactions/">morling.dev/blog/sending-messages-as-part-of-database-transactions&lt;/a>&lt;/li>
&lt;li>Debezium — SMT documentation. &lt;a href="https://debezium.io/documentation/reference/stable/transformations/">debezium.io/documentation/reference/stable/transformations&lt;/a>&lt;/li>
&lt;li>Qdrant Documentation — Filtering. &lt;a href="https://qdrant.tech/documentation/concepts/filtering/">qdrant.tech/documentation/concepts/filtering&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Function calling and tool-augmented retrieval: the detective who knows which file to ask for</title><link>https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/</link><pubDate>Thu, 04 Jun 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/</guid><description>&lt;blockquote>
&lt;p>This post goes deeper into the agentic reasoning mechanism that extends the RAG described in &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a>. The retriever invoked when the LLM chooses &lt;code>vector_search&lt;/code> is exactly the pipeline in that article. The JSON Schema that defines each tool call is &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a> applied to the tool interface. And the agent&amp;rsquo;s requests pass through the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 inference gateway&lt;/a> before reaching the model.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A naive RAG always queries the same source. Function calling gives the LLM the ability to decide which tool to invoke (vector store, SQL, web search) according to what the query actually needs. The ReAct pattern chains those invocations in a reasoned loop until there is enough evidence. A 3-iteration pipeline with Llama-3.1-70B on on-premise hardware takes ≈ 1.1 s against the ≈ 300 ms of a single-step RAG; the gain is not in speed but in queries that a naive RAG simply cannot answer. The critical eval metric is &lt;strong>tool selection accuracy&lt;/strong>: the percentage of turns in which the model picks the right tool, measured over a synthetic eval set.&lt;/p>
&lt;h2 id="the-analogy-the-detective-who-knows-which-file-to-ask-for">The analogy: the detective who knows which file to ask for&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The detective and his sources of evidence">
&lt;style>
.db{fill:#f8f8f8;stroke:#444;stroke-width:1.4}
.dh{fill:#7aafff;stroke:#444;stroke-width:1.4}
.ds{fill:#ffd76b;stroke:#444;stroke-width:1.4}
.dg{fill:#b2e8b2;stroke:#444;stroke-width:1.4}
.dr{fill:#ffb3b3;stroke:#444;stroke-width:1.4}
.dl{font:600 13px sans-serif;fill:#222}
.dm{font:400 11px sans-serif;fill:#555}
.da{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mda)}
.dq{stroke:#666;stroke-width:1.5;fill:none;stroke-dasharray:5 3;marker-end:url(#mda)}
&lt;/style>
&lt;defs>&lt;marker id="mda" 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;!-- Detective (LLM) in the centre -->
&lt;rect x="290" y="130" width="200" height="80" rx="8" class="dh"/>
&lt;text x="390" y="158" text-anchor="middle" class="dl">Detective (LLM)&lt;/text>
&lt;text x="390" y="176" text-anchor="middle" class="dm">reasons what evidence&lt;/text>
&lt;text x="390" y="192" text-anchor="middle" class="dm">it needs and asks for it&lt;/text>
&lt;!-- Case / Query -->
&lt;rect x="310" y="20" width="160" height="50" rx="8" class="db"/>
&lt;text x="390" y="42" text-anchor="middle" class="dl">Case (Query)&lt;/text>
&lt;text x="390" y="60" text-anchor="middle" class="dm">"how many EU contracts &amp;gt; 100k€?"&lt;/text>
&lt;path class="da" d="M390,70 L390,128"/>
&lt;!-- Vector store -->
&lt;rect x="20" y="240" width="160" height="70" rx="8" class="ds"/>
&lt;text x="100" y="263" text-anchor="middle" class="dl">Witness files&lt;/text>
&lt;text x="100" y="281" text-anchor="middle" class="dm">vector_search&lt;/text>
&lt;text x="100" y="297" text-anchor="middle" class="dm">Qdrant · 5-50 ms&lt;/text>
&lt;!-- SQL -->
&lt;rect x="310" y="240" width="160" height="70" rx="8" class="dg"/>
&lt;text x="390" y="263" text-anchor="middle" class="dl">Accounting ledger&lt;/text>
&lt;text x="390" y="281" text-anchor="middle" class="dm">sql_query&lt;/text>
&lt;text x="390" y="297" text-anchor="middle" class="dm">PostgreSQL · 10-200 ms&lt;/text>
&lt;!-- Web search -->
&lt;rect x="600" y="240" width="160" height="70" rx="8" class="dr"/>
&lt;text x="680" y="263" text-anchor="middle" class="dl">Press archive&lt;/text>
&lt;text x="680" y="281" text-anchor="middle" class="dm">web_search&lt;/text>
&lt;text x="680" y="297" text-anchor="middle" class="dm">public · 200-2000 ms&lt;/text>
&lt;!-- Arrows from the detective to the sources -->
&lt;path class="da" d="M310,190 L180,238"/>
&lt;path class="da" d="M390,210 L390,238"/>
&lt;path class="da" d="M470,190 L600,238"/>
&lt;!-- Return arrows (observations) -->
&lt;path class="dq" d="M140,240 Q200,220 300,195"/>
&lt;path class="dq" d="M390,240 L390,212"/>
&lt;path class="dq" d="M640,240 Q560,220 480,195"/>
&lt;!-- Final answer -->
&lt;rect x="310" y="20" width="160" height="50" rx="8" class="db"/>
&lt;text x="390" y="42" text-anchor="middle" class="dl">Case (Query)&lt;/text>
&lt;text x="390" y="60" text-anchor="middle" class="dm">"how many EU contracts &amp;gt; 100k€?"&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A detective in a novel does not go to the same filing cabinet regardless of the case that lands on his desk. When he takes a case, he reasons first: what kind of evidence do I need? If there are witnesses, he asks for the statements (vector search over unstructured documents). If there are financial transactions, he asks the bank for the accounting records (SQL over the structured database). If the suspect has recent activity that the company cannot possibly have indexed, he goes to the press archive (web search). He does not consult all three sources at once on every case: he picks the one the evidence requires, receives the result, reasons again if he needs more, and only writes the report when he has enough evidence.&lt;/p>
&lt;p>A bad detective always goes to the same filing cabinet. A naive RAG is that bad detective: it vectorises the query, goes to the vector store, and returns whatever it finds even if the question was &amp;ldquo;how many contracts?&amp;rdquo;, something no PDF chunk can answer better than a &lt;code>COUNT(*)&lt;/code> in SQL.&lt;/p>
&lt;p>&lt;strong>Function calling is giving the LLM the ability to reason about which source to ask for, and to invoke it in a structured way.&lt;/strong> The analogy has three edges worth keeping in mind:&lt;/p>
&lt;ol>
&lt;li>The detective does not improvise the file he asks for: there is a catalogue of available sources with a description of what each one contains. The tool description in the system prompt does that job.&lt;/li>
&lt;li>The detective can ask for several pieces of evidence at once if they are independent (parallel tool calling).&lt;/li>
&lt;li>The detective knows when to stop: if after N rounds he reaches no conclusion, he declares that he does not have enough evidence. The agent has an iteration limit for the same reason.&lt;/li>
&lt;/ol>
&lt;h2 id="what-function-calling-is-the-anatomy-of-a-tool-call">What function calling is: the anatomy of a tool call&lt;/h2>
&lt;p>Function calling, also called tool use, is a mechanism by which the LLM, instead of generating free text as its answer, generates a structured JSON object representing a tool invocation. The system intercepts that JSON, executes the real tool, and returns the result as a message with the &lt;code>tool&lt;/code> role in the conversation.&lt;/p>
&lt;h3 id="defining-tools-in-the-system-prompt">Defining tools in the system prompt&lt;/h3>
&lt;p>Each tool is defined through a JSON Schema that specifies name, description and parameters. This JSON Schema is exactly the same mechanism described in &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, applied here to the tool interface:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">:&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="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vector_search&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="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Search internal company documents about policies, contracts and procedures. Use when the query requires unstructured text, document context or semantic similarity.&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="nt">&amp;#34;parameters&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Natural language search 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="nt">&amp;#34;top_k&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;integer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5&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="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&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>&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="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="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_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="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Query the SQL database for structured metrics, counts, aggregations and financial data. Use when the query requires exact numbers, filters, sums or joins over structured records.&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="nt">&amp;#34;parameters&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Parameterized SQL query with $1, $2 placeholders&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="nt">&amp;#34;params&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;array&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;items&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{},&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Parameter values for the placeholders&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="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&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>&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="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="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;web_search&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="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Search public web for real-time information, recent news or current prices. Use only when data is public and not covered by internal sources.&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="nt">&amp;#34;parameters&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&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="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&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="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&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>&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="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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-life-cycle-of-a-tool-call">The life cycle of a tool call&lt;/h3>
&lt;p>When the LLM decides to invoke a tool, the message it generates in place of free text has this structure (OpenAI-compatible format, the same one vLLM supports):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;assistant&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="nt">&amp;#34;tool_calls&amp;#34;&lt;/span>&lt;span class="p">:&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="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&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="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;function&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="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_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="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;query\&amp;#34;: \&amp;#34;SELECT COUNT(*), SUM(amount) FROM contracts WHERE amount &amp;gt; $1 AND year = $2 AND provider_region = $3\&amp;#34;, \&amp;#34;params\&amp;#34;: [100000, 2025, \&amp;#34;EU\&amp;#34;]}&amp;#34;&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="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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The system executes the tool and returns:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_call_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;count\&amp;#34;: 47, \&amp;#34;total\&amp;#34;: 8300000}&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The LLM receives that message as a continuation of the conversation and decides whether it needs more information or can generate the final answer.&lt;/p>
&lt;h3 id="support-in-oss-models">Support in OSS models&lt;/h3>
&lt;p>In 2026, native function calling support (not emulated via system prompt) is available in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 / 3.3+&lt;/strong>: native tool call format, supported in vLLM with &lt;code>--enable-auto-tool-choice --tool-call-parser llama3_json&lt;/code>&lt;/li>
&lt;li>&lt;strong>Qwen 2.5+&lt;/strong>: native support with &lt;code>--tool-call-parser hermes&lt;/code>&lt;/li>
&lt;li>&lt;strong>Mistral NeMo / Mistral 7B Instruct v0.3+&lt;/strong>: native format with &lt;code>--tool-call-parser mistral&lt;/code>&lt;/li>
&lt;li>&lt;strong>Qwen3 (2025+)&lt;/strong>: extended native support with parallel tool calling&lt;/li>
&lt;/ul>
&lt;p>Without &lt;code>--enable-auto-tool-choice&lt;/code>, vLLM can force the tool call format via guided decoding (JSON Schema), but the ability to &lt;em>decide when to invoke&lt;/em> a tool versus generating free text requires specific model training.&lt;/p>
&lt;h2 id="the-react-pattern-reasoning--acting">The ReAct pattern: Reasoning + Acting&lt;/h2>
&lt;p>ReAct (Yao et al., 2022) is the loop that turns an LLM with tools into an agent with reasoning. The central idea is that the model interleaves &lt;strong>thoughts&lt;/strong> (explicit reasoning about what to do) with &lt;strong>actions&lt;/strong> (tool invocations) and &lt;strong>observations&lt;/strong> (results received). This loop repeats until the model reckons it has enough information to answer, or until the iteration limit is reached.&lt;/p>
&lt;pre tabindex="0">&lt;code>Thought → Action → Observation → Thought → Action → Observation → ... → Response
&lt;/code>&lt;/pre>&lt;h3 id="a-full-react-trace-a-real-production-query">A full ReAct trace: a real production query&lt;/h3>
&lt;p>Query: &lt;em>&amp;ldquo;How many contracts above 100k€ did we sign in 2025 with EU providers, and which special conditions recur?&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>This query requires two different sources: the exact numbers live in SQL, and the context of special conditions lives in the contract PDFs indexed in the vector store.&lt;/p>
&lt;hr>
&lt;p>&lt;strong>Iteration 1&lt;/strong>&lt;/p>
&lt;p>&lt;em>Thought:&lt;/em> The query asks for aggregated data (count and sum) with exact filters over structured fields (amount, year, region). This is a SQL query, not a semantic search.&lt;/p>
&lt;p>&lt;em>Action:&lt;/em> &lt;code>sql_query&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">num_contratos&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SUM&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total_eur&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">contracts&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">year&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">provider_region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>params: &lt;code>[100000, 2025, &amp;quot;EU&amp;quot;]&lt;/code>&lt;/p>
&lt;p>&lt;em>Observation:&lt;/em> &lt;code>{&amp;quot;num_contratos&amp;quot;: 47, &amp;quot;total_eur&amp;quot;: 8300000}&lt;/code>&lt;/p>
&lt;hr>
&lt;p>&lt;strong>Iteration 2&lt;/strong>&lt;/p>
&lt;p>&lt;em>Thought:&lt;/em> I have the numbers. Now I need the special conditions that recur in these contracts. That kind of information is in the free text of the PDFs, not in the structured table. I go to the vector store.&lt;/p>
&lt;p>&lt;em>Action:&lt;/em> &lt;code>vector_search&lt;/code>&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;EU provider contracts 2025 amount above 100k special conditions recurring clauses&amp;#34;
&lt;/code>&lt;/pre>&lt;p>top_k: 5&lt;/p>
&lt;p>&lt;em>Observation:&lt;/em>&lt;/p>
&lt;pre tabindex="0">&lt;code>[chunk_1] Provider A contract — clause 8.3: late-delivery penalty of 2% per month on the invoiced amount...
[chunk_2] Provider C contract — clause 7.1: price revision linked to the euro area CPI capped at 4% per year...
[chunk_3] Provider B contract — clause 8.3: late-delivery penalty of 2% per month...
&lt;/code>&lt;/pre>&lt;hr>
&lt;p>&lt;em>Thought:&lt;/em> I have enough data from both sources. Clause 8.3 on late-delivery penalties appears in at least two of the retrieved contracts. I can synthesise the answer.&lt;/p>
&lt;p>&lt;em>Response:&lt;/em> &amp;ldquo;In 2025 you signed &lt;strong>47 contracts&lt;/strong> with EU providers above 100,000 €, for a cumulative total of &lt;strong>8.3 M€&lt;/strong>. The special condition that appears most often in the higher-value contracts is the late-delivery penalty clause (8.3 in the contract templates of Provider A and B): 2% per month on the invoiced amount. The price revision linked to the euro area CPI, capped at 4% per year (clause 7.1), also recurs.&amp;rdquo;&lt;/p>
&lt;hr>
&lt;p>This trace has &lt;strong>2 iterations&lt;/strong>. A more complex query (comparing with the previous year, cross-referencing with payment status) would add further iterations. Common practice is to set a limit of &lt;strong>5–10 iterations&lt;/strong> maximum to avoid loops.&lt;/p>
&lt;h2 id="the-three-sources-and-when-to-use-each-one">The three sources and when to use each one&lt;/h2>
&lt;p>The choice of source is neither arbitrary nor left solely to the LLM: the architecture defines which tools exist and how they are described. The table below summarises the selection criteria:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Characteristic&lt;/th>
&lt;th>Vector store (Qdrant)&lt;/th>
&lt;th>SQL / structured (PostgreSQL)&lt;/th>
&lt;th>Web search&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Data type&lt;/strong>&lt;/td>
&lt;td>Free text, documents, PDFs&lt;/td>
&lt;td>Tables with a fixed schema&lt;/td>
&lt;td>Public pages, news&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structure&lt;/strong>&lt;/td>
&lt;td>Unstructured&lt;/td>
&lt;td>Highly structured&lt;/td>
&lt;td>Semi-structured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Temporality&lt;/strong>&lt;/td>
&lt;td>Static index (periodic refresh)&lt;/td>
&lt;td>Real time (transactional)&lt;/td>
&lt;td>Real time (crawl)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical latency&lt;/strong>&lt;/td>
&lt;td>5–50 ms&lt;/td>
&lt;td>10–200 ms&lt;/td>
&lt;td>200–2,000 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Privacy&lt;/strong>&lt;/td>
&lt;td>Internal data, full sovereignty&lt;/td>
&lt;td>Internal data, full sovereignty&lt;/td>
&lt;td>Public data only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Natural query&lt;/strong>&lt;/td>
&lt;td>Yes (natural language → embedding)&lt;/td>
&lt;td>No (parameterised SQL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Exact aggregations&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (&lt;code>COUNT&lt;/code>, &lt;code>SUM&lt;/code>, &lt;code>GROUP BY&lt;/code>)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to use&lt;/strong>&lt;/td>
&lt;td>Document context, semantics, PDFs&lt;/td>
&lt;td>Metrics, counts, exact filters, joins&lt;/td>
&lt;td>Data that does not exist internally and is public&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most important rule of thumb: if the question contains words like &amp;ldquo;how many&amp;rdquo;, &amp;ldquo;sum&amp;rdquo;, &amp;ldquo;total&amp;rdquo;, &amp;ldquo;more than X&amp;rdquo;, &amp;ldquo;in year Y&amp;rdquo; and the data is in a structured table, the right answer is &lt;code>sql_query&lt;/code>. If the question asks for context, explanations, clauses, procedures or examples from documents, the answer is &lt;code>vector_search&lt;/code>. If it asks for the current price of something or recent news about a third party, &lt;code>web_search&lt;/code>, but only if no data sovereignty is involved.&lt;/p>
&lt;h2 id="tool-routing-how-the-llm-picks-the-right-tool">Tool routing: how the LLM picks the right tool&lt;/h2>
&lt;p>The description of each tool in the system prompt is &lt;strong>the most critical factor&lt;/strong> for routing accuracy. An LLM with good function calling capabilities can still choose badly if the descriptions are ambiguous or overlap.&lt;/p>
&lt;h3 id="descriptions-that-work-versus-those-that-do-not">Descriptions that work versus those that do not&lt;/h3>
&lt;p>&lt;strong>Weak description&lt;/strong> (leads the LLM to use the wrong tool):&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;search_docs&amp;#34; — Searches for information in the available sources.
&amp;#34;query_data&amp;#34; — Gets data from the system.
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Strong description&lt;/strong> (delimits precisely when to use each one):&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;vector_search&amp;#34; — Search internal company documents about policies, contracts and procedures.
Use when the query requires unstructured text, document context or semantic
similarity. NOT for counts, sums or exact filters.
&amp;#34;sql_query&amp;#34; — Query the SQL database for structured metrics, counts, aggregations and
financial data. Use when the query requires exact numbers, filters, sums or
joins over structured records. NOT for finding document context.
&lt;/code>&lt;/pre>&lt;p>The difference lies in two elements: (1) positive use-case examples, and (2) explicit exclusions with &lt;code>NOT for&lt;/code>. Both reduce the semantic overlap between tools and improve tool selection accuracy.&lt;/p>
&lt;h3 id="parallel-tool-calling">Parallel tool calling&lt;/h3>
&lt;p>When two tools are independent of each other, that is, when the result of one does not affect the query of the other, the LLM can invoke them simultaneously in the same turn:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;tool_calls&amp;#34;&lt;/span>&lt;span class="p">:&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="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&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="p">{&lt;/span>&lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_02&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vector_search&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&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="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;/code>&lt;/pre>&lt;/div>&lt;p>The system executes both in parallel and returns both observations before the LLM&amp;rsquo;s next turn. This cuts total latency when the queries are independent: instead of 2 sequential iterations (2 × tool_latency), you pay 1 × max(sql_latency, vector_latency). For the detective example: if he needs both the accounting records and the witness statements to answer, he can ask for them at the same time.&lt;/p>
&lt;h3 id="tool-selection-accuracy-the-eval-metric">Tool selection accuracy: the eval metric&lt;/h3>
&lt;p>&lt;strong>Tool selection accuracy&lt;/strong> is the percentage of turns in which the LLM picks the right tool given a set of evaluated queries:&lt;/p>
\[
\text{TSA} = \frac{\text{turns with correct tool chosen}}{\text{total turns with expected tool call}}
\]
&lt;p>It is measured over a synthetic eval set built with &lt;code>(query, expected_tool, expected_args)&lt;/code> triples. An example of a minimal eval set:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Query&lt;/th>
&lt;th>Expected tool&lt;/th>
&lt;th>Failure indicator&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;How many orders in March?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>sql_query&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What does the returns policy say?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>vector_search&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>sql_query&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What is the price of copper today?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>web_search&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Sum the Q3 contracts&amp;rdquo;&lt;/td>
&lt;td>&lt;code>sql_query&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A TSA &amp;lt; 0.85 in a production agent is a sign that the tool descriptions need revising before the model does. For more detail on how to build these evals, see &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals&lt;/a>.&lt;/p>
&lt;h2 id="error-handling-in-tool-calls">Error handling in tool calls&lt;/h2>
&lt;h3 id="sql-injection-via-prompt">SQL injection via prompt&lt;/h3>
&lt;p>The most serious risk in tool-augmented retrieval is that the LLM generates malicious SQL, either because a user induced it via prompt injection, or because the model hallucinated a destructive query. This attack vector is covered in detail in &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>, but the minimum rules on the tool side are:&lt;/p>
&lt;p>&lt;strong>Rule 1: parameterised queries always, never direct interpolation.&lt;/strong>&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="c1"># NEVER this:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cursor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM contracts WHERE provider = &amp;#39;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">llm_output&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#39;&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"># Always this:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cursor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM contracts WHERE provider = $1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">llm_output&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Rule 2: DB user with minimum permissions.&lt;/strong> The user the agent runs SQL as must have &lt;code>SELECT&lt;/code> on the necessary tables and nothing else. No &lt;code>DROP&lt;/code>, &lt;code>INSERT&lt;/code>, &lt;code>UPDATE&lt;/code> or &lt;code>DELETE&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Rule 3: table allowlist.&lt;/strong> The system validates that the query generated by the LLM only references tables on an allowlist before executing it.&lt;/p>
&lt;p>&lt;strong>Rule 4: per-query timeout.&lt;/strong> Queries that block for too long, potentially induced to DoS the database, are cancelled with a configured timeout.&lt;/p>
&lt;h3 id="rate-limits-timeouts-and-tool-errors">Rate limits, timeouts and tool errors&lt;/h3>
&lt;p>When a tool fails, the error is returned to the LLM as an observation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_call_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;error\&amp;#34;: \&amp;#34;timeout after 5s\&amp;#34;, \&amp;#34;tool\&amp;#34;: \&amp;#34;web_search\&amp;#34;}&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The system prompt must instruct the LLM on what to do in this case:&lt;/p>
&lt;pre tabindex="0">&lt;code>If a tool returns an error or is unavailable, acknowledge the limitation in your response.
Do not retry more than once. If web_search is unavailable, state that real-time data
is not accessible at this moment and answer with available internal sources only.
&lt;/code>&lt;/pre>&lt;p>This stops the agent from entering retry loops and handles graceful degradation: if &lt;code>web_search&lt;/code> is unavailable, it answers with what it has in the internal sources.&lt;/p>
&lt;h2 id="diagram-of-the-react-loop-with-the-three-sources">Diagram of the ReAct loop with the three sources&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ReAct loop with three data sources">
&lt;style>
.rb{fill:#f8f8f8;stroke:#444;stroke-width:1.4}
.rh{fill:#7aafff;stroke:#444;stroke-width:1.4}
.ry{fill:#ffd76b;stroke:#444;stroke-width:1.4}
.rg{fill:#b2e8b2;stroke:#444;stroke-width:1.4}
.rr{fill:#ffb3b3;stroke:#444;stroke-width:1.4}
.rp{fill:#e0c8ff;stroke:#444;stroke-width:1.4}
.rl{font:600 13px sans-serif;fill:#222}
.rs{font:400 11px sans-serif;fill:#555}
.ri{font:italic 11px sans-serif;fill:#555}
.ra{stroke:#555;stroke-width:1.5;fill:none;marker-end:url(#mra)}
.rloop{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#mra)}
&lt;/style>
&lt;defs>&lt;marker id="mra" 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="#555"/>&lt;/marker>&lt;/defs>
&lt;!-- Query input -->
&lt;rect x="310" y="10" width="200" height="50" rx="8" class="rb"/>
&lt;text x="410" y="32" text-anchor="middle" class="rl">User query&lt;/text>
&lt;text x="410" y="50" text-anchor="middle" class="rs">message with user role&lt;/text>
&lt;!-- LLM: Thought -->
&lt;rect x="290" y="90" width="240" height="60" rx="8" class="rh"/>
&lt;text x="410" y="114" text-anchor="middle" class="rl">LLM — Thought&lt;/text>
&lt;text x="410" y="132" text-anchor="middle" class="rs">reasons what it needs&lt;/text>
&lt;text x="410" y="146" text-anchor="middle" class="rs">and which tool to invoke&lt;/text>
&lt;!-- Action -->
&lt;rect x="290" y="185" width="240" height="50" rx="8" class="ry"/>
&lt;text x="410" y="207" text-anchor="middle" class="rl">Action&lt;/text>
&lt;text x="410" y="223" text-anchor="middle" class="rs">generates tool_call JSON&lt;/text>
&lt;!-- Tool router -->
&lt;rect x="290" y="265" width="240" height="50" rx="8" class="rp"/>
&lt;text x="410" y="287" text-anchor="middle" class="rl">Tool router (system)&lt;/text>
&lt;text x="410" y="303" text-anchor="middle" class="rs">dispatches the call&lt;/text>
&lt;!-- Three sources -->
&lt;rect x="30" y="355" width="160" height="70" rx="8" class="ry"/>
&lt;text x="110" y="378" text-anchor="middle" class="rl">vector_search&lt;/text>
&lt;text x="110" y="396" text-anchor="middle" class="rs">Qdrant&lt;/text>
&lt;text x="110" y="412" text-anchor="middle" class="rs">5–50 ms&lt;/text>
&lt;rect x="330" y="355" width="160" height="70" rx="8" class="rg"/>
&lt;text x="410" y="378" text-anchor="middle" class="rl">sql_query&lt;/text>
&lt;text x="410" y="396" text-anchor="middle" class="rs">PostgreSQL&lt;/text>
&lt;text x="410" y="412" text-anchor="middle" class="rs">10–200 ms&lt;/text>
&lt;rect x="630" y="355" width="160" height="70" rx="8" class="rr"/>
&lt;text x="710" y="378" text-anchor="middle" class="rl">web_search&lt;/text>
&lt;text x="710" y="396" text-anchor="middle" class="rs">external API&lt;/text>
&lt;text x="710" y="412" text-anchor="middle" class="rs">200–2,000 ms&lt;/text>
&lt;!-- Observation -->
&lt;rect x="290" y="355" width="0" height="0" rx="8"/>
&lt;!-- Main arrows -->
&lt;path class="ra" d="M410,60 L410,88"/>
&lt;path class="ra" d="M410,150 L410,183"/>
&lt;path class="ra" d="M410,235 L410,263"/>
&lt;path class="ra" d="M370,315 L190,353"/>
&lt;path class="ra" d="M410,315 L410,353"/>
&lt;path class="ra" d="M450,315 L630,353"/>
&lt;!-- Observation arrows back to the LLM (dashed lines) -->
&lt;path class="rloop" d="M110,355 Q60,260 280,145"/>
&lt;path class="rloop" d="M410,355 Q510,310 530,150"/>
&lt;path class="rloop" d="M710,355 Q780,260 540,145"/>
&lt;!-- Observation labels -->
&lt;text x="60" y="255" class="ri">Observation&lt;/text>
&lt;text x="548" y="255" class="ri">Observation&lt;/text>
&lt;text x="726" y="255" class="ri">Observation&lt;/text>
&lt;!-- Final answer -->
&lt;rect x="600" y="90" width="190" height="60" rx="8" class="rg"/>
&lt;text x="695" y="114" text-anchor="middle" class="rl">Response&lt;/text>
&lt;text x="695" y="132" text-anchor="middle" class="rs">when the LLM has&lt;/text>
&lt;text x="695" y="148" text-anchor="middle" class="rs">enough evidence&lt;/text>
&lt;!-- Arrow to the answer -->
&lt;path class="ra" d="M530,120 L598,120"/>
&lt;!-- Iteration limit -->
&lt;rect x="0" y="185" width="240" height="50" rx="8" class="rb"/>
&lt;text x="120" y="207" text-anchor="middle" class="rl">Iteration limit&lt;/text>
&lt;text x="120" y="223" text-anchor="middle" class="rs">max. 5–10 turns&lt;/text>
&lt;path class="rloop" d="M290,210 L242,210"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="latency-maths-of-the-react-pipeline">Latency maths of the ReAct pipeline&lt;/h2>
&lt;p>Each iteration of the ReAct loop has three latency components:&lt;/p>
\[
T_{\text{iter}} = \text{TTFT}_{\text{LLM}} + T_{\text{tool}} + \Delta_{\text{context}}
\]
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TTFT_LLM&lt;/strong>: time to the LLM&amp;rsquo;s first token (dominated by the prefill of the accumulated context)&lt;/li>
&lt;li>&lt;strong>T_tool&lt;/strong>: tool execution time&lt;/li>
&lt;li>&lt;strong>Δ_context&lt;/strong>: overhead of the growing context window (each iteration adds the previous output to the context)&lt;/li>
&lt;/ul>
&lt;h3 id="reference-values-llama-31-70b-on-4h100-sxm-320-gb-nvlink">Reference values: Llama-3.1-70B on 4×H100 SXM (320 GB, NVLink)&lt;/h3>
&lt;p>With Llama-3.1-70B in FP8 on a node with 4×H100 SXM (320 GB HBM3, NVLink 900 GB/s), the typical values in production are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT with context &amp;lt; 4k tokens&lt;/td>
&lt;td>≈ 150 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT with context 8k tokens&lt;/td>
&lt;td>≈ 220 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>sql_query&lt;/code> (simple query, indexed)&lt;/td>
&lt;td>≈ 50 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vector_search&lt;/code> (top-5, Qdrant in RAM)&lt;/td>
&lt;td>≈ 20 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>web_search&lt;/code> (external API)&lt;/td>
&lt;td>≈ 600 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="total-latency-by-number-of-iterations">Total latency by number of iterations&lt;/h3>
&lt;p>&lt;strong>1-iteration pipeline&lt;/strong> (simple query, a single tool):&lt;/p>
\[
T_1 = 150 + 50 = 200 \text{ ms} + \text{final synthesis} \approx 200 + 300 = 500 \text{ ms}
\]
&lt;p>&lt;strong>2-iteration pipeline&lt;/strong> (SQL + vector_search in sequence):&lt;/p>
\[
T_2 = (150 + 50) + (180 + 20) + 400 = 800 \text{ ms}
\]
&lt;p>The context in the second iteration already includes the result of the first, so TTFT rises slightly to ≈ 180 ms.&lt;/p>
&lt;p>&lt;strong>3-iteration pipeline&lt;/strong> (the most common case in complex queries):&lt;/p>
\[
T_3 = (150 + 50) + (180 + 20) + (200 + 50) + 450 \approx 1,100 \text{ ms}
\]
&lt;p>&lt;strong>Parallel tool calling&lt;/strong> (SQL + vector_search in parallel, a single iteration):&lt;/p>
\[
T_{\text{parallel}} = 150 + \max(50, 20) + 400 = 600 \text{ ms}
\]
&lt;p>When the two queries are independent, parallel tool calling trims latency from ≈ 800 ms to ≈ 600 ms: a 25% improvement over the 2 sequential iterations case.&lt;/p>
&lt;h3 id="comparison-with-a-naive-rag">Comparison with a naive RAG&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Latency&lt;/th>
&lt;th>Queries it can answer&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Naive RAG (1 retriever, 1 step)&lt;/td>
&lt;td>≈ 300 ms&lt;/td>
&lt;td>Document context queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 1 iteration (SQL)&lt;/td>
&lt;td>≈ 500 ms&lt;/td>
&lt;td>Structured aggregation queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 2 iterations (SQL + vector)&lt;/td>
&lt;td>≈ 800 ms&lt;/td>
&lt;td>Hybrid numeric + context queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 3 iterations&lt;/td>
&lt;td>≈ 1,100 ms&lt;/td>
&lt;td>Complex multi-source queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct with web_search&lt;/td>
&lt;td>≈ 1,500 ms&lt;/td>
&lt;td>Queries requiring real-time data&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical conclusion: multi-hop ReAct is &lt;strong>3–5× slower than a single-step naive RAG&lt;/strong>. The gain is not in speed but in &lt;strong>query coverage&lt;/strong>: a naive RAG cannot answer &amp;ldquo;how many contracts?&amp;rdquo; because that answer is in no text chunk at all. For applications with a strict latency SLO (&amp;lt; 500 ms), you have to work out whether the use case really needs ReAct or whether a well-configured RAG with hybrid retrieval covers 90% of the queries.&lt;/p>
&lt;h2 id="on-premise-hardware-for-react-agents">On-premise hardware for ReAct agents&lt;/h2>
&lt;p>A ReAct agent with Llama-3.1-70B in production has different requirements from a naive RAG, because the context grows with each iteration and prefill throughput is more critical.&lt;/p>
&lt;p>&lt;strong>Recommended configuration: 4×H100 SXM (320 GB HBM3, NVLink 900 GB/s)&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Llama-3.1-70B in FP8: fits in 2×H100 (70B params × 1 byte/param ≈ 70 GB + KV cache). With 4×H100 you can serve it with tensor parallelism TP=4, cutting prefill TTFT by ≈ 2×.&lt;/li>
&lt;li>Qdrant instance: it can sit on the same node (if the collection fits in RAM) or on a dedicated node. For collections &amp;lt; 50M vectors of 768 dims: ≈ 150 GB, which fits in the RAM of a dual-socket server.&lt;/li>
&lt;li>PostgreSQL: separate node or managed instance. The agent adds no unusual load to SQL; the queries are simple and bounded by timeout.&lt;/li>
&lt;li>vLLM with &lt;code>--enable-auto-tool-choice --tool-call-parser llama3_json --max-model-len 16384&lt;/code>: a 16k token context comfortably covers the 5–10 turns of a ReAct pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Minimum viable configuration: 2×H100 SXM (160 GB)&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Llama-3.1-70B in FP8 with TP=2. TTFT ≈ 250–300 ms for 4k token contexts (a 60–100% increase over TP=4).&lt;/li>
&lt;li>Good enough for internal workloads with &amp;lt; 20 concurrent requests.&lt;/li>
&lt;li>Not advisable for an SLO &amp;lt; 1 s with more than 5 concurrent users and a long context.&lt;/li>
&lt;/ul>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;p>&lt;strong>Agentic retrieval loops with planning.&lt;/strong> ReAct is the simplest agent pattern. When a query requires decomposition into sub-tasks with dependencies, you need orchestration frameworks such as LangGraph (state graphs), smolagents (Hugging Face, agents with Python code as actions) or llama-index Agents (planning + retrieval pipeline). These introduce a planning step before the execution loop.&lt;/p>
&lt;p>&lt;strong>MCP (Model Context Protocol).&lt;/strong> Anthropic&amp;rsquo;s emerging standard, with OSS implementations, for defining tools portably across frameworks and hosts. Instead of defining each tool&amp;rsquo;s JSON Schema separately in every application, MCP centralises those definitions in an MCP server that any compatible client can discover and invoke. Adoption in 2025–2026 is fast among OSS frameworks (LangChain, smolagents, OpenWebUI).&lt;/p>
&lt;p>&lt;strong>Tool caching.&lt;/strong> If the same tool call (same arguments, same tool) is going to be invoked several times within the same context or in very similar contexts, the result can be cached. The mechanism is analogous to the semantic cache described for RAG: before executing the tool, the hash of the arguments (or its embedding, for semantic matching) is compared against a cache with a TTL. Especially valuable for &lt;code>sql_query&lt;/code> with frequent queries and data that changes little.&lt;/p>
&lt;p>&lt;strong>Multi-agent.&lt;/strong> When an orchestrating agent delegates sub-tasks to specialised agents, one for SQL, one for document retrieval, one for code generation, you enter multi-agent system territory. Each sub-agent can have its own tool set and its own LLM (possibly smaller and more specialised). Coordination between agents introduces additional tracing and observability complexity.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a> — the retriever invoked when the LLM chooses &lt;code>vector_search&lt;/code> is exactly the pipeline described there: dense + sparse + cross reranker&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — the JSON Schema that defines the contract of each tool call is exactly structured output applied to the tool interface&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">Inference router and L7 LLM gateway&lt;/a> — the L7 gateway that receives the ReAct agent&amp;rsquo;s requests and routes them to the right LLM; it also applies rate limiting per user and tenant&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: fundamentals&lt;/a> — SQL injection via prompt is a real attack vector in tool-augmented retrieval; LLM Guard covers prompt injection detection before the request reaches the LLM&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Six-stage LLMOps pipeline&lt;/a> — tool-augmented retrieval lives at the intersection of the pipeline&amp;rsquo;s Deploy and Observe stages: it is deployed as part of the inference system and observed via tracing of each agent turn&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals: the layer after tracing&lt;/a> — tool selection accuracy is the critical eval metric for a ReAct agent; the golden eval dataset must include (query, expected tool, expected args) triples&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Yao, S. et al. (2022). &lt;em>ReAct: Synergizing Reasoning and Acting in Language Models&lt;/em>. arXiv:2210.03629. &lt;a href="https://arxiv.org/abs/2210.03629">https://arxiv.org/abs/2210.03629&lt;/a>&lt;/li>
&lt;li>vLLM documentation. &lt;em>Tool calling&lt;/em>. &lt;a href="https://docs.vllm.ai/en/stable/features/tool_calling.html">https://docs.vllm.ai/en/stable/features/tool_calling.html&lt;/a>&lt;/li>
&lt;li>Qdrant documentation. &lt;em>Search&lt;/em>. &lt;a href="https://qdrant.tech/documentation/concepts/search/">https://qdrant.tech/documentation/concepts/search/&lt;/a>&lt;/li>
&lt;li>OpenAI. &lt;em>Function calling&lt;/em>. &lt;a href="https://platform.openai.com/docs/guides/function-calling">https://platform.openai.com/docs/guides/function-calling&lt;/a>&lt;/li>
&lt;li>Meta AI. &lt;em>Llama 3.1 Model Card&lt;/em>. &lt;a href="https://github.com/meta-llama/llama-models/blob/main/models/llama3_1/MODEL_CARD.md">https://github.com/meta-llama/llama-models/blob/main/models/llama3_1/MODEL_CARD.md&lt;/a>&lt;/li>
&lt;li>Qwen Team (Alibaba). &lt;em>Qwen2.5 Technical Report&lt;/em>. arXiv:2412.15115. &lt;a href="https://arxiv.org/abs/2412.15115">https://arxiv.org/abs/2412.15115&lt;/a>&lt;/li>
&lt;li>Anthropic. &lt;em>Model Context Protocol&lt;/em>. &lt;a href="https://modelcontextprotocol.io">https://modelcontextprotocol.io&lt;/a>&lt;/li>
&lt;li>OWASP. &lt;em>LLM Top 10 for Large Language Model Applications&lt;/em>. LLM01: Prompt Injection. &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Evaluating a RAG system without fooling yourself: RAGAS, the golden dataset and the four metrics that matter</title><link>https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/</link><pubDate>Thu, 04 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG pipeline fails in ways that user satisfaction cannot distinguish: the LLM can hallucinate even with good chunks, or retrieval can ignore key documents even though the LLM synthesises well what it receives. RAGAS breaks evaluation down into four orthogonal metrics, faithfulness, answer relevance, context precision and context recall, each pointing at a different sub-component. The golden dataset is the reference calibrator; without it the metrics have no anchor. The complete stack runs 100 % on-premise with vLLM as the judge and Langfuse for traceability.&lt;/p>
&lt;hr>
&lt;h2 id="the-master-analogy-the-quality-inspector-at-a-furniture-factory">The master analogy: the quality inspector at a furniture factory&lt;/h2>
&lt;p>Imagine you manufacture chairs. You could ask customers &amp;ldquo;is it comfortable?&amp;rdquo; and leave it there. But that question does not tell you what to fix when the answer is &amp;ldquo;no&amp;rdquo;. The quality inspector does not ask that: they measure the board with a Shore hardness test, check that each leg is exactly 45 cm, verify that the assembly manual includes the twelve screws from the BOM, and detect whether a low-density board slipped past the incoming filter.&lt;/p>
&lt;p>RAGAS is that inspector applied to RAG:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Faithfulness&lt;/strong> → does the board have the specified hardness? The LLM can only use the material (chunks) that retrieval hands it.&lt;/li>
&lt;li>&lt;strong>Context Precision&lt;/strong> → is the leg exactly the right length? Of the K retrieved chunks, how many are genuinely useful and how many are filler that confuses the assembler?&lt;/li>
&lt;li>&lt;strong>Context Recall&lt;/strong> → does the manual include all the screws? Of all the facts the correct answer should contain, how many appear in the retrieved chunks?&lt;/li>
&lt;li>&lt;strong>Noise Sensitivity&lt;/strong> → if the operator uses a medium-low density board, does it show in the final product? If you introduce irrelevant chunks, does the LLM start hallucinating?&lt;/li>
&lt;/ul>
&lt;p>Without measuring each dimension separately, the diagnosis is opaque: &amp;ldquo;the RAG is not working well&amp;rdquo; does not tell you whether to repair the embedder, the reranker, the prompt or the corpus.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-of-evaluating-rag">The problem of evaluating RAG&lt;/h2>
&lt;p>Classification has one uncomfortable virtue: if you predict 87 out of 100 labels correctly, accuracy = 0.87. There is no ambiguity. RAG does not enjoy that luxury.&lt;/p>
&lt;p>A RAG system can fail in at least three independent ways:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Retrieval correct, LLM hallucinates&lt;/strong>: the chunks contain the correct answer, but the LLM generates claims that are not in those chunks. Faithfulness low; context recall high.&lt;/li>
&lt;li>&lt;strong>LLM correct, retrieval fails&lt;/strong>: retrieval returns irrelevant chunks (low context precision) or incomplete ones (low context recall). If the LLM has enough parametric knowledge, it may look as though it is answering well, but in reality it is ignoring the context, which is a time bomb once the parametric knowledge goes stale.&lt;/li>
&lt;li>&lt;strong>Retrieval and LLM both correct, answer does not answer the question&lt;/strong>: the answer is faithful to the context and the chunks are relevant, but the question was a different one. Answer relevance low.&lt;/li>
&lt;/ol>
&lt;p>Each failure mode requires a different metric and a different corrective action. Using a single metric (BLEU, ROUGE, user satisfaction) mixes the signals and makes it impossible to prioritise improvement work.&lt;/p>
&lt;hr>
&lt;h2 id="the-four-ragas-metrics">The four RAGAS metrics&lt;/h2>
&lt;h3 id="1-faithfulness--fidelity-to-the-context">1. Faithfulness — fidelity to the context&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> how many claims in the generated answer are supported by the retrieved chunks?&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{Faithfulness} = \frac{|\text{claims supported by the context}|}{|\text{total claims in the answer}|}$$
&lt;p>The process uses an LLM-as-judge (see https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/): first the atomic claims are extracted from the answer (&amp;ldquo;the model was released in 2023&amp;rdquo;, &amp;ldquo;it supports contexts of 128k tokens&amp;rdquo;, and so on), then the judge classifies each claim as &lt;em>supported&lt;/em> or &lt;em>not supported&lt;/em> by the chunks.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> The generated answer has 5 claims. The judge determines that 4 are in the chunks and 1 is an unsupported extrapolation.&lt;/p>
$$\text{Faithfulness} = \frac{4}{5} = 0{.}80$$
&lt;p>&lt;strong>Warning sign:&lt;/strong> faithfulness &amp;lt; 0.85 indicates that the LLM is generating content that goes beyond the context, which is to say it is hallucinating with superficial backing.&lt;/p>
&lt;h3 id="2-answer-relevance--relevance-of-the-answer">2. Answer Relevance — relevance of the answer&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> does the answer actually answer the question as posed?&lt;/p>
&lt;p>&lt;strong>Intuition:&lt;/strong> An answer that answers the question well &amp;ldquo;implies&amp;rdquo; that question. If you generate N hypothetical questions from the answer and measure their semantic similarity to the original question, you get a relevance signal.&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{AnswerRelevance} = \frac{1}{N} \sum_{i=1}^{N} \cos(\vec{q}_{\text{original}}, \vec{q}_{i}^{\text{generated}})$$
&lt;p>where $\vec{q}$ are question embeddings.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> For the question &amp;ldquo;Which Python versions does FastAPI support?&amp;rdquo; and an answer about web frameworks in general, the hypothetical questions generated will be along the lines of &amp;ldquo;what are the best web frameworks?&amp;rdquo;, with low cosine against the original question → low answer relevance.&lt;/p>
&lt;h3 id="3-context-precision--retrieval-precision">3. Context Precision — retrieval precision&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> of the K retrieved chunks, what proportion are genuinely relevant?&lt;/p>
&lt;p>&lt;strong>Calculation&lt;/strong> (weighted version):&lt;/p>
$$\text{ContextPrecision@K} = \frac{\sum_{k=1}^{K} \text{Precision@}k \cdot \mathbb{1}[\text{chunk}_k \text{ is relevant}]}{|\text{relevant chunks in top-K}|}$$
&lt;p>The most direct form: the LLM judge classifies each chunk as relevant or not for answering the question. Precision is the relevant fraction.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> 5 chunks are retrieved. The judge considers 3 relevant and 2 noise.&lt;/p>
$$\text{ContextPrecision} = \frac{3}{5} = 0{.}60$$
&lt;p>&lt;strong>Warning sign:&lt;/strong> precision &amp;lt; 0.6 indicates that retrieval is contaminating the context with information that can contradict or dilute the correct answer.&lt;/p>
&lt;h3 id="4-context-recall--retrieval-recall">4. Context Recall — retrieval recall&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> of all the facts needed to build the correct answer (ground truth), what proportion are covered by the retrieved chunks?&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{ContextRecall} = \frac{|\text{ground-truth claims attributable to some chunk}|}{|\text{total claims in ground truth}|}$$
&lt;p>This metric &lt;strong>requires ground truth&lt;/strong>, which is to say you need the golden dataset.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> The ground truth has 6 claims. The retrieved chunks cover 5 of them; the sixth is in a document that retrieval did not find.&lt;/p>
$$\text{ContextRecall} = \frac{5}{6} \approx 0{.}83$$
&lt;h3 id="5-noise-sensitivity--sensitivity-to-noise">5. Noise Sensitivity — sensitivity to noise&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> if you introduce irrelevant chunks into the context, how far does faithfulness fall?&lt;/p>
&lt;p>It is computed as the difference in faithfulness between the clean condition and the condition with controlled noise:&lt;/p>
$$\Delta\text{Faithfulness} = \text{Faithfulness}_{\text{clean}} - \text{Faithfulness}_{\text{noisy}}$$
&lt;p>A $\Delta > 0{.}15$ indicates that the prompt or the LLM are fragile against contaminated contexts, a problem that low context precision turns into a routine occurrence.&lt;/p>
&lt;hr>
&lt;h2 id="a-complete-worked-example">A complete worked example&lt;/h2>
&lt;h3 id="scenario">Scenario&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> &amp;ldquo;What latency guarantees does Redis offer for in-memory read operations with a 10 GB dataset?&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Answer generated by the RAG:&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Redis guarantees sub-millisecond latencies for GET operations on datasets that fit in RAM. With a 10 GB dataset and clients on the same local network, P99 is typically below 1 ms. Redis also supports horizontal clustering to scale beyond the RAM of a single node. In addition, Redis has included native JSON support since version 7.2.&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Ground truth (reference answer):&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Redis operates entirely in memory, which guarantees sub-millisecond latencies for GET. On local networks with 10 GB datasets in RAM, P99 sits below 1 ms. Clustering allows scaling beyond the RAM of a single node.&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Retrieved chunks (5 chunks, summarised fragments):&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Summarised content&lt;/th>
&lt;th>Relevant&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>C1&lt;/td>
&lt;td>&amp;ldquo;Redis operates in memory; GET has latencies &amp;lt; 1 ms on LAN&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C2&lt;/td>
&lt;td>&amp;ldquo;Redis Cluster allows sharding to scale total RAM&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C3&lt;/td>
&lt;td>&amp;ldquo;Redis Sentinel manages high availability through automatic failover&amp;rdquo;&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C4&lt;/td>
&lt;td>&amp;ldquo;Redis benchmarks: P50 = 0.3 ms, P99 = 0.9 ms on a 10 GB dataset&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C5&lt;/td>
&lt;td>&amp;ldquo;Redis Stack adds modules: RedisJSON, RediSearch, RedisTimeSeries&amp;rdquo;&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="step-by-step-calculation">Step-by-step calculation&lt;/h3>
&lt;p>&lt;strong>Faithfulness:&lt;/strong>&lt;/p>
&lt;p>Claims in the generated answer:&lt;/p>
&lt;ol>
&lt;li>&amp;ldquo;Redis guarantees sub-millisecond latencies for GET on in-RAM datasets&amp;rdquo; → &lt;strong>supported&lt;/strong> by C1, C4&lt;/li>
&lt;li>&amp;ldquo;With 10 GB on LAN, P99 &amp;lt; 1 ms&amp;rdquo; → &lt;strong>supported&lt;/strong> by C4&lt;/li>
&lt;li>&amp;ldquo;Redis supports horizontal clustering to scale RAM&amp;rdquo; → &lt;strong>supported&lt;/strong> by C2&lt;/li>
&lt;li>&amp;ldquo;Redis has included native JSON support since version 7.2&amp;rdquo; → &lt;strong>NOT supported&lt;/strong> by any chunk (C5 mentions RedisJSON as a Redis Stack module, not as native to Redis core)&lt;/li>
&lt;/ol>
$$\text{Faithfulness} = \frac{3}{4} = 0{.}75$$
&lt;p>Claim 4 is an extrapolation that mixes information from C5 imprecisely, a partial hallucination.&lt;/p>
&lt;p>&lt;strong>Context Precision:&lt;/strong>&lt;/p>
&lt;p>Relevant chunks: C1, C2, C4 (3 out of 5).&lt;/p>
$$\text{ContextPrecision} = \frac{3}{5} = 0{.}60$$
&lt;p>C3 and C5 are noise. C5 in particular contributed to the partial hallucination about JSON.&lt;/p>
&lt;p>&lt;strong>Context Recall:&lt;/strong>&lt;/p>
&lt;p>Ground-truth claims:&lt;/p>
&lt;ol>
&lt;li>&amp;ldquo;Redis operates in memory, GET &amp;lt; 1 ms&amp;rdquo; → attributable to C1 ✓&lt;/li>
&lt;li>&amp;ldquo;P99 &amp;lt; 1 ms on LAN with 10 GB&amp;rdquo; → attributable to C4 ✓&lt;/li>
&lt;li>&amp;ldquo;Clustering scales beyond the RAM of a single node&amp;rdquo; → attributable to C2 ✓&lt;/li>
&lt;/ol>
$$\text{ContextRecall} = \frac{3}{3} = 1{.}00$$
&lt;p>Retrieval found every chunk needed for the ground truth. The problem is not recall but precision (C3 and C5 contaminated the context).&lt;/p>
&lt;p>&lt;strong>Answer Relevance:&lt;/strong>&lt;/p>
&lt;p>The judge generates 3 hypothetical questions from the answer:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;What latencies does Redis offer for in-memory reads?&amp;rdquo; — cos = 0.91&lt;/li>
&lt;li>&amp;ldquo;How does Redis scale horizontally?&amp;rdquo; — cos = 0.74&lt;/li>
&lt;li>&amp;ldquo;Which JSON modules does Redis include?&amp;rdquo; — cos = 0.52 (a drift caused by the hallucination)&lt;/li>
&lt;/ul>
$$\text{AnswerRelevance} = \frac{0{.}91 + 0{.}74 + 0{.}52}{3} = 0{.}72$$
&lt;p>The drift towards JSON reduced relevance. A tighter answer would have scored around 0.90.&lt;/p>
&lt;h3 id="summary-of-the-example">Summary of the example&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>Diagnosis&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness&lt;/td>
&lt;td>0.75&lt;/td>
&lt;td>LLM extrapolated beyond the context&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Precision&lt;/td>
&lt;td>0.60&lt;/td>
&lt;td>Retrieval returned 2 irrelevant chunks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Recall&lt;/td>
&lt;td>1.00&lt;/td>
&lt;td>Retrieval captured everything needed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer Relevance&lt;/td>
&lt;td>0.72&lt;/td>
&lt;td>Answer drifts off topic&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Main corrective action:&lt;/strong> improve the reranker so that C3 and C5 are filtered before they reach the LLM. The faithfulness and relevance problem is a direct consequence of the low precision, not of the LLM itself.&lt;/p>
&lt;hr>
&lt;h2 id="building-the-golden-dataset">Building the golden dataset&lt;/h2>
&lt;h3 id="what-it-is-and-why-it-matters">What it is and why it matters&lt;/h3>
&lt;p>The golden dataset is a set of &lt;code>(question, relevant chunks, correct answer)&lt;/code> tuples that acts as a reference calibrator. Without it, context recall cannot be computed (there is no ground truth) and the other metrics lack any interpretive anchor: is 0.75 faithfulness good or bad for this corpus and this domain?&lt;/p>
&lt;p>A well-built golden dataset lets you:&lt;/p>
&lt;ul>
&lt;li>Compare pipeline versions (embedder v1 vs v2, chunk size 512 vs 1024)&lt;/li>
&lt;li>Detect regressions in CI before deploying&lt;/li>
&lt;li>Stratify the analysis by question type&lt;/li>
&lt;/ul>
&lt;h3 id="llm-assisted-construction-pipeline">LLM-assisted construction pipeline&lt;/h3>
&lt;p>Purely manual construction is expensive. The standard pattern in 2026 is LLM assistance with human review of a sample:&lt;/p>
&lt;p>&lt;strong>Step 1 — Seed chunk selection.&lt;/strong> From the full corpus, select representative chunks through stratified sampling (by section, date, document type). For a technical corpus of 10,000 chunks, 500-1,000 seeds is a reasonable starting point.&lt;/p>
&lt;p>&lt;strong>Step 2 — Question generation.&lt;/strong> A powerful LLM (Llama-3.1-70B or similar) generates 2-3 questions per seed chunk using a prompt along these lines:&lt;/p>
&lt;pre tabindex="0">&lt;code>Given the following documentation fragment, generate specific questions
that can only be answered correctly using THIS fragment and not
general knowledge. The questions should be the ones an engineer looking
for operational information would ask.
Fragment: {chunk}
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Step 3 — Reference answer generation.&lt;/strong> The same LLM, with access to the seed chunk (and to adjacent chunks if the question requires them), generates the reference answer.&lt;/p>
&lt;p>&lt;strong>Step 4 — Human review of a sample.&lt;/strong> Manually review 10-20 % of the generated dataset. The most common rejection criteria: trivial questions that any LLM answers without the corpus, answers the LLM padded with parametric knowledge instead of the chunks, and badly formulated or ambiguous questions.&lt;/p>
&lt;h3 id="minimum-size">Minimum size&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Use case&lt;/th>
&lt;th>Minimum pairs&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Prototype / initial validation&lt;/td>
&lt;td>50-100&lt;/td>
&lt;td>Enough to spot coarse problems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Technical corpus in production&lt;/td>
&lt;td>200-500&lt;/td>
&lt;td>Allows basic stratification&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Robust production with full stratification&lt;/td>
&lt;td>500-1,000+&lt;/td>
&lt;td>Needed to detect subtle regressions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="stratifying-the-dataset">Stratifying the dataset&lt;/h3>
&lt;p>A flat golden dataset measures the average but hides the extreme cases. The minimum recommended stratification includes three question types:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Easy (single-hop):&lt;/strong> A single chunk contains all the information needed. The baseline that any decent RAG must beat.&lt;/li>
&lt;li>&lt;strong>Hard (multi-hop):&lt;/strong> The correct answer requires combining information from 2-4 different chunks. This is where the limits of the reranker and the synthesis prompt show up.&lt;/li>
&lt;li>&lt;strong>Adversarial:&lt;/strong> The question has a false premise, or the corpus does not contain the answer. A correct RAG must answer &amp;ldquo;I do not have enough information&amp;rdquo;, whereas a fragile RAG hallucinates confidently. This question type directly measures the risk of high-impact hallucination.&lt;/li>
&lt;/ul>
&lt;h3 id="the-goodhart-trap">The Goodhart trap&lt;/h3>
&lt;blockquote>
&lt;p>&amp;ldquo;When a measure becomes a target, it ceases to be a good measure.&amp;rdquo; — Charles Goodhart&lt;/p>
&lt;/blockquote>
&lt;p>If you optimise the embedder or the reranker using the golden dataset as a loss function, the dataset is corrupted as a metric: the system learns to perform well on those specific questions without improving on the general domain.&lt;/p>
&lt;p>The solution is the same as in supervised ML: separate the &lt;strong>dev set&lt;/strong> (for optimisation and iteration) from the &lt;strong>test set&lt;/strong> (for final evaluation, frozen and audited). The test set must never be used to make design decisions; only to report the state of the system at release time.&lt;/p>
&lt;hr>
&lt;h2 id="correlation-with-real-satisfaction">Correlation with real satisfaction&lt;/h2>
&lt;p>Field studies published by the Databricks teams (2024) and the RAGAS adoption analyses (2025) point to interpretable operational thresholds:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric range&lt;/th>
&lt;th>Observable symptom&lt;/th>
&lt;th>Corrective action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness &amp;lt; 0.75&lt;/td>
&lt;td>Users frequently report &amp;ldquo;made-up answers&amp;rdquo;&lt;/td>
&lt;td>Review the LLM prompt; increase citation instructions; lower the temperature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Faithfulness 0.75-0.85&lt;/td>
&lt;td>Occasional hallucinations on peripheral topics&lt;/td>
&lt;td>Improve context precision to eliminate contaminating chunks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Faithfulness ≥ 0.85&lt;/td>
&lt;td>Correlates with positive NPS in field studies&lt;/td>
&lt;td>Hold steady; monitor for drift&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Precision &amp;lt; 0.60&lt;/td>
&lt;td>LLM includes contradictory information; inconsistent answers&lt;/td>
&lt;td>Tune the reranker; reduce K; review similarity thresholds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Recall &amp;lt; 0.70&lt;/td>
&lt;td>Multi-hop questions fail; key information missing&lt;/td>
&lt;td>Review the chunking strategy; add larger chunks; enrich metadata&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer Relevance &amp;lt; 0.70&lt;/td>
&lt;td>Answers that are &amp;ldquo;correct but do not answer&amp;rdquo;&lt;/td>
&lt;td>Review the synthesis prompt; add an explicit instruction to stick to the question&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Low context precision is especially pernicious: irrelevant chunks are not neutral. They raise the probability that the LLM uses incorrect information as though it were relevant, degrading faithfulness in a chain reaction. It is the transmission belt by which a retrieval problem turns into an LLM problem.&lt;/p>
&lt;hr>
&lt;h2 id="diagram-the-continuous-evaluation-loop">Diagram: the continuous evaluation loop&lt;/h2>
&lt;figure>
&lt;svg viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="rag-eval-title rag-eval-desc" style="width:100%;max-width:800px;font-family:system-ui,sans-serif;">
&lt;title id="rag-eval-title">RAG evaluation loop with RAGAS&lt;/title>
&lt;desc id="rag-eval-desc">Circular diagram showing the flow from corpus to corrective action via retrieval, LLM, answer, RAGAS judge and metrics with alerts.&lt;/desc>
&lt;!-- Background -->
&lt;rect width="800" height="420" fill="#0f1117" rx="12"/>
&lt;!-- Node: Corpus -->
&lt;rect x="30" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="85" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Corpus&lt;/text>
&lt;text x="85" y="207" text-anchor="middle" fill="#718096" font-size="10">documents&lt;/text>
&lt;!-- Arrow Corpus → Retrieval -->
&lt;line x1="140" y1="195" x2="175" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: Retrieval -->
&lt;rect x="175" y="170" width="120" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="235" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Retrieval&lt;/text>
&lt;text x="235" y="207" text-anchor="middle" fill="#718096" font-size="10">top-K chunks&lt;/text>
&lt;!-- Arrow Retrieval → LLM -->
&lt;line x1="295" y1="195" x2="330" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: LLM -->
&lt;rect x="330" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="385" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">LLM&lt;/text>
&lt;text x="385" y="207" text-anchor="middle" fill="#718096" font-size="10">synthesis&lt;/text>
&lt;!-- Arrow LLM → Answer -->
&lt;line x1="440" y1="195" x2="475" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: Answer -->
&lt;rect x="475" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="530" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Answer&lt;/text>
&lt;text x="530" y="207" text-anchor="middle" fill="#718096" font-size="10">generated&lt;/text>
&lt;!-- Arrow Answer → RAGAS -->
&lt;line x1="585" y1="195" x2="620" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: RAGAS Judge -->
&lt;rect x="620" y="170" width="130" height="50" rx="8" fill="#1a2640" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="685" y="191" text-anchor="middle" fill="#93c5fd" font-size="12" font-weight="600">RAGAS Judge&lt;/text>
&lt;text x="685" y="207" text-anchor="middle" fill="#6b9fd4" font-size="10">LLM-as-judge&lt;/text>
&lt;!-- Arrow RAGAS → Metrics (downwards) -->
&lt;line x1="685" y1="220" x2="685" y2="285" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
&lt;!-- Node: Metrics -->
&lt;rect x="615" y="285" width="140" height="60" rx="8" fill="#1a2a1a" stroke="#22c55e" stroke-width="1.5"/>
&lt;text x="685" y="305" text-anchor="middle" fill="#86efac" font-size="12" font-weight="600">Metrics&lt;/text>
&lt;text x="685" y="320" text-anchor="middle" fill="#4ade80" font-size="9">faithfulness · precision&lt;/text>
&lt;text x="685" y="333" text-anchor="middle" fill="#4ade80" font-size="9">recall · relevance&lt;/text>
&lt;!-- Arrow Metrics → Alert -->
&lt;line x1="615" y1="315" x2="510" y2="315" stroke="#22c55e" stroke-width="1.5" marker-end="url(#arrow-green)"/>
&lt;!-- Node: Grafana alert -->
&lt;rect x="390" y="285" width="120" height="60" rx="8" fill="#2a1a1a" stroke="#f97316" stroke-width="1.5"/>
&lt;text x="450" y="305" text-anchor="middle" fill="#fdba74" font-size="12" font-weight="600">Alert&lt;/text>
&lt;text x="450" y="320" text-anchor="middle" fill="#fb923c" font-size="9">Prometheus&lt;/text>
&lt;text x="450" y="333" text-anchor="middle" fill="#fb923c" font-size="9">Grafana&lt;/text>
&lt;!-- Arrow Alert → Action -->
&lt;line x1="390" y1="315" x2="285" y2="315" stroke="#f97316" stroke-width="1.5" marker-end="url(#arrow-orange)"/>
&lt;!-- Node: Action -->
&lt;rect x="155" y="285" width="130" height="60" rx="8" fill="#1a1a2a" stroke="#a855f7" stroke-width="1.5"/>
&lt;text x="220" y="305" text-anchor="middle" fill="#d8b4fe" font-size="12" font-weight="600">Corrective action&lt;/text>
&lt;text x="220" y="320" text-anchor="middle" fill="#c084fc" font-size="9">retrieval / chunking&lt;/text>
&lt;text x="220" y="333" text-anchor="middle" fill="#c084fc" font-size="9">prompt / fine-tuning&lt;/text>
&lt;!-- Arrow Action → Corpus (closing the loop) -->
&lt;line x1="155" y1="315" x2="85" y2="315" stroke="#a855f7" stroke-width="1.5"/>
&lt;line x1="85" y1="315" x2="85" y2="222" stroke="#a855f7" stroke-width="1.5" marker-end="url(#arrow-purple)"/>
&lt;!-- Golden Dataset (side input) -->
&lt;rect x="600" y="350" width="155" height="40" rx="6" fill="#1a2633" stroke="#64748b" stroke-width="1" stroke-dasharray="5,3"/>
&lt;text x="677" y="366" text-anchor="middle" fill="#94a3b8" font-size="10" font-weight="600">Golden Dataset&lt;/text>
&lt;text x="677" y="380" text-anchor="middle" fill="#64748b" font-size="9">ground truth for recall&lt;/text>
&lt;!-- Arrow Golden Dataset → Metrics -->
&lt;line x1="677" y1="350" x2="685" y2="347" stroke="#64748b" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-gray)"/>
&lt;!-- Langfuse label -->
&lt;rect x="460" y="130" width="90" height="28" rx="5" fill="#1a2633" stroke="#64748b" stroke-width="1" stroke-dasharray="4,2"/>
&lt;text x="505" y="148" text-anchor="middle" fill="#94a3b8" font-size="9">Langfuse tracing&lt;/text>
&lt;!-- Langfuse line above -->
&lt;line x1="505" y1="158" x2="505" y2="170" stroke="#64748b" stroke-width="1" stroke-dasharray="3,2"/>
&lt;!-- Marker definitions -->
&lt;defs>
&lt;marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#4a5568"/>
&lt;/marker>
&lt;marker id="arrow-blue" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#3b82f6"/>
&lt;/marker>
&lt;marker id="arrow-green" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#22c55e"/>
&lt;/marker>
&lt;marker id="arrow-orange" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#f97316"/>
&lt;/marker>
&lt;marker id="arrow-purple" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#a855f7"/>
&lt;/marker>
&lt;marker id="arrow-gray" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>
&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;figcaption style="text-align:center;font-size:0.85em;color:#718096;margin-top:0.5em">The continuous evaluation loop: corpus → retrieval → LLM → RAGAS judge → metrics → alert → corrective action → corpus.&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;h2 id="the-2026-oss-stack-for-running-ragas-on-premise">The 2026 OSS stack for running RAGAS on-premise&lt;/h2>
&lt;h3 id="ragas-apache-20">ragas (Apache 2.0)&lt;/h3>
&lt;p>The &lt;code>ragas&lt;/code> library supports asynchronous evaluation and multiple LLM backends. Integrating vLLM as the judge removes any need to send data to external APIs, which is critical in environments with sensitive data.&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">ragas&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">evaluate&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ragas.metrics&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">faithfulness&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">answer_relevancy&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_precision&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_recall&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="kn">from&lt;/span> &lt;span class="nn">langchain_openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">OpenAIEmbeddings&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"># Judge LLM pointing at on-premise vLLM&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">judge_llm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;meta-llama/Llama-3.1-70B-Instruct&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">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://vllm-service:8000/v1&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">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sk-local&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># vLLM ignores the value but requires the field&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">embeddings&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAIEmbeddings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://embedding-service:8001/v1&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">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sk-local&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="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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">evaluate&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">golden_dataset&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># HuggingFace Dataset with standard columns&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metrics&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">faithfulness&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">answer_relevancy&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context_precision&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context_recall&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llm&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">judge_llm&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embeddings&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">embeddings&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;/code>&lt;/pre>&lt;/div>&lt;p>The dataset RAGAS expects has four columns: &lt;code>question&lt;/code>, &lt;code>answer&lt;/code>, &lt;code>contexts&lt;/code> (a list of strings), &lt;code>ground_truth&lt;/code>.&lt;/p>
&lt;h3 id="langfuse-for-eval-traceability">Langfuse for eval traceability&lt;/h3>
&lt;p>Each RAGAS evaluation is recorded in Langfuse as a &lt;em>dataset experiment&lt;/em>, linking the scores to the production spans (see https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/). This makes it possible to correlate a drop in faithfulness with the specific request that caused it. Without that link, the metrics are numbers with no actionable context.&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">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&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">lf&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&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"># Create or retrieve the dataset in Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">dataset&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_or_create_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;rag-golden-v3&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"># Record the experiment scores&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">idx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">row&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_pandas&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">iterrows&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ragas-faithfulness&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">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">row&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;faithfulness&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">trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">row&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="c1"># linked to the production span&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="prometheus--grafana-for-operational-alerts">Prometheus + Grafana for operational alerts&lt;/h3>
&lt;p>RAGAS metrics are exposed as Prometheus gauges. A Grafana dashboard with thresholds configures alerts when faithfulness falls sustainedly below 0.80:&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"># Prometheus alert rule&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RAGFaithfulnessLow&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg_over_time(rag_faithfulness_score[30m]) &amp;lt; 0.80&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10m&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">labels&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">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;RAG faithfulness below threshold ({{ $value | humanize }})&amp;#34;&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">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Review context precision and the reranker. Possible corpus drift.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="running-ragas-against-on-premise-vllm--practical-considerations">Running RAGAS against on-premise vLLM — practical considerations&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Judge size:&lt;/strong> Llama-3.1-70B as the judge produces results comparable to GPT-4 on faithfulness and context evaluation, according to the RAGAS 0.2 benchmarks (2025). Smaller models (8B-13B) degrade judge quality on multi-hop questions.&lt;/li>
&lt;li>&lt;strong>Throughput:&lt;/strong> On on-premise hardware with 4×H100 SXM (320 GB, NVLink), a run of 200 evaluations with Llama-3.1-70B takes roughly 8-12 minutes with batch_size=8 and vLLM in continuous batching mode.&lt;/li>
&lt;li>&lt;strong>Cost per evaluation:&lt;/strong> With no external API, the marginal cost is electricity plus GPU amortisation. With 4×H100 at around 3 kW sustained, a run of 200 evaluations costs &amp;lt; 0.10 € in energy at a typical industrial tariff.&lt;/li>
&lt;li>&lt;strong>Recommended frequency:&lt;/strong> weekly offline eval over the full golden dataset plus sampled online eval (5-10 % of production requests) with a subset of metrics that do not require ground truth (faithfulness, answer relevance).&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Alternatives to RAGAS:&lt;/strong> TruLens (evaluation with modular feedbacks), DeepEval (programmatic assertions, pytest integration), ARES (Stanford framework with trained classifiers instead of LLM-as-judge), and the OpenAI evals framework. Each has different trade-offs in judge cost, reliability and ease of integration.&lt;/li>
&lt;li>&lt;strong>Continuous eval in production:&lt;/strong> automatically sampling real requests, anonymising them, running a subset of metrics without ground truth and using the result to detect system drift before users report it. It requires a data pipeline separate from the inference pipeline.&lt;/li>
&lt;li>&lt;strong>Multilingual eval:&lt;/strong> RAGAS with a judge in Spanish or Catalan over a non-English corpus has documented biases when the judge is a model trained fundamentally in English. The semantic similarity embeddings for answer relevance are especially sensitive to corpus language vs judge language.&lt;/li>
&lt;li>&lt;strong>A/B testing of RAG configurations:&lt;/strong> using RAGAS metrics as the success criterion in controlled experiments, chunk size 512 vs 1024, pure BM25 vs hybrid, cross-encoder reranker vs biencoder, with statistical significance computed over the golden dataset.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/ — the LLM judge pattern that RAGAS uses to measure faithfulness claim by claim&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/ — the general evals framework in which RAGAS is the RAG specialisation&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/ — the retrieval layer whose context precision and recall these metrics measure&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/ — the corpus quality that context recall reflects&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/ — the production spans where Langfuse records the RAGAS scores&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/ — the golden dataset is a data artefact that needs versioning just like the corpus&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Es Shahul, et al. &lt;em>RAGAS: Automated Evaluation of Retrieval Augmented Generation&lt;/em>. arXiv:2309.15217 (2023). &lt;a href="https://arxiv.org/abs/2309.15217">https://arxiv.org/abs/2309.15217&lt;/a>&lt;/li>
&lt;li>RAGAS Documentation v0.2. &lt;em>Metrics Reference&lt;/em>. &lt;a href="https://docs.ragas.io/en/stable/concepts/metrics/">https://docs.ragas.io/en/stable/concepts/metrics/&lt;/a> (accessed June 2026)&lt;/li>
&lt;li>Langfuse. &lt;em>Dataset Experiments&lt;/em>. &lt;a href="https://langfuse.com/docs/datasets/overview">https://langfuse.com/docs/datasets/overview&lt;/a> (accessed June 2026)&lt;/li>
&lt;li>Databricks. &lt;em>LLM Quality Evaluation: From Lab to Production&lt;/em>. Databricks Engineering Blog (2024).&lt;/li>
&lt;li>Saad-Falcon, J. et al. &lt;em>ARES: An Automated Evaluation Framework for Retrieval-Augmented Generation Systems&lt;/em>. arXiv:2311.09476 (2023).&lt;/li>
&lt;li>Goodhart, C.A.E. &lt;em>Problems of Monetary Management: The U.K. Experience&lt;/em>. Papers in Monetary Economics. Reserve Bank of Australia (1975). Modern formulation of the law that bears his name.&lt;/li>
&lt;li>vLLM Project. &lt;em>OpenAI-Compatible Server&lt;/em>. &lt;a href="https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html">https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html&lt;/a> (accessed June 2026)&lt;/li>
&lt;/ol></description></item><item><title>Semantic cache in RAG: the receptionist with a photographic memory</title><link>https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/</link><pubDate>Thu, 04 Jun 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>In a RAG system with real traffic, 30–70% of queries are semantically equivalent to some earlier one even when the text differs. The semantic cache intercepts those queries ahead of the retriever and the LLM, returning the answer already computed if the cosine similarity with a previous query exceeds a threshold θ (typically 0.92–0.95). At 10,000 requests/day and a 45% hit rate, that amounts to not running 4,500 LLM generations: roughly 0.62 GPU hours saved every day on a cluster running Llama-3.1-70B. The fundamental trade-off is that a high θ gives more accurate answers but less saving; a low θ maximises saving but can return incorrect answers for subtly different queries.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy-the-receptionist-with-a-notebook">The analogy: the receptionist with a notebook&lt;/h2>
&lt;p>Picture the front desk of a 400-room hotel. Over the course of the day, the receptionist takes hundreds of questions. But if you look through the log book, you will see that 60% of those questions are variants of the same ten:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;Where is the gym?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;What time is breakfast?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Do you have parking?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;How do I connect to the WiFi?&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>By the third day, the receptionist has built a mental notebook of answers. When someone asks &amp;ldquo;where can I go to exercise?&amp;rdquo;, they do not call the concierge (retrieval) or open the hotel&amp;rsquo;s 300-page internal manual (LLM): they look at the notebook, work out that the question is the same as &amp;ldquo;where is the gym?&amp;rdquo;, and answer in two seconds.&lt;/p>
&lt;p>But when someone asks &amp;ldquo;what time does the gym close &lt;strong>today&lt;/strong>?&amp;rdquo;, the receptionist knows the notebook cannot be trusted: the schedule may have changed because of a private event. They have to call the concierge.&lt;/p>
&lt;p>That is exactly the mechanism of a semantic cache:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>notebook&lt;/strong> is the cache store (Redis with a vector index, or a Qdrant collection).&lt;/li>
&lt;li>&lt;strong>Working out that &amp;ldquo;exercise&amp;rdquo; ≈ &amp;ldquo;gym&amp;rdquo;&lt;/strong> is the cosine similarity search with threshold θ.&lt;/li>
&lt;li>&lt;strong>Calling the concierge&lt;/strong> is retrieval over the corpus.&lt;/li>
&lt;li>&lt;strong>Consulting the manual&lt;/strong> is LLM generation.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Today&amp;rdquo;&lt;/strong> is the temporal query signal that invalidates the cache.&lt;/li>
&lt;/ul>
&lt;p>The threshold θ is exactly what separates &amp;ldquo;where is it&amp;rdquo; (semantically the same) from &amp;ldquo;what time is it open today&amp;rdquo; (semantically different). It is not magic: it is vector arithmetic over learned representations.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-in-production">The problem in production&lt;/h2>
&lt;p>A typical RAG pipeline has three layers of latency and compute: embedding the query, vector search over the corpus, and generation with the LLM. In development, that cost is irrelevant. In production with 50 concurrent users, each of those layers scales linearly with the number of requests.&lt;/p>
&lt;p>The problem is that users ask the same questions over and over, with slightly different wording:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Original query&lt;/th>
&lt;th>Equivalent query&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;How do I configure the agent?&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;What is the process for configuring the agent?&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;error installing the dependency&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;the dependency installation fails&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;what is an embedding?&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;explain to me what embeddings are&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Running the full pipeline for each of these variants is pure waste. Empirical studies in technical support and corporate Q&amp;amp;A systems report that between 30% and 70% of a day&amp;rsquo;s queries are semantically redundant with respect to earlier queries from the same week.&lt;/p>
&lt;p>The query distribution in real systems follows a power law similar to the Zipf distribution: the 100 most frequent topics account for roughly 60% of total traffic. A well-calibrated cache captures exactly that concentration.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-semantic-cache-works">How the semantic cache works&lt;/h2>
&lt;p>The full flow is shown in the diagram below. Let us describe it in prose first.&lt;/p>
&lt;p>When a new query $q$ arrives:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Embedding the query&lt;/strong>: $q$ is embedded with the same model used to index the corpus. This is critical: if the corpus was indexed with &lt;code>text-embedding-3-large&lt;/code> and the cache uses a different embedder, the vector spaces are not comparable.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Search in the cache store&lt;/strong>: an ANN (Approximate Nearest Neighbor) search is run over the vectors of previously cached queries. The most similar query $q^*$ and its cosine similarity $s$ are retrieved.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Threshold decision&lt;/strong>:
&lt;/p>
$$
\text{answer} = \begin{cases} r^* &amp; \text{if } s(q, q^*) \geq \theta \\ \text{pipeline}(q) &amp; \text{if } s(q, q^*) &lt; \theta \end{cases}
$$
&lt;p>
where $r^*$ is the cached answer associated with $q^*$.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>On a miss&lt;/strong>: the full pipeline runs (retrieval + LLM). The generated answer is stored in the cache with a configurable TTL for future similar queries.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>The cache store is not an ordinary key-value database. It is a vector index over the query embeddings, with the values being the generated answers. Each entry has the structure:&lt;/p>
&lt;pre tabindex="0">&lt;code>{vector: embed(q), response: r, ttl: T, metadata: {...}}
&lt;/code>&lt;/pre>&lt;h3 id="flow-diagram">Flow diagram&lt;/h3>
&lt;figure>
&lt;svg viewBox="0 0 760 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sc-title sc-desc" style="width:100%;max-width:760px;font-family:system-ui,sans-serif;">
&lt;title id="sc-title">Semantic cache flow in a RAG pipeline&lt;/title>
&lt;desc id="sc-desc">Flow diagram showing how a query first passes through the semantic cache and, depending on whether there is a hit or a miss, either a cached answer is returned or the full retrieval and LLM pipeline runs.&lt;/desc>
&lt;!-- Background -->
&lt;rect width="760" height="420" fill="#f8f9fa" rx="8"/>
&lt;!-- Query input -->
&lt;rect x="20" y="180" width="110" height="52" rx="8" fill="#e3f2fd" stroke="#1565c0" stroke-width="1.5"/>
&lt;text x="75" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#0d47a1">Query&lt;/text>
&lt;text x="75" y="220" text-anchor="middle" font-size="11" fill="#1565c0">from the user&lt;/text>
&lt;!-- Arrow query → embedder -->
&lt;line x1="130" y1="206" x2="168" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Embedder -->
&lt;rect x="168" y="180" width="100" height="52" rx="8" fill="#f3e5f5" stroke="#6a1b9a" stroke-width="1.5"/>
&lt;text x="218" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#4a148c">Embedder&lt;/text>
&lt;text x="218" y="220" text-anchor="middle" font-size="10" fill="#6a1b9a">same model&lt;/text>
&lt;!-- Arrow embedder → cache check -->
&lt;line x1="268" y1="206" x2="306" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Cache check -->
&lt;rect x="306" y="172" width="120" height="68" rx="8" fill="#fff3e0" stroke="#e65100" stroke-width="1.5"/>
&lt;text x="366" y="196" text-anchor="middle" font-size="12" font-weight="600" fill="#bf360c">Cache check&lt;/text>
&lt;text x="366" y="212" text-anchor="middle" font-size="10" fill="#e65100">ANN search&lt;/text>
&lt;text x="366" y="226" text-anchor="middle" font-size="10" fill="#e65100">sim ≥ θ ?&lt;/text>
&lt;!-- HIT branch (top) -->
&lt;line x1="366" y1="172" x2="366" y2="120" stroke="#2e7d32" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr-green)"/>
&lt;text x="380" y="148" font-size="10" fill="#2e7d32" font-weight="600">HIT&lt;/text>
&lt;rect x="296" y="68" width="140" height="48" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.5"/>
&lt;text x="366" y="90" text-anchor="middle" font-size="12" font-weight="600" fill="#1b5e20">Answer&lt;/text>
&lt;text x="366" y="106" text-anchor="middle" font-size="11" fill="#2e7d32">cached → user&lt;/text>
&lt;!-- MISS branch (right) -->
&lt;line x1="426" y1="206" x2="466" y2="206" stroke="#c62828" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr-red)"/>
&lt;text x="435" y="196" font-size="10" fill="#c62828" font-weight="600">MISS&lt;/text>
&lt;!-- Retriever -->
&lt;rect x="466" y="180" width="100" height="52" rx="8" fill="#e8eaf6" stroke="#283593" stroke-width="1.5"/>
&lt;text x="516" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#1a237e">Retriever&lt;/text>
&lt;text x="516" y="220" text-anchor="middle" font-size="10" fill="#283593">vector search&lt;/text>
&lt;!-- Arrow retriever → LLM -->
&lt;line x1="566" y1="206" x2="606" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- LLM -->
&lt;rect x="606" y="180" width="100" height="52" rx="8" fill="#fce4ec" stroke="#880e4f" stroke-width="1.5"/>
&lt;text x="656" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#880e4f">LLM&lt;/text>
&lt;text x="656" y="220" text-anchor="middle" font-size="10" fill="#ad1457">generation&lt;/text>
&lt;!-- Arrow LLM → new answer -->
&lt;line x1="656" y1="232" x2="656" y2="290" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- New answer -->
&lt;rect x="596" y="290" width="120" height="48" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.5"/>
&lt;text x="656" y="312" text-anchor="middle" font-size="12" font-weight="600" fill="#1b5e20">Answer&lt;/text>
&lt;text x="656" y="328" text-anchor="middle" font-size="11" fill="#2e7d32">new → user&lt;/text>
&lt;!-- Arrow new answer → store in cache -->
&lt;line x1="596" y1="314" x2="430" y2="314" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;text x="510" y="308" text-anchor="middle" font-size="10" fill="#e65100">store + TTL&lt;/text>
&lt;!-- Arrow from store to cache check (feedback) -->
&lt;line x1="366" y1="314" x2="366" y2="240" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;!-- Cache store label -->
&lt;rect x="296" y="340" width="140" height="48" rx="8" fill="#fff8e1" stroke="#f57f17" stroke-width="1.5"/>
&lt;text x="366" y="361" text-anchor="middle" font-size="11" font-weight="600" fill="#e65100">Cache store&lt;/text>
&lt;text x="366" y="377" text-anchor="middle" font-size="10" fill="#f57f17">Redis / Qdrant&lt;/text>
&lt;line x1="366" y1="340" x2="366" y2="314" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;!-- Legend -->
&lt;text x="20" y="390" font-size="10" fill="#555">— — — HIT path&lt;/text>
&lt;rect x="20" y="397" width="30" height="2" fill="#2e7d32"/>
&lt;text x="60" y="410" font-size="10" fill="#2e7d32">cache hit&lt;/text>
&lt;rect x="110" y="397" width="30" height="2" fill="#c62828"/>
&lt;text x="150" y="410" font-size="10" fill="#c62828">cache miss&lt;/text>
&lt;rect x="200" y="397" width="30" height="2" fill="#e65100"/>
&lt;text x="240" y="410" font-size="10" fill="#e65100">store / feedback&lt;/text>
&lt;!-- Arrow markers -->
&lt;defs>
&lt;marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#555"/>
&lt;/marker>
&lt;marker id="arr-green" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#2e7d32"/>
&lt;/marker>
&lt;marker id="arr-red" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#c62828"/>
&lt;/marker>
&lt;marker id="arr-orange" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#e65100"/>
&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;figcaption>The semantic cache flow as middleware between the gateway and the retriever. Cache hits skip the vector search over the corpus and the LLM generation entirely.&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;h2 id="the-threshold-θ-and-its-trade-off">The threshold θ and its trade-off&lt;/h2>
&lt;p>The threshold θ is the most sensitive parameter in the system. It works exactly like the receptionist&amp;rsquo;s recognition threshold: if it is too demanding, it will only identify textually identical questions and the notebook will not be much use. If it is too lax, it will return the answer to &amp;ldquo;where is the gym?&amp;rdquo; to someone who asked &amp;ldquo;what time does the gym close?&amp;rdquo;.&lt;/p>
&lt;p>The cosine similarity between two vectors $\mathbf{a}$ and $\mathbf{b}$ is:&lt;/p>
$$
s(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \cdot \|\mathbf{b}\|}
$$
&lt;p>For prose text (Spanish or English), modern embedders such as &lt;code>text-embedding-3-large&lt;/code> or &lt;code>nomic-embed-text&lt;/code> assign cosine similarities of around 0.90–0.96 to semantically equivalent paraphrases and similarities of 0.75–0.88 to related but non-equivalent queries.&lt;/p>
&lt;p>The cache quality metric is not just hit rate: it is &lt;strong>precision@cache&lt;/strong>, defined as the fraction of cached answers that remain correct for the new query. A cached answer is &amp;ldquo;correct&amp;rdquo; if an evaluator (another LLM, or metrics such as BERTScore) judges it equivalent to the one the full pipeline would have generated for that specific query.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>θ&lt;/th>
&lt;th>Estimated hit rate&lt;/th>
&lt;th>Estimated precision@cache&lt;/th>
&lt;th>Effective saving&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0.85&lt;/td>
&lt;td>~65%&lt;/td>
&lt;td>~72%&lt;/td>
&lt;td>~47%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.90&lt;/td>
&lt;td>~55%&lt;/td>
&lt;td>~85%&lt;/td>
&lt;td>~47%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.92&lt;/td>
&lt;td>~48%&lt;/td>
&lt;td>~91%&lt;/td>
&lt;td>~44%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>0.93&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~45%&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~94%&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~42%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.95&lt;/td>
&lt;td>~35%&lt;/td>
&lt;td>~98%&lt;/td>
&lt;td>~34%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.97&lt;/td>
&lt;td>~18%&lt;/td>
&lt;td>~99.5%&lt;/td>
&lt;td>~18%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The effective saving is defined as $\text{hit rate} \times \text{precision@cache}$, since a hit with an incorrect answer is not a saving: it is an error that can cost more in lost trust than what was saved in GPU.&lt;/p>
&lt;p>The &lt;strong>empirical sweet spot&lt;/strong> for most corporate Q&amp;amp;A applications in Spanish or English lies between θ = 0.92 and θ = 0.95. In highly specialised domains where small nuances change the answer (medicine, law, finance), θ ≥ 0.95 is advisable.&lt;/p>
&lt;hr>
&lt;h2 id="the-arithmetic-of-the-saving">The arithmetic of the saving&lt;/h2>
&lt;p>Let us put concrete numbers on a real system.&lt;/p>
&lt;p>&lt;strong>Base configuration:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>10,000 requests/day&lt;/li>
&lt;li>Technical corpus of 1 million chunks in a Qdrant index&lt;/li>
&lt;li>LLM: Llama-3.1-70B on 4×H100 SXM (320 GB, NVLink)&lt;/li>
&lt;li>Average answer: 200 output tokens&lt;/li>
&lt;li>LLM throughput on this hardware: ~400 tokens/s/GPU with batching (continuous batching enabled, see &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous-batching-fundamentos&lt;/a>)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Cost of a request without cache:&lt;/strong>&lt;/p>
&lt;p>Embedding the query takes ~2 ms on a GPU. The ANN vector search over 1 M chunks in Qdrant takes ~5 ms (measured empirically with HNSW, ef=128). Generating 200 tokens at 400 tok/s in total (4 GPUs) comes to:&lt;/p>
$$
t_{\text{LLM}} = \frac{200 \text{ tokens}}{400 \text{ tok/s}} = 0.5 \text{ s per request}
$$
&lt;p>If 10,000 requests reach the LLM in a day, the total GPU time spent on generation is:&lt;/p>
$$
T_{\text{GPU}} = 10{,}000 \times 0.5 \text{ s} = 5{,}000 \text{ s} \approx 1.38 \text{ GPU hours per day}
$$
&lt;p>&lt;strong>With a semantic cache at θ = 0.93 and a hit rate of ~45%:&lt;/strong>&lt;/p>
&lt;p>Only 5,500 requests (55%) reach the LLM:&lt;/p>
$$
T_{\text{GPU,cache}} = 5{,}500 \times 0.5 \text{ s} = 2{,}750 \text{ s} \approx 0.76 \text{ GPU hours per day}
$$
&lt;p>&lt;strong>Saving:&lt;/strong>&lt;/p>
$$
\Delta T_{\text{GPU}} = 1.38 - 0.76 = 0.62 \text{ GPU hours/day}
$$
&lt;p>In inference compute, this amounts roughly to being able to serve 45% more users without adding hardware, or cutting inference costs by 45% if you work with external APIs billed per token.&lt;/p>
&lt;p>The cost of the semantic cache itself (embedding the query + ANN search over the cache store) is ~7 ms per request, negligible against the 500 ms of generation avoided on hits.&lt;/p>
&lt;p>&lt;strong>Zipf distribution of topics:&lt;/strong>&lt;/p>
&lt;p>The reason it works is the Zipf distribution of traffic. If we number topics by frequency (topic 1 = most frequent), the frequency of topic $k$ is proportional to $1/k$. With 1,000 distinct topics:&lt;/p>
$$
\text{fraction of traffic covered by top-}N = \frac{\sum_{k=1}^{N} 1/k}{\sum_{k=1}^{1000} 1/k} \approx \frac{\ln N}{\ln 1000} = \frac{\ln N}{6.9}
$$
&lt;p>For the top-100 topics: $\ln(100)/6.9 \approx 4.6/6.9 \approx 67\%$ of traffic. The cache does not need to cover every topic: it captures 67% of traffic while covering only 10% of the topics.&lt;/p>
&lt;hr>
&lt;h2 id="oss-stack-2026">OSS stack 2026&lt;/h2>
&lt;h3 id="gptcache">GPTCache&lt;/h3>
&lt;p>&lt;a href="https://github.com/zilliztech/GPTCache">GPTCache&lt;/a> is the reference library for a standalone semantic cache. Its architecture is modular:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embedder&lt;/strong>: ONNX Runtime with converted models (&lt;code>onnx/all-MiniLM-L6-v2&lt;/code> by default), with no GPU dependency for the cache layer.&lt;/li>
&lt;li>&lt;strong>Vector store&lt;/strong>: Faiss (local), Milvus, or Qdrant.&lt;/li>
&lt;li>&lt;strong>Scalar store&lt;/strong>: SQLite (development) or Redis (production) for metadata, TTL, and answers.&lt;/li>
&lt;li>&lt;strong>Similarity evaluation&lt;/strong>: cosine by default, configurable.&lt;/li>
&lt;/ul>
&lt;p>Minimal configuration in Python:&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">gptcache&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">cache&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.adapter&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="kn">from&lt;/span> &lt;span class="nn">gptcache.embedding&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Onnx&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.manager&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CacheBase&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">VectorBase&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_data_manager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.similarity_evaluation.distance&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">SearchDistanceEvaluation&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">onnx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Onnx&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">data_manager&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_data_manager&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">CacheBase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;redis&amp;#34;&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;redis://localhost:6379&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">VectorBase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;qdrant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;localhost&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;query_cache&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">init&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embedding_func&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">onnx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_embeddings&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">data_manager&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">data_manager&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">similarity_evaluation&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">SearchDistanceEvaluation&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">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_openai_key&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>GPTCache intercepts calls to the OpenAI API (or to compatible proxies) transparently. The TTL is configured at the &lt;code>data_manager&lt;/code> level.&lt;/p>
&lt;h3 id="meancache">MeanCache&lt;/h3>
&lt;p>&lt;a href="https://arxiv.org/abs/2403.02694">MeanCache&lt;/a> (2024) extends GPTCache to multi-turn conversations. The problem with standard GPTCache is that in dialogue the &amp;ldquo;relevant&amp;rdquo; query is not just the last message but the whole context window. MeanCache computes the query embedding as the weighted mean of the embeddings of the last $k$ turns:&lt;/p>
$$
\mathbf{e}_{\text{query}} = \frac{\sum_{i=1}^{k} w_i \cdot \mathbf{e}_{q_i}}{\sum_{i=1}^{k} w_i}
$$
&lt;p>where $w_i$ decreases with the age of the turn. This reduces false positives in dialogues where the topic keeps shifting.&lt;/p>
&lt;h3 id="qdrant-as-a-dual-cache-store">Qdrant as a dual cache store&lt;/h3>
&lt;p>If the RAG corpus is already in Qdrant, you can use the same instance with a &lt;strong>separate collection&lt;/strong> for the cache. The advantages are operational: a single service to manage, the same backup and monitoring infrastructure.&lt;/p>
&lt;p>The cache collection uses &lt;code>payload filters&lt;/code> to implement TTL:&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Range&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">time&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="s2">&amp;#34;localhost&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&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"># Search the cache with a TTL filter&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">search&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;semantic_cache&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">query_vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;expires_at&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="nb">range&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">gt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&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="p">),&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">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">score_threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.93&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;/code>&lt;/pre>&lt;/div>&lt;p>You have to run a periodic cleanup of expired entries, since Qdrant has no native TTL (unlike Redis).&lt;/p>
&lt;h3 id="langfuse-for-traceability">Langfuse for traceability&lt;/h3>
&lt;p>&lt;a href="https://langfuse.com">Langfuse&lt;/a> is the OSS standard for LLM pipeline observability (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing-llm-otel-genai&lt;/a>). Every request should be tagged with whether it was a cache hit or a miss:&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">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.decorators&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">observe&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">langfuse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&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="nd">@observe&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">process_query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cache_result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">semantic_cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lookup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_score&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&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="k">return&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">response&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># full pipeline...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&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;/code>&lt;/pre>&lt;/div>&lt;p>With this metadata, Langfuse lets you compute the real hit rate, the distribution of similarity scores, and detect whether the threshold θ needs adjusting.&lt;/p>
&lt;hr>
&lt;h2 id="on-premise-hardware-a-reference-configuration">On-premise hardware: a reference configuration&lt;/h2>
&lt;p>For an on-premise deployment with this stack, a suitable configuration for RAG with a semantic cache is:&lt;/p>
&lt;p>&lt;strong>Inference node:&lt;/strong> 4×H100 SXM (320 GB NVLink in total) for Llama-3.1-70B in FP8. Throughput ~400 tok/s in generation with continuous batching (vLLM or TGI).&lt;/p>
&lt;p>&lt;strong>Vector services node:&lt;/strong> CPU with 256 GB RAM. Qdrant for the corpus (1–10 M chunks) and for the cache store (up to 500K entries in memory). Redis 7.x for metadata and an exact-match cache as a first layer.&lt;/p>
&lt;p>&lt;strong>Embedding node:&lt;/strong> CPU or a mid-range GPU (A10G). The cache embedder can run on ONNX Runtime on CPU with no perceptible impact on latency (~2 ms per embedding).&lt;/p>
&lt;p>Separating the cache store from the corpus matters: the corpus holds millions of chunks with large HNSW indexes; the cache store holds at most tens of thousands of queries with a much smaller index and search times of 1–2 ms.&lt;/p>
&lt;hr>
&lt;h2 id="cases-where-the-cache-fails">Cases where the cache fails&lt;/h2>
&lt;p>The receptionist with a notebook fails in three well-defined scenarios:&lt;/p>
&lt;h3 id="1-queries-with-a-temporal-context">1. Queries with a temporal context&lt;/h3>
&lt;p>&amp;ldquo;What is the current status of the incident?&amp;rdquo; or &amp;ldquo;What changed in the latest release?&amp;rdquo; are questions whose correct answer changes over time. A cache with a 24-hour TTL could return stale information.&lt;/p>
&lt;p>The solution is to detect temporal markers in the query (regular expressions over &amp;ldquo;today&amp;rdquo;, &amp;ldquo;now&amp;rdquo;, &amp;ldquo;current&amp;rdquo;, &amp;ldquo;latest&amp;rdquo;, &amp;ldquo;yesterday&amp;rdquo;, and their Spanish equivalents) and force a cache miss for these queries, whatever the similarity score.&lt;/p>
&lt;h3 id="2-personalised-queries-over-private-data">2. Personalised queries over private data&lt;/h3>
&lt;p>If the RAG has access to user data (account history, private documents), two different users asking the same question must get different answers. A shared cache that ignores user context is a privacy risk.&lt;/p>
&lt;p>The solution is a cache partitioned by &lt;code>user_id&lt;/code> or &lt;code>tenant_id&lt;/code>. This lowers the hit rate (each user&amp;rsquo;s cache is smaller) but it is the only safe option in multi-tenant architectures.&lt;/p>
&lt;h3 id="3-ttl-and-a-stale-corpus">3. TTL and a stale corpus&lt;/h3>
&lt;p>When the corpus is updated (new documents are ingested, errors are corrected), cached answers can go out of date. A fixed TTL (24–48 hours) mitigates the problem but does not remove it.&lt;/p>
&lt;p>For corpora with frequent updates, the solution is an active invalidation mechanism: when the corpus is updated in Qdrant, a job runs that identifies which cache entries might be affected (by semantic overlap with the updated chunks) and deletes them. This is the &amp;ldquo;selective cache invalidation&amp;rdquo; mentioned in the section on topics not covered.&lt;/p>
&lt;hr>
&lt;h2 id="integration-into-the-pipeline-as-middleware">Integration into the pipeline as middleware&lt;/h2>
&lt;p>The semantic cache is implemented as middleware between the API gateway and the retriever. It does not change the API contract: the client still sends queries and receives answers in the same format.&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="k">class&lt;/span> &lt;span class="nc">SemanticCacheMiddleware&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cache_store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">retriever&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">llm&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.93&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cache_store&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retriever&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">retriever&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">llm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llm&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">threshold&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># First layer: exact-match cache (Redis GET, O(1))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">exact&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">exact_lookup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">exact&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">exact&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;exact&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"># Second layer: semantic cache (ANN search)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_embedding&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">semantic&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">semantic_lookup&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">threshold&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="k">if&lt;/span> &lt;span class="n">semantic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">semantic&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;semantic&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"># Miss: full pipeline&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retriever&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retrieve&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">llm&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunks&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"># Store for future queries&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embedding&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&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">query&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;ttl&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">86400&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="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;miss&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first exact-match layer (Redis GET) is an extra optimisation: for textually identical queries, the embedding is not even computed. The cost is a Redis operation taking microseconds. Only if there is no exact match does it fall through to the semantic lookup.&lt;/p>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Selective cache invalidation&lt;/strong>: when a subset of the corpus is updated (for example, the documents for a specific product are reindexed), you would need to identify which cache entries overlap semantically with the updated chunks and mark them stale. The mechanism involves computing similarity between the embeddings of the updated chunks and the embeddings of the cached queries, which is expensive at scale.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Multi-tenant cache: isolation vs sharing&lt;/strong>: in a SaaS product with multiple customers, a shared cache maximises the hit rate but can expose one tenant&amp;rsquo;s answers to another if filtering is not done correctly. A cache partitioned by tenant is safe but has much lower hit rates. The middle ground is a shared cache with ACL filtering applied over Qdrant&amp;rsquo;s payload filters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Semantic cache for streaming responses&lt;/strong>: when the LLM emits tokens in streaming mode (SSE), the cache cannot easily intercept the complete answer. The options are: cache on the first miss and return the whole answer at once on hits (breaking the streaming experience), or implement a &amp;ldquo;fake streaming&amp;rdquo; that emits the tokens of the cached answer at a controlled rate.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Exact-match cache as a first layer&lt;/strong>: ahead of the semantic cache, an O(1) lookup in Redis with the query as the key can catch textually identical queries at negligible cost. The code in the previous section already shows this two-layer architecture.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a> — the retrieval that the semantic cache avoids running on hits; understanding how the vector search you are saving works&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant: document ingestion in microservices&lt;/a> — the vector store that can double as a cache store, with the same Qdrant instance for corpus and cache&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache in transformers&lt;/a> — caching at the transformer&amp;rsquo;s attention level; different from the semantic cache at the RAG system&amp;rsquo;s query level, but complementary&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching in LLM inference&lt;/a> — the batching that processes cache misses; the semantic cache reduces the pressure of requests reaching the inference engine&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Tracing LLMs with OTel and GenAI&lt;/a> — how to instrument cache hits vs misses with OpenTelemetry to measure the real saving in production&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Bang, J. et al. (2024). &lt;em>MeanCache: User-Centric Semantic Cache for Large Language Model Based Web Applications&lt;/em>. arXiv:2403.02694.&lt;/li>
&lt;li>Zilliz. (2023). &lt;em>GPTCache: A Library for Creating Semantic Cache for LLM Queries&lt;/em>. GitHub: &lt;a href="https://github.com/zilliztech/GPTCache">zilliztech/GPTCache&lt;/a>.&lt;/li>
&lt;li>Qdrant Team. (2024). &lt;em>Qdrant Documentation: Filtering with payload&lt;/em>. &lt;a href="https://qdrant.tech/documentation/concepts/filtering/">qdrant.tech/documentation&lt;/a>.&lt;/li>
&lt;li>Manning, C. D., Raghavan, P., Schütze, H. (2008). &lt;em>Introduction to Information Retrieval&lt;/em>. Cambridge University Press. Cap. 19: Web search (distribución Zipf).&lt;/li>
&lt;li>Langfuse. (2024). &lt;em>Observability for LLM Applications&lt;/em>. &lt;a href="https://langfuse.com/docs">langfuse.com/docs&lt;/a>.&lt;/li>
&lt;li>Meta AI. (2024). &lt;em>Llama 3.1 Model Card&lt;/em>. &lt;a href="https://ai.meta.com/blog/meta-llama-3-1/">ai.meta.com&lt;/a>.&lt;/li>
&lt;li>Guo, Y. et al. (2023). &lt;em>Evaluating the Factual Consistency of Large Language Models Through Summarization&lt;/em>. Referencia para BERTScore como métrica de evaluación de respuestas cacheadas.&lt;/li>
&lt;/ol></description></item><item><title>PostgreSQL + Qdrant in RAG ingestion: the postman who keeps two worlds in sync</title><link>https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/</link><pubDate>Thu, 04 Jun 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>TL;DR&lt;/strong> — In a production RAG system, PostgreSQL holds the official truth about documents and Qdrant holds the vectors for search. Keeping them in sync is not trivial: if you delete a document from Postgres and do not invalidate its chunks in Qdrant, the system returns answers from ghost documents. There are two patterns to avoid this: the &lt;strong>outbox pattern&lt;/strong> (atomic transaction plus asynchronous worker, at-least-once) and &lt;strong>CDC with Debezium&lt;/strong> (reading the Postgres WAL directly, low latency, higher complexity). This article explains when to use each one, how to orchestrate them as microservices and what numbers to expect with &lt;code>bge-m3&lt;/code> on on-premise hardware.&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="the-postman-and-the-civil-registry">The postman and the civil registry&lt;/h2>
&lt;p>Picture a town with two complementary offices.&lt;/p>
&lt;p>The first is the &lt;strong>civil registry&lt;/strong>: it keeps the official census. Every time someone is born, dies or moves house, the registry is the first to know. It is slow, structured, transactional. If the registry says someone exists, they exist. If it says they died, they are dead. &lt;strong>PostgreSQL is the civil registry of your documents.&lt;/strong>&lt;/p>
&lt;p>The second is the &lt;strong>postman&amp;rsquo;s notebook&lt;/strong>: a copy optimised for finding any resident in seconds, organised by area, phonetic names and habitual routes. The postman cannot update the registry, but can search at speeds the registry would never reach. &lt;strong>Qdrant is the postman&amp;rsquo;s notebook.&lt;/strong>&lt;/p>
&lt;p>The problem is synchronisation. If the registry records a death but nobody tells the postman, he will keep trying to deliver letters to an address that no longer exists. In RAG, that translates into indexed chunks of documents that have already been deleted, edited or replaced: ghost documents that pollute the results.&lt;/p>
&lt;p>How does the registry tell the postman?&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Outbox pattern&lt;/strong>: every time the registry updates its ledger, it notes the change on an &lt;em>outbox sheet&lt;/em>. A messenger clerk reads that sheet periodically and updates the postman&amp;rsquo;s notebook. Guaranteed, asynchronous, fault tolerant.&lt;/li>
&lt;li>&lt;strong>CDC with Debezium&lt;/strong>: the postman has a direct phone line to the registry. Every time the clerk writes something new, the phone rings and the postman updates his notebook in near real time.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="the-consistency-gap-problem">The consistency gap problem&lt;/h2>
&lt;p>In a naive RAG architecture, the flow is:&lt;/p>
&lt;ol>
&lt;li>The user uploads a document → it is inserted into Postgres with metadata.&lt;/li>
&lt;li>A worker splits it into chunks, generates embeddings and upserts into Qdrant.&lt;/li>
&lt;li>Retrieval uses Qdrant to find relevant chunks and Postgres to hydrate metadata.&lt;/li>
&lt;/ol>
&lt;p>So far so good. The problem shows up in &lt;strong>updates and deletes&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The user &lt;strong>edits&lt;/strong> a document → Postgres updates the row, but the vectors of the old chunks are still in Qdrant. Retrieval returns stale context.&lt;/li>
&lt;li>The user &lt;strong>deletes&lt;/strong> a document → Postgres removes the row, but the chunks remain in Qdrant. Retrieval returns chunks from a document that should no longer exist.&lt;/li>
&lt;li>The permission system &lt;strong>revokes a tenant&amp;rsquo;s access&lt;/strong> → Qdrant has no way of knowing unless there is explicit synchronisation.&lt;/li>
&lt;/ul>
&lt;p>This is not a theoretical problem. In living corpora (corporate wikis, knowledge bases updated daily), the &lt;em>consistency gap&lt;/em> accumulates noise progressively. An internal study on production pipelines shows that without active reconciliation, 3-8% of indexed chunks correspond to documents that no longer exist in the source of truth after 30 days of operation.&lt;/p>
&lt;p>The answer is not &amp;ldquo;reindex everything every night&amp;rdquo;. With 10M chunks and a non-trivial embedding model, that costs hours of compute and causes unavailability windows. The answer is &lt;strong>change propagation with guarantees&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="outbox-pattern-the-outbox-sheet">Outbox pattern: the outbox sheet&lt;/h2>
&lt;h3 id="mechanism">Mechanism&lt;/h3>
&lt;p>The outbox pattern solves the problem of &amp;ldquo;writing to two systems in the same operation&amp;rdquo; without distributed transactions (which are expensive and fragile).&lt;/p>
&lt;p>The idea is simple: &lt;strong>PostgreSQL is the single coordinator&lt;/strong>. When the ingestion microservice processes a document, it performs two writes in the &lt;em>same local transaction&lt;/em>:&lt;/p>
&lt;ol>
&lt;li>It inserts or updates the document in the &lt;code>documents&lt;/code> table.&lt;/li>
&lt;li>It inserts an event into the &lt;code>outbox_events&lt;/code> table.&lt;/li>
&lt;/ol>
&lt;p>If the transaction fails, both writes are rolled back. If it succeeds, both are committed atomically. There is no inconsistent intermediate state.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Relevant tables
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &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="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">gen_random_uuid&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="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">content&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">checksum&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&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="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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">outbox_events&lt;/span>&lt;span class="w"> &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="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BIGSERIAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&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="n">aggregate_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- document id
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;document.created&amp;#39; | &amp;#39;document.updated&amp;#39; | &amp;#39;document.deleted&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">payload&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&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="n">processed_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- NULL = pending
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- Example of an atomic insert
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">BEGIN&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="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">checksum&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="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">4&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="n">RETURNING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">_doc_id&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">outbox_events&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aggregate_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_type&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">payload&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="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">_doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;document.created&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">jsonb_build_object&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="s1">&amp;#39;tenant_id&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&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="s1">&amp;#39;title&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&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="s1">&amp;#39;checksum&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">4&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="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="k">COMMIT&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-outbox-worker">The outbox worker&lt;/h3>
&lt;p>A separate process (the &lt;em>outbox worker&lt;/em>) polls &lt;code>outbox_events&lt;/code> where &lt;code>processed_at IS NULL&lt;/code>, processes each event (chunking, embedding, upsert into Qdrant) and marks the row as processed:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">UPDATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">outbox_events&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="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">processed_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">now&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Guarantee&lt;/strong>: at-least-once. If the worker fails between the Qdrant upsert and the &lt;code>UPDATE&lt;/code>, the event will be reprocessed. Qdrant tolerates idempotent upserts (same point &lt;code>id&lt;/code> = overwrite), so reprocessing does not create duplicates.&lt;/p>
&lt;p>&lt;strong>Latency&lt;/strong>: it depends on the polling interval. With polling every 500ms, p50 latency is ~250ms; p99, ~500ms. Acceptable for most RAG cases, where the user does not expect to see a document indexed in under a second.&lt;/p>
&lt;hr>
&lt;h2 id="cdc-with-debezium-the-direct-phone-line">CDC with Debezium: the direct phone line&lt;/h2>
&lt;h3 id="mechanism-1">Mechanism&lt;/h3>
&lt;p>&lt;em>Change Data Capture&lt;/em> (CDC) reads PostgreSQL&amp;rsquo;s &lt;strong>Write-Ahead Log (WAL)&lt;/strong> directly. Postgres writes every change to the WAL before applying it to the tables; it is the mechanism it uses for replication and recovery. Debezium subscribes to a &lt;em>logical replication slot&lt;/em> and turns those events into structured messages.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Enable logical replication in postgresql.conf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- wal_level = logical
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&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="c1">-- Create a replication slot for Debezium
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_create_logical_replication_slot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;debezium_slot&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;pgoutput&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The full flow:&lt;/p>
&lt;pre tabindex="0">&lt;code>Postgres WAL → Debezium connector → Kafka/NATS → Indexer consumer → Qdrant
&lt;/code>&lt;/pre>&lt;p>Debezium emits events with the record&amp;rsquo;s &lt;em>before/after&lt;/em> structure:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;d&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="nt">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;550e8400-e29b-41d4-a716-446655440000&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="nt">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&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="nt">&amp;#34;checksum&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sha256:abc123&amp;#34;&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="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&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;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code> (delete), the consumer knows it must delete every point in Qdrant whose payload contains that &lt;code>document_id&lt;/code>.&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="c1"># Consumer: delete by payload filter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">qdrant_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&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">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">FilterSelector&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&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">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;document_id&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="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;before&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;id&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="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="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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="upsides-and-downsides">Upsides and downsides&lt;/h3>
&lt;p>CDC removes polling and cuts latency to &lt;strong>tens of milliseconds&lt;/strong> (WAL propagation time plus consumer processing). But it adds operational complexity: you need to manage the replication slot (unconsumed slots retain WAL indefinitely, which can fill the disk), the message broker and the consumer offset state.&lt;/p>
&lt;hr>
&lt;h2 id="comparison-outbox-vs-cdc">Comparison: outbox vs CDC&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Outbox pattern&lt;/th>
&lt;th>CDC with Debezium&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Typical latency&lt;/strong>&lt;/td>
&lt;td>250ms – 2s&lt;/td>
&lt;td>20ms – 200ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Delivery guarantee&lt;/strong>&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Operational complexity&lt;/strong>&lt;/td>
&lt;td>Low (Postgres only)&lt;/td>
&lt;td>High (Debezium + broker)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WAL retention risk&lt;/strong>&lt;/td>
&lt;td>None&lt;/td>
&lt;td>High if the slot stalls&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Idempotency required&lt;/strong>&lt;/td>
&lt;td>Yes (in the indexer)&lt;/td>
&lt;td>Yes (in the consumer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-table support&lt;/strong>&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>Automatic (any table)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Backpressure&lt;/strong>&lt;/td>
&lt;td>Natural (polling)&lt;/td>
&lt;td>Needs explicit design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to choose it&lt;/strong>&lt;/td>
&lt;td>Corpus &amp;lt; 100k docs/day, small team&lt;/td>
&lt;td>Corpus &amp;gt; 1M docs/day, low latency critical&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Rule of thumb&lt;/strong>: start with outbox. Move to CDC when the change volume goes past ~50k events/hour or when latency measured in seconds becomes unacceptable for the use case (for example, real-time news indexing).&lt;/p>
&lt;hr>
&lt;h2 id="microservice-architecture">Microservice architecture&lt;/h2>
&lt;p>The ingestion pipeline is made up of three microservices with cleanly separated responsibilities:&lt;/p>
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="svg-title svg-desc">
&lt;title id="svg-title">RAG ingestion microservice architecture&lt;/title>
&lt;desc id="svg-desc">Diagram showing the flow from Postgres through Ingestor, Indexer and Reconciler to Qdrant&lt;/desc>
&lt;!-- Background -->
&lt;rect width="780" height="420" fill="#0f1117" rx="8"/>
&lt;!-- Postgres -->
&lt;rect x="20" y="160" width="130" height="100" rx="6" fill="#1e2433" stroke="#4a90d9" stroke-width="1.5"/>
&lt;text x="85" y="200" text-anchor="middle" fill="#4a90d9" font-family="monospace" font-size="13" font-weight="bold">PostgreSQL&lt;/text>
&lt;text x="85" y="218" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">documents&lt;/text>
&lt;text x="85" y="234" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">outbox_events&lt;/text>
&lt;!-- Ingestor -->
&lt;rect x="210" y="60" width="140" height="90" rx="6" fill="#1e2433" stroke="#7c4dff" stroke-width="1.5"/>
&lt;text x="280" y="96" text-anchor="middle" fill="#7c4dff" font-family="monospace" font-size="13" font-weight="bold">Ingestor&lt;/text>
&lt;text x="280" y="114" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">chunking&lt;/text>
&lt;text x="280" y="130" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">embedding&lt;/text>
&lt;!-- Indexer -->
&lt;rect x="210" y="190" width="140" height="90" rx="6" fill="#1e2433" stroke="#00c853" stroke-width="1.5"/>
&lt;text x="280" y="226" text-anchor="middle" fill="#00c853" font-family="monospace" font-size="13" font-weight="bold">Indexer&lt;/text>
&lt;text x="280" y="244" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">outbox worker&lt;/text>
&lt;text x="280" y="260" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">Qdrant upsert&lt;/text>
&lt;!-- Reconciler -->
&lt;rect x="210" y="320" width="140" height="70" rx="6" fill="#1e2433" stroke="#ff6d00" stroke-width="1.5"/>
&lt;text x="280" y="354" text-anchor="middle" fill="#ff6d00" font-family="monospace" font-size="13" font-weight="bold">Reconciler&lt;/text>
&lt;text x="280" y="372" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">periodic diff&lt;/text>
&lt;!-- Qdrant -->
&lt;rect x="430" y="160" width="130" height="100" rx="6" fill="#1e2433" stroke="#e91e8c" stroke-width="1.5"/>
&lt;text x="495" y="200" text-anchor="middle" fill="#e91e8c" font-family="monospace" font-size="13" font-weight="bold">Qdrant&lt;/text>
&lt;text x="495" y="218" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">collection&lt;/text>
&lt;text x="495" y="234" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">corpus&lt;/text>
&lt;!-- GPU Embedder -->
&lt;rect x="430" y="50" width="130" height="80" rx="6" fill="#1e2433" stroke="#ffd600" stroke-width="1.5"/>
&lt;text x="495" y="83" text-anchor="middle" fill="#ffd600" font-family="monospace" font-size="12" font-weight="bold">4×H100 SXM&lt;/text>
&lt;text x="495" y="101" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">bge-m3&lt;/text>
&lt;text x="495" y="117" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">~2000 chunks/s&lt;/text>
&lt;!-- Optional Kafka/NATS -->
&lt;rect x="430" y="320" width="130" height="70" rx="6" fill="#1e2433" stroke="#26c6da" stroke-width="1.5"/>
&lt;text x="495" y="352" text-anchor="middle" fill="#26c6da" font-family="monospace" font-size="12" font-weight="bold">Kafka/NATS&lt;/text>
&lt;text x="495" y="370" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">(CDC path)&lt;/text>
&lt;!-- Arrow Postgres → Ingestor -->
&lt;line x1="150" y1="190" x2="210" y2="130" stroke="#7c4dff" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Postgres → Indexer -->
&lt;line x1="150" y1="210" x2="210" y2="230" stroke="#00c853" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Arrow Postgres → Reconciler -->
&lt;line x1="150" y1="240" x2="210" y2="340" stroke="#ff6d00" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Ingestor → GPU -->
&lt;line x1="350" y1="100" x2="430" y2="90" stroke="#ffd600" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Arrow GPU → Indexer -->
&lt;line x1="495" y1="130" x2="380" y2="210" stroke="#ffd600" stroke-width="1.2" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Indexer → Qdrant -->
&lt;line x1="350" y1="230" x2="430" y2="210" stroke="#00c853" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Arrow Reconciler → Kafka -->
&lt;line x1="350" y1="355" x2="430" y2="355" stroke="#26c6da" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Kafka → Qdrant -->
&lt;line x1="495" y1="320" x2="495" y2="260" stroke="#26c6da" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Reconciler → Qdrant direct -->
&lt;line x1="350" y1="345" x2="430" y2="230" stroke="#ff6d00" stroke-width="1.2" stroke-dasharray="3,4" marker-end="url(#arr)"/>
&lt;!-- Marker definitions -->
&lt;defs>
&lt;marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#667788"/>
&lt;/marker>
&lt;/defs>
&lt;!-- Legend -->
&lt;text x="590" y="80" fill="#ccddee" font-family="monospace" font-size="11" font-weight="bold">Outbox flow:&lt;/text>
&lt;line x1="590" y1="92" x2="630" y2="92" stroke="#00c853" stroke-width="1.5"/>
&lt;text x="635" y="96" fill="#8899aa" font-family="monospace" font-size="10">sync&lt;/text>
&lt;text x="590" y="115" fill="#ccddee" font-family="monospace" font-size="11" font-weight="bold">CDC flow:&lt;/text>
&lt;line x1="590" y1="127" x2="630" y2="127" stroke="#26c6da" stroke-width="1.5" stroke-dasharray="4,3"/>
&lt;text x="635" y="131" fill="#8899aa" font-family="monospace" font-size="10">async&lt;/text>
&lt;text x="590" y="150" fill="#ccddee" font-family="monospace" font-size="11" font-weight="bold">Reconciler:&lt;/text>
&lt;line x1="590" y1="162" x2="630" y2="162" stroke="#ff6d00" stroke-width="1.5" stroke-dasharray="3,4"/>
&lt;text x="635" y="166" fill="#8899aa" font-family="monospace" font-size="10">periodic&lt;/text>
&lt;/svg>
&lt;h3 id="microservice-1-ingestor">Microservice 1: Ingestor&lt;/h3>
&lt;p>Responsibilities: receive documents, split them into chunks and request embeddings. It does not write to Qdrant directly.&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="c1"># ingestor/main.py (simplified)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_text_splitters&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">RecursiveCharacterTextSplitter&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">splitter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">RecursiveCharacterTextSplitter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunk_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># tokens, not characters&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunk_overlap&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">length_function&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token_count&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">ingest_document&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Document&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Session&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">splitter&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">begin&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;UPDATE documents SET checksum=$1 WHERE id=$2&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="p">[&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">checksum&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&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">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;INSERT INTO outbox_events (aggregate_id, event_type, payload)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> VALUES ($1, &amp;#39;document.updated&amp;#39;, $2)&amp;#34;&amp;#34;&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="p">[&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;chunks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1.0.0&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="microservice-2-indexer">Microservice 2: Indexer&lt;/h3>
&lt;p>It reads the outbox, generates embeddings by calling the inference server (vLLM or TEI) and upserts into Qdrant.&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="c1"># indexer/worker.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">asyncio&lt;/span>
&lt;/span>&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PointStruct&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">VectorParams&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Distance&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">qdrant&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">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;qdrant-service&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">process_event&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">payload&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;payload&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">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">payload&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;chunks&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">doc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aggregate_id&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"># Batch embedding on TEI (Text Embeddings Inference)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embeddings&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">embed_batch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunks&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;bge-m3&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="n">points&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">PointStruct&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">_&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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">vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">emb&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">payload&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="s2">&amp;#34;document_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">payload&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;tenant_id&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="s2">&amp;#34;chunk_index&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;text&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;model_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">payload&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;model_version&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="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="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">emb&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">enumerate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embeddings&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># If it is an update, delete the previous chunks first&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;document.updated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;document.deleted&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">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&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">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">filter_by_doc_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;document.deleted&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">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">upsert&lt;/span>&lt;span class="p">(&lt;/span>&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 class="n">points&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">points&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="microservice-3-reconciler">Microservice 3: Reconciler&lt;/h3>
&lt;p>The reconciler is the safety net. Periodically (every hour, for example) it compares the set of &lt;code>document_id&lt;/code> values in Postgres with the set of &lt;code>document_id&lt;/code> values in Qdrant. IDs present in Qdrant but absent from Postgres are &lt;em>ghosts&lt;/em>: they get deleted.&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="c1"># reconciler/diff.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">reconcile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pg_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">await&lt;/span> &lt;span class="n">fetch_all_doc_ids&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">qdrant_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">await&lt;/span> &lt;span class="n">scroll_all_doc_ids&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="c1"># paginated scroll&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">orphans&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">qdrant_ids&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">pg_ids&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">orphans&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">warning&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Orphan chunks for &lt;/span>&lt;span class="si">%d&lt;/span>&lt;span class="s2"> documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">orphans&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">doc_id&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">orphans&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">filter_by_doc_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&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="k">return&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">orphans&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="throughput-and-ingestion-maths">Throughput and ingestion maths&lt;/h2>
&lt;h3 id="embedding-throughput">Embedding throughput&lt;/h3>
&lt;p>The &lt;code>bge-m3&lt;/code> model (1024 dimensions, dense + sparse + colbert support) on a node with &lt;strong>4×H100 SXM (320 GB NVLink)&lt;/strong> running via vLLM or HuggingFace TEI reaches roughly &lt;strong>2,000 chunks/second&lt;/strong> with batch size = 256 and 512-token sequences.&lt;/p>
$$\text{throughput} = 4 \times 500 \text{ chunks/s/GPU} = 2{,}000 \text{ chunks/s}$$
&lt;blockquote>
&lt;p>The figure of 500 chunks/s per GPU comes from public TEI benchmarks with bge-m3 on H100 SXM5, batch=256, seq_len=512 &lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>.&lt;/p>
&lt;/blockquote>
&lt;h3 id="total-re-ingestion-time">Total re-ingestion time&lt;/h3>
&lt;p>For a corpus of &lt;strong>10M chunks&lt;/strong>:&lt;/p>
$$t = \frac{10{,}000{,}000 \text{ chunks}}{2{,}000 \text{ chunks/s}} = 5{,}000 \text{ s} \approx 83 \text{ minutes}$$
&lt;p>That is pure embedding time. Adding Qdrant write latency (~0.5ms per upsert batch of 100 points):&lt;/p>
$$t_{\text{qdrant}} = \frac{10{,}000{,}000}{100} \times 0.5\text{ ms} = 50{,}000 \text{ ms} = 50 \text{ s}$$
&lt;p>Estimated total for a full re-ingestion: &lt;strong>~85-90 minutes&lt;/strong> on a 4×H100 node.&lt;/p>
&lt;h3 id="storage-cost-in-qdrant">Storage cost in Qdrant&lt;/h3>
&lt;p>Each &lt;code>bge-m3&lt;/code> vector has &lt;strong>1024 dimensions&lt;/strong> in &lt;code>float32&lt;/code> (4 bytes):&lt;/p>
$$\text{size per vector} = 1{,}024 \times 4 \text{ B} = 4{,}096 \text{ B} = 4 \text{ KB}$$
&lt;p>For 10M chunks (dense vectors only):&lt;/p>
$$\text{total vectors} = 10^7 \times 4{,}096 \text{ B} = 40.96 \text{ GB}$$
&lt;p>Adding the JSON payload (estimated at ~500 bytes/chunk):&lt;/p>
$$\text{payload} = 10^7 \times 500 \text{ B} = 5 \text{ GB}$$
&lt;p>HNSW index (roughly 1.2× the vector size for $m=16$):&lt;/p>
$$\text{HNSW} \approx 40.96 \text{ GB} \times 1.2 = 49.15 \text{ GB}$$
&lt;p>&lt;strong>Estimated total on disk: ~95 GB&lt;/strong> for 10M chunks with dense bge-m3.&lt;/p>
&lt;p>With &lt;code>scalar&lt;/code> quantization (int8), the vector size shrinks 4×:&lt;/p>
$$\text{with int8 quantization} \approx \frac{40.96}{4} + 5 + \frac{49.15}{4} \approx 27.5 \text{ GB}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Vectors&lt;/th>
&lt;th>HNSW&lt;/th>
&lt;th>Payload&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>float32, no quantization&lt;/td>
&lt;td>40.96 GB&lt;/td>
&lt;td>49.15 GB&lt;/td>
&lt;td>5 GB&lt;/td>
&lt;td>~95 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>int8 scalar quantization&lt;/td>
&lt;td>10.24 GB&lt;/td>
&lt;td>12.29 GB&lt;/td>
&lt;td>5 GB&lt;/td>
&lt;td>~28 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>binary quantization&lt;/td>
&lt;td>1.28 GB&lt;/td>
&lt;td>1.54 GB&lt;/td>
&lt;td>5 GB&lt;/td>
&lt;td>~8 GB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Binary quantization loses recall precision (~2-5% in NDCG@10), but it lets you host much larger corpora in RAM. For production where recall is critical, int8 is the usual balance point.&lt;/p>
&lt;hr>
&lt;h2 id="recommended-on-premise-hardware">Recommended on-premise hardware&lt;/h2>
&lt;p>For a continuous ingestion pipeline in production:&lt;/p>
&lt;p>&lt;strong>Embedding node&lt;/strong>: 4×H100 SXM (320 GB, NVLink), 2× 64-core CPU (EPYC 9654), 1 TB DDR5 RAM, 100 GbE. Runs vLLM or TEI serving &lt;code>bge-m3&lt;/code>. Sustained throughput: ~2,000 chunks/s with an asynchronous batch pipeline.&lt;/p>
&lt;p>&lt;strong>Qdrant node&lt;/strong>: 32-core CPU, 256 GB RAM (to keep the HNSW index in memory with 10M chunks and no quantization), 2 TB NVMe (Qdrant snapshot and WAL writes). Qdrant recommends that the HNSW index fit in RAM for p99 latency &amp;lt; 5ms.&lt;/p>
&lt;p>&lt;strong>PostgreSQL node&lt;/strong>: 16-core CPU, 128 GB RAM, 4 TB NVMe for the WAL (especially relevant if you use CDC with a logical replication slot; the slot retains WAL until Debezium consumes it).&lt;/p>
&lt;p>&lt;strong>Broker (if CDC)&lt;/strong>: 3-broker Kafka with 500 GB NVMe per node, or NATS JetStream with 3 nodes for more modest loads.&lt;/p>
&lt;hr>
&lt;h2 id="kubernetes-manifests">Kubernetes manifests&lt;/h2>
&lt;h3 id="indexer-deployment">Indexer Deployment&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-indexer&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-pipeline&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">spec&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">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&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">selector&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">matchLabels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-indexer&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">template&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">metadata&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">labels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-indexer&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">spec&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">containers&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">indexer&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">registry.example.com/rag-indexer:1.0.0&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">env&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">POSTGRES_DSN&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">valueFrom&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">secretKeyRef&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pg-credentials&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">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dsn&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">QDRANT_HOST&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qdrant-service.qdrant.svc.cluster.local&amp;#34;&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">EMBEDDING_ENDPOINT&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://tei-service.embeddings.svc.cluster.local:8080&amp;#34;&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">POLL_INTERVAL_MS&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;500&amp;#34;&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">requests&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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;512Mi&amp;#34;&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;500m&amp;#34;&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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2Gi&amp;#34;&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="reconciler-cronjob">Reconciler CronJob&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">batch/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CronJob&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-reconciler&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-pipeline&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">spec&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">schedule&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 * * * *&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># hourly&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">concurrencyPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Forbid&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">jobTemplate&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">spec&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">template&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">spec&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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&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">containers&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">reconciler&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">registry.example.com/rag-reconciler:1.0.0&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">env&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">POSTGRES_DSN&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">valueFrom&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">secretKeyRef&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pg-credentials&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">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dsn&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">QDRANT_HOST&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qdrant-service.qdrant.svc.cluster.local&amp;#34;&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">SCROLL_PAGE_SIZE&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1000&amp;#34;&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">requests&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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;256Mi&amp;#34;&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;250m&amp;#34;&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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1Gi&amp;#34;&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="production-gotchas">Production gotchas&lt;/h2>
&lt;h3 id="reindexing-when-the-embedding-model-changes">Reindexing when the embedding model changes&lt;/h3>
&lt;p>This is the most painful problem. If you move from &lt;code>bge-m3&lt;/code> to &lt;code>nomic-embed-text-v2&lt;/code>, the vectors are &lt;strong>incompatible&lt;/strong>: they live in different embedding spaces and the cosine distances between them are meaningless.&lt;/p>
&lt;p>The answer is &lt;strong>dual-index aliasing&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>Create a new collection in Qdrant: &lt;code>corpus_v2&lt;/code>.&lt;/li>
&lt;li>Re-embed the whole corpus with the new model and load &lt;code>corpus_v2&lt;/code>.&lt;/li>
&lt;li>When the new collection is complete and validated (recall test), switch the &lt;code>corpus_prod&lt;/code> alias from &lt;code>corpus_v1&lt;/code> to &lt;code>corpus_v2&lt;/code>.&lt;/li>
&lt;li>Delete &lt;code>corpus_v1&lt;/code> once traffic has migrated.&lt;/li>
&lt;/ol>
&lt;p>During the migration the two indexes coexist. The retriever uses the alias, not the collection&amp;rsquo;s direct name.&lt;/p>
&lt;h3 id="index-versioning">Index versioning&lt;/h3>
&lt;p>Store &lt;code>model_version&lt;/code> in the payload of every point in Qdrant. This lets you:&lt;/p>
&lt;ul>
&lt;li>Filter by version during retrieval (useful in model A/B testing).&lt;/li>
&lt;li>Have the reconciler detect points with an old version and reprocess them selectively.&lt;/li>
&lt;li>Audit: know which model generated each embedding.&lt;/li>
&lt;/ul>
&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="c1"># Filter by model version at retrieval time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&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">query_vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&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">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;model_version&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0.0&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">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tenant_id&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="p">),&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">10&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="namespace-per-tenant-multi-tenancy">Namespace per tenant (multi-tenancy)&lt;/h3>
&lt;p>There are two strategies in Qdrant:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Pros&lt;/th>
&lt;th>Cons&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Collection per tenant&lt;/strong>&lt;/td>
&lt;td>Total isolation, no extra filter&lt;/td>
&lt;td>N collections = N HNSW indexes in RAM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Payload filter per tenant&lt;/strong>&lt;/td>
&lt;td>A single collection, less RAM&lt;/td>
&lt;td>The filter adds ~10-15% search latency&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For fewer than 100 tenants with large corpora (&amp;gt; 1M chunks/tenant), use a collection per tenant. For hundreds or thousands of tenants with small corpora, use a payload filter with &lt;code>tenant_id&lt;/code> indexed:&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="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_payload_index&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">field_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tenant_id&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">field_schema&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">PayloadSchemaType&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">KEYWORD&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;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Streaming corpus updates with near-real-time CDC&lt;/strong>: selective chunk invalidation when only one section of a document changes (incremental chunking based on content diff, not full re-chunking).&lt;/li>
&lt;li>&lt;strong>Multi-tenant corpus isolation with per-chunk ACLs&lt;/strong>: going beyond the &lt;code>tenant_id&lt;/code> filter towards group, role or even individual document permissions applied at retrieval time.&lt;/li>
&lt;li>&lt;strong>Federated corpus&lt;/strong>: corpora distributed across cross-border silos where regulation (GDPR, CCPA) prevents centralising the embeddings; federated search patterns without moving data.&lt;/li>
&lt;li>&lt;strong>Zero-downtime incremental reindexing using dual-index aliasing&lt;/strong>: the full model migration protocol with rollback, recall regression testing and progressive traffic splitting.&lt;/li>
&lt;/ul>
&lt;hr>
&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 full ingestion pipeline (parsing, chunking, dedup, metadata) around this synchronisation.&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> — ingestion and index building are CPU work, not GPU work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">The curated corpus this architecture has to index&lt;/a> — curation and filtering strategies before ingestion.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">The retrieval that consumes this vector store&lt;/a> — how the reranker and hybrid retrieval use what we build here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">The embedder that generates the vectors&lt;/a> — a comparison of bge-m3, nomic-embed-text-v2 and multivector models.&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 Data stage of the LLMOps master map&lt;/a> — where this pipeline sits within the full cycle.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Versioning the raw corpus before ingestion&lt;/a> — how DVC and LakeFS manage lineage before anything reaches PostgreSQL.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/">Debezium and CDC: the notary who listens to changes before anyone asks for them&lt;/a> — the CDC deep dive this article introduces: the Postgres WAL, replication slots, pgoutput and the full comparison with the outbox pattern.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>HuggingFace Text Embeddings Inference — benchmarks oficiales con modelos de la familia bge en hardware A100/H100. &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a>&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></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><item><title>Reranker and hybrid retrieval: the committee that decides the 5 chunks the LLM will actually read</title><link>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/</link><pubDate>Mon, 25 May 2026 14:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/</guid><description>&lt;blockquote>
&lt;p>This post takes apart the retrieval layer inside the RAG piece of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It sits directly on top of the &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">corpus curation post&lt;/a>: if the librarian decided &lt;strong>what enters the index&lt;/strong>, the committee in this post decides &lt;strong>what leaves the index for the LLM&amp;rsquo;s face&lt;/strong>. It is the stage that moves the real quality of a RAG in production the most, and the one most teams solve with &amp;ldquo;dense top-k and off we go&amp;rdquo;.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The most widespread antipattern in real RAG is: curated corpus, dense embeddings with &lt;code>bge-m3&lt;/code>, the user&amp;rsquo;s query straight through, &lt;code>top_k=5&lt;/code> of cosine similarity and off to the LLM. The system delivers answers that look good in the demo, mediocre with real traffic and dreadful with three-word queries, technical jargon, internal abbreviations or anything outside the embedder&amp;rsquo;s fine-tuning domain. The root cause is almost never the model: it is that &lt;strong>a single pass of dense retrieval over the raw query is structurally insufficient&lt;/strong> for the cases a real user produces. In 2026 the field has consolidated the &lt;strong>three-layer retrieval&lt;/strong> pattern: a broad layer that mixes dense + sparse (BM25 / SPLADE) via Reciprocal Rank Fusion to secure recall, a fine layer with a &lt;strong>cross-encoder reranker&lt;/strong> (BAAI/bge-reranker-v2-m3, Cohere Rerank 3, mxbai-rerank-large) that reorders the 30-50 candidates by true relevance, and optionally a late interaction layer with ColBERT-v2 or an LLM reranker for critical cases. On top of it all, a &lt;strong>query rewriting&lt;/strong> pattern that rewrites malformed queries and HyDE, which generates a hypothetical document so you embed the &lt;em>expected answer&lt;/em> instead of the &lt;em>question&lt;/em>. This post goes through them one by one: why each layer exists, what latency budget it consumes, the minimum maths of RRF and of the recall/precision trade-off, the dominant OSS stack in 2026, the on-premise hardware for serving it under data sovereignty, and the seven operational traps that kill the stage.&lt;/p>
&lt;h2 id="the-analogy-the-committee-for-a-professorial-chair">The analogy: the committee for a professorial chair&lt;/h2>
&lt;p>A university department has a chair to fill and receives 3,000 applications. Nobody is going to read 3,000 CVs in depth. The real process is organised in rounds:&lt;/p>
&lt;p>&lt;strong>Round 1, mass pre-screening.&lt;/strong> A system of automatic filters reads each CV in seconds. One route looks at the keywords of the post (publications in specific journals, languages, certifications); another route looks at the overall profile (research area, h-index, career). The two routes run in parallel. Each proposes its shortlist of 30-50 candidates. The two rankings are combined and what remains is a list of some 50-100 candidates for the next round to act on. The metric that matters here is &lt;strong>recall&lt;/strong>: not losing the good candidate through a filter error.&lt;/p>
&lt;p>&lt;strong>Round 2, careful reading.&lt;/strong> A member of the panel reads each CV on the shortlist thoroughly and scores it against the real profile of the post. It is slow, 10-20 minutes per CV, but it discriminates far better than the automatic filter. It reorders the 100 candidates into a fine ranking and keeps the 5 finalists. The metric that matters here is &lt;strong>precision&lt;/strong>: that the top-5 is genuinely the real top-5.&lt;/p>
&lt;p>&lt;strong>Round 3 (optional), personal interview.&lt;/strong> Long interviews are held for the 5 finalists. Face to face, questions adapted to each candidate&amp;rsquo;s profile, a check against the reality of the post. It is extremely expensive and very slow, but for critical chairs it justifies the cost.&lt;/p>
&lt;p>The retrieval of a modern RAG works exactly like this. Change the vocabulary and the structure holds:&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="RAG retrieval as a committee for a professorial chair">
&lt;style>
.rbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.rhead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.rspar{fill:#a8d5a8;stroke:#444;stroke-width:1.4;rx:8}
.rdens{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.rfuse{fill:#d8a8ff;stroke:#444;stroke-width:1.4;rx:8}
.rrer{fill:#ffb86b;stroke:#444;stroke-width:1.4;rx:8}
.rllm{fill:#ffe18a;stroke:#444;stroke-width:1.4;rx:8}
.rfin{fill:#f4f4f4;stroke:#444;stroke-width:1.4;rx:8}
.rblt{font:600 13px sans-serif;fill:#222}
.rsub{font:400 11px sans-serif;fill:#444}
.rarr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mr1)}
.rlbl{font:600 11px sans-serif;fill:#555}
&lt;/style>
&lt;defs>&lt;marker id="mr1" 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="20" width="160" height="60" class="rhead"/>
&lt;text x="100" y="44" text-anchor="middle" class="rblt">User query&lt;/text>
&lt;text x="100" y="62" text-anchor="middle" class="rsub">"how do I cancel subscription"&lt;/text>
&lt;text x="100" y="76" text-anchor="middle" class="rsub">+ rewriter / HyDE&lt;/text>
&lt;rect x="220" y="20" width="160" height="60" class="rspar"/>
&lt;text x="300" y="42" text-anchor="middle" class="rblt">Layer 1a · Sparse&lt;/text>
&lt;text x="300" y="60" text-anchor="middle" class="rsub">BM25 / SPLADE&lt;/text>
&lt;text x="300" y="76" text-anchor="middle" class="rsub">top-50 lexical&lt;/text>
&lt;rect x="220" y="100" width="160" height="60" class="rdens"/>
&lt;text x="300" y="122" text-anchor="middle" class="rblt">Layer 1b · Dense&lt;/text>
&lt;text x="300" y="140" text-anchor="middle" class="rsub">bi-encoder (bge-m3)&lt;/text>
&lt;text x="300" y="156" text-anchor="middle" class="rsub">top-50 semantic&lt;/text>
&lt;rect x="420" y="60" width="150" height="60" class="rfuse"/>
&lt;text x="495" y="82" text-anchor="middle" class="rblt">Layer 2 · Fusion&lt;/text>
&lt;text x="495" y="100" text-anchor="middle" class="rsub">Reciprocal Rank Fusion&lt;/text>
&lt;text x="495" y="116" text-anchor="middle" class="rsub">→ top-30 combined&lt;/text>
&lt;rect x="600" y="60" width="160" height="60" class="rrer"/>
&lt;text x="680" y="82" text-anchor="middle" class="rblt">Layer 3 · Reranker&lt;/text>
&lt;text x="680" y="100" text-anchor="middle" class="rsub">cross-encoder bge-rerank-v2&lt;/text>
&lt;text x="680" y="116" text-anchor="middle" class="rsub">→ top-5 reordered&lt;/text>
&lt;rect x="280" y="200" width="220" height="60" class="rllm"/>
&lt;text x="390" y="222" text-anchor="middle" class="rblt">Layer 4 (optional) · ColBERT / LLM reranker&lt;/text>
&lt;text x="390" y="240" text-anchor="middle" class="rsub">late interaction or LLM judge&lt;/text>
&lt;text x="390" y="256" text-anchor="middle" class="rsub">for critical cases&lt;/text>
&lt;rect x="280" y="290" width="220" height="60" class="rfin"/>
&lt;text x="390" y="312" text-anchor="middle" class="rblt">LLM context&lt;/text>
&lt;text x="390" y="330" text-anchor="middle" class="rsub">5 chunks ready to answer&lt;/text>
&lt;text x="390" y="346" text-anchor="middle" class="rsub">~2,000 tokens&lt;/text>
&lt;path class="rarr" d="M180,40 L220,40"/>
&lt;path class="rarr" d="M180,60 L220,120"/>
&lt;path class="rarr" d="M380,40 L420,80"/>
&lt;path class="rarr" d="M380,120 L420,100"/>
&lt;path class="rarr" d="M570,90 L600,90"/>
&lt;path class="rarr" d="M680,120 L390,200"/>
&lt;path class="rarr" d="M390,260 L390,290"/>
&lt;text x="200" y="200" class="rlbl">broad recall&lt;/text>
&lt;text x="200" y="216" class="rlbl">~50-100ms total&lt;/text>
&lt;text x="450" y="40" class="rlbl">k=60 typical&lt;/text>
&lt;text x="630" y="50" class="rlbl">fine precision&lt;/text>
&lt;text x="630" y="66" class="rlbl">~80-300ms&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The equivalence is exact. The &lt;strong>broad layer&lt;/strong> guarantees that the right chunk is on the shortlist (recall). The &lt;strong>fine layer&lt;/strong> orders well inside the shortlist (precision). Each layer is justified by an operational asymmetry: the broad one is cheap and parallelises, the fine one is expensive and sequential. Solving everything with the expensive one, a single cross-encoder over the whole corpus, is operationally unsustainable. Solving everything with the cheap one, a single dense top-k, is functionally insufficient.&lt;/p>
&lt;h2 id="the-problem-why-dense-on-its-own-is-not-enough">The problem: why dense on its own is not enough&lt;/h2>
&lt;p>The argument against dense-only is not theoretical. It is that the queries a real user produces have patterns the dense bi-encoder &lt;em>does not capture well by construction&lt;/em>:&lt;/p>
&lt;p>&lt;strong>Short queries.&lt;/strong> &amp;ldquo;IRPF dates 2026&amp;rdquo; goes through the embedder and produces a vector close to any chunk that mentions dates and IRPF, including chunks from 2023, chunks of generic calendars, chunks of corporate IRPF when you were asking about personal income tax. &lt;strong>BM25 disambiguates better&lt;/strong> because the weight of the infrequent terms (&amp;ldquo;2026&amp;rdquo;, &amp;ldquo;IRPF&amp;rdquo;) dominates the score.&lt;/p>
&lt;p>&lt;strong>Infrequent or internal technical terms.&lt;/strong> Product codes (&lt;code>FBR-X42-PRO&lt;/code>), organisation abbreviations (&lt;code>PYM-23-bis&lt;/code>), internal variable names. The embedder, trained on a public corpus, breaks them down into sub-tokens and pushes them into the &amp;ldquo;noisy&amp;rdquo; zone of the latent space. Two completely different codes can end up 0.02 cosine distance apart. &lt;strong>BM25 treats them as exact tokens&lt;/strong> and scores them correctly.&lt;/p>
&lt;p>&lt;strong>Polysemy.&lt;/strong> &amp;ldquo;Java&amp;rdquo; as a language vs Java as an island vs Java as coffee. The dense embedding averages the meanings; the ranking becomes mediocre. Here dense can beat BM25 &lt;em>if&lt;/em> the query carries enough context, but that &amp;ldquo;if&amp;rdquo; is rarely met with real user queries.&lt;/p>
&lt;p>&lt;strong>Synonyms and rephrasings.&lt;/strong> &amp;ldquo;How do I cancel my subscription&amp;rdquo; vs a chunk titled &amp;ldquo;Service termination procedure&amp;rdquo;. Here dense beats BM25: the semantic space brings them together even though they share no tokens. It is exactly the case where dense shines and BM25 fails.&lt;/p>
&lt;p>The operational conclusion: &lt;strong>no single retriever covers the four cases&lt;/strong>. The combination covers them all. And for the cases where both retrievers fail (very ambiguous queries, contradictory context between chunks), the &lt;strong>third layer&lt;/strong> comes in: the reranker that reads the query and each chunk together with a cross-encoder, and produces a &lt;em>real&lt;/em> relevance score far more discriminating than cosine similarity.&lt;/p>
&lt;h2 id="layer-1--hybrid-retrieval-dense--sparse">Layer 1 — Hybrid retrieval: dense + sparse&lt;/h2>
&lt;h3 id="why-bm25-is-still-alive-in-2026">Why BM25 is still alive in 2026&lt;/h3>
&lt;p>BM25 is from 1994. It is not deep learning. It needs no GPU. And it is still competitive with dense retrievers of hundreds of millions of parameters on many retrieval benchmarks (&lt;a href="https://github.com/beir-cellar/beir">BEIR&lt;/a>, 2021-2024). Its formula is simple:&lt;/p>
$$
\mathrm{BM25}(q, d) = \sum_{t \in q} \mathrm{IDF}(t) \cdot \frac{\mathrm{tf}(t,d) \cdot (k_1 + 1)}{\mathrm{tf}(t,d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\overline{|d|}})}
$$
&lt;p>Where $\mathrm{tf}(t,d)$ is the frequency of term $t$ in document $d$, $|d|$ the document length, $\overline{|d|}$ the mean length of the corpus, and IDF the inverse document frequency. $k_1$ and $b$ are hyperparameters (typically $k_1 = 1.2$, $b = 0.75$). In practice you use &lt;code>pyserini&lt;/code>, &lt;code>Tantivy&lt;/code>, &lt;code>Elasticsearch&lt;/code> or &lt;code>OpenSearch&lt;/code> and never write the formula.&lt;/p>
&lt;p>SPLADE (&lt;a href="https://arxiv.org/abs/2107.05720">Formal et al., 2021&lt;/a>) is the neural generation of BM25: it learns sparse weights over the BERT vocabulary, expanding each token to its related terms. It keeps BM25&amp;rsquo;s interpretability (you can see which query terms match which document terms) and beats plain BM25 on BEIR. The price is that you need GPU inference over the query (and over each document at indexing time, once).&lt;/p>
&lt;p>The rule of thumb in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Corpus &amp;lt; 10M chunks, queries with a broad vocabulary&lt;/strong>: plain BM25 via Elasticsearch / Tantivy. Trivial indexing, 1-5ms queries.&lt;/li>
&lt;li>&lt;strong>Corpus 10M-100M, specific domain&lt;/strong>: SPLADE-v3 indexed in Elasticsearch or Vespa. Slow indexing (you need a GPU once), 5-20ms queries.&lt;/li>
&lt;li>&lt;strong>Corpus &amp;gt; 100M, latency critical&lt;/strong>: BM25 with external query expansion (synonyms, query2doc) is the operational option, with SPLADE reserved for the critical segments.&lt;/li>
&lt;/ul>
&lt;h3 id="dense-bi-encoder-the-second-lane">Dense bi-encoder: the second lane&lt;/h3>
&lt;p>The bi-encoder produces one vector per chunk and another per query, and relevance is the cosine similarity (or dot product) between the two. The dominant multilingual model in 2026 is &lt;code>BAAI/bge-m3&lt;/code>: 568M params, 1024 dimensions, support for up to 8,192 context tokens per chunk, multivector (dense + sparse + ColBERT-style multi-vec in the same embedder), trained on 100+ languages. Alternatives:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Embedder&lt;/th>
&lt;th style="text-align:right">Params&lt;/th>
&lt;th style="text-align:right">Dim&lt;/th>
&lt;th style="text-align:center">Multilingual&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-m3&lt;/code>&lt;/td>
&lt;td style="text-align:right">568M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>the default all-rounder in 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>intfloat/multilingual-e5-large-instruct&lt;/code>&lt;/td>
&lt;td style="text-align:right">560M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>direct competitor, instruct-style queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nomic-ai/nomic-embed-text-v1.5&lt;/code>&lt;/td>
&lt;td style="text-align:right">137M&lt;/td>
&lt;td style="text-align:right">768&lt;/td>
&lt;td style="text-align:center">English&lt;/td>
&lt;td>fast, variable Matryoshka dimension&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-embeddings-v3&lt;/code>&lt;/td>
&lt;td style="text-align:right">570M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>with task-specific LoRAs per domain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixedbread-ai/mxbai-embed-large-v1&lt;/code>&lt;/td>
&lt;td style="text-align:right">335M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">English&lt;/td>
&lt;td>top English on MTEB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Snowflake/arctic-embed-l-v2.0&lt;/code>&lt;/td>
&lt;td style="text-align:right">568M&lt;/td>
&lt;td style="text-align:right">1024&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td>enterprise-oriented, Apache licence&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical selection criterion: &lt;strong>multilingual no matter what if your corpus or queries need it&lt;/strong> (&lt;code>bge-m3&lt;/code>, &lt;code>multilingual-e5&lt;/code>, &lt;code>jina-v3&lt;/code>, &lt;code>arctic-l-v2&lt;/code>), Matryoshka if you want to save vector store memory by reducing dimensions without re-embedding (&lt;code>nomic&lt;/code>), and the MTEB leaderboard ranking only as a &lt;strong>tie-breaker&lt;/strong>, never as the sole criterion, since MTEB benchmarks are contaminable and a 1-2 point difference rarely translates into a real improvement in your domain.&lt;/p>
&lt;h3 id="the-fusion-reciprocal-rank-fusion-rrf">The fusion: Reciprocal Rank Fusion (RRF)&lt;/h3>
&lt;p>You have two rankings, one from BM25 and one from dense. How do you combine them? The naive option, adding the scores, fails because the scores are not comparable (BM25 produces numbers of 0-30, cosine 0-1, L2 distances 0-2…). The option that has consolidated is Reciprocal Rank Fusion (&lt;a href="https://dl.acm.org/doi/10.1145/1571941.1572114">Cormack, Clarke, Buettcher 2009&lt;/a>), which ignores absolute scores and uses only the &lt;em>rankings&lt;/em>:&lt;/p>
$$
\mathrm{RRF}(d) = \sum_{r \in R} \frac{1}{k + \mathrm{rank}_r(d)}
$$
&lt;p>Where $R$ is the set of retrievers and $\mathrm{rank}_r(d)$ is the position of document $d$ in the ranking of retriever $r$. The constant $k$ is typically $60$ and smooths the weight of the first positions.&lt;/p>
&lt;p>A numerical example. You have BM25 with top-5: &lt;code>[d_a, d_b, d_c, d_d, d_e]&lt;/code> and dense with top-5: &lt;code>[d_b, d_f, d_a, d_g, d_h]&lt;/code>. You compute the RRF of each candidate:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Doc&lt;/th>
&lt;th style="text-align:right">BM25 rank&lt;/th>
&lt;th style="text-align:right">dense rank&lt;/th>
&lt;th style="text-align:right">$\frac{1}{60+r_{\text{BM25}}}$&lt;/th>
&lt;th style="text-align:right">$\frac{1}{60+r_{\text{dense}}}$&lt;/th>
&lt;th style="text-align:right">total RRF&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>d_b&lt;/code>&lt;/td>
&lt;td style="text-align:right">2&lt;/td>
&lt;td style="text-align:right">1&lt;/td>
&lt;td style="text-align:right">0.01613&lt;/td>
&lt;td style="text-align:right">0.01639&lt;/td>
&lt;td style="text-align:right">0.03252&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_a&lt;/code>&lt;/td>
&lt;td style="text-align:right">1&lt;/td>
&lt;td style="text-align:right">3&lt;/td>
&lt;td style="text-align:right">0.01639&lt;/td>
&lt;td style="text-align:right">0.01587&lt;/td>
&lt;td style="text-align:right">0.03226&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_c&lt;/code>&lt;/td>
&lt;td style="text-align:right">3&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">0.01587&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;td style="text-align:right">0.01587&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_f&lt;/code>&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">2&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;td style="text-align:right">0.01613&lt;/td>
&lt;td style="text-align:right">0.01613&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>d_d&lt;/code>&lt;/td>
&lt;td style="text-align:right">4&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">0.01562&lt;/td>
&lt;td style="text-align:right">0&lt;/td>
&lt;td style="text-align:right">0.01562&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>d_b&lt;/code> wins because it appears well ranked in both. &lt;code>d_a&lt;/code> follows it. Those that appear in only one list stay below. With nothing tuned, RRF tends to push the &lt;em>consensus&lt;/em> candidates to the top, which is exactly what you want as a coarse filter before the reranker.&lt;/p>
&lt;p>Variants: weighted RRF assigns a multiplier per retriever ($w_r \cdot \frac{1}{k + \mathrm{rank}_r(d)}$) when you have reasons to trust one more; learning-to-rank trains a model (LambdaMART or a LightGBM ranker) over features from the two retrievers, a more powerful option but one that demands relevance labels most teams do not have.&lt;/p>
&lt;p>In Qdrant, Vespa, Weaviate and Elastic 8.11+ the RRF fusion is built in as a native mode: you ask for hybrid search with a lexical query and a vector and they return the combined top-k without you having to compute the fusion.&lt;/p>
&lt;h2 id="layer-2--the-reranker-bi-encoder-vs-cross-encoder">Layer 2 — The reranker: bi-encoder vs cross-encoder&lt;/h2>
&lt;p>The bi-encoder looks at query and document &lt;em>separately&lt;/em> and compares the resulting vectors. It is extremely fast (a dot product) and lets you index the document embeddings once and for all. The price is that the model never sees query and document together, and the compressed representation loses nuance.&lt;/p>
&lt;p>The cross-encoder looks at query and document &lt;em>concatenated&lt;/em>: &lt;code>[CLS] query [SEP] document [SEP]&lt;/code> goes through the model and the output is a relevance score. It discriminates far better, since shades of ordering and of local relationship between terms are captured, but the cost explodes: each (query, doc) pair requires a full inference of the model. You cannot precompute anything. If you want to rerank 50 candidates, that is 50 inferences.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>Bi-encoder&lt;/th>
&lt;th>Cross-encoder&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inference per chunk at indexing&lt;/td>
&lt;td>yes, once&lt;/td>
&lt;td>not precomputable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inference at query time&lt;/td>
&lt;td>1 (query) + dot product per chunk&lt;/td>
&lt;td>1 per (query, chunk) pair&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical latency for top-50&lt;/td>
&lt;td>5-20 ms (CPU possible)&lt;/td>
&lt;td>80-300 ms (GPU recommended)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Discrimination&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical use&lt;/td>
&lt;td>recall (layer 1)&lt;/td>
&lt;td>precision (layer 2)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This asymmetry is why the canonical pattern is a &lt;strong>two-step funnel&lt;/strong>: the bi-encoder does cheap broad recall and the cross-encoder does expensive fine precision, over 30-50 candidates instead of millions.&lt;/p>
&lt;h3 id="2026-oss-cross-encoder-reranker-stack">2026 OSS cross-encoder reranker stack&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Reranker&lt;/th>
&lt;th style="text-align:right">Params&lt;/th>
&lt;th style="text-align:center">Multilingual&lt;/th>
&lt;th style="text-align:right">top-50 latency (A100)&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-reranker-v2-m3&lt;/code>&lt;/td>
&lt;td style="text-align:right">568M&lt;/td>
&lt;td style="text-align:center">yes (100+ languages)&lt;/td>
&lt;td style="text-align:right">~90 ms&lt;/td>
&lt;td>the de facto default in 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>BAAI/bge-reranker-v2-gemma&lt;/code>&lt;/td>
&lt;td style="text-align:right">2B&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~250 ms&lt;/td>
&lt;td>more quality, more cost&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixedbread-ai/mxbai-rerank-large-v2&lt;/code>&lt;/td>
&lt;td style="text-align:right">1.5B&lt;/td>
&lt;td style="text-align:center">English&lt;/td>
&lt;td style="text-align:right">~180 ms&lt;/td>
&lt;td>top English on BEIR rerank&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>jinaai/jina-reranker-v2-base-multilingual&lt;/code>&lt;/td>
&lt;td style="text-align:right">278M&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~50 ms&lt;/td>
&lt;td>fast and enough for many cases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Alibaba-NLP/gte-multilingual-reranker-base&lt;/code>&lt;/td>
&lt;td style="text-align:right">306M&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~55 ms&lt;/td>
&lt;td>direct competitor to jina&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Cohere Rerank 3&lt;/code>&lt;/td>
&lt;td style="text-align:right">(closed)&lt;/td>
&lt;td style="text-align:center">yes (100+ languages)&lt;/td>
&lt;td style="text-align:right">~120 ms (API)&lt;/td>
&lt;td>commercial, not on-prem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Voyage rerank-2&lt;/code>&lt;/td>
&lt;td style="text-align:right">(closed)&lt;/td>
&lt;td style="text-align:center">yes&lt;/td>
&lt;td style="text-align:right">~100 ms (API)&lt;/td>
&lt;td>commercial, not on-prem&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For deployments under data sovereignty the obvious choice is &lt;strong>&lt;code>bge-reranker-v2-m3&lt;/code>&lt;/strong> as a starting point, with &lt;code>jina-reranker-v2-base&lt;/code> as the lightweight alternative. If the domain is exclusively English and the hardware budget allows it, &lt;code>mxbai-rerank-large-v2&lt;/code> takes a point or two more on BEIR. Cohere and Voyage are the option when you can reach out to an external API, ruled out in ENS / NIS2 scenarios with sensitive data.&lt;/p>
&lt;h3 id="late-interaction-colbert-v2-as-a-compromise">Late interaction: ColBERT-v2 as a compromise&lt;/h3>
&lt;p>There is a third option gaining traction: late interaction, embodied in ColBERT-v2 (&lt;a href="https://arxiv.org/abs/2112.01488">Santhanam et al., 2022&lt;/a>). The idea is that the document is embedded not as a single vector but as a &lt;em>matrix&lt;/em> of embeddings, one per token. And at query time the query is also embedded as a matrix, and the similarity is the sum of the maxima per column (&lt;code>MaxSim&lt;/code>):&lt;/p>
$$
S(q, d) = \sum_{i \in q} \max_{j \in d} \langle E_q[i], E_d[j] \rangle
$$
&lt;p>The result is retrieval with quality close to a cross-encoder but at a much lower cost (there is no need to run the whole model over each pair). The price is space: each chunk is ~150-200 vectors instead of 1, and that multiplies the index size by ~100. For small to medium corpora (&amp;lt;10M chunks) it is manageable; for huge corpora &lt;code>PLAID&lt;/code> compression and &lt;code>ColBERT-v2 quantization&lt;/code> make it viable up to 100M.&lt;/p>
&lt;p>In 2026 the reference implementations are &lt;code>RAGatouille&lt;/code> (Python wrapper), &lt;code>JaColBERT&lt;/code> (Japanese), &lt;code>Vespa.ai&lt;/code> (a search engine with native ColBERT support as a first-class citizen), and the native integration in &lt;code>bge-m3&lt;/code> that produces the ColBERT-style multivectors as part of the same dense model.&lt;/p>
&lt;p>My operational recommendation: &lt;strong>start with a cross-encoder reranker&lt;/strong> over the top-30 of the hybrid retrieval. Only if the precision numbers fall short, try late interaction as an alternative to the cross-encoder (not as an additional layer). Late interaction as a &lt;em>third&lt;/em> layer rarely pays back the operational cost for most cases.&lt;/p>
&lt;h2 id="query-rewriting-and-hyde-correcting-the-query-before-retrieval">Query rewriting and HyDE: correcting the query before retrieval&lt;/h2>
&lt;p>The user&amp;rsquo;s query is the worst-controlled input in the whole system. The most frequent problems:&lt;/p>
&lt;p>&lt;strong>Queries that are too short&lt;/strong>: &amp;ldquo;GDPR fines&amp;rdquo; → retrieves any chunk about GDPR or about fines.&lt;/p>
&lt;p>&lt;strong>Queries with typos or jargon&lt;/strong>: &amp;ldquo;i wnat to know how to cancel the premium subscripton&amp;rdquo; → the embedder is noisy because of the typos.&lt;/p>
&lt;p>&lt;strong>Multi-intent queries&lt;/strong>: &amp;ldquo;compare the premium and enterprise plans and tell me which has the better SLA&amp;rdquo; → dense will try to embed the three questions into one vector.&lt;/p>
&lt;p>&lt;strong>Queries with references resolved in context&lt;/strong>: &amp;ldquo;and for next month?&amp;rdquo; → without the previous context, dense does not know what it is about.&lt;/p>
&lt;p>&lt;strong>Canonical 2026 patterns to mitigate&lt;/strong>:&lt;/p>
&lt;p>&lt;strong>Query rewriting with a light LLM.&lt;/strong> Before retrieval, a small model (Qwen2.5-7B, Llama-3.2-3B, Phi-4-mini) rewrites the user&amp;rsquo;s query into a &amp;ldquo;canonical&amp;rdquo; version: it expands abbreviations, fixes typos, breaks multi-intent into sub-queries, resolves references using the conversation history. Cost: one cheap inference (~50-100 ms). Benefit: the four pathologies above are noticeably reduced.&lt;/p>
&lt;p>&lt;strong>Multi-query&lt;/strong>: the LLM generates 3-5 rephrasings of the query and you retrieve with each one; then you fuse with RRF. It increases recall at the cost of time and LLM tokens.&lt;/p>
&lt;p>&lt;strong>Step-back prompting&lt;/strong> (&lt;a href="https://arxiv.org/abs/2310.06117">Zheng et al., 2023&lt;/a>): the LLM generates a &lt;em>more general&lt;/em> question than the original query (&amp;ldquo;what is a premium subscription in this system?&amp;rdquo;), you retrieve with both and combine. It helps with very specific queries that need context.&lt;/p>
&lt;p>&lt;strong>HyDE — Hypothetical Document Embeddings&lt;/strong> (&lt;a href="https://arxiv.org/abs/2212.10496">Gao et al., 2022&lt;/a>): instead of embedding the question, you ask a light LLM to generate a &lt;em>hypothetical answer&lt;/em> and you embed that. The reasoning is that the semantic space of answers is closer to that of the real chunks than the space of questions. It works especially well in domains with very specific vocabulary. Cost: one inference of the light LLM (~100-200 ms). Benefit: in many cases +5-15 points of Recall@10.&lt;/p>
&lt;p>The operational trade-off is the added latency. Each technique adds 50-300 ms to the pipeline. In synchronous interactions with a 2s SLO there is still headroom; in agentic interactions with tool-use loops, every ms counts. The practical choice in 2026: &lt;strong>rewriting with a light LLM as the default&lt;/strong>, &lt;strong>HyDE for specific cases where it has been measured to help&lt;/strong>, &lt;strong>multi-query and step-back only when the case justifies it with numbers&lt;/strong>.&lt;/p>
&lt;h2 id="minimum-maths-latency-budget-and-metrics">Minimum maths: latency budget and metrics&lt;/h2>
&lt;h3 id="latency-budget-of-a-canonical-pipeline">Latency budget of a canonical pipeline&lt;/h3>
&lt;p>A typical hybrid + rerank pipeline in 2026 production over a corpus of 5M chunks with bge-m3 + bge-reranker-v2-m3 on an H100 with TEI:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th style="text-align:right">p50 latency&lt;/th>
&lt;th style="text-align:right">p95 latency&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Query rewrite (LLM 3B)&lt;/td>
&lt;td style="text-align:right">60 ms&lt;/td>
&lt;td style="text-align:right">150 ms&lt;/td>
&lt;td>only if enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HyDE (LLM 3B)&lt;/td>
&lt;td style="text-align:right">90 ms&lt;/td>
&lt;td style="text-align:right">220 ms&lt;/td>
&lt;td>only if enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BM25 / SPLADE top-50&lt;/td>
&lt;td style="text-align:right">8 ms&lt;/td>
&lt;td style="text-align:right">20 ms&lt;/td>
&lt;td>Elasticsearch or local Tantivy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense embedding of the query&lt;/td>
&lt;td style="text-align:right">12 ms&lt;/td>
&lt;td style="text-align:right">35 ms&lt;/td>
&lt;td>bge-m3 with batch=1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dense top-50 (filterable HNSW)&lt;/td>
&lt;td style="text-align:right">6 ms&lt;/td>
&lt;td style="text-align:right">18 ms&lt;/td>
&lt;td>Qdrant with scalar quantization&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RRF fusion → top-30&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td>arithmetic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cross-encoder rerank top-30&lt;/td>
&lt;td style="text-align:right">60 ms&lt;/td>
&lt;td style="text-align:right">180 ms&lt;/td>
&lt;td>bge-reranker-v2-m3 batched&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Top-5 selection + format&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td style="text-align:right">&amp;lt;1 ms&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total without rewrite/HyDE&lt;/strong>&lt;/td>
&lt;td style="text-align:right">~90 ms&lt;/td>
&lt;td style="text-align:right">~270 ms&lt;/td>
&lt;td>realistic range on an H100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total with rewrite + HyDE&lt;/strong>&lt;/td>
&lt;td style="text-align:right">~240 ms&lt;/td>
&lt;td style="text-align:right">~640 ms&lt;/td>
&lt;td>+ ~150-370 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On an RTX 4090 (~3× slower than an H100 on a large cross-encoder, similar on everything else) the numbers shift: ~180 ms p50 without rewrite, ~400 ms p50 with rewrite + HyDE. It is perfectly serviceable for a synchronous assistant with a 2-3s SLO, but tight for one with a 500 ms SLO.&lt;/p>
&lt;h3 id="metrics-ndcg-mrr-recallk">Metrics: nDCG, MRR, Recall@k&lt;/h3>
&lt;p>There are three classic metrics that any retrieval eval reports:&lt;/p>
&lt;p>&lt;strong>Recall@k&lt;/strong>: is the relevant chunk among the top-k? A binary metric, it ignores ordering. The one that matters for the &lt;strong>broad layer&lt;/strong>.&lt;/p>
$$
\mathrm{Recall@k} = \frac{|\{\text{relevant}\} \cap \{\text{top-k}\}|}{|\{\text{relevant}\}|}
$$
&lt;p>&lt;strong>Mean Reciprocal Rank (MRR@k)&lt;/strong>: in which position does the first relevant chunk appear? It penalises placing it far down.&lt;/p>
$$
\mathrm{MRR@k} = \frac{1}{|Q|} \sum_{q \in Q} \frac{1}{\mathrm{rank}_q}
$$
&lt;p>where $\mathrm{rank}_q$ is the position of the first relevant item for query $q$ (or $\infty$ if it does not appear in the top-k).&lt;/p>
&lt;p>&lt;strong>nDCG@k&lt;/strong>: it takes into account multiple relevant chunks with degrees of relevance. The most used metric for evaluating rerankers:&lt;/p>
$$
\mathrm{DCG@k} = \sum_{i=1}^{k} \frac{2^{\mathrm{rel}_i} - 1}{\log_2(i+1)} \quad \mathrm{nDCG@k} = \frac{\mathrm{DCG@k}}{\mathrm{IDCG@k}}
$$
&lt;p>The operational heuristic: &lt;strong>Recall@50 measures the quality of your broad layer&lt;/strong> (we want &amp;gt; 95%; otherwise layer 2 will never be able to recover what was lost), &lt;strong>nDCG@5 measures the quality of your reranker&lt;/strong> (we want &amp;gt; 0.75 on the golden set to consider the system &amp;ldquo;good&amp;rdquo;) and &lt;strong>MRR@5 measures the quality of your top-1&lt;/strong> (it matters especially in systems with &lt;code>top_k=1&lt;/code> or where the LLM&amp;rsquo;s answer is based mainly on the first chunk).&lt;/p>
&lt;h3 id="costs-and-throughput">Costs and throughput&lt;/h3>
&lt;p>Assuming a bge-reranker-v2-m3 cross-encoder (568M params) on an H100 SXM with TEI 1.7 and dynamic batching:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Throughput&lt;/strong>: ~280 queries/sec with top-30 each (= ~8,400 chunk-query pairs/sec).&lt;/li>
&lt;li>&lt;strong>VRAM used&lt;/strong>: ~3.5 GB for the model + ~6 GB of KV cache + activations at batch=32.&lt;/li>
&lt;li>&lt;strong>Energy cost&lt;/strong>: an H100 SXM draws ~700 W. At 8,400 pairs/sec, the energy cost of pure reranking is ~0.083 mJ per pair. In Spanish industrial electricity bill terms (~0.12 €/kWh, May 2026): ~0.000003 € per rerank of 30 candidates. The bill is negligible compared with the cost of the downstream LLM.&lt;/li>
&lt;/ul>
&lt;p>On an RTX 4090 with TEI: ~95 queries/sec at top-30 (~2,850 pairs/sec), VRAM ~3.5 GB + ~5 GB. Serviceable for an internal assistant with modest traffic (~5 QPS sustained, peaks at 20-30).&lt;/p>
&lt;h2 id="the-canonical-pattern--which-piece-goes-where">The canonical pattern — which piece goes where&lt;/h2>
&lt;p>The 2026 operational reference stack for an on-premise hybrid + rerank RAG under data sovereignty:&lt;/p>
&lt;pre tabindex="0">&lt;code> ┌─────────────────┐
User query ────────▶│ Query rewriter │ (optional, LLM 3B)
│ (Qwen2.5-7B-IT) │
└────────┬────────┘
▼
┌─────────────────┐
│ HyDE │ (optional)
│ (same LLM) │
└────────┬────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ BM25/SPLADE │ │ Dense bi-enc │ │ (metadata │
│ Elasticsearch│ │ bge-m3 + TEI │ │ filters │
│ top-50 │ │ + Qdrant │ │ per tenant) │
│ │ │ top-50 │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
└─────────┬───────┘ │
▼ │
┌──────────────┐ │
│ RRF k=60 │◀─────────────────┘
│ → top-30 │
└──────┬───────┘
▼
┌──────────────────┐
│ Cross-encoder │
│ bge-reranker-v2 │
│ TEI batch=32 │
│ → final top-5 │
└──────┬───────────┘
▼
┌──────────────────┐
│ LLM context │
│ ~2,000 tokens │
└──────────────────┘
&lt;/code>&lt;/pre>&lt;p>Key services:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sparse index&lt;/strong>: Elasticsearch 8.16, OpenSearch 2.18 or embedded Tantivy. Native BM25, SPLADE optional via plugin.&lt;/li>
&lt;li>&lt;strong>Vector index&lt;/strong>: Qdrant 1.13 (filterable HNSW, scalar quantization, multivectors). Covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in ingestion post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Embedder serving&lt;/strong>: HuggingFace&amp;rsquo;s &lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI (Text Embeddings Inference)&lt;/a> for bge-m3, multilingual-e5 and compatible models. OpenAI-compatible endpoint.&lt;/li>
&lt;li>&lt;strong>Reranker serving&lt;/strong>: TEI too, for the rerankers of the bge-reranker, jina-reranker and mxbai-rerank families. Separate &lt;code>/rerank&lt;/code> endpoint.&lt;/li>
&lt;li>&lt;strong>LLM for rewriter/HyDE&lt;/strong>: vLLM 0.7+ serving Qwen2.5-7B-Instruct or Phi-4-mini-Instruct. If the main LLM is the same, it shares resources; if not, a dedicated pod with GPTQ-INT4 quantization.&lt;/li>
&lt;li>&lt;strong>Orchestration&lt;/strong>: the RAG client (FastAPI, LangChain, LlamaIndex) composes the three lanes. Each parallel call (Sparse + Dense) is made concurrently; rewriter, HyDE and rerank are sequential.&lt;/li>
&lt;/ul>
&lt;p>Minimum Kubernetes manifests are already developed in the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes post&lt;/a> and the full orchestration piece fits the pattern of the &lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">multi-tenant H100 cluster&lt;/a>.&lt;/p>
&lt;h2 id="on-premise-hardware-what-fits-in-an-rtx-4090-vs-a-generic-4h100-sxm-configuration">On-premise hardware: what fits in an RTX 4090 vs a generic 4×H100 SXM configuration&lt;/h2>
&lt;p>The operational reality matters. Two typical profiles:&lt;/p>
&lt;p>&lt;strong>Modest configuration — 1× RTX 4090 (24 GB Ada Lovelace)&lt;/strong>&lt;/p>
&lt;p>It comfortably serves:&lt;/p>
&lt;ul>
&lt;li>bge-m3 (568M) for dense embedding of the query → ~1 GB VRAM&lt;/li>
&lt;li>bge-reranker-v2-m3 (568M) cross-encoder → ~3.5 GB VRAM at batch=32&lt;/li>
&lt;li>A light LLM for rewriter/HyDE (Qwen2.5-7B GPTQ-INT4) → ~5-6 GB VRAM&lt;/li>
&lt;li>The main LLM (Llama-3.1-8B-Instruct FP8) → ~11-12 GB VRAM&lt;/li>
&lt;/ul>
&lt;p>Total VRAM used: ~21 GB. That leaves ~3 GB for the main LLM&amp;rsquo;s KV cache, enough for 4-8 concurrent users with ~4K token contexts. For an internal corporate assistant it is viable. If you need a larger context or more concurrency, you have to split things up: either the main LLM is served outside the 4090 (another GPU, another node), or you fall back on quantising the reranker (not advisable: you lose the nDCG points that justify the cost of adding it).&lt;/p>
&lt;p>&lt;strong>Generic configuration — 4×H100 SXM (320 GB total, NVLink)&lt;/strong>&lt;/p>
&lt;p>It comfortably serves everything above multiplied by 30-50× in throughput and opens the door to:&lt;/p>
&lt;ul>
&lt;li>A large main LLM (Llama 3.3 70B FP8 or Qwen2.5-72B FP8) on 2 GPUs with tensor parallelism&lt;/li>
&lt;li>Large rerankers (bge-reranker-v2-gemma 2B) with quality ~2 points higher&lt;/li>
&lt;li>Late interaction (ColBERT-v2) on one of the nodes for critical cases&lt;/li>
&lt;li>Several light LLMs in parallel for rewriter, HyDE and the evals judge&lt;/li>
&lt;/ul>
&lt;p>The practical rule: &lt;strong>the reranker is one of the most efficient things you can put on a GPU&lt;/strong>. The VRAM used is modest, the throughput is high, and the quality improvement per euro of extra hardware is among the best in the RAG stack.&lt;/p>
&lt;h2 id="the-seven-traps-that-kill-retrieval">The seven traps that kill retrieval&lt;/h2>
&lt;p>&lt;strong>Trap 1 — Top-k to the LLM with no reranker.&lt;/strong> Dense retrieval returns a top-5 and those 5 chunks go straight to the LLM. Without a reranker, the order of the 5 is the cosine similarity order, which is not the real relevance order. The LLM answers based much more on the first chunks than the last ones, and the nuance is lost. Symptom: confident answers with citations that are not the most relevant in the corpus.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Dense-only with no BM25.&lt;/strong> Short queries and queries with internal jargon work badly, the demos look pretty, real traffic complains. Symptom: on QA with real support tickets, the system fails specifically on queries of fewer than 5 words or with product codes.&lt;/p>
&lt;p>&lt;strong>Trap 3 — Reranker badly calibrated on top-k.&lt;/strong> A layer 1 top-k that is too small (top-10) cuts before the reranker can do its job. A top-k that is too large (top-200) blows up latency without improving nDCG. The canonical sweet spot is top-30 to top-50 from the hybrid → top-5 to top-10 from the reranker. Symptom: the reranker&amp;rsquo;s nDCG@5 stuck no matter which reranker model you try.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Embedder and reranker in different languages.&lt;/strong> The corpus is Spanish + Catalan, the embedder is &lt;code>all-MiniLM&lt;/code> (English only), the reranker is &lt;code>bge-reranker-v2-m3&lt;/code> (multilingual). The broad layer retrieves badly because of the embedder, and the reranker has little to rerank. Symptom: Recall@50 &amp;lt; 80%, unrecoverable by changing the reranker.&lt;/p>
&lt;p>&lt;strong>Trap 5 — RRF with &lt;code>k=1&lt;/code>.&lt;/strong> Someone reads the paper, copies the formula and sets &lt;code>k=1&lt;/code> &amp;ldquo;because they do not understand what it does&amp;rdquo;. The result: the first positions of the worst ranking (whichever it is) dominate. The fusion stops averaging and becomes &amp;ldquo;winner-takes-all&amp;rdquo;. Symptom: the final ranking is almost identical to that of just one of the retrievers.&lt;/p>
&lt;p>&lt;strong>Trap 6 — A query rewriter that changes the meaning.&lt;/strong> The LLM rewriter &amp;ldquo;improves&amp;rdquo; &lt;code>&amp;quot;how do I cancel&amp;quot;&lt;/code> into &lt;code>&amp;quot;termination procedure for the premium service for corporate customers&amp;quot;&lt;/code>. It retrieves chunks about corporate customers when the user was a private individual. Over-specification by the rewriter is worse than no rewriter. Symptom: queries from &amp;ldquo;normal&amp;rdquo; users come back worse than the old ones. Mitigation: the rewriter must keep the user&amp;rsquo;s intent and only add context, never assume.&lt;/p>
&lt;p>&lt;strong>Trap 7 — No retrieval telemetry.&lt;/strong> The system serves, the user complains, you do not know whether the fault was the corpus, the retrieval, the reranker or the LLM. Without emitting traces with &lt;code>retrieved_chunks_ids&lt;/code>, &lt;code>retrieved_chunks_scores&lt;/code>, &lt;code>rerank_scores&lt;/code>, &lt;code>query_rewritten_to&lt;/code>, &lt;code>selected_top_k&lt;/code>, debugging is theatre. Symptom: each incident takes days to investigate. The &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">tracing with OTel and MCP&lt;/a> piece covers the canonical pattern.&lt;/p>
&lt;p>All seven are operational. Just as with the corpus, retrieval does not break because the maths is wrong: it breaks because the discipline relaxes. And as in Eval, the metrics can go up while the real experience gets worse, because the golden set accommodates itself to the system instead of the system accommodating itself to the golden set.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Embedding model selection and fine-tuning&lt;/strong>: how to choose among the embedders on the MTEB leaderboard without falling into goodharting, when and how to fine-tune an embedder on your own domain with MNR loss or triplet loss, which synthetic dataset is generated with an LLM for training (GPL, InPars, Promptagator), and the gotchas of massive re-embedding when you change model.&lt;/li>
&lt;li>&lt;strong>Semantic cache for RAG&lt;/strong>: how to cache semantically similar queries to serve answers without going through retrieval or the LLM, GPTCache, MeanCache, and the cache&amp;rsquo;s precision/coverage trade-off. It saves 30-70% of the cost on workloads with repeated queries.&lt;/li>
&lt;li>&lt;strong>Multi-vector and ColBERT-v2 at scale&lt;/strong>: how PLAID and CITADEL indexes are designed to serve corpora of 100M+ chunks with late interaction without burning the memory budget.&lt;/li>
&lt;li>&lt;strong>RAG-specific eval — RAGAS deep dive&lt;/strong>: faithfulness, answer relevance, context precision, context recall, noise sensitivity. How the RAG-specific golden set is built (with chunks labelled as relevant/irrelevant) and which metrics correlate with real user satisfaction.&lt;/li>
&lt;li>&lt;strong>Function calling and tool-augmented retrieval&lt;/strong>: when the LLM decides which retriever to invoke (SQL for structured data, vector for unstructured, web search for real time), the ReAct pattern, handling tool errors.&lt;/li>
&lt;li>&lt;strong>Agentic retrieval loops&lt;/strong>: when a single retrieval pass is not enough and the agent iterates (planning, sub-queries, summary-and-refine). The latency / quality trade-off and the anti-patterns (infinite loops, diverging sub-queries).&lt;/li>
&lt;/ul>
&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 deploy the cross-encoder reranker with TEI and its /rerank endpoint.&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> — where each piece runs: light rerank over a top-k fits on CPU; massive rerank demands a 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> — the master map where the RAG piece crosses Data + Deploy + Observe. Retrieval lives between the curated corpus and the served LLM.&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: the active librarian&lt;/a> — the previous layer. Without a curated corpus, no reranker rescues the system. Reranking is built on the librarian&amp;rsquo;s work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: the three families, the model zoo and the decision that matters&lt;/a> — the piece that produces the vectors this retrieval consumes. bge-m3 runs dense + sparse + colbert in a single pass; the selection criteria and the real cost per million chunks are there.&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 fourth retrieval channel alongside dense / sparse / multi-vector. GraphRAG (Microsoft v2 / LightRAG / HippoRAG 2 / KAG) joins the three-round committee via RRF, and typed chunks enable reranking by graph distance.&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/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the retrieval microservice consumes the vector store that this architecture keeps in sync.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> — the streaming transport that keeps fresh the index this post&amp;rsquo;s retrieval operates over.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the forensic tour of the request crosses retrieval; this post details what happens inside that box.&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: the layer after tracing&lt;/a> — the retrieval&amp;rsquo;s Recall@k and nDCG@k are metrics the eval gate can use as a promotion criterion. If they drop, the adapter deploy is blocked.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the challenge of a reproducible golden dataset&lt;/a> — the retrieval golden eval (queries with relevance-labelled chunks) is one of the four data artefacts worth versioning separately.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety for LLMs&lt;/a> — output filters are applied after the LLM, but retrieval also receives adversarial queries worth filtering beforehand (prompt injection via the query). The reranker is also a natural point for discarding chunks with sensitive material that slipped into the corpus.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the downstream LLM and the light LLMs for rewriter/HyDE are served with the same engine.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — spec sheets for Qdrant, Elasticsearch, TEI, vLLM, Langfuse, Phoenix.&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 in RAG: the receptionist with a photographic memory&lt;/a> — the middleware placed before this retrieval; when the query is semantically similar to one already answered (cosine ≥ θ), the reranking committee never even fires.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself: RAGAS, the golden dataset and the four metrics that matter&lt;/a> — context precision and context recall measure exactly the quality of this three-round committee; faithfulness measures what the LLM does with the resulting top-5.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">Function calling and tool-augmented retrieval: the detective who knows which file to ask for&lt;/a> — the retriever in this post is the &lt;code>vector_search&lt;/code> tool the LLM invokes in the ReAct pattern; the three-layer pipeline described here runs every time the agent picks that tool.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>BEIR&lt;/strong>: Thakur et al. (2021). &amp;ldquo;BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models.&amp;rdquo; NeurIPS 2021 Datasets and Benchmarks Track. &lt;a href="https://github.com/beir-cellar/beir">https://github.com/beir-cellar/beir&lt;/a>&lt;/li>
&lt;li>&lt;strong>SPLADE&lt;/strong>: Formal et al. (2021). &amp;ldquo;SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking.&amp;rdquo; SIGIR 2021. &lt;a href="https://arxiv.org/abs/2107.05720">https://arxiv.org/abs/2107.05720&lt;/a>&lt;/li>
&lt;li>&lt;strong>ColBERT-v2&lt;/strong>: Santhanam et al. (2022). &amp;ldquo;ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction.&amp;rdquo; NAACL 2022. &lt;a href="https://arxiv.org/abs/2112.01488">https://arxiv.org/abs/2112.01488&lt;/a>&lt;/li>
&lt;li>&lt;strong>Reciprocal Rank Fusion&lt;/strong>: Cormack, Clarke, Buettcher (2009). &amp;ldquo;Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods.&amp;rdquo; SIGIR 2009. &lt;a href="https://dl.acm.org/doi/10.1145/1571941.1572114">https://dl.acm.org/doi/10.1145/1571941.1572114&lt;/a>&lt;/li>
&lt;li>&lt;strong>HyDE&lt;/strong>: Gao et al. (2022). &amp;ldquo;Precise Zero-Shot Dense Retrieval without Relevance Labels.&amp;rdquo; &lt;a href="https://arxiv.org/abs/2212.10496">https://arxiv.org/abs/2212.10496&lt;/a>&lt;/li>
&lt;li>&lt;strong>Step-back prompting&lt;/strong>: Zheng et al. (2023). &amp;ldquo;Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models.&amp;rdquo; &lt;a href="https://arxiv.org/abs/2310.06117">https://arxiv.org/abs/2310.06117&lt;/a>&lt;/li>
&lt;li>&lt;strong>BGE-M3&lt;/strong>: Chen et al. (2024). &amp;ldquo;BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation.&amp;rdquo; &lt;a href="https://arxiv.org/abs/2402.03216">https://arxiv.org/abs/2402.03216&lt;/a>&lt;/li>
&lt;li>&lt;strong>MTEB leaderboard&lt;/strong>: &lt;a href="https://huggingface.co/spaces/mteb/leaderboard">https://huggingface.co/spaces/mteb/leaderboard&lt;/a> — útil como punto de partida, peligroso como criterio único (overfitting al benchmark, dataset contamination).&lt;/li>
&lt;li>&lt;strong>TEI (Text Embeddings Inference)&lt;/strong>: &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a> — el motor de serving de HuggingFace para embedders y rerankers de tamaño pequeño-mediano. Endpoint OpenAI-compatible.&lt;/li>
&lt;li>&lt;strong>Qdrant hybrid search&lt;/strong>: &lt;a href="https://qdrant.tech/documentation/concepts/hybrid-queries/">https://qdrant.tech/documentation/concepts/hybrid-queries/&lt;/a> — implementación nativa de RRF y multi-vector queries.&lt;/li>
&lt;li>&lt;strong>RAGatouille&lt;/strong>: &lt;a href="https://github.com/AnswerDotAI/RAGatouille">https://github.com/AnswerDotAI/RAGatouille&lt;/a> — wrapper Python para ColBERT-v2 que reduce drásticamente la curva de entrada.&lt;/li>
&lt;li>&lt;strong>Cohere Rerank 3&lt;/strong>: &lt;a href="https://docs.cohere.com/docs/rerank-2">https://docs.cohere.com/docs/rerank-2&lt;/a> — referencia técnica del reranker comercial multilingüe líder.&lt;/li>
&lt;/ul></description></item><item><title>RAG corpus curation: the active librarian who decides what goes in, what goes out and what gets signed</title><link>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/</link><pubDate>Mon, 25 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>curation&lt;/strong> layer inside stage 1 (Data) of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It complements the other Data posts: &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dataset versioning&lt;/a> for the four versionable artefacts, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant ingestion in microservices&lt;/a> for the outbox + CDC pattern, and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> for streaming transport. This post is not about moving data: it is about what to do with data before you let a model read it.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG system serving mediocre answers is rarely the retriever&amp;rsquo;s fault, nor the model&amp;rsquo;s. The root cause is usually the corpus: three nearly identical versions of the same PDF that make top-k return the same thing three times, an old manual nobody removed that contradicts the current one, a free-text field with customer numbers that the model quotes verbatim, a scanned PDF with dirty OCR that the chunker cut in the middle of a sentence. None of that is fixed by changing the model, the embedder, the reranker or the prompt. It is fixed by curating the corpus. This post takes apart the five operational layers of curation (schema-validated ingest, three-level deduplication, PII anonymisation measured with precision/recall, anti-contamination against the golden eval set, chunk→trace lineage), the minimum maths needed to avoid fooling yourself, the 2026 stack (Presidio, Unstructured, Argilla, LangChain text splitters, OpenLineage, Marquez, Great Expectations), the seven traps that reduce the stage to theatre, and the on-premise hardware to sustain all of it without sending anything sensitive to external APIs.&lt;/p>
&lt;h2 id="the-analogy-the-active-librarian">The analogy: the active librarian&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RAG corpus curation as an active librarian">
&lt;style>
.lbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.lhead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.lstage{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.lblt{font:600 13px sans-serif;fill:#222}
.lsub{font:400 11px sans-serif;fill:#555}
.larr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mc1)}
.lgate{fill:#ffd76b;stroke:#444;stroke-width:1.6;rx:6}
.lrej{fill:#f4b8b8;stroke:#a44;stroke-width:1.4;rx:6}
&lt;/style>
&lt;defs>&lt;marker id="mc1" 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="20" width="180" height="60" class="lhead"/>
&lt;text x="110" y="44" text-anchor="middle" class="lblt">Document arrives&lt;/text>
&lt;text x="110" y="62" text-anchor="middle" class="lsub">PDF, HTML, ticket, manual&lt;/text>
&lt;text x="110" y="76" text-anchor="middle" class="lsub">(book offered to the librarian)&lt;/text>
&lt;rect x="240" y="20" width="160" height="60" class="lstage"/>
&lt;text x="320" y="44" text-anchor="middle" class="lblt">1 · Schema check&lt;/text>
&lt;text x="320" y="62" text-anchor="middle" class="lsub">Great Expectations&lt;/text>
&lt;text x="320" y="76" text-anchor="middle" class="lsub">required fields + types&lt;/text>
&lt;rect x="440" y="20" width="160" height="60" class="lstage"/>
&lt;text x="520" y="44" text-anchor="middle" class="lblt">2 · Dedup&lt;/text>
&lt;text x="520" y="62" text-anchor="middle" class="lsub">exact + near + semantic&lt;/text>
&lt;text x="520" y="76" text-anchor="middle" class="lsub">hash + MinHash + cosine&lt;/text>
&lt;rect x="640" y="20" width="120" height="60" class="lstage"/>
&lt;text x="700" y="44" text-anchor="middle" class="lblt">3 · PII&lt;/text>
&lt;text x="700" y="62" text-anchor="middle" class="lsub">Presidio + recall&lt;/text>
&lt;text x="700" y="76" text-anchor="middle" class="lsub">measured vs golden&lt;/text>
&lt;path class="larr" d="M200,50 L240,50"/>
&lt;path class="larr" d="M400,50 L440,50"/>
&lt;path class="larr" d="M600,50 L640,50"/>
&lt;rect x="100" y="130" width="220" height="60" class="lstage"/>
&lt;text x="210" y="154" text-anchor="middle" class="lblt">4 · Anti-contamination&lt;/text>
&lt;text x="210" y="172" text-anchor="middle" class="lsub">cross-check against golden eval set&lt;/text>
&lt;text x="210" y="186" text-anchor="middle" class="lsub">reject token-by-token overlaps&lt;/text>
&lt;rect x="360" y="130" width="220" height="60" class="lstage"/>
&lt;text x="470" y="154" text-anchor="middle" class="lblt">5 · Lineage emit&lt;/text>
&lt;text x="470" y="172" text-anchor="middle" class="lsub">OpenLineage event with source,&lt;/text>
&lt;text x="470" y="186" text-anchor="middle" class="lsub">hash, schema_version, embedder&lt;/text>
&lt;rect x="600" y="130" width="160" height="60" class="lgate"/>
&lt;text x="680" y="154" text-anchor="middle" class="lblt">Gate&lt;/text>
&lt;text x="680" y="172" text-anchor="middle" class="lsub">passes all 5 layers →&lt;/text>
&lt;text x="680" y="186" text-anchor="middle" class="lsub">index in the vector store&lt;/text>
&lt;path class="larr" d="M700,86 L210,124"/>
&lt;path class="larr" d="M700,86 L470,124"/>
&lt;path class="larr" d="M700,86 L680,124"/>
&lt;rect x="80" y="240" width="280" height="60" class="lbox"/>
&lt;text x="220" y="264" text-anchor="middle" class="lblt">Accept → living corpus&lt;/text>
&lt;text x="220" y="282" text-anchor="middle" class="lsub">chunks with metadata, embeddings computed,&lt;/text>
&lt;text x="220" y="296" text-anchor="middle" class="lsub">indexed with dataset_hash in metadata&lt;/text>
&lt;rect x="400" y="240" width="280" height="60" class="lrej"/>
&lt;text x="540" y="264" text-anchor="middle" class="lblt">Reject → auditable quarantine&lt;/text>
&lt;text x="540" y="282" text-anchor="middle" class="lsub">rejection reason + diff vs previous version&lt;/text>
&lt;text x="540" y="296" text-anchor="middle" class="lsub">available for human review&lt;/text>
&lt;path class="larr" d="M580,196 L220,236"/>
&lt;path class="larr" d="M700,196 L540,236"/>
&lt;text x="390" y="338" text-anchor="middle" class="lsub" style="font-style:italic;">The active librarian: pass 5 layers or stay out. Nothing is accepted because "it was already there".&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A serious librarian does not accept books by the kilo. When someone offers a new volume:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>They look at the spine, the ISBN and the stamp&lt;/strong>: is it legible? Is it catalogued correctly? Does it belong to a recognised collection? Without valid metadata, it does not get in. This is the &lt;strong>schema check&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>They check whether they already hold a copy&lt;/strong>: is it exactly the same book? Is it a later edition of the same one? Is it a translated version of something already on the shelves? If so, they decide explicitly what to do (replace, archive the old one, withdraw both from lending). This is &lt;strong>dedup&lt;/strong> at its three levels.&lt;/li>
&lt;li>&lt;strong>They flag what is restricted&lt;/strong>: if the book contains identifiable personal data, some pages cannot be lent as they are, so they must be redacted, anonymised or moved to the reserved section. This is &lt;strong>PII anonymisation&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>They verify it is not this year&amp;rsquo;s final exam book&lt;/strong>: if it is, it stays out of the public collection until the syllabus changes, because if students can consult it the exam stops measuring what it is meant to measure. This is &lt;strong>anti-contamination&lt;/strong> against the golden eval set.&lt;/li>
&lt;li>&lt;strong>They write it into the register&lt;/strong>: this book, this edition, this provenance, this date, this person who approved the entry. This is &lt;strong>lineage&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>If the book passes all five, it joins the collection. If it fails any of them, it goes to an &lt;strong>auditable quarantine shelf&lt;/strong> with the reason for rejection. The difference between a good collection and a mediocre one is not size: it is how much discipline you apply across the five layers, every day, to every new book that arrives.&lt;/p>
&lt;p>A RAG corpus is exactly that. The only differences are scale (thousands or millions of documents per month) and the fact that the &amp;ldquo;readers&amp;rdquo; are LLMs that cannot tell a duplicate from a reinforced truth, nor a PII value from a synthetic example, nor a contaminated fragment from an authentic one.&lt;/p>
&lt;h2 id="the-four-data-artefacts-and-where-the-rag-corpus-fits">The four data artefacts and where the RAG corpus fits&lt;/h2>
&lt;p>Before going down to the five layers it is worth being clear about which corpus we are curating. The Data stage of the pipeline manages four distinct artefacts, each with its own discipline. The &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning post&lt;/a> lists them; here I reorder them from a curation perspective:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefact&lt;/th>
&lt;th>Who consumes it&lt;/th>
&lt;th>Dominant curation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Training dataset&lt;/strong>&lt;/td>
&lt;td>Tune (fine-tuning the model or an adapter)&lt;/td>
&lt;td>aggressive dedup + quality filters + label balancing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Deploy (retrieval at request time)&lt;/td>
&lt;td>the 5 layers in this post&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Eval (promotion gates)&lt;/td>
&lt;td>strict hold-out + stratification + maintenance driven by incidents&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrain enriched dataset&lt;/strong>&lt;/td>
&lt;td>Retrain (closing the loop)&lt;/td>
&lt;td>production feedback + human triage&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>RAG corpus&lt;/strong> is the most volatile of the four and the one most exposed to the end user: every answer the system serves literally contains fragments of it. A duplicate in the training dataset degrades learning but ends up buried in the weights; a duplicate in the RAG corpus shows up in today&amp;rsquo;s answer and tomorrow&amp;rsquo;s. That justifies the extra discipline that follows.&lt;/p>
&lt;h2 id="layer-1--schema-validated-ingest">Layer 1 — Schema-validated ingest&lt;/h2>
&lt;p>Every piece entering the corpus has to arrive with &lt;strong>structured metadata validated against a schema&lt;/strong>. This is not bureaucracy: it is the only way to make the following layers (dedup, PII, lineage) work without friction.&lt;/p>
&lt;p>The canonical pattern is to define a schema in JSON Schema or Pydantic that every document must satisfy:&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="k">class&lt;/span> &lt;span class="nc">CorpusDocument&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># e.g. &amp;#34;confluence&amp;#34;, &amp;#34;salesforce&amp;#34;, &amp;#34;manual_pdf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># unique ID in the source system&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># document version (semver or date)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">language&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># ISO 639-1: &amp;#34;es&amp;#34;, &amp;#34;en&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">body&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">captured_at&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">datetime&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">captured_by&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># pipeline or human&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sensitivity&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;public&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;restricted&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">schema_version&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># version of the schema itself, not of the document&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any piece that fails this contract is rejected at ingest and never reaches the following layers. Validation is done with &lt;strong>Great Expectations&lt;/strong> (declarative suites), &lt;strong>Pandera&lt;/strong> (more pythonic, integrates with pandas) or &lt;strong>Soda&lt;/strong> (oriented towards continuous data quality). The choice is a matter of style; what matters is:&lt;/p>
&lt;ul>
&lt;li>Validation suites live in code and are versioned with the pipeline, not in a notebook off to one side.&lt;/li>
&lt;li>Rejection generates an auditable event (quarantine) with the specific schema failure as its reason, not a log line lost in stdout.&lt;/li>
&lt;li>The schema itself is versioned. When it changes, previous documents are reprocessed or explicit backward compatibility is maintained.&lt;/li>
&lt;/ul>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka post&lt;/a> covers the &lt;strong>Schema Registry&lt;/strong> pattern (Confluent Schema Registry or Apicurio) that materialises this in streaming: every message on the topic is validated against the registered schema before propagating downstream. For batch or pull, Great Expectations is the equivalent.&lt;/p>
&lt;p>&lt;strong>Common trap&lt;/strong>: leaving the &lt;code>body&lt;/code> field free with no further validation. You have to tighten it up with minimum/maximum length (a PDF that yields 12 characters after extraction is almost certainly broken), valid encoding (UTF-8 with no control characters), proportion of alphanumeric characters (dirty OCR returns a soup of symbols). These are simple rules that filter 80% of the noise with no need for AI.&lt;/p>
&lt;h2 id="layer-2--three-level-deduplication">Layer 2 — Three-level deduplication&lt;/h2>
&lt;p>The most expensive and most silent mistake in a RAG corpus is the duplicate. A document that appears three times in the corpus makes retrieval&amp;rsquo;s top-k return it three times, wasting two slots and reinforcing a single source. The LLM reads it as if three independent sources agreed, when in fact it is the same thing repeated.&lt;/p>
&lt;p>Deduplication is done at three levels, in this order by cost:&lt;/p>
&lt;h3 id="level-a--exact-dedup-sha-256-hash">Level A — Exact dedup (SHA-256 hash)&lt;/h3>
&lt;p>Compute the hash of the normalised content (trim, lower-case where applicable, remove redundant whitespace) and compare it against an index of already ingested hashes. On a match, discard or replace. Cost: \(O(1)\) per document. It catches literal duplicates (the same PDF uploaded twice, two byte-for-byte copies of the same HTML).&lt;/p>
&lt;h3 id="level-b--near-duplicate-minhash--lsh">Level B — Near-duplicate (MinHash + LSH)&lt;/h3>
&lt;p>Nearly identical documents with minor differences (a different header, an updated date, one version in Castilian and another in Galician with minimal changes). The canonical algorithm is &lt;strong>MinHash with Locality-Sensitive Hashing (LSH)&lt;/strong>, which approximates Jaccard similarity over shingles of k tokens. For n documents, comparing all against all is \(O(n^2)\), which is unworkable for large corpora. LSH cuts the cost to \(O(n)\) most-likely buckets.&lt;/p>
&lt;p>A typical threshold is Jaccard ≥ 0.80 over shingles of 5 tokens. The standard libraries are &lt;code>datasketch&lt;/code> (Python, MIT) or &lt;code>dedup&lt;/code> (Python, MIT). A numerical example: for 1 M short documents (300 tokens each), &lt;code>datasketch.MinHashLSH&lt;/code> with 128 permutations and threshold 0.8 takes about 2 GB of RAM and processes the full corpus in around 30 minutes on a modern CPU. The fraction of duplicates found in a real enterprise corpus is usually between 5% and 25%; removing them cuts storage and improves retrieval quality at the same time.&lt;/p>
&lt;h3 id="level-c--semantic-dedup-cosine-over-embeddings">Level C — Semantic dedup (cosine over embeddings)&lt;/h3>
&lt;p>Documents saying the same thing in different words, such as paraphrases, translations or rewritten versions, are not caught by MinHash. This is where semantic similarity comes in: compute the embedding of each document and compare the cosine between pairs.&lt;/p>
&lt;p>The problem is quadratic cost: for n documents, computing every pair is \(O(n^2)\). For n = 1 M and 768-dimensional embeddings (a typical model such as &lt;code>BAAI/bge-base-en-v1.5&lt;/code>), that is 5×10^11 dot products, which is unworkable. The solution is the same idea as LSH but over dense vectors: &lt;strong>HNSW&lt;/strong> (Hierarchical Navigable Small World) or &lt;strong>IVF&lt;/strong> (Inverted File) to build an approximate search index. For each new document you run a k-NN query against the index and examine only the k nearest neighbours.&lt;/p>
&lt;p>A sensible threshold for treating something as a semantic duplicate: &lt;strong>cosine ≥ 0.95&lt;/strong>. Below 0.95 the documents are related but distinct; above it, they are almost always the same information rewritten. The exact threshold is calibrated by observing precision/recall over a human-annotated sample; 100 pairs confirmed by a reviewer is reasonable for setting it.&lt;/p>
&lt;p>A numerical example: with &lt;code>qdrant&lt;/code> or &lt;code>pgvector&lt;/code> as the HNSW index and k=10 neighbours per query, deduplicating 1 M documents against the existing corpus takes on the order of 2-4 hours on an RTX 4090 (including embedding computation). If the embedder is self-hosted with vLLM, the cost per token is negligible against compute time.&lt;/p>
&lt;h3 id="policy-on-what-to-do-with-a-duplicate">Policy on what to do with a duplicate&lt;/h3>
&lt;p>Detection is not enough, you have to decide. Three common policies, in order of complexity:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drop&lt;/strong>: discard the more recent one, keep the older one. Simple, with no extra lineage.&lt;/li>
&lt;li>&lt;strong>Replace&lt;/strong>: discard the old one, index the new one. More volatility but it reflects the update.&lt;/li>
&lt;li>&lt;strong>Merge with provenance&lt;/strong>: mark the new one as a &amp;ldquo;shadow&amp;rdquo; of the old one, keep both in lineage but index only one. Better for regulated audit.&lt;/li>
&lt;/ul>
&lt;p>The policy has to be explicit and applied uniformly, not an ad-hoc decision per document.&lt;/p>
&lt;h2 id="layer-3--pii-anonymisation-with-measured-precisionrecall">Layer 3 — PII anonymisation with measured precision/recall&lt;/h2>
&lt;p>This layer is the one that most easily turns into theatre. The typical mistake: install Presidio, run it over the corpus, assume the output is clean. Without measuring the detector&amp;rsquo;s precision and recall against an annotated golden set, you know nothing.&lt;/p>
&lt;p>A PII detector can fail in two ways:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>False negative&lt;/strong> (low recall): it does not detect a national ID written as &amp;ldquo;12345678-A&amp;rdquo; because your model was trained on the &lt;code>12345678A&lt;/code> format without a hyphen. The RAG serves personal data unredacted.&lt;/li>
&lt;li>&lt;strong>False positive&lt;/strong> (low precision): it redacts a number from a configuration manual thinking it is a phone number. The RAG loses useful information.&lt;/li>
&lt;/ul>
&lt;p>Both are problems; regulation (GDPR, ENS, NIS2) penalises the first, and user experience degrades with the second. The acceptable ratio depends on the domain. In medical data, practically zero false negatives is non-negotiable; in internal technical documentation you can tolerate more recall in exchange for less precision.&lt;/p>
&lt;p>The standard metric is &lt;strong>F1 over an annotated golden set&lt;/strong>:&lt;/p>
\[
\text{precision} = \frac{TP}{TP + FP}, \quad \text{recall} = \frac{TP}{TP + FN}, \quad F_1 = 2 \cdot \frac{\text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}}
\]
&lt;p>To build the PII golden set, annotate around 200 documents by hand with every entity marked (national ID, IBAN, email, phone, address, personal name). Then run the detector and compute the metrics per category, not just aggregated, because a global F1 of 0.90 can hide a recall of 0.55 on IBANs.&lt;/p>
&lt;p>&lt;strong>2026 stack&lt;/strong> for this layer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Microsoft Presidio&lt;/strong> (MIT, Microsoft): the most complete OSS option. Configurable detectors, recognises around 50 entities by default, extensible with your own regex patterns or with fine-tuned NER models.&lt;/li>
&lt;li>&lt;strong>spaCy NER&lt;/strong> (MIT, Explosion AI): a base for custom detectors; useful when Presidio does not cover a domain entity.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong> (Llama Community License, Meta): a safety classifier that also detects PII in one pass, an option when you already have a GPU for inference and prefer a single pass.&lt;/li>
&lt;li>&lt;strong>DataFog&lt;/strong> (Apache 2.0): a more recent alternative, specialised in streaming pipelines.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Recommended hybrid pattern&lt;/strong>: Presidio for rule-based + regex detection (fast, deterministic) → Llama Guard as a second pass over what Presidio did not flag (an ensemble that raises recall without killing throughput). This is measured and reported as aggregate and per-category F1 on every release of the detector.&lt;/p>
&lt;p>&lt;strong>Common fallacy&lt;/strong>: trusting that a detector with F1 0.95 &amp;ldquo;is very good&amp;rdquo;. If you have 1 M documents and each contains on average 1 PII entity, F1 0.95 means 50,000 mishandled entities (between false positives and negatives). With sensitive data you have to design so that false negatives go to human quarantine, not to the public corpus.&lt;/p>
&lt;h2 id="layer-4--anti-contamination-against-the-golden-eval-set">Layer 4 — Anti-contamination against the golden eval set&lt;/h2>
&lt;p>If the RAG corpus contains fragments of the golden eval set, Eval metrics measure memorisation. The model returns the exact answer because it has it literally in its context, not because it generalised anything. Deploy then promotes models that shine in the exam and fail in production.&lt;/p>
&lt;p>This layer is the easiest to implement and the easiest to forget:&lt;/p>
&lt;ol>
&lt;li>The &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">golden eval set&lt;/a> has its own versioned hash.&lt;/li>
&lt;li>Before indexing any new document into the RAG corpus, run a token-by-token overlap check (or by shingles, similar to MinHash) against the golden set.&lt;/li>
&lt;li>If overlap exceeds a threshold (typically ≥ 30% of 5-token n-grams), the document is not indexed. It stays in quarantine flagged as &amp;ldquo;contamination risk vs golden_v12&amp;rdquo;.&lt;/li>
&lt;li>A human reviews the rejections. Sometimes they are false positives (a short quotation, a boilerplate sentence). Sometimes they are real contamination that a supplier introduced without realising.&lt;/li>
&lt;/ol>
&lt;p>The deeper reason: the RAG corpus and the golden set are enemies by design. The golden set measures how well the system generalises to questions it has not seen. If those questions are in the RAG, the system &amp;ldquo;sees&amp;rdquo; them on every query. The metric stops measuring generalisation.&lt;/p>
&lt;p>This check is computationally trivial, a hash join over n-grams. The difficulty is keeping it up: every time the golden set changes (monthly or quarterly), the full corpus has to be re-validated against the new golden set. Without that discipline, contamination sneaks in through the back door when someone updates the golden set with real cases that the RAG was already serving.&lt;/p>
&lt;h2 id="layer-5--end-to-end-lineage-from-document-to-trace">Layer 5 — End-to-end lineage: from document to trace&lt;/h2>
&lt;p>The last layer is the one that closes the auditable chain. Every chunk indexed in the vector store carries metadata that lets you answer the forensic question:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;The system generated this answer on 14 March at 16:23. Which exact document did the quoted fragment come from? When did that document enter the corpus? Which version of the embedder processed it? Who approved its ingest?&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>Without lineage, that question is unanswerable. With lineage done properly, it is four queries.&lt;/p>
&lt;p>The canonical pattern:&lt;/p>
&lt;ul>
&lt;li>Every indexed chunk carries in its metadata: &lt;code>source_system&lt;/code>, &lt;code>source_id&lt;/code>, &lt;code>document_version&lt;/code>, &lt;code>chunk_index&lt;/code>, &lt;code>embedder_version&lt;/code>, &lt;code>dataset_hash&lt;/code>, &lt;code>ingested_at&lt;/code>, &lt;code>ingested_by&lt;/code>, &lt;code>schema_version&lt;/code>.&lt;/li>
&lt;li>Every RAG answer in production emits a trace span that includes the retrieved &lt;code>chunk_id&lt;/code> values.&lt;/li>
&lt;li>The central tracing system (&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">Langfuse, Phoenix or OpenLLMetry&lt;/a>) joins &lt;code>chunk_id&lt;/code> → chunk metadata → document metadata → corpus &lt;code>dataset_hash&lt;/code> → embedder version → and so on.&lt;/li>
&lt;/ul>
&lt;p>The tools that standardise this glue are &lt;strong>OpenLineage&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data) and &lt;strong>Marquez&lt;/strong> (Apache 2.0, its server implementation). They define a lineage event schema that interoperates between systems; an ingest job emits an event &amp;ldquo;produced corpus_v12.3 from source X with embedder bge-base-v1.5&amp;rdquo;; a retrieval job emits &amp;ldquo;consumed corpus_v12.3 with query Q produced response R&amp;rdquo;. The graph is reconstructed automatically.&lt;/p>
&lt;p>This layer is the only way to pass real audits under regulations such as the EU AI Act, GDPR or ENS, where traceability of which data went into which answer is a requirement, not an option. Without it, the answer &amp;ldquo;we do not know which document this came from&amp;rdquo; is not acceptable, and it is the default answer if lineage is not built from day one.&lt;/p>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;p>Beyond dedup thresholds and PII F1 scores, there are three mathematical pieces that any serious team ends up using.&lt;/p>
&lt;p>&lt;strong>Chunk size vs retrieval quality&lt;/strong>. Chunk size affects retrieval quality in a non-monotonic way: chunks that are too small fragment ideas (retrieval returns a piece without context), and chunks that are too large dilute the signal (the embedding mixes several topics and similarity drops). The empirical sweet spot for technical text in 2026 is between &lt;strong>256 and 768 tokens per chunk&lt;/strong>, with &lt;strong>15-25% overlap&lt;/strong> between contiguous chunks to preserve continuity.&lt;/p>
&lt;p>Numerically, for a corpus of 1 M documents with a mean length of 2,000 tokens, chunking at 512 tokens with overlap 100 gives \(\frac{2000}{512 - 100} \approx 5\) chunks per document, a total of roughly 5 M indexed chunks. With 768-dimensional embeddings in &lt;code>float32&lt;/code>, that occupies \(5 \cdot 10^6 \cdot 768 \cdot 4 \approx 15\) GB of vector memory, manageable in any modern vector store.&lt;/p>
&lt;p>&lt;strong>PII golden set coverage&lt;/strong>. To know whether the annotated PII golden set is representative enough, compute the proportion of categories covered: if your golden set of 200 documents has 5 IBAN examples and production has 12,000 IBANs per day, the measured F1 on IBANs is statistical noise. Rule of thumb: &lt;strong>a minimum of 30 examples per category&lt;/strong> for per-category metrics to mean anything.&lt;/p>
&lt;p>&lt;strong>Cost of re-embedding when rotating the model&lt;/strong>. Changing the embedder invalidates the whole index. For a corpus of 5 M chunks with a model such as &lt;code>BAAI/bge-base-en-v1.5&lt;/code> (768 dim, around 110 M parameters) served in vLLM on 1× H100, throughput is on the order of 8,000-15,000 chunks/second. Full re-embedding: about 5-10 minutes. For a larger embedder (&lt;code>bge-large&lt;/code>, 1024 dim, around 335 M parameters), a factor of 3× worse, around 15-30 minutes. The bottleneck is usually vector store I/O, not GPU compute. The &lt;strong>dual-index&lt;/strong> pattern, keeping the old index serving while the new one is built and doing an atomic swap at the end, avoids downtime and allows rollback.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For an on-premise deployment that keeps all curation in-house without sending data to external APIs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RTX 4090 (24 GB)&lt;/strong>: covers layer 1 (the schema check with Great Expectations is CPU-bound), layer 2 levels A and B (hash + MinHash are CPU-bound), and layer 2 level C semantic dedup with a &lt;code>bge-base&lt;/code>-class embedder (8-15k chunks/s, enough for corpora of up to 5-10 M chunks in hours). For Presidio in NER mode (layer 3) it runs comfortably. It is the sensible GPU for the whole curation pipeline on a mid-size corpus.&lt;/li>
&lt;li>&lt;strong>Generic 4×H100 SXM configuration (320 GB total, NVLink)&lt;/strong>: only needed if the corpus exceeds around 50 M chunks or if you want frequent re-embeddings with large models (&lt;code>bge-large&lt;/code>, &lt;code>e5-mistral&lt;/code>). In practice, two GPUs serve the embedder at TP=2 with throughput above 50k chunks/s, and the other two go to the PII judge (Llama Guard 4) or to serving the main inference model. Capacity for corpora of hundreds of millions of chunks.&lt;/li>
&lt;/ul>
&lt;p>The stubborn arithmetic: with a 4090, corpus curation is an overnight job; with 4×H100, it is minutes. The decision depends on corpus size and on how often you rotate the embedder or the PII rules.&lt;/p>
&lt;h2 id="the-seven-traps-that-kill-this-stage">The seven traps that kill this stage&lt;/h2>
&lt;p>&lt;strong>Trap 1 — No validated schema at ingest.&lt;/strong> Malformed documents reach the chunker, the chunker slices them into nonsense, garbage embeddings enter the index. The RAG answer quotes incoherent text and nobody knows why.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Dedup only at exact hash level.&lt;/strong> The corpus fills up with paraphrases and translations of the same document. Retrieval&amp;rsquo;s top-k returns the same source 3 times. The LLM reads it as three confirmations.&lt;/p>
&lt;p>&lt;strong>Trap 3 — PII detector without precision/recall measurement.&lt;/strong> Everyone assumes Presidio &amp;ldquo;works&amp;rdquo;. IBANs in non-standard formats slip through. The RAG serves personal data.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Golden eval set contaminated with the corpus.&lt;/strong> Eval metrics measure memorisation. Promotions approve models that fail in real production.&lt;/p>
&lt;p>&lt;strong>Trap 5 — No lineage down to the chunk.&lt;/strong> The question &amp;ldquo;where did this quotation come from?&amp;rdquo; has no answer. Regulatory audit fails. Incidents cannot be investigated.&lt;/p>
&lt;p>&lt;strong>Trap 6 — Maintenance as a one-off event.&lt;/strong> The corpus is curated once when the system is initialised, and afterwards everyone assumes it is fine. Six months later the documents are out of date, the new PII rules are not applied retrospectively, dedup has not been re-run after adding new sources. The corpus degrades silently.&lt;/p>
&lt;p>&lt;strong>Trap 7 — Quarantine without human review.&lt;/strong> Rejected documents go into a table nobody looks at. False positives pile up, real contamination cases go uninvestigated, the team&amp;rsquo;s trust in curation erodes and the pressure to &amp;ldquo;relax the thresholds&amp;rdquo; begins.&lt;/p>
&lt;p>All seven are operational, not technical. Corpus curation does not break because of an algorithm bug: it breaks because discipline slackens. It is the exact equivalent of the kind of degradation that kills Eval suites, and in both cases the symptom is the same: the metrics improve or hold steady while the real experience gets worse.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Vector store versioning proper&lt;/strong>: an embedding index is not versioned like a raw dataset because it depends on the embedding model. Changing the embedder rewrites the whole index. It is a different animal with its own patterns (index branching, selective reembedding, recall-aware ANN parameters).&lt;/li>
&lt;li>&lt;strong>Streaming corpus updates with CDC&lt;/strong>: when the corpus has to be updated in near-real time from an OLTP system. The &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">Postgres + Qdrant ingestion post&lt;/a> covers the mechanics; still pending is the pattern for selectively invalidating chunks that depend on deleted rows.&lt;/li>
&lt;li>&lt;strong>Multi-tenant corpus isolation&lt;/strong>: how to set up a shared corpus versus one with per-tenant namespaces, with ACLs over individual chunks. Especially relevant for multi-customer RAG under data sovereignty constraints.&lt;/li>
&lt;li>&lt;strong>Federated corpus&lt;/strong>: corpora distributed across silos that the system queries without centralising the content. An emerging pattern for companies with several sites and cross-border restrictions.&lt;/li>
&lt;li>&lt;strong>Reranking-aware curation&lt;/strong>: how curation discipline changes when there is a reranker (Cohere Rerank, ColBERTv2, BGE-Reranker) reordering the top-k after retrieval. Some duplicates you would tolerate without a reranker become intolerable when the reranker pushes them up the ranking.&lt;/li>
&lt;/ul>
&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> — how curation materialises in the ingestion pipeline, from PDF to indexed chunk.&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> — Stage 1 (Data) and why curation is the most underrated sub-task in the whole chain.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: the three families, the model zoo and the decision that matters&lt;/a> — which cartographer processes the chunks curated here to turn them into searchable vectors; embedder selection criteria (bge-m3 / Snowflake Arctic / Jina / Nomic / ColBERT) and storage cost.&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 Linnaean nomenclature without which curation is left with ad-hoc categories; how the chunks in this post are typed against a TBox (FIBO / SNOMED / ENS / schema.org), validated with SHACL and enriched with metadata queryable in SPARQL.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the forensic tour crosses the corpus and the retrieved chunks; here are the criteria any chunk had to pass to be in production.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the reproducible golden dataset challenge&lt;/a> — the four data artefacts and why they are versioned separately. The RAG corpus is one of the four.&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/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the microservices pattern that moves documents from source to vector store. The curation in this post plugs in between ingest and indexer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> — streaming transport. Schema Registry materialises layer 1.&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: the layer after tracing&lt;/a> — the golden eval set is the RAG corpus&amp;rsquo;s &amp;ldquo;enemy by design&amp;rdquo;; layer 4 (anti-contamination) materialises the discipline between the two.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning: the contract that stops a five-word change sinking your system&lt;/a> — the &lt;code>prompt_id&lt;/code> travelling in the trace is the counterpart of the corpus &lt;code>dataset_hash&lt;/code> in lineage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop between the production incident and the adapter that fixes it&lt;/a> — the retrain enriched corpus also needs the five layers, with additional emphasis on human feedback.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — entries for Presidio, Unstructured, Argilla, Great Expectations, OpenLineage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the curation in this post is prevention at ingest; guardrails are mitigation at runtime when something slips through. Line 2 (retrieval GR) filters chunks with indirect prompt injection before they enter the LLM&amp;rsquo;s context.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for an AI system&lt;/a> — the five curation layers in this post directly cover controls A.7.3 (acquisition), A.7.4 (quality), A.7.5 (provenance) and A.7.6 (preparation) of the AIMS Annex A.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Presidio&lt;/strong>: &lt;a href="https://microsoft.github.io/presidio/">https://microsoft.github.io/presidio/&lt;/a> — official docs, list of supported entities, guide to extending it with custom NER.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong>: &lt;a href="https://openlineage.io/">https://openlineage.io/&lt;/a> — event schema spec and libraries per language.&lt;/li>
&lt;li>&lt;strong>Marquez&lt;/strong>: &lt;a href="https://marquezproject.ai/">https://marquezproject.ai/&lt;/a> — server implementation of OpenLineage.&lt;/li>
&lt;li>&lt;strong>datasketch (MinHash + LSH)&lt;/strong>: &lt;a href="https://ekzhu.com/datasketch/">https://ekzhu.com/datasketch/&lt;/a> — the reference Python library for near-duplicate deduplication at scale.&lt;/li>
&lt;li>&lt;strong>Great Expectations&lt;/strong>: &lt;a href="https://docs.greatexpectations.io/">https://docs.greatexpectations.io/&lt;/a> — declarative data quality suites.&lt;/li>
&lt;li>&lt;strong>Unstructured&lt;/strong>: &lt;a href="https://docs.unstructured.io/">https://docs.unstructured.io/&lt;/a> — parsing and normalisation of heterogeneous documents (PDF, HTML, DOCX, eml) before chunking.&lt;/li>
&lt;li>&lt;strong>Argilla&lt;/strong>: &lt;a href="https://docs.argilla.io/">https://docs.argilla.io/&lt;/a> — human annotation UI for building the PII golden set and other calibration sets.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong>: Meta technical paper, multimodal safety classifier — useful as a second PII detection layer.&lt;/li>
&lt;li>&lt;strong>GDPR, EU AI Act, ENS, NIS2&lt;/strong> — the regulatory frameworks whose compliance depends, in practice, on the discipline of layers 3 (PII) and 5 (lineage). The CEN/CENELEC technical standards for conformity assessment of GenAI systems under the EU AI Act are still pending final publication.&lt;/li>
&lt;/ul></description></item><item><title>RAG on Kafka: a technical reference architecture for streaming datalakes, with fresh embeddings and vector stores that are always up to date</title><link>https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/</link><pubDate>Thu, 21 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The piece that blocks enterprise GenAI projects most in 2026 is not the model, nor even the guardrails: it is &lt;strong>data ingestion for RAG&lt;/strong>. Companies hold valuable information in OLTP databases, in operational logs, in SaaS systems, and all of it is quietly changing every second. Batch RAG systems that reindex every night arrive late (the model&amp;rsquo;s answer is backed by a snapshot from 18 hours ago) and open the door to operational hallucinations even when the retriever is perfect. The dominant answer in production in 2026 is to build the RAG piece on top of Kafka as the source of truth: an immutable log, massive throughput, managed schema evolution, and a mature stream processing ecosystem (Flink, Kafka Streams, RisingWave) that makes it possible to transform and embed events as they happen, delivering them to vector stores (Milvus, Qdrant, Weaviate, pgvector) in milliseconds. The canonical pattern: source → CDC with Debezium → Kafka topics → Flink SQL with an embedding UDF → sink connector to a vector store → serving with vLLM or equivalent. The 2026 developments that change the game: Confluent Tableflow turns Kafka topics into Iceberg/Delta tables automatically (read from Snowflake/Databricks/Trino with no ETL, 30-50 % lower TCO); native Flink SQL brings &lt;code>openai_embedding()&lt;/code> and vector search integrated with Cosmos DB and Amazon S3 Vectors; Confluent&amp;rsquo;s official MCP server lets AI agents query Kafka/Flink/Tableflow in natural language. This post develops the end-to-end architecture with manifests, Flink SQL code and concrete numbers.&lt;/p>
&lt;blockquote>
&lt;p>This is the second post in the MLOps series specific to LLMs. The first (&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">2026 landscape&lt;/a>) set out the framework. Here we go down to the most operational piece of the stack: how a real enterprise system connects to an LLM agent while keeping RAG fresh without falling into explosive complexity.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-kafka-as-the-single-source-of-truth">The analogy: Kafka as the &amp;ldquo;single source of truth&amp;rdquo;&lt;/h2>
&lt;p>Anyone who has spent time in distributed systems has seen the pattern again and again: an immutable, append-only, replicated, time-ordered log has become the canonical primitive for rebuilding complex systems. DBAs know it as the write-ahead log (PostgreSQL WAL, MySQL binlog). Event system developers know it as event sourcing. Data architects know it as the Kappa architecture. Kafka is the massive, distributed, mature implementation of that primitive: a log that lives on disk, partitioned to scale, replicated for durability, retained by time or size, readable from any point in history.&lt;/p>
&lt;p>When you think about RAG, this is exactly what you need. A well-designed RAG system has two critical questions: how do you keep the index fresh? And how do you rebuild the index when something breaks? Kafka answers both naturally: fresh because every change at the source is published as an event to the log and the pipeline processes it in milliseconds; rebuildable because the whole log is there: you drop the vector store, you have the Kafka topic from offset 0, and you build the index back exactly as it was.&lt;/p>
&lt;p>There is a second layer to the analogy as well. For a modern GenAI architecture, Kafka plays the role of the WAL of the entire system. Just as the Postgres WAL is the gospel of the database&amp;rsquo;s state (if you lose the DB but keep the WAL, you can rebuild it), the Kafka log is the gospel of the state of the business as a whole: orders, users, transactions, documents. Connecting your AI agent to Kafka means connecting it to the real pulse of the system, not to stale snapshots.&lt;/p>
&lt;h2 id="the-static-rag-problem">The static RAG problem&lt;/h2>
&lt;p>Before presenting the architecture, it is worth pinning down what concrete problem we are solving. The antipattern that trips up most GenAI projects:&lt;/p>
&lt;ol>
&lt;li>A team builds RAG on a static dataset: it dumps Confluence documents, product PDFs, database snapshots.&lt;/li>
&lt;li>It embeds the lot with a nightly cron that regenerates the index every 24 hours.&lt;/li>
&lt;li>It ships the product.&lt;/li>
&lt;li>&lt;strong>Day 2&lt;/strong>: a user asks about a change that happened two hours ago. RAG does not have it; the model answers about the old version.&lt;/li>
&lt;li>The team adds fragile logic: &amp;ldquo;if the query mentions a recent date, escalate to a human agent&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Day 30&lt;/strong>: the dataset has moved so much that half the RAG is out of date. The team decides to refactor and migrate to streaming.&lt;/li>
&lt;/ol>
&lt;p>It is the repeated story of so many projects that the ecosystem has learned the lesson: streaming from day 1, even when the volume is low. The operational complexity of a well-designed streaming pipeline is constant; the complexity of migrating from batch to streaming on a live project is enormous.&lt;/p>
&lt;h2 id="from-lambda-to-kappa-to-streaming-rag">From Lambda to Kappa to Streaming RAG&lt;/h2>
&lt;p>Three architectures in historical order:&lt;/p>
&lt;p>&lt;strong>Lambda (the classic big-data design from 2014)&lt;/strong>: two parallel pipelines, one batch for accuracy and one streaming for freshness. The query combines both. It works, but it means maintaining two pipelines.&lt;/p>
&lt;p>&lt;strong>Kappa (Jay Kreps, 2014, mainstream since 2020)&lt;/strong>: a single streaming pipeline. Batch is a special case of streaming (reprocessing from the beginning). It simplifies a great deal.&lt;/p>
&lt;p>&lt;strong>Streaming RAG (emerging in 2025-2026)&lt;/strong>: a specific variant of Kappa where the pipeline&amp;rsquo;s output is embeddings indexed in a vector store that the LLM queries at runtime. The Kafka log is the source of truth, the vector store is a queryable projection.&lt;/p>
&lt;p>The mental conversion: think of the vector store as the materialised view of the Kafka log. If the view is corrupted, you rebuild it from the log. If you want a new view (a different embedding model, a different chunking strategy), you create another consumer of the log and build a second view in parallel.&lt;/p>
&lt;h2 id="the-reference-architecture">The reference architecture&lt;/h2>
&lt;p>On to the diagram. I am going to present the canonical architecture that stabilised in 2026, showing where each component fits:&lt;/p>
&lt;pre tabindex="0">&lt;code>[OLTP DB (Postgres)] [Other sources]
│ │
│ WAL via logical decoding │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Debezium / Kafka Connect (Sources) │
└──────────────────────────────────────────────────────────┘
│
▼ produces events
┌──────────────────────────────────────────────────────────┐
│ Kafka cluster │
│ ┌───────────────────────────────────────────────────┐ │
│ │ topic: orders.raw (3 partitions, RF=3) │ │
│ │ topic: users.raw (3 partitions, RF=3) │ │
│ │ topic: documents.raw (6 partitions, RF=3) │ │
│ └───────────────────────────────────────────────────┘ │
│ + Schema Registry (Avro/Protobuf) │
└──────────────────────────────────────────────────────────┘
│
▼ consumes and transforms
┌──────────────────────────────────────────────────────────┐
│ Flink SQL streaming jobs │
│ - chunking text │
│ - calls to the embedding model (UDF) │
│ - enrichment with metadata │
│ - sink to a curated topic: documents.embedded │
└──────────────────────────────────────────────────────────┘
│
┌───────────┼────────────────────┐
▼ ▼ ▼
[Vector store] [Tableflow] [Iceberg/Delta]
Milvus/Qdrant auto-convert for analytics
/pgvector/ topics →
Weaviate tables
│
▼ queried at runtime
┌──────────────────────────────────────────────────────────┐
│ LLM serving (vLLM / SGLang) + Retriever │
│ - receives the agent&amp;#39;s query │
│ - searches top-K in the vector store │
│ - builds prompt + context │
│ - generates an answer with citations │
└──────────────────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>The five layers you can see (source, ingestion (CDC), transport (Kafka), processing (Flink), storage (vectors + tables)) are the ones that structure any serious RAG on a datalake in 2026. Let us go through each one.&lt;/p>
&lt;h2 id="layer-1--sources-your-oltp-as-the-starting-point">Layer 1 — Sources: your OLTP as the starting point&lt;/h2>
&lt;p>The typical source is an OLTP database (Postgres, MySQL, SQL Server). It is where the live state of the business lives. The technique for extracting changes in real time is Change Data Capture (CDC): reading the database&amp;rsquo;s transaction log (PostgreSQL WAL, MySQL binlog) and turning each commit into a Kafka event.&lt;/p>
&lt;p>The OSS standard is &lt;a href="https://debezium.io/">Debezium&lt;/a>. It supports Postgres, MySQL, SQL Server, MongoDB, Oracle, Cassandra and others. The typical deployment is a Kafka Connect cluster with Debezium connectors.&lt;/p>
&lt;p>An example Debezium configuration for PostgreSQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-orders-connector&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="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.connector.postgresql.PostgresConnector&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="nt">&amp;#34;tasks.max&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1&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="nt">&amp;#34;database.hostname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres.prod.internal&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="nt">&amp;#34;database.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;5432&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="nt">&amp;#34;database.user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium&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="nt">&amp;#34;database.password&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;${secret:postgres-creds}&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="nt">&amp;#34;database.dbname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce&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="nt">&amp;#34;database.server.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce-prod&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="nt">&amp;#34;table.include.list&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public.orders,public.users,public.products&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="nt">&amp;#34;publication.autocreate.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;filtered&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="nt">&amp;#34;slot.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium_slot&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="nt">&amp;#34;plugin.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;pgoutput&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="nt">&amp;#34;topic.prefix&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce&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="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.confluent.connect.avro.AvroConverter&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="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.confluent.connect.avro.AvroConverter&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="nt">&amp;#34;key.converter.schema.registry.url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http://schema-registry:8081&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="nt">&amp;#34;value.converter.schema.registry.url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http://schema-registry:8081&amp;#34;&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For each commit in the database, this produces an Avro event on the corresponding topic (&lt;code>ecommerce.public.orders&lt;/code>, &lt;code>ecommerce.public.users&lt;/code>, and so on) carrying the change: type (INSERT/UPDATE/DELETE), values before and after, commit timestamp, position in the WAL.&lt;/p>
&lt;p>A simpler alternative for 2026: &lt;a href="https://risingwave.com/">RisingWave&lt;/a> can read the Postgres WAL directly, with no Debezium or Kafka Connect in between. When the case is CDC only, with no other sources, it is operationally simpler. For architectures with multiple sources (CDC + APIs + scrapers + logs), Debezium remains the standard piece.&lt;/p>
&lt;h2 id="layer-2--kafka-as-transport-and-persistence">Layer 2 — Kafka as transport and persistence&lt;/h2>
&lt;p>The Kafka cluster is where all events land. Key operational decisions:&lt;/p>
&lt;h3 id="topics-raw-vs-curated">Topics: raw vs curated&lt;/h3>
&lt;p>The convention that settled in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>*.raw&lt;/code>&lt;/strong>: the raw event exactly as it arrived. Untransformed CDC, unparsed application logs.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.cleaned&lt;/code>&lt;/strong>: after dedup, schema validation, type normalisation.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.enriched&lt;/code>&lt;/strong>: after adding metadata (geolocation, cross identifiers, and so on).&lt;/li>
&lt;li>&lt;strong>&lt;code>*.embedded&lt;/code>&lt;/strong>: the event with its vector embedding already computed.&lt;/li>
&lt;/ul>
&lt;p>Multi-stage topics allow debugging per layer and partial reprocessing: if you change the embedding model, discarding &lt;code>*.embedded&lt;/code> and rebuilding from &lt;code>*.enriched&lt;/code> costs hours; rebuilding from &lt;code>*.raw&lt;/code> costs days.&lt;/p>
&lt;h3 id="schema-registry">Schema Registry&lt;/h3>
&lt;p>Without a schema registry, topics break silently when someone changes the schema at the source. &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> or the OSS &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> are the dominant options.&lt;/p>
&lt;p>Common formats:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Avro&lt;/strong>: versioned schema, strict evolution rules. The historical default.&lt;/li>
&lt;li>&lt;strong>Protobuf&lt;/strong>: compatible with gRPC stacks, good performance.&lt;/li>
&lt;li>&lt;strong>JSON Schema&lt;/strong>: textual, debuggable by eye, less efficient.&lt;/li>
&lt;/ul>
&lt;p>For RAG on Kafka we recommend Avro by default. Schema evolution matters because source tables change over time, and an unversioned schema breaks downstream consumers.&lt;/p>
&lt;h3 id="partitions-replication-and-retention">Partitions, replication and retention&lt;/h3>
&lt;p>Operational decisions for RAG topics:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Partitions&lt;/strong>: typically 3-12. More partitions means more parallelism in the Flink consumer, but more overhead. The rule of thumb: partitions = expected peak events/s ÷ 1000.&lt;/li>
&lt;li>&lt;strong>Replication factor&lt;/strong>: 3 as a minimum in production. Replication protects against broker failure; with RAG, the cost of losing a topic can be weeks of re-embedding.&lt;/li>
&lt;li>&lt;strong>Retention&lt;/strong>: for topics that feed RAG, use long retention or compaction by key. If document &lt;code>doc-42&lt;/code> changes 100 times, compaction keeps only the last state per key, leaving a smaller, rebuildable log. For data that is never updated (historical logs), retention by time (90 days, 1 year).&lt;/li>
&lt;/ul>
&lt;h3 id="cross-cluster-replication">Cross-cluster replication&lt;/h3>
&lt;p>For multi-region or multi-cloud deployments, MirrorMaker 2 or &lt;a href="https://docs.confluent.io/platform/current/multi-dc-deployments/cluster-linking/index.html">Cluster Linking&lt;/a> (Confluent) replicate topics between Kafka clusters. RAG can then query the local cluster without crossing regions.&lt;/p>
&lt;h2 id="layer-3--flink-as-the-streaming-processor">Layer 3 — Flink as the streaming processor&lt;/h2>
&lt;p>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> is the dominant stream processing piece in 2026. Apache 2.0, a mature distribution, a broad ecosystem. The main alternative is Kafka Streams (simpler, Java-only); RisingWave is the emerging option for pure SQL cases.&lt;/p>
&lt;p>What Flink adds to Kafka:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stateful streaming&lt;/strong>: time-based aggregations, joins between streams, sessions.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: with checkpoint coordination.&lt;/li>
&lt;li>&lt;strong>Watermarks&lt;/strong>: correct handling of out-of-order events.&lt;/li>
&lt;li>&lt;strong>UDFs in Python/Java&lt;/strong>: including calls to LLM models.&lt;/li>
&lt;/ul>
&lt;h3 id="flink-sql-the-most-operational-piece">Flink SQL: the most operational piece&lt;/h3>
&lt;p>Flink SQL is the most usable part of Flink for data engineers who are not streaming experts. Here is a realistic RAG pipeline example:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- 1. Define the source: Kafka topic with CDC events from documents
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w"> &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="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">body&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&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="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ENFORCED&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="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &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="s1">&amp;#39;connector&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;upsert-kafka&amp;#39;&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="s1">&amp;#39;topic&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;ecommerce.public.documents&amp;#39;&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="s1">&amp;#39;properties.bootstrap.servers&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;kafka:9092&amp;#39;&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="s1">&amp;#39;key.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;avro-confluent&amp;#39;&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="s1">&amp;#39;value.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;avro-confluent&amp;#39;&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="s1">&amp;#39;value.fields-include&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;EXCEPT_KEY&amp;#39;&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="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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 2. Define the sink: vector store via an intermediate Kafka topic
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w"> &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="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">chunk_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">chunk_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">ARRAY&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="nb">FLOAT&lt;/span>&lt;span class="o">&amp;gt;&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="n">embedded_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&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="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ENFORCED&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="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &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="s1">&amp;#39;connector&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;upsert-kafka&amp;#39;&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="s1">&amp;#39;topic&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;rag.documents.embedded&amp;#39;&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="s1">&amp;#39;properties.bootstrap.servers&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;kafka:9092&amp;#39;&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="s1">&amp;#39;key.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;json&amp;#39;&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="s1">&amp;#39;value.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;json&amp;#39;&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="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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 3. UDF for chunking (defined in Python or Java)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- CREATE TEMPORARY FUNCTION chunk_text AS &amp;#39;com.example.ChunkingUDF&amp;#39;;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&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="c1">-- 4. Pipeline: chunk, embed, write to the sink
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&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="k">SELECT&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="n">doc_id&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="n">chunk_idx&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&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="n">title&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="n">chunk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&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="n">category&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="n">OPENAI_EMBEDDING&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&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="s1">&amp;#39;text-embedding-3-small&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&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="k">CURRENT_TIMESTAMP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&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="k">CROSS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNNEST&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">64&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="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDINALITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_idx&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What happens here, line by line:&lt;/p>
&lt;ul>
&lt;li>The &lt;code>documents_raw&lt;/code> table reads the CDC topic in upsert-kafka mode (each new event on the same key replaces the previous one). This correctly reflects the semantics of &amp;ldquo;this is the latest version of doc 42&amp;rdquo;.&lt;/li>
&lt;li>The &lt;code>documents_embedded&lt;/code> table will be the intermediate topic where Flink writes the embedded chunks.&lt;/li>
&lt;li>The &lt;code>chunk_text&lt;/code> UDF (defined in Python or Java) splits each doc into 512-token chunks with an overlap of 64.&lt;/li>
&lt;li>The &lt;code>INSERT INTO&lt;/code> query runs continuously: every new event in &lt;code>documents_raw&lt;/code> is chunked, each chunk is embedded with &lt;code>OPENAI_EMBEDDING&lt;/code> (a built-in Flink SQL function in Confluent Cloud 2026), and written to the embedded topic.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> can be swapped for a custom function that calls a self-hosted model (vLLM with an encoder), SentenceTransformers, or a managed service. The syntax is the same; you change the provider.&lt;/p>
&lt;h3 id="watermarks-and-late-events">Watermarks and late events&lt;/h3>
&lt;p>For cases where an event can arrive late (for example, the Postgres WAL falls behind because of a network blip), Flink lets you define watermarks:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w"> &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="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">body&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&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="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&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="n">WATERMARK&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MINUTE&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="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This tells Flink &amp;ldquo;assume no event arrives more than 5 minutes late relative to the event timestamp&amp;rdquo;. For temporal joins and aggregations, Flink uses the watermark to decide when to &amp;ldquo;close&amp;rdquo; a window.&lt;/p>
&lt;h2 id="layer-4--sinks-to-vector-stores">Layer 4 — Sinks to vector stores&lt;/h2>
&lt;p>The last step is indexing the embeddings in a vector store. Three patterns in 2026:&lt;/p>
&lt;h3 id="pattern-a--direct-kafka-connect-sink">Pattern A — direct Kafka Connect sink&lt;/h3>
&lt;p>Each vector store has its official connector:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus&lt;/a>&lt;/strong>: official Zilliz sink connector. Supports named/unnamed dense/sparse vectors.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://github.com/qdrant/qdrant-kafka">Qdrant&lt;/a>&lt;/strong>: official sink connector. Supports dense, sparse, multi-vector.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong>: has no dedicated connector, but the &lt;a href="https://www.confluent.io/hub/confluentinc/kafka-connect-jdbc">JDBC Sink Connector&lt;/a> is used with custom SQL.&lt;/li>
&lt;li>&lt;strong>Weaviate&lt;/strong>: community connector.&lt;/li>
&lt;li>&lt;strong>LanceDB&lt;/strong>: community connector.&lt;/li>
&lt;/ul>
&lt;p>An example Milvus sink configuration:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;milvus-rag-embeddings-sink&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="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;com.milvus.io.kafka.MilvusSinkConnector&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="nt">&amp;#34;tasks.max&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;3&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="nt">&amp;#34;topics&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag.documents.embedded&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="nt">&amp;#34;milvus.host&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;milvus.prod.internal&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="nt">&amp;#34;milvus.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;19530&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="nt">&amp;#34;milvus.collection.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;documents&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="nt">&amp;#34;milvus.collection.dim&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1536&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="nt">&amp;#34;milvus.collection.partition&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;default&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="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.storage.StringConverter&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="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.json.JsonConverter&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="nt">&amp;#34;value.converter.schemas.enable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three parallel tasks (&lt;code>tasks.max: 3&lt;/code>) consume the embedded topic and write to the Milvus collection. Latency from &amp;ldquo;event in Kafka&amp;rdquo; to &amp;ldquo;indexable vector in Milvus&amp;rdquo; is typically &amp;lt;5 seconds.&lt;/p>
&lt;h3 id="pattern-b--pgvector-with-a-direct-cdc-pipe">Pattern B — pgvector with a direct CDC pipe&lt;/h3>
&lt;p>For teams that already live in PostgreSQL, pgvector is the lowest-friction option. The pattern: the same source Postgres cluster holds a second DB for embeddings with the pgvector extension enabled; the Flink pipeline writes to it directly over JDBC.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- On the Postgres cluster with pgvector enabled
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_embeddings&lt;/span>&lt;span class="w"> &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="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&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="n">chunk_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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="n">chunk_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&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="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&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="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1536&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="n">embedded_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TIMESTAMP&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="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&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="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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_embeddings&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="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hnsw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector_cosine_ops&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="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ef_construction&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Advantages: the same DBA operates everything, transactionality across tables, trivial joins with relational metadata. Limitation: beyond 10M vectors, pgvector performance starts to give way compared with dedicated systems.&lt;/p>
&lt;h3 id="pattern-c--confluent-tableflow--iceberg--flink-sql-vector-search">Pattern C — Confluent Tableflow → Iceberg + Flink SQL vector search&lt;/h3>
&lt;p>This is the 2026 development that changes the mechanics. &lt;a href="https://www.confluent.io/product/tableflow/">Confluent Tableflow&lt;/a> materialises Kafka topics automatically as Apache Iceberg or Delta Lake tables. Features:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No ETL pipeline&lt;/strong>: you do not write Flink/Spark jobs to move Kafka into a table. Tableflow does it.&lt;/li>
&lt;li>&lt;strong>Automatic schema evolution&lt;/strong>: changes to the topic schema are reflected in the table.&lt;/li>
&lt;li>&lt;strong>Unified catalogue&lt;/strong>: the table appears in Glue, Unity Catalog, Snowflake, Databricks. Any analytical engine queries it without copying data.&lt;/li>
&lt;li>&lt;strong>Native CDC&lt;/strong>: handles inserts, updates and deletes correctly.&lt;/li>
&lt;li>&lt;strong>30-50 % lower TCO&lt;/strong> according to the figures Confluent publishes against traditional pipelines.&lt;/li>
&lt;/ul>
&lt;p>And since 2026, Tableflow + Flink SQL offer native vector search integrated with Cosmos DB and Amazon S3 Vectors. The RAG query can be made directly in Flink SQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">category&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VECTOR_SEARCH&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&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="n">OPENAI_EMBEDDING&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;user query&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;text-embedding-3-small&amp;#39;&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="n">top_k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">7&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="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VECTOR_SEARCH_SCORE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This unifies layers that used to be separate (vector store + analytics). For many cases, it removes the need to maintain a dedicated vector store.&lt;/p>
&lt;h2 id="confluents-official-mcp-server">Confluent&amp;rsquo;s official MCP server&lt;/h2>
&lt;p>One piece added in 2026 deserves a mention: Confluent has published an official MCP server that exposes Kafka, Flink and Tableflow as tools accessible to AI agents over MCP. Any MCP client (Claude Desktop, Cursor, your own agents) can:&lt;/p>
&lt;ul>
&lt;li>List topics, read recent messages, publish to topics.&lt;/li>
&lt;li>Run Flink SQL queries in natural language (&amp;ldquo;give me the orders from the last 24 hours worth more than 1000 €&amp;rdquo;).&lt;/li>
&lt;li>Query Tableflow Iceberg tables.&lt;/li>
&lt;li>Manage Kafka Connect connectors.&lt;/li>
&lt;/ul>
&lt;p>This closes the circle: as well as reading data from the datalake through RAG (with vector search), your AI agent can write data to the log (through MCP) and trigger transformations (through Flink SQL in natural language). It is the deepest point of fusion between LLM ops and data ops this year.&lt;/p>
&lt;p>A connection with the earlier series: this MCP server emits traces with the OpenTelemetry GenAI MCP semantic conventions we covered in the &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability post&lt;/a>. The spans show up in Langfuse, Phoenix or your OTel backend with the right cardinality. Zero instrumentation code.&lt;/p>
&lt;h2 id="vector-stores-a-2026-comparison">Vector stores: a 2026 comparison&lt;/h2>
&lt;p>The five dominant options:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vector store&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Operation&lt;/th>
&lt;th>Where it fits&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>pgvector&lt;/strong>&lt;/td>
&lt;td>Postgres ext, OSS&lt;/td>
&lt;td>Your DBA&lt;/td>
&lt;td>&amp;lt;10M vectors, Postgres-heavy team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Qdrant&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Self-host or managed&lt;/td>
&lt;td>Mid-scale, performance focus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Milvus&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Self-host or Zilliz Cloud&lt;/td>
&lt;td>Large-scale, scalability focus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Weaviate&lt;/strong>&lt;/td>
&lt;td>BSD-3&lt;/td>
&lt;td>Self-host or managed&lt;/td>
&lt;td>Native hybrid search, semantically rich&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LanceDB&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Embedded or serverless&lt;/td>
&lt;td>Small-medium, simplicity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The choice depends on:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scale&lt;/strong>: pgvector falls short beyond 10M vectors. Milvus and Qdrant scale to billions.&lt;/li>
&lt;li>&lt;strong>Hybrid search&lt;/strong>: Weaviate brings lexical + vector natively. Others support it but less integrated.&lt;/li>
&lt;li>&lt;strong>Operation&lt;/strong>: pgvector if you already run Postgres. Qdrant if you want simplicity. Milvus if you need maximum scale.&lt;/li>
&lt;li>&lt;strong>Cloud managed&lt;/strong>: Zilliz Cloud for Milvus, Qdrant Cloud for Qdrant, Pinecone if you want pure SaaS (with no OSS behind it).&lt;/li>
&lt;/ul>
&lt;h2 id="freshness-vs-accuracy-the-operational-trade-off">Freshness vs accuracy: the operational trade-off&lt;/h2>
&lt;p>A critical decision any RAG system on Kafka must answer: when is a new document considered &amp;ldquo;live&amp;rdquo; in the index?&lt;/p>
&lt;p>Three options:&lt;/p>
&lt;p>&lt;strong>Synchronous streaming&lt;/strong>: the event reaches Kafka, Flink embeds it, the sink writes it to the vector store, and only then is it considered live. Typical latency: 1-5 seconds. The best freshness. But if the embedding model fails or the vector store is slow, events pile up in the topic.&lt;/p>
&lt;p>&lt;strong>Asynchronous streaming with a baseline&lt;/strong>: the event is considered live immediately; a background process embeds it when it can. In the meantime, queries asking for that document do not find it. Typical latency: 5-60 seconds. Acceptable for most applications.&lt;/p>
&lt;p>&lt;strong>Micro-batch&lt;/strong>: processing happens in mini-batches every 1-5 minutes. Less efficient than continuous streaming but more stable under variable load. Latency: 1-5 minutes.&lt;/p>
&lt;p>The decision depends on the product&amp;rsquo;s SLA. For customer support chatbots, 5-60 seconds is acceptable. For systems that react to critical events (financial prices, alarms), synchronous streaming is necessary.&lt;/p>
&lt;h2 id="schema-evolution-and-re-embedding">Schema evolution and re-embedding&lt;/h2>
&lt;p>When the embedding model changes (you move from &lt;code>text-embedding-3-small&lt;/code> to &lt;code>text-embedding-3-large&lt;/code>, or from OpenAI to Cohere), the vectors already in the index are incompatible: different dimensions, different semantic spaces. The distance between an old vector and a new one means nothing.&lt;/p>
&lt;p>The standard pattern for handling this: &lt;strong>dual-index&lt;/strong> during the migration.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>T0&lt;/strong>: the active index is V1 (embedding model A).&lt;/li>
&lt;li>&lt;strong>T1&lt;/strong>: a parallel pipeline starts writing to a V2 index (embedding model B), consuming the topic from offset 0 (reprocessing the whole log).&lt;/li>
&lt;li>&lt;strong>T2&lt;/strong>: V2 has caught up with the present.&lt;/li>
&lt;li>&lt;strong>T3&lt;/strong>: you switch the retriever over to V2.&lt;/li>
&lt;li>&lt;strong>T4&lt;/strong>: a week later, you discard V1.&lt;/li>
&lt;/ol>
&lt;p>The Kafka log makes this pattern feasible because it is &lt;strong>immutable and reproducible&lt;/strong>. Without the log, this pattern becomes a weeks-long data migration project.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;h3 id="topics-without-adequate-retention">Topics without adequate retention&lt;/h3>
&lt;p>Configuring topics with 7-day retention on the grounds that &amp;ldquo;I already have the vector store&amp;rdquo; means losing the ability to rebuild if the vector store fails. &lt;strong>Long retention (90+ days) or compaction by key&lt;/strong> for topics that feed RAG.&lt;/p>
&lt;h3 id="heavy-cdc-during-peak-load">Heavy CDC during peak load&lt;/h3>
&lt;p>Debezium reading the WAL at peak hours can hit the source database&amp;rsquo;s performance. &lt;strong>A dedicated read replica&lt;/strong> for Debezium, not the production primary. Or use logical replication restricted to just the tables you need.&lt;/p>
&lt;h3 id="embedding-cost-run-away">Embedding cost run-away&lt;/h3>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> on every event of a topic with millions of messages a day comes to thousands of USD per month. Strategies: filter before embedding (only embed what adds value); deduplicate by content hash; use self-hosted open-source embedding models (BGE, E5, GTE) when the cloud cost is prohibitive.&lt;/p>
&lt;h3 id="slow-re-embedding-due-to-limited-throughput">Slow re-embedding due to limited throughput&lt;/h3>
&lt;p>Recomputing 10M embeddings with the OpenAI API at 3000 req/min takes 55 hours. If you wait for an incident to re-embed, that is two days without service. &lt;strong>Embedding throughput is an explicit capacity planning item&lt;/strong>; reserve capacity or keep an offline job ready to start.&lt;/p>
&lt;h3 id="downstream-schema-breaks">Downstream schema breaks&lt;/h3>
&lt;p>A change to the raw topic&amp;rsquo;s schema breaks Flink jobs downstream. &lt;strong>Schema Registry with BACKWARD compatibility made mandatory&lt;/strong>; never ALLOW_ALL. And test schema evolution in CI.&lt;/p>
&lt;h3 id="a-vector-store-with-no-backup">A vector store with no backup&lt;/h3>
&lt;p>Your vector store holds 50M vectors. It is the only copy (the topics expired). A failure wipes it. &lt;strong>Vector stores must be backed up&lt;/strong> like any primary persistence layer. For Milvus/Qdrant: periodic snapshots. For pgvector: plain pg_dump.&lt;/p>
&lt;h3 id="multi-region-without-cross-cluster-replication">Multi-region without cross-cluster replication&lt;/h3>
&lt;p>Your RAG serves users in the US and the EU. The vector store is in US-east. Latency from the EU = 100 ms+ per query. &lt;strong>MirrorMaker or Cluster Linking&lt;/strong> to replicate topics and vector stores in both regions.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Hybrid search in production&lt;/strong>: combining BM25/lexical + vector + reranker. A topic for its own post.&lt;/li>
&lt;li>&lt;strong>Multimodal RAG&lt;/strong>: indexing images, audio and video alongside text. Multimodal embeddings (CLIP, Imagebind), a specific architecture.&lt;/li>
&lt;li>&lt;strong>GraphRAG&lt;/strong>: using structured knowledge (knowledge graphs) as well as vector retrieval. Microsoft GraphRAG, LlamaIndex KnowledgeGraphQueryEngine.&lt;/li>
&lt;li>&lt;strong>RAG with multi-tenant ACLs&lt;/strong>: filtering by permissions at runtime. The pattern uses metadata in the vector store + server-side filters.&lt;/li>
&lt;li>&lt;strong>Query rewriting with an LLM&lt;/strong>: using a first LLM to expand the query before retrieval (HyDE, multi-query, step-back prompting).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Kafka and stream processing:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kafka.apache.org/">Apache Kafka&lt;/a> y &lt;a href="https://debezium.io/">Debezium&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> y &lt;a href="https://www.apicur.io/registry/">Apicurio Registry&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> y &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/sql/overview/">Flink SQL docs&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://risingwave.com/">RisingWave&lt;/a> — alternativa SQL streaming con embedding built-in.&lt;/li>
&lt;/ul>
&lt;p>Vector store connectors:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus Sink Connector (Zilliz, GitHub)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://milvus.io/docs/kafka-connect-milvus.md">Connect Apache Kafka with Milvus (docs)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/qdrant/qdrant-kafka">Qdrant Kafka Sink (GitHub)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://callsphere.ai/blog/vector-database-benchmarks-2026-pgvector-qdrant-weaviate-milvus-lancedb">Vector Database Benchmarks 2026&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/streaming-vector-databases-platform-comparison">Streaming to Vector Databases (Streamkap)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tableflow and 2026 architecture:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.confluent.io/product/tableflow/">Tableflow — Confluent&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/tableflow-ga-kafka-snowflake-iceberg/">Tableflow GA: Real-Time Kafka to Iceberg (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/tableflow-delta-lake-databricks-unity-catalog-ga/">Tableflow + Databricks Unity Catalog (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/data-lake-governance-tableflow/">Better-Governed Data Lake Architectures with Tableflow (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kai-waehner.de/blog/2025/12/10/top-trends-for-data-streaming-with-apache-kafka-and-flink-in-2026/">Top Trends for Data Streaming with Kafka and Flink in 2026 (Kai Waehner)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Streaming RAG:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://risingwave.com/blog/rag-architecture-2026/">RAG Architecture in 2026: How to Keep Retrieval Actually Fresh (RisingWave)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/streaming-to-vector-databases">Streaming CDC Events to Vector Databases (Streamkap)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kai-waehner.de/blog/2023/11/08/apache-kafka-flink-vector-database-llm-real-time-genai/">Apache Kafka + Vector Database + LLM = Real-Time GenAI (Kai Waehner)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/pdf/2508.05662">From Static to Dynamic: A Streaming RAG Approach (arxiv 2508.05662)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://developer.confluent.io/confluent-tutorials/gen-ai-vector-embedding/flinksql/">How to generate vector embeddings for RAG with Flink SQL (Confluent Developer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dasroot.net/posts/2026/03/event-driven-architectures-ai-pipelines-kafka-flink/">Event-Driven Architectures for AI Pipelines (dasroot)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Previous post: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026: the landscape&lt;/a>.&lt;/li>
&lt;li>Data series: &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant at the ingestion stage&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — the four pieces that close the Data block: streaming, ingest, curation and retrieval.&lt;/li>
&lt;li>Post-tracing series: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>MLOps specific to LLMs in 2026: the landscape of three modalities, six stages and ten tools that make them work</title><link>https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/</link><pubDate>Thu, 21 May 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>This is the blog&amp;rsquo;s fourth series and it is called &lt;strong>MLOps specific to LLMs&lt;/strong>. It takes the operational craft of traditional MLOps (reproducible pipelines, model registries, dataset versioning, eval gates, controlled deployments) and redraws it for a world where the model is probabilistic, the outputs are subjective, the dependencies include external vendors that update weights without warning, and the &amp;ldquo;application&amp;rdquo; is not a model but an orchestration of models, embeddings, retrievers, guardrails and routers. Gartner predicts that more than 50% of enterprise GenAI deployments will fail before the end of 2026, and the main cause is not the model: it is that deterministic software assumptions were applied to probabilistic systems. This post opens the series with the frame: the seven structural differences between LLMOps and classic MLOps; the six-stage pipeline (data → tune → eval → deploy → observe → retrain); the three modalities of preparing a model (continuous fine-tuning, RAG over datalakes, agent training) with their decision matrix, where 60% of 2025-2026 deployments use hybrid because each modality solves a different problem: &amp;ldquo;fine-tune for behaviour, RAG for volatile knowledge&amp;rdquo;; and the 2026 tool landscape, which already forms reasonably stable layers: MLflow 3.10 (March 2026) as a GenAI-aware registry, W&amp;amp;B Weave and ZenML for tracing and pipelines, Kubeflow + KServe vLLM 0.8.1+ for serving, BentoML for flexibility, DVC + lakeFS (joined since November 2025) for data, Langfuse for prompts and observability. The three following posts will go down into the detail of the most critical pieces.&lt;/p>
&lt;blockquote>
&lt;p>This is the opening of series 4: MLOps for LLMs. It continues the tradition of the previous series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">LLM inference&lt;/a> (the first), &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF&lt;/a> (the second) and &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post-tracing&lt;/a> (the third). Here we enter the discipline that ties all the pieces together: how an LLM system is operated in production over months, not just deployed once.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-the-sres-craft-redrawn">The analogy: the SRE&amp;rsquo;s craft redrawn&lt;/h2>
&lt;p>Anyone who has worked for years as an SRE or a platform engineer recognises the classic pillars: reproducibility (same code + same data + same config = same result), observability (what happens can be measured), safe rollback (if something goes wrong, I go back within minutes), gradual rollout (the new thing goes to 1% before it goes to 100%). These pillars are not negotiable. The question is whether they hold up when the central component is an LLM.&lt;/p>
&lt;p>The answer is: &lt;strong>same pillars, radically different mechanics&lt;/strong>. Reproducibility: versioning code and data is no longer enough; you have to version prompts, retrieval configurations, snapshots of the vendor&amp;rsquo;s model (which change without warning). Observability: error and latency metrics are no longer enough; you have to measure subjective quality via LLM-as-judge and embedding drift. Rollback: dropping the binary&amp;rsquo;s version is no longer enough; you have to keep the old model cached because loading a new one takes minutes. Gradual rollout: a % of traffic is no longer enough; you have to decide what % of what kind of queries by segment.&lt;/p>
&lt;p>It is the same craft, practised with partly new tools and reflexes. MLOps specific to LLMs, or &amp;ldquo;LLMOps&amp;rdquo;, as the field has christened itself, is the discipline that codifies those reflexes.&lt;/p>
&lt;h2 id="the-seven-structural-differences-between-llmops-and-traditional-mlops">The seven structural differences between LLMOps and traditional MLOps&lt;/h2>
&lt;p>Before going down to the pipeline, let us fix the differences that make this territory new rather than a mere continuation. Each one has concrete practical consequences.&lt;/p>
&lt;h3 id="1-non-deterministic-outputs">1. Non-deterministic outputs&lt;/h3>
&lt;p>Traditional MLOps: the model receives structured input and returns a bounded, reproducible prediction. Same input → same output. Unit tests work.&lt;/p>
&lt;p>LLMOps: same input → different output every time (through sampling, through temperature, through the order of tools invoked, through the retrieval context that changed). The idea of a &amp;ldquo;unit test&amp;rdquo; breaks.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: tests over properties (was the tone maintained?, does it mention the source?, does it respect the JSON schema?), not over equality. Statistical evals over a distribution, not over samples.&lt;/p>
&lt;h3 id="2-behaviour-metrics-not-statistical-accuracy">2. Behaviour metrics, not statistical accuracy&lt;/h3>
&lt;p>Traditional MLOps: F1, accuracy, AUC, RMSE. Metrics with a clear number.&lt;/p>
&lt;p>LLMOps: subjective rubric scores (G-Eval, faithfulness, helpfulness, toxicity), judge LLMs, human feedback. The &amp;ldquo;number&amp;rdquo; depends on who judges.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: platforms have to treat evals as versioned artifacts, not just &amp;ldquo;model v3 scored 0.87&amp;rdquo;, but &amp;ldquo;model v3 evaluated with the judge claude-3-5-sonnet-20251022 over the dataset gold-rag-v7 with the prompt judge-v2 scored 0.87&amp;rdquo;. Versioning the judge is as important as versioning the model being evaluated.&lt;/p>
&lt;h3 id="3-the-model-is-an-external-dependency-not-an-internal-asset">3. The model is an external dependency, not an internal asset&lt;/h3>
&lt;p>Traditional MLOps: you train the model, it lives in your registry, it does not change until you change it.&lt;/p>
&lt;p>LLMOps: the base model belongs to Anthropic, OpenAI, Google, Meta. They change it on you without warning. The &lt;code>claude-3-5-sonnet&lt;/code> version that answered well yesterday answers somewhat differently today.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: drift detection becomes far more critical (&lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">previous post&lt;/a>). Pinning to specific snapshots (&lt;code>claude-3-5-sonnet-20251022&lt;/code>) when the vendor allows it. For high-commitment apps, self-hosting the base model to guarantee reproducibility.&lt;/p>
&lt;h3 id="4-the-application-is-an-orchestration-not-a-model">4. The application is an orchestration, not a model&lt;/h3>
&lt;p>Traditional MLOps: an app calls a model and consumes its output.&lt;/p>
&lt;p>LLMOps 2026: an app connects foundation model + LoRA adapters + retrievers + vector stores + guardrails + routers + tool servers (MCP) + evaluators, all composing the final behaviour. Any component can degrade the result.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: cross-component debugging requires distributed tracing with OTel (covered in previous posts). The registry does not only store &amp;ldquo;the model&amp;rdquo; but the composition: which prompt version + which adapter + which vector store + which retriever config.&lt;/p>
&lt;h3 id="5-cost-per-inference-not-per-training">5. Cost per inference, not per training&lt;/h3>
&lt;p>Traditional MLOps: the high cost is training; serving is cheap. You optimise training.&lt;/p>
&lt;p>LLMOps: the high cost is serving (every token costs, every call to the vendor is paid for, the GPUs doing the serving are switched on 24/7). You optimise inference.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: cost accountability per tenant, per agent, per tool. Metrics such as &lt;code>gen_ai.usage.input_tokens&lt;/code> aggregated at customer and product level. Model decisions based on cost per query, not only on quality.&lt;/p>
&lt;h3 id="6-gpu-heavy-infra-with-specific-primitives">6. GPU-heavy infra with specific primitives&lt;/h3>
&lt;p>Traditional MLOps: CPU + some GPU for training. Standard Kubernetes.&lt;/p>
&lt;p>LLMOps: Hopper/Blackwell SXM GPUs, NVLink/NVSwitch, tensor parallel, paged attention, KV cache. Infra that only fits into Kubernetes with primitives such as LeaderWorkerSet, GPU Operator, KEDA with LLM metrics (covered in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: the orchestration stack includes specialised operators (OME, vLLM Production Stack, NVIDIA Dynamo, llm-d, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>) that traditional MLOps does not contemplate.&lt;/p>
&lt;h3 id="7-rlhf-and-human-feedback-as-a-first-class-citizen">7. RLHF and human feedback as a first-class citizen&lt;/h3>
&lt;p>Traditional MLOps: human feedback means labelling data before training.&lt;/p>
&lt;p>LLMOps: human feedback lives inside the model in production, whether through RLHF of the foundation models (Anthropic, OpenAI), through RLAIF, through DPO, or through explicit user feedback that is fed back into fine-tuning.&lt;/p>
&lt;p>&lt;strong>Operational consequence&lt;/strong>: bidirectional production → training pipelines. Datasets grow with real incidents. Model decisions are taken with continuous feedback, not in a training project every N months.&lt;/p>
&lt;h2 id="why-gartner-predicts-50-failures">Why Gartner predicts 50%+ failures&lt;/h2>
&lt;p>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">Gartner published that more than 50% of enterprise GenAI deployments will fail before 2026&lt;/a>. The causes are not technical ones about the model but about the system:&lt;/p>
&lt;ul>
&lt;li>Hallucinated outputs from poor grounding: badly designed RAG, poor retrieval, insufficient context.&lt;/li>
&lt;li>Data architectures that are not ready: companies have data in silos, with no stable schemas, with no controlled freshness. Connecting an LLM to this data without a serious pipeline produces erratic answers.&lt;/li>
&lt;li>Lack of structured workflows for prompt-driven systems: teams that treat prompts as code in hardcoded strings, with no versioning, no tests, no gates.&lt;/li>
&lt;/ul>
&lt;p>The conclusion the field draws: &lt;strong>LLMOps is not optional&lt;/strong>. Companies that deploy GenAI without operational discipline fall into one of the three failure modes. Those that apply it (MLflow/W&amp;amp;B for tracking, DVC/lakeFS for data, Langfuse for prompts and evals, KServe or vLLM Production Stack for serving, drift detection in production) are the ones that keep the system working six months after the first release.&lt;/p>
&lt;h2 id="the-six-stage-llmops-pipeline">The six-stage LLMOps pipeline&lt;/h2>
&lt;p>On to the pipeline. The six stages that any serious LLM system goes through, in order:&lt;/p>
&lt;pre tabindex="0">&lt;code>[1. Data] → [2. Tune] → [3. Eval] → [4. Deploy] → [5. Observe] → [6. Retrain]
│
└─→ back to 1
&lt;/code>&lt;/pre>&lt;p>Each stage is an operational domain of its own, with its own tools and traps:&lt;/p>
&lt;h3 id="stage-1--data">Stage 1 — Data&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: ingestion, cleaning, curation, versioning, indexing of the corpus. It is where real projects suffer most, because companies have data in heterogeneous silos.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: extraction from source (CDC over Kafka, batch from data lakes, scraping), cleaning (PII removal, dedup, format), curation (labelling for fine-tuning, golden datasets for eval), versioning (DVC + lakeFS), indexing (embeddings + vector store for RAG).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: schema drift at the source, undetected PII, poor dedup that injects redundancy into training, a vector store that is not updated.&lt;/p>
&lt;h3 id="stage-2--tune">Stage 2 — Tune&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: preparing the model for your use case. Three modalities (we go deeper into them shortly): fine-tuning, RAG, agent training.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: base model selection, adapter preparation (LoRA, QLoRA), training loop with continuous eval, hyperparameter sweep (Optuna, W&amp;amp;B Sweeps), saving the checkpoint.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: catastrophic forgetting if the fine-tuning is very aggressive, overfitting to the golden dataset, no independent validation set.&lt;/p>
&lt;h3 id="stage-3--eval">Stage 3 — Eval&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: validating that the model + adapters + RAG configuration is acceptable before promoting. Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: running an eval framework (DeepEval, Promptfoo, Ragas) against the golden dataset, judge LLM evaluations, human review over a sample, gates with thresholds.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: a golden dataset that ages, an uncalibrated judge, evals that pass in CI but fail in production because of distribution shift.&lt;/p>
&lt;h3 id="stage-4--deploy">Stage 4 — Deploy&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: going from &amp;ldquo;the model evaluated well&amp;rdquo; to &amp;ldquo;the model serves real traffic&amp;rdquo;. Covered in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: serving with vLLM/SGLang/TRT-LLM, runtime configuration, gradual rollout (canary, shadow, blue-green), routing between models (LiteLLM, OpenRouter, LangChain routers).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: a naive rolling update that cuts sessions, autoscaling by CPU% that does not respond to LLM metrics (covered), a new model that performs worse in production than in eval.&lt;/p>
&lt;h3 id="stage-5--observe">Stage 5 — Observe&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: seeing what is happening in real time. Covered in the whole post-tracing series and the eBPF series.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: tracing (Langfuse, LangSmith, Phoenix, OpenLLMetry), metrics (TTFT, TPOT, queue depth, cost per query), active guardrails (NeMo, Llama Guard), drift detection (Evidently, NannyML, WhyLabs).&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: cardinality explosion in metrics, batch evals with no tail-sampling over real traces, drift that is ignored until an incident makes it real.&lt;/p>
&lt;h3 id="stage-6--retrain">Stage 6 — Retrain&lt;/h3>
&lt;p>&lt;strong>What happens&lt;/strong>: closing the loop. Production feedback (incidents, worst-scoring cases, detected drift) generates new data to go back to stage 1.&lt;/p>
&lt;p>&lt;strong>Typical sub-tasks&lt;/strong>: extraction of problematic logs, human labelling of the sample, incorporation into the golden dataset, re-fine-tuning if applicable, decision on a new release.&lt;/p>
&lt;p>&lt;strong>Traps&lt;/strong>: an &amp;ldquo;open&amp;rdquo; loop where production never informs the dataset, human feedback that gets lost, lack of a clear retrain cadence.&lt;/p>
&lt;h2 id="the-three-modalities-of-preparing-the-model">The three modalities of &amp;ldquo;preparing the model&amp;rdquo;&lt;/h2>
&lt;p>Stage 2 (Tune) is where there is most confusion. In 2026 three modalities coexist, each solving a different problem:&lt;/p>
&lt;h3 id="fine-tuning">Fine-tuning&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: modifying the model&amp;rsquo;s weights (or those of a LoRA/QLoRA adapter on top) so that it learns specific behaviour patterns: tone, output structure, idiomatic decisions of the domain.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when your main failure is behavioural inconsistency between calls. The model sometimes answers formally, sometimes not; sometimes it structures the JSON, sometimes not; sometimes it follows the company&amp;rsquo;s conventions, sometimes it invents them. Fine-tuning stabilises it.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when what you need is up-to-date knowledge. Fine-tuning fixes knowledge in frozen weights; the day after the fine-tuning, the model knows nothing new.&lt;/p>
&lt;h3 id="rag-retrieval-augmented-generation">RAG (Retrieval-Augmented Generation)&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: leaving the model intact and, on each call, retrieving fresh context from a knowledge base (vector store + lexical search, typically) and passing it to the model so that it answers on that basis.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when the knowledge you need is dynamic or very large. Documentation that changes, a product catalogue that is updated, an internal knowledge base that grows.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when the problem is behavioural (RAG does not teach the model how to behave, it only gives it information). Or when retrieval is so noisy that the context arriving is worse than nothing.&lt;/p>
&lt;h3 id="agent-training">Agent training&lt;/h3>
&lt;p>&lt;strong>What it does&lt;/strong>: going beyond conventional fine-tuning with Reinforcement Learning techniques. RFT (OpenAI&amp;rsquo;s Reinforcement Fine-Tuning), classic RLHF, RLAIF (with AI feedback), DPO (Direct Preference Optimization) over datasets of (good, bad) pairs.&lt;/p>
&lt;p>&lt;strong>When&lt;/strong>: when the model needs to learn complex multistep trajectories: when to choose each tool, how to decompose a task, when to ask the user for confirmation. It is what is turning Claude, Gemini and GPT into agents capable of long tasks.&lt;/p>
&lt;p>&lt;strong>When NOT&lt;/strong>: when your case is simple chat or RAG. It is overkill, expensive and complicated for problems that the previous modalities solve.&lt;/p>
&lt;h3 id="decision-matrix">Decision matrix&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observed problem&lt;/th>
&lt;th>Modality&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inconsistent answers in tone/structure&lt;/td>
&lt;td>Fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model makes things up (hallucinates)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Out-of-date knowledge (&amp;gt;1 year)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model chooses tools badly&lt;/td>
&lt;td>Agent training (RLAIF/RFT)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixed behaviour + knowledge&lt;/td>
&lt;td>Hybrid (fine-tune + RAG)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-step trajectory fails&lt;/td>
&lt;td>Agent training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A specific language/regional style&lt;/td>
&lt;td>Fine-tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-2026-verdict-hybrid-is-the-default">The 2026 verdict: hybrid is the default&lt;/h3>
&lt;p>&lt;a href="https://www.scalacode.com/blog/rag-vs-fine-tuning/">Multiple reports&lt;/a> agree that in 2025-2026, &lt;strong>around 60% of production projects use hybrid&lt;/strong>: fine-tuning for behaviour + RAG for knowledge. The key insight:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Fine-tune for behaviour (brand voice, decision protocol, output structure); use RAG for volatile knowledge you need the model to cite. Do not force a single tool to do both jobs.&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>A practical observation: the biggest quality improvements of 2025-2026 come from better reranking in RAG (cross-encoders), not from better embeddings. Rerankers add 15-35% of quality with little complexity.&lt;/p>
&lt;p>On cost: combined fine-tuning + RAG tends to be 30-50% cheaper than pure RAG with frontier models at high volume, because the fine-tuned model can be smaller and cheaper while keeping equivalent quality.&lt;/p>
&lt;h2 id="the-2026-tool-landscape">The 2026 tool landscape&lt;/h2>
&lt;p>On to the concrete pieces, grouped by function. The field has matured enough for each piece to have 2-3 reasonable options and a couple of leaders.&lt;/p>
&lt;h3 id="experiment-tracking-and-model-registry">Experiment tracking and model registry&lt;/h3>
&lt;p>&lt;a href="https://mlflow.org/">MLflow&lt;/a> remains the de facto standard, now with LLM-specific traction. MLflow 3 was published in June 2025; version 3.10.1 (March 2026) added:&lt;/p>
&lt;ul>
&lt;li>A GenAI Overview dashboard with ready-made metrics for LLM apps.&lt;/li>
&lt;li>Multi-workspace support for large teams.&lt;/li>
&lt;li>Cost tracking in traces (gen_ai.usage.* aggregated per experiment).&lt;/li>
&lt;li>MemAlign: a new specific eval algorithm.&lt;/li>
&lt;li>Integrated native OpenTelemetry tracing.&lt;/li>
&lt;li>First-class support for LangChain, LlamaIndex, AutoGen as frameworks.&lt;/li>
&lt;/ul>
&lt;p>MLflow treats prompts and agents as first-class citizens alongside classic models. It is the biggest change relative to MLflow 2.x.&lt;/p>
&lt;p>&lt;a href="https://wandb.ai/">Weights &amp;amp; Biases (W&amp;amp;B)&lt;/a> with its LLM-specific product Weave offers tracing + eval + debug with a very polished UI. More commercial, less self-host friendly, but excellent UX.&lt;/p>
&lt;p>&lt;a href="https://www.zenml.io/">ZenML&lt;/a> is the piece that most cleanly integrates &amp;ldquo;classic MLOps + emerging LLMOps&amp;rdquo; into a single framework. Its automatic artifact versioning captures prompt templates, retrieval chunks and agent conversation histories with no extra work. Open-source. The most complete unification option that exists.&lt;/p>
&lt;h3 id="dataset-versioning">Dataset versioning&lt;/h3>
&lt;p>&lt;a href="https://dvc.org/">DVC&lt;/a> remains the OSS standard. It extends Git to large files and pipelines. Important news from November 2025: &lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquired DVC&lt;/a>, consolidating the two OSS data versioning projects under one organisation. The combined roadmap is oriented towards LLM training and RAG datalakes specifically.&lt;/p>
&lt;p>&lt;strong>Typical pattern&lt;/strong>: Git for code + DVC for data/models + MLflow or W&amp;amp;B for experiment tracking + registry. Few teams use just one; the combination is what covers the cycle. Detailed in the &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dedicated post on data versioning for LLMOps&lt;/a> — the four artifacts to version separately, schema contracts, end-to-end lineage, and a golden eval set with a strict holdout.&lt;/p>
&lt;h3 id="prompt-versioning-and-observability">Prompt versioning and observability&lt;/h3>
&lt;p>Covered in depth in the &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight post&lt;/a> where we go deeper into Langfuse as the OSS reference. Summary here:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>: MIT, self-host, prompt management with v1/v2/v3 versioning + labels + cache + linkage with traces.&lt;/li>
&lt;li>&lt;a href="https://www.langchain.com/langsmith">LangSmith&lt;/a>: if your stack is LangChain.&lt;/li>
&lt;li>&lt;a href="https://phoenix.arize.com/">Arize Phoenix&lt;/a>: ELv2, OTel-native.&lt;/li>
&lt;/ul>
&lt;h3 id="pipeline-orchestration">Pipeline orchestration&lt;/h3>
&lt;p>For the LLMOps pipeline steps, the dominant options:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.kubeflow.org/docs/components/pipelines/">Kubeflow Pipelines&lt;/a>: the K8s-native standard. KServe (the serving part of Kubeflow) has its vLLM runtime upgraded to v0.8.1+ with support for reasoning models, tool calling, embeddings, reranking, Llama 4 and Qwen 3.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/">ZenML&lt;/a>: already mentioned; also a pipeline orchestrator.&lt;/li>
&lt;li>&lt;a href="https://metaflow.org/">Metaflow&lt;/a> (Netflix-originated): Python-first pipelines, less LLM-specific but workable.&lt;/li>
&lt;li>&lt;a href="https://argoproj.github.io/workflows/">Argo Workflows&lt;/a>: a pure K8s OSS alternative.&lt;/li>
&lt;li>&lt;a href="https://flyte.org/">Flyte&lt;/a>: Kubernetes-native, OSS.&lt;/li>
&lt;/ul>
&lt;h3 id="serving">Serving&lt;/h3>
&lt;p>Covered in depth in &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>. Summary:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a>: a curated Helm chart.&lt;/li>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe vLLM runtime&lt;/a>: K8s-native, vLLM 0.8.1+ with full agentic support.&lt;/li>
&lt;li>&lt;a href="https://www.bentoml.com/">BentoML&lt;/a>: flexible serving, popular in startups for its simplicity.&lt;/li>
&lt;li>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a>: the successor to Triton.&lt;/li>
&lt;li>&lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a>: CNCF Sandbox.&lt;/li>
&lt;li>&lt;a href="https://github.com/ome-projects/ome">OME&lt;/a>: an LMSYS operator with native SGLang.&lt;/li>
&lt;/ul>
&lt;h3 id="evals-and-guardrails">Evals and guardrails&lt;/h3>
&lt;p>Covered in &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>. Ultra-short summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Evals CI&lt;/strong>: DeepEval, Promptfoo, Ragas.&lt;/li>
&lt;li>&lt;strong>Evals platform&lt;/strong>: Langfuse, LangSmith, Phoenix, Braintrust.&lt;/li>
&lt;li>&lt;strong>Guardrails&lt;/strong>: NeMo Guardrails, Llama Guard 4, Llama Prompt Guard 2, LLM Guard, Lakera.&lt;/li>
&lt;/ul>
&lt;h3 id="drift-detection-and-observability">Drift detection and observability&lt;/h3>
&lt;p>Covered in the &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">closing eBPF post&lt;/a>. Summary:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drift&lt;/strong>: Evidently AI, NannyML, WhyLabs.&lt;/li>
&lt;li>&lt;strong>Tracing&lt;/strong>: Langfuse, OpenLLMetry, Phoenix.&lt;/li>
&lt;li>&lt;strong>eBPF&lt;/strong>: AgentSight, Hubble, Tetragon, ProfInfer.&lt;/li>
&lt;/ul>
&lt;h3 id="the-typical-2026-stack-table">The typical 2026 stack table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Dominant piece&lt;/th>
&lt;th>Alternatives&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Data ingestion + versioning&lt;/td>
&lt;td>DVC + lakeFS (unified Nov 2025)&lt;/td>
&lt;td>Pachyderm, Quilt&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector store / RAG index&lt;/td>
&lt;td>Milvus, Qdrant, pgvector, Weaviate&lt;/td>
&lt;td>LanceDB, Pinecone, Chroma&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Experiment tracking&lt;/td>
&lt;td>MLflow 3.10&lt;/td>
&lt;td>W&amp;amp;B Weave, Neptune&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pipeline orchestration&lt;/td>
&lt;td>Kubeflow + Argo&lt;/td>
&lt;td>ZenML, Metaflow, Flyte&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model registry&lt;/td>
&lt;td>MLflow registry&lt;/td>
&lt;td>W&amp;amp;B Models, KServe ModelMesh&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt versioning&lt;/td>
&lt;td>Langfuse&lt;/td>
&lt;td>LangSmith, MLflow Prompts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Serving&lt;/td>
&lt;td>vLLM Production Stack&lt;/td>
&lt;td>KServe, BentoML, Dynamo, llm-d, OME&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Evals CI&lt;/td>
&lt;td>DeepEval, Ragas&lt;/td>
&lt;td>Promptfoo, OpenAI Evals&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Evals platform&lt;/td>
&lt;td>Langfuse, Phoenix&lt;/td>
&lt;td>LangSmith, Braintrust&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Guardrails&lt;/td>
&lt;td>NeMo + Llama Guard&lt;/td>
&lt;td>LLM Guard, Lakera&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tracing&lt;/td>
&lt;td>OpenLLMetry + Langfuse&lt;/td>
&lt;td>Phoenix, LangSmith&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift detection&lt;/td>
&lt;td>Evidently AI&lt;/td>
&lt;td>NannyML, WhyLabs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>eBPF observability&lt;/td>
&lt;td>AgentSight + Tetragon + Hubble&lt;/td>
&lt;td>(new territory, few alternatives)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>13 pieces. No org uses them all; any serious org uses at least six. &lt;strong>This is the current LLMOps stack&lt;/strong>.&lt;/p>
&lt;h2 id="the-operational-reality-nobody-uses-a-single-tool">The operational reality: nobody uses a single tool&lt;/h2>
&lt;p>&lt;a href="https://medium.com/@kanerika/mlflow-vs-kubeflow-vs-w-b-which-mlops-tool-fits-your-stack-b59007460b25">Multiple comparisons&lt;/a> agree on one thing: &lt;strong>the teams that win combine&lt;/strong>. Recurring patterns:&lt;/p>
&lt;ul>
&lt;li>ZenML to orchestrate + MLflow for tracking + KServe for serving: the most popular OSS stack in companies coming from classic MLOps.&lt;/li>
&lt;li>Kubeflow + W&amp;amp;B + BentoML: for teams with a research focus.&lt;/li>
&lt;li>Langfuse + DeepEval + Phoenix + LiteLLM: for pure-LLM teams with no classic MLOps background.&lt;/li>
&lt;li>MLflow + DVC + Argo + KServe: an idiomatic cloud-native stack without additional LLM-specifics (with its limitations).&lt;/li>
&lt;/ul>
&lt;p>The choice depends on the team&amp;rsquo;s background, on the licensing model they can afford, on the level of self-hosting they need, and on which frictions blocked them most in previous projects. &lt;strong>There is no &amp;ldquo;one right answer&amp;rdquo;&lt;/strong>; there is a stable meta-pattern of layers worth covering.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="treating-the-prompt-as-text-in-code">Treating the prompt as text in code&lt;/h3>
&lt;p>Hardcoding prompts in strings in the repo. Changing them requires a PR + redeploy. Result: teams that do not iterate on prompts because every change costs hours of pipeline. &lt;strong>Solution&lt;/strong>: externalised prompt management (Langfuse, MLflow Prompts) with versioning, labels, hot-reload.&lt;/p>
&lt;h3 id="skipping-dataset-versioning">Skipping dataset versioning&lt;/h3>
&lt;p>&amp;ldquo;DVC is complicated, we will add it later&amp;rdquo;. Result: two months later, nobody knows which dataset trained which model. Impossible to reproduce incidents. &lt;strong>Solution&lt;/strong>: DVC + lakeFS from day 1, even if only with a small subset.&lt;/p>
&lt;h3 id="mixing-layers-in-the-same-pipeline">Mixing layers in the same pipeline&lt;/h3>
&lt;p>Teams that put ingestion, fine-tuning, eval and deploy into a single giant pipeline. When something fails, the whole pipeline fails. &lt;strong>Solution&lt;/strong>: independent pipelines per stage, with versioned artifacts as the interfaces between them.&lt;/p>
&lt;h3 id="tracking-with-no-structure">Tracking with no structure&lt;/h3>
&lt;p>Logging everything to stdout and &amp;ldquo;we will look at it in CloudWatch later&amp;rdquo;. Result: impossible to correlate, compare, debug. &lt;strong>Solution&lt;/strong>: OTel from day 1 with &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;h3 id="evals-that-block-nothing">Evals that block nothing&lt;/h3>
&lt;p>You have evals, you run them, you look at them, but they do not prevent the deploy if they drop. Eventually it drops gradually and nobody notices. &lt;strong>Solution&lt;/strong>: eval gates in CI/CD that block the merge if critical metrics regress by more than X%.&lt;/p>
&lt;h3 id="no-retrain-cadence">No retrain cadence&lt;/h3>
&lt;p>You launch v1 and never go back to the model. Six months later, drift has degraded it but the team is on other projects. &lt;strong>Solution&lt;/strong>: a formal retrain cadence (monthly, quarterly) tied to the production incident queue.&lt;/p>
&lt;h3 id="invisible-vendor-lock-in">Invisible vendor lock-in&lt;/h3>
&lt;p>You start with the OpenAI API + LangSmith + Pinecone. When you want to self-host, you discover that migrating is a 3-month project. &lt;strong>Solution&lt;/strong>: abstraction layers (LiteLLM, OpenLLMetry) and vendor-neutrality from the start.&lt;/p>
&lt;h2 id="what-is-coming-in-the-following-posts-of-the-series">What is coming in the following posts of the series&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Post 2 — &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over datalakes with Kafka: end-to-end technical architecture&lt;/a>&lt;/strong> — the most hands-on one. Kafka as source-of-truth, Flink CDC, embedding pipelines, continuous indexing in Milvus/Qdrant, a complete example with real numbers and manifests.&lt;/li>
&lt;li>&lt;strong>Post 3 — &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline: global architecture&lt;/a>&lt;/strong> — the master map of the complete system with a reusable &amp;ldquo;you are here&amp;rdquo; SVG for the following posts. A deep dive into each of the six stages (Data, Tune, Eval, Deploy, Observe, Retrain).&lt;/li>
&lt;li>&lt;strong>Post 4 — &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a>&lt;/strong> — synchronisation patterns (dual-write, outbox + CDC, event-driven), a complete microservices architecture, a Qdrant cluster manifest.&lt;/li>
&lt;li>&lt;strong>Upcoming posts&lt;/strong> — still to be decided: the cluster as a multi-tenant platform, Constitutional AI / alignment runtime, continuous fine-tuning in depth, edge LLMs.&lt;/li>
&lt;li>&lt;strong>Synthesis post&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the complete journey of a real request through the six stages and the two cross-cutting components, with cross-links to each dedicated post. It works as a mental map of the blog and as an integrator&amp;rsquo;s guide.&lt;/li>
&lt;li>&lt;strong>Parallel OSS vs hyperscalers catalogue&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">The parallel catalogue: the six LLMOps stages in OSS and in AWS / GCP / Azure&lt;/a> — for each stage of the pipeline, which tools the blog&amp;rsquo;s reference OSS stack uses and what the cloud equivalents are, with summary tables, identification of real gaps and the multi-tenant chatbot ported to an AWS stack component by component. A neutral editorial stance.&lt;/li>
&lt;li>&lt;strong>OSS catalogue entry by entry&lt;/strong> — &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages: entry by entry&lt;/a> — the zoom in on the open source side: ~150 words of description per core tool (vLLM, Langfuse, DVC, Qdrant, Airflow, NeMo Guardrails, Presidio…), licence and governance, a decision matrix per stage, a diagram of the connected OSS stack and a master table of licences and EE / SaaS offerings.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>LLMOps vs MLOps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@sanjeebmeister/the-complete-mlops-llmops-roadmap-for-2026-building-production-grade-ai-systems-bdcca5ed2771">The Complete MLOps/LLMOps Roadmap for 2026 (Sanjeeb Panda)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.iamraghuveer.com/posts/mlops-vs-llmops-what-changes/">MLOps vs LLMOps: What Changes (Raghuveer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://hyscaler.com/insights/mlops-in-2026-guide/">MLOps in 2026: Architecture, Trends &amp;amp; Strategy (Hyscaler)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.ideas2it.com/blogs/llmops-vs-mlops-key-differences-and-evolution">LLMOps vs MLOps: Differences and Evolution (Ideas2IT)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/apprecode/llmops-vs-mlops-whats-different-whats-the-same-and-how-to-run-both-in-production-2o52">LLMOps vs MLOps in production (DEV/Apprecode)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tools:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://mlflow.org/">MLflow&lt;/a> — registry + tracking + serving.&lt;/li>
&lt;li>&lt;a href="https://wandb.ai/site/weave">Weights &amp;amp; Biases Weave&lt;/a> — LLM tracing.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/">ZenML&lt;/a> — pipeline orchestration MLOps + LLMOps.&lt;/li>
&lt;li>&lt;a href="https://www.kubeflow.org/">Kubeflow&lt;/a> — K8s-native MLOps.&lt;/li>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe&lt;/a> — model serving K8s.&lt;/li>
&lt;li>&lt;a href="https://www.bentoml.com/">BentoML&lt;/a> — flexible serving.&lt;/li>
&lt;li>&lt;a href="https://metaflow.org/">Metaflow&lt;/a> — Netflix&amp;rsquo;s pipelines.&lt;/li>
&lt;li>&lt;a href="https://dvc.org/">DVC&lt;/a> — dataset versioning.&lt;/li>
&lt;li>&lt;a href="https://lakefs.io/">lakeFS&lt;/a> — enterprise data versioning, acquired DVC in Nov 2025.&lt;/li>
&lt;/ul>
&lt;p>2026 comparisons:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://medium.com/@kanerika/mlflow-vs-kubeflow-vs-w-b-which-mlops-tool-fits-your-stack-b59007460b25">MLflow vs Kubeflow vs W&amp;amp;B (Kanerika)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/mlflow-alternatives">9 MLflow alternatives tested (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/metaflow-vs-kubeflow">Metaflow vs Kubeflow vs ZenML (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.zenml.io/blog/mlops-tools">12 Best MLOps Tools for Agentic AI (ZenML)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/mlops-pipeline-gpu-cloud-kubeflow-zenml-metaflow-2026/">MLOps Pipeline on GPU Cloud 2026 (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://northflank.com/blog/top-7-kubeflow-alternatives">Top 7 Kubeflow alternatives 2026 (Northflank)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.sganalytics.com/blog/mlops-tools/">Top 20 MLOps Tools 2026 (SG Analytics)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>RAG vs Fine-Tuning:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.scalacode.com/blog/rag-vs-fine-tuning/">RAG Vs Fine-Tuning In 2026 (ScalaCode)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.arxiv.org/pdf/2510.01375">Fine-Tuning with RAG (ICLR 2026, arxiv)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dev.to/tyson_cung/rag-vs-fine-tuning-what-actually-works-in-production-2026-20jg">RAG vs Fine-Tuning — What Actually Works in Production 2026 (DEV)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kapa.ai/blog/how-to-build-a-rag-pipeline-from-scratch-in-2026">How to Build a RAG Pipeline from Scratch in 2026 (kapa.ai)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references (the three previous series):&lt;/p>
&lt;ul>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;li>Post-tracing series: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>