<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Dedup on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/dedup/</link><description>Recent content in Dedup 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/dedup/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>RAG corpus curation: the active librarian who decides what goes in, what goes out and what gets signed</title><link>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/</link><pubDate>Mon, 25 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>curation&lt;/strong> layer inside stage 1 (Data) of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It complements the other Data posts: &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">dataset versioning&lt;/a> for the four versionable artefacts, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant ingestion in microservices&lt;/a> for the outbox + CDC pattern, and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> for streaming transport. This post is not about moving data: it is about what to do with data before you let a model read it.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG system serving mediocre answers is rarely the retriever&amp;rsquo;s fault, nor the model&amp;rsquo;s. The root cause is usually the corpus: three nearly identical versions of the same PDF that make top-k return the same thing three times, an old manual nobody removed that contradicts the current one, a free-text field with customer numbers that the model quotes verbatim, a scanned PDF with dirty OCR that the chunker cut in the middle of a sentence. None of that is fixed by changing the model, the embedder, the reranker or the prompt. It is fixed by curating the corpus. This post takes apart the five operational layers of curation (schema-validated ingest, three-level deduplication, PII anonymisation measured with precision/recall, anti-contamination against the golden eval set, chunk→trace lineage), the minimum maths needed to avoid fooling yourself, the 2026 stack (Presidio, Unstructured, Argilla, LangChain text splitters, OpenLineage, Marquez, Great Expectations), the seven traps that reduce the stage to theatre, and the on-premise hardware to sustain all of it without sending anything sensitive to external APIs.&lt;/p>
&lt;h2 id="the-analogy-the-active-librarian">The analogy: the active librarian&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RAG corpus curation as an active librarian">
&lt;style>
.lbox{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.lhead{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.lstage{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.lblt{font:600 13px sans-serif;fill:#222}
.lsub{font:400 11px sans-serif;fill:#555}
.larr{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mc1)}
.lgate{fill:#ffd76b;stroke:#444;stroke-width:1.6;rx:6}
.lrej{fill:#f4b8b8;stroke:#a44;stroke-width:1.4;rx:6}
&lt;/style>
&lt;defs>&lt;marker id="mc1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="20" y="20" width="180" height="60" class="lhead"/>
&lt;text x="110" y="44" text-anchor="middle" class="lblt">Document arrives&lt;/text>
&lt;text x="110" y="62" text-anchor="middle" class="lsub">PDF, HTML, ticket, manual&lt;/text>
&lt;text x="110" y="76" text-anchor="middle" class="lsub">(book offered to the librarian)&lt;/text>
&lt;rect x="240" y="20" width="160" height="60" class="lstage"/>
&lt;text x="320" y="44" text-anchor="middle" class="lblt">1 · Schema check&lt;/text>
&lt;text x="320" y="62" text-anchor="middle" class="lsub">Great Expectations&lt;/text>
&lt;text x="320" y="76" text-anchor="middle" class="lsub">required fields + types&lt;/text>
&lt;rect x="440" y="20" width="160" height="60" class="lstage"/>
&lt;text x="520" y="44" text-anchor="middle" class="lblt">2 · Dedup&lt;/text>
&lt;text x="520" y="62" text-anchor="middle" class="lsub">exact + near + semantic&lt;/text>
&lt;text x="520" y="76" text-anchor="middle" class="lsub">hash + MinHash + cosine&lt;/text>
&lt;rect x="640" y="20" width="120" height="60" class="lstage"/>
&lt;text x="700" y="44" text-anchor="middle" class="lblt">3 · PII&lt;/text>
&lt;text x="700" y="62" text-anchor="middle" class="lsub">Presidio + recall&lt;/text>
&lt;text x="700" y="76" text-anchor="middle" class="lsub">measured vs golden&lt;/text>
&lt;path class="larr" d="M200,50 L240,50"/>
&lt;path class="larr" d="M400,50 L440,50"/>
&lt;path class="larr" d="M600,50 L640,50"/>
&lt;rect x="100" y="130" width="220" height="60" class="lstage"/>
&lt;text x="210" y="154" text-anchor="middle" class="lblt">4 · Anti-contamination&lt;/text>
&lt;text x="210" y="172" text-anchor="middle" class="lsub">cross-check against golden eval set&lt;/text>
&lt;text x="210" y="186" text-anchor="middle" class="lsub">reject token-by-token overlaps&lt;/text>
&lt;rect x="360" y="130" width="220" height="60" class="lstage"/>
&lt;text x="470" y="154" text-anchor="middle" class="lblt">5 · Lineage emit&lt;/text>
&lt;text x="470" y="172" text-anchor="middle" class="lsub">OpenLineage event with source,&lt;/text>
&lt;text x="470" y="186" text-anchor="middle" class="lsub">hash, schema_version, embedder&lt;/text>
&lt;rect x="600" y="130" width="160" height="60" class="lgate"/>
&lt;text x="680" y="154" text-anchor="middle" class="lblt">Gate&lt;/text>
&lt;text x="680" y="172" text-anchor="middle" class="lsub">passes all 5 layers →&lt;/text>
&lt;text x="680" y="186" text-anchor="middle" class="lsub">index in the vector store&lt;/text>
&lt;path class="larr" d="M700,86 L210,124"/>
&lt;path class="larr" d="M700,86 L470,124"/>
&lt;path class="larr" d="M700,86 L680,124"/>
&lt;rect x="80" y="240" width="280" height="60" class="lbox"/>
&lt;text x="220" y="264" text-anchor="middle" class="lblt">Accept → living corpus&lt;/text>
&lt;text x="220" y="282" text-anchor="middle" class="lsub">chunks with metadata, embeddings computed,&lt;/text>
&lt;text x="220" y="296" text-anchor="middle" class="lsub">indexed with dataset_hash in metadata&lt;/text>
&lt;rect x="400" y="240" width="280" height="60" class="lrej"/>
&lt;text x="540" y="264" text-anchor="middle" class="lblt">Reject → auditable quarantine&lt;/text>
&lt;text x="540" y="282" text-anchor="middle" class="lsub">rejection reason + diff vs previous version&lt;/text>
&lt;text x="540" y="296" text-anchor="middle" class="lsub">available for human review&lt;/text>
&lt;path class="larr" d="M580,196 L220,236"/>
&lt;path class="larr" d="M700,196 L540,236"/>
&lt;text x="390" y="338" text-anchor="middle" class="lsub" style="font-style:italic;">The active librarian: pass 5 layers or stay out. Nothing is accepted because "it was already there".&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A serious librarian does not accept books by the kilo. When someone offers a new volume:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>They look at the spine, the ISBN and the stamp&lt;/strong>: is it legible? Is it catalogued correctly? Does it belong to a recognised collection? Without valid metadata, it does not get in. This is the &lt;strong>schema check&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>They check whether they already hold a copy&lt;/strong>: is it exactly the same book? Is it a later edition of the same one? Is it a translated version of something already on the shelves? If so, they decide explicitly what to do (replace, archive the old one, withdraw both from lending). This is &lt;strong>dedup&lt;/strong> at its three levels.&lt;/li>
&lt;li>&lt;strong>They flag what is restricted&lt;/strong>: if the book contains identifiable personal data, some pages cannot be lent as they are, so they must be redacted, anonymised or moved to the reserved section. This is &lt;strong>PII anonymisation&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>They verify it is not this year&amp;rsquo;s final exam book&lt;/strong>: if it is, it stays out of the public collection until the syllabus changes, because if students can consult it the exam stops measuring what it is meant to measure. This is &lt;strong>anti-contamination&lt;/strong> against the golden eval set.&lt;/li>
&lt;li>&lt;strong>They write it into the register&lt;/strong>: this book, this edition, this provenance, this date, this person who approved the entry. This is &lt;strong>lineage&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>If the book passes all five, it joins the collection. If it fails any of them, it goes to an &lt;strong>auditable quarantine shelf&lt;/strong> with the reason for rejection. The difference between a good collection and a mediocre one is not size: it is how much discipline you apply across the five layers, every day, to every new book that arrives.&lt;/p>
&lt;p>A RAG corpus is exactly that. The only differences are scale (thousands or millions of documents per month) and the fact that the &amp;ldquo;readers&amp;rdquo; are LLMs that cannot tell a duplicate from a reinforced truth, nor a PII value from a synthetic example, nor a contaminated fragment from an authentic one.&lt;/p>
&lt;h2 id="the-four-data-artefacts-and-where-the-rag-corpus-fits">The four data artefacts and where the RAG corpus fits&lt;/h2>
&lt;p>Before going down to the five layers it is worth being clear about which corpus we are curating. The Data stage of the pipeline manages four distinct artefacts, each with its own discipline. The &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning post&lt;/a> lists them; here I reorder them from a curation perspective:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefact&lt;/th>
&lt;th>Who consumes it&lt;/th>
&lt;th>Dominant curation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Training dataset&lt;/strong>&lt;/td>
&lt;td>Tune (fine-tuning the model or an adapter)&lt;/td>
&lt;td>aggressive dedup + quality filters + label balancing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Deploy (retrieval at request time)&lt;/td>
&lt;td>the 5 layers in this post&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Eval (promotion gates)&lt;/td>
&lt;td>strict hold-out + stratification + maintenance driven by incidents&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrain enriched dataset&lt;/strong>&lt;/td>
&lt;td>Retrain (closing the loop)&lt;/td>
&lt;td>production feedback + human triage&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>RAG corpus&lt;/strong> is the most volatile of the four and the one most exposed to the end user: every answer the system serves literally contains fragments of it. A duplicate in the training dataset degrades learning but ends up buried in the weights; a duplicate in the RAG corpus shows up in today&amp;rsquo;s answer and tomorrow&amp;rsquo;s. That justifies the extra discipline that follows.&lt;/p>
&lt;h2 id="layer-1--schema-validated-ingest">Layer 1 — Schema-validated ingest&lt;/h2>
&lt;p>Every piece entering the corpus has to arrive with &lt;strong>structured metadata validated against a schema&lt;/strong>. This is not bureaucracy: it is the only way to make the following layers (dedup, PII, lineage) work without friction.&lt;/p>
&lt;p>The canonical pattern is to define a schema in JSON Schema or Pydantic that every document must satisfy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">CorpusDocument&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># e.g. &amp;#34;confluence&amp;#34;, &amp;#34;salesforce&amp;#34;, &amp;#34;manual_pdf&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># unique ID in the source system&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># document version (semver or date)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">language&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># ISO 639-1: &amp;#34;es&amp;#34;, &amp;#34;en&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">body&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">captured_at&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">datetime&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">captured_by&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># pipeline or human&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sensitivity&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;public&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;restricted&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">schema_version&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="c1"># version of the schema itself, not of the document&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any piece that fails this contract is rejected at ingest and never reaches the following layers. Validation is done with &lt;strong>Great Expectations&lt;/strong> (declarative suites), &lt;strong>Pandera&lt;/strong> (more pythonic, integrates with pandas) or &lt;strong>Soda&lt;/strong> (oriented towards continuous data quality). The choice is a matter of style; what matters is:&lt;/p>
&lt;ul>
&lt;li>Validation suites live in code and are versioned with the pipeline, not in a notebook off to one side.&lt;/li>
&lt;li>Rejection generates an auditable event (quarantine) with the specific schema failure as its reason, not a log line lost in stdout.&lt;/li>
&lt;li>The schema itself is versioned. When it changes, previous documents are reprocessed or explicit backward compatibility is maintained.&lt;/li>
&lt;/ul>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka post&lt;/a> covers the &lt;strong>Schema Registry&lt;/strong> pattern (Confluent Schema Registry or Apicurio) that materialises this in streaming: every message on the topic is validated against the registered schema before propagating downstream. For batch or pull, Great Expectations is the equivalent.&lt;/p>
&lt;p>&lt;strong>Common trap&lt;/strong>: leaving the &lt;code>body&lt;/code> field free with no further validation. You have to tighten it up with minimum/maximum length (a PDF that yields 12 characters after extraction is almost certainly broken), valid encoding (UTF-8 with no control characters), proportion of alphanumeric characters (dirty OCR returns a soup of symbols). These are simple rules that filter 80% of the noise with no need for AI.&lt;/p>
&lt;h2 id="layer-2--three-level-deduplication">Layer 2 — Three-level deduplication&lt;/h2>
&lt;p>The most expensive and most silent mistake in a RAG corpus is the duplicate. A document that appears three times in the corpus makes retrieval&amp;rsquo;s top-k return it three times, wasting two slots and reinforcing a single source. The LLM reads it as if three independent sources agreed, when in fact it is the same thing repeated.&lt;/p>
&lt;p>Deduplication is done at three levels, in this order by cost:&lt;/p>
&lt;h3 id="level-a--exact-dedup-sha-256-hash">Level A — Exact dedup (SHA-256 hash)&lt;/h3>
&lt;p>Compute the hash of the normalised content (trim, lower-case where applicable, remove redundant whitespace) and compare it against an index of already ingested hashes. On a match, discard or replace. Cost: \(O(1)\) per document. It catches literal duplicates (the same PDF uploaded twice, two byte-for-byte copies of the same HTML).&lt;/p>
&lt;h3 id="level-b--near-duplicate-minhash--lsh">Level B — Near-duplicate (MinHash + LSH)&lt;/h3>
&lt;p>Nearly identical documents with minor differences (a different header, an updated date, one version in Castilian and another in Galician with minimal changes). The canonical algorithm is &lt;strong>MinHash with Locality-Sensitive Hashing (LSH)&lt;/strong>, which approximates Jaccard similarity over shingles of k tokens. For n documents, comparing all against all is \(O(n^2)\), which is unworkable for large corpora. LSH cuts the cost to \(O(n)\) most-likely buckets.&lt;/p>
&lt;p>A typical threshold is Jaccard ≥ 0.80 over shingles of 5 tokens. The standard libraries are &lt;code>datasketch&lt;/code> (Python, MIT) or &lt;code>dedup&lt;/code> (Python, MIT). A numerical example: for 1 M short documents (300 tokens each), &lt;code>datasketch.MinHashLSH&lt;/code> with 128 permutations and threshold 0.8 takes about 2 GB of RAM and processes the full corpus in around 30 minutes on a modern CPU. The fraction of duplicates found in a real enterprise corpus is usually between 5% and 25%; removing them cuts storage and improves retrieval quality at the same time.&lt;/p>
&lt;h3 id="level-c--semantic-dedup-cosine-over-embeddings">Level C — Semantic dedup (cosine over embeddings)&lt;/h3>
&lt;p>Documents saying the same thing in different words, such as paraphrases, translations or rewritten versions, are not caught by MinHash. This is where semantic similarity comes in: compute the embedding of each document and compare the cosine between pairs.&lt;/p>
&lt;p>The problem is quadratic cost: for n documents, computing every pair is \(O(n^2)\). For n = 1 M and 768-dimensional embeddings (a typical model such as &lt;code>BAAI/bge-base-en-v1.5&lt;/code>), that is 5×10^11 dot products, which is unworkable. The solution is the same idea as LSH but over dense vectors: &lt;strong>HNSW&lt;/strong> (Hierarchical Navigable Small World) or &lt;strong>IVF&lt;/strong> (Inverted File) to build an approximate search index. For each new document you run a k-NN query against the index and examine only the k nearest neighbours.&lt;/p>
&lt;p>A sensible threshold for treating something as a semantic duplicate: &lt;strong>cosine ≥ 0.95&lt;/strong>. Below 0.95 the documents are related but distinct; above it, they are almost always the same information rewritten. The exact threshold is calibrated by observing precision/recall over a human-annotated sample; 100 pairs confirmed by a reviewer is reasonable for setting it.&lt;/p>
&lt;p>A numerical example: with &lt;code>qdrant&lt;/code> or &lt;code>pgvector&lt;/code> as the HNSW index and k=10 neighbours per query, deduplicating 1 M documents against the existing corpus takes on the order of 2-4 hours on an RTX 4090 (including embedding computation). If the embedder is self-hosted with vLLM, the cost per token is negligible against compute time.&lt;/p>
&lt;h3 id="policy-on-what-to-do-with-a-duplicate">Policy on what to do with a duplicate&lt;/h3>
&lt;p>Detection is not enough, you have to decide. Three common policies, in order of complexity:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drop&lt;/strong>: discard the more recent one, keep the older one. Simple, with no extra lineage.&lt;/li>
&lt;li>&lt;strong>Replace&lt;/strong>: discard the old one, index the new one. More volatility but it reflects the update.&lt;/li>
&lt;li>&lt;strong>Merge with provenance&lt;/strong>: mark the new one as a &amp;ldquo;shadow&amp;rdquo; of the old one, keep both in lineage but index only one. Better for regulated audit.&lt;/li>
&lt;/ul>
&lt;p>The policy has to be explicit and applied uniformly, not an ad-hoc decision per document.&lt;/p>
&lt;h2 id="layer-3--pii-anonymisation-with-measured-precisionrecall">Layer 3 — PII anonymisation with measured precision/recall&lt;/h2>
&lt;p>This layer is the one that most easily turns into theatre. The typical mistake: install Presidio, run it over the corpus, assume the output is clean. Without measuring the detector&amp;rsquo;s precision and recall against an annotated golden set, you know nothing.&lt;/p>
&lt;p>A PII detector can fail in two ways:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>False negative&lt;/strong> (low recall): it does not detect a national ID written as &amp;ldquo;12345678-A&amp;rdquo; because your model was trained on the &lt;code>12345678A&lt;/code> format without a hyphen. The RAG serves personal data unredacted.&lt;/li>
&lt;li>&lt;strong>False positive&lt;/strong> (low precision): it redacts a number from a configuration manual thinking it is a phone number. The RAG loses useful information.&lt;/li>
&lt;/ul>
&lt;p>Both are problems; regulation (GDPR, ENS, NIS2) penalises the first, and user experience degrades with the second. The acceptable ratio depends on the domain. In medical data, practically zero false negatives is non-negotiable; in internal technical documentation you can tolerate more recall in exchange for less precision.&lt;/p>
&lt;p>The standard metric is &lt;strong>F1 over an annotated golden set&lt;/strong>:&lt;/p>
\[
\text{precision} = \frac{TP}{TP + FP}, \quad \text{recall} = \frac{TP}{TP + FN}, \quad F_1 = 2 \cdot \frac{\text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}}
\]
&lt;p>To build the PII golden set, annotate around 200 documents by hand with every entity marked (national ID, IBAN, email, phone, address, personal name). Then run the detector and compute the metrics per category, not just aggregated, because a global F1 of 0.90 can hide a recall of 0.55 on IBANs.&lt;/p>
&lt;p>&lt;strong>2026 stack&lt;/strong> for this layer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Microsoft Presidio&lt;/strong> (MIT, Microsoft): the most complete OSS option. Configurable detectors, recognises around 50 entities by default, extensible with your own regex patterns or with fine-tuned NER models.&lt;/li>
&lt;li>&lt;strong>spaCy NER&lt;/strong> (MIT, Explosion AI): a base for custom detectors; useful when Presidio does not cover a domain entity.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong> (Llama Community License, Meta): a safety classifier that also detects PII in one pass, an option when you already have a GPU for inference and prefer a single pass.&lt;/li>
&lt;li>&lt;strong>DataFog&lt;/strong> (Apache 2.0): a more recent alternative, specialised in streaming pipelines.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Recommended hybrid pattern&lt;/strong>: Presidio for rule-based + regex detection (fast, deterministic) → Llama Guard as a second pass over what Presidio did not flag (an ensemble that raises recall without killing throughput). This is measured and reported as aggregate and per-category F1 on every release of the detector.&lt;/p>
&lt;p>&lt;strong>Common fallacy&lt;/strong>: trusting that a detector with F1 0.95 &amp;ldquo;is very good&amp;rdquo;. If you have 1 M documents and each contains on average 1 PII entity, F1 0.95 means 50,000 mishandled entities (between false positives and negatives). With sensitive data you have to design so that false negatives go to human quarantine, not to the public corpus.&lt;/p>
&lt;h2 id="layer-4--anti-contamination-against-the-golden-eval-set">Layer 4 — Anti-contamination against the golden eval set&lt;/h2>
&lt;p>If the RAG corpus contains fragments of the golden eval set, Eval metrics measure memorisation. The model returns the exact answer because it has it literally in its context, not because it generalised anything. Deploy then promotes models that shine in the exam and fail in production.&lt;/p>
&lt;p>This layer is the easiest to implement and the easiest to forget:&lt;/p>
&lt;ol>
&lt;li>The &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">golden eval set&lt;/a> has its own versioned hash.&lt;/li>
&lt;li>Before indexing any new document into the RAG corpus, run a token-by-token overlap check (or by shingles, similar to MinHash) against the golden set.&lt;/li>
&lt;li>If overlap exceeds a threshold (typically ≥ 30% of 5-token n-grams), the document is not indexed. It stays in quarantine flagged as &amp;ldquo;contamination risk vs golden_v12&amp;rdquo;.&lt;/li>
&lt;li>A human reviews the rejections. Sometimes they are false positives (a short quotation, a boilerplate sentence). Sometimes they are real contamination that a supplier introduced without realising.&lt;/li>
&lt;/ol>
&lt;p>The deeper reason: the RAG corpus and the golden set are enemies by design. The golden set measures how well the system generalises to questions it has not seen. If those questions are in the RAG, the system &amp;ldquo;sees&amp;rdquo; them on every query. The metric stops measuring generalisation.&lt;/p>
&lt;p>This check is computationally trivial, a hash join over n-grams. The difficulty is keeping it up: every time the golden set changes (monthly or quarterly), the full corpus has to be re-validated against the new golden set. Without that discipline, contamination sneaks in through the back door when someone updates the golden set with real cases that the RAG was already serving.&lt;/p>
&lt;h2 id="layer-5--end-to-end-lineage-from-document-to-trace">Layer 5 — End-to-end lineage: from document to trace&lt;/h2>
&lt;p>The last layer is the one that closes the auditable chain. Every chunk indexed in the vector store carries metadata that lets you answer the forensic question:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;The system generated this answer on 14 March at 16:23. Which exact document did the quoted fragment come from? When did that document enter the corpus? Which version of the embedder processed it? Who approved its ingest?&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>Without lineage, that question is unanswerable. With lineage done properly, it is four queries.&lt;/p>
&lt;p>The canonical pattern:&lt;/p>
&lt;ul>
&lt;li>Every indexed chunk carries in its metadata: &lt;code>source_system&lt;/code>, &lt;code>source_id&lt;/code>, &lt;code>document_version&lt;/code>, &lt;code>chunk_index&lt;/code>, &lt;code>embedder_version&lt;/code>, &lt;code>dataset_hash&lt;/code>, &lt;code>ingested_at&lt;/code>, &lt;code>ingested_by&lt;/code>, &lt;code>schema_version&lt;/code>.&lt;/li>
&lt;li>Every RAG answer in production emits a trace span that includes the retrieved &lt;code>chunk_id&lt;/code> values.&lt;/li>
&lt;li>The central tracing system (&lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">Langfuse, Phoenix or OpenLLMetry&lt;/a>) joins &lt;code>chunk_id&lt;/code> → chunk metadata → document metadata → corpus &lt;code>dataset_hash&lt;/code> → embedder version → and so on.&lt;/li>
&lt;/ul>
&lt;p>The tools that standardise this glue are &lt;strong>OpenLineage&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data) and &lt;strong>Marquez&lt;/strong> (Apache 2.0, its server implementation). They define a lineage event schema that interoperates between systems; an ingest job emits an event &amp;ldquo;produced corpus_v12.3 from source X with embedder bge-base-v1.5&amp;rdquo;; a retrieval job emits &amp;ldquo;consumed corpus_v12.3 with query Q produced response R&amp;rdquo;. The graph is reconstructed automatically.&lt;/p>
&lt;p>This layer is the only way to pass real audits under regulations such as the EU AI Act, GDPR or ENS, where traceability of which data went into which answer is a requirement, not an option. Without it, the answer &amp;ldquo;we do not know which document this came from&amp;rdquo; is not acceptable, and it is the default answer if lineage is not built from day one.&lt;/p>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;p>Beyond dedup thresholds and PII F1 scores, there are three mathematical pieces that any serious team ends up using.&lt;/p>
&lt;p>&lt;strong>Chunk size vs retrieval quality&lt;/strong>. Chunk size affects retrieval quality in a non-monotonic way: chunks that are too small fragment ideas (retrieval returns a piece without context), and chunks that are too large dilute the signal (the embedding mixes several topics and similarity drops). The empirical sweet spot for technical text in 2026 is between &lt;strong>256 and 768 tokens per chunk&lt;/strong>, with &lt;strong>15-25% overlap&lt;/strong> between contiguous chunks to preserve continuity.&lt;/p>
&lt;p>Numerically, for a corpus of 1 M documents with a mean length of 2,000 tokens, chunking at 512 tokens with overlap 100 gives \(\frac{2000}{512 - 100} \approx 5\) chunks per document, a total of roughly 5 M indexed chunks. With 768-dimensional embeddings in &lt;code>float32&lt;/code>, that occupies \(5 \cdot 10^6 \cdot 768 \cdot 4 \approx 15\) GB of vector memory, manageable in any modern vector store.&lt;/p>
&lt;p>&lt;strong>PII golden set coverage&lt;/strong>. To know whether the annotated PII golden set is representative enough, compute the proportion of categories covered: if your golden set of 200 documents has 5 IBAN examples and production has 12,000 IBANs per day, the measured F1 on IBANs is statistical noise. Rule of thumb: &lt;strong>a minimum of 30 examples per category&lt;/strong> for per-category metrics to mean anything.&lt;/p>
&lt;p>&lt;strong>Cost of re-embedding when rotating the model&lt;/strong>. Changing the embedder invalidates the whole index. For a corpus of 5 M chunks with a model such as &lt;code>BAAI/bge-base-en-v1.5&lt;/code> (768 dim, around 110 M parameters) served in vLLM on 1× H100, throughput is on the order of 8,000-15,000 chunks/second. Full re-embedding: about 5-10 minutes. For a larger embedder (&lt;code>bge-large&lt;/code>, 1024 dim, around 335 M parameters), a factor of 3× worse, around 15-30 minutes. The bottleneck is usually vector store I/O, not GPU compute. The &lt;strong>dual-index&lt;/strong> pattern, keeping the old index serving while the new one is built and doing an atomic swap at the end, avoids downtime and allows rollback.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For an on-premise deployment that keeps all curation in-house without sending data to external APIs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RTX 4090 (24 GB)&lt;/strong>: covers layer 1 (the schema check with Great Expectations is CPU-bound), layer 2 levels A and B (hash + MinHash are CPU-bound), and layer 2 level C semantic dedup with a &lt;code>bge-base&lt;/code>-class embedder (8-15k chunks/s, enough for corpora of up to 5-10 M chunks in hours). For Presidio in NER mode (layer 3) it runs comfortably. It is the sensible GPU for the whole curation pipeline on a mid-size corpus.&lt;/li>
&lt;li>&lt;strong>Generic 4×H100 SXM configuration (320 GB total, NVLink)&lt;/strong>: only needed if the corpus exceeds around 50 M chunks or if you want frequent re-embeddings with large models (&lt;code>bge-large&lt;/code>, &lt;code>e5-mistral&lt;/code>). In practice, two GPUs serve the embedder at TP=2 with throughput above 50k chunks/s, and the other two go to the PII judge (Llama Guard 4) or to serving the main inference model. Capacity for corpora of hundreds of millions of chunks.&lt;/li>
&lt;/ul>
&lt;p>The stubborn arithmetic: with a 4090, corpus curation is an overnight job; with 4×H100, it is minutes. The decision depends on corpus size and on how often you rotate the embedder or the PII rules.&lt;/p>
&lt;h2 id="the-seven-traps-that-kill-this-stage">The seven traps that kill this stage&lt;/h2>
&lt;p>&lt;strong>Trap 1 — No validated schema at ingest.&lt;/strong> Malformed documents reach the chunker, the chunker slices them into nonsense, garbage embeddings enter the index. The RAG answer quotes incoherent text and nobody knows why.&lt;/p>
&lt;p>&lt;strong>Trap 2 — Dedup only at exact hash level.&lt;/strong> The corpus fills up with paraphrases and translations of the same document. Retrieval&amp;rsquo;s top-k returns the same source 3 times. The LLM reads it as three confirmations.&lt;/p>
&lt;p>&lt;strong>Trap 3 — PII detector without precision/recall measurement.&lt;/strong> Everyone assumes Presidio &amp;ldquo;works&amp;rdquo;. IBANs in non-standard formats slip through. The RAG serves personal data.&lt;/p>
&lt;p>&lt;strong>Trap 4 — Golden eval set contaminated with the corpus.&lt;/strong> Eval metrics measure memorisation. Promotions approve models that fail in real production.&lt;/p>
&lt;p>&lt;strong>Trap 5 — No lineage down to the chunk.&lt;/strong> The question &amp;ldquo;where did this quotation come from?&amp;rdquo; has no answer. Regulatory audit fails. Incidents cannot be investigated.&lt;/p>
&lt;p>&lt;strong>Trap 6 — Maintenance as a one-off event.&lt;/strong> The corpus is curated once when the system is initialised, and afterwards everyone assumes it is fine. Six months later the documents are out of date, the new PII rules are not applied retrospectively, dedup has not been re-run after adding new sources. The corpus degrades silently.&lt;/p>
&lt;p>&lt;strong>Trap 7 — Quarantine without human review.&lt;/strong> Rejected documents go into a table nobody looks at. False positives pile up, real contamination cases go uninvestigated, the team&amp;rsquo;s trust in curation erodes and the pressure to &amp;ldquo;relax the thresholds&amp;rdquo; begins.&lt;/p>
&lt;p>All seven are operational, not technical. Corpus curation does not break because of an algorithm bug: it breaks because discipline slackens. It is the exact equivalent of the kind of degradation that kills Eval suites, and in both cases the symptom is the same: the metrics improve or hold steady while the real experience gets worse.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Vector store versioning proper&lt;/strong>: an embedding index is not versioned like a raw dataset because it depends on the embedding model. Changing the embedder rewrites the whole index. It is a different animal with its own patterns (index branching, selective reembedding, recall-aware ANN parameters).&lt;/li>
&lt;li>&lt;strong>Streaming corpus updates with CDC&lt;/strong>: when the corpus has to be updated in near-real time from an OLTP system. The &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">Postgres + Qdrant ingestion post&lt;/a> covers the mechanics; still pending is the pattern for selectively invalidating chunks that depend on deleted rows.&lt;/li>
&lt;li>&lt;strong>Multi-tenant corpus isolation&lt;/strong>: how to set up a shared corpus versus one with per-tenant namespaces, with ACLs over individual chunks. Especially relevant for multi-customer RAG under data sovereignty constraints.&lt;/li>
&lt;li>&lt;strong>Federated corpus&lt;/strong>: corpora distributed across silos that the system queries without centralising the content. An emerging pattern for companies with several sites and cross-border restrictions.&lt;/li>
&lt;li>&lt;strong>Reranking-aware curation&lt;/strong>: how curation discipline changes when there is a reranker (Cohere Rerank, ColBERTv2, BGE-Reranker) reordering the top-k after retrieval. Some duplicates you would tolerate without a reranker become intolerable when the reranker pushes them up the ranking.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">End-to-end document ingestion: from PDF to indexed chunk&lt;/a> — how curation materialises in the ingestion pipeline, from PDF to indexed chunk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — Stage 1 (Data) and why curation is the most underrated sub-task in the whole chain.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: the three families, the model zoo and the decision that matters&lt;/a> — which cartographer processes the chunks curated here to turn them into searchable vectors; embedder selection criteria (bge-m3 / Snowflake Arctic / Jina / Nomic / ColBERT) and storage cost.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs in LLMOps&lt;/a> — the Linnaean nomenclature without which curation is left with ad-hoc categories; how the chunks in this post are typed against a TBox (FIBO / SNOMED / ENS / schema.org), validated with SHACL and enriched with metadata queryable in SPARQL.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production, May 2026&lt;/a> — the forensic tour crosses the corpus and the retrieved chunks; here are the criteria any chunk had to pass to be in production.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning: DVC, lakeFS and the reproducible golden dataset challenge&lt;/a> — the four data artefacts and why they are versioned separately. The RAG corpus is one of the four.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant in the ingestion stage&lt;/a> — the microservices pattern that moves documents from source to vector store. The curation in this post plugs in between ingest and indexer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka and a datalake&lt;/a> — streaming transport. Schema Registry materialises layer 1.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the golden eval set is the RAG corpus&amp;rsquo;s &amp;ldquo;enemy by design&amp;rdquo;; layer 4 (anti-contamination) materialises the discipline between the two.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning: the contract that stops a five-word change sinking your system&lt;/a> — the &lt;code>prompt_id&lt;/code> travelling in the trace is the counterpart of the corpus &lt;code>dataset_hash&lt;/code> in lineage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop between the production incident and the adapter that fixes it&lt;/a> — the retrain enriched corpus also needs the five layers, with additional emphasis on human feedback.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — entries for Presidio, Unstructured, Argilla, Great Expectations, OpenLineage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — the curation in this post is prevention at ingest; guardrails are mitigation at runtime when something slips through. Line 2 (retrieval GR) filters chunks with indirect prompt injection before they enter the LLM&amp;rsquo;s context.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the operations manual for an AI system&lt;/a> — the five curation layers in this post directly cover controls A.7.3 (acquisition), A.7.4 (quality), A.7.5 (provenance) and A.7.6 (preparation) of the AIMS Annex A.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Presidio&lt;/strong>: &lt;a href="https://microsoft.github.io/presidio/">https://microsoft.github.io/presidio/&lt;/a> — official docs, list of supported entities, guide to extending it with custom NER.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong>: &lt;a href="https://openlineage.io/">https://openlineage.io/&lt;/a> — event schema spec and libraries per language.&lt;/li>
&lt;li>&lt;strong>Marquez&lt;/strong>: &lt;a href="https://marquezproject.ai/">https://marquezproject.ai/&lt;/a> — server implementation of OpenLineage.&lt;/li>
&lt;li>&lt;strong>datasketch (MinHash + LSH)&lt;/strong>: &lt;a href="https://ekzhu.com/datasketch/">https://ekzhu.com/datasketch/&lt;/a> — the reference Python library for near-duplicate deduplication at scale.&lt;/li>
&lt;li>&lt;strong>Great Expectations&lt;/strong>: &lt;a href="https://docs.greatexpectations.io/">https://docs.greatexpectations.io/&lt;/a> — declarative data quality suites.&lt;/li>
&lt;li>&lt;strong>Unstructured&lt;/strong>: &lt;a href="https://docs.unstructured.io/">https://docs.unstructured.io/&lt;/a> — parsing and normalisation of heterogeneous documents (PDF, HTML, DOCX, eml) before chunking.&lt;/li>
&lt;li>&lt;strong>Argilla&lt;/strong>: &lt;a href="https://docs.argilla.io/">https://docs.argilla.io/&lt;/a> — human annotation UI for building the PII golden set and other calibration sets.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong>: Meta technical paper, multimodal safety classifier — useful as a second PII detection layer.&lt;/li>
&lt;li>&lt;strong>GDPR, EU AI Act, ENS, NIS2&lt;/strong> — the regulatory frameworks whose compliance depends, in practice, on the discipline of layers 3 (PII) and 5 (lineage). The CEN/CENELEC technical standards for conformity assessment of GenAI systems under the EU AI Act are still pending final publication.&lt;/li>
&lt;/ul></description></item></channel></rss>