<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Pgvector on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/pgvector/</link><description>Recent content in Pgvector on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 11 Jun 2026 09:00:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/pgvector/index.xml" rel="self" type="application/rss+xml"/><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>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>Continuous fine-tuning in production: from real traffic to the deployed adapter</title><link>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/</link><pubDate>Thu, 21 May 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Continuous fine-tuning is not &amp;ldquo;training the model every so often&amp;rdquo;. It is a &lt;strong>closed loop&lt;/strong> where real production traffic generates the datasets, a short pipeline trains a LoRA adapter, a battery of evaluations decides whether it is promoted, and vLLM loads it &lt;strong>without restarting&lt;/strong>. The state of the art in May 2026 has fragmented the stack: it is no longer DPO against everything, but a choice between SFT, DPO, KTO, ORPO and SimPO according to the type of signal your product captures. What has consolidated the pattern is the combination of PostgreSQL 18 + pgvector 0.8 as the &lt;strong>nervous system of the pipeline&lt;/strong>, traffic capture, dataset versioning, eval results, adapter registry, together with vLLM multi-LoRA hot-swap, which turns deployment into an HTTP call. This article takes the cycle apart with concrete schemas, real queries, and the numbers it costs on an RTX 4090 against a 4×H100 cluster.&lt;/p>
&lt;h2 id="you-are-here-tune--retrain">You are here: Tune + Retrain&lt;/h2>
&lt;p>This post crosses two stages 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>: the decision to &lt;strong>train a new adapter&lt;/strong> (the &lt;strong>Tune&lt;/strong> stage) is triggered by the signals from &lt;strong>Observe&lt;/strong> that travel through the &lt;strong>Retrain&lt;/strong> stage until the loop closes. The post takes apart the complete circuit between the two boxes.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Tune + Retrain">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.active2{fill:#fff5b0;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#ftm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#ftm)}&lt;/style>
&lt;defs>&lt;marker id="ftm" 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;text x="390" y="20" text-anchor="middle" class="lbl">You are here: TUNE + RETRAIN · continuous adapter cycle driven by real traffic&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box active"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box active2"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-restaurant-that-tunes-its-menu">The analogy: the restaurant that tunes its menu&lt;/h2>
&lt;p>Picture a neighbourhood restaurant with a signature dish that works, but the chef knows it can be tuned. Every night things happen:&lt;/p>
&lt;ul>
&lt;li>Some diners &lt;strong>leave part of the dish&lt;/strong>: a weak signal that something did not quite land.&lt;/li>
&lt;li>Others ask for &lt;strong>another version&lt;/strong> (&amp;ldquo;could you put less salt in it?&amp;rdquo;): an explicit, directional signal.&lt;/li>
&lt;li>Others &lt;strong>finish the dish and come back the following week&lt;/strong>: the only signal that really matters, but it arrives late.&lt;/li>
&lt;li>And a select group offers an opinion without being asked, usually a negative one.&lt;/li>
&lt;/ul>
&lt;p>The chef does not redo the menu every night. He does something more interesting: he writes down in a notebook the dishes served, the returns, the changes requested, the tips. Every so often, he &lt;strong>reads the whole notebook&lt;/strong>, decides on minimal adjustments to a recipe, tries the new version at a private table with his staff, and only if they take to it does he add it to the next day&amp;rsquo;s menu. Sometimes he even serves two different versions of the dish to different tables for a week, measures what happens, and picks one.&lt;/p>
&lt;p>That is &lt;strong>continuous fine-tuning&lt;/strong>. The notebook is Postgres. The dish is the base model. The notes are feedback signals, explicit and implicit. The &amp;ldquo;minimal adjustment&amp;rdquo; is a 30 MB LoRA adapter. The private table is the battery of automated evaluations. The next day&amp;rsquo;s menu is vLLM with multi-LoRA hot-swap, which loads the new adapter without restarting the service. Serving two versions to different tables is A/B testing with real traffic.&lt;/p>
&lt;p>The analogy is exact on one critical point: &lt;strong>the chef does not throw away the original recipe&lt;/strong>. He keeps the base recipe and holds a separate notebook with the &amp;ldquo;modifications that work well for the neighbourhood regulars&amp;rdquo;. That notebook is the LoRA adapter: on top of the base model, not in its place.&lt;/p>
&lt;h2 id="the-cycle-taken-apart">The cycle, taken apart&lt;/h2>
&lt;p>Before going into components, it helps to fix the complete flow. These seven steps are what any serious team replicates with variations:&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Closed loop of continuous fine-tuning">
&lt;style>
.node { fill: #f4f4f4; stroke: #333; stroke-width: 1.5; }
.n-serve { fill: #d6eaff; }
.n-data { fill: #ffe9d6; }
.n-train { fill: #d9f5d6; }
.n-eval { fill: #f4e1ff; }
.lbl { font: 600 12px sans-serif; fill: #222; }
.lbl-sm { font: 10.5px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.4; fill: none; marker-end: url(#ah2); }
.arr-dim { stroke: #888; stroke-width: 1.2; fill: none; stroke-dasharray: 4,3; marker-end: url(#ah2); }
&lt;/style>
&lt;defs>
&lt;marker id="ah2" 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="#444"/>
&lt;/marker>
&lt;/defs>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">The closed loop of continuous fine-tuning&lt;/text>
&lt;rect x="280" y="40" width="160" height="50" rx="8" class="node n-serve"/>
&lt;text x="360" y="62" text-anchor="middle" class="lbl">1 · vLLM serving&lt;/text>
&lt;text x="360" y="78" text-anchor="middle" class="lbl-sm">base + active adapters&lt;/text>
&lt;rect x="500" y="120" width="170" height="50" rx="8" class="node n-data"/>
&lt;text x="585" y="142" text-anchor="middle" class="lbl">2 · Traffic capture&lt;/text>
&lt;text x="585" y="158" text-anchor="middle" class="lbl-sm">prompts, responses, feedback&lt;/text>
&lt;rect x="500" y="210" width="170" height="50" rx="8" class="node n-data"/>
&lt;text x="585" y="232" text-anchor="middle" class="lbl">3 · Curation&lt;/text>
&lt;text x="585" y="248" text-anchor="middle" class="lbl-sm">dedup, PII, balancing, snapshot&lt;/text>
&lt;rect x="280" y="290" width="160" height="50" rx="8" class="node n-train"/>
&lt;text x="360" y="312" text-anchor="middle" class="lbl">4 · LoRA training&lt;/text>
&lt;text x="360" y="328" text-anchor="middle" class="lbl-sm">SFT / DPO / KTO / ORPO / SimPO&lt;/text>
&lt;rect x="50" y="210" width="170" height="50" rx="8" class="node n-eval"/>
&lt;text x="135" y="232" text-anchor="middle" class="lbl">5 · Eval gates&lt;/text>
&lt;text x="135" y="248" text-anchor="middle" class="lbl-sm">3 stages: PR, full, canary&lt;/text>
&lt;rect x="50" y="120" width="170" height="50" rx="8" class="node n-train"/>
&lt;text x="135" y="142" text-anchor="middle" class="lbl">6 · Adapter registry&lt;/text>
&lt;text x="135" y="158" text-anchor="middle" class="lbl-sm">status: canary | prod | retired&lt;/text>
&lt;rect x="50" y="40" width="170" height="50" rx="8" class="node n-serve"/>
&lt;text x="135" y="62" text-anchor="middle" class="lbl">7 · Hot-swap&lt;/text>
&lt;text x="135" y="78" text-anchor="middle" class="lbl-sm">POST /v1/load_lora_adapter&lt;/text>
&lt;path class="arr" d="M440,75 C480,80 495,100 510,120"/>
&lt;path class="arr" d="M585,170 L585,210"/>
&lt;path class="arr" d="M500,250 C460,270 440,280 440,300"/>
&lt;path class="arr" d="M280,315 C240,290 230,275 220,260"/>
&lt;path class="arr" d="M135,210 L135,170"/>
&lt;path class="arr" d="M135,120 L135,90"/>
&lt;path class="arr" d="M220,65 L280,65"/>
&lt;rect x="280" y="170" width="160" height="80" rx="10" fill="#fffae6" stroke="#d4a52a" stroke-width="2"/>
&lt;text x="360" y="200" text-anchor="middle" class="lbl">PostgreSQL 18&lt;/text>
&lt;text x="360" y="218" text-anchor="middle" class="lbl-sm">+ pgvector 0.8&lt;/text>
&lt;text x="360" y="234" text-anchor="middle" class="lbl-sm">single source of truth&lt;/text>
&lt;path class="arr-dim" d="M500,145 L440,180"/>
&lt;path class="arr-dim" d="M500,235 L440,225"/>
&lt;path class="arr-dim" d="M280,225 L220,235"/>
&lt;path class="arr-dim" d="M280,200 L220,160"/>
&lt;path class="arr-dim" d="M360,290 L360,250"/>
&lt;/svg>
&lt;/div>
&lt;p>The cycle lasts between 1 and 4 weeks in real production. What changes between teams is the tempo (faster in assistant chat, slower in regulated banking) and the details of each step. The structure is the same.&lt;/p>
&lt;h2 id="why-continuous-fine-tuning-and-why-it-is-not-rag">Why continuous fine-tuning (and why it is not RAG)&lt;/h2>
&lt;p>Before going deeper, a distinction that people still confuse. &lt;strong>Fine-tuning is for form, not for facts.&lt;/strong> If your problem is that the model does not know the customer&amp;rsquo;s rates or the updated catalogue, do not fine-tune: use RAG. If your problem is that the model responds in a tone that does not fit, does not respect your JSON format, refuses legitimate cases or invents structure, then fine-tuning is the answer.&lt;/p>
&lt;p>In 2026 the boundary is well established by community practice:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observed problem&lt;/th>
&lt;th>Solution&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>The model does not know X (X changes weekly)&lt;/td>
&lt;td>RAG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model knows X but responds badly in tone or format&lt;/td>
&lt;td>SFT fine-tuning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>There are two ways of responding and I prefer one over the other&lt;/td>
&lt;td>Fine-tuning with preferences (DPO/KTO/ORPO/SimPO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model reasons badly in a verifiable domain (code, maths)&lt;/td>
&lt;td>RL with verifiable reward (GRPO/DAPO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The model is competent, it just needs a memory of facts&lt;/td>
&lt;td>RAG, not fine-tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Continuous fine-tuning is the disciplined version of the second and third cases. The key word is &lt;strong>continuous&lt;/strong>: it is not a one-off &amp;ldquo;we aligned the model&amp;rdquo; event, it is a process that runs every time the traffic distribution drifts far enough, or new use cases appear.&lt;/p>
&lt;h2 id="the-four-techniques-according-to-the-signal-you-capture">The four techniques according to the signal you capture&lt;/h2>
&lt;p>The most important change of the last 12 months has been the end of DPO&amp;rsquo;s monopoly. In 2024 every team doing alignment used DPO with &lt;code>(chosen, rejected)&lt;/code> pairs. In 2026 the choice is finer and depends on &lt;strong>what the signal you collect in your product looks like&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Real signal in the product&lt;/th>
&lt;th>Recommended technique&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Labelled correct examples (input → expected output)&lt;/td>
&lt;td>&lt;strong>SFT + LoRA&lt;/strong>&lt;/td>
&lt;td>Still the baseline. 500-5,000 examples are enough for style.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Explicit &lt;code>(chosen, rejected)&lt;/code> pairs&lt;/td>
&lt;td>&lt;strong>DPO&lt;/strong> or &lt;strong>SimPO&lt;/strong>&lt;/td>
&lt;td>SimPO removes the reference model → 50 % less VRAM in training.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Loose 👍 / 👎 on responses&lt;/td>
&lt;td>&lt;strong>KTO&lt;/strong>&lt;/td>
&lt;td>The method that fits real telemetry most naturally.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SFT and preferences in a single pass&lt;/td>
&lt;td>&lt;strong>ORPO&lt;/strong>&lt;/td>
&lt;td>A single model in memory, avoids drift between phases.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Verifiable reward (tests, solutions)&lt;/td>
&lt;td>&lt;strong>GRPO&lt;/strong> / &lt;strong>DAPO&lt;/strong>&lt;/td>
&lt;td>Reasoning, not chat. A different world.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical rule: &lt;strong>design the feedback capture in the product thinking about which method you will be able to use afterwards&lt;/strong>. If your UI only has 👍/👎, you force the path to KTO. If you add a &amp;ldquo;regenerate response&amp;rdquo; button, you unlock DPO from regenerate-as-rejected (we will see it below). If you add an &amp;ldquo;edit response&amp;rdquo; button, the edited response becomes high-quality direct SFT.&lt;/p>
&lt;p>There is a cost detail that gets little publicity. DPO needs to keep &lt;strong>two models&lt;/strong> in memory: the one you are training and the reference one. SimPO removes that second model. ORPO does too. For a Llama 3 8B in BF16 this is the difference between needing ~32 GB of active VRAM during training (DPO) or ~16 GB (SimPO/ORPO). It is the difference between the training fitting on an RTX 4090 with aggressive QLoRA, or not fitting without offload.&lt;/p>
&lt;h2 id="postgres-as-the-nervous-system-of-the-pipeline">Postgres as the nervous system of the pipeline&lt;/h2>
&lt;p>Here is the strong technical opinion of this article, and it is one worth defending with data: &lt;strong>Postgres 18 + pgvector 0.8 + an S3/MinIO bucket for the weights is enough for the whole pipeline&lt;/strong>. You do not need MLflow, you do not need lakeFS, you do not need DVC.&lt;/p>
&lt;p>This is not ideological minimalism. It is about three concrete advantages that no alternative stack matches in the on-premise scenario with compliance:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A single source of truth, a single authorisation model.&lt;/strong> The ACLs you already have for Postgres cover the training data, the eval results, the adapter registry and the audit log. You do not multiply control planes.&lt;/li>
&lt;li>&lt;strong>SQL as the pipeline&amp;rsquo;s universal language.&lt;/strong> The query that generates the dataset, the eval gate predicate, the A/B traffic assignment, the promotion decision: it is all SQL. Your team already knows SQL.&lt;/li>
&lt;li>&lt;strong>Audit and cryptographic reproducibility for free.&lt;/strong> The &lt;code>pg_audit&lt;/code> and &lt;code>pgcrypto&lt;/code> extensions, combined with &lt;code>set_hash&lt;/code> over the dataset, give you cryptographic traceability with no additional code. It is a subject that would fill an article of its own.&lt;/li>
&lt;/ol>
&lt;h3 id="a-concrete-schema">A concrete schema&lt;/h3>
&lt;p>We start with the traffic table, partitioned by week so that &lt;code>DROP PARTITION&lt;/code> is cheap:&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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&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="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">request_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>&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">INT&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">user_hash&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">BYTEA&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- GDPR pseudonymisation
&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">adapter_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 class="c1">-- e.g. &amp;#34;support-es-v4.1&amp;#34;
&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">experiment&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 class="c1">-- e.g. &amp;#34;rerank-v2-canary&amp;#34;
&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">variant&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">CHAR&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;A&amp;#39; | &amp;#39;B&amp;#39; | NULL
&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">messages&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="k">completion&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">ttft_ms&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">tokens_in&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">tokens_out&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="c1">-- feedback signals
&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">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SMALLINT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- -1/0/+1 (KTO-ready)
&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">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- user regenerated -&amp;gt; DPO-rejected
&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">fb_edited&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- user edited -&amp;gt; SFT golden
&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">parent_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BIGINT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- self-reference for regenerate
&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="c1">-- vector and meta
&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">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">HALFVEC&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1024&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- pgvector 0.8, half the RAM
&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">pii_flags&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SMALLINT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- bitmask
&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">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="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&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">RANGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">created_at&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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PARTITION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="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">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-18&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2026-05-25&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>&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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="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">halfvec_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">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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log_2026w21&lt;/span>&lt;span class="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="n">tenant_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&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>Three decisions deserve a note:&lt;/p>
&lt;p>&lt;strong>&lt;code>HALFVEC(1024)&lt;/code>.&lt;/strong> Native FP16 vectors in pgvector 0.8. Half the RAM and disk, with a loss of precision that is irrelevant for semantic deduplication. This alone, at a scale of millions of rows, saves between 4 and 8 GB.&lt;/p>
&lt;p>&lt;strong>Weekly partitioning by time range.&lt;/strong> At 90 days, &lt;code>DROP TABLE obs.inference_log_2026wXX&lt;/code> frees space in milliseconds without a prolonged lock. Autovacuum never touches frozen partitions again.&lt;/p>
&lt;p>&lt;strong>Self-referencing &lt;code>parent_id&lt;/code>.&lt;/strong> The user regenerates the response → a new row is inserted with &lt;code>parent_id&lt;/code> pointing at the previous one. That will give us a DPO dataset without touching the UX.&lt;/p>
&lt;h3 id="the-adapter-registry">The adapter registry&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="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">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&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="nb">TEXT&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">base_model&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">rank&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 class="n">alpha&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">target_modules&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&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">method&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 class="c1">-- &amp;#39;sft&amp;#39;|&amp;#39;dpo&amp;#39;|&amp;#39;kto&amp;#39;|&amp;#39;orpo&amp;#39;|&amp;#39;simpo&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">training_run_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">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">dataset_snapshot_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">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">weights_uri&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 class="c1">-- s3://.../v4.2.safetensors
&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">eval_summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&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">status&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;training&amp;#39;|&amp;#39;canary&amp;#39;|&amp;#39;prod&amp;#39;|&amp;#39;retired&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">traffic_pct&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">NUMERIC&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&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">promoted_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="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;/code>&lt;/pre>&lt;/div>&lt;p>The vLLM router reads this table with a TTL of a few seconds. An &lt;code>UPDATE serve.adapter SET status='prod', traffic_pct=100 WHERE id='v4.2'&lt;/code> is a promotion. An &lt;code>UPDATE ... SET status='retired'&lt;/code> is a rollback. The audit of who did what and when comes from &lt;code>pg_audit&lt;/code> without writing a single additional line of code.&lt;/p>
&lt;h2 id="generating-dpo-and-kto-datasets-from-real-traffic">Generating DPO and KTO datasets from real traffic&lt;/h2>
&lt;p>This is where the elegance of the schema pays off. The dataset is not a static file: it is a &lt;strong>materialised view&lt;/strong> built with SQL over &lt;code>obs.inference_log&lt;/code>.&lt;/p>
&lt;h3 id="kto-dataset-from-">KTO dataset from 👍/👎&lt;/h3>
&lt;p>KTO is the method that best fits the signal any decent chat product captures. The query:&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="n">MATERIALIZED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VIEW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kto_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="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">messages&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">prompt&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">completion&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">response&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">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_explicit&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="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&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">label&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">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="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">fb_explicit&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_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="n">now&lt;/span>&lt;span class="p">()&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;60 days&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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pii_flags&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">tenant&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">consent_training&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>Simple. Every row with explicit feedback becomes an example &lt;code>(prompt, response, desirable_yes_no)&lt;/code>. KTO trains directly on this signal, with no need to build pairs.&lt;/p>
&lt;h3 id="dpo-dataset-from-regenerate">DPO dataset from &amp;ldquo;regenerate&amp;rdquo;&lt;/h3>
&lt;p>The trick that is worth this article on its own. When the user presses &amp;ldquo;regenerate response&amp;rdquo;, they are giving an extraordinarily strong signal: the first response was no good to them. If the second is neither regenerated nor rated negatively, we assume it was. That is a DPO pair without a single extra click in the UI:&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="n">MATERIALIZED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VIEW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dpo_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="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">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">messages&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">prompt&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">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">completion&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">chosen&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">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">completion&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">rejected&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">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="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">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&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">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parent_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="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">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">true&lt;/span>&lt;span class="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">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fb_explicit&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="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">-- length bias mitigation in classic DPO
&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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">cho&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BETWEEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&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">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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rej&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tokens_out&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&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 clause on lengths is the cheap cure for the &lt;strong>length bias&lt;/strong> documented in DPO. Without it, the model learns that &amp;ldquo;longer = better&amp;rdquo; because the responses the user accepts tend to be slightly longer. With SimPO or ORPO this filter is optional; with classic DPO it is necessary.&lt;/p>
&lt;h3 id="semantic-deduplication-with-pgvector">Semantic deduplication with pgvector&lt;/h3>
&lt;p>Before training, dedup. Two almost identical prompts in the dataset are noise that biases the model:&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">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ranked&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&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="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">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">row_number&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">OVER&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">PARTITION&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">hashtext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">::&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="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">fb_explicit&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 class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="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">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">rn&lt;/span>&lt;span class="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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="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">created_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="n">now&lt;/span>&lt;span class="p">()&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;60 days&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 class="k">DELETE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kto_v3_candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">kto&lt;/span>&lt;span class="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">ranked&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">r&lt;/span>&lt;span class="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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">rn&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">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="n">kto&lt;/span>&lt;span class="p">.&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="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&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>And for the semantic duplicates (paraphrases) we use pgvector 0.8 directly with an &lt;code>iterative index scan&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="c1">-- Find near-duplicates of any given example
&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">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&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">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">dist&lt;/span>&lt;span class="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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="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">created_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="n">now&lt;/span>&lt;span class="p">()&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;60 days&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="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&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="o">&amp;lt;&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">05&lt;/span>&lt;span class="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">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&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">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">50&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 &lt;code>iterative scan&lt;/code> is a key improvement in pgvector 0.8: before, the HNSW index could return fewer results than requested when there were additional filters (&lt;code>WHERE&lt;/code>); now it iterates until it meets the limit. Without that improvement, curation queries over datasets of millions of rows were unworkable without a brutal pre-filter.&lt;/p>
&lt;h2 id="eval-gates-three-stages-all-sql">Eval gates: three stages, all SQL&lt;/h2>
&lt;p>The most common mistake when implementing continuous fine-tuning is skipping or watering down the eval gates. That turns the cycle into a roulette wheel. The pattern that works in 2026 is &lt;strong>three stages&lt;/strong>, each with a different latency/coverage trade-off:&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three stages of eval gates">
&lt;style>
.stage { stroke: #333; stroke-width: 1.5; }
.s1 { fill: #d6eaff; }
.s2 { fill: #d9f5d6; }
.s3 { fill: #ffe9d6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.6; fill: none; marker-end: url(#ah3); }
&lt;/style>
&lt;defs>
&lt;marker id="ah3" 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="#444"/>
&lt;/marker>
&lt;/defs>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">Three stages of eval gates&lt;/text>
&lt;rect x="30" y="50" width="200" height="110" rx="10" class="stage s1"/>
&lt;text x="130" y="78" text-anchor="middle" class="lbl">Stage 1 · PR&lt;/text>
&lt;text x="130" y="98" text-anchor="middle" class="lbl-sm">&amp;lt; 90 seconds&lt;/text>
&lt;text x="130" y="118" text-anchor="middle" class="lbl-sm">schema-lint + prompt-lint&lt;/text>
&lt;text x="130" y="135" text-anchor="middle" class="lbl-sm">+ 50 mini-eval cases&lt;/text>
&lt;rect x="260" y="50" width="200" height="110" rx="10" class="stage s2"/>
&lt;text x="360" y="78" text-anchor="middle" class="lbl">Stage 2 · pre-merge&lt;/text>
&lt;text x="360" y="98" text-anchor="middle" class="lbl-sm">&amp;lt; 20 minutes&lt;/text>
&lt;text x="360" y="118" text-anchor="middle" class="lbl-sm">200-500 golden cases&lt;/text>
&lt;text x="360" y="135" text-anchor="middle" class="lbl-sm">+ LLM-as-judge&lt;/text>
&lt;rect x="490" y="50" width="200" height="110" rx="10" class="stage s3"/>
&lt;text x="590" y="78" text-anchor="middle" class="lbl">Stage 3 · canary&lt;/text>
&lt;text x="590" y="98" text-anchor="middle" class="lbl-sm">24-72 hours&lt;/text>
&lt;text x="590" y="118" text-anchor="middle" class="lbl-sm">1-5 % real traffic&lt;/text>
&lt;text x="590" y="135" text-anchor="middle" class="lbl-sm">online metrics + feedback&lt;/text>
&lt;path class="arr" d="M230,105 L260,105"/>
&lt;path class="arr" d="M460,105 L490,105"/>
&lt;/svg>
&lt;/div>
&lt;p>And this is where Postgres shines again: the promotion gate is expressed as an SQL predicate. Nothing more:&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">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&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">adapter_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">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">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">suite_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 class="c1">-- &amp;#39;safety-es&amp;#39;, &amp;#39;support-helpfulness&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">metric&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">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">NUMERIC&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">judge_model&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">judged_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="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">adapter_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&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">OR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REPLACE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FUNCTION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">can_promote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">candidate&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 class="k">current&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="k">RETURNS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$$&lt;/span>&lt;span class="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 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="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">SELECT&lt;/span>&lt;span class="w"> &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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="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">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">eval_result&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">metric&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="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="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">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="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">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">suite_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;safety-es&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;support-helpfulness&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;refusal-rate&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">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&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">98&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 2 % tolerance
&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="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="err">$$&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LANGUAGE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sql&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">STABLE&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>An SQL function as the gate. Usable from CI with &lt;code>psql -c &amp;quot;SELECT serve.can_promote('v4.2','v4.1')&amp;quot;&lt;/code> and an exit code of 0/1. No orchestrator needed, no specific UI needed. The audit stays in the Postgres log.&lt;/p>
&lt;h2 id="vllm-multi-lora-the-deploy-is-an-http-post">vLLM multi-LoRA: the deploy is an HTTP POST&lt;/h2>
&lt;p>Two years ago, deploying a new fine-tune meant rotating inference pods. Today it is an HTTP call. vLLM 0.7+ supports loading and unloading LoRA adapters &lt;strong>hot&lt;/strong>, keeping several resident in VRAM and choosing the right one per request.&lt;/p>
&lt;p>Server configuration:&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 meta-llama/Llama-3.1-8B-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> --enable-lora &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-loras &lt;span class="m">4&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> --max-lora-rank &lt;span class="m">64&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> --env &lt;span class="nv">VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/span>&lt;span class="o">=&lt;/span>True
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Deploying a new adapter:&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://localhost:8000/v1/load_lora_adapter &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="s2">&amp;#34;Content-Type: application/json&amp;#34;&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 &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_name&amp;#34;: &amp;#34;support-es-v4.2&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_path&amp;#34;: &amp;#34;/mnt/adapters/support-es-v4.2&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>From that moment on, requests including &lt;code>&amp;quot;model&amp;quot;: &amp;quot;support-es-v4.2&amp;quot;&lt;/code> are served with that adapter applied on top of the base model. Switching between adapters has negligible latency (the most recent research on Activated LoRA takes this to levels where the switching cost is invisible).&lt;/p>
&lt;p>This changes operations substantially. &lt;strong>Deploying a new fine-tune stops being an infrastructure event and becomes a state change in Postgres&lt;/strong>. The router queries the &lt;code>serve.adapter&lt;/code> table, sees that &lt;code>v4.2&lt;/code> is in &lt;code>canary&lt;/code> with &lt;code>traffic_pct=5&lt;/code>, and directs 5 % of requests to the new adapter. The exact route of that 5 % is decided with deterministic hashing of the &lt;code>user_id&lt;/code> so that the same user always sees the same variant (sticky):&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">-- No assignment table, no additional state
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- the variant is computed in-place in SQL or in the router:
&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hashtext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">user_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="n">experiment&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traffic_pct&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">serve&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">adapter&lt;/span>&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="n">candidate&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">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="n">candidate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&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">adapter_id&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;h2 id="ab-with-real-traffic-measure-or-live-deceived">A/B with real traffic: measure or live deceived&lt;/h2>
&lt;p>The eval gates measure against fixed benchmarks. That is necessary but insufficient. Reality is only measured with real traffic. Once the adapter is in canary, what matters are the &lt;strong>online metrics&lt;/strong> measured over &lt;code>obs.inference_log&lt;/code> for each variant:&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">adapter_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="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">n&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">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fb_explicit&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">mean_score&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">STDDEV&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fb_explicit&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">SQRT&lt;/span>&lt;span class="p">(&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">sem&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">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ttft_ms&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">ttft_avg&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">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="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="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">ttft_ms&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">ttft_p50&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">percentile_cont&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">WITHIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="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="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">ttft_ms&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">ttft_p95&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">AVG&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">CASE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">fb_regen&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">THEN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ELSE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">END&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">regen_rate&lt;/span>&lt;span class="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">obs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inference_log&lt;/span>&lt;span class="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">experiment&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="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">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">created_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="n">now&lt;/span>&lt;span class="p">()&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;7 days&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="k">GROUP&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">adapter_id&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 to look at: explicit feedback, latency (TTFT, p50, p95), regeneration rate. An adapter that raises the mean feedback but also raises the regeneration rate is suspicious, probably it is responding in a flashier but less useful way. An adapter that lowers latency but lowers feedback may be worth studying: it may be being more concise than it should.&lt;/p>
&lt;p>Promotion to &lt;code>prod&lt;/code> happens when, after 24-72 hours in canary, the candidate adapter beats the current one on at least one key metric without degrading the others. Again: it is an &lt;code>UPDATE&lt;/code> in Postgres.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>Let us come down to two representative configurations, one for iteration and one for production.&lt;/p>
&lt;h3 id="case-1--rtx-4090-24-gb-for-development-iteration">Case 1 — RTX 4090 (24 GB) for development iteration&lt;/h3>
&lt;p>An RTX 4090 with 4-bit QLoRA can train adapters on an 8B model without trouble. The VRAM budget combines four components; the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> during the intermediate evaluations is not negligible and deserves an explicit margin:&lt;/p>
&lt;pre tabindex="0">&lt;code>8B base model in 4-bit: ~5 GB
Activations + gradients: ~8 GB (depends on batch and context)
Optimizer state (LoRA r=16): ~0.5 GB
KV cache during eval: ~2 GB
Safety margin: ~8 GB
&lt;/code>&lt;/pre>&lt;p>Typical times (an estimate based on community benchmarks; measure in the lab before promising anything):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dataset&lt;/th>
&lt;th>Technique&lt;/th>
&lt;th style="text-align:right">Adapter rank&lt;/th>
&lt;th style="text-align:right">Approximate time&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1,000 SFT examples&lt;/td>
&lt;td>LoRA r=16&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">20-40 min&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5,000 SFT examples&lt;/td>
&lt;td>LoRA r=32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">2-4 h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2,000 DPO pairs&lt;/td>
&lt;td>LoRA r=16&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">1-2 h&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5,000 KTO examples&lt;/td>
&lt;td>LoRA r=32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">3-5 h&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>This puts the iteration cycle, a change in the dataset, retrain, eval, look at the number, in the range of a &lt;strong>working day&lt;/strong>. Enough to validate a hypothesis before moving anything to the production cluster.&lt;/p>
&lt;h3 id="case-2--4h100-sxm-cluster-320-gb-nvlink-for-production">Case 2 — 4×H100 SXM cluster (320 GB, NVLink) for production&lt;/h3>
&lt;p>With a cluster of this order the whole picture changes. You can:&lt;/p>
&lt;ul>
&lt;li>Train &lt;strong>LoRA on a 70B in BF16 with no quantisation&lt;/strong> with tensor parallel = 4.&lt;/li>
&lt;li>Do &lt;strong>full DPO with a resident reference model&lt;/strong> when the reference is quantised to FP8, or move to &lt;strong>SimPO / ORPO&lt;/strong>, which remove that intermediate model and simplify VRAM planning (see the table of techniques above).&lt;/li>
&lt;li>Support &lt;strong>multi-tenant fine-tuning&lt;/strong>: several customer adapters training in parallel in separate logical pipelines, each isolated in a different Postgres partition with its own ACLs.&lt;/li>
&lt;li>Serve &lt;strong>multi-LoRA with &lt;code>--max-loras 8&lt;/code>&lt;/strong> on top of the base model without concurrency lowering throughput perceptibly.&lt;/li>
&lt;/ul>
&lt;p>The practical budgeting rule: over a 12-month horizon, a team with this cluster can run &lt;strong>~150-200 continuous fine-tuning cycles&lt;/strong> (training + eval + canary + promotion or discard) if the discipline around the dataset and the eval gates is strict. If it is not, it will run twice as many but with half the usefulness.&lt;/p>
&lt;h2 id="position-within-the-architecture-what-this-article-covers-and-what-it-does-not">Position within the architecture: what this article covers and what it does not&lt;/h2>
&lt;p>To place the scope: the cycle drawn at the start has seven boxes, all of them covered here in their mechanics. Three cross-cutting layers are &lt;strong>deliberately left out&lt;/strong>, and they are the ones that end up separating a pipeline that works technically from one that survives an audit:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cryptographic provenance and traceability.&lt;/strong> We have mentioned &lt;code>dataset_snapshot&lt;/code> and &lt;code>pg_audit&lt;/code>, but the complete mechanics, the &lt;code>set_hash&lt;/code> over the examples, the integration with the EU AI Act, the frozen &lt;code>query_sql&lt;/code> as proof of what trained the model, is enough for a whole analysis.&lt;/li>
&lt;li>&lt;strong>Judge calibration.&lt;/strong> We have assumed LLM-as-judge works. It needs calibrating against a human rubric on at least 100 cases per critical suite before trusting it. Without that calibration, the eval gates are theatre.&lt;/li>
&lt;li>&lt;strong>The forgetting problem.&lt;/strong> What happens if a user exercises their GDPR right to be forgotten and their interactions were part of the dataset of an adapter already in production? There is no clean solution. There are options, incremental retrain, sample-level machine unlearning, negative LoRA, and it is worth knowing them before a customer asks.&lt;/li>
&lt;/ol>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cryptographic provenance on Postgres&lt;/strong>: how &lt;code>set_hash&lt;/code> and a frozen &lt;code>query_sql&lt;/code> compose an auditable chain of custody under the EU AI Act.&lt;/li>
&lt;li>&lt;strong>Honest judge calibration&lt;/strong>: why &lt;code>score &amp;gt; 0.85&lt;/code> means nothing without a human baseline, and how to build that baseline without it costing a month of work.&lt;/li>
&lt;li>&lt;strong>The forgetting problem in adapters&lt;/strong>: sample-level machine unlearning, incremental retrain and other techniques for answering GDPR without throwing the adapter away.&lt;/li>
&lt;li>&lt;strong>Online DPO and on-policy continual learning&lt;/strong>: the state of 2026 research (Fast-Slow Chasing, RLOO, iterative on-policy) and why it is not production yet.&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/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map the Tune and Retrain stages belong to. This post is the operational deep-dive of that cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLM-specific MLOps in 2026: the landscape&lt;/a> — the opening of the series. It places continuous fine-tuning within the framework of three modalities (fine-tuning, RAG, agents) and seven structural differences from classic MLOps.&lt;/li>
&lt;li>&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 Data stage that feeds this cycle: how events enter Postgres and embeddings enter Qdrant, which this post would then curate as a dataset.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the Eval stage of the pipeline. The eval gates described here as SQL predicates are the materialisation of that post&amp;rsquo;s generic framework.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — the Deploy stage where the multi-LoRA hot-swap described here coexists with quotas, gateway and isolation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — the fundamentals of the cache that comes into play in every intermediate eval of the training and in every deployment of the resulting adapter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside&lt;/a> — the mechanics of the KV cache and the landscape of optimisations (vAttention, EvicPress, RadixAttention) that hold up the throughput of the intermediate evals in the fine-tuning pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — the serving pattern the multi-LoRA hot-swap described here connects to: each specialised pod loads its adapters separately.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning for LLMOps: DVC, lakeFS and a reproducible golden dataset&lt;/a> — the opposite position. This post defends a minimalist stack (Postgres + pgvector + S3) with no DVC/lakeFS; the other explains when the line is crossed and why.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO and SimPO&lt;/a> — the mathematical foundations behind each of the preference optimisation methods this post uses operationally. Derivation of DPO from RLHF, a numerical example, biases.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge: the exam marker that evaluates other models&lt;/a> — the mechanism that generates the &lt;code>(chosen, rejected)&lt;/code> pairs that then feed DPO/SimPO/ORPO in this cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference: FP8, INT4 and GGUF&lt;/a> — the QLoRA with NF4 that holds up the training on an RTX 4090 is the quantisation family seen in detail there. And the DPO reference model is quantised with the same formats to free VRAM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: the single translator with a thousand glossaries&lt;/a> — the other side of the loop. This post trains the adapters; multi-LoRA serving (SGMV, S-LoRA, vLLM/LoRAX) is what puts hundreds of them to work concurrently against a single base without replicating the base.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hu et al., &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em> (ICLR 2022).&lt;/li>
&lt;li>Dettmers et al., &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Rafailov et al., &lt;em>Direct Preference Optimization: Your Language Model is Secretly a Reward Model&lt;/em> (NeurIPS 2023).&lt;/li>
&lt;li>Meng, Xia, Chen, &lt;em>SimPO: Simple Preference Optimization with a Reference-Free Reward&lt;/em> (NeurIPS 2024).&lt;/li>
&lt;li>Hong et al., &lt;em>ORPO: Monolithic Preference Optimization without Reference Model&lt;/em> (2024).&lt;/li>
&lt;li>Ethayarajh et al., &lt;em>KTO: Model Alignment as Prospect Theoretic Optimization&lt;/em> (2024).&lt;/li>
&lt;li>Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023) — original vLLM.&lt;/li>
&lt;li>Official vLLM Multi-LoRA documentation: &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>.&lt;/li>
&lt;li>Official pgvector 0.8 documentation: &lt;a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector&lt;/a>.&lt;/li>
&lt;li>TRL (HuggingFace) docs: &lt;a href="https://huggingface.co/docs/trl">https://huggingface.co/docs/trl&lt;/a>.&lt;/li>
&lt;li>EU AI Act, consolidated text and application timeline: &lt;a href="https://artificialintelligenceact.eu/">https://artificialintelligenceact.eu/&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>