<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Datos-Llm on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/datos-llm/</link><description>Recent content in Datos-Llm on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Fri, 22 May 2026 11:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/datos-llm/index.xml" rel="self" type="application/rss+xml"/><item><title>Data versioning para LLMOps: DVC, lakeFS y el reto del golden dataset reproducible</title><link>https://blog.lo0.es/posts/data-versioning-dvc-lakefs/</link><pubDate>Fri, 22 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/data-versioning-dvc-lakefs/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>La etapa &lt;strong>Data&lt;/strong> del &lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">pipeline LLMOps de seis etapas&lt;/a> tiene un eslabón silencioso del que depende todo lo demás: &lt;strong>versionar los datasets&lt;/strong> con la misma disciplina que se versiona el código. No es opcional. Un sistema LLM en producción consume al menos &lt;strong>cuatro tipos de dataset diferenciados&lt;/strong> —training/fine-tuning, corpus RAG, golden eval set, dataset enriquecido del bucle Retrain— y cada uno tiene exigencias propias. Git resuelve el código pero falla en datos por dos razones técnicas (tamaño y diff binario inútil) y una operativa (no propaga lineage hasta el bucket de pesos del modelo entrenado). Las dos herramientas OSS dominantes —&lt;strong>DVC&lt;/strong> y &lt;strong>lakeFS&lt;/strong>— se &lt;strong>unificaron en noviembre de 2025&lt;/strong> bajo una sola organización con hoja de ruta orientada a LLM training y RAG datalakes; siguen siendo proyectos complementarios (file-level vs branching de bucket completo) pero ya bajo gobierno común. El patrón productivo que el mercado ha consolidado: identificar cada artefacto con &lt;code>(dataset_id, version)&lt;/code> inmutable, propagar el par hasta el experiment tracking (MLflow / W&amp;amp;B), versionar también el &lt;strong>schema&lt;/strong> del dataset (no solo el contenido), aplicar &lt;strong>holdout estricto&lt;/strong> al golden eval set para no medir memorización, y mantener trazabilidad bidireccional &lt;code>dataset_version ↔ model_version ↔ deployment ↔ trace_id&lt;/code>. Sin esto, la promesa de &amp;ldquo;podemos auditar qué modelo respondió qué&amp;rdquo; se cae en el primer incidente serio.&lt;/p>
&lt;h2 id="estás-aquí-data-con-efecto-transversal-sobre-tune-eval-y-retrain">Estás aquí: Data (con efecto transversal sobre Tune, Eval y Retrain)&lt;/h2>
&lt;p>Este post entra al detalle del &lt;strong>eslabón de versionado&lt;/strong> dentro de la etapa &lt;strong>1 · Data&lt;/strong>. El versionado pertenece operativamente a Data, pero los artefactos que produce viajan a Tune (training set), Eval (golden set) y Retrain (dataset enriquecido). Por eso el diagrama marca Data como activa &lt;strong>y&lt;/strong> una banda transversal indicando el lineage end-to-end.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 135" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="estás aquí: Data con lineage transversal">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.cross{fill:#ffe9d6;stroke-width:1.4;stroke:#c66;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#444}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#dvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#dvm)}&lt;/style>
&lt;defs>&lt;marker id="dvm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">Estás aquí: DATA · versionado de datasets con lineage hasta el trace de producción&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box active"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;rect x="30" y="98" width="735" height="25" class="cross"/>
&lt;text x="397" y="115" text-anchor="middle" class="sm">Lineage de dataset: training set → Tune · golden set → Eval · enriched set → Retrain (que vuelve a Data)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="la-analogía-maestra-trazabilidad-de-lote-en-una-fábrica-seria">La analogía maestra: trazabilidad de lote en una fábrica seria&lt;/h2>
&lt;p>Una fábrica farmacéutica seria no produce sin &lt;strong>trazabilidad de lote&lt;/strong>. Cada caja de pastillas lleva un número de lote impreso; ese lote se asocia a fechas de fabricación, a los lotes concretos de cada materia prima que se usó, a las pruebas de calidad que pasó, y a los técnicos que firmaron cada paso. Si un paciente reporta un efecto adverso, la fábrica puede rebobinar en horas: este envase → este lote → estas materias primas → este turno → esta línea de producción → este resultado de control de calidad. Sin esa cadena, el incidente es un misterio permanente.&lt;/p>
&lt;p>Un sistema LLM serio funciona igual. El &amp;ldquo;envase&amp;rdquo; es la respuesta que un usuario vio en producción. El &amp;ldquo;lote&amp;rdquo; es la combinación de &lt;strong>modelo, adapter, prompt, contexto y configuración&lt;/strong> que la generó. Y las &amp;ldquo;materias primas&amp;rdquo; son los datasets: el training set sobre el que se entrenó el modelo base, el dataset del fine-tuning del adapter, el corpus RAG que alimenta el retrieval, el golden eval set que valida la promotion. Si un cliente dice &lt;em>&amp;quot;¿con qué datos se entrenó el modelo que el 14 de marzo respondió X a mi pregunta Y?&amp;quot;&lt;/em>, sin trazabilidad de lote la respuesta es &lt;em>&amp;ldquo;no lo sabemos&amp;rdquo;&lt;/em>. Y eso, en un cliente con compliance encima, mata el contrato.&lt;/p>
&lt;p>Git versiona la receta (el código). Data versioning versiona los ingredientes. Sin las dos cosas, no hay fábrica auditable.&lt;/p>
&lt;h2 id="los-cuatro-artefactos-que-conviene-versionar-con-exigencias-diferenciadas">Los cuatro artefactos que conviene versionar (con exigencias diferenciadas)&lt;/h2>
&lt;p>No todos los datasets se versionan igual ni con la misma frecuencia. El sistema LLM en producción típico maneja &lt;strong>cuatro artefactos&lt;/strong> que conviene gobernar por separado.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefacto&lt;/th>
&lt;th>Qué es&lt;/th>
&lt;th>Tamaño típico&lt;/th>
&lt;th>Frecuencia de versión nueva&lt;/th>
&lt;th>Quién la consume&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Training / fine-tuning dataset&lt;/strong>&lt;/td>
&lt;td>Pares input/output (o conversaciones) que entrenan el adapter o el modelo.&lt;/td>
&lt;td>10⁴ – 10⁷ ejemplos · 1 – 100 GB&lt;/td>
&lt;td>Por experimento de Tune&lt;/td>
&lt;td>Trainer (Axolotl, TRL, Unsloth)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Documentos indexados que alimentan retrieval.&lt;/td>
&lt;td>10⁵ – 10⁹ chunks · 10 GB – 10 TB&lt;/td>
&lt;td>Casi continuo (ingest streaming)&lt;/td>
&lt;td>Indexer + vector store&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Ejemplos curados con respuesta esperada para medir calidad.&lt;/td>
&lt;td>10² – 10⁴ ejemplos · MB&lt;/td>
&lt;td>Por release del producto&lt;/td>
&lt;td>Eval gates en CI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Enriched retrain dataset&lt;/strong>&lt;/td>
&lt;td>Casos donde el sistema falló + corrección humana.&lt;/td>
&lt;td>Cientos a miles por trimestre&lt;/td>
&lt;td>Por ciclo de &lt;a href="https://blog.lo0.es/posts/retrain-cerrar-el-bucle-feedback-dataset-adapter/">retrain&lt;/a>&lt;/td>
&lt;td>Siguiente Tune&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Los cuatro tienen requisitos comunes (identidad inmutable, lineage, schema) y diferencias relevantes:&lt;/p>
&lt;ul>
&lt;li>El &lt;strong>training set&lt;/strong> suele ser &lt;strong>grande, estable por experimento&lt;/strong>, y el coste de un error es un experimento perdido (caro pero acotado).&lt;/li>
&lt;li>El &lt;strong>RAG corpus&lt;/strong> es &lt;strong>enorme, en continuo cambio&lt;/strong>, y el versionado se gestiona por snapshots periódicos del índice (no del raw text). Usualmente lakeFS o branches del bucket; DVC no es la mejor encaja.&lt;/li>
&lt;li>El &lt;strong>golden eval set&lt;/strong> es &lt;strong>pequeño pero crítico&lt;/strong>: errores aquí contaminan toda la cadena de promotion. Aquí la rigidez del versionado importa más que en ningún otro.&lt;/li>
&lt;li>El &lt;strong>enriched retrain dataset&lt;/strong> es &lt;strong>incremental por naturaleza&lt;/strong>: cada ciclo de Retrain aporta un delta sobre el anterior. La versión nueva no sobrescribe; hereda y añade.&lt;/li>
&lt;/ul>
&lt;p>Confundirlos —tratar el RAG corpus como si fuera el training set, o el golden eval como si fuera un dataset más— es el origen de la mitad de los problemas operacionales en data versioning.&lt;/p>
&lt;h2 id="por-qué-git-no-basta">Por qué Git no basta&lt;/h2>
&lt;p>La pregunta evidente: si Git ya resuelve el código, ¿por qué no resuelve también los datos? Tres razones, dos técnicas y una operacional.&lt;/p>
&lt;p>&lt;strong>Razón 1: tamaño.&lt;/strong> Un repositorio Git con un dataset de 50 GB se vuelve inmanejable. &lt;code>git clone&lt;/code> baja todo el histórico; &lt;code>git status&lt;/code> recorre todos los archivos; el pack file en &lt;code>.git/objects&lt;/code> infla hasta el doble del dataset. Git LFS resuelve la primera parte (el binario sale del pack) pero introduce su propia complejidad sin abordar las otras dos razones.&lt;/p>
&lt;p>&lt;strong>Razón 2: diff binario inútil.&lt;/strong> Git asume que los diffs de texto son útiles. Cuando cambia una columna en un parquet de 8 GB, el diff es opaco —el archivo es binario, comprimido, columnar—. No puedes hacer code review sobre un cambio de dataset igual que sobre un cambio de función. Necesitas &lt;strong>diff semántico&lt;/strong>: cuántas filas cambiaron, qué columnas cambiaron, qué distribución se movió. Ningún Git nativo te da eso.&lt;/p>
&lt;p>&lt;strong>Razón 3: lineage que cruza fronteras de repositorio.&lt;/strong> Esta es la más importante y la más sutil. El dataset de training vive en un bucket. El código del trainer vive en un repo Git. El modelo entrenado se publica a un model registry. La inferencia en producción genera traces en un sistema de observability. Conectar &lt;code>dataset_v3 → adapter_v7 → deployment_d2 → trace t_x9&lt;/code> requiere propagar identificadores &lt;strong>a través de cuatro sistemas distintos&lt;/strong>, no dentro de un repo. Git no tiene opinión sobre esto.&lt;/p>
&lt;p>Las herramientas de data versioning (DVC, lakeFS, Pachyderm, Quilt) existen porque resuelven los tres problemas a la vez: cuelgan los datos fuera del repo Git, ofrecen alguna forma de diff semántico, y exponen identidades estables propagables hacia experiment tracking y model registry.&lt;/p>
&lt;h2 id="dvc-vs-lakefs-antes-de-la-unificación">DVC vs lakeFS antes de la unificación&lt;/h2>
&lt;p>Hasta noviembre de 2025, las dos herramientas dominantes OSS coexistían como aproximaciones complementarias.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Eje&lt;/th>
&lt;th>DVC&lt;/th>
&lt;th>lakeFS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Modelo mental&lt;/td>
&lt;td>&amp;ldquo;Git para datos&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;Branching para el data lake&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Granularidad&lt;/td>
&lt;td>Archivo individual&lt;/td>
&lt;td>Bucket entero (con namespacing por branch)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Storage&lt;/td>
&lt;td>Remote-agnóstico (S3, GCS, Azure, MinIO, SSH)&lt;/td>
&lt;td>S3-compatible (S3, MinIO, Ceph)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workflow&lt;/td>
&lt;td>&lt;code>dvc add&lt;/code> + &lt;code>dvc push&lt;/code> + &lt;code>dvc.yaml&lt;/code> pipelines&lt;/td>
&lt;td>&lt;code>lakectl commit&lt;/code> + branches/merges sobre el bucket&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Diff&lt;/td>
&lt;td>Hash del archivo + metadata externa&lt;/td>
&lt;td>Diff a nivel de objeto + commit log&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Casos fuertes&lt;/td>
&lt;td>Training datasets discretos, model files, pipelines reproducibles&lt;/td>
&lt;td>RAG corpora grandes, branching de un data lake compartido, experimentos en paralelo sin duplicar datos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Integración con Git&lt;/td>
&lt;td>Profunda (los &lt;code>.dvc&lt;/code> files se commitean a Git)&lt;/td>
&lt;td>Tangencial (lakeFS vive en paralelo)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quién lo opera&lt;/td>
&lt;td>Equipo MLE&lt;/td>
&lt;td>Equipo data engineering&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>En la práctica, muchos equipos los usaban &lt;strong>a la vez&lt;/strong>: DVC para los datasets discretos que alimentaban un experimento (cabe en un repo Git por la indirección de los &lt;code>.dvc&lt;/code> pointers), y lakeFS para el bucket grande del corpus RAG sobre el que querían branching sin duplicar terabytes.&lt;/p>
&lt;h2 id="qué-cambió-con-la-adquisición-de-noviembre-2025">Qué cambió con la adquisición de noviembre 2025&lt;/h2>
&lt;p>&lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS adquirió DVC&lt;/a> en noviembre de 2025. La consecuencia operacional a mayo de 2026 es modesta pero relevante:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No hay (todavía) fusión técnica de los proyectos.&lt;/strong> DVC sigue siendo DVC y lakeFS sigue siendo lakeFS. Las CLIs, los formatos y los workflows actuales no han cambiado.&lt;/li>
&lt;li>&lt;strong>Hoja de ruta combinada explícita hacia LLM training y RAG datalakes.&lt;/strong> La organización fusionada ha enunciado prioridades específicas: branching consistente entre el dataset y el modelo entrenado, integraciones nativas con MLflow / W&amp;amp;B / Langfuse, soporte para los formatos típicos de LLM (jsonl, parquet con tokenización embebida), e indexación vectorial branch-aware.&lt;/li>
&lt;li>&lt;strong>Convergencia esperada en 2026-2027.&lt;/strong> El mercado anticipa un único registry con dos modos operativos (file-level + bucket-branching) bajo CLI unificada. A día de hoy, los equipos siguen combinando ambos.&lt;/li>
&lt;/ul>
&lt;p>La lectura práctica para 2026: &lt;strong>adopta DVC para training/eval datasets discretos y lakeFS para el RAG corpus&lt;/strong>, pero diseña el lineage para que un futuro registry unificado pueda absorber ambos sin re-versionar todo. En concreto: usa identificadores estables (&lt;code>dataset_id&lt;/code>, &lt;code>version&lt;/code>, &lt;code>commit_hash&lt;/code>) que sean propagables independientemente de la herramienta.&lt;/p>
&lt;h2 id="el-patrón-operativo-lineage-de-cuatro-saltos">El patrón operativo: lineage de cuatro saltos&lt;/h2>
&lt;p>Una vez aceptado que hay que versionar datasets, la pregunta no es &amp;ldquo;qué herramienta&amp;rdquo; sino &amp;ldquo;qué cadena de identificadores conecta producción con el dato origen&amp;rdquo;. El patrón que ha consolidado el mercado tiene &lt;strong>cuatro saltos&lt;/strong>:&lt;/p>
&lt;pre tabindex="0">&lt;code>(dataset_id, dataset_version)
│ versiona en DVC o lakeFS
▼
(model_id, model_version)
│ registra en MLflow / W&amp;amp;B con dataset como input
▼
(deployment_id, prompt_version)
│ registra en model registry + prompt registry
▼
(trace_id)
│ emite el motor de inferencia con OTel
▼
respuesta visible al usuario
&lt;/code>&lt;/pre>&lt;p>Cada flecha es un escritura de metadata que cruza el límite entre dos sistemas. Si una sola flecha falta, el lineage se rompe y la promesa de auditabilidad se evapora.&lt;/p>
&lt;p>Ejemplo concreto del flujo, usando DVC + MLflow:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Etapa Data: versionar el dataset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dvc add data/finetune_v3.jsonl
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git add data/finetune_v3.jsonl.dvc data/.gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;data: finetune dataset v3&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dvc push &lt;span class="c1"># sube el binario al remote (MinIO/S3)&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"># Etapa Tune: entrenar registrando lineage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mlflow run train.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_id&lt;/span>&lt;span class="o">=&lt;/span>finetune &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_version&lt;/span>&lt;span class="o">=&lt;/span>v3 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_hash&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>dvc get-url data/finetune_v3.jsonl &lt;span class="p">|&lt;/span> sha256sum&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># El run registra: input dataset + model output&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"># Etapa Eval: validar registrando lineage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mlflow run eval.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">model_id&lt;/span>&lt;span class="o">=&lt;/span>adapter_customer_v7 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">golden_set_id&lt;/span>&lt;span class="o">=&lt;/span>customer_support &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">golden_set_version&lt;/span>&lt;span class="o">=&lt;/span>v12
&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"># Etapa Deploy: el deployment hereda dataset + golden ids&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Cada trace en Observe lleva model_version + prompt_version&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># que rebobinan hasta dataset_version&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Versión equivalente con lakeFS sobre el RAG corpus:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Branch para los embeddings del nuevo corpus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl branch create lakefs://corpus/embed-2026q2 --source main
&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"># Indexar el corpus en ese branch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python index_corpus.py --branch embed-2026q2
&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"># Validar antes de mergear a main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python eval_retrieval.py --branch embed-2026q2 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --metric recall@10 --threshold 0.78
&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"># Si pasa, mergear (cambia el corpus que sirve producción)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl commit lakefs://corpus/embed-2026q2 -m &lt;span class="s2">&amp;#34;embed: corpus 2026q2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl merge lakefs://corpus/embed-2026q2 lakefs://corpus/main
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>La virtud del segundo flujo: durante la validación del nuevo corpus, &lt;strong>el sistema de producción sigue sirviendo desde &lt;code>main&lt;/code> sin interferencia&lt;/strong>. La rama paralela funciona como un staging real sobre el bucket completo.&lt;/p>
&lt;h2 id="schema-contracts-data-versioning-sin-esto-es-ilusión">Schema contracts: data versioning sin esto es ilusión&lt;/h2>
&lt;p>Versionar el contenido de un dataset sin versionar su &lt;strong>schema&lt;/strong> es un error frecuente. El problema: un dataset versionado pero con schema implícito sigue rompiendo silenciosamente cuando un productor (el equipo de ingestión, el equipo de annotation, un script ad-hoc) cambia un campo.&lt;/p>
&lt;p>Caso concreto: golden eval set de soporte al cliente, 1000 ejemplos, campo &lt;code>expected_output&lt;/code> originalmente &lt;code>string&lt;/code>. Alguien decide que necesita capturar varias respuestas válidas y cambia el campo a &lt;code>list[string]&lt;/code>. El loader del eval acepta ambos formatos por casualidad (Python es laxa) pero el judge LLM downstream recibe un objeto diferente. El eval sigue pasando pero ahora &lt;strong>mide otra cosa&lt;/strong>.&lt;/p>
&lt;p>Patrón productivo: el dataset se versiona con DVC/lakeFS &lt;strong>y&lt;/strong> su schema se versiona con &lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">Schema Registry&lt;/a> (Confluent o Apicurio) o, en sistemas menos maduros, con un JSON Schema embebido junto al dataset. CI bloquea cualquier PR que rompa el contract sin bump de versión.&lt;/p>
&lt;p>Schema mínimo de un golden eval entry (ilustrativo):&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="nt">$schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://json-schema.org/draft/2020-12/schema&lt;/span>&lt;span class="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">$id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://example.org/schemas/golden_eval_entry/v3.json&lt;/span>&lt;span class="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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="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">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">example_id, input, expected_outputs, rubric, segment]&lt;/span>&lt;span class="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">properties&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">example_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: string, format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">uuid}&lt;/span>&lt;span class="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">input&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="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">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">user_query, retrieved_context]&lt;/span>&lt;span class="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">properties&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">user_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="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">retrieved_context&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="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">expected_outputs&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">array&lt;/span>&lt;span class="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">minItems&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="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">rubric&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="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">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">must_include, must_not_include, format]&lt;/span>&lt;span class="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">properties&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">must_include&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="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">must_not_include&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="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">format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">enum&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">text, json, markdown]}&lt;/span>&lt;span class="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">segment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="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">difficulty&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">enum&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">easy, medium, hard]}&lt;/span>&lt;span class="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">added_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: string, format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">date-time}&lt;/span>&lt;span class="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">curated_by&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Reglas operativas:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compatibility forward/backward&lt;/strong> explícita: añadir un campo opcional es backward-compatible; quitar uno requerido es breaking. La política se enforza con un compatibility check en CI.&lt;/li>
&lt;li>&lt;strong>Versión del schema embebida&lt;/strong> en cada fila del dataset (un campo &lt;code>_schema_version&lt;/code>). El loader valida que la versión coincide con lo que espera el código que lo consume.&lt;/li>
&lt;li>&lt;strong>Schema registry como única fuente de verdad&lt;/strong>, no como copia opcional del JSON Schema en cuatro repos.&lt;/li>
&lt;/ul>
&lt;p>Sin este nivel de disciplina, &amp;ldquo;tenemos data versioning&amp;rdquo; significa &amp;ldquo;guardamos los bytes pero no controlamos qué significan&amp;rdquo;.&lt;/p>
&lt;h2 id="golden-eval-set-la-versión-más-crítica">Golden eval set: la versión más crítica&lt;/h2>
&lt;p>De los cuatro artefactos, el &lt;strong>golden eval set&lt;/strong> es el que más rigor exige. Un fallo aquí contamina toda la cadena de promotion: si el eval miente, los gates aprueban modelos que no deberían.&lt;/p>
&lt;p>Tres disciplinas extra sobre el golden set:&lt;/p>
&lt;p>&lt;strong>Anotación con calidad medida.&lt;/strong> Cada ejemplo lo etiqueta un humano, y un porcentaje (10-20 %) se anota por dos personas independientes. El &lt;strong>acuerdo inter-anotador&lt;/strong> (Cohen&amp;rsquo;s kappa o F1 pairwise) se mide y se publica; un golden set con kappa &amp;lt; 0.7 está midiendo ruido humano, no comportamiento del modelo. Argilla y Label Studio dan la mecánica; lo importante es la disciplina, no la herramienta.&lt;/p>
&lt;p>&lt;strong>Holdout estricto contra contaminación.&lt;/strong> El golden set &lt;strong>nunca&lt;/strong> debe entrar al training set. Mecanismo concreto: hash de cada &lt;code>input&lt;/code> del golden set (sha256 normalizado por lowercasing + stripping de puntuación trivial) → check en CI contra todos los hashes del training set. Si hay intersección, el CI bloquea hasta resolución. Sin este check, el modelo aprueba el eval por memorización, no por capacidad. La consecuencia es desastrosa en producción: el modelo &amp;ldquo;validado&amp;rdquo; falla en casos análogos al golden set que no estaban memorizados.&lt;/p>
&lt;p>&lt;strong>Versionado aditivo, nunca destructivo.&lt;/strong> Cuando el golden set crece (cada ciclo de retrain añade casos), &lt;code>golden_v3 = golden_v2 ∪ new_examples&lt;/code>. Nunca &lt;code>golden_v3 = nuevo set distinto&lt;/code>. Sólo así puedes comparar dos modelos entrenados a meses de distancia sobre la &lt;strong>misma base&lt;/strong> + el delta nuevo. Si reescribes el golden set, no puedes decir si el modelo de marzo era peor que el de mayo o si simplemente medías cosas distintas.&lt;/p>
&lt;p>Tabla resumen de la disciplina por artefacto:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Práctica&lt;/th>
&lt;th>Training set&lt;/th>
&lt;th>RAG corpus&lt;/th>
&lt;th>Golden eval set&lt;/th>
&lt;th>Enriched retrain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Versionado inmutable&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Sí (snapshots)&lt;/td>
&lt;td>&lt;strong>Sí, crítico&lt;/strong>&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schema con contract&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Recomendado&lt;/td>
&lt;td>&lt;strong>Sí, crítico&lt;/strong>&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Doble anotación&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No aplica&lt;/td>
&lt;td>&lt;strong>Sí (10-20 %)&lt;/strong>&lt;/td>
&lt;td>Sí (10-20 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Holdout vs otros datasets&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>&lt;strong>Sí, hash check&lt;/strong>&lt;/td>
&lt;td>Sí (vs golden)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift check vs versión anterior&lt;/td>
&lt;td>Recomendado&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Recomendado&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lineage hasta deployment&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Sí&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="promotion-gates-el-dataset-es-promovido-como-el-modelo">Promotion gates: el dataset es promovido como el modelo&lt;/h2>
&lt;p>Un dataset candidato (un &lt;code>golden_v13&lt;/code> recién enriquecido, un &lt;code>enriched_retrain_2026_q2&lt;/code> resultado del ciclo de Retrain) no entra a producción por estar en el bucket. Pasa por &lt;strong>gates&lt;/strong> equivalentes a los del modelo o del prompt:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Schema validation&lt;/strong> — el contract se cumple. Bloqueo en CI si no.&lt;/li>
&lt;li>&lt;strong>Quality validation&lt;/strong> — muestra aleatoria del 5-10 % revisada por humano con quality score ≥ 4/5. Bloqueo si la muestra falla.&lt;/li>
&lt;li>&lt;strong>Holdout segregation check&lt;/strong> — para golden sets y enriched datasets, hash check contra todos los demás datasets activos. Bloqueo si hay solapamiento.&lt;/li>
&lt;li>&lt;strong>Drift check vs versión anterior&lt;/strong> — KS test sobre distribución de embeddings de los inputs, o métricas más simples (longitud media, distribución de segmentos, ratio de cada label). Aviso si el drift es alto sin causa documentada; bloqueo si es muy alto.&lt;/li>
&lt;li>&lt;strong>Lineage check&lt;/strong> — el dataset declara explícitamente de qué versión hereda y qué cambió. Sin esa metadata, no entra.&lt;/li>
&lt;/ol>
&lt;p>Sólo cuando los cinco gates pasan, el dataset se etiqueta como &lt;code>production-ready&lt;/code> y se desbloquean los pipelines downstream que dependen de él (el siguiente Tune, el siguiente release del producto, el siguiente ciclo de eval).&lt;/p>
&lt;h2 id="el-stack-on-premise-aplicado">El stack on-premise aplicado&lt;/h2>
&lt;p>En una infraestructura genérica con &lt;strong>RTX 4090&lt;/strong> (24 GB VRAM, perfil de desarrollo / batch chico) y un &lt;strong>cluster 4×H100 SXM&lt;/strong> (80 GB VRAM cada una, NVLink, entrenamientos y inferencia productiva), el data versioning encaja sin GPU dedicado para el versionado en sí —el versionado vive en CPU + storage— pero sí toca la GPU para los drift checks que requieren embeddings.&lt;/p>
&lt;p>Topología típica:&lt;/p>
&lt;pre tabindex="0">&lt;code>┌────────────────────────────────────────────────────────────┐
│ Object store (MinIO o Ceph) │
│ buckets: /training-sets /corpus-rag │
│ /golden-evals /enriched-retrain │
└────────────────────────┬───────────────────────────────────┘
│
┌─────────────────┼──────────────────┐
│ │ │
┌───▼────┐ ┌────▼────┐ ┌────▼─────┐
│ DVC │ │ lakeFS │ │ MLflow │
│ remote │ │ branches│ │ Tracking │
└───┬────┘ └────┬────┘ └────┬─────┘
│ │ │
└─────────────────┴──────────────────┘
│
┌──────▼──────┐
│ CI/CD gates │
│ (Forgejo / │
│ GitLab) │
└──────┬──────┘
│
┌──────────┴───────────┐
│ │
┌─────▼──────┐ ┌─────▼─────┐
│ RTX 4090 │ │ 4×H100 │
│ (drift │ │ (training │
│ embeds, │ │ + │
│ validates)│ │ serving) │
└────────────┘ └───────────┘
&lt;/code>&lt;/pre>&lt;p>Notas operativas:&lt;/p>
&lt;ul>
&lt;li>El &lt;strong>object store&lt;/strong> (MinIO o Ceph) sirve a la vez como DVC remote y como storage de lakeFS. Un solo plano de almacenamiento, dos vistas.&lt;/li>
&lt;li>Los &lt;strong>schema checks&lt;/strong> y &lt;strong>hash de holdout&lt;/strong> son tareas CPU-bound rápidas; el CI runner las ejecuta sin GPU.&lt;/li>
&lt;li>El &lt;strong>drift check por embeddings&lt;/strong> requiere encoder; la RTX 4090 sirve para esto sin tocar el cluster productivo. Un encoder pequeño (BGE-small, E5-small, ~100M parámetros) procesa 10⁴ ejemplos en pocos minutos.&lt;/li>
&lt;li>El &lt;strong>cluster H100&lt;/strong> queda libre para training y serving, sin contaminación por jobs de versionado.&lt;/li>
&lt;/ul>
&lt;h3 id="cuándo-no-hace-falta-dvclakefs">¿Cuándo NO hace falta DVC/lakeFS?&lt;/h3>
&lt;p>Hay una posición opuesta defendida con datos en el &lt;a href="https://blog.lo0.es/posts/fine-tuning-continuo-produccion/">post de fine-tuning continuo&lt;/a>: para sistemas pequeños con un único equipo, datasets &amp;lt; 1 GB y un puñado de adapters, &lt;strong>Postgres + pgvector + un bucket S3 + filenames con hash&lt;/strong> son suficientes. La complejidad operativa de DVC/lakeFS no se amortiza.&lt;/p>
&lt;p>La línea divisoria es razonable:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No hace falta DVC/lakeFS&lt;/strong>: un solo equipo, datasets pequeños, pocos adapters, sin múltiples productos compartiendo datos.&lt;/li>
&lt;li>&lt;strong>Sí hace falta&lt;/strong>: múltiples equipos, datasets &amp;gt; 10 GB, varios productos que comparten golden eval set, compliance externo que exige trazabilidad de lote, o un ciclo de retrain trimestral institucionalizado.&lt;/li>
&lt;/ul>
&lt;p>Adoptar DVC + lakeFS antes de necesitarlos es overhead. Adoptarlos seis meses tarde es perder seis meses de lineage de manera irrecuperable.&lt;/p>
&lt;h2 id="siete-pitfalls-que-convierten-data-versioning-en-teatro">Siete pitfalls que convierten data versioning en teatro&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Versionar los datos pero no los schemas.&lt;/strong> El contenido se versiona, el contrato cambia silenciosamente, el sistema rompe sin que el versionado lo capture. Schema Registry no es opcional; es la mitad del problema.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Mismo S3 path sobrescrito.&lt;/strong> &amp;ldquo;Sube &lt;code>training.jsonl&lt;/code> al bucket&amp;rdquo; y el siguiente experimento reescribe el archivo. El versionado de S3 (si está habilitado) salva la lana, pero sin un identificador inmutable propagado a MLflow no se puede rebobinar. Patrón correcto: &lt;code>training_v3.jsonl&lt;/code> o &lt;code>training/2026q2/&amp;lt;sha&amp;gt;.jsonl&lt;/code>, nunca el mismo nombre.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Golden eval set sin holdout estricto.&lt;/strong> Sin hash check contra training, el modelo memoriza el eval y aprueba sin haber aprendido. Es el equivalente LLM de un examen que el profesor anuncia: aprueba todo el mundo, no se ha medido nada.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>No registrar lineage dataset → modelo.&lt;/strong> Cuando un incidente requiere saber con qué datos se entrenó cierto modelo, la respuesta correcta es un query a MLflow / W&amp;amp;B. Si la respuesta es &amp;ldquo;preguntemos a quien lo entrenó&amp;rdquo; (suponiendo que siga en el equipo), el lineage no existe.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>DVC añadido seis meses tarde.&lt;/strong> Adoptar versionado en mes 1 = molestia. Adoptarlo en mes 6 = pérdida irrecuperable de seis meses de datasets que ya no se pueden reconstruir. La maldición del &amp;ldquo;lo metemos después&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>lakeFS con branches que nunca se mergean.&lt;/strong> Branches paralelos sobre el corpus son útiles para experimentar; mantenidos indefinidamente sin merge, el operativo se vuelve un cementerio de branches medio actualizados. Política explícita: merge o destruir en N semanas.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Validación de schema solo en producción.&lt;/strong> El contract se valida cuando el dataset ya está en producción y el modelo entrenado. Para entonces, el incidente ya pasó. La validación tiene que ser &lt;strong>en CI&lt;/strong>, antes del merge, sobre el delta que el PR introduce.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="el-ciclo-de-un-dataset-en-una-pantalla">El ciclo de un dataset en una pantalla&lt;/h2>
&lt;pre tabindex="0">&lt;code>┌─────────────────────────────────────────────────────────────┐
│ Productor (ingest / annotation / retrain bucle) │
└────────────────┬────────────────────────────────────────────┘
│
▼ (commit a candidate version)
┌─────────────────────────┐
│ CI gates │
│ - Schema validation │
│ - Quality sampled │
│ - Holdout hash check │ ── falla → PR bloqueado
│ - Drift vs anterior │
│ - Lineage declarado │
└────────────┬────────────┘
│ pasa
▼
┌─────────────────────────┐
│ DVC tag o lakeFS commit│
│ + MLflow registry │ ← versión inmutable
│ + Schema Registry │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Pipeline downstream │
│ Tune / Eval / Deploy │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Trace de producción │
│ → rebobina hasta dataset│
└─────────────────────────┘
&lt;/code>&lt;/pre>&lt;h2 id="lo-que-no-hemos-cubierto">Lo que no hemos cubierto&lt;/h2>
&lt;p>A primer nivel queda fuera de este post:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Vector store versioning&lt;/strong> propiamente dicho: un índice de embeddings no se versiona como un dataset crudo porque depende del modelo de embedding. Cambiar el embedder reescribe todo el índice. Es otro animal y merece tratamiento aparte (recall, ANN parameters, branching del índice vs reembedding completo).&lt;/li>
&lt;li>&lt;strong>Tooling de lineage estandarizado&lt;/strong> (OpenLineage, Marquez): cómo emitir y consumir lineage events de manera interoperable entre sistemas.&lt;/li>
&lt;li>&lt;strong>Data quality frameworks&lt;/strong> (Great Expectations, Soda, Deequ): cómo escribir suites de &amp;ldquo;expectations&amp;rdquo; sobre un dataset y enforzarlas en cada versión.&lt;/li>
&lt;li>&lt;strong>Privacy-preserving versioning&lt;/strong>: federated learning sin centralizar el dataset, differential privacy aplicada a la versión que se distribuye.&lt;/li>
&lt;li>&lt;strong>Contaminación entre golden sets de proveedores externos&lt;/strong> (HumanEval, MMLU, etc.) y datasets de training de modelos open: el problema de &amp;ldquo;el modelo aprueba HumanEval porque HumanEval está en su pretraining&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>Cada uno da para un post propio cuando el campo lo justifique.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">Pipeline LLMOps de seis etapas&lt;/a> — el mapa maestro donde encaja esta pieza, sección Data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/retrain-cerrar-el-bucle-feedback-dataset-adapter/">Retrain: cerrar el bucle&lt;/a> — cómo el enriched dataset producido por Retrain vuelve a Data; este post detalla cómo versionarlo bien.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prompt-versioning-langfuse-mlflow/">Prompt versioning con Langfuse y MLflow&lt;/a> — la otra pieza transversal del lineage; el &lt;code>prompt_version&lt;/code> viaja junto al &lt;code>dataset_version&lt;/code> en cada trace.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/fine-tuning-continuo-produccion/">Fine-tuning continuo en producción&lt;/a> — defiende un stack minimalista (Postgres + pgvector + S3) sin DVC/lakeFS para sistemas pequeños; este post explica cuándo se cruza la línea hacia el otro lado.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">Evals para LLMs: la capa después del tracing&lt;/a> — el consumidor principal del golden eval set.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/mlops-llms-panorama-2026/">MLOps específico para LLMs en 2026&lt;/a> — contexto de mercado del stack LLMOps completo.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">PostgreSQL + Qdrant para ingestión&lt;/a> — cómo se materializa la ingestión que precede al versionado.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://dvc.org/doc">DVC documentation&lt;/a> — workflows de versionado, pipelines y remotes.&lt;/li>
&lt;li>&lt;a href="https://docs.lakefs.io/">lakeFS documentation&lt;/a> — branching, merging y commits sobre el bucket.&lt;/li>
&lt;li>&lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS adquiere DVC, noviembre 2025&lt;/a> — anuncio y hoja de ruta combinada.&lt;/li>
&lt;li>&lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> y &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> — schema contracts para datos en streaming.&lt;/li>
&lt;li>&lt;a href="https://openlineage.io/">OpenLineage&lt;/a> y &lt;a href="https://marquezproject.ai/">Marquez&lt;/a> — estándar abierto de eventos de lineage.&lt;/li>
&lt;li>&lt;a href="https://greatexpectations.io/">Great Expectations&lt;/a> — data quality expectations en CI.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/latest/tracking.html">MLflow Tracking&lt;/a> — input datasets como artefactos de primera clase desde MLflow 2.4.&lt;/li>
&lt;li>&lt;a href="https://www.pachyderm.com/">Pachyderm&lt;/a> y &lt;a href="https://quiltdata.com/">Quilt&lt;/a> — alternativas históricas a DVC/lakeFS.&lt;/li>
&lt;li>Sobre contaminación de eval sets: &lt;em>&amp;ldquo;Stop Uploading Test Data in Plain Text&amp;rdquo;&lt;/em> (Magar &amp;amp; Schwartz, 2022) y trabajo posterior sobre detección de contaminación en pretraining corpora.&lt;/li>
&lt;/ul></description></item><item><title>PostgreSQL + Qdrant en la etapa de ingestión: patrones de sincronización, microservicios y cómo encaja todo sin romperse</title><link>https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/</link><pubDate>Thu, 21 May 2026 06:50:00 +0200</pubDate><guid>https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>PostgreSQL es la fuente de verdad transaccional de la mayoría de las empresas; Qdrant es el motor de búsqueda vectorial que más equipos eligen cuando pgvector se queda corto. Combinarlos no es trivial: tu modelo de dominio vive en Postgres con ACID, las relaciones, las constraints, los triggers; los embeddings viven en Qdrant con HNSW filterable, quantization escalar, multivectors, sparse-dense hybrid search. &lt;strong>Mantener los dos sincronizados es el problema operacional número uno&lt;/strong> que el campo LLMOps ha codificado en 2026 con tres patrones canónicos: &lt;strong>dual-write&lt;/strong> (simple, frágil, válido para prototipos), &lt;strong>transactional outbox + CDC con Debezium&lt;/strong> (la opción &amp;ldquo;correcta&amp;rdquo; para producción seria) y &lt;strong>event-driven directo a Kafka&lt;/strong> (cuando el evento es el ciudadano de primera y la DB es proyección). La elección de Qdrant sobre pgvector se justifica con números concretos —&lt;strong>filtered search 6ms vs 29ms&lt;/strong> en 500K vectores, &lt;strong>65% menos memoria&lt;/strong> con scalar quantization, &lt;strong>HNSW filterable&lt;/strong> que no se hunde con metadata, escalabilidad horizontal—. El precio es operacional: un servicio stateful adicional que mantener, snapshots que gestionar, gRPC que asegurar. Este post entra en detalle en cómo se sitúa PostgreSQL + Qdrant en la &lt;strong>etapa Data&lt;/strong> del pipeline LLMOps que dibujamos en el post anterior, qué microservicios participan, cómo se sincronizan, cómo se observan y dónde están las trampas que se ven una y otra vez en producción.&lt;/p>
&lt;blockquote>
&lt;p>Este es el &lt;strong>cuarto post de la serie MLOps para LLMs&lt;/strong> y el primero que aplica el patrón &amp;ldquo;estás aquí&amp;rdquo; sobre el mini-mapa que definimos en el &lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">post anterior sobre el pipeline de seis etapas&lt;/a>. Aquí estamos plenamente en la primera etapa: &lt;strong>Data&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="estás-aquí-etapa-data-del-pipeline">Estás aquí: etapa Data del pipeline&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="estás aquí: Data">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#nv1)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#nv1)}&lt;/style>
&lt;defs>&lt;marker id="nv1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">Estás aquí: DATA · PostgreSQL + Qdrant + patrones de sincronización en microservicios&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box active"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="la-pregunta-que-define-la-arquitectura-una-db-o-dos">La pregunta que define la arquitectura: ¿una DB o dos?&lt;/h2>
&lt;p>Antes de hablar de patrones, vamos a la decisión que marca el resto del diseño. Tienes datos transaccionales en PostgreSQL —usuarios, productos, documentos, conversaciones— y necesitas búsqueda vectorial sobre ellos para RAG. Dos respuestas razonables:&lt;/p>
&lt;p>&lt;strong>Opción A — pgvector dentro de Postgres&lt;/strong>: añades la extensión &lt;code>vector&lt;/code>, una columna &lt;code>embedding vector(1536)&lt;/code>, un índice HNSW. Cero arquitectura nueva, cero servicio nuevo. Tu DBA sigue siendo el DBA. Una sola DB, ACID con tus tablas relacionales, JOINs entre embedding y metadata. &lt;strong>Una sola fuente de verdad&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Opción B — Qdrant separado&lt;/strong>: dejas Postgres como está y montas Qdrant como servicio stateful aparte. Tu microservicio escribe a las dos. &lt;strong>Dos fuentes parciales que mantener en sync&lt;/strong>.&lt;/p>
&lt;p>La elección depende de números. Vamos a ellos.&lt;/p>
&lt;h3 id="cuándo-pgvector-basta-y-cuándo-no">Cuándo pgvector basta y cuándo no&lt;/h3>
&lt;p>&lt;a href="https://qdrant.tech/blog/pgvector-tradeoffs/">Los benchmarks 2026&lt;/a> son consistentes. La regla del pulgar:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hasta ~1M vectores&lt;/strong>: pgvector es excelente. Setup en minutos, cero overhead operacional, queries ACID con JOINs naturales.&lt;/li>
&lt;li>&lt;strong>1-10M vectores&lt;/strong>: pgvector funciona pero ya empiezas a sufrir. Index builds tardan, recall baja bajo carga, memoria sube linealmente.&lt;/li>
&lt;li>&lt;strong>&amp;gt;10M vectores&lt;/strong>: pgvector se hunde a no ser que tunes mucho. Index build pasa de horas; query p95 deriva por encima de 200ms.&lt;/li>
&lt;li>&lt;strong>&amp;gt;50M vectores&lt;/strong>: pgvector deja de ser opción razonable en single-node.&lt;/li>
&lt;/ul>
&lt;p>Qdrant escala a billones con sharding. Numéricamente, en 500K vectores con 3 condiciones de payload:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qdrant&lt;/strong>: 6 ms p95 (filtered HNSW).&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong>: 29 ms p95 (heap scans rompen la localidad del índice).&lt;/li>
&lt;/ul>
&lt;p>Y en memoria: &lt;strong>Qdrant con scalar quantization usa 65% menos RAM&lt;/strong> que pgvector con IVFFlat sobre el mismo dataset. Para 50M vectores de 1024 dimensiones eso son decenas de GB de diferencia. Multiplicado por tres réplicas para HA, es un nodo entero menos.&lt;/p>
&lt;p>Pero pgvector tiene una ventaja decisiva en proyectos pequeños y medianos: &lt;strong>es gratis, embebido, y lo opera tu DBA&lt;/strong>. La fricción de adoptar Qdrant —un servicio stateful nuevo, gRPC, snapshots, observabilidad propia— solo se justifica cuando el dolor de pgvector es real, no anticipado.&lt;/p>
&lt;h3 id="el-veredicto-operativo-2026">El veredicto operativo 2026&lt;/h3>
&lt;ul>
&lt;li>Empieza con &lt;strong>pgvector&lt;/strong> si tu corpus es &amp;lt;5M vectores y tu equipo es pequeño.&lt;/li>
&lt;li>Migra a &lt;strong>Qdrant&lt;/strong> cuando uno de los tres siguientes signos aparezca: latencia p95 inaceptable, presión de memoria sobre el cluster Postgres principal, necesidad de hybrid search (sparse + dense) avanzada.&lt;/li>
&lt;li>&lt;strong>No migres anticipadamente&lt;/strong>: el coste operacional de Qdrant es real; sufre cuando lo necesitas, no por si acaso.&lt;/li>
&lt;/ul>
&lt;p>Lo importante: &lt;strong>diseña la capa de acceso a embeddings con una abstracción&lt;/strong> (un &lt;code>VectorStore&lt;/code> interface en tu código) para que cambiar de pgvector a Qdrant sea cambiar la implementación, no reescribir la app.&lt;/p>
&lt;h2 id="qdrant-en-detalle-lo-que-ofrece-sobre-pgvector">Qdrant en detalle: lo que ofrece sobre pgvector&lt;/h2>
&lt;p>Si decides que Qdrant es la opción, vale la pena entender qué te da más allá del rendimiento bruto. Cinco features dominantes:&lt;/p>
&lt;h3 id="1-filterable-hnsw">1. Filterable HNSW&lt;/h3>
&lt;p>El &lt;strong>HNSW filterable&lt;/strong> es lo que más se nota en producción. En pgvector, filtrar por metadata (&lt;code>WHERE category = 'tech' AND date &amp;gt; '2026-01-01'&lt;/code>) hace que el índice HNSW pierda eficiencia: la búsqueda tiene que recorrer más nodos para encontrar los que cumplen el filtro. En Qdrant, el HNSW está construido para &lt;strong>podar la búsqueda con filtros dentro del propio recorrido del grafo&lt;/strong>, sin escapar a heap scans externos. Para queries con filtros densos (lo normal en RAG con permisos multi-tenant), la diferencia es brutal.&lt;/p>
&lt;h3 id="2-multivector-y-late-interaction-colbert">2. Multivector y late-interaction (ColBERT)&lt;/h3>
&lt;p>Qdrant permite almacenar &lt;strong>una matriz de vectores por punto&lt;/strong>, no solo un vector. Esto soporta nativamente modelos late-interaction como ColBERT, que codifican un vector por token y comparan con &lt;code>MaxSim&lt;/code>. La calidad de retrieval con ColBERT-style multivectors es típicamente 5-15% mejor que single-vector en cargas semánticas complejas.&lt;/p>
&lt;h3 id="3-sparse--dense-hybrid-search">3. Sparse + dense hybrid search&lt;/h3>
&lt;p>&lt;a href="https://qdrant.tech/articles/sparse-vectors/">Hybrid search&lt;/a> combina un vector denso (semántico, eg embeddings de SentenceTransformers) con un vector disperso (lexical, eg SPLADE, BM25 reproducido como sparse). El denso captura &amp;ldquo;esto es semánticamente similar&amp;rdquo;; el disperso captura &amp;ldquo;esta palabra concreta aparece&amp;rdquo;. Combinados —tipicamente con reciprocal rank fusion o weighted combination— recuperan tanto la similitud semántica como los matches exactos de keyword. Es el patrón de retrieval que más calidad da en 2026 y Qdrant lo trae nativo desde la versión 1.10.&lt;/p>
&lt;h3 id="4-quantization-escalar-y-binaria">4. Quantization escalar y binaria&lt;/h3>
&lt;p>Para cargas grandes, Qdrant ofrece &lt;strong>scalar quantization&lt;/strong> (&lt;code>int8&lt;/code> en lugar de &lt;code>float32&lt;/code>, 4× menos memoria con pérdida marginal de recall) y &lt;strong>binary quantization&lt;/strong> (1 bit por dimensión, 32× menos memoria con pérdida moderada que se recupera con rescoring de los top-K). En el roadmap 2026 está la &lt;strong>4-bit quantization&lt;/strong>, que será un punto medio.&lt;/p>
&lt;h3 id="5-named-vectors">5. Named vectors&lt;/h3>
&lt;p>Una colección Qdrant puede tener &lt;strong>múltiples espacios vectoriales por punto&lt;/strong>, llamados named vectors. Caso típico: el mismo documento se indexa con un vector denso (&lt;code>text-embedding-3-small&lt;/code>) y un vector sparse (SPLADE), bajo el mismo &lt;code>point_id&lt;/code>. Las queries pueden buscar en el vector concreto que les interesa.&lt;/p>
&lt;p>A esto se suma el roadmap 2026: &lt;strong>4-bit quantization, read-write segregation, expanded inference capabilities&lt;/strong> (Qdrant puede embeddar texto él mismo, sin un servicio externo).&lt;/p>
&lt;h2 id="la-arquitectura-de-microservicios-dónde-encaja-cada-pieza">La arquitectura de microservicios: dónde encaja cada pieza&lt;/h2>
&lt;p>Aquí está lo que el usuario que monta esto en producción tiene que diseñar. La arquitectura típica que se ha estabilizado tiene &lt;strong>cinco microservicios&lt;/strong> que tocan estas piezas, cada uno con su responsabilidad clara:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Microservicios PG + Qdrant">
&lt;style>.title{font:700 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.tiny{font:10px sans-serif;fill:#666}.svc{stroke:#444;stroke-width:1.5;rx:6}.domain{fill:#ffe9d6}.emb{fill:#d6eaff}.idx{fill:#d9f5d6}.retr{fill:#e9d6f5}.llm{fill:#ffd6d6}.db{stroke:#666;stroke-width:1.5;rx:4}.pg{fill:#fff5b0}.qd{fill:#d6f0ff}.kafka{fill:#f4d6ff}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#nv2)}.async{stroke:#aa6;stroke-width:1.4;fill:none;marker-end:url(#nv2);stroke-dasharray:5 3}&lt;/style>
&lt;defs>&lt;marker id="nv2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="22" text-anchor="middle" class="title">Microservicios típicos en un RAG con PostgreSQL + Qdrant&lt;/text>
&lt;rect x="30" y="50" width="150" height="60" class="svc domain"/>&lt;text x="105" y="74" text-anchor="middle" class="lbl">Domain Service&lt;/text>&lt;text x="105" y="92" text-anchor="middle" class="sm">CRUD de documentos&lt;/text>&lt;text x="105" y="106" text-anchor="middle" class="sm">/users, /products...&lt;/text>
&lt;rect x="30" y="180" width="150" height="80" class="db pg"/>&lt;text x="105" y="204" text-anchor="middle" class="lbl">PostgreSQL&lt;/text>&lt;text x="105" y="222" text-anchor="middle" class="sm">documents (main)&lt;/text>&lt;text x="105" y="238" text-anchor="middle" class="sm">outbox (events)&lt;/text>&lt;text x="105" y="254" text-anchor="middle" class="sm">ACID&lt;/text>
&lt;rect x="220" y="180" width="140" height="80" class="db kafka"/>&lt;text x="290" y="204" text-anchor="middle" class="lbl">Kafka&lt;/text>&lt;text x="290" y="222" text-anchor="middle" class="sm">documents.changes&lt;/text>&lt;text x="290" y="238" text-anchor="middle" class="sm">documents.embedded&lt;/text>&lt;text x="290" y="254" text-anchor="middle" class="sm">retention 30d+&lt;/text>
&lt;rect x="400" y="50" width="160" height="60" class="svc emb"/>&lt;text x="480" y="74" text-anchor="middle" class="lbl">Embedding Service&lt;/text>&lt;text x="480" y="92" text-anchor="middle" class="sm">consume Kafka,&lt;/text>&lt;text x="480" y="106" text-anchor="middle" class="sm">batch embed, escribe&lt;/text>
&lt;rect x="400" y="180" width="160" height="60" class="svc idx"/>&lt;text x="480" y="204" text-anchor="middle" class="lbl">Indexing Worker&lt;/text>&lt;text x="480" y="222" text-anchor="middle" class="sm">consume embedded,&lt;/text>&lt;text x="480" y="238" text-anchor="middle" class="sm">upsert a Qdrant&lt;/text>
&lt;rect x="580" y="180" width="170" height="80" class="db qd"/>&lt;text x="665" y="204" text-anchor="middle" class="lbl">Qdrant&lt;/text>&lt;text x="665" y="222" text-anchor="middle" class="sm">collection: documents&lt;/text>&lt;text x="665" y="238" text-anchor="middle" class="sm">HNSW + payload&lt;/text>&lt;text x="665" y="254" text-anchor="middle" class="sm">3 réplicas&lt;/text>
&lt;rect x="220" y="310" width="160" height="60" class="svc retr"/>&lt;text x="300" y="334" text-anchor="middle" class="lbl">Retrieval Service&lt;/text>&lt;text x="300" y="352" text-anchor="middle" class="sm">query Qdrant + reranker&lt;/text>&lt;text x="300" y="366" text-anchor="middle" class="sm">+ enrich con PG&lt;/text>
&lt;rect x="430" y="310" width="160" height="60" class="svc llm"/>&lt;text x="510" y="334" text-anchor="middle" class="lbl">LLM Service&lt;/text>&lt;text x="510" y="352" text-anchor="middle" class="sm">vLLM / API externa&lt;/text>&lt;text x="510" y="366" text-anchor="middle" class="sm">recibe context + query&lt;/text>
&lt;path class="arr" d="M105,110 L105,180"/>
&lt;text x="115" y="148" class="tiny">tx con outbox&lt;/text>
&lt;path class="async" d="M180,228 L220,228"/>
&lt;text x="200" y="222" class="tiny">CDC&lt;/text>
&lt;text x="200" y="245" class="tiny">Debezium&lt;/text>
&lt;path class="arr" d="M360,228 L360,148 L400,80"/>
&lt;text x="365" y="160" class="tiny">consume&lt;/text>
&lt;text x="365" y="173" class="tiny">events&lt;/text>
&lt;path class="arr" d="M480,110 L480,180"/>
&lt;text x="490" y="148" class="tiny">produce&lt;/text>
&lt;text x="490" y="161" class="tiny">embedded&lt;/text>
&lt;path class="arr" d="M560,225 L580,225"/>
&lt;text x="565" y="217" class="tiny">upsert&lt;/text>
&lt;path class="arr" d="M580,225 C600,250 600,300 510,310"/>
&lt;text x="590" y="285" class="tiny">query&lt;/text>
&lt;path class="arr" d="M180,340 L220,340"/>
&lt;text x="185" y="333" class="tiny">enrich&lt;/text>
&lt;text x="180" y="346" class="tiny">metadata&lt;/text>
&lt;path class="arr" d="M380,340 L430,340"/>
&lt;text x="395" y="333" class="tiny">context&lt;/text>
&lt;text x="395" y="346" class="tiny">+ query&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Vamos a cada microservicio.&lt;/p>
&lt;h3 id="1-domain-service">1. Domain Service&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: la lógica de negocio. CRUD de documentos, productos, conversaciones. Endpoints REST/gRPC para el front-end o para otros servicios. &lt;strong>Solo conoce PostgreSQL como sistema de persistencia&lt;/strong>; no sabe nada de Qdrant.&lt;/p>
&lt;p>Esto es &lt;strong>importante por diseño&lt;/strong>: el domain service no debería tener nunca una referencia directa a Qdrant. Si la tiene, ya estás en el antipattern del dual-write. El domain service escribe a Postgres en una transacción ACID; el resto del pipeline se entera vía eventos.&lt;/p>
&lt;h3 id="2-postgresql">2. PostgreSQL&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: source of truth transaccional. Schemas relacionales, constraints, triggers, ACID. &lt;strong>Y la outbox table&lt;/strong> que veremos en breve, que es lo que va a permitir la sincronización fiable.&lt;/p>
&lt;p>Patrón típico de despliegue: HA con Patroni + repmgr + PgBouncer para connection pooling, replicas de lectura para offloading.&lt;/p>
&lt;h3 id="3-kafka">3. Kafka&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: el bus de eventos. Recibe los cambios capturados por CDC (Debezium leyendo el WAL de Postgres o leyendo la outbox table) y los pone disponibles para los consumidores. Cubierto en profundidad en &lt;a href="https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/">RAG sobre Kafka&lt;/a>.&lt;/p>
&lt;p>Topics típicos:&lt;/p>
&lt;ul>
&lt;li>&lt;code>documents.changes&lt;/code>: eventos crudos de cambio (insert/update/delete).&lt;/li>
&lt;li>&lt;code>documents.embedded&lt;/code>: eventos con embedding ya calculado.&lt;/li>
&lt;/ul>
&lt;h3 id="4-embedding-service">4. Embedding Service&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: consumir eventos de cambio, calcular embeddings, publicar al topic &lt;code>embedded&lt;/code>. Esta es la pieza que más coste consume si usas embeddings vía API (OpenAI, Cohere, Voyage AI).&lt;/p>
&lt;p>Estructura típica:&lt;/p>
&lt;ul>
&lt;li>Consumer Kafka con consumer group propio.&lt;/li>
&lt;li>Batching de eventos para llamadas embedding (mucho más eficiente que uno a uno).&lt;/li>
&lt;li>Llamadas paralelas con concurrency control.&lt;/li>
&lt;li>Retry con exponential backoff ante rate limits.&lt;/li>
&lt;li>Métricas exportadas (latencia, throughput, errores, coste).&lt;/li>
&lt;li>Idempotencia (key del topic = doc_id, mismo doc no se re-embedea sin necesidad).&lt;/li>
&lt;/ul>
&lt;p>Patrón de optimización clave: &lt;strong>deduplicate por hash de contenido&lt;/strong>. Si el documento se actualiza pero el texto no cambió (solo metadata), no merece la pena re-embedear. Hash + cache de embeddings ahorra 30-70% del coste en cargas reales.&lt;/p>
&lt;h3 id="5-indexing-worker">5. Indexing Worker&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: consumir el topic &lt;code>embedded&lt;/code> y hacer &lt;code>upsert&lt;/code> a Qdrant. Es la pieza más simple de toda la arquitectura: lee del topic, escribe al vector store. Pero importante para la fiabilidad: tiene que ser &lt;strong>idempotente&lt;/strong> (el mismo &lt;code>doc_id&lt;/code> puede llegar varias veces si el consumer reinicia) y &lt;strong>resiliente&lt;/strong> (si Qdrant está caído, reintentar sin perder eventos).&lt;/p>
&lt;p>Estructura: Consumer Kafka con commit manual de offset solo después de confirmación del upsert. Si Qdrant falla, el offset no se commitea y el evento se reprocesa.&lt;/p>
&lt;h3 id="6-retrieval-service">6. Retrieval Service&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: la cara que el LLM Service ve. Recibe una query del usuario, hace búsqueda en Qdrant (vector + filtros + reranker), enriquece los resultados con metadata fresca de PostgreSQL si hace falta, y devuelve top-K documentos con su contenido para que el LLM construya su prompt.&lt;/p>
&lt;p>Es &lt;strong>el único servicio que consulta Qdrant&lt;/strong>. Esto centraliza la lógica de retrieval: cuando quieras añadir reranking, hybrid search, query rewriting, lo haces aquí sin tocar el resto.&lt;/p>
&lt;h3 id="7-llm-service">7. LLM Service&lt;/h3>
&lt;p>&lt;strong>Responsabilidad&lt;/strong>: la generación. Recibe del Retrieval Service el contexto + query, construye el prompt, llama al LLM (self-hosted vLLM o API externa vía LiteLLM), devuelve la respuesta. Lo cubrimos en posts anteriores; no es el foco aquí.&lt;/p>
&lt;h2 id="el-problema-del-dual-write-y-los-tres-patrones-de-solución">El problema del dual-write y los tres patrones de solución&lt;/h2>
&lt;p>Aquí está la pieza arquitectónica más importante del post. El problema: tu Domain Service necesita escribir a &lt;strong>dos lugares&lt;/strong>: PostgreSQL (el documento) y, indirectamente vía pipeline, Qdrant (el embedding del documento). Si lo haces ingenuamente —escribir a uno y luego al otro— tienes &lt;strong>el problema del dual-write&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>La escritura a Postgres tiene éxito, pero la publicación del evento a Kafka falla → &lt;strong>el embedding no se calcula, Qdrant nunca se entera&lt;/strong>.&lt;/li>
&lt;li>La publicación a Kafka tiene éxito, pero el commit a Postgres falla → &lt;strong>evento fantasma&lt;/strong>, el embedding se calcula sobre algo que no existe.&lt;/li>
&lt;li>El servicio crashea entre las dos operaciones → &lt;strong>estado parcial&lt;/strong>, no sabes qué pasó.&lt;/li>
&lt;/ul>
&lt;p>Distributed transactions (two-phase commit) son la solución teórica pero &lt;strong>nadie las quiere en producción&lt;/strong>: requieren coordinator XA, latencia alta, locking distribuido. La solución práctica son los patrones modernos. Tres opciones:&lt;/p>
&lt;h3 id="patrón-1--dual-write-naïve-prototipos">Patrón 1 — Dual-write naïve (prototipos)&lt;/h3>
&lt;p>El Domain Service escribe a Postgres, luego publica a Kafka:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">create_document&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;INSERT INTO documents ...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">kafka&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">publish&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;documents.changes&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&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="k">return&lt;/span> &lt;span class="n">doc_id&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Funciona&lt;/strong> en happy path. &lt;strong>Falla&lt;/strong> cuando algo entre las dos operaciones se rompe. Para prototipos donde la inconsistencia es aceptable, vale; para producción seria, no.&lt;/p>
&lt;h3 id="patrón-2--transactional-outbox--cdc-con-debezium-la-opción-correcta">Patrón 2 — Transactional outbox + CDC con Debezium (la opción correcta)&lt;/h3>
&lt;p>Solución elegante: &lt;strong>el Domain Service escribe a Postgres en una sola transacción que incluye tanto la tabla principal como una &lt;code>outbox&lt;/code> table&lt;/strong>. La outbox no es consumida directamente; &lt;strong>Debezium lee el WAL de Postgres y produce a Kafka los eventos de la outbox&lt;/strong>.&lt;/p>
&lt;p>Schema típico:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">outbox&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">gen_random_uuid&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">aggregate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;document&amp;#39;, &amp;#39;user&amp;#39;, &amp;#39;product&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">aggregate_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- el doc_id que cambió
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_type&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- &amp;#39;created&amp;#39;, &amp;#39;updated&amp;#39;, &amp;#39;deleted&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">payload&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">JSONB&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">created_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMPTZ&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">NOW&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Cuando el Domain Service crea un documento:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">create_document&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;INSERT INTO documents (id, body) VALUES (...)&amp;#34;&lt;/span>&lt;span class="p">,&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="k">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;INSERT INTO outbox (aggregate, aggregate_id, event_type, payload) VALUES (...)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;document&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;created&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&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="c1"># transacción committed; Debezium leerá el WAL y publicará a Kafka&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">doc_id&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lo crucial&lt;/strong>: las dos inserciones están en &lt;strong>la misma transacción ACID&lt;/strong> de Postgres. O las dos van, o ninguna va. Garantía absoluta de consistencia local.&lt;/p>
&lt;p>Configuración Debezium para leer la outbox:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;outbox-debezium-connector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.connector.postgresql.PostgresConnector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.hostname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.dbname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;app&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;table.include.list&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public.outbox&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;outbox&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.transforms.outbox.EventRouter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.route.by.field&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;aggregate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.table.field.event.id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.table.field.event.key&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;aggregate_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.table.field.event.type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.table.field.event.payload&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;payload&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;transforms.outbox.route.topic.replacement&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;${routedByValue}.changes&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.storage.StringConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.json.JsonConverter&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>EventRouter&lt;/code> enruta a topics distintos según el valor de &lt;code>aggregate&lt;/code>: eventos de &lt;code>document&lt;/code> van a &lt;code>document.changes&lt;/code>, los de &lt;code>user&lt;/code> a &lt;code>user.changes&lt;/code>, etc.&lt;/p>
&lt;p>&lt;strong>Ventajas&lt;/strong>: garantía &amp;ldquo;exactly-once&amp;rdquo; desde el punto de vista de la aplicación; eventos en orden del commit; sin polling.&lt;/p>
&lt;p>&lt;strong>Coste&lt;/strong>: una tabla extra, una configuración Debezium, ~5-10 ms extra de latencia en la escritura.&lt;/p>
&lt;h3 id="patrón-3--event-driven-directo-event-sourcing-puro">Patrón 3 — Event-driven directo (event sourcing puro)&lt;/h3>
&lt;p>Variante más radical: &lt;strong>el evento es el primer ciudadano&lt;/strong>; PostgreSQL es solo una proyección. El Domain Service publica el evento a Kafka, y un consumer lo escribe a Postgres y otro lo procesa para embedding. &lt;strong>No hay tabla principal, no hay outbox&lt;/strong>; el log Kafka es la fuente de verdad.&lt;/p>
&lt;p>Más limpio conceptualmente pero requiere repensar el modelo de dominio (eventos como source of truth, queries reconstruidas de la proyección). Más adecuado para greenfield con equipo que entiende event sourcing.&lt;/p>
&lt;h3 id="comparativa">Comparativa&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Patrón&lt;/th>
&lt;th>Setup&lt;/th>
&lt;th>Consistencia&lt;/th>
&lt;th>Cuando&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Dual-write naïve&lt;/td>
&lt;td>Trivial&lt;/td>
&lt;td>Frágil&lt;/td>
&lt;td>Prototipos, PoC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Outbox + CDC&lt;/td>
&lt;td>Medio&lt;/td>
&lt;td>&lt;strong>Sólido&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Producción seria&lt;/strong> (default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Event-driven directo&lt;/td>
&lt;td>Alto&lt;/td>
&lt;td>Sólido&lt;/td>
&lt;td>Greenfield con event sourcing&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>El default en 2026 para &lt;strong>producción&lt;/strong> es &lt;strong>outbox + CDC con Debezium&lt;/strong>. Es lo suficientemente simple para mantenerse, lo suficientemente robusto para no preocupar de noche.&lt;/p>
&lt;h2 id="manifest-completo-despliegue-qdrant-en-kubernetes">Manifest completo: despliegue Qdrant en Kubernetes&lt;/h2>
&lt;p>Ya cubrimos cómo se monta el resto del pipeline (Kafka, Debezium, Flink) en el &lt;a href="https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/">post anterior de Kafka&lt;/a>. La pieza que añadimos aquí es Qdrant. Despliegue típico vía Helm chart oficial:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml para qdrant/qdrant chart&lt;/span>&lt;span class="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">replicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cluster con 3 réplicas&lt;/span>&lt;span 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">image&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">repository&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qdrant/qdrant&lt;/span>&lt;span class="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">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;v1.14.0&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">persistence&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fast-ssd&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">200Gi&lt;/span>&lt;span 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">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">8Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span 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="c"># clustering: cada réplica conoce a las otras&lt;/span>&lt;span class="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">cluster&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">consensus&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">tickPeriodMs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># auth via API key&lt;/span>&lt;span class="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">apiKey&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qdrant-auth&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">api-key&lt;/span>&lt;span 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="c"># observability&lt;/span>&lt;span class="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">metrics&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serviceMonitor&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># scrapping desde kube-prometheus&lt;/span>&lt;span 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="c"># snapshots periódicos&lt;/span>&lt;span class="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">snapshots&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedule&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 3 * * *&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># diario a las 3 AM&lt;/span>&lt;span class="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">retention&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;s3&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">s3&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">bucket&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qdrant-snapshots-prod&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">config&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">storage&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">performance&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">max_search_threads&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="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">quantization&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">always_ram&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># quantized vectors en RAM&lt;/span>&lt;span class="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">service&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">enable_tls&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y la creación de la colección con configuración para hybrid search:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">QdrantClient&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">VectorParams&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SparseVectorParams&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Distance&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">HnswConfigDiff&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ScalarQuantization&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ScalarType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">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;https://qdrant.internal:6333&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">API_KEY&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_collection&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">vectors_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;dense&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">VectorParams&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1536&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">distance&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Distance&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">COSINE&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">on_disk&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># en RAM para latencia&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sparse_vectors_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;sparse&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">SparseVectorParams&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># para BM25-style lexical&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">hnsw_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">HnswConfigDiff&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">m&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ef_construct&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">128&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">on_disk&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ScalarQuantization&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scalar&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ScalarType&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">INT8&lt;/span> &lt;span class="c1"># 65% menos memoria&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">on_disk_payload&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># payload en disco&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">shard_number&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># particionado para escala&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">replication_factor&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># cada shard replicado&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">write_consistency_factor&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con esta config, una colección de 50M vectores de 1536 dimensiones ocupa ~150-200 GB en RAM (vs ~600 GB con float32 puro), con queries p95 sub-10ms en cargas típicas.&lt;/p>
&lt;h2 id="observabilidad-ver-qué-está-pasando">Observabilidad: ver qué está pasando&lt;/h2>
&lt;p>Cuatro métricas que cualquier dashboard mínimo de la etapa Data debería tener:&lt;/p>
&lt;h3 id="1-lag-del-outbox">1. Lag del outbox&lt;/h3>
&lt;p>&lt;code>debezium_lag_seconds&lt;/code>: cuánto tarda Debezium en leer un evento desde que se commitea. &lt;strong>Objetivo: &amp;lt;1 segundo&lt;/strong>. Si sube, indica WAL retention insuficiente o consumer rate menor que producer.&lt;/p>
&lt;h3 id="2-lag-del-embedding-service">2. Lag del embedding service&lt;/h3>
&lt;p>&lt;code>embedding_service_consumer_lag_messages&lt;/code>: cuántos eventos pendientes hay en el topic &lt;code>documents.changes&lt;/code>. &lt;strong>Objetivo: &amp;lt;100 sostenido&lt;/strong>. Si crece, indica que el rate de cambios supera la capacidad del embedding service. Soluciones: más consumers (paralelismo), batching más grande, modelo de embedding más rápido.&lt;/p>
&lt;h3 id="3-tasa-de-upsert-a-qdrant">3. Tasa de upsert a Qdrant&lt;/h3>
&lt;p>&lt;code>qdrant_upsert_rate&lt;/code> y &lt;code>qdrant_upsert_p95_latency&lt;/code>. &lt;strong>Objetivo: latencia &amp;lt;50 ms p95, tasa estable acorde al CDC rate&lt;/strong>. Si la latencia sube, Qdrant está degradado (memory pressure, disk slow, conn pool saturado).&lt;/p>
&lt;h3 id="4-recall-en-producción-offline-check">4. Recall en producción (offline check)&lt;/h3>
&lt;p>Una vez al día, ejecutar un job que toma N queries reales, busca en Qdrant, busca en pgvector si lo mantienes en paralelo, compara recall@k. Si Qdrant deja de devolver lo que debería, lo detectas antes de que un usuario se queje.&lt;/p>
&lt;h2 id="trampas-operativas-comunes">Trampas operativas comunes&lt;/h2>
&lt;h3 id="sin-outbox-el-equipo-aprende-dual-write-a-base-de-incidentes">Sin outbox: el equipo aprende dual-write a base de incidentes&lt;/h3>
&lt;p>Lo más común. La primera versión hace dual-write directo &amp;ldquo;para empezar simple&amp;rdquo;; un día se cae Kafka durante 10 minutos y miles de embeddings quedan sin generar. Migrar a outbox &lt;strong>después de tener tráfico&lt;/strong> es caro porque hay que backfill. &lt;strong>Outbox desde el día 1&lt;/strong>.&lt;/p>
&lt;h3 id="reembedding-ignorante-del-coste">Reembedding ignorante del coste&lt;/h3>
&lt;p>Cambias el modelo de embedding (&lt;code>text-embedding-3-small&lt;/code> → &lt;code>text-embedding-3-large&lt;/code>). Tu pipeline reemboda los 5M documentos. &lt;strong>17 horas y $1500 de coste&lt;/strong> que nadie anticipó. &lt;strong>Calcular reembedding upfront&lt;/strong>: documentos × tokens promedio × coste/1k tokens × throughput limits.&lt;/p>
&lt;h3 id="snapshot-de-qdrant-sin-testear-restore">Snapshot de Qdrant sin testear restore&lt;/h3>
&lt;p>Sacas snapshots diarios pero nunca pruebas restaurar. Un día Qdrant se corrompe y descubres que el snapshot está incompleto o que tu storage class no permite recuperarlo. &lt;strong>Test trimestral de restore&lt;/strong> en entorno paralelo, obligatorio para producción.&lt;/p>
&lt;h3 id="qdrant-detrás-de-service-clusterip-estándar-sin-grpc-affinity">Qdrant detrás de Service ClusterIP estándar sin gRPC affinity&lt;/h3>
&lt;p>Qdrant habla gRPC. Si el Service hace round-robin connection-level pero el cliente reusa connections, todo el tráfico va a un solo pod. &lt;strong>Headless Service + client-side load balancing&lt;/strong> o gRPC-aware service mesh.&lt;/p>
&lt;h3 id="pg-y-qdrant-sin-shared-trace-id">PG y Qdrant sin shared trace id&lt;/h3>
&lt;p>El Domain Service recibe un request, lo procesa, escribe a PG, dispara evento. Cuando un día algo va mal, no puedes correlar el span del Domain Service con el span del Indexing Worker porque no propagaste trace context. &lt;strong>OTel context propagation&lt;/strong> por el topic Kafka (vía headers Kafka), igual que hicimos en el &lt;a href="https://blog.lo0.es/posts/mcp-observability-otel/">post de MCP observability&lt;/a>.&lt;/p>
&lt;h3 id="vector-y-metadata-en-sync-nominal-pero-no-real">Vector y metadata en sync nominal pero no real&lt;/h3>
&lt;p>PG dice &amp;ldquo;documento X tiene categoría tech&amp;rdquo;; Qdrant dice &amp;ldquo;documento X tiene categoría legal&amp;rdquo; (porque el cambio de categoría se actualizó en PG pero el evento de update no llegó a regenerar el payload en Qdrant). Filtras &lt;code>category=tech&lt;/code>, no aparece. &lt;strong>Tests periódicos de consistencia cross-store&lt;/strong> sobre muestreo aleatorio.&lt;/p>
&lt;h3 id="dimensión-del-vector-hardcodeada-en-mil-sitios">Dimensión del vector hardcodeada en mil sitios&lt;/h3>
&lt;p>&lt;code>1536&lt;/code> aparece en el código del Domain Service, del Embedding Service, del Indexing Worker, del Retrieval Service, en la creación de la colección Qdrant. Cuando cambias modelo (a uno de 768 dimensiones), olvidas uno y todo se rompe. &lt;strong>Configuración centralizada&lt;/strong> del modelo + dimensión.&lt;/p>
&lt;h3 id="sin-rate-limiting-al-embedding-provider">Sin rate limiting al embedding provider&lt;/h3>
&lt;p>Tu CDC procesa una migración masiva: 1M documentos cambian. El embedding service intenta procesar todo a la vez. &lt;strong>OpenAI te rate-limita&lt;/strong>, el consumer queda atascado, los eventos se acumulan, tu cluster Kafka queda con horas de lag. &lt;strong>Rate limiting en el consumer&lt;/strong>, no en el producer.&lt;/p>
&lt;h2 id="cuándo-no-usar-qdrant-el-contrapunto-honesto">Cuándo NO usar Qdrant: el contrapunto honesto&lt;/h2>
&lt;p>Para no presentar Qdrant como bala de plata:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tu corpus es &amp;lt;1M vectores&lt;/strong> y no esperas crecer. pgvector basta y te ahorra un servicio.&lt;/li>
&lt;li>&lt;strong>Tu equipo es pequeño y no tiene capacidad de operar un stateful service más&lt;/strong>. Qdrant añade snapshots, gRPC, mTLS, observabilidad propia. Cada uno de esos puntos es un día de trabajo de un SRE.&lt;/li>
&lt;li>&lt;strong>Tu retrieval es batch off-hours&lt;/strong>, no real-time. Si solo haces RAG para reportes nocturnos, la latencia de pgvector no duele.&lt;/li>
&lt;li>&lt;strong>Necesitas JOINs nativos&lt;/strong> entre embeddings y tablas relacionales en queries críticos. pgvector permite hacer &lt;code>JOIN documents d ON d.id = embedding.doc_id WHERE d.tenant_id = X&lt;/code>. Qdrant lo simula con payload pero menos elegante.&lt;/li>
&lt;/ul>
&lt;p>Y al revés, cuando Qdrant &lt;strong>gana claramente&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Corpus &amp;gt;10M vectores con queries con filtros densos.&lt;/li>
&lt;li>Necesidad de hybrid search nativo (sparse + dense + multivector).&lt;/li>
&lt;li>Multi-tenant con strict latency requirements por cliente.&lt;/li>
&lt;li>Quantization agresiva para mantener todo en RAM en hardware limitado.&lt;/li>
&lt;li>Cluster mode con sharding horizontal real.&lt;/li>
&lt;/ul>
&lt;h2 id="lo-que-no-hemos-cubierto">Lo que no hemos cubierto&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Migración pgvector → Qdrant en vivo&lt;/strong>: patrón con dual-read durante la transición.&lt;/li>
&lt;li>&lt;strong>Vector search federation&lt;/strong>: queries que cruzan múltiples Qdrant collections o múltiples vector stores. Tema propio.&lt;/li>
&lt;li>&lt;strong>Multi-tenancy en Qdrant&lt;/strong>: payload filters + namespace isolation + per-tenant rate limiting.&lt;/li>
&lt;li>&lt;strong>Cold storage para vectores antiguos&lt;/strong>: archivo de partitions a object storage con índice secundario.&lt;/li>
&lt;li>&lt;strong>Embedding model self-hosted con vLLM&lt;/strong>: alternativa a OpenAI API que reduce coste y mejora privacidad. Tema cruzado con la serie de inferencia.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;p>PostgreSQL y pgvector:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/pgvector/pgvector">PostgreSQL pgvector extension (GitHub)&lt;/a> — el de toda la vida.&lt;/li>
&lt;li>&lt;a href="https://www.tigerdata.com/blog/pgvector-vs-qdrant">Pgvector vs Qdrant (Tiger Data)&lt;/a> — comparativa con números.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/blog/pgvector-tradeoffs/">Start with pgvector: Why You&amp;rsquo;ll Outgrow It Faster Than You Think (Qdrant blog)&lt;/a> — los tradeoffs honestos desde Qdrant.&lt;/li>
&lt;/ul>
&lt;p>Qdrant:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://qdrant.tech/">Qdrant — sitio oficial&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/documentation/private-cloud/changelog/">Qdrant changelog&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/articles/sparse-vectors/">Sparse Vectors in Qdrant&lt;/a> — hybrid search nativo.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/documentation/tutorials-search-engineering/using-multivector-representations/">Multivectors and Late Interaction&lt;/a> — ColBERT-style.&lt;/li>
&lt;li>&lt;a href="https://qdrant.tech/blog/2025-recap/">Qdrant 2025 Recap: Powering the Agentic Era&lt;/a> — estado del proyecto y roadmap.&lt;/li>
&lt;/ul>
&lt;p>Outbox y CDC:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/">Reliable Microservices Data Exchange With the Outbox Pattern (Debezium blog)&lt;/a> — el post canónico.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/outbox-pattern-explained">The Outbox Pattern Explained (Streamkap)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://thorben-janssen.com/outbox-pattern-with-cdc-and-debezium/">Outbox Pattern with Debezium (Thorben Janssen)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://debezium.io/blog/2020/02/10/event-sourcing-vs-cdc/">Distributed Data for Microservices — Event Sourcing vs CDC (Debezium blog)&lt;/a> — comparativa entre patrones.&lt;/li>
&lt;/ul>
&lt;p>Comparativas 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.knowsync.ai/blog/choosing-vector-database-qdrant-pinecone-pgvector-2026">Choosing Your Vector Database: Qdrant vs Pinecone vs pgvector in 2026 (KnowSync)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://open-techstack.com/blog/pgvector-vs-qdrant-2026/">pgvector vs Qdrant: Production Tradeoffs 2026 (Open Techstack)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://markaicode.com/vs/qdrant-vs-pgvector/">qdrant vs pgvector: Which Vector Database Should You Choose in 2026 (Markaicode)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.marktechpost.com/2026/05/10/best-vector-databases-in-2026-pricing-scale-limits-and-architecture-tradeoffs-across-nine-leading-systems/">Best Vector Databases in 2026: Pricing, Scale Limits, Architecture Tradeoffs (MarkTechPost)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://callsphere.ai/blog/vector-database-benchmarks-2026-pgvector-qdrant-weaviate-milvus-lancedb">Vector Database Benchmarks 2026: pgvector 0.9, Qdrant, Weaviate, Milvus, LanceDB (CallSphere)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Post anterior: &lt;a href="https://blog.lo0.es/posts/pipeline-llmops-seis-etapas/">Pipeline LLMOps de 6 etapas&lt;/a> — donde definimos el mini-mapa.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/">RAG sobre Kafka — arquitectura técnica&lt;/a> — la pieza que precede a Qdrant en el pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/mlops-llms-panorama-2026/">Panorama MLOps LLMs 2026&lt;/a> — el marco general.&lt;/li>
&lt;li>Series previas: &lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">post-tracing&lt;/a> y &lt;a href="https://blog.lo0.es/posts/ebpf-cilium-tcp-ip-bypass/">eBPF&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>RAG sobre Kafka: arquitectura técnica de referencia para datalakes en streaming, con embeddings frescos y vector stores siempre al día</title><link>https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/</link><pubDate>Thu, 21 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/rag-kafka-datalake-arquitectura/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>La pieza que más bloquea proyectos GenAI empresariales en 2026 no es el modelo, ni siquiera los guardrails: es la &lt;strong>ingestión de datos para RAG&lt;/strong>. Las empresas tienen información valiosa en bases de datos OLTP, en logs operacionales, en sistemas SaaS, y todo eso está silenciosamente cambiando cada segundo. Los RAG batch que se reindexan cada noche llegan tarde —la respuesta del modelo está respaldada en un snapshot de hace 18 horas— y dan paso a alucinaciones operacionales aunque el retriever sea perfecto. La respuesta dominante en producción en 2026 es montar la &lt;strong>pieza RAG sobre Kafka como source-of-truth&lt;/strong>: log inmutable, throughput masivo, schema evolution gestionada, y un ecosistema de stream processing maduro (Flink, Kafka Streams, RisingWave) que permite &lt;strong>transformar y embedder eventos a medida que ocurren&lt;/strong>, llevándolos en milisegundos a vector stores (Milvus, Qdrant, Weaviate, pgvector). El patrón canónico: &lt;strong>origen → CDC con Debezium → topics Kafka → Flink SQL con embedding UDF → sink connector a vector store → serving con vLLM o equivalente&lt;/strong>. Las novedades 2026 que cambian el juego: &lt;strong>Confluent Tableflow&lt;/strong> convierte topics Kafka en tablas Iceberg/Delta automáticamente (lectura desde Snowflake/Databricks/Trino sin ETL, 30-50% menos TCO); &lt;strong>Flink SQL nativo&lt;/strong> trae &lt;code>openai_embedding()&lt;/code> y vector search integrado con Cosmos DB y Amazon S3 Vectors; el &lt;strong>MCP server oficial de Confluent&lt;/strong> permite a agentes IA consultar Kafka/Flink/Tableflow en lenguaje natural. Este post desarrolla la arquitectura end-to-end con manifests, código Flink SQL y números concretos.&lt;/p>
&lt;blockquote>
&lt;p>Este es el &lt;strong>segundo post de la serie MLOps específico para LLMs&lt;/strong>. El primero (&lt;a href="https://blog.lo0.es/posts/mlops-llms-panorama-2026/">Panorama 2026&lt;/a>) estableció el marco. Aquí bajamos a la pieza más operacional del stack: cómo se conecta un sistema empresarial real a un agente LLM &lt;strong>manteniendo el RAG fresco&lt;/strong> sin caer en complejidad explosiva.&lt;/p>
&lt;/blockquote>
&lt;h2 id="la-analogía-kafka-como-el-single-source-of-truth">La analogía: Kafka como el &amp;ldquo;single source of truth&amp;rdquo;&lt;/h2>
&lt;p>Quien lleva tiempo en sistemas distribuidos ha visto el patrón una y otra vez: &lt;strong>un log inmutable, append-only, replicado, ordenado en el tiempo&lt;/strong> se ha vuelto la primitiva canónica para reconstruir sistemas complejos. Los DBAs lo conocen como &lt;strong>write-ahead log&lt;/strong> (PostgreSQL WAL, MySQL binlog). Los desarrolladores de sistemas de eventos lo conocen como &lt;strong>event sourcing&lt;/strong>. Los arquitectos de datos lo conocen como &lt;strong>Kappa architecture&lt;/strong>. Kafka es la implementación masiva, distribuida y madura de esa primitiva: un log que vive en disco, particionado para escalar, replicado para durabilidad, retenido por tiempo o tamaño, &lt;strong>legible desde cualquier punto histórico&lt;/strong>.&lt;/p>
&lt;p>Cuando se piensa en RAG, esto es &lt;strong>exactamente&lt;/strong> lo que se necesita. Un sistema RAG bien diseñado tiene dos preguntas críticas: ¿cómo se mantiene fresco el índice? y ¿cómo se reconstruye el índice cuando algo se rompe? Las dos las contesta Kafka de manera natural: &lt;strong>fresco&lt;/strong> porque cada cambio en el origen se publica como evento al log y el pipeline lo procesa en milisegundos; &lt;strong>reconstruible&lt;/strong> porque el log entero está ahí: borras el vector store, dispones del topic Kafka desde el offset 0 y vuelves a construir el índice tal como estaba.&lt;/p>
&lt;p>Hay además una segunda capa de analogía. Kafka, para una arquitectura GenAI moderna, juega el papel del &lt;strong>WAL del sistema entero&lt;/strong>. Igual que el WAL de Postgres es el evangelio del estado de la base de datos —si pierdes la DB pero conservas el WAL, puedes reconstruirla—, el log de Kafka es el evangelio del estado del &lt;strong>conjunto del negocio&lt;/strong>: pedidos, usuarios, transacciones, documentos. Conectar tu agente IA a Kafka es conectarlo al pulso real del sistema, no a snapshots obsoletos.&lt;/p>
&lt;h2 id="el-problema-del-rag-estático">El problema del RAG estático&lt;/h2>
&lt;p>Antes de presentar la arquitectura, vale la pena fijar &lt;strong>qué problema concreto&lt;/strong> estamos resolviendo. El antipattern que tropieza a la mayoría de proyectos GenAI:&lt;/p>
&lt;ol>
&lt;li>Equipo construye RAG sobre un dataset estático: vuelca documentos de Confluence, PDFs de productos, snapshots de base de datos.&lt;/li>
&lt;li>Lo embedea con un cron nocturno que regenera el índice cada 24 horas.&lt;/li>
&lt;li>Lanza el producto.&lt;/li>
&lt;li>&lt;strong>Día 2&lt;/strong>: usuario pregunta sobre un cambio que ocurrió hace dos horas. El RAG no lo tiene; el modelo responde sobre la versión vieja.&lt;/li>
&lt;li>Equipo añade lógica frágil: &amp;ldquo;si la query menciona una fecha reciente, escalar a un agente humano&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Día 30&lt;/strong>: el dataset se ha movido tanto que media RAG está desactualizado. El equipo decide refactor y migrar a streaming.&lt;/li>
&lt;/ol>
&lt;p>Es la historia repetida de tantos proyectos que el ecosistema ha aprendido la lección: &lt;strong>streaming desde el día 1&lt;/strong>, aunque el volumen sea bajo. La complejidad operacional de un pipeline streaming bien diseñado es &lt;strong>constante&lt;/strong>; la complejidad de migrar de batch a streaming en proyecto vivo es &lt;strong>enorme&lt;/strong>.&lt;/p>
&lt;h2 id="del-lambda-al-kappa-al-streaming-rag">Del Lambda al Kappa al Streaming RAG&lt;/h2>
&lt;p>Tres arquitecturas en orden histórico:&lt;/p>
&lt;p>&lt;strong>Lambda (clásica de big data 2014)&lt;/strong>: dos pipelines paralelos, uno batch para precisión y uno streaming para freshness. La consulta combina ambos. Funciona pero exige mantener dos pipelines.&lt;/p>
&lt;p>&lt;strong>Kappa (Jay Kreps 2014, mainstream desde 2020)&lt;/strong>: solo un pipeline streaming. El batch es un caso particular del streaming (reprocesar desde el principio). Simplifica mucho.&lt;/p>
&lt;p>&lt;strong>Streaming RAG (emergente 2025-2026)&lt;/strong>: variante específica de Kappa donde el output del pipeline son &lt;strong>embeddings indexados en un vector store&lt;/strong> que el LLM consulta en runtime. El log Kafka es la &lt;strong>fuente de verdad&lt;/strong>, el vector store es un &lt;strong>proyección consultable&lt;/strong>.&lt;/p>
&lt;p>La conversión mental: piensa en el vector store como la &lt;strong>vista materializada&lt;/strong> del log Kafka. Si la vista se corrompe, la reconstruyes desde el log. Si quieres una vista nueva (otro embedding model, otro chunking strategy), creas otro consumer del log y construyes una segunda vista en paralelo.&lt;/p>
&lt;h2 id="la-arquitectura-de-referencia">La arquitectura de referencia&lt;/h2>
&lt;p>Vamos al diagrama. Voy a presentar la arquitectura canónica que se ha estabilizado en 2026, mostrando dónde encaja cada componente:&lt;/p>
&lt;pre tabindex="0">&lt;code>[OLTP DB (Postgres)] [Otros origenes]
│ │
│ WAL via logical decoding │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Debezium / Kafka Connect (Sources) │
└──────────────────────────────────────────────────────────┘
│
▼ produce eventos
┌──────────────────────────────────────────────────────────┐
│ Kafka cluster │
│ ┌───────────────────────────────────────────────────┐ │
│ │ topic: orders.raw (3 particiones, RF=3) │ │
│ │ topic: users.raw (3 particiones, RF=3) │ │
│ │ topic: documents.raw (6 particiones, RF=3) │ │
│ └───────────────────────────────────────────────────┘ │
│ + Schema Registry (Avro/Protobuf) │
└──────────────────────────────────────────────────────────┘
│
▼ consume y transforma
┌──────────────────────────────────────────────────────────┐
│ Flink SQL streaming jobs │
│ - chunking text │
│ - llamadas a embedding model (UDF) │
│ - enriquecimiento con metadata │
│ - sink a topic curado: documents.embedded │
└──────────────────────────────────────────────────────────┘
│
┌───────────┼────────────────────┐
▼ ▼ ▼
[Vector store] [Tableflow] [Iceberg/Delta]
Milvus/Qdrant auto-convert para analytics
/pgvector/ topics →
Weaviate tables
│
▼ consultado en runtime
┌──────────────────────────────────────────────────────────┐
│ LLM serving (vLLM / SGLang) + Retriever │
│ - recibe query del agente │
│ - busca top-K en vector store │
│ - construye prompt + contexto │
│ - genera respuesta con citas │
└──────────────────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>Las &lt;strong>cinco capas&lt;/strong> que ves —&lt;strong>fuente, ingestión (CDC), transporte (Kafka), procesamiento (Flink), almacenamiento (vector + tablas)&lt;/strong>— son las que estructuran cualquier RAG sobre datalake serio en 2026. Vamos a cada una.&lt;/p>
&lt;h2 id="capa-1--fuentes-tu-oltp-como-punto-de-partida">Capa 1 — Fuentes: tu OLTP como punto de partida&lt;/h2>
&lt;p>La fuente típica es una &lt;strong>base de datos OLTP&lt;/strong> (Postgres, MySQL, SQL Server). Es donde vive el estado vivo del negocio. La técnica para extraer cambios en tiempo real es &lt;strong>Change Data Capture (CDC)&lt;/strong>: leer el log de transacciones de la base de datos (PostgreSQL WAL, MySQL binlog) y convertir cada commit en un evento Kafka.&lt;/p>
&lt;p>El estándar OSS es &lt;strong>&lt;a href="https://debezium.io/">Debezium&lt;/a>&lt;/strong>. Soporta Postgres, MySQL, SQL Server, MongoDB, Oracle, Cassandra y otros. Despliegue típico como cluster Kafka Connect con conectores Debezium.&lt;/p>
&lt;p>Ejemplo de configuración Debezium para PostgreSQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-orders-connector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.connector.postgresql.PostgresConnector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tasks.max&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.hostname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres.prod.internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;5432&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.password&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;${secret:postgres-creds}&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.dbname&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;database.server.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce-prod&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;table.include.list&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public.orders,public.users,public.products&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;publication.autocreate.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;filtered&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;slot.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium_slot&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;plugin.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;pgoutput&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;topic.prefix&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ecommerce&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.confluent.connect.avro.AvroConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.confluent.connect.avro.AvroConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter.schema.registry.url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http://schema-registry:8081&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter.schema.registry.url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http://schema-registry:8081&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Esto produce, por cada commit en la base de datos, un evento Avro al topic correspondiente (&lt;code>ecommerce.public.orders&lt;/code>, &lt;code>ecommerce.public.users&lt;/code>, etc.) con el cambio: tipo (INSERT/UPDATE/DELETE), valores antes y después, timestamp del commit, posición en el WAL.&lt;/p>
&lt;p>&lt;strong>Alternativa más simple para 2026&lt;/strong>: &lt;a href="https://risingwave.com/">RisingWave&lt;/a> puede leerse el WAL de Postgres &lt;strong>directamente, sin Debezium ni Kafka Connect intermedio&lt;/strong>. Cuando el caso es solo CDC sin más fuentes, es operacionalmente más simple. Para arquitecturas con múltiples fuentes (CDC + APIs + scrapers + logs), Debezium sigue siendo la pieza estándar.&lt;/p>
&lt;h2 id="capa-2--kafka-como-transporte-y-persistencia">Capa 2 — Kafka como transporte y persistencia&lt;/h2>
&lt;p>El cluster Kafka es donde aterrizan todos los eventos. Decisiones operativas clave:&lt;/p>
&lt;h3 id="topics-raw-vs-curated">Topics: raw vs curated&lt;/h3>
&lt;p>Convención que se ha establecido en 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>*.raw&lt;/code>&lt;/strong>: el evento crudo tal como llegó. CDC sin transformar, log de aplicación sin parsear.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.cleaned&lt;/code>&lt;/strong>: tras dedup, validación de schema, normalización de tipos.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.enriched&lt;/code>&lt;/strong>: tras añadir metadatos (geolocalización, identificadores cruzados, etc.).&lt;/li>
&lt;li>&lt;strong>&lt;code>*.embedded&lt;/code>&lt;/strong>: el evento con su vector embedding ya calculado.&lt;/li>
&lt;/ul>
&lt;p>Multi-stage topics permite &lt;strong>debug por capa&lt;/strong> y &lt;strong>reprocesamiento parcial&lt;/strong>: si cambias el embedding model, descartar &lt;code>*.embedded&lt;/code> y reconstruir desde &lt;code>*.enriched&lt;/code> cuesta horas; reconstruir desde &lt;code>*.raw&lt;/code> cuesta días.&lt;/p>
&lt;h3 id="schema-registry">Schema Registry&lt;/h3>
&lt;p>Sin &lt;strong>schema registry&lt;/strong>, los topics se rompen silenciosamente cuando alguien cambia el schema en origen. &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">&lt;strong>Confluent Schema Registry&lt;/strong>&lt;/a> o el OSS &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> son las opciones dominantes.&lt;/p>
&lt;p>Formatos comunes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Avro&lt;/strong>: schema versionado, evolution rules estrictas. El default histórico.&lt;/li>
&lt;li>&lt;strong>Protobuf&lt;/strong>: compatible con stacks gRPC, buena performance.&lt;/li>
&lt;li>&lt;strong>JSON Schema&lt;/strong>: textual, debuggable a ojo, menos eficiente.&lt;/li>
&lt;/ul>
&lt;p>Para RAG sobre Kafka recomendamos &lt;strong>Avro&lt;/strong> por defecto. Schema evolution es importante porque las tablas origen cambian con el tiempo, y un esquema sin versión rompe consumidores aguas abajo.&lt;/p>
&lt;h3 id="particiones-replicación-y-retención">Particiones, replicación y retención&lt;/h3>
&lt;p>Decisiones operativas para topics de RAG:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Particiones&lt;/strong>: típicamente 3-12. Más particiones = más paralelismo en consumer Flink, pero más overhead. La regla del pulgar: &lt;strong>particiones = pico esperado de eventos/s ÷ 1000&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Replication factor&lt;/strong>: 3 mínimo en producción. La replicación protege contra fallo de broker; con RAG el coste de perder un topic puede ser semanas de re-embedding.&lt;/li>
&lt;li>&lt;strong>Retención&lt;/strong>: para topics que alimentan RAG, &lt;strong>retención larga&lt;/strong> o &lt;strong>compactada por key&lt;/strong>. Si el documento &lt;code>doc-42&lt;/code> cambia 100 veces, compactación solo guarda el último estado por key, dejando un log más pequeño y reconstruible. Para datos que no se actualizan (logs históricos), retención por tiempo (90 días, 1 año).&lt;/li>
&lt;/ul>
&lt;h3 id="replicación-cross-cluster">Replicación cross-cluster&lt;/h3>
&lt;p>Para deployments multi-región o multi-cloud, &lt;strong>MirrorMaker 2&lt;/strong> o &lt;strong>&lt;a href="https://docs.confluent.io/platform/current/multi-dc-deployments/cluster-linking/index.html">Cluster Linking&lt;/a>&lt;/strong> (Confluent) replican topics entre clusters Kafka. El RAG puede consultar el cluster local sin tener que cruzar región.&lt;/p>
&lt;h2 id="capa-3--flink-como-procesador-streaming">Capa 3 — Flink como procesador streaming&lt;/h2>
&lt;p>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> es la pieza dominante de stream processing en 2026. Apache 2.0, distribución mature, ecosistema amplio. La alternativa principal es Kafka Streams (más simple, Java-only); RisingWave es la opción emergente para casos SQL puros.&lt;/p>
&lt;p>Lo que Flink añade a Kafka:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stateful streaming&lt;/strong>: agregaciones temporales, joins entre streams, sesiones.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: con checkpoint coordination.&lt;/li>
&lt;li>&lt;strong>Watermarks&lt;/strong>: handling correcto de eventos out-of-order.&lt;/li>
&lt;li>&lt;strong>UDFs en Python/Java&lt;/strong>: incluyendo llamadas a modelos LLM.&lt;/li>
&lt;/ul>
&lt;h3 id="flink-sql-la-pieza-más-operacional">Flink SQL: la pieza más operacional&lt;/h3>
&lt;p>Flink SQL es la pieza más usable de Flink para data engineers que no son streaming experts. Veamos un ejemplo realista de pipeline RAG:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- 1. Definir la fuente: topic Kafka con eventos CDC de documentos
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ENFORCED&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;connector&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;upsert-kafka&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;topic&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;ecommerce.public.documents&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;properties.bootstrap.servers&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;kafka:9092&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;key.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;avro-confluent&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;value.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;avro-confluent&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;value.fields-include&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;EXCEPT_KEY&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 2. Definir el sink: vector store via Kafka topic intermedio
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">ARRAY&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="nb">FLOAT&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ENFORCED&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;connector&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;upsert-kafka&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;topic&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;rag.documents.embedded&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;properties.bootstrap.servers&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;kafka:9092&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;key.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;json&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;value.format&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;json&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 3. UDF para chunking (definida en Python o Java)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- CREATE TEMPORARY FUNCTION chunk_text AS &amp;#39;com.example.ChunkingUDF&amp;#39;;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 4. Pipeline: chunkear, embedder, escribir al sink
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_idx&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">OPENAI_EMBEDDING&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;text-embedding-3-small&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">CURRENT_TIMESTAMP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CROSS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNNEST&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDINALITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunk&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_idx&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Lo que pasa aquí, línea a línea:&lt;/p>
&lt;ul>
&lt;li>La tabla &lt;code>documents_raw&lt;/code> lee el topic CDC en modo &lt;strong>upsert-kafka&lt;/strong> (cada nuevo evento por la misma key reemplaza el anterior). Esto refleja correctamente la semántica &amp;ldquo;esta es la última versión del doc 42&amp;rdquo;.&lt;/li>
&lt;li>La tabla &lt;code>documents_embedded&lt;/code> será el topic intermedio donde Flink escribe los chunks embedded.&lt;/li>
&lt;li>La UDF &lt;code>chunk_text&lt;/code> (definida en Python o Java) divide cada doc en chunks de 512 tokens con overlap de 64.&lt;/li>
&lt;li>La consulta &lt;code>INSERT INTO&lt;/code> se ejecuta continuamente: cada evento nuevo en &lt;code>documents_raw&lt;/code> se chunkea, cada chunk se embedea con &lt;code>OPENAI_EMBEDDING&lt;/code> (función built-in de Flink SQL en Confluent Cloud 2026), y se escribe al topic embedded.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> puede sustituirse por una función custom que llame a un modelo self-hosted (vLLM con un encoder), a SentenceTransformers, o a un servicio managed. La sintaxis es la misma; cambias el provider.&lt;/p>
&lt;h3 id="watermarks-y-late-events">Watermarks y late events&lt;/h3>
&lt;p>Para casos donde un evento puede llegar tarde (eg el WAL de Postgres se retrasa porque hubo un network blip), Flink permite definir &lt;strong>watermarks&lt;/strong>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_raw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">body&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STRING&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TIMESTAMP_LTZ&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">WATERMARK&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MINUTE&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(...)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Esto le dice a Flink &amp;ldquo;asume que ningún evento llega más de 5 minutos tarde respecto al timestamp del evento&amp;rdquo;. Para joins y agregaciones temporales, Flink usa el watermark para decidir cuándo &amp;ldquo;cerrar&amp;rdquo; una ventana.&lt;/p>
&lt;h2 id="capa-4--sinks-a-vector-stores">Capa 4 — Sinks a vector stores&lt;/h2>
&lt;p>El último paso es indexar los embeddings en un vector store. Tres patrones en 2026:&lt;/p>
&lt;h3 id="patrón-a--kafka-connect-sink-directo">Patrón A — Kafka Connect sink directo&lt;/h3>
&lt;p>Cada vector store tiene su connector oficial:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus&lt;/a>&lt;/strong>: sink connector oficial de Zilliz. Soporta named/unnamed dense/sparse vectors.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://github.com/qdrant/qdrant-kafka">Qdrant&lt;/a>&lt;/strong>: sink connector oficial. Soporta dense, sparse, multi-vector.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong>: no tiene connector dedicado, pero se usa el &lt;a href="https://www.confluent.io/hub/confluentinc/kafka-connect-jdbc">JDBC Sink Connector&lt;/a> con SQL custom.&lt;/li>
&lt;li>&lt;strong>Weaviate&lt;/strong>: connector community.&lt;/li>
&lt;li>&lt;strong>LanceDB&lt;/strong>: connector community.&lt;/li>
&lt;/ul>
&lt;p>Ejemplo de configuración Milvus sink:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;milvus-rag-embeddings-sink&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;config&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;connector.class&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;com.milvus.io.kafka.MilvusSinkConnector&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tasks.max&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;topics&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag.documents.embedded&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.host&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;milvus.prod.internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;19530&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.collection.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.collection.dim&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1536&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;milvus.collection.partition&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;key.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.storage.StringConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;org.apache.kafka.connect.json.JsonConverter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;value.converter.schemas.enable&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tres tasks en paralelo (&lt;code>tasks.max: 3&lt;/code>) consumen el topic embedded y escriben a la colección Milvus. La latencia desde &amp;ldquo;evento en Kafka&amp;rdquo; hasta &amp;ldquo;vector indexable en Milvus&amp;rdquo; es típicamente &lt;strong>&amp;lt;5 segundos&lt;/strong>.&lt;/p>
&lt;h3 id="patrón-b--pgvector-con-cdc-pipe-directo">Patrón B — pgvector con CDC pipe directo&lt;/h3>
&lt;p>Para equipos que ya viven en PostgreSQL, &lt;strong>pgvector&lt;/strong> es la opción de menor fricción. Patrón: el mismo cluster Postgres origen tiene una segunda DB para embeddings con extensión pgvector activada; el pipeline Flink escribe directamente vía JDBC.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- En el cluster Postgres con pgvector activado
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_embeddings&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1536&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedded_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TIMESTAMP&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_embeddings&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">USING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">hnsw&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector_cosine_ops&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ef_construction&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ventajas: tu mismo DBA opera todo, transactionality cross-tables, joins con metadatos relacionales triviales. Limitación: a &amp;gt;10M vectores, el rendimiento de pgvector empieza a ceder respecto a sistemas dedicados.&lt;/p>
&lt;h3 id="patrón-c--confluent-tableflow--iceberg--vector-search-flink-sql">Patrón C — Confluent Tableflow → Iceberg + vector search Flink SQL&lt;/h3>
&lt;p>Esta es la novedad 2026 que cambia la mecánica. &lt;a href="https://www.confluent.io/product/tableflow/">Confluent Tableflow&lt;/a> materializa &lt;strong>automáticamente&lt;/strong> topics Kafka como tablas &lt;strong>Apache Iceberg&lt;/strong> o &lt;strong>Delta Lake&lt;/strong>. Características:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sin pipeline ETL&lt;/strong>: no escribes Flink/Spark jobs para mover Kafka a tabla. Lo hace Tableflow.&lt;/li>
&lt;li>&lt;strong>Schema evolution automática&lt;/strong>: cambios en el schema del topic se reflejan en la tabla.&lt;/li>
&lt;li>&lt;strong>Catálogo unificado&lt;/strong>: la tabla aparece en Glue, Unity Catalog, Snowflake, Databricks. Cualquier motor analítico la consulta sin copiar datos.&lt;/li>
&lt;li>&lt;strong>CDC nativo&lt;/strong>: maneja inserts, updates, deletes correctamente.&lt;/li>
&lt;li>&lt;strong>30-50% menos TCO&lt;/strong> según las cifras que Confluent publica vs pipelines tradicionales.&lt;/li>
&lt;/ul>
&lt;p>Y desde 2026, Tableflow + Flink SQL ofrecen &lt;strong>vector search nativo integrado con Cosmos DB y Amazon S3 Vectors&lt;/strong>. La consulta RAG se puede hacer directamente en Flink SQL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">chunk_text&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">category&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents_embedded&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VECTOR_SEARCH&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">OPENAI_EMBEDDING&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;query del usuario&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;text-embedding-3-small&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">top_k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">VECTOR_SEARCH_SCORE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Esto unifica capas que antes eran separadas (vector store + analytics). Para muchos casos, &lt;strong>elimina&lt;/strong> la necesidad de mantener un vector store dedicado.&lt;/p>
&lt;h2 id="el-mcp-server-oficial-de-confluent">El MCP server oficial de Confluent&lt;/h2>
&lt;p>Una pieza añadida en 2026 que merece mención: Confluent ha publicado &lt;strong>un MCP server oficial&lt;/strong> que expone Kafka, Flink y Tableflow como tools accesibles a agentes IA vía MCP. Cualquier MCP client (Claude Desktop, Cursor, agentes propios) puede:&lt;/p>
&lt;ul>
&lt;li>Listar topics, leer mensajes recientes, publicar a topics.&lt;/li>
&lt;li>Ejecutar queries Flink SQL en lenguaje natural (&amp;ldquo;dame las órdenes de las últimas 24 horas con valor &amp;gt; 1000€&amp;rdquo;).&lt;/li>
&lt;li>Consultar tablas Tableflow Iceberg.&lt;/li>
&lt;li>Gestionar conectores Kafka Connect.&lt;/li>
&lt;/ul>
&lt;p>Esto cierra el círculo: tu agente IA, además de &lt;strong>leer datos&lt;/strong> del datalake vía RAG (con vector search), puede &lt;strong>escribir datos&lt;/strong> al log (vía MCP) y disparar transformaciones (vía Flink SQL en natural language). Es el punto de fusión más profundo entre LLM ops y data ops del año.&lt;/p>
&lt;p>Conexión con la serie anterior: este MCP server emite traces con las OpenTelemetry GenAI MCP semantic conventions que cubrimos en el &lt;a href="https://blog.lo0.es/posts/mcp-observability-otel/">post de MCP observability&lt;/a>. Los spans aparecen en Langfuse, Phoenix o tu OTel backend con la cardinalidad correcta. Cero código de instrumentación.&lt;/p>
&lt;h2 id="vector-stores-comparativa-2026">Vector stores: comparativa 2026&lt;/h2>
&lt;p>Las cinco opciones dominantes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vector store&lt;/th>
&lt;th>Licencia&lt;/th>
&lt;th>Operación&lt;/th>
&lt;th>Cuándo encaja&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>pgvector&lt;/strong>&lt;/td>
&lt;td>Postgres ext, OSS&lt;/td>
&lt;td>Tu DBA&lt;/td>
&lt;td>&amp;lt;10M vectores, equipo Postgres-heavy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Qdrant&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Self-host o managed&lt;/td>
&lt;td>Mid-scale, foco performance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Milvus&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Self-host o Zilliz Cloud&lt;/td>
&lt;td>Large-scale, foco escalabilidad&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Weaviate&lt;/strong>&lt;/td>
&lt;td>BSD-3&lt;/td>
&lt;td>Self-host o managed&lt;/td>
&lt;td>Hybrid search nativo, semantic rich&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LanceDB&lt;/strong>&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Embedded o serverless&lt;/td>
&lt;td>Small-medium, simplicidad&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La selección depende de:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Escala&lt;/strong>: pgvector se queda corto &amp;gt;10M vectores. Milvus y Qdrant escalan a billones.&lt;/li>
&lt;li>&lt;strong>Hybrid search&lt;/strong>: Weaviate trae lexical + vector nativo. Otros lo soportan pero menos integrado.&lt;/li>
&lt;li>&lt;strong>Operación&lt;/strong>: pgvector si ya tienes Postgres operado. Qdrant si quieres simplicidad. Milvus si necesitas máxima escala.&lt;/li>
&lt;li>&lt;strong>Cloud managed&lt;/strong>: Zilliz Cloud para Milvus, Qdrant Cloud para Qdrant, Pinecone si quieres SaaS puro (sin OSS detrás).&lt;/li>
&lt;/ul>
&lt;h2 id="freshness-vs-accuracy-el-trade-off-operativo">Freshness vs accuracy: el trade-off operativo&lt;/h2>
&lt;p>Una decisión crítica que cualquier sistema RAG sobre Kafka debe responder: &lt;strong>¿cuándo se considera que un nuevo documento está &amp;ldquo;live&amp;rdquo; en el índice?&lt;/strong>&lt;/p>
&lt;p>Tres opciones:&lt;/p>
&lt;p>&lt;strong>Streaming síncrono&lt;/strong>: el evento llega a Kafka, Flink lo embedea, el sink lo escribe al vector store, y solo entonces se considera live. &lt;strong>Latencia típica: 1-5 segundos&lt;/strong>. La mejor freshness. Pero si el embedding model falla o el vector store es lento, los eventos se acumulan en el topic.&lt;/p>
&lt;p>&lt;strong>Streaming asíncrono con baseline&lt;/strong>: el evento se considera live inmediatamente; un proceso de fondo lo embedea cuando puede. Mientras tanto, queries que pidan ese documento no lo encuentran. &lt;strong>Latencia típica: 5-60 segundos&lt;/strong>. Aceptable para la mayoría de aplicaciones.&lt;/p>
&lt;p>&lt;strong>Batch micro&lt;/strong>: se procesa en mini-batches cada 1-5 minutos. Menos eficiente que streaming continuo pero más estable bajo carga variable. &lt;strong>Latencia: 1-5 minutos&lt;/strong>.&lt;/p>
&lt;p>La decisión depende del SLA del producto. Para chatbots de soporte al cliente, 5-60 segundos es aceptable. Para sistemas que reaccionan a eventos críticos (precios financieros, alarmas), streaming síncrono es necesario.&lt;/p>
&lt;h2 id="schema-evolution-y-reembedding">Schema evolution y reembedding&lt;/h2>
&lt;p>Cuando el embedding model cambia (cambias de &lt;code>text-embedding-3-small&lt;/code> a &lt;code>text-embedding-3-large&lt;/code>, o pasas de OpenAI a Cohere), los vectores existentes en el índice son &lt;strong>incompatibles&lt;/strong>: dimensiones distintas, espacios semánticos distintos. La distancia entre un vector viejo y uno nuevo no significa nada.&lt;/p>
&lt;p>Patrón estándar para handle de esto: &lt;strong>dual-index&lt;/strong> durante la migración.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>T0&lt;/strong>: índice activo es V1 (embedding model A).&lt;/li>
&lt;li>&lt;strong>T1&lt;/strong>: empieza pipeline paralelo que escribe a un índice V2 (embedding model B), consumiendo el topic desde offset 0 (reprocesar todo el log).&lt;/li>
&lt;li>&lt;strong>T2&lt;/strong>: V2 ha caught-up al presente.&lt;/li>
&lt;li>&lt;strong>T3&lt;/strong>: cambias el retriever para que use V2.&lt;/li>
&lt;li>&lt;strong>T4&lt;/strong>: una semana después, descartas V1.&lt;/li>
&lt;/ol>
&lt;p>El log de Kafka hace este patrón factible porque es &lt;strong>inmutable y reproducible&lt;/strong>. Sin el log, este patrón se vuelve un proyecto de migración de datos de semanas.&lt;/p>
&lt;h2 id="trampas-operativas">Trampas operativas&lt;/h2>
&lt;h3 id="topics-sin-retención-adecuada">Topics sin retención adecuada&lt;/h3>
&lt;p>Configurar topics con retención de 7 días pensando &amp;ldquo;ya tengo el vector store&amp;rdquo; lleva a perder la capacidad de reconstruir si el vector store falla. &lt;strong>Retención larga (90+ días) o compactada por key&lt;/strong> para topics que alimentan RAG.&lt;/p>
&lt;h3 id="cdc-pesado-en-cargas-pico">CDC pesado en cargas pico&lt;/h3>
&lt;p>Debezium leyendo el WAL en horas pico puede impactar performance de la base de datos origen. &lt;strong>Replica de lectura dedicada&lt;/strong> para Debezium, no la primaria de producción. O usar &lt;strong>logical replication&lt;/strong> específica solo para las tablas necesarias.&lt;/p>
&lt;h3 id="embedding-cost-run-away">Embedding cost run-away&lt;/h3>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> en cada evento de un topic con millones de mensajes/día son &lt;strong>miles de USD/mes&lt;/strong>. Estrategias: filtrar antes de embedder (solo embedder lo que aporta valor); deduplicar por hash de contenido; usar embedding models open-source self-hosted (BGE, E5, GTE) cuando el coste cloud sea prohibitivo.&lt;/p>
&lt;h3 id="reembedding-lento-por-throughput-limitado">Reembedding lento por throughput limitado&lt;/h3>
&lt;p>Recalcular 10M embeddings con OpenAI API a 3000 req/min tarda &lt;strong>55 horas&lt;/strong>. Si esperas a un incidente para reembeder, son dos días sin servicio. &lt;strong>Embedding throughput es un capacity planning explícito&lt;/strong>; reservar capacity o tener un job offline pre-arrancable.&lt;/p>
&lt;h3 id="schema-breaks-aguas-abajo">Schema breaks aguas abajo&lt;/h3>
&lt;p>Un cambio en el schema del topic raw rompe Flink jobs aguas abajo. &lt;strong>Schema Registry con compatibility BACKWARD obligatoria&lt;/strong>; nunca ALLOW_ALL. Y test schema evolution en CI.&lt;/p>
&lt;h3 id="vector-store-sin-backup">Vector store sin backup&lt;/h3>
&lt;p>Tu vector store tiene 50M vectores. Es la única copia (los topics expiraron). Un fallo lo borra. &lt;strong>Vector stores deben ser backed up&lt;/strong> igual que cualquier persistencia primaria. Para Milvus/Qdrant: snapshots periódicos. Para pgvector: el propio pg_dump.&lt;/p>
&lt;h3 id="multi-region-sin-replicación-cross-cluster">Multi-region sin replicación cross-cluster&lt;/h3>
&lt;p>Tu RAG sirve a usuarios en US y EU. El vector store está en US-east. Latencia desde EU = 100ms+ por query. &lt;strong>MirrorMaker o Cluster Linking&lt;/strong> para replicar topics y vector stores en ambas regiones.&lt;/p>
&lt;h2 id="lo-que-no-hemos-cubierto">Lo que no hemos cubierto&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Hybrid search en producción&lt;/strong>: combinar BM25/lexical + vector + reranker. Tema de su propio post.&lt;/li>
&lt;li>&lt;strong>Multimodal RAG&lt;/strong>: indexar imágenes, audio, vídeo además de texto. Embeddings multimodales (CLIP, Imagebind), arquitectura específica.&lt;/li>
&lt;li>&lt;strong>GraphRAG&lt;/strong>: usar conocimiento estructurado (knowledge graphs) además de vector retrieval. Microsoft GraphRAG, LlamaIndex KnowledgeGraphQueryEngine.&lt;/li>
&lt;li>&lt;strong>RAG con ACL multi-tenant&lt;/strong>: filtrar por permisos en runtime. Patrón con metadatos en el vector store + filtros server-side.&lt;/li>
&lt;li>&lt;strong>Query rewriting con LLM&lt;/strong>: usar un primer LLM para expandir la query antes del retrieval (HyDE, multi-query, step-back prompting).&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;p>Kafka y stream processing:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kafka.apache.org/">Apache Kafka&lt;/a> y &lt;a href="https://debezium.io/">Debezium&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> y &lt;a href="https://www.apicur.io/registry/">Apicurio Registry&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> y &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/sql/overview/">Flink SQL docs&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://risingwave.com/">RisingWave&lt;/a> — alternativa SQL streaming con embedding built-in.&lt;/li>
&lt;/ul>
&lt;p>Vector store connectors:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus Sink Connector (Zilliz, GitHub)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://milvus.io/docs/kafka-connect-milvus.md">Connect Apache Kafka with Milvus (docs)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/qdrant/qdrant-kafka">Qdrant Kafka Sink (GitHub)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://callsphere.ai/blog/vector-database-benchmarks-2026-pgvector-qdrant-weaviate-milvus-lancedb">Vector Database Benchmarks 2026&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/streaming-vector-databases-platform-comparison">Streaming to Vector Databases (Streamkap)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Tableflow y arquitectura 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.confluent.io/product/tableflow/">Tableflow — Confluent&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/tableflow-ga-kafka-snowflake-iceberg/">Tableflow GA: Real-Time Kafka to Iceberg (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/tableflow-delta-lake-databricks-unity-catalog-ga/">Tableflow + Databricks Unity Catalog (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.confluent.io/blog/data-lake-governance-tableflow/">Better-Governed Data Lake Architectures with Tableflow (Confluent Blog)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kai-waehner.de/blog/2025/12/10/top-trends-for-data-streaming-with-apache-kafka-and-flink-in-2026/">Top Trends for Data Streaming with Kafka and Flink in 2026 (Kai Waehner)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>RAG streaming:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://risingwave.com/blog/rag-architecture-2026/">RAG Architecture in 2026: How to Keep Retrieval Actually Fresh (RisingWave)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://streamkap.com/resources-and-guides/streaming-to-vector-databases">Streaming CDC Events to Vector Databases (Streamkap)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kai-waehner.de/blog/2023/11/08/apache-kafka-flink-vector-database-llm-real-time-genai/">Apache Kafka + Vector Database + LLM = Real-Time GenAI (Kai Waehner)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/pdf/2508.05662">From Static to Dynamic: A Streaming RAG Approach (arxiv 2508.05662)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://developer.confluent.io/confluent-tutorials/gen-ai-vector-embedding/flinksql/">How to generate vector embeddings for RAG with Flink SQL (Confluent Developer)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://dasroot.net/posts/2026/03/event-driven-architectures-ai-pipelines-kafka-flink/">Event-Driven Architectures for AI Pipelines (dasroot)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Post anterior: &lt;a href="https://blog.lo0.es/posts/mlops-llms-panorama-2026/">MLOps específico para LLMs en 2026: el panorama&lt;/a>.&lt;/li>
&lt;li>Serie post-tracing: &lt;a href="https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/posts/guardrails-safety-llm/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/posts/mcp-observability-otel/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/posts/ebpf-on-device-inference-drift/">eBPF + drift&lt;/a>.&lt;/li>
&lt;li>Serie eBPF: &lt;a href="https://blog.lo0.es/posts/ebpf-cilium-tcp-ip-bypass/">eBPF de cero a Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/posts/tetragon-runtime-security/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/posts/hubble-observabilidad-ebpf/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/posts/agentsight-tracing-llm/">AgentSight&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>