<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Microservicios on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/microservicios/</link><description>Recent content in Microservicios on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Thu, 21 May 2026 06:50:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/microservicios/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>