<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ingesta on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/ingesta/</link><description>Recent content in Ingesta on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Thu, 11 Jun 2026 09:00:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/tags/ingesta/index.xml" rel="self" type="application/rss+xml"/><item><title>Ingesta documental end-to-end: del PDF al chunk indexado</title><link>https://blog.lo0.es/posts/ingesta-documental-rag-pdf-a-chunk-indexado/</link><pubDate>Thu, 11 Jun 2026 09:00:00 +0000</pubDate><guid>https://blog.lo0.es/posts/ingesta-documental-rag-pdf-a-chunk-indexado/</guid><description>&lt;blockquote>
&lt;p>Cuarta pieza de una serie operativa sobre exprimir un cluster LLM on-premise genérico de &lt;strong>4×H100 SXM 80 GB&lt;/strong>. Las hermanas de esta tanda: &lt;a href="https://blog.lo0.es/posts/servir-embeddings-rerankers-tei-produccion/">servir embeddings y rerankers con TEI en producción&lt;/a> detalla la pieza de inferencia que esta ingesta alimenta; &lt;a href="https://blog.lo0.es/posts/gitops-stack-inferencia-llm-flux/">GitOps del stack de inferencia con Flux&lt;/a> versiona y despliega todo este pipeline; y &lt;a href="https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/">hardening y secretos del stack soberano&lt;/a> protege el corpus y las credenciales que la ingesta toca. El consumidor final de lo que aquí construimos —un asistente soberano end-to-end— se monta en una entrega posterior.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un sistema RAG hereda la calidad de su corpus, y el corpus hereda la calidad de la &lt;strong>ingesta&lt;/strong> que lo fabricó. Es el &lt;strong>garbage-in/garbage-out&lt;/strong> del retrieval: un chunk mal extraído de una tabla, un encabezado de página repetido mil veces, un PDF escaneado del que solo sacaste ruido OCR —todo eso se embebe, se indexa y reaparece como contexto envenenado en la respuesta del modelo. La ingesta documental no es un script de una tarde, es un &lt;strong>pipeline de seis etapas&lt;/strong> con decisiones de ingeniería en cada una: (1) &lt;strong>extraer/parsear&lt;/strong> —aquí se decide casi todo: layout-aware (Docling, que con Granite-Docling-258M preserva tablas, fórmulas y estructura, y según IBM evita el OCR clásico hasta ~30× más rápido) frente a extracción de texto plano (PyMuPDF), con OCR o un VLM para escaneos—; (2) &lt;strong>limpiar/normalizar&lt;/strong> —quitar boilerplate, normalizar Unicode, reconstruir párrafos rotos—; (3) &lt;strong>trocear&lt;/strong> —y aquí 2026 ha movido el consenso: un benchmark de febrero de 2026 puso el &lt;em>recursive&lt;/em> a 512 tokens en cabeza (69% de acierto) por delante del chunking semántico (54%), y el &lt;em>late chunking&lt;/em> aporta contexto global sin coste extra de almacenamiento—; (4) &lt;strong>enriquecer metadatos&lt;/strong> —fuente, página, sección, timestamp, ACL/tenant: para retrieval filtrado, citación y auditabilidad—; (5) &lt;strong>embeber&lt;/strong> —vía un servidor de embeddings tipo TEI—; (6) &lt;strong>indexar&lt;/strong> —en pgvector o Qdrant, con payload, dense + sparse—. Entre medias, &lt;strong>deduplicación&lt;/strong> exacta (hash) y near-dup (MinHash/LSH o coseno con umbral), porque los near-duplicados degradan el recall y la diversidad del retrieval. La parte numérica importa: un corpus de $N_{docs}\times$ páginas $\times$ tokens/página da los tokens totales y, a un throughput de CPU dado, el tiempo de ingesta; y $N_{chunks}\times d\times\text{bytes}$ da el tamaño del índice, que en int8 cae 4× frente a fp32. Lectura para el 4×H100: &lt;strong>la ingesta es trabajo de CPU&lt;/strong> —enlaza con la pieza de RAG en CPU—; la GPU solo entra si parseas con un VLM (Granite-Docling) o usas un embedder de 7B.&lt;/p>
&lt;h2 id="la-analogía-la-cadena-de-catalogación-de-un-archivo">La analogía: la cadena de catalogación de un archivo&lt;/h2>
&lt;p>Imagina el departamento de catalogación de un gran archivo documental. No es una persona metiendo papeles en cajas; es una &lt;strong>cadena de estaciones de trabajo&lt;/strong>, cada una con un oficio distinto y un criterio de calidad propio. Un documento entra por un extremo en bruto y sale por el otro convertido en una ficha localizable en segundos. Si una estación hace mal su trabajo, las de abajo heredan el error y lo amplifican.&lt;/p>
&lt;p>La &lt;strong>primera estación es recepción y lectura&lt;/strong>. Llega una caja heterogénea: informes mecanografiados, fotocopias torcidas, tablas dobladas, microfichas. Un archivero experto &lt;strong>lee de verdad&lt;/strong> cada pieza: distingue el cuerpo del texto de los márgenes, reconstruye una tabla que ocupa dos páginas, transcribe a mano lo que el escáner no leyó. Un archivero novato, en cambio, fotocopia todo en plano y entrega un churro de texto donde las columnas de las tablas se entrelazan sin sentido. Esa es exactamente la diferencia entre &lt;strong>parsing layout-aware y extracción de texto plano&lt;/strong>, y es donde se gana o se pierde casi toda la calidad.&lt;/p>
&lt;p>La &lt;strong>segunda estación es el expurgo&lt;/strong>. Antes de archivar, alguien retira los duplicados —tres copias del mismo memo, dos versiones casi idénticas de un informe— y limpia las marcas inútiles: sellos de &amp;ldquo;COPIA&amp;rdquo;, pies de página repetidos en cada hoja, manchas de café. Si no expurgas, el archivo se llena de copias que, cuando alguien busca, devuelven el mismo documento seis veces y entierran lo diverso. Esto es la &lt;strong>deduplicación y la limpieza&lt;/strong>.&lt;/p>
&lt;p>La &lt;strong>tercera estación trocea en fichas&lt;/strong>. Un libro de 400 páginas no se cataloga como una sola ficha gigante; se descompone en entradas manejables —por capítulo, por sección— de un tamaño que un lector pueda consultar de un vistazo. Demasiado grande y la ficha mezcla temas; demasiado pequeña y pierde el contexto. Esto es el &lt;strong>chunking&lt;/strong>, y el tamaño de la ficha es la decisión que más condiciona el retrieval.&lt;/p>
&lt;p>La &lt;strong>cuarta estación etiqueta&lt;/strong>. Cada ficha lleva signatura, fecha, fondo de procedencia, nivel de acceso (¿esto lo puede ver cualquiera o solo el departamento jurídico?). Sin esas etiquetas no puedes filtrar una búsqueda ni decir de qué documento salió una afirmación. Son los &lt;strong>metadatos&lt;/strong>: fuente, página, sección, timestamp, ACL.&lt;/p>
&lt;p>La &lt;strong>quinta y sexta estaciones colocan la ficha en la estantería indexada&lt;/strong>: la traducen al lenguaje del catálogo —un vector— y la colocan en el cajón correcto del fichero, de modo que una consulta encuentre las fichas afines sin recorrerlo entero. Es el &lt;strong>embedding y la indexación&lt;/strong> en el vector store.&lt;/p>
&lt;p>La moraleja recorre todo el post: &lt;strong>el RAG no puede recuperar mejor de lo que la cadena de catalogación archivó&lt;/strong>. Si la primera estación troceó mal una tabla, ningún reranker la arreglará después. La ingesta es la estación de calidad del sistema entero, y casi toda ella —como el archivo, lleno de gente paciente trabajando sin que nadie les cronometre— es trabajo de fondo que cabe en CPU.&lt;/p>
&lt;h2 id="el-pipeline-de-seis-etapas">El pipeline de seis etapas&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="Pipeline de ingesta documental RAG en seis etapas, del PDF al chunk indexado">
&lt;text x="440" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Del documento heterogéneo al chunk indexado: seis etapas&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">ENTRADA&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 · escaneo&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 · PARSEAR&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">tablas · 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 si 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 · LIMPIAR&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 · TROCEAR&lt;/text>
&lt;text x="510" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">recursive · semántico&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 · METADATOS&lt;/text>
&lt;text x="650" y="89" text-anchor="middle" font-size="9.5" fill="currentColor">fuente · página&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 · EMBEBER&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 si 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 · INDEXAR&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">Idempotencia y re-indexación incremental (transversal):&lt;/text>
&lt;text x="40" y="346" font-size="11" fill="currentColor">upsert por doc-id + hash de contenido → si el hash no cambió, no se re-embebe.&lt;/text>
&lt;text x="40" y="364" font-size="11" fill="currentColor">CDC (Debezium sobre el WAL de Postgres) propaga altas/bajas/ediciones al índice sin reindexar el corpus entero.&lt;/text>
&lt;text x="40" y="386" font-size="11" font-style="italic" fill="currentColor">El garbage-in/garbage-out se decide en la etapa 1 (parsear): un error de extracción ningún reranker lo arregla después.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>La tentación de tratar la ingesta como &amp;ldquo;leer el PDF y trocearlo&amp;rdquo; es la fuente del 80% de los RAG mediocres. Cada etapa tiene un criterio de calidad y un fallo característico, y los fallos se &lt;strong>encadenan hacia abajo&lt;/strong>: si parseas mal, limpiar no recupera lo perdido; si troceas mal, embeber fija el error en el vector; si no etiquetas, no podrás filtrar ni citar. El resto del post recorre las seis estaciones con sus decisiones.&lt;/p>
&lt;h2 id="etapa-1--parsear-layout-aware-vs-texto-plano">Etapa 1 — Parsear: layout-aware vs texto plano&lt;/h2>
&lt;p>Aquí se gana o se pierde casi todo. Un PDF no es texto: es un conjunto de instrucciones de dibujo de glifos sobre un lienzo. &amp;ldquo;Extraer el texto&amp;rdquo; de un PDF es &lt;strong>reconstruir&lt;/strong> un orden de lectura que el formato no garantiza, y las tablas, las columnas y las figuras lo rompen sistemáticamente.&lt;/p>
&lt;p>Hay dos filosofías, y la elección condiciona el resto del pipeline.&lt;/p>
&lt;p>&lt;strong>Extracción de texto plano.&lt;/strong> Herramientas como &lt;strong>PyMuPDF&lt;/strong> (rápida, robusta, sin dependencias pesadas) leen el flujo de texto del PDF y lo vuelcan. Para documentos de una sola columna, prosa corrida y sin tablas, es perfecto: rapidísimo, fiel y barato en CPU. Su límite aparece con la estructura: una tabla de dos columnas sale con las celdas entrelazadas, un documento a doble columna mezcla el final de una con el principio de otra, y una factura escaneada no sale en absoluto porque no hay capa de texto. PyMuPDF también sabe segmentar por la tabla de contenidos (TOC) cuando el PDF la trae, lo que ayuda a un troceado por secciones (&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>Parsing layout-aware.&lt;/strong> Herramientas como &lt;strong>Docling&lt;/strong> (proyecto open source impulsado por IBM Research) y &lt;strong>unstructured.io&lt;/strong> primero &lt;strong>entienden el layout&lt;/strong> —identifican títulos, párrafos, tablas, figuras, listas, fórmulas— y solo después extraen el contenido respetando esa estructura. Docling captura la estructura de las tablas (filas, columnas, encabezados multinivel) y, en su evolución de 2026, lo hace con un VLM: &lt;strong>Granite-Docling-258M&lt;/strong>, liberado por IBM en enero de 2026 bajo Apache 2.0, un modelo visión-lenguaje compacto (~258M parámetros, backbone Granite 3 + encoder visual SigLIP2) que convierte páginas —PDF, diapositivas, escaneos— directamente a un formato estructurado llamado &lt;strong>DocTags&lt;/strong>, preservando tablas, código, matemáticas inline y de bloque, y la jerarquía del documento (&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 en Hugging Face&lt;/a>; &lt;a href="https://github.com/docling-project/docling">repo Docling&lt;/a>). IBM afirma que la vía VLM &lt;strong>evita el OCR clásico&lt;/strong> y que eso &amp;ldquo;reduce errores y acelera la solución hasta 30×&amp;rdquo; frente a un pipeline OCR tradicional —cifra de IBM Research, la cito y la trato como orientativa, no como un benchmark independiente reproducido aquí.&lt;/p>
&lt;p>unstructured.io ofrece estrategias de partición graduadas según la complejidad del documento: &lt;strong>&lt;code>fast&lt;/code>&lt;/strong> (texto plano, rápido), &lt;strong>&lt;code>hi_res&lt;/code>&lt;/strong> (identifica el layout, recomendada cuando importa clasificar bien tablas y elementos), &lt;strong>VLM&lt;/strong> y &lt;strong>&lt;code>auto&lt;/code>&lt;/strong>, equilibrando velocidad, coste y precisión (&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>). La regla práctica: &lt;strong>&lt;code>fast&lt;/code> para prosa, &lt;code>hi_res&lt;/code> o VLM para documentos con tablas y estructura&lt;/strong>.&lt;/p>
&lt;h3 id="ocr-para-escaneos">OCR para escaneos&lt;/h3>
&lt;p>Cuando el documento no tiene capa de texto —un escaneo, una foto, una microficha digitalizada—, hay que &lt;strong>reconocer los caracteres&lt;/strong>. Tres vías:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OCR clásico&lt;/strong> (Tesseract, PaddleOCR, EasyOCR —con el que Docling integra cuando se necesita OCR explícito). Maduro, CPU-friendly, bueno con texto limpio; sufre con tablas, manuscritos y layouts complejos.&lt;/li>
&lt;li>&lt;strong>VLM end-to-end&lt;/strong> (Granite-Docling y similares). El modelo &amp;ldquo;mira&amp;rdquo; la página y emite estructura directamente, sin la etapa OCR separada. Mejor con layout complejo; &lt;strong>aquí sí entra la GPU&lt;/strong> si el VLM es grande o el volumen alto.&lt;/li>
&lt;li>&lt;strong>Híbrido&lt;/strong>: OCR para la transcripción de caracteres, modelo de layout para la estructura.&lt;/li>
&lt;/ul>
&lt;p>El criterio honesto: para un corpus de PDFs nativos con texto, PyMuPDF o unstructured &lt;code>fast&lt;/code> resuelven en CPU y barato. Para un corpus con tablas densas, formularios o escaneos, &lt;strong>Docling/Granite-Docling layout-aware paga su coste&lt;/strong> en calidad de chunk —y es la única etapa del pipeline donde la GPU puede justificarse.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Caso de documento&lt;/th>
&lt;th>Herramienta recomendada&lt;/th>
&lt;th>Silicio&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>PDF nativo, una columna, prosa&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 con tablas, doble columna, jerarquía&lt;/td>
&lt;td>Docling / unstructured &lt;code>hi_res&lt;/code>&lt;/td>
&lt;td>CPU (modelos de layout)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Escaneo, formulario, manuscrito, layout complejo&lt;/td>
&lt;td>Granite-Docling (VLM) o OCR+layout&lt;/td>
&lt;td>GPU si VLM grande / alto volumen&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HTML, DOCX, PPTX&lt;/td>
&lt;td>Docling (multi-formato) / parsers nativos&lt;/td>
&lt;td>CPU&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="etapa-2--limpiar-normalizar-y-deduplicar">Etapa 2 — Limpiar, normalizar y deduplicar&lt;/h2>
&lt;p>El texto recién parseado viene sucio. Limpiar es retirar lo que no aporta y normalizar lo que se representa de mil formas:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Boilerplate&lt;/strong>: encabezados y pies de página repetidos en cada hoja, números de página, marcas de agua, menús de navegación en HTML, banners de cookies. Si no los quitas, se embeben mil veces y contaminan tanto el índice como las respuestas.&lt;/li>
&lt;li>&lt;strong>Normalización Unicode&lt;/strong> (NFC/NFKC), espacios y guiones: el mismo carácter representado de varias formas rompe el matching exacto y ensucia los embeddings.&lt;/li>
&lt;li>&lt;strong>Reconstrucción de párrafos&lt;/strong>: deshacer los saltos de línea duros que el PDF metió a mitad de frase, sin fusionar párrafos que sí debían quedar separados.&lt;/li>
&lt;/ul>
&lt;h3 id="deduplicación-por-qué-importa">Deduplicación: por qué importa&lt;/h3>
&lt;p>El RAG sufre dos males de los duplicados. El &lt;strong>exacto&lt;/strong> —el mismo documento subido tres veces— hincha el índice y hace que una búsqueda devuelva la misma respuesta repetida, desperdiciando los &lt;code>top-k&lt;/code> slots de contexto. El &lt;strong>near-duplicado&lt;/strong> —dos versiones casi idénticas de un informe, un documento y su borrador— es peor: parecen distintos al hash pero dicen lo mismo, y &lt;strong>degradan el recall y la diversidad del retrieval&lt;/strong>. No es teoría: en la colección MS MARCO V2 se ha documentado un solapamiento sustancial de near-duplicados que, sin tratar, degrada la precisión de recuperación y reduce la diversidad de documentos recuperados (&lt;a href="https://arxiv.org/pdf/2406.16828">Ragnarök / TREC RAG 2024&lt;/a>).&lt;/p>
&lt;p>Dos niveles de dedup, complementarios:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Exacto (hash).&lt;/strong> Calcula un &lt;code>sha256&lt;/code> del contenido normalizado de cada documento (o chunk) y descarta los que coinciden. Coste $O(N)$, trivial. Atrapa duplicados byte a byte.&lt;/li>
&lt;li>&lt;strong>Near-dup (MinHash + LSH, o coseno de embeddings con umbral).&lt;/strong> Para los que difieren un poco pero significan lo mismo. &lt;strong>MinHash&lt;/strong> comprime cada documento en una firma compacta tal que la probabilidad de que dos firmas coincidan en una posición &lt;strong>iguala la similitud de Jaccard&lt;/strong> de los conjuntos de shingles originales; combinado con &lt;strong>Locality-Sensitive Hashing (LSH)&lt;/strong> encuentra todos los pares near-duplicados &lt;strong>sin comparar todos contra todos&lt;/strong>, reduciendo un problema cuadrático a casi lineal (&lt;a href="https://mbrenndoerfer.com/writing/minhash-algorithm-jaccard-similarity-lsh-deduplication">Brenndoerfer, &lt;em>MinHash, Jaccard, LSH&lt;/em>&lt;/a>). Es la técnica dominante en la limpieza de corpus de entrenamiento de LLM (C4, RefinedWeb la usan) y aplica igual al corpus de un RAG (&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>). La alternativa —coseno de embeddings con un umbral (p. ej. &amp;gt; 0.97)— atrapa duplicados semánticos que MinHash no ve (parafraseo), pero exige ya tener los embeddings y es más cara.&lt;/li>
&lt;/ul>
&lt;p>La regla práctica: &lt;strong>hash exacto siempre (es gratis); MinHash/LSH para corpus grandes con versiones; coseno con umbral si el parafraseo es un problema real&lt;/strong>. Y deduplica &lt;strong>antes de embeber&lt;/strong>: re-embeber un duplicado es gastar cómputo en basura que luego habrá que filtrar.&lt;/p>
&lt;h2 id="etapa-3--trocear-chunking">Etapa 3 — Trocear (chunking)&lt;/h2>
&lt;p>El chunking es la decisión que más condiciona el retrieval, y la que más mitos arrastra. El trade-off es triple: &lt;strong>tamaño de chunk ↔ granularidad de retrieval ↔ coste de contexto&lt;/strong>.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Chunks grandes&lt;/strong>: cada uno contiene más contexto y menos riesgo de cortar una idea por la mitad, pero la búsqueda es menos precisa (un vector representa demasiados temas) y, al recuperar, metes más tokens en el prompt del LLM —más coste y más riesgo de diluir lo relevante.&lt;/li>
&lt;li>&lt;strong>Chunks pequeños&lt;/strong>: retrieval muy granular y preciso, pero cada chunk pierde contexto (un fragmento de 43 tokens puede no significar nada fuera de su sección) y necesitas recuperar más para cubrir una respuesta.&lt;/li>
&lt;/ul>
&lt;p>Las estrategias, de menos a más sofisticada:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Tamaño fijo + overlap.&lt;/strong> Cortar cada $N$ tokens con un solapamiento de $k$ tokens entre chunks consecutivos para no partir una frase en seco. Simple, predecible, baseline razonable. El overlap es el seguro contra cortar una idea justo en la frontera.&lt;/li>
&lt;li>&lt;strong>Recursivo&lt;/strong> (&lt;code>RecursiveCharacterTextSplitter&lt;/code> de LangChain). Intenta cortar por separadores en orden de prioridad —párrafo, luego frase, luego palabra— para respetar lo más posible la estructura natural antes de caer al corte duro. Es el caballo de batalla.&lt;/li>
&lt;li>&lt;strong>Semántico.&lt;/strong> Embebe frases y corta donde la similitud entre frases consecutivas cae por debajo de un umbral, agrupando por coherencia de significado. Suena mejor sobre el papel; en la práctica de 2026 ha decepcionado (ver abajo).&lt;/li>
&lt;li>&lt;strong>Structure/layout-aware (por headings).&lt;/strong> Aprovecha la jerarquía que el parser layout-aware ya extrajo: un chunk por sección o subsección. unstructured ofrece la estrategia &lt;strong>&lt;code>by_title&lt;/code>&lt;/strong>, que abre un chunk nuevo cuando aparece un elemento de tipo título, evitando mezclar texto de secciones distintas (&lt;a href="https://docs.unstructured.io/">Unstructured docs&lt;/a>). Si parseaste con Docling/&lt;code>hi_res&lt;/code>, esta estrategia es casi gratis y suele ser la mejor para documentos bien estructurados.&lt;/li>
&lt;li>&lt;strong>Late chunking.&lt;/strong> El giro de 2024–2026: en vez de trocear y luego embeber cada chunk por separado, &lt;strong>embebe el documento entero primero&lt;/strong> (con un encoder de contexto largo) y &lt;strong>luego&lt;/strong> aplica las fronteras de chunk haciendo &lt;em>mean-pooling&lt;/em> de los embeddings de token dentro de cada span. El resultado: cada chunk conserva el &lt;strong>contexto global&lt;/strong> del documento —un pronombre o una referencia que solo se entiende por el párrafo anterior queda codificada en el vector— y todo sin coste extra de almacenamiento, porque acabas con un vector por chunk igual que siempre (&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="qué-dicen-los-benchmarks-de-2026-y-por-qué-el-semántico-decepciona">Qué dicen los benchmarks de 2026 (y por qué el semántico decepciona)&lt;/h3>
&lt;p>Conviene ser escéptico con la moda. Un benchmark de &lt;strong>Vecta de febrero de 2026&lt;/strong> sobre 7 estrategias en 50 papers académicos colocó al &lt;strong>recursive a 512 tokens en primer lugar con 69% de acierto&lt;/strong>, mientras que el &lt;strong>chunking semántico quedó en 54%&lt;/strong>, en parte porque producía fragmentos minúsculos —de media &lt;strong>43 tokens&lt;/strong>— demasiado pequeños para significar algo (&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>). Un análisis sistemático de enero de 2026 identificó además un &lt;strong>&amp;ldquo;context cliff&amp;rdquo; en torno a los 2.500 tokens&lt;/strong>, donde la calidad de respuesta cae al meter contextos demasiado largos —argumento extra contra los chunks gigantes ([ídem]). La lectura honesta: &lt;strong>el recursive de tamaño moderado con overlap sigue siendo el baseline difícil de batir&lt;/strong>; el late chunking es la mejora con mejor relación coste/beneficio cuando el modelo lo soporta; el semántico promete más de lo que entrega.&lt;/p>
&lt;h3 id="ejemplo-numérico-de-chunking">Ejemplo numérico de chunking&lt;/h3>
&lt;p>Pongamos un documento técnico de &lt;strong>30 páginas&lt;/strong>, ~&lt;strong>500 tokens de prosa útil por página&lt;/strong> tras limpiar (las tablas y figuras se trocean aparte). Son $30 \times 500 = 15{.}000$ tokens de texto. Troceamos con &lt;strong>recursive a 512 tokens y un 20% de overlap&lt;/strong> ($0.20 \times 512 \approx 102$ tokens). El &amp;ldquo;paso&amp;rdquo; efectivo entre el inicio de un chunk y el siguiente es:&lt;/p>
&lt;p>$$\text{paso} = \text{tamaño} - \text{overlap} = 512 - 102 = 410 \text{ tokens}$$&lt;/p>
&lt;p>El número de chunks del documento es entonces, aproximadamente:&lt;/p>
&lt;p>$$N_{chunks} \approx \left\lceil \frac{T_{doc} - \text{overlap}}{\text{paso}} \right\rceil = \left\lceil \frac{15{.}000 - 102}{410} \right\rceil \approx \lceil 36.3 \rceil = 37 \text{ chunks}$$&lt;/p>
&lt;p>Sin overlap habrían sido $\lceil 15{.}000 / 512 \rceil = 30$ chunks; el 20% de overlap nos cuesta &lt;strong>7 chunks extra (~23% más)&lt;/strong> a cambio de no partir ideas en las fronteras. Ese es el precio concreto del overlap: más vectores que embeber, indexar y almacenar, a cambio de robustez en el retrieval. Para el dimensionado del corpus completo usaremos este factor.&lt;/p>
&lt;h2 id="etapa-4--enriquecer-con-metadatos">Etapa 4 — Enriquecer con metadatos&lt;/h2>
&lt;p>Un chunk sin metadatos es una ficha sin signatura: existe pero no sirve. A cada chunk se le adjunta un &lt;strong>payload&lt;/strong> con, al menos:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fuente y localización&lt;/strong>: &lt;code>document_id&lt;/code>, nombre/URI del documento, &lt;strong>número de página&lt;/strong>, &lt;strong>sección/heading&lt;/strong> (que el parser layout-aware ya te dio). Imprescindible para &lt;strong>citar&lt;/strong>: poder decir &amp;ldquo;esto sale del documento X, página 12, sección 3.2&amp;rdquo; es lo que separa un RAG auditable de uno que alucina sin trazabilidad.&lt;/li>
&lt;li>&lt;strong>Timestamp&lt;/strong>: cuándo se ingestó y la fecha del documento. Permite filtrar por frescura y detectar contenido obsoleto.&lt;/li>
&lt;li>&lt;strong>ACL / tenant&lt;/strong>: quién puede ver este chunk. Es &lt;strong>crítico&lt;/strong> y se aplica como &lt;strong>filtro en el retrieval&lt;/strong>: un usuario del departamento A no debe recuperar chunks marcados solo para el B. Sin esto, el RAG es una fuga de datos esperando a ocurrir.&lt;/li>
&lt;li>&lt;strong>Versión del modelo de embedding&lt;/strong> (&lt;code>model_version&lt;/code>): para saber con qué embedder se generó cada vector y poder migrar sin mezclar espacios incompatibles.&lt;/li>
&lt;/ul>
&lt;p>Estos metadatos no son decoración: habilitan &lt;strong>retrieval filtrado&lt;/strong> (buscar solo en lo que el usuario puede ver, o solo en documentos posteriores a una fecha), &lt;strong>citación&lt;/strong> (reconstruir el origen de cada afirmación) y &lt;strong>auditabilidad&lt;/strong> (saber qué se recuperó, de dónde y cuándo). Todo vive en el payload del punto en el vector store.&lt;/p>
&lt;h2 id="etapas-5-y-6--embeber-e-indexar">Etapas 5 y 6 — Embeber e indexar&lt;/h2>
&lt;p>Las dos últimas estaciones traducen el chunk a un vector y lo colocan en la estantería.&lt;/p>
&lt;p>&lt;strong>Embeber.&lt;/strong> Los chunks se envían en &lt;strong>batch&lt;/strong> a un &lt;strong>servidor de embeddings&lt;/strong> —típicamente &lt;strong>TEI&lt;/strong> (Text Embeddings Inference) de Hugging Face, que expone el contrato OpenAI &lt;code>/v1/embeddings&lt;/code> y corre en CPU o GPU—. Es trabajo throughput-bound, sin SLA de latencia: el sitio natural es la CPU con un encoder pequeño en int8 (la pieza hermana de esta tanda, &lt;a href="https://blog.lo0.es/posts/servir-embeddings-rerankers-tei-produccion/">servir embeddings y rerankers con TEI&lt;/a>, detalla el cómo). Conviene emitir &lt;strong>dense + sparse&lt;/strong> a la vez: el vector denso captura la semántica, el sparse (SPLADE/BM25-like) el solapamiento léxico exacto, y juntos hacen el retrieval híbrido más robusto.&lt;/p>
&lt;p>&lt;strong>Indexar.&lt;/strong> Los vectores, con su payload, se hacen &lt;strong>upsert&lt;/strong> en el vector store. Dos opciones de referencia, ambas vigentes en 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>pgvector&lt;/strong> (extensión de PostgreSQL). Su gran virtud es vivir &lt;strong>dentro de Postgres&lt;/strong>: transacciones ACID, joins con los metadatos relacionales, una sola base de datos que operar. La versión &lt;strong>0.8&lt;/strong> añadió &lt;code>halfvec&lt;/code> (vectores en media precisión, &lt;strong>2× menos almacenamiento&lt;/strong>) y la &lt;strong>0.9&lt;/strong> (principios de 2026) sumó soporte de &lt;strong>vectores sparse&lt;/strong> y mejoras de velocidad. Su límite conocido: &lt;strong>no trae cuantización int8 nativa&lt;/strong>, así que los embeddings de alta dimensión consumen RAM de forma lineal (&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> (motor vectorial dedicado). Soporta &lt;strong>cuantización escalar int8&lt;/strong> (float32 → int8, &lt;strong>4× menos memoria&lt;/strong>) y product quantization, vectores &lt;strong>sparse&lt;/strong> nativos y fusión &lt;strong>RRF&lt;/strong> para híbrido. Es más eficiente en memoria y en cuantización; el coste es operar &lt;strong>un sistema más&lt;/strong> además de 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>La regla práctica: &lt;strong>pgvector si ya tienes Postgres y el corpus cabe en RAM cómodamente&lt;/strong> (una sola base que operar y respaldar); &lt;strong>Qdrant si la eficiencia de memoria y la cuantización int8 son críticas&lt;/strong> por el tamaño del corpus. La sincronización entre la verdad relacional (Postgres) y el índice (Qdrant) cuando se usan los dos se detalla en &lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">PostgreSQL + Qdrant en microservicios&lt;/a>.&lt;/p>
&lt;h2 id="etapa-transversal--ingesta-incremental-e-idempotencia">Etapa transversal — Ingesta incremental e idempotencia&lt;/h2>
&lt;p>Un corpus vivo cambia: se añaden documentos, se editan, se borran. Re-indexar todo cada noche es caro y provoca ventanas de indisponibilidad. La alternativa es &lt;strong>ingesta incremental&lt;/strong> con dos pilares:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Idempotencia por doc-id + hash.&lt;/strong> Cada chunk se identifica de forma determinista (&lt;code>{doc_id}_{chunk_index}&lt;/code>) y cada documento lleva un &lt;strong>hash de contenido&lt;/strong>. Al re-procesar, si el hash no cambió, &lt;strong>no se re-embebe&lt;/strong>: se ahorra el cómputo. Si cambió, se borran los chunks viejos de ese &lt;code>doc_id&lt;/code> y se hace &lt;strong>upsert&lt;/strong> de los nuevos. El upsert con id determinista es idempotente: reprocesar un evento dos veces no genera duplicados.&lt;/li>
&lt;li>&lt;strong>CDC (Change Data Capture).&lt;/strong> En vez de hacer polling, &lt;strong>Debezium&lt;/strong> lee el WAL de PostgreSQL y propaga altas, ediciones y borrados al índice en tiempo casi real. Un borrado en Postgres dispara el borrado de los chunks de ese documento en el vector store, evitando los &amp;ldquo;documentos fantasma&amp;rdquo; que contaminan el retrieval. El deep dive está en &lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">PostgreSQL + Qdrant en microservicios&lt;/a> y en el de &lt;a href="https://blog.lo0.es/posts/debezium-cdc-fundamentos/">Debezium y CDC&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="las-matemáticas-dimensionar-el-corpus-y-el-índice">Las matemáticas: dimensionar el corpus y el índice&lt;/h2>
&lt;p>Dos cálculos que hay que saber hacer antes de aprovisionar nada.&lt;/p>
&lt;h3 id="dimensionado-del-corpus-y-tiempo-de-ingesta">Dimensionado del corpus y tiempo de ingesta&lt;/h3>
&lt;p>Supongamos un corpus corporativo de &lt;strong>$N_{docs} = 50{.}000$ documentos&lt;/strong>, de media &lt;strong>20 páginas&lt;/strong> y &lt;strong>500 tokens útiles por página&lt;/strong> tras limpiar. Los tokens totales del corpus:&lt;/p>
&lt;p>$$T_{corpus} = N_{docs} \times \text{páginas} \times \text{tokens/página} = 50{.}000 \times 20 \times 500 = 5 \times 10^{8} \text{ tokens}$$&lt;/p>
&lt;p>Quinientos millones de tokens. Aplicando el factor de overlap del ejemplo de chunking (~1.23×, el 23% extra de chunks por el 20% de overlap) y un tamaño efectivo de &lt;strong>512 tokens/chunk&lt;/strong>, el número de chunks es:&lt;/p>
&lt;p>$$N_{chunks} \approx \frac{T_{corpus}}{\text{paso}} = \frac{5 \times 10^{8}}{410} \approx 1.22 \times 10^{6} \text{ chunks}$$&lt;/p>
&lt;p>Es decir, &lt;strong>~1,22 millones de chunks&lt;/strong> que embeber. A un throughput de embedding &lt;strong>CPU conservador de 3.000 tok/s&lt;/strong> por servidor Xeon en int8 —la misma cifra que usamos en la pieza de RAG en CPU—, el tiempo de ingesta del primer corpus completo en &lt;strong>una&lt;/strong> caja es:&lt;/p>
&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{ horas}$$&lt;/p>
&lt;p>46 horas en una sola caja suena mal, pero la ingesta es &lt;strong>vergonzosamente paralela&lt;/strong>: el corpus se reparte. Con &lt;strong>8 servidores CPU&lt;/strong> baja a &lt;strong>~6 horas&lt;/strong>, holgadamente dentro de una ventana de fin de semana para la carga inicial; y las ingestas &lt;strong>incrementales&lt;/strong> posteriores (solo lo que cambió) son minutos. El parsing layout-aware añade su propio coste —Docling con VLM es más lento que PyMuPDF—, pero también es batch y paraleliza igual.&lt;/p>
&lt;h3 id="tamaño-del-índice-vectorial">Tamaño del índice vectorial&lt;/h3>
&lt;p>Cada vector tiene dimensión $d = 1024$ (la de &lt;code>bge-m3&lt;/code>). En &lt;strong>float32&lt;/strong> (4 bytes/dimensión), cada vector ocupa:&lt;/p>
&lt;p>$$\text{bytes}_{fp32} = d \times 4 = 1024 \times 4 = 4096 \text{ B} = 4 \text{ KB}$$&lt;/p>
&lt;p>Para los &lt;strong>1,22 M de chunks&lt;/strong>, solo los vectores densos en fp32:&lt;/p>
&lt;p>$$\text{tamaño}&lt;em>{fp32} = N&lt;/em>{chunks} \times d \times 4 = 1.22 \times 10^{6} \times 4096 \text{ B} \approx 5.0 \text{ GB}$$&lt;/p>
&lt;p>En &lt;strong>int8&lt;/strong> (1 byte/dimensión), cada vector ocupa 1 KB y el total cae 4×:&lt;/p>
&lt;p>$$\text{tamaño}&lt;em>{int8} = N&lt;/em>{chunks} \times d \times 1 = 1.22 \times 10^{6} \times 1024 \text{ B} \approx 1.25 \text{ GB}$$&lt;/p>
&lt;p>A esto hay que sumar el &lt;strong>índice HNSW&lt;/strong> (~1.2× el tamaño de los vectores para $m=16$) y el &lt;strong>payload&lt;/strong> (metadatos + texto del chunk, ~500 B/chunk → ~0,6 GB). En números redondos:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuración&lt;/th>
&lt;th>Vectores&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>La lectura: un corpus de 50.000 documentos cabe &lt;strong>en RAM de un solo nodo&lt;/strong> incluso en fp32, y en int8 (Qdrant) entra con holgura, lo que mantiene la latencia de búsqueda en milisegundos de un dígito. La cuantización int8 es casi siempre el punto de equilibrio —ahorra 4× con una pérdida de recall típicamente por debajo del 1%. (Estos números son de orden de magnitud, con las constantes y supuestos declarados; sirven para dimensionar, no para clavar una factura.)&lt;/p>
&lt;h2 id="aplicado-al-cluster-genérico-4h100">Aplicado al cluster genérico 4×H100&lt;/h2>
&lt;p>Bajemos esto al cluster de la serie: &lt;strong>4×H100 SXM 80 GB&lt;/strong> más una flota CPU genérica (Xeon con AMX, NUCs). El reparto correcto de la ingesta es casi todo CPU, con la GPU como excepción puntual:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Limpieza, dedup, chunking, metadatos, embedding de ingesta e indexado → flota CPU.&lt;/strong> Todo esto es trabajo batch, throughput-bound, sin SLA de latencia. Es exactamente el &amp;ldquo;plano de datos&amp;rdquo; del que habla la pieza &lt;a href="https://blog.lo0.es/posts/rag-en-cpu-plano-datos-generacion/">RAG en CPU&lt;/a>: ninguna H100 debería gastar un ciclo troceando documentos o construyendo un índice HNSW (que siempre fue CPU por diseño). El re-indexado incremental nocturno de un corpus que cambia a ritmo de horas es el caso de libro de &amp;ldquo;trabajo de CPU sin prisa&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>La GPU solo entra en dos puntos.&lt;/strong> Primero, en el &lt;strong>parsing con VLM&lt;/strong>: si el corpus tiene escaneos, tablas densas o formularios y eliges &lt;strong>Granite-Docling-258M&lt;/strong> u otro modelo visión-lenguaje, ese parsing puede acelerarse en GPU —aunque a 258M parámetros es ligero y, en volúmenes moderados, corre en CPU sin drama. Segundo, en el &lt;strong>embedder grande&lt;/strong>: si la calidad de recuperación exige un embedder de &lt;strong>7B&lt;/strong> (gte-Qwen2, NV-Embed) en lugar de &lt;code>bge-m3&lt;/code> (568M), ese embedder vuelve a ser un modelo LLM-class y vive donde viven los 7B, en la GPU.&lt;/li>
&lt;li>&lt;strong>Las 4×H100 se reservan para generar.&lt;/strong> Como en toda la serie, el silicio caro y escaso se guarda para lo latency-bound —el LLM produciendo la respuesta— y, como mucho, para los picos de parsing VLM o embedding 7B que la CPU no absorba. Para hacerse una idea del techo: un nodo 4×H100 sirviendo &lt;code>bge-m3&lt;/code> vía TEI ronda los ~2.000 chunks/s, frente a los miles de tok/s de un Xeon en int8; pero usar las H100 para la ingesta diaria es gastar el recurso por el que se pelea toda la organización en un trabajo que la flota CPU hace de noche sin que nadie la eche en falta.&lt;/li>
&lt;/ul>
&lt;p>La frase que resume el reparto: &lt;strong>la ingesta es la cadena de catalogación del archivo, y casi toda se hace con personal paciente y barato (CPU); el redactor estrella (GPU) solo se molesta cuando hay que leer una página que ningún OCR clásico descifra.&lt;/strong>&lt;/p>
&lt;h2 id="cierre-la-calidad-se-decide-arriba">Cierre: la calidad se decide arriba&lt;/h2>
&lt;p>El error recurrente del RAG mediocre no está en el reranker ni en el prompt: está en una ingesta que parseó mal una tabla, no expurgó los duplicados o troceó con un tamaño que destruye el contexto. &lt;strong>Garbage-in, garbage-out&lt;/strong>: ningún componente de abajo arregla lo que la ingesta estropeó arriba. Invertir en la cadena de catalogación —parsing layout-aware donde haga falta, dedup de verdad, chunking medido, metadatos completos— es lo que más mueve la aguja de la calidad del sistema, y casi todo cabe en la flota CPU. La GPU, como el redactor estrella, solo debería tocar el corpus cuando de verdad hace falta su cabeza.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/multimodal-vlm-on-premise-vllm/">Multimodal on-premise: servir un VLM con vLLM (visión + lenguaje)&lt;/a> — el VLM como alternativa al OCR clásico para parsear documentos con layout, tablas o manuscritos.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/servir-embeddings-rerankers-tei-produccion/">Servir embeddings y rerankers con TEI en producción&lt;/a> — la pieza hermana: el servidor de inferencia que esta ingesta alimenta en la etapa de embedding.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/rag-en-cpu-plano-datos-generacion/">Llevar el RAG a la CPU: plano de datos vs plano de generación&lt;/a> — por qué toda esta ingesta es trabajo de CPU y no debe tocar la GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">PostgreSQL + Qdrant en la ingestión RAG&lt;/a> — la sincronización, el upsert idempotente y el CDC de la ingesta incremental, en detalle.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/rag-corpus-curation-fundamentos/">El corpus curado que esta ingesta debe construir&lt;/a> — los fundamentos de curación y filtrado que preceden y guían a la ingesta.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/rag-reranker-hybrid-retrieval-fundamentos/">Reranking e hybrid retrieval: fundamentos&lt;/a> — qué hace el sistema con los chunks dense + sparse que esta ingesta indexó.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/embeddings-modelos-2026-dense-sparse-multivector/">Embeddings 2026: dense, sparse y multivector&lt;/a> — el embedder que traduce cada chunk a vector y cuándo justifica un modelo de 7B en GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/rag-eval-ragas-golden-dataset/">Evaluar el RAG con RAGAS y un golden dataset&lt;/a> — cómo medir si la ingesta realmente mejoró el retrieval, en vez de creerlo.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&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>Llevar el RAG a la CPU: separar el plano de datos del plano de generación</title><link>https://blog.lo0.es/posts/rag-en-cpu-plano-datos-generacion/</link><pubDate>Thu, 11 Jun 2026 03:20:00 +0000</pubDate><guid>https://blog.lo0.es/posts/rag-en-cpu-plano-datos-generacion/</guid><description>&lt;blockquote>
&lt;p>Tercera pieza de una serie operativa sobre exprimir un cluster LLM on-premise genérico de &lt;strong>4×H100 SXM 80 GB&lt;/strong>. Las hermanas: &lt;a href="https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/">compartir una GPU entre cargas&lt;/a> (time-slicing, MPS, MIG) y &lt;a href="https://blog.lo0.es/posts/servir-varios-modelos-una-gpu-swap-sleep/">servir varios modelos en una GPU&lt;/a> (swap + sleep) atacan el reparto &lt;em>dentro&lt;/em> de la GPU. Este ataca el reparto &lt;em>fuera&lt;/em>: qué partes del RAG no tienen por qué tocar la GPU nunca. El cierre de la serie, el asistente soberano end-to-end (cuarta entrega, en preparación), monta el sistema completo donde estas piezas encajan.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Un sistema RAG no es una cosa, son &lt;strong>tres fases con perfiles de cómputo opuestos&lt;/strong>, y meterlas todas en la GPU &amp;ldquo;porque es IA&amp;rdquo; es un error de reparto. (1) &lt;strong>Construcción/ingesta&lt;/strong> —embeber el corpus y construir el índice— es trabajo &lt;strong>batch, throughput-bound, sin SLA de latencia&lt;/strong>: su sitio natural es la CPU. (2) &lt;strong>Retrieval en tiempo de consulta&lt;/strong> —embeber la query, búsqueda HNSW, fusión RRF, rerank ligero— es &lt;strong>mayoritariamente CPU&lt;/strong>, con matices solo en el rerank pesado; la búsqueda vectorial &lt;strong>siempre&lt;/strong> fue CPU, incluso en stacks que se venden como &amp;ldquo;GPU&amp;rdquo;. (3) &lt;strong>Generación&lt;/strong> —el LLM produciendo la respuesta— es &lt;strong>latency-bound&lt;/strong> y ahí la GPU es irremplazable: un 7B en CPU da un &lt;em>time to first token&lt;/em> de &lt;strong>segundos&lt;/strong>, inaceptable para chat. La clave técnica de por qué (1) y (2) caben en CPU: el embedder no es un LLM. &lt;code>bge-m3&lt;/code> son &lt;strong>~568M parámetros&lt;/strong> (un encoder XLM-RoBERTa), no 7B+; en int8 ocupa ~580 MB y activa rutas de cómputo entero rápidas (Intel &lt;strong>AVX-512 + VNNI + AMX&lt;/strong> en Xeon de 4ª gen en adelante; &lt;strong>NEON SDOT/UDOT&lt;/strong> en ARM). Hay runtimes listos: &lt;strong>TEI&lt;/strong> con backend CPU (mismo API OpenAI &lt;code>/v1/embeddings&lt;/code> y &lt;code>/rerank&lt;/code>), &lt;strong>fastembed&lt;/strong> de Qdrant (ONNX-CPU), &lt;code>bge-m3&lt;/code> en ONNX int8 con sus tres cabezas (dense/sparse/ColBERT). El blog de Intel + Hugging Face con Optimum Intel y fastRAG reporta &lt;strong>hasta ~10× en indexación&lt;/strong> para BGE-large int8 sobre un Xeon de 4ª gen (cifra de su benchmark, encoding-only; la cito y la matizo abajo). La conclusión operativa: &lt;strong>separa el plano de datos (CPU) del plano de generación (GPU)&lt;/strong>. En el cluster 4×H100, ninguna H100 debería gastarse en re-indexar un corpus que cambia una vez al día —eso va a la flota CPU genérica (Xeon AMX, NUCs)— y las H100 se reservan para generar y, como mucho, para picos de rerank o embedders grandes de 7B. Lo que &lt;strong>no&lt;/strong> baja a CPU: generación interactiva, reranking masivo a alto QPS, re-indexación con SLA estricto en tiempo real y embedders de 7B (gte-Qwen2, NV-Embed).&lt;/p>
&lt;h2 id="la-analogía-la-biblioteca-y-el-bibliotecario">La analogía: la biblioteca y el bibliotecario&lt;/h2>
&lt;p>Imagina una biblioteca de investigación seria. Hay tres trabajos distintos, hechos por personas distintas, con relojes distintos.&lt;/p>
&lt;p>El primero es la &lt;strong>catalogación&lt;/strong>. Llegan cajas de libros nuevos; alguien los abre, los clasifica, les asigna signatura, los indexa en el catálogo y los coloca en la estantería correcta. Es trabajo paciente, de fondo, que se hace &lt;strong>de noche o entre horas&lt;/strong>. Nadie está esperando con un cronómetro a que termines de catalogar el lote de hoy: lo que importa es que mañana esté hecho y bien hecho. Es &lt;strong>throughput puro&lt;/strong>: cuántos libros catalogas por hora, no cuánto tardas en catalogar uno concreto. Esto es la &lt;strong>ingesta&lt;/strong>.&lt;/p>
&lt;p>El segundo es &lt;strong>atender una consulta en el mostrador&lt;/strong>. Un lector llega y pregunta por un tema. El bibliotecario va al catálogo —que ya está construido—, localiza media docena de signaturas relevantes, las va a buscar a la estantería y le pone los libros encima del mostrador. Es rápido, ligero, y consiste en &lt;strong>buscar en un índice que ya existe&lt;/strong>, no en construirlo. Esto es el &lt;strong>retrieval&lt;/strong>.&lt;/p>
&lt;p>El tercero es &lt;strong>redactar un informe razonado&lt;/strong> a partir de esos libros. El lector —o un experto al que se lo encargas— lee los seis libros, los compara, sintetiza, escribe una respuesta argumentada con citas. Esto es lento, exige una cabeza muy entrenada, y &lt;strong>el lector está esperando&lt;/strong>: aquí sí hay un cronómetro humano. Esto es la &lt;strong>generación&lt;/strong>, el LLM.&lt;/p>
&lt;p>La moraleja es la del reparto del personal. &lt;strong>No pones a tu redactor estrella —caro, escaso, con cola de gente esperando sus informes— a catalogar cajas de libros de madrugada.&lt;/strong> Catalogar lo hace un equipo numeroso y barato que trabaja por la noche sin prisa. El redactor estrella solo toca lo que de verdad necesita su cabeza: redactar. En nuestro sistema, el redactor estrella es la H100, y catalogar de madrugada es la ingesta del corpus. Gastar la H100 re-indexando es exactamente el error de poner al redactor a etiquetar cajas.&lt;/p>
&lt;p>El resto del post es, esencialmente, qué partes del trabajo de biblioteca puede hacer el equipo barato de la CPU (casi todas) y cuál es irrenunciablemente del redactor en GPU (solo la última).&lt;/p>
&lt;h2 id="las-tres-fases-y-sus-perfiles-de-cómputo">Las tres fases y sus perfiles de cómputo&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Las tres fases del RAG: construcción y retrieval en CPU, generación en GPU">
&lt;text x="410" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Las tres fases del RAG y dónde corre cada una&lt;/text>
&lt;rect x="20" y="50" width="250" height="190" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="145" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">1 · CONSTRUCCIÓN / INGESTA&lt;/text>
&lt;text x="145" y="94" text-anchor="middle" font-size="11" fill="currentColor">chunking del corpus&lt;/text>
&lt;text x="145" y="111" text-anchor="middle" font-size="11" fill="currentColor">embedding dense (bge-m3)&lt;/text>
&lt;text x="145" y="128" text-anchor="middle" font-size="11" fill="currentColor">cabeza sparse / SPLADE&lt;/text>
&lt;text x="145" y="145" text-anchor="middle" font-size="11" fill="currentColor">construir índice HNSW&lt;/text>
&lt;rect x="45" y="162" width="200" height="26" fill="#22c55e" stroke="none"/>
&lt;text x="145" y="180" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">[CPU]&lt;/text>
&lt;text x="145" y="208" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">throughput-bound · batch&lt;/text>
&lt;text x="145" y="224" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">sin SLA de latencia · nocturno&lt;/text>
&lt;rect x="285" y="50" width="250" height="190" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="410" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">2 · RETRIEVAL (query-time)&lt;/text>
&lt;text x="410" y="94" text-anchor="middle" font-size="11" fill="currentColor">embedding de la query&lt;/text>
&lt;text x="410" y="111" text-anchor="middle" font-size="11" fill="currentColor">búsqueda HNSW (dense)&lt;/text>
&lt;text x="410" y="128" text-anchor="middle" font-size="11" fill="currentColor">búsqueda sparse + RRF&lt;/text>
&lt;text x="410" y="145" text-anchor="middle" font-size="11" fill="currentColor">rerank top-20/50 (ligero)&lt;/text>
&lt;rect x="310" y="162" width="200" height="26" fill="#3b82f6" stroke="none"/>
&lt;text x="410" y="180" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">[CPU] (rerank: matiz)&lt;/text>
&lt;text x="410" y="208" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">latencia baja pero tolerable&lt;/text>
&lt;text x="410" y="224" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">decenas de ms · HNSW siempre CPU&lt;/text>
&lt;rect x="550" y="50" width="250" height="190" fill="none" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="675" y="74" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">3 · GENERACIÓN&lt;/text>
&lt;text x="675" y="98" text-anchor="middle" font-size="11" fill="currentColor">el LLM produce la respuesta&lt;/text>
&lt;text x="675" y="115" text-anchor="middle" font-size="11" fill="currentColor">prefill del contexto aumentado&lt;/text>
&lt;text x="675" y="132" text-anchor="middle" font-size="11" fill="currentColor">decode token a token&lt;/text>
&lt;rect x="575" y="162" width="200" height="26" fill="#ef4444" stroke="none"/>
&lt;text x="675" y="180" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">[GPU]&lt;/text>
&lt;text x="675" y="208" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">latency-bound · TTFT importa&lt;/text>
&lt;text x="675" y="224" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">7B en CPU = TTFT de segundos&lt;/text>
&lt;path d="M270,145 L285,145" stroke="currentColor" stroke-width="1.6" fill="none"/>
&lt;path d="M535,145 L550,145" stroke="currentColor" stroke-width="1.6" fill="none"/>
&lt;line x1="20" y1="270" x2="800" y2="270" stroke="currentColor" stroke-width="1"/>
&lt;text x="20" y="290" font-size="12" font-weight="700" fill="currentColor">PLANO DE DATOS (CPU)&lt;/text>
&lt;text x="520" y="290" font-size="12" font-weight="700" fill="currentColor">PLANO DE GENERACIÓN (GPU)&lt;/text>
&lt;rect x="20" y="300" width="510" height="20" fill="#22c55e" opacity="0.25" stroke="#22c55e" stroke-width="1"/>
&lt;rect x="550" y="300" width="250" height="20" fill="#ef4444" opacity="0.25" stroke="#ef4444" stroke-width="1"/>
&lt;text x="275" y="314" text-anchor="middle" font-size="11" fill="currentColor">fases 1 y 2 · flota CPU genérica · barato y horizontal&lt;/text>
&lt;text x="675" y="314" text-anchor="middle" font-size="11" fill="currentColor">fase 3 · GPU escasa&lt;/text>
&lt;text x="410" y="345" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">La frontera del sistema cae entre retrieval y generación, no entre "IA" y "no-IA"&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>La confusión de la que vive el sobre-aprovisionamiento de GPU es tratar &amp;ldquo;el RAG&amp;rdquo; como un bloque monolítico que &amp;ldquo;usa IA, luego va a la GPU&amp;rdquo;. No. El RAG es un &lt;strong>pipeline de datos con un modelo generativo enchufado al final&lt;/strong>. La frontera arquitectónica correcta no separa &amp;ldquo;lo que usa modelos&amp;rdquo; de &amp;ldquo;lo que no&amp;rdquo; —ambos lados usan modelos—, sino &lt;strong>throughput-bound de latency-bound&lt;/strong>, que es lo mismo que separar el &lt;strong>plano de datos&lt;/strong> del &lt;strong>plano de generación&lt;/strong>.&lt;/p>
&lt;h2 id="por-qué-la-ingesta-encaja-en-cpu-el-embedder-no-es-un-llm">Por qué la ingesta encaja en CPU: el embedder no es un LLM&lt;/h2>
&lt;p>El argumento entero descansa en una asimetría de tamaño que se pasa por alto. La gente oye &amp;ldquo;embeddings&amp;rdquo; y &amp;ldquo;generación&amp;rdquo; y los mete en el mismo saco de &amp;ldquo;modelos grandes que necesitan GPU&amp;rdquo;. Pero el encoder de embeddings y el LLM generativo están &lt;strong>dos órdenes de magnitud de distancia&lt;/strong> en parámetros.&lt;/p>
&lt;p>&lt;code>bge-m3&lt;/code> —el embedder multilingüe de referencia— es un &lt;strong>XLM-RoBERTa de ~568M parámetros&lt;/strong> (&lt;a href="https://huggingface.co/BAAI/bge-m3">model card&lt;/a>, &lt;a href="https://arxiv.org/abs/2402.03216">paper arXiv:2402.03216&lt;/a>). Su hermano el reranker, &lt;code>bge-reranker-v2-m3&lt;/code>, está construido sobre la misma base y ronda los mismos &lt;strong>~568M parámetros&lt;/strong> (&lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">model card&lt;/a>). Compáralo con un LLM generativo de gama de entrada: un Llama 3.1 &lt;strong>8B&lt;/strong> tiene ~14× más parámetros, y los grandes de producción andan por 70B+. Un encoder de 568M es, en presupuesto de cómputo, &lt;strong>otro animal&lt;/strong>.&lt;/p>
&lt;p>Dos diferencias estructurales hacen que ese encoder sea cómodo en CPU:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Es un encoder, no un decoder autoregresivo.&lt;/strong> Procesa la secuencia entera en &lt;strong>un único forward pass&lt;/strong> y emite el vector. No hay decode token a token, no hay KV cache que crece, no hay la fase de generación memory-bound que mata a la CPU. Es un pase denso de matrices y se acabó.&lt;/li>
&lt;li>&lt;strong>Cuantiza a int8 sin apenas pérdida.&lt;/strong> En int8, &lt;code>bge-m3&lt;/code> ocupa del orden de &lt;strong>~580 MB&lt;/strong> y, sobre todo, activa las &lt;strong>rutas de cómputo entero&lt;/strong> que la CPU moderna ejecuta deprisa: instrucciones matriciales tipo &lt;strong>Intel AMX&lt;/strong> (Advanced Matrix Extensions, Xeon de 4ª generación en adelante), &lt;strong>AVX-512 con VNNI&lt;/strong> (Vector Neural Network Instructions) en Xeon previos, y &lt;strong>NEON SDOT/UDOT&lt;/strong> en ARM. La pérdida de calidad de pasar FP32 a int8 en estos modelos suele quedar &lt;strong>por debajo del 1%&lt;/strong> de recall de recuperación, prácticamente invisible (&lt;a href="https://huggingface.co/blog/intel-fast-embedding">Intel + Hugging Face, &lt;em>CPU Optimized Embeddings&lt;/em>&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>Cuantifiquemos el tamaño del int8. Para $P = 568 \times 10^6$ parámetros a 1 byte cada uno:&lt;/p>
&lt;p>$$\text{tamaño}_{\text{int8}} \approx 568 \times 10^6 \text{ params} \times 1 \text{ byte/param} \approx 568 \text{ MB}$$&lt;/p>
&lt;p>Es decir, el modelo cabe en la caché y la RAM de cualquier servidor o NUC sin pestañear, y el cuello de botella es de &lt;strong>cómputo entero&lt;/strong>, justo lo que AMX/VNNI aceleran. No hay nada en este perfil que pida una GPU.&lt;/p>
&lt;h3 id="runtimes-que-ya-hacen-esto-sin-esfuerzo">Runtimes que ya hacen esto sin esfuerzo&lt;/h3>
&lt;p>No hay que inventar nada. El ecosistema CPU para el plano de datos está maduro:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Text Embeddings Inference (TEI)&lt;/strong> de Hugging Face: servidor en Rust con &lt;strong>backends CPU&lt;/strong> vía ONNX Runtime (recomendado) o Intel MKL, y endpoints &lt;strong>OpenAI-compatibles&lt;/strong> (&lt;code>/v1/embeddings&lt;/code>) además de &lt;code>/rerank&lt;/code> (&lt;a href="https://github.com/huggingface/text-embeddings-inference">repo TEI&lt;/a>). Es decir, el plano de datos en CPU expone exactamente el mismo contrato HTTP que un servidor GPU; el resto del sistema no se entera de qué silicio hay detrás.&lt;/li>
&lt;li>&lt;strong>fastembed&lt;/strong> de Qdrant: librería ligera que carga embedders en &lt;strong>ONNX-CPU&lt;/strong> y genera vectores dense, sparse y ColBERT (&lt;a href="https://github.com/qdrant/fastembed">repo fastembed&lt;/a>). Pensada de origen para correr sin GPU.&lt;/li>
&lt;li>&lt;strong>&lt;code>bge-m3&lt;/code> en ONNX int8&lt;/strong> con sus &lt;strong>tres cabezas&lt;/strong> (dense / sparse-lexical / ColBERT multivector) exportadas y cuantizadas, listas para ONNX Runtime CPU.&lt;/li>
&lt;/ul>
&lt;p>El dato de Intel y Hugging Face que ancla la viabilidad: en su benchmark con &lt;strong>Optimum Intel + fastRAG&lt;/strong> sobre un &lt;strong>Xeon de 4ª generación (8480+, 56 cores, 1 socket)&lt;/strong>, la variante int8 de &lt;strong>BGE-large&lt;/strong> alcanza &lt;strong>hasta ~10× de throughput de indexación&lt;/strong> frente a FP32 (&lt;a href="https://huggingface.co/blog/intel-fast-embedding">HF blog&lt;/a>, &lt;a href="https://haystack.deepset.ai/blog/cpu-optimized-models-with-fastrag">Haystack/deepset&lt;/a>). Hay que leer la letra pequeña y la leo: ese ~10× es &lt;strong>encoding-only&lt;/strong> (tokenización excluida), a secuencia 256, comparando int8 contra FP32 &lt;strong>en la misma CPU&lt;/strong> —no es &amp;ldquo;CPU 10× más rápido que GPU&amp;rdquo;, es &amp;ldquo;int8 10× más rápido que FP32 en CPU&amp;rdquo;—. Sigue siendo el dato relevante: te dice que con cuantización la CPU pasa de inviable a perfectamente útil para ingesta batch.&lt;/p>
&lt;h2 id="tabla-de-viabilidad-cpu-para-cada-componente">Tabla de viabilidad: ¿CPU para cada componente?&lt;/h2>
&lt;p>Esta es la tabla operativa. La columna que importa es la del &lt;strong>matiz&lt;/strong>, porque &amp;ldquo;sí&amp;rdquo; y &amp;ldquo;no&amp;rdquo; a secas mienten.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Componente&lt;/th>
&lt;th>¿CPU viable?&lt;/th>
&lt;th>Matiz&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Chunking&lt;/strong> (trocear el corpus)&lt;/td>
&lt;td>&lt;strong>Sí, siempre&lt;/strong>&lt;/td>
&lt;td>Es regex, parsing y ventanas; nunca tuvo nada que ver con GPU.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Embedding ingesta&lt;/strong> &lt;code>bge-m3&lt;/code> dense&lt;/td>
&lt;td>&lt;strong>Sí, su mejor caso&lt;/strong>&lt;/td>
&lt;td>Batch nocturno, int8 + AMX/VNNI. Es exactamente para lo que la CPU brilla.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cabeza sparse / SPLADE / BM25&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Sí, nativo CPU&lt;/strong>&lt;/td>
&lt;td>El léxico es inverted-index puro; la GPU no aporta nada aquí.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Construir índice HNSW&lt;/strong> (Qdrant, pgvector)&lt;/td>
&lt;td>&lt;strong>Sí, siempre CPU&lt;/strong>&lt;/td>
&lt;td>El build del grafo HNSW es CPU por diseño en estos motores.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Embedding de query&lt;/strong> (online)&lt;/td>
&lt;td>&lt;strong>Sí&lt;/strong>&lt;/td>
&lt;td>Un solo texto corto; decenas de ms en CPU, sobra para chat.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Búsqueda dense + sparse + RRF&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Sí&lt;/strong>&lt;/td>
&lt;td>La búsqueda vectorial &lt;strong>siempre&lt;/strong> fue CPU, incluso en stacks &amp;ldquo;GPU&amp;rdquo;. RRF es ordenar listas.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Reranker cross-encoder&lt;/strong> &lt;code>bge-reranker-v2-m3&lt;/code> top-20/50&lt;/td>
&lt;td>&lt;strong>Sí, con cuidado&lt;/strong>&lt;/td>
&lt;td>Un cross-encoder evalúa $k$ pares query-doc: coste $\propto k$. Sobre 20-50 candidatos va; sobre cientos a alto QPS, no.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ColBERT late-interaction&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Marginal en CPU&lt;/strong>&lt;/td>
&lt;td>El producto de matrices token-a-token de la interacción tardía es pesado; viable en volúmenes bajos, sufre con QPS.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Generación LLM&lt;/strong>&lt;/td>
&lt;td>&lt;strong>No, en la práctica&lt;/strong>&lt;/td>
&lt;td>Un 7B en CPU da TTFT de segundos. Latencia interactiva = GPU.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dos filas merecen subrayado porque desmontan mitos.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;La búsqueda vectorial necesita GPU.&amp;rdquo;&lt;/strong> Falso de origen. El índice &lt;strong>HNSW&lt;/strong> —el grafo navegable de pequeño mundo que usan Qdrant, pgvector con &lt;code>vector&lt;/code>/&lt;code>halfvec&lt;/code>, Milvus en su modo CPU y casi todo lo demás— &lt;strong>siempre se construyó y se recorrió en CPU&lt;/strong>. Incluso los stacks que se anuncian como &amp;ldquo;GPU-accelerated RAG&amp;rdquo; hacen el embedding en GPU pero la &lt;strong>búsqueda ANN sigue en CPU&lt;/strong> en la inmensa mayoría de despliegues; las variantes GPU del índice (CAGRA y similares) son la excepción cara, no la norma, y se justifican solo con miles de millones de vectores y QPS extremo. Para un corpus corporativo de millones de chunks, HNSW en CPU resuelve en &lt;strong>single-digit milisegundos&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;El reranker es un modelo, luego GPU.&amp;rdquo;&lt;/strong> El reranker &lt;code>bge-reranker-v2-m3&lt;/code> es un cross-encoder de ~568M: corre en CPU. El matiz es el &lt;strong>número de pares&lt;/strong>. Un cross-encoder no produce un vector reutilizable; evalúa la pareja (query, documento) junta, así que su coste crece &lt;strong>linealmente con los candidatos&lt;/strong> $k$:&lt;/p>
&lt;p>$$\text{coste}_{\text{rerank}} \propto k \times \text{forward}(\text{query} + \text{doc})$$&lt;/p>
&lt;p>Rerankear el &lt;strong>top-20 o top-50&lt;/strong> que sale del retrieval híbrido es perfectamente asumible en CPU. Rerankear &lt;strong>cientos&lt;/strong> de candidatos a &lt;strong>alto QPS&lt;/strong> no: ahí el coste lineal se dispara y la GPU gana. La regla práctica: &lt;strong>recall amplio barato en el retriever, rerank de precisión sobre pocos candidatos&lt;/strong>. (El detalle de hybrid retrieval y reranking está en la pieza de fundamentos enlazada abajo.)&lt;/p>
&lt;h2 id="los-números-con-metodología-honesta">Los números, con metodología honesta&lt;/h2>
&lt;p>Aquí viene la parte donde mucha gente miente por omisión. Voy a dar rangos de throughput, pero &lt;strong>son rangos de literatura y de orden de magnitud, no medidas mías en este hardware&lt;/strong>. Tómalos como tales: la decisión correcta no depende de clavar el número, depende de entender el reparto.&lt;/p>
&lt;p>Para &lt;code>bge-m3&lt;/code> dense, secuencia ≈256 tokens, el throughput de embedding se mueve aproximadamente así:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Plataforma&lt;/th>
&lt;th>Throughput dense (orden de magnitud)&lt;/th>
&lt;th>Lectura&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>GPU gama alta&lt;/strong> (5090 fp16, TEI)&lt;/td>
&lt;td>~12k tok/s+ (orientativo)&lt;/td>
&lt;td>El techo; caro y escaso.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CPU servidor grande&lt;/strong> (Xeon ~56 cores, int8 ONNX)&lt;/td>
&lt;td>banda baja de &lt;strong>miles&lt;/strong> tok/s&lt;/td>
&lt;td>~1/5–1/10 de la GPU, pero escalable horizontal y barato.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CPU edge / NUC&lt;/strong> (4-8 cores, int8)&lt;/td>
&lt;td>&lt;strong>decenas a bajos cientos&lt;/strong> tok/s&lt;/td>
&lt;td>Suficiente para ingesta nocturna de un corpus local.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La tentación es leer la segunda fila como &amp;ldquo;CPU es 5-10× más lento, descartado&amp;rdquo;. &lt;strong>Es la lectura equivocada para la ingesta.&lt;/strong> Para trabajo batch sin SLA, lo que mandan no son los tok/s absolutos sino el &lt;strong>throughput por euro&lt;/strong> y el &lt;strong>throughput por vatio&lt;/strong> —y ahí la cuenta cambia de signo.&lt;/p>
&lt;p>Pongamos un ejemplo numérico de reparto. Supón un corpus de &lt;strong>2 millones de chunks&lt;/strong> de ~256 tokens que hay que re-indexar &lt;strong>una vez al día&lt;/strong> (cambia el corpus, hay que rehacer embeddings). Eso son:&lt;/p>
&lt;p>$$2 \times 10^6 \text{ chunks} \times 256 \text{ tok/chunk} \approx 5.1 \times 10^8 \text{ tokens}$$&lt;/p>
&lt;p>A un throughput CPU conservador de, digamos, &lt;strong>3000 tok/s&lt;/strong> por servidor Xeon int8:&lt;/p>
&lt;p>$$t_{\text{ingesta}} \approx \frac{5.1 \times 10^8 \text{ tok}}{3000 \text{ tok/s}} \approx 1.7 \times 10^5 \text{ s} \approx 47 \text{ horas en un solo servidor}$$&lt;/p>
&lt;p>47 horas en &lt;strong>una&lt;/strong> caja suena mal hasta que recuerdas dos cosas. Primero, esto es &lt;strong>vergonzosamente paralelo&lt;/strong>: el corpus se trocea y se reparte; con &lt;strong>8 servidores CPU&lt;/strong> baja a ~6 horas, con 16 a ~3 horas, holgadamente dentro de la ventana nocturna. Segundo, y más importante: &lt;strong>ese mismo trabajo en la GPU bloquea la GPU&lt;/strong>. Si la H100 hace 12k tok/s, tarda ~12 horas&amp;hellip; pero son 12 horas de &lt;strong>la H100&lt;/strong>, el recurso por el que se pelea toda la organización para &lt;strong>generar&lt;/strong>. Gastar el recurso escaso y caro en re-indexar un corpus que cambia una vez al día es un &lt;strong>mal reparto&lt;/strong>, aunque sea &amp;ldquo;más rápido&amp;rdquo;: estás optimizando el tok/s equivocado.&lt;/p>
&lt;p>La regla mental: &lt;strong>para la ingesta, optimiza throughput/€ y throughput/W; los tok/s absolutos son del plano de generación, donde el cronómetro humano sí corre.&lt;/strong>&lt;/p>
&lt;h2 id="árbol-de-decisión-cpu-o-gpu-para-esta-pieza">Árbol de decisión: ¿CPU o GPU para esta pieza?&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Árbol de decisión CPU vs GPU por componente del RAG">
&lt;defs>&lt;marker id="dt" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="24" text-anchor="middle" font-size="14" font-weight="700" fill="currentColor">¿Esta pieza del RAG va a CPU o a GPU?&lt;/text>
&lt;rect x="270" y="44" width="240" height="40" rx="6" fill="#f59e0b" opacity="0.18" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="390" y="68" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">¿Está el usuario esperando (SLA interactivo)?&lt;/text>
&lt;path d="M390,84 L200,120" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="270" y="104" text-anchor="middle" font-size="11" fill="currentColor">no (batch)&lt;/text>
&lt;path d="M390,84 L580,120" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="510" y="104" text-anchor="middle" font-size="11" fill="currentColor">sí (online)&lt;/text>
&lt;rect x="60" y="124" width="280" height="38" rx="6" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="200" y="148" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Ingesta/indexado → [CPU] flota barata&lt;/text>
&lt;rect x="440" y="124" width="290" height="40" rx="6" fill="#f59e0b" opacity="0.18" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="585" y="142" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">¿Genera tokens (es el LLM)?&lt;/text>
&lt;text x="585" y="157" text-anchor="middle" font-size="11" fill="currentColor">¿o es retrieval/rerank?&lt;/text>
&lt;path d="M585,164 L460,210" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="500" y="190" text-anchor="middle" font-size="11" fill="currentColor">retrieval&lt;/text>
&lt;path d="M585,164 L690,210" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="680" y="190" text-anchor="middle" font-size="11" fill="currentColor">genera&lt;/text>
&lt;rect x="330" y="214" width="250" height="40" rx="6" fill="#f59e0b" opacity="0.18" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="455" y="232" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">embed query / HNSW / RRF / rerank&lt;/text>
&lt;text x="455" y="248" text-anchor="middle" font-size="11" fill="currentColor">¿cuántos candidatos y a qué QPS?&lt;/text>
&lt;rect x="600" y="214" width="150" height="38" rx="6" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-width="1.4"/>
&lt;text x="675" y="238" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">[GPU] generación&lt;/text>
&lt;path d="M455,254 L250,300" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="320" y="280" text-anchor="middle" font-size="11" fill="currentColor">embed/HNSW/RRF, o rerank top-20/50&lt;/text>
&lt;path d="M455,254 L600,300" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#dt)"/>
&lt;text x="600" y="280" text-anchor="middle" font-size="11" fill="currentColor">rerank cientos&lt;/text>
&lt;text x="600" y="293" text-anchor="middle" font-size="11" fill="currentColor">alto QPS / ColBERT&lt;/text>
&lt;rect x="110" y="304" width="280" height="38" rx="6" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1.4"/>
&lt;text x="250" y="328" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">[CPU] plano de datos&lt;/text>
&lt;rect x="510" y="304" width="200" height="38" rx="6" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-width="1.4"/>
&lt;text x="610" y="322" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">[GPU] rerank pesado&lt;/text>
&lt;text x="610" y="336" text-anchor="middle" font-size="10" fill="currentColor">(o embedder 7B grande)&lt;/text>
&lt;text x="390" y="372" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">Regla: solo cruza a GPU lo latency-bound (generación) y lo masivo-online (rerank a alto QPS).&lt;/text>
&lt;text x="390" y="392" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">Todo lo demás —que es casi todo— se queda en el plano de datos en CPU.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="arquitectura-de-referencia-a-cpu-only">Arquitectura de referencia (a): CPU-only&lt;/h2>
&lt;p>El primer caso es un &lt;strong>nodo sin GPU&lt;/strong>: un NUC, un Xeon de oficina, un servidor edge soberano en una sucursal o en un entorno aislado. Todo el plano de datos vive ahí; la generación se &lt;strong>delega&lt;/strong> a un endpoint GPU remoto o se hace en batch con un SLM cuando la latencia no apremia.&lt;/p>
&lt;p>El stack:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TEI-CPU&lt;/strong> sirviendo &lt;code>bge-m3&lt;/code> int8 con dense + sparse (mismo contrato OpenAI &lt;code>/v1/embeddings&lt;/code>, más &lt;code>/rerank&lt;/code> para el reranker).&lt;/li>
&lt;li>&lt;strong>Qdrant&lt;/strong> con índice &lt;strong>HNSW&lt;/strong> dense + vectores &lt;strong>sparse&lt;/strong>, fusión &lt;strong>RRF&lt;/strong> nativa.&lt;/li>
&lt;li>&lt;strong>Reranker&lt;/strong> &lt;code>bge-reranker-v2-m3&lt;/code> sobre el top-k (vía el &lt;code>/rerank&lt;/code> de TEI).&lt;/li>
&lt;li>&lt;strong>Gateway&lt;/strong> que orquesta y, para generar, llama a un endpoint externo.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># docker-compose: plano de datos RAG completo en CPU (sin GPU)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tei-embed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:cpu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;--model-id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--pooling&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cls&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--dtype&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;int8&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8081:80&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># backend ONNX/MKL: aprovecha AVX-512+VNNI / AMX si el Xeon lo soporta&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tei-rerank&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:cpu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;--model-id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-reranker-v2-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--dtype&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;int8&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8082:80&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># expone /rerank — se invoca SOLO sobre top-20/50, no sobre cientos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">qdrant&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qdrant/qdrant:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;6333:6333&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./qdrant_storage:/qdrant/storage&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># HNSW dense + sparse vectors + RRF, todo CPU&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Búsqueda híbrida con fusión RRF en Qdrant (dense + sparse en una sola query):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">models&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://qdrant:6333&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># embed de la query: dense y sparse desde el TEI-CPU (omitido el wiring HTTP)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">hits&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">query_points&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefetch&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Prefetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">dense_vec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">using&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;dense&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Prefetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sparse_vec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">using&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sparse&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">FusionQuery&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fusion&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">models&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Fusion&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">RRF&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="c1"># RRF nativo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">20&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">points&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -&amp;gt; luego: POST /rerank (TEI) sobre estos 20, te quedas con top-5&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -&amp;gt; luego: el gateway manda query + top-5 al endpoint de GENERACIÓN (GPU)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La generación, en este nodo CPU-only, &lt;strong>sale del nodo&lt;/strong>: el gateway construye el prompt aumentado y lo envía a un endpoint vLLM en el cluster GPU (o, si no hay SLA interactivo, a un SLM en CPU en modo batch, asumiendo TTFT de segundos). El plano de datos entero —lo de arriba— corre sin una sola GPU.&lt;/p>
&lt;h2 id="arquitectura-de-referencia-b-híbrida-recomendada">Arquitectura de referencia (b): híbrida recomendada&lt;/h2>
&lt;p>Esta es la que recomiendo para el caso general con cluster GPU disponible: &lt;strong>plano de datos en CPU, plano de generación en GPU&lt;/strong>, comunicados por contratos HTTP OpenAI-compatibles para que cada lado sea sustituible.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Arquitectura híbrida: plano de datos CPU + plano de generación GPU">
&lt;defs>&lt;marker id="hy" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="24" text-anchor="middle" font-size="14" font-weight="700" fill="currentColor">Arquitectura híbrida: dos planos, dos silicios&lt;/text>
&lt;rect x="30" y="50" width="470" height="210" rx="8" fill="#22c55e" opacity="0.08" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="265" y="72" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">PLANO DE DATOS — flota CPU (Xeon AMX / NUC)&lt;/text>
&lt;rect x="50" y="88" width="130" height="50" rx="5" fill="none" stroke="#3b82f6" stroke-width="1.3"/>
&lt;text x="115" y="108" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Ingesta batch&lt;/text>
&lt;text x="115" y="124" text-anchor="middle" font-size="10" fill="currentColor">chunk + embed int8&lt;/text>
&lt;rect x="200" y="88" width="130" height="50" rx="5" fill="none" stroke="#3b82f6" stroke-width="1.3"/>
&lt;text x="265" y="108" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Qdrant&lt;/text>
&lt;text x="265" y="124" text-anchor="middle" font-size="10" fill="currentColor">HNSW+sparse+RRF&lt;/text>
&lt;rect x="350" y="88" width="130" height="50" rx="5" fill="none" stroke="#3b82f6" stroke-width="1.3"/>
&lt;text x="415" y="108" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">TEI rerank&lt;/text>
&lt;text x="415" y="124" text-anchor="middle" font-size="10" fill="currentColor">top-20/50 ligero&lt;/text>
&lt;rect x="50" y="160" width="430" height="44" rx="5" fill="none" stroke="#22c55e" stroke-width="1.3"/>
&lt;text x="265" y="180" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">TEI-CPU /v1/embeddings + /rerank · int8 · AVX-512/AMX&lt;/text>
&lt;text x="265" y="196" text-anchor="middle" font-size="10" fill="currentColor">contrato OpenAI-compatible: la GPU no sabe que esto es CPU&lt;/text>
&lt;rect x="540" y="50" width="250" height="210" rx="8" fill="#ef4444" opacity="0.08" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="665" y="72" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">PLANO DE GENERACIÓN — GPU&lt;/text>
&lt;rect x="560" y="100" width="210" height="60" rx="5" fill="none" stroke="#ef4444" stroke-width="1.3"/>
&lt;text x="665" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">vLLM · LLM 7B+&lt;/text>
&lt;text x="665" y="142" text-anchor="middle" font-size="10" fill="currentColor">prefill + decode interactivo&lt;/text>
&lt;rect x="560" y="172" width="210" height="44" rx="5" fill="none" stroke="#ef4444" stroke-width="1.3"/>
&lt;text x="665" y="192" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">opcional: rerank pesado / embedder 7B&lt;/text>
&lt;text x="665" y="207" text-anchor="middle" font-size="10" fill="currentColor">solo picos que la CPU no absorbe&lt;/text>
&lt;path d="M480,182 L540,150" stroke="currentColor" stroke-width="1.8" fill="none" marker-end="url(#hy)"/>
&lt;text x="510" y="155" text-anchor="middle" font-size="10" fill="currentColor">query+top-k&lt;/text>
&lt;text x="410" y="284" text-anchor="middle" font-size="12" font-style="italic" fill="currentColor">El gateway habla OpenAI por HTTP con ambos lados; cada plano escala por separado.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Servidor de generación, mínimo, en GPU:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># vLLM en el cluster GPU — SOLO generación&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">vllm-gen&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --model meta-llama/Llama-3.1-8B-Instruct
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --dtype bfloat16 --max-model-len 8192
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --gpu-memory-utilization 0.85&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># expone /v1/chat/completions — el gateway le manda query + top-5 ya recuperados&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reservations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">driver: nvidia, count: 1, capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">gpu]}]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La virtud del diseño: como &lt;strong>ambos lados hablan el contrato OpenAI por HTTP&lt;/strong>, el plano de datos en CPU y el de generación en GPU &lt;strong>escalan por separado&lt;/strong> y son sustituibles. Si mañana quieres mover el rerank a GPU porque el QPS subió, cambias una URL. Si quieres meter más nodos CPU de ingesta, los añades sin tocar la generación. Todo el stack es &lt;strong>OSS y license-clean&lt;/strong>: &lt;code>bge-m3&lt;/code> y &lt;code>bge-reranker-v2-m3&lt;/code> son &lt;strong>MIT&lt;/strong> (&lt;a href="https://huggingface.co/BAAI/bge-m3">bge-m3&lt;/a>, &lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">reranker&lt;/a>), Qdrant es Apache-2.0, TEI y vLLM son OSS.&lt;/p>
&lt;h2 id="aplicado-al-cluster-genérico-4h100">Aplicado al cluster genérico 4×H100&lt;/h2>
&lt;p>Bajemos esto al cluster de la serie: &lt;strong>4×H100 SXM 80 GB&lt;/strong> más una flota CPU genérica (Xeon con AMX, NUCs). El reparto correcto:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Construcción e indexación → flota CPU.&lt;/strong> Ninguna H100 debería gastar un ciclo re-embebiendo el corpus. Eso va a los Xeon AMX (servidores grandes, throughput de miles de tok/s en int8) o, para corpus locales pequeños, a los NUCs por la noche. El re-indexado nocturno de un corpus que cambia una vez al día es el caso de libro de &amp;ldquo;trabajo de CPU sin prisa&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Las H100 → generación.&lt;/strong> Las cuatro tarjetas se reservan para lo que solo ellas hacen bien: producir tokens a latencia interactiva. Esto es lo que las piezas hermanas de la serie —&lt;a href="https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/">compartir GPU&lt;/a> y &lt;a href="https://blog.lo0.es/posts/servir-varios-modelos-una-gpu-swap-sleep/">varios modelos en una GPU&lt;/a>— ayudan a exprimir: una vez que la ingesta no compite por la GPU, todo el silicio caro queda libre para generar y se reparte mejor entre modelos y tenants.&lt;/li>
&lt;li>&lt;strong>Las H100, como mucho, → picos de rerank o embedders grandes.&lt;/strong> Si en algún momento necesitas un embedder de &lt;strong>7B&lt;/strong> (gte-Qwen2, NV-Embed) para un dominio donde &lt;code>bge-m3&lt;/code> no llega, o un rerank masivo a QPS que la CPU no absorbe, esos picos sí pueden visitar la GPU. Pero son la &lt;strong>excepción puntual&lt;/strong>, no la carga base.&lt;/li>
&lt;/ul>
&lt;h3 id="el-ángulo-de-auditabilidad-ens--nis2">El ángulo de auditabilidad: ENS / NIS2&lt;/h3>
&lt;p>Hay un argumento de compliance que rara vez se menciona y que el reparto CPU/GPU regala casi gratis.&lt;/p>
&lt;p>Un &lt;strong>nodo CPU-only sin driver propietario&lt;/strong> es &lt;strong>más fácil de auditar&lt;/strong>. No hay stack de kernel cerrado de NVIDIA, no hay versiones de CUDA y firmware que casar con la cadena de suministro, no hay superficie de driver propietario que documentar para un ENS o un NIS2. Todo el plano de datos —chunking, embeddings, índice, búsqueda— corre sobre software OSS en CPU genérica con instrucciones estándar. Para un entorno soberano o clasificado, poder decir &amp;ldquo;el plano que toca el corpus no depende de ningún binario propietario&amp;rdquo; es un argumento real, no marketing.&lt;/p>
&lt;p>Y hay un segundo ángulo de auditabilidad &lt;strong>intrínseco al RAG bien hecho&lt;/strong>: la &lt;strong>trazabilidad de fuentes&lt;/strong>. Un RAG que recupera chunks identificables y los cita es &lt;strong>auditable&lt;/strong> —puedes reconstruir de qué documento salió cada afirmación— frente al &lt;em>context-stuffing&lt;/em> o el conocimiento paramétrico opaco del modelo, donde no hay forma de saber de dónde viene un dato. Esa trazabilidad vive en el &lt;strong>plano de datos&lt;/strong> (qué se recuperó, de qué fuente, con qué score), justo el plano que estamos poniendo en CPU auditable. Los dos argumentos se refuerzan: el silicio auditable y la cadena de evidencia auditable son el mismo plano.&lt;/p>
&lt;h2 id="cuándo-no-llevarlo-a-cpu">Cuándo NO llevarlo a CPU&lt;/h2>
&lt;p>Por honestidad y para no caer en el espejo del hype contrario, los casos donde la CPU &lt;strong>no&lt;/strong> es la respuesta:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Generación a latencia interactiva.&lt;/strong> El caso obvio. Un 7B en CPU da TTFT de &lt;strong>segundos&lt;/strong>: inaceptable para chat. Si el usuario espera, la generación va a GPU. Sin excepciones prácticas a día de hoy.&lt;/li>
&lt;li>&lt;strong>Reranking masivo a alto QPS.&lt;/strong> Un cross-encoder o ColBERT sobre &lt;strong>cientos&lt;/strong> de candidatos, multiplicado por muchas peticiones por segundo, satura la CPU. El coste $\propto k \times \text{QPS}$ cruza el umbral donde la GPU paga. Mantén el rerank CPU acotado a top-20/50; si necesitas más amplitud a más QPS, sube a GPU.&lt;/li>
&lt;li>&lt;strong>Re-indexación en tiempo real con SLA estricto.&lt;/strong> Si el corpus cambia continuamente y la frescura es de segundos (no de horas), el throughput de la CPU puede no alcanzar la ventana. Ahí el embedding de ingesta puede necesitar GPU —pero nota que esto es &lt;strong>raro&lt;/strong>: la mayoría de corpus corporativos cambian a ritmo de horas o días, no de segundos.&lt;/li>
&lt;li>&lt;strong>Embedders grandes (7B).&lt;/strong> &lt;code>bge-m3&lt;/code> (568M) es cómodo en CPU; un &lt;strong>gte-Qwen2&lt;/strong> o &lt;strong>NV-Embed&lt;/strong> de &lt;strong>7B&lt;/strong> vuelve a ser un LLM-class y arrastra el mismo perfil de coste que la generación. Si tu calidad de recuperación exige un embedder de 7B, ese embedder vive donde viven los 7B: en la GPU.&lt;/li>
&lt;/ul>
&lt;p>La frase que resume todo: &lt;strong>la CPU es el sitio por defecto del plano de datos; la GPU es la excepción justificada para lo latency-bound y lo masivo-online.&lt;/strong> Empieza poniendo todo en CPU y sube a GPU solo lo que demuestre que no cabe —no al revés.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/ingesta-documental-rag-pdf-a-chunk-indexado/">Ingesta documental end-to-end: del PDF al chunk indexado&lt;/a> — el pipeline de ingesta que corre en ese plano de datos CPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/servir-embeddings-rerankers-tei-produccion/">Servir embeddings y rerankers con TEI en producción&lt;/a> — el motor (TEI) que sirve los embeddings y rerankers de ese plano de datos.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/">Compartir una GPU: time-slicing, MPS y MIG&lt;/a> — la pieza hermana sobre el reparto &lt;em>dentro&lt;/em> de la GPU; una vez que la ingesta sale de la GPU, esto exprime lo que queda.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/embeddings-modelos-2026-dense-sparse-multivector/">Embeddings 2026: dense, sparse y multivector&lt;/a> — las tres cabezas de &lt;code>bge-m3&lt;/code> que el plano de datos sirve en CPU, y cuándo justifica un embedder de 7B que sí pide GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/rag-reranker-hybrid-retrieval-fundamentos/">Reranking e hybrid retrieval: fundamentos&lt;/a> — el detalle de RRF y del rerank cross-encoder cuyo coste lineal decide la frontera CPU/GPU del top-k.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">Ingestión con PostgreSQL y Qdrant en microservicios&lt;/a> — cómo se estructura el pipeline de ingesta que aquí ponemos en la flota CPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/entornos-mixtos-nvidia-intel-servidores-nucs/">Entornos mixtos NVIDIA + Intel: del cluster H100 al NUC&lt;/a> — el hardware concreto de la flota CPU (Xeon AMX, NUC) que sostiene el plano de datos.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/semantic-cache-rag/">Caché semántico para RAG&lt;/a> — otra capa que vive en el plano de datos en CPU y evita tocar la GPU cuando la query ya se respondió.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/posts/rag-agresivo-modelos-pequenos/">RAG agresivo en modelos pequeños&lt;/a> — el lado de generación de esta moneda: cómo el plano de datos curado descarga al modelo de la fase generativa.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ul>
&lt;li>Chen, J., et al. &lt;em>BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings&lt;/em>. arXiv 2402.03216. &lt;a href="https://arxiv.org/abs/2402.03216">https://arxiv.org/abs/2402.03216&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>BGE-M3 model card&lt;/em> (568M params, XLM-RoBERTa, 8192 tokens, MIT). &lt;a href="https://huggingface.co/BAAI/bge-m3">https://huggingface.co/BAAI/bge-m3&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>bge-reranker-v2-m3 model card&lt;/em> (cross-encoder sobre bge-m3, ~568M). &lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">https://huggingface.co/BAAI/bge-reranker-v2-m3&lt;/a>&lt;/li>
&lt;li>Intel + Hugging Face — &lt;em>CPU Optimized Embeddings with Optimum Intel and fastRAG&lt;/em> (~10× indexación BGE-large int8, Xeon 4ª gen). &lt;a href="https://huggingface.co/blog/intel-fast-embedding">https://huggingface.co/blog/intel-fast-embedding&lt;/a>&lt;/li>
&lt;li>deepset / Haystack — &lt;em>CPU-Optimized Embedding Models with fastRAG and Haystack&lt;/em>. &lt;a href="https://haystack.deepset.ai/blog/cpu-optimized-models-with-fastrag">https://haystack.deepset.ai/blog/cpu-optimized-models-with-fastrag&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>Text Embeddings Inference (TEI)&lt;/em>, backends CPU ONNX/MKL, 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;li>Qdrant — &lt;em>fastembed&lt;/em> (ONNX-CPU, dense/sparse/ColBERT) y &lt;em>Hybrid Search con RRF&lt;/em>. &lt;a href="https://github.com/qdrant/fastembed">https://github.com/qdrant/fastembed&lt;/a> · &lt;a href="https://qdrant.tech/documentation/beginner-tutorials/hybrid-search-fastembed/">https://qdrant.tech/documentation/beginner-tutorials/hybrid-search-fastembed/&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>