<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Data-Curation on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/data-curation/</link><description>Recent content in Data-Curation on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Mon, 25 May 2026 11:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/data-curation/index.xml" rel="self" type="application/rss+xml"/><item><title>RAG corpus curation: el bibliotecario activo que decide qué entra, qué sale y qué firma</title><link>https://blog.lo0.es/posts/rag-corpus-curation-fundamentos/</link><pubDate>Mon, 25 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/rag-corpus-curation-fundamentos/</guid><description>&lt;blockquote>
&lt;p>Esta es la capa de &lt;strong>curación&lt;/strong> dentro de la etapa 1 (Data) del &lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">pipeline LLMOps de seis etapas&lt;/a>. Complementa los otros posts Data: el de &lt;a href="https://blog.lo0.es/posts/data-versioning-dvc-lakefs/">versionado de datasets&lt;/a> para los cuatro artefactos versionables, el de &lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">ingestión PostgreSQL + Qdrant en microservicios&lt;/a> para el patrón outbox + CDC, y el de &lt;a href="https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/">RAG sobre Kafka y datalake&lt;/a> para el transporte streaming. Aquí no hablamos de mover datos: hablamos de &lt;strong>qué hacer con ellos antes de dejar que un modelo los lea&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un RAG que sirve respuestas mediocres rara vez es culpa del retriever ni del modelo. &lt;strong>La causa raíz suele estar en el corpus&lt;/strong>: tres versiones casi idénticas del mismo PDF que hacen que el top-k devuelva siempre tres veces lo mismo, un manual antiguo no eliminado que contradice al vigente, un campo libre con números de cliente que el modelo cita literalmente, un PDF escaneado con OCR sucio que el chunker partió por la mitad de una frase. Ninguna de esas cosas se arregla cambiando el modelo, el embedder, el rerankear o el prompt. Se arreglan &lt;strong>curando el corpus&lt;/strong>. Este post desmonta las cinco capas operacionales de la curación (schema-validated ingest, deduplicación en tres niveles, anonimización PII medida con precision/recall, anti-contaminación con el golden eval, lineage chunk→trace), las matemáticas mínimas para no autoengañarse, el stack 2026 (Presidio, Unstructured, Argilla, LangChain text splitters, OpenLineage, Marquez, Great Expectations), las siete trampas que tiran la etapa al teatro, y el hardware on-premise para sostener todo esto sin enviar nada sensible a APIs externas.&lt;/p>
&lt;h2 id="la-analogía-el-bibliotecario-activo">La analogía: el bibliotecario activo&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="Curación del corpus RAG como bibliotecario activo">
&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">Documento llega&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">(libro propuesto al bibliotecario)&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">campos obligatorios + tipos&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 + coseno&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">medido 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-contaminación&lt;/text>
&lt;text x="210" y="172" text-anchor="middle" class="lsub">cross-check contra golden eval set&lt;/text>
&lt;text x="210" y="186" text-anchor="middle" class="lsub">rechazar overlaps token-a-token&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 con 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">pasa las 5 capas →&lt;/text>
&lt;text x="680" y="186" text-anchor="middle" class="lsub">indexar en 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">Acepta → corpus vivo&lt;/text>
&lt;text x="220" y="282" text-anchor="middle" class="lsub">chunks con metadata, embeddings calculados,&lt;/text>
&lt;text x="220" y="296" text-anchor="middle" class="lsub">indexados con dataset_hash en 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">Rechaza → cuarentena auditable&lt;/text>
&lt;text x="540" y="282" text-anchor="middle" class="lsub">razón de rechazo + diff vs versión previa&lt;/text>
&lt;text x="540" y="296" text-anchor="middle" class="lsub">disponible para revisión humana&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;">El bibliotecario activo: pasa 5 capas o no entra. Nada se acepta porque "ya estaba".&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Un bibliotecario serio no acepta libros al peso. Cuando alguien le propone un volumen nuevo:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Mira el lomo, el ISBN y el sello&lt;/strong>: ¿es legible? ¿está catalogado correctamente? ¿pertenece a una colección reconocida? Sin metadata válida, no entra. Esto es el &lt;strong>schema check&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Comprueba si ya tiene una copia&lt;/strong>: ¿es exactamente el mismo libro? ¿es una edición posterior del mismo? ¿es una versión traducida de algo que ya está? Si lo tiene, decide explícitamente qué hacer (sustituir, archivar la vieja, retirar las dos del préstamo). Esto es el &lt;strong>dedup&lt;/strong> en sus tres niveles.&lt;/li>
&lt;li>&lt;strong>Marca lo restringido&lt;/strong>: si el libro contiene datos personales identificables, hay páginas que no se pueden prestar tal cual — hay que tacharlas, anonimizarlas o moverlas a la sección reservada. Esto es la &lt;strong>anonimización PII&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Verifica que no es el libro del examen final del año&lt;/strong>: si lo es, fuera del fondo público hasta que cambie el temario, porque si los estudiantes lo consultan deja de medir lo que el examen pretende medir. Esto es la &lt;strong>anti-contaminación&lt;/strong> con el golden eval set.&lt;/li>
&lt;li>&lt;strong>Anota en el registro&lt;/strong>: este libro, esta edición, esta procedencia, esta fecha, este responsable que aprobó la entrada. Esto es el &lt;strong>lineage&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>Si el libro pasa las cinco, entra al fondo. Si falla en cualquiera, va a una &lt;strong>estantería de cuarentena auditable&lt;/strong> con la razón del rechazo. La diferencia entre un fondo bueno y uno mediocre no es el tamaño: es cuánta disciplina aplicas en las cinco capas, todos los días, sobre cada libro nuevo que llega.&lt;/p>
&lt;p>El corpus de RAG es exactamente eso. Lo único distinto es la escala (miles o millones de documentos por mes) y que los &amp;ldquo;lectores&amp;rdquo; son LLMs que no saben distinguir un duplicado de una verdad reforzada, ni un dato PII de un ejemplo sintético, ni un fragmento contaminado de uno auténtico.&lt;/p>
&lt;h2 id="los-cuatro-artefactos-data-y-dónde-encaja-el-corpus-rag">Los cuatro artefactos data y dónde encaja el corpus RAG&lt;/h2>
&lt;p>Antes de bajar a las cinco capas conviene ser claro sobre &lt;strong>qué corpus&lt;/strong> estamos curando. La etapa Data del pipeline gestiona cuatro artefactos diferenciados, cada uno con disciplina distinta. El &lt;a href="https://blog.lo0.es/posts/data-versioning-dvc-lakefs/">post de data versioning&lt;/a> los enumera; aquí los reordeno desde la perspectiva de curación:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefacto&lt;/th>
&lt;th>Quién lo consume&lt;/th>
&lt;th>Curación dominante&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 del modelo o adapter)&lt;/td>
&lt;td>dedup agresivo + filtros de calidad + balanceo por etiqueta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Deploy (retrieval en tiempo de petición)&lt;/td>
&lt;td>&lt;strong>las 5 capas de este post&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Eval (gates de promotion)&lt;/td>
&lt;td>hold-out estricto + estratificación + mantenimiento con incidentes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Retrain enriched dataset&lt;/strong>&lt;/td>
&lt;td>Retrain (cierre del bucle)&lt;/td>
&lt;td>feedback de producción + triage humano&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>El &lt;strong>RAG corpus&lt;/strong> es el más volátil de los cuatro y el que más expuesto está al usuario final: cada respuesta que el sistema sirve contiene literalmente fragmentos suyos. Un duplicado en el training dataset degrada el aprendizaje pero queda enterrado en los pesos; un duplicado en el RAG corpus aparece en la respuesta de hoy y la de mañana. Esto justifica la disciplina extra que sigue.&lt;/p>
&lt;h2 id="capa-1--schema-validated-ingest">Capa 1 — Schema-validated ingest&lt;/h2>
&lt;p>Toda pieza que entra al corpus tiene que llegar acompañada de &lt;strong>metadata estructurada y validada contra un esquema&lt;/strong>. No es burocracia: es la única forma de hacer que las capas siguientes (dedup, PII, lineage) funcionen sin frituras.&lt;/p>
&lt;p>El patrón canónico es definir un schema en JSON Schema o Pydantic que cada documento debe satisfacer:&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"># ej. &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"># ID único en el sistema origen&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"># versión del documento (semver o fecha)&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 o humano&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"># versión del propio schema, no del documento&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Toda pieza que no cumple este contrato se rechaza al ingest, no llega a las capas siguientes. La validación se hace con &lt;strong>Great Expectations&lt;/strong> (suites declarativas), &lt;strong>Pandera&lt;/strong> (más pythónico, integra con pandas) o &lt;strong>Soda&lt;/strong> (orientado a data quality continuo). La elección es estilo; lo decisivo es:&lt;/p>
&lt;ul>
&lt;li>Las suites de validación &lt;strong>viven en código y se versionan con el pipeline&lt;/strong>, no en un cuaderno aparte.&lt;/li>
&lt;li>El rechazo genera un evento auditable (cuarentena) con la razón concreta del fallo de schema, no un log perdido en stdout.&lt;/li>
&lt;li>El schema mismo se versiona — cuando cambia, los documentos previos se reprocesan o se mantiene compatibilidad backward explícita.&lt;/li>
&lt;/ul>
&lt;p>El &lt;a href="https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/">post de RAG sobre Kafka&lt;/a> cubre el patrón &lt;strong>Schema Registry&lt;/strong> (Confluent Schema Registry o Apicurio) que materializa esto en streaming: cada mensaje del topic se valida contra el schema registrado antes de propagarse aguas abajo. Para batch o pull, Great Expectations es el equivalente.&lt;/p>
&lt;p>&lt;strong>Trampa habitual&lt;/strong>: dejar el campo &lt;code>body&lt;/code> libre sin más validación. Hay que afinar — longitud mínima/máxima (un PDF que arroja 12 caracteres tras la extracción seguramente está roto), encoding válido (UTF-8 sin caracteres de control), proporción de caracteres alfanuméricos (un OCR sucio devuelve sopa de signos). Estas son reglas simples que filtran el 80% del ruido sin necesidad de IA.&lt;/p>
&lt;h2 id="capa-2--deduplicación-en-tres-niveles">Capa 2 — Deduplicación en tres niveles&lt;/h2>
&lt;p>El error más caro y silencioso del corpus RAG es el duplicado. Un documento que aparece tres veces en el corpus consigue que el top-k del retrieval lo devuelva tres veces — desperdiciando dos slots y reforzando una sola fuente. El LLM lo lee como si tres fuentes independientes coincidieran, cuando en realidad es la misma cosa repetida.&lt;/p>
&lt;p>La deduplicación se hace en tres niveles, en este orden por coste:&lt;/p>
&lt;h3 id="nivel-a--exact-dedup-hash-sha-256">Nivel A — Exact dedup (hash SHA-256)&lt;/h3>
&lt;p>Calcular el hash del contenido normalizado (trim, lower-case si aplica, eliminar whitespace redundante) y comparar contra un índice de hashes ya ingeridos. Si coincide, descarta o sustituye. Coste: (O(1)) por documento. Atrapa duplicados literales (el mismo PDF subido dos veces, dos copias byte-a-byte del mismo HTML).&lt;/p>
&lt;h3 id="nivel-b--near-duplicate-minhash--lsh">Nivel B — Near-duplicate (MinHash + LSH)&lt;/h3>
&lt;p>Documentos casi idénticos con diferencias menores (un encabezado distinto, una fecha actualizada, una versión en castellano y otra en gallego con cambios mínimos). El algoritmo canónico es &lt;strong>MinHash con Locality-Sensitive Hashing (LSH)&lt;/strong>, que aproxima la similitud de Jaccard sobre shingles de k tokens. Para n documentos, comparar todos contra todos es (O(n^2)) — inviable para corpus grandes. LSH reduce el coste a (O(n)) buckets más probables.&lt;/p>
&lt;p>Un threshold típico es Jaccard ≥ 0,80 sobre shingles de 5 tokens. Las librerías estándar son &lt;code>datasketch&lt;/code> (Python, MIT) o &lt;code>dedup&lt;/code> (Python, MIT). Ejemplo numérico: para 1 M de documentos cortos (300 tokens cada uno), &lt;code>datasketch.MinHashLSH&lt;/code> con 128 permutations y threshold 0,8 ocupa ~2 GB de RAM y procesa el corpus completo en ~30 minutos sobre una CPU moderna. La fracción de duplicados detectados en un corpus empresarial real suele estar entre el 5% y el 25% — eliminarlos reduce el storage y mejora la calidad del retrieval simultáneamente.&lt;/p>
&lt;h3 id="nivel-c--semantic-dedup-coseno-sobre-embeddings">Nivel C — Semantic dedup (coseno sobre embeddings)&lt;/h3>
&lt;p>Documentos que dicen lo mismo en palabras distintas — paráfrasis, traducciones, versiones reescritas — no los captura MinHash. Aquí entra la similitud semántica: calcular el embedding de cada documento y comparar el coseno entre pares.&lt;/p>
&lt;p>El problema es de coste cuadrático: para n documentos, calcular todas las parejas es (O(n^2)). Para n = 1 M y embeddings de 768 dimensiones (modelo típico tipo &lt;code>BAAI/bge-base-en-v1.5&lt;/code>), son 5×10^11 dot products — inviable. La solución es la misma idea que LSH pero sobre vectores densos: &lt;strong>HNSW&lt;/strong> (Hierarchical Navigable Small World) o &lt;strong>IVF&lt;/strong> (Inverted File) para construir un índice de búsqueda aproximada. Para cada documento nuevo, se hace una query k-NN al índice y se examinan sólo los k vecinos más cercanos.&lt;/p>
&lt;p>Threshold sensato para considerar duplicado semántico: &lt;strong>coseno ≥ 0,95&lt;/strong>. Por debajo de 0,95 son documentos relacionados pero distintos; por encima, casi siempre son la misma información reescrita. El threshold exacto se calibra observando precision/recall sobre una muestra anotada por humanos — 100 pares confirmados por revisor es razonable para fijarlo.&lt;/p>
&lt;p>Ejemplo numérico: con &lt;code>qdrant&lt;/code> o &lt;code>pgvector&lt;/code> como índice HNSW y k=10 vecinos por query, deduplicar 1 M de documentos contra el corpus existente lleva del orden de 2-4 horas sobre una RTX 4090 (incluyendo el cómputo de embeddings). Si el embedder es self-hosted con vLLM, el coste por token es despreciable contra el tiempo de cómputo.&lt;/p>
&lt;h3 id="política-de-qué-hacer-con-un-duplicado">Política de qué hacer con un duplicado&lt;/h3>
&lt;p>Detectar no es suficiente — hay que decidir. Tres políticas comunes, en orden de complejidad:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Drop&lt;/strong>: descartar el más reciente, mantener el más antiguo. Simple, sin lineage extra.&lt;/li>
&lt;li>&lt;strong>Replace&lt;/strong>: descartar el viejo, indexar el nuevo. Más volatilidad pero refleja la actualización.&lt;/li>
&lt;li>&lt;strong>Merge with provenance&lt;/strong>: marcar el nuevo como &amp;ldquo;shadow&amp;rdquo; del viejo, mantener ambos en lineage pero indexar sólo uno. Mejor para auditoría regulada.&lt;/li>
&lt;/ul>
&lt;p>La política tiene que ser explícita y aplicada por igual, no decisión ad-hoc por documento.&lt;/p>
&lt;h2 id="capa-3--anonimización-pii-con-precisionrecall-medidos">Capa 3 — Anonimización PII con precision/recall medidos&lt;/h2>
&lt;p>Esta capa es la que más fácilmente se vuelve teatro. El error típico: instalar Presidio, ejecutarlo sobre el corpus, asumir que el output está limpio. &lt;strong>Sin medir precision y recall del detector contra un golden anotado, no sabes nada.&lt;/strong>&lt;/p>
&lt;p>El detector de PII puede fallar de dos formas:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Falso negativo&lt;/strong> (recall bajo): no detecta un DNI escrito como &amp;ldquo;12345678-A&amp;rdquo; porque tu modelo está entrenado en formato &lt;code>12345678A&lt;/code> sin guión. El RAG sirve datos personales sin redactar.&lt;/li>
&lt;li>&lt;strong>Falso positivo&lt;/strong> (precision baja): redacta el número de un manual de configuración pensando que es un teléfono. El RAG pierde información útil.&lt;/li>
&lt;/ul>
&lt;p>Los dos son problemas; la regulación (RGPD, ENS, NIS2) penaliza el primero, la experiencia del usuario se degrada con el segundo. El ratio aceptable depende del dominio — en datos médicos prácticamente cero falsos negativos es no-negociable; en documentación técnica interna se puede tolerar más recall a cambio de menos precision.&lt;/p>
&lt;p>La métrica estándar es &lt;strong>F1 sobre un golden anotado&lt;/strong>:&lt;/p>
&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>
&lt;p>Para construir el golden de PII, anotar ~200 documentos a mano con cada entidad marcada (DNI, IBAN, email, teléfono, dirección, nombre propio). Después, ejecutar el detector y calcular las métricas por categoría — no solo agregadas, porque un F1 global 0,90 puede esconder un recall 0,55 sobre IBANs.&lt;/p>
&lt;p>&lt;strong>Stack 2026&lt;/strong> para esta capa:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Microsoft Presidio&lt;/strong> (MIT, Microsoft): el OSS más completo. Detectores configurables, reconoce ~50 entidades por defecto, extensible con patrones regex propios o con modelos NER fine-tuneados.&lt;/li>
&lt;li>&lt;strong>spaCy NER&lt;/strong> (MIT, Explosion AI): base para detectores custom; útil cuando Presidio no cubre una entidad de dominio.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong> (LLama Community License, Meta): clasificador safety que también detecta PII en una pasada — opción cuando ya tienes GPU para inferencia y prefieres una sola pasada.&lt;/li>
&lt;li>&lt;strong>DataFog&lt;/strong> (Apache 2.0): alternativa más reciente, especializado en pipelines streaming.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Patrón híbrido recomendado&lt;/strong>: Presidio para detección rule-based + regex (rápido, deterministic) → Llama Guard como segunda pasada sobre lo que Presidio no marcó (ensemble que sube recall sin matar throughput). Esto se mide y se reporta como F1 agregado y por categoría en cada release del detector.&lt;/p>
&lt;p>&lt;strong>Falacia común&lt;/strong>: confiar en que un detector con F1 0,95 &amp;ldquo;es muy bueno&amp;rdquo;. Si tienes 1 M de documentos y cada uno contiene 1 entidad PII media, F1 0,95 significa &lt;strong>50.000 entidades mal manejadas&lt;/strong> (entre falsos positivos y negativos). En datos sensibles, hay que diseñar para que los falsos negativos vayan a cuarentena humana, no al corpus público.&lt;/p>
&lt;h2 id="capa-4--anti-contaminación-con-el-golden-eval-set">Capa 4 — Anti-contaminación con el golden eval set&lt;/h2>
&lt;p>Si el RAG corpus contiene fragmentos del golden eval set, las métricas de Eval miden memorización. El modelo devuelve la respuesta exacta porque la tiene literalmente en su contexto, no porque haya generalizado nada. El deploy promociona modelos que &lt;strong>brillan en el examen y fallan en producción&lt;/strong>.&lt;/p>
&lt;p>Esta capa es la más fácil de implementar y la más fácil de olvidar:&lt;/p>
&lt;ol>
&lt;li>El &lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">golden eval set&lt;/a> tiene su hash versionado.&lt;/li>
&lt;li>Antes de indexar cualquier documento nuevo en el corpus RAG, ejecutar un check de overlap token-a-token (o por shingles, similar a MinHash) contra el golden set.&lt;/li>
&lt;li>Si hay overlap superior a un umbral (típicamente ≥ 30% de n-gramas de 5 tokens), &lt;strong>el documento no se indexa&lt;/strong>. Se queda en cuarentena con bandera de &amp;ldquo;contamination risk vs golden_v12&amp;rdquo;.&lt;/li>
&lt;li>Un humano revisa los rechazos. A veces son falsos positivos (cita corta, frase boilerplate). A veces son contaminación real que un proveedor metió sin darse cuenta.&lt;/li>
&lt;/ol>
&lt;p>La razón profunda: el RAG corpus y el golden set son artefactos &lt;strong>enemigos por diseño&lt;/strong>. El golden mide qué tan bien el sistema generaliza a preguntas que no ha visto. Si esas preguntas están en el RAG, el sistema las &amp;ldquo;ve&amp;rdquo; en cada query. La métrica deja de medir generalización.&lt;/p>
&lt;p>Este check es trivial computacionalmente — un hash join sobre n-gramas. La complejidad está en mantenerlo: cada vez que el golden cambia (mensual o trimestral), hay que re-validar el corpus completo contra el nuevo golden. Sin esa disciplina, la contaminación entra por la puerta de atrás cuando alguien actualiza el golden con casos reales que ya estaban siendo servidos por el RAG.&lt;/p>
&lt;h2 id="capa-5--lineage-end-to-end-del-documento-al-trace">Capa 5 — Lineage end-to-end: del documento al trace&lt;/h2>
&lt;p>La última capa es la que cierra la cadena auditable. Cada chunk que se indexa en el vector store lleva metadata que permite responder a la pregunta forense:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;El sistema generó esta respuesta el 14 de marzo a las 16:23. ¿De qué documento exacto salió el fragmento citado? ¿Cuándo entró ese documento al corpus? ¿Qué versión del embedder lo procesó? ¿Quién aprobó su ingest?&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>Sin lineage, esa pregunta es irrespondible. Con lineage bien hecho, son cuatro queries.&lt;/p>
&lt;p>El patrón canónico:&lt;/p>
&lt;ul>
&lt;li>Cada chunk indexado lleva en su 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>Cada respuesta del RAG en producción emite un span de trace que incluye los &lt;code>chunk_id&lt;/code> recuperados.&lt;/li>
&lt;li>El sistema central de tracing (&lt;a href="https://blog.lo0.es/posts/agentsight-tracing-llm/">Langfuse, Phoenix u OpenLLMetry&lt;/a>) une &lt;code>chunk_id&lt;/code> → metadata del chunk → metadata del documento → &lt;code>dataset_hash&lt;/code> del corpus → versión del embedder → etc.&lt;/li>
&lt;/ul>
&lt;p>Las herramientas que estandarizan este pegamento son &lt;strong>OpenLineage&lt;/strong> (Apache 2.0, LF AI &amp;amp; Data) y &lt;strong>Marquez&lt;/strong> (Apache 2.0, su implementación de servidor). Definen un schema de eventos de lineage interoperable entre sistemas; un job de ingest emite un evento &amp;ldquo;produced corpus_v12.3 from source X with embedder bge-base-v1.5&amp;rdquo;; un job de retrieval emite &amp;ldquo;consumed corpus_v12.3 with query Q produced response R&amp;rdquo;. El grafo se reconstruye automáticamente.&lt;/p>
&lt;p>Esta capa es la única forma de cumplir auditorías reales bajo regulaciones tipo EU AI Act, RGPD o ENS, donde la trazabilidad de qué dato entró en qué respuesta es exigencia, no opción. Sin ella, la respuesta &amp;ldquo;no sabemos de qué documento salió esto&amp;rdquo; no es aceptable — y es la respuesta por defecto si no se construye el lineage desde el día uno.&lt;/p>
&lt;h2 id="las-matemáticas-que-importan">Las matemáticas que importan&lt;/h2>
&lt;p>Más allá de los thresholds de dedup y las F1 de PII, hay tres piezas matemáticas que cualquier equipo serio acaba usando.&lt;/p>
&lt;p>&lt;strong>Chunk size vs retrieval quality&lt;/strong>. El tamaño del chunk afecta la calidad del retrieval de forma no monótona: chunks demasiado pequeños fragmentan ideas (el retrieval devuelve un trozo sin contexto), demasiado grandes diluyen la señal (el embedding mezcla varios temas y la similitud baja). El sweet spot empírico para textos técnicos en 2026 está entre &lt;strong>256 y 768 tokens por chunk&lt;/strong>, con &lt;strong>overlap de 15-25%&lt;/strong> entre chunks contiguos para preservar continuidad.&lt;/p>
&lt;p>Numéricamente, para un corpus de 1 M documentos con longitud media 2.000 tokens, chunkear a 512 tokens con overlap 100 da: (\frac{2000}{512 - 100} \approx 5) chunks por documento, total ≈ 5 M chunks indexados. Con embeddings de 768 dimensiones y &lt;code>float32&lt;/code>, ocupa (5 \cdot 10^6 \cdot 768 \cdot 4 \approx 15) GB de memoria de vectores — manejable en cualquier vector store moderno.&lt;/p>
&lt;p>&lt;strong>Cobertura del golden de PII&lt;/strong>. Para saber si el golden anotado de PII es suficientemente representativo, calcular la &lt;strong>proporción de categorías cubiertas&lt;/strong>: si tu golden de 200 documentos tiene 5 ejemplos de IBAN y producción tiene 12.000 IBANs por día, el F1 sobre IBANs medido es ruido estadístico. Regla práctica: &lt;strong>mínimo 30 ejemplos por categoría&lt;/strong> para que las métricas por categoría tengan sentido.&lt;/p>
&lt;p>&lt;strong>Coste de re-embedding al rotar el modelo&lt;/strong>. Cambiar el embedder invalida el índice entero. Para un corpus de 5 M chunks con un modelo tipo &lt;code>BAAI/bge-base-en-v1.5&lt;/code> (768 dim, ~110 M parámetros) servido en vLLM sobre 1× H100, el throughput es del orden de 8.000-15.000 chunks/segundo. Re-embedding completo: ~5-10 minutos. Para un embedder más grande (&lt;code>bge-large&lt;/code>, 1024 dim, ~335 M parámetros): factor 3× peor, ~15-30 minutos. El cuello de botella suele ser el I/O del vector store, no el cómputo GPU. El patrón &lt;strong>dual-index&lt;/strong> —mantener el índice viejo sirviendo mientras se construye el nuevo, swap atómico al final— evita downtime y permite rollback.&lt;/p>
&lt;h2 id="aplicado-a-hardware-on-premise-típico">Aplicado a hardware on-premise típico&lt;/h2>
&lt;p>Para un despliegue on-premise que mantenga toda la curación sin enviar datos a APIs externas:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RTX 4090 (24 GB)&lt;/strong>: cubre la capa 1 (schema check con Great Expectations es CPU-bound), capa 2 nivel A y B (hash + MinHash son CPU-bound), capa 2 nivel C semantic dedup con embedder tipo &lt;code>bge-base&lt;/code> (8-15k chunks/s, suficiente para corpus de hasta 5-10 M chunks en horas). Para Presidio en modo NER (capa 3) corre cómodo. Es la GPU razonable para todo el pipeline de curación en corpus mid-size.&lt;/li>
&lt;li>&lt;strong>Configuración genérica 4×H100 SXM (320 GB total, NVLink)&lt;/strong>: necesaria sólo si el corpus supera ~50 M chunks o si quieres re-embeddings frecuentes con modelos grandes (&lt;code>bge-large&lt;/code>, &lt;code>e5-mistral&lt;/code>). En la práctica, dos GPUs sirven el embedder en TP=2 con throughput &amp;gt;50k chunks/s, las otras dos van para el judge PII (Llama Guard 4) o para serving del modelo principal de inferencia. Capacity para corpora de cientos de millones de chunks.&lt;/li>
&lt;/ul>
&lt;p>La cuenta tozuda: con 4090, la curación del corpus es una tarea overnight; con 4×H100, es minutos. La decisión depende del tamaño del corpus y de la frecuencia con la que rotas embedder o reglas PII.&lt;/p>
&lt;h2 id="las-siete-trampas-que-matan-esta-etapa">Las siete trampas que matan esta etapa&lt;/h2>
&lt;p>&lt;strong>Trampa 1 — Sin schema validado al ingest.&lt;/strong> Documentos malformados llegan al chunker, el chunker los trocea sin sentido, embeddings basura entran al índice. La respuesta del RAG cita texto incoherente y nadie sabe por qué.&lt;/p>
&lt;p>&lt;strong>Trampa 2 — Dedup sólo a nivel exact hash.&lt;/strong> El corpus se llena de paráfrasis y traducciones del mismo documento. El top-k del retrieval devuelve 3 veces la misma fuente. El LLM la lee como tres confirmaciones.&lt;/p>
&lt;p>&lt;strong>Trampa 3 — PII detector sin medición de precision/recall.&lt;/strong> Se asume que Presidio &amp;ldquo;funciona&amp;rdquo;. Los IBANs en formato no estándar se cuelan. El RAG sirve datos personales.&lt;/p>
&lt;p>&lt;strong>Trampa 4 — Golden eval set contaminado con corpus.&lt;/strong> Las métricas de Eval miden memorización. Promociones aprueban modelos que fallan en producción real.&lt;/p>
&lt;p>&lt;strong>Trampa 5 — Sin lineage al chunk.&lt;/strong> La pregunta &amp;ldquo;¿de dónde salió esta cita?&amp;rdquo; no tiene respuesta. La auditoría regulatoria fracasa. Los incidentes no se pueden investigar.&lt;/p>
&lt;p>&lt;strong>Trampa 6 — Mantenimiento como evento puntual.&lt;/strong> El corpus se cura una vez al inicializar el sistema, después se asume que está bien. Tras 6 meses, los documentos están desactualizados, las nuevas reglas PII no se aplican retrospectivamente, el dedup no se re-corre tras añadir nuevas fuentes. El corpus se degrada en silencio.&lt;/p>
&lt;p>&lt;strong>Trampa 7 — Cuarentena sin revisión humana.&lt;/strong> Los documentos rechazados van a una tabla que nadie mira. Los falsos positivos se acumulan, los verdaderos casos de contaminación no se investigan, la confianza del equipo en la curación se erosiona y empieza la presión para &amp;ldquo;relajar los umbrales&amp;rdquo;.&lt;/p>
&lt;p>Las siete son operacionales, no técnicas. La curación del corpus no se rompe por un bug del algoritmo: se rompe porque la disciplina se relaja. Es el equivalente exacto del tipo de degradación que mata las suites de Eval — y en ambos casos el síntoma es el mismo: las métricas mejoran o se mantienen mientras la experiencia real empeora.&lt;/p>
&lt;h2 id="lo-que-no-hemos-cubierto-próximos-posts">Lo que no hemos cubierto (próximos posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Vector store versioning propiamente dicho&lt;/strong>: un índice de embeddings no se versiona como un dataset crudo porque depende del modelo de embedding. Cambiar el embedder reescribe todo el índice. Es otro animal con sus propios patrones (branching del índice, reembedding selectivo, recall-aware ANN parameters).&lt;/li>
&lt;li>&lt;strong>Streaming corpus updates con CDC&lt;/strong>: cuando el corpus tiene que actualizarse en near-real-time desde un sistema OLTP. El &lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">post de ingestión Postgres + Qdrant&lt;/a> cubre la mecánica; queda pendiente el patrón de invalidación selectiva de chunks que dependen de filas borradas.&lt;/li>
&lt;li>&lt;strong>Multi-tenant corpus isolation&lt;/strong>: cómo se monta un corpus compartido vs uno con namespaces por tenant, con ACLs sobre chunks individuales. Especialmente relevante para RAG multi-cliente bajo soberanía de datos.&lt;/li>
&lt;li>&lt;strong>Federated corpus&lt;/strong>: corpora distribuidos en silos que el sistema consulta sin centralizar el contenido. Patrón emergente para empresas con varias sedes y restricciones cross-border.&lt;/li>
&lt;li>&lt;strong>Reranking aware curation&lt;/strong>: cómo cambia la disciplina de curación cuando hay un reranker (Cohere Rerank, ColBERTv2, BGE-Reranker) que reordena el top-k tras la retrieval. Algunos duplicados que tolerarías sin reranker no se toleran cuando el reranker les sube en el ranking. La mecánica de la capa de retrieval (hybrid BM25 + dense + reranker) está desarrollada en el &lt;a href="https://blog.lo0.es/posts/rag-reranker-hybrid-retrieval-fundamentos/">post de reranker y hybrid retrieval&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">El pipeline LLMOps de seis etapas&lt;/a> — Etapa 1 (Data) y por qué la curación es la sub-tarea más infravalorada de toda la cadena.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/anatomia-request-llm-mayo-2026/">Anatomía de una petición LLM en producción, mayo 2026&lt;/a> — el tour forense cruza el corpus y los chunks recuperados; aquí están los criterios que cualquier chunk tuvo que pasar para estar en producción.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/data-versioning-dvc-lakefs/">Data versioning: DVC, lakeFS y el reto del golden dataset reproducible&lt;/a> — los cuatro artefactos data y por qué se versionan diferenciados. El corpus RAG es uno de los cuatro.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">PostgreSQL + Qdrant en la etapa de ingestión&lt;/a> — el patrón de microservicios que mueve documentos desde origen hasta el vector store. La curación de este post se enchufa entre el ingest y el indexador.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/">RAG sobre Kafka y datalake&lt;/a> — el transporte streaming. Schema Registry materializa la capa 1.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/rag-reranker-hybrid-retrieval-fundamentos/">Reranker y hybrid retrieval: el comité que decide los 5 chunks que el LLM va a leer&lt;/a> — la capa siguiente. El bibliotecario de este post decide qué entra al índice; aquel decide qué sale del índice al contexto del LLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">Evals para LLMs: la capa después del tracing&lt;/a> — el golden eval set es el &amp;ldquo;enemigo por diseño&amp;rdquo; del corpus RAG; la capa 4 (anti-contaminación) materializa la disciplina entre ambos.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">Prompt versioning: el contrato que evita que un cambio de cinco palabras hunda tu sistema&lt;/a> — el &lt;code>prompt_id&lt;/code> que viaja en el trace es el complemento del &lt;code>dataset_hash&lt;/code> del corpus en lineage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/retrain-cerrar-el-bucle-feedback-dataset-adapter/">Retrain: cerrar el bucle entre el incidente en producción y el adapter que lo arregla&lt;/a> — el corpus enriched de retrain también necesita las cinco capas, con énfasis adicional en el feedback humano.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/catalogo-herramientas-oss-llmops/">El catálogo OSS para LLMOps en seis etapas&lt;/a> — fichas de Presidio, Unstructured, Argilla, Great Expectations, OpenLineage.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&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> — docs oficiales, lista de entidades soportadas, guía de extensión con NER custom.&lt;/li>
&lt;li>&lt;strong>OpenLineage&lt;/strong>: &lt;a href="https://openlineage.io/">https://openlineage.io/&lt;/a> — spec del schema de eventos y libs por lenguaje.&lt;/li>
&lt;li>&lt;strong>Marquez&lt;/strong>: &lt;a href="https://marquezproject.ai/">https://marquezproject.ai/&lt;/a> — implementación de servidor de 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> — librería Python de referencia para deduplicación near-duplicate a escala.&lt;/li>
&lt;li>&lt;strong>Great Expectations&lt;/strong>: &lt;a href="https://docs.greatexpectations.io/">https://docs.greatexpectations.io/&lt;/a> — suites declarativas de data quality.&lt;/li>
&lt;li>&lt;strong>Unstructured&lt;/strong>: &lt;a href="https://docs.unstructured.io/">https://docs.unstructured.io/&lt;/a> — parseo y normalización de documentos heterogéneos (PDF, HTML, DOCX, eml) antes del chunking.&lt;/li>
&lt;li>&lt;strong>Argilla&lt;/strong>: &lt;a href="https://docs.argilla.io/">https://docs.argilla.io/&lt;/a> — UI de anotación humana para construir el golden de PII y otros calibration sets.&lt;/li>
&lt;li>&lt;strong>Llama Guard 4&lt;/strong>: paper técnico de Meta, multimodal safety classifier — útil como segunda capa de detección PII.&lt;/li>
&lt;li>&lt;strong>RGPD, EU AI Act, ENS, NIS2&lt;/strong> — los marcos regulatorios cuya conformidad depende, en la práctica, de la disciplina de las capas 3 (PII) y 5 (lineage). Pendiente la publicación final de los technical standards de CEN/CENELEC para conformity assessment de sistemas GenAI bajo EU AI Act.&lt;/li>
&lt;/ul></description></item></channel></rss>