<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Debezium on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/debezium/</link><description>Recent content in Debezium on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 04 Jun 2026 11:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/debezium/index.xml" rel="self" type="application/rss+xml"/><item><title>Debezium and CDC: the notary who hears changes before anyone asks</title><link>https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/</link><pubDate>Thu, 04 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Change Data Capture (CDC) with Debezium listens to the PostgreSQL Write-Ahead Log and turns every INSERT, UPDATE and DELETE into a structured Kafka event. Unlike traditional polling (&lt;code>SELECT ... WHERE updated_at &amp;gt; ?&lt;/code>), it detects deletes, has latency in the tens of milliseconds and adds no extra load to the database. In RAG pipelines, this means that when a document is deleted from Postgres, the Qdrant chunks disappear too, automatically and in real time. The supporting infrastructure is modest: the connector uses 2-4 cores and 4-8 GB RAM to process thousands of events per second.&lt;/p>
&lt;hr>
&lt;h2 id="the-master-analogy-the-land-registry-notary">The master analogy: the land registry notary&lt;/h2>
&lt;p>Picture the land registry. Every time a flat is sold, mortgaged, or a mortgage is cancelled, the registrar records the operation in the &lt;strong>registry ledger&lt;/strong>, a chronological and immutable journal. If you want to know what has changed in the registry, you have two options.&lt;/p>
&lt;p>&lt;strong>Option A (polling):&lt;/strong> you send someone every 5 minutes with a list of properties to ask &amp;ldquo;has anything changed?&amp;rdquo;. Problems: if a title was cancelled (DELETE), the property no longer exists by the time your envoy arrives, and there is no trace of it. If 20 different departments are doing the same thing, that is 20 people pestering the registrar every 5 minutes. And the minimum latency is the interval: 5 minutes.&lt;/p>
&lt;p>&lt;strong>Option B (Debezium):&lt;/strong> you hire a &lt;strong>notary&lt;/strong> who sits right at the registrar&amp;rsquo;s desk. Every time the registrar signs an operation into the ledger, the notary notes it down immediately and notifies whoever needs to know. Title cancellations included, because the notary sees them as clearly as any other operation: he was there when it was signed.&lt;/p>
&lt;p>In this analogy:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>registry ledger&lt;/strong> is the PostgreSQL &lt;strong>WAL&lt;/strong> (Write-Ahead Log).&lt;/li>
&lt;li>The &lt;strong>notary&lt;/strong> is the &lt;strong>Debezium connector&lt;/strong>.&lt;/li>
&lt;li>The notary&amp;rsquo;s &lt;strong>bookmark&lt;/strong>, which guarantees he loses no page even if he steps out for a moment, is the &lt;strong>logical replication slot&lt;/strong>.&lt;/li>
&lt;li>The &lt;strong>messenger&lt;/strong> who carries the notifications to interested parties is &lt;strong>Kafka&lt;/strong> (or Redpanda, or NATS JetStream).&lt;/li>
&lt;/ul>
&lt;p>We will pick this thread up in every section. When a technical detail is unclear, go back to the image of the notary.&lt;/p>
&lt;hr>
&lt;h2 id="1-the-problem-cdc-solves">1. The problem CDC solves&lt;/h2>
&lt;p>The most common synchronisation pattern between services sharing PostgreSQL is periodic polling:&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">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">updated_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&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">updated_at&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="err">$&lt;/span>&lt;span class="mi">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="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">updated_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">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1000&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>This pattern has three structural problems.&lt;/p>
&lt;p>&lt;strong>DELETEs are invisible.&lt;/strong> When you delete a row, &lt;code>updated_at&lt;/code> is not updated: the row is gone. The next time the poller queries, the row does not exist and there is no way to know it ever did. In a RAG pipeline, this means &lt;strong>orphan chunks in Qdrant&lt;/strong>: the document no longer exists in Postgres, but its vectors keep polluting search results.&lt;/p>
&lt;p>&lt;strong>The minimum latency is the interval.&lt;/strong> If the poller runs every 5 seconds, average latency is 2.5 seconds. For near-real-time synchronisation (dashboards, alerts, RAG with frequently changing documents), that is too much.&lt;/p>
&lt;p>&lt;strong>Load scales with the number of consumers.&lt;/strong> If 10 services poll the same table every 5 seconds, that is 10 × 12 = 120 queries/minute producing no useful work; they only check whether there is anything new. On large tables with complex indexes, that is real load on the database.&lt;/p>
&lt;p>CDC inverts the model: &lt;strong>the database notifies, the consumers listen&lt;/strong>. Zero polling, zero extra load, DELETEs included, latency in the tens of milliseconds.&lt;/p>
&lt;hr>
&lt;h2 id="2-what-the-postgresql-wal-is">2. What the PostgreSQL WAL is&lt;/h2>
&lt;h3 id="the-operations-journal">The operations journal&lt;/h3>
&lt;p>The Write-Ahead Log (WAL) is the chronological, immutable record of every operation Postgres performs. Before modifying any data page on disk, Postgres writes the operation to the WAL. That sequence, log first then data, is what guarantees durability (the D in ACID) and enables crash recovery: if Postgres dies mid-transaction, on restart it replays the WAL to return the database to a consistent state.&lt;/p>
&lt;p>The WAL is the &lt;strong>registry ledger&lt;/strong> of our analogy: chronological, immutable, complete.&lt;/p>
&lt;h3 id="physical-vs-logical-replication">Physical vs logical replication&lt;/h3>
&lt;p>PostgreSQL supports two WAL-based replication modes:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Physical replication:&lt;/strong> it replicates disk blocks verbatim. The standby receives the same bytes as the primary. It serves high availability and failover, but the target must be an exact copy of Postgres; you cannot send the changes to an external application.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Logical replication:&lt;/strong> instead of disk blocks, it replicates &lt;strong>semantic operations&lt;/strong>: &amp;ldquo;the row with id=42 was inserted into table &lt;code>documents&lt;/code> with these values&amp;rdquo;. The target can be anything that understands the protocol: another Postgres, Debezium, or any custom consumer.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>CDC uses logical replication. It is what lets Debezium understand &amp;ldquo;what changed and in which table&amp;rdquo; rather than &amp;ldquo;which disk block changed at which offset&amp;rdquo;.&lt;/p>
&lt;h3 id="the-replication-slot-the-notarys-bookmark">The replication slot: the notary&amp;rsquo;s bookmark&lt;/h3>
&lt;p>A &lt;strong>logical replication slot&lt;/strong> is a persistent cursor in the WAL. Postgres keeps a record of how far into the WAL each slot has consumed. While a slot exists, Postgres &lt;strong>guarantees it will not discard the WAL segments the slot has not yet read&lt;/strong>.&lt;/p>
&lt;p>This is exactly the notary&amp;rsquo;s bookmark: even if the notary goes out for lunch, the ledger stays open at the last page he read. When he comes back, he carries on from exactly where he left off, having lost nothing.&lt;/p>
&lt;p>The risk is the inverse: &lt;strong>if the notary never comes back&lt;/strong>, the bookmark stops the registrar from archiving the old pages. If the Debezium connector goes down and does not recover for hours, the WAL grows indefinitely on disk until the slot is deleted manually or the consumer starts consuming again. This is called &lt;strong>WAL disk blowup&lt;/strong> and is the most important operational risk of Debezium.&lt;/p>
&lt;p>Mandatory monitoring:&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">slot_name&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">confirmed_flush_lsn&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">pg_current_wal_lsn&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">pg_wal_lsn_diff&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pg_current_wal_lsn&lt;/span>&lt;span class="p">(),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">confirmed_flush_lsn&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">lag_bytes&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">pg_replication_slots&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">slot_type&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;logical&amp;#39;&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;h3 id="the-pgoutput-plugin">The pgoutput plugin&lt;/h3>
&lt;p>The WAL stores operations in an internal binary format. For Debezium to understand them, Postgres needs to &lt;strong>decode&lt;/strong> them into a readable format. The &lt;code>pgoutput&lt;/code> decoding plugin, included in the Postgres core since version 10, does exactly that: it translates the binary WAL events into messages with the before/after structure of each row.&lt;/p>
&lt;p>Debezium uses &lt;code>pgoutput&lt;/code> by default. It requires no external extensions (unlike the &lt;code>wal2json&lt;/code> plugin that was popular before Postgres 10).&lt;/p>
&lt;hr>
&lt;h2 id="3-debezium-architecture">3. Debezium architecture&lt;/h2>
&lt;h3 id="the-connector-as-a-kafka-connect-plugin">The connector as a Kafka Connect plugin&lt;/h3>
&lt;p>Debezium is not a standalone service; it is a plugin for the &lt;strong>Kafka Connect&lt;/strong> framework. Kafka Connect manages the connector&amp;rsquo;s lifecycle (start, stop, reconnection, offset tracking) and provides the parallelism and fault tolerance infrastructure.&lt;/p>
&lt;p>The connector talks to Postgres through the logical replication protocol (not over JDBC), using the credentials of a user with the &lt;code>REPLICATION&lt;/code> role.&lt;/p>
&lt;pre tabindex="0">&lt;code>PostgreSQL (WAL + pgoutput)
│
│ logical replication protocol
▼
Debezium Connector (Kafka Connect worker)
│
│ Kafka Producer API
▼
Kafka topic: rag.public.documents
│
▼
Consumer (Qdrant sync, audit log, fine-tuning pipeline...)
&lt;/code>&lt;/pre>&lt;h3 id="structure-of-a-debezium-event">Structure of a Debezium event&lt;/h3>
&lt;p>Each change in the table becomes a JSON message with this structure:&lt;/p>
&lt;p>&lt;strong>INSERT (&lt;code>&amp;quot;op&amp;quot;: &amp;quot;c&amp;quot;&lt;/code> — create):&lt;/strong>&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;before&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&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;after&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;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&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;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Contrato de arrendamiento...&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;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&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;updated_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000000&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="nt">&amp;#34;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;c&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;source&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;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2.7.0.Final&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;connector&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgresql&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;db&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rag_db&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;schema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;public&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;table&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;lsn&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">29823948&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;txId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1047&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;ts_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000123&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;strong>DELETE (&lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code>):&lt;/strong>&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;before&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;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&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;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Contrato de arrendamiento...&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;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&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;updated_at&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934000000&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="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&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;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;d&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;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;lsn&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">29824102&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;txId&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1051&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;ts_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1748934060200&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;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>before&lt;/code> field holds the row&amp;rsquo;s previous state, available because Postgres can be configured with &lt;strong>REPLICA IDENTITY FULL&lt;/strong> to include the complete row in the WAL on delete or update. Without that setting, &lt;code>before&lt;/code> contains only the primary key.&lt;/p>
&lt;p>&lt;strong>This is the key for the RAG pipeline&lt;/strong>: the DELETE event carries the document &lt;code>id&lt;/code>. The consumer uses it to delete every associated chunk in Qdrant with a &lt;code>doc_id = 42&lt;/code> filter. Without CDC, those chunks would never have been deleted.&lt;/p>
&lt;h3 id="initial-snapshot">Initial snapshot&lt;/h3>
&lt;p>When the connector starts for the first time (or after a reset), it cannot begin consuming the WAL from &amp;ldquo;the beginning of time&amp;rdquo;, only from the moment the slot was created. How does it guarantee the consistency of the initial state?&lt;/p>
&lt;p>Through a &lt;strong>transactional snapshot&lt;/strong>: the connector opens a transaction in &lt;code>REPEATABLE READ&lt;/code> mode, exports the snapshot ID (&lt;code>pg_export_snapshot()&lt;/code>), and does a full &lt;code>SELECT&lt;/code> of the configured tables inside that transaction. It then starts consuming the WAL from the snapshot&amp;rsquo;s LSN. That way there is no gap: the snapshot covers the state up to an instant, and the WAL covers from that instant onwards.&lt;/p>
&lt;h3 id="smt-transformations-single-message-transforms">SMT transformations (Single Message Transforms)&lt;/h3>
&lt;p>Before emitting the event to the Kafka topic, the connector can apply inline transformations called &lt;strong>SMT&lt;/strong>. Common use cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Filtering sensitive columns&lt;/strong> (&lt;code>ReplaceField&lt;/code> with &lt;code>blacklist&lt;/code>): strip &lt;code>password_hash&lt;/code>, &lt;code>phone_number&lt;/code> before they reach the topic.&lt;/li>
&lt;li>&lt;strong>Adding metadata&lt;/strong> (&lt;code>InsertField&lt;/code>): enrich the event with a &lt;code>tenant_id&lt;/code> extracted from the original HTTP header (if it is in the row).&lt;/li>
&lt;li>&lt;strong>Conditional routing&lt;/strong> (&lt;code>Filter&lt;/code>): drop events from rows with &lt;code>status = 'draft'&lt;/code> before emitting them.&lt;/li>
&lt;/ul>
&lt;p>SMTs are pure configuration, requiring no code, and they are applied inside the connector process with no perceptible extra latency.&lt;/p>
&lt;hr>
&lt;h2 id="4-debezium-vs-the-outbox-pattern">4. Debezium vs the Outbox pattern&lt;/h2>
&lt;p>The &lt;strong>Outbox pattern&lt;/strong> is the most common alternative to pure CDC. Instead of emitting events directly to Kafka, the application writes to an &lt;code>outbox&lt;/code> table in Postgres within the same transaction that modifies the data. A separate worker reads that table and publishes the events.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Debezium (pure CDC)&lt;/th>
&lt;th>Outbox pattern&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Event latency&lt;/strong>&lt;/td>
&lt;td>~50-200 ms from commit&lt;/td>
&lt;td>Depends on the worker interval (typically 1-5 s)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Consistency&lt;/strong>&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DELETE detection&lt;/strong>&lt;/td>
&lt;td>Native (the DELETE event includes &lt;code>before&lt;/code>)&lt;/td>
&lt;td>Only if the app writes to the outbox on delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Setup complexity&lt;/strong>&lt;/td>
&lt;td>High (Kafka Connect, replication slot, permissions)&lt;/td>
&lt;td>Low (an extra table plus a simple worker)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Infrastructure dependency&lt;/strong>&lt;/td>
&lt;td>Requires Kafka/Redpanda/NATS JetStream&lt;/td>
&lt;td>Postgres plus a worker; Kafka optional&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WAL disk blowup risk&lt;/strong>&lt;/td>
&lt;td>Yes, if the slot stops consuming&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Schema visibility&lt;/strong>&lt;/td>
&lt;td>Reads the table&amp;rsquo;s real schema&lt;/td>
&lt;td>The event schema is defined by the app&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Schema migration&lt;/strong>&lt;/td>
&lt;td>Needs care (events reflect DDL changes)&lt;/td>
&lt;td>More flexible (the event is whatever the app puts there)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to use it&lt;/strong>&lt;/td>
&lt;td>When you need DELETEs, low latency, or you cannot modify the app&lt;/td>
&lt;td>When the app controls the event domain and infrastructure is limited&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Rule of thumb:&lt;/strong> if you control the application code and do not need native DELETEs, the Outbox is simpler. If you do not control the code (legacy database, third-party application) or DELETEs are critical (a RAG pipeline with document deletion), Debezium is the right choice.&lt;/p>
&lt;hr>
&lt;h2 id="5-the-maths">5. The maths&lt;/h2>
&lt;h3 id="throughput">Throughput&lt;/h3>
&lt;p>Debezium on a connector with 4 workers can process between &lt;strong>10,000 and 50,000 events/second&lt;/strong> on modest hardware (4 cores, 8 GB RAM). The real bottleneck is not the connector but the Kafka broker: with 3 brokers and suitable partitions, Kafka can comfortably sustain 500,000 messages/second with 1 KB messages (source: public Confluent benchmarks, 2023).&lt;/p>
&lt;p>For a typical RAG pipeline with 100 documents modified per minute:&lt;/p>
$$\text{events/s} = \frac{100}{60} \approx 1{.}7 \text{ events/s}$$
&lt;p>That is 0.0034% of the connector&amp;rsquo;s capacity. Debezium will not be the bottleneck in any realistic RAG scenario.&lt;/p>
&lt;h3 id="end-to-end-latency">End-to-end latency&lt;/h3>
&lt;p>The path from a commit in Postgres to an upsert in Qdrant has these stages:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Typical latency&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Commit in Postgres → WAL written&lt;/td>
&lt;td>&amp;lt; 1 ms (synchronous with the commit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>WAL written → Debezium reads it (WAL lag)&lt;/td>
&lt;td>10-50 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Debezium → Kafka produce (ack)&lt;/td>
&lt;td>5-20 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kafka → Consumer (poll interval)&lt;/td>
&lt;td>0-100 ms (configurable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consumer → Qdrant upsert/delete&lt;/td>
&lt;td>5-15 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>30-200 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With &lt;code>fetch.min.bytes=1&lt;/code> and &lt;code>fetch.max.wait.ms=10&lt;/code> on the consumer, the Kafka poll latency drops to ~10 ms. The realistic range for an optimised pipeline is &lt;strong>30-100 ms&lt;/strong>.&lt;/p>
&lt;h3 id="wal-disk-blowup-risk">WAL disk blowup risk&lt;/h3>
&lt;p>If the connector stops consuming, Postgres retains the WAL from the slot&amp;rsquo;s &lt;code>confirmed_flush_lsn&lt;/code> onwards. The retained volume grows linearly with time and the write rate:&lt;/p>
$$\text{retained WAL} = \text{write rate} \times \text{average WAL event size} \times \text{time without consuming}$$
&lt;p>Example with a moderate load (50,000 writes/hour, 500 bytes average per WAL event):&lt;/p>
$$50{,}000 \times 500 \text{ B} \times 1 \text{ h} = 25 \text{ MB/h}$$
&lt;p>With a high load (1,000,000 writes/hour):&lt;/p>
$$1{,}000{,}000 \times 500 \text{ B} \times 1 \text{ h} = 500 \text{ MB/h}$$
&lt;p>If the connector is down for 48 hours under high load: &lt;strong>24 GB of retained WAL&lt;/strong>. That can fill the disk and block Postgres completely.&lt;/p>
&lt;p>&lt;strong>Recommended alert:&lt;/strong> set an alert when &lt;code>lag_bytes &amp;gt; 1 GB&lt;/code> or when &lt;code>confirmed_flush_lsn&lt;/code> has not advanced for more than 15 minutes. See the monitoring query in section 2.&lt;/p>
&lt;hr>
&lt;h2 id="6-use-cases-in-llmops--rag">6. Use cases in LLMOps / RAG&lt;/h2>
&lt;h3 id="rag-synchronisation-with-real-deletion">RAG synchronisation with real deletion&lt;/h3>
&lt;p>This is the use case that most clearly justifies Debezium over polling. The flow:&lt;/p>
&lt;ol>
&lt;li>A user deletes document &lt;code>id=42&lt;/code> from the document management interface.&lt;/li>
&lt;li>Postgres runs &lt;code>DELETE FROM documents WHERE id = 42&lt;/code>.&lt;/li>
&lt;li>Debezium detects the DELETE in the WAL and emits the event with &lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code> and &lt;code>&amp;quot;before&amp;quot;: {&amp;quot;id&amp;quot;: 42, ...}&lt;/code>.&lt;/li>
&lt;li>The consumer receives the event and runs:
&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="n">qdrant_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&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">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;doc_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">42&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;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>Every chunk with &lt;code>doc_id=42&lt;/code> disappears from Qdrant in ~100 ms.&lt;/li>
&lt;/ol>
&lt;p>Without Debezium, those chunks would stay indefinitely, polluting retrieval results with fragments of documents that no longer exist in the source of truth.&lt;/p>
&lt;h3 id="event-sourcing-for-fine-tuning-datasets">Event sourcing for fine-tuning datasets&lt;/h3>
&lt;p>Every time a human annotator updates a row in the &lt;code>annotations&lt;/code> table (correcting an LLM output), Debezium emits the UPDATE with &lt;code>before&lt;/code> and &lt;code>after&lt;/code>. The consumer writes the pair (original_output, correction) into the dataset curation pipeline, with no need for the annotator to do anything beyond saving in the interface. The fine-tuning pipeline knows exactly what changed and when, with no polling and no risk of duplicates from overlapping time windows.&lt;/p>
&lt;h3 id="immutable-audit-log">Immutable audit log&lt;/h3>
&lt;p>WAL events are, by definition, the most faithful record of what happened in the database: they are the same data Postgres uses for crash recovery. Kafka with long retention (90 days, or size-based retention) serves as an &lt;strong>immutable audit log&lt;/strong> without modifying the application schema or adding triggers. This is particularly useful in regulated environments where traceability of data modifications is required.&lt;/p>
&lt;hr>
&lt;h2 id="7-architecture-diagram">7. Architecture diagram&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Debezium CDC architecture diagram: PostgreSQL, Debezium, Kafka, Consumer and Qdrant">
&lt;defs>
&lt;marker id="arrow-deb" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">&lt;path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>&lt;/marker>
&lt;/defs>
&lt;rect x="0" y="0" width="820" height="340" fill="#f8f9fa" rx="8"/>
&lt;rect x="20" y="60" width="170" height="220" fill="#dbeafe" stroke="#3b82f6" stroke-width="1.5" rx="8"/>
&lt;text x="105" y="84" font-family="monospace" font-size="13" font-weight="bold" fill="#1e40af" text-anchor="middle">PostgreSQL&lt;/text>
&lt;rect x="36" y="96" width="138" height="52" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="116" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">WAL&lt;/text>
&lt;text x="105" y="131" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(registry ledger)&lt;/text>
&lt;rect x="36" y="160" width="138" height="52" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="180" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">Replication slot&lt;/text>
&lt;text x="105" y="195" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(bookmark)&lt;/text>
&lt;rect x="36" y="224" width="138" height="40" fill="#bfdbfe" stroke="#3b82f6" stroke-width="1" rx="4"/>
&lt;text x="105" y="244" font-family="monospace" font-size="11" fill="#1e3a8a" text-anchor="middle">pgoutput&lt;/text>
&lt;text x="105" y="257" font-family="monospace" font-size="10" fill="#1e3a8a" text-anchor="middle">(decoding)&lt;/text>
&lt;line x1="190" y1="170" x2="248" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="219" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">logical&lt;/text>
&lt;text x="219" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">replication&lt;/text>
&lt;rect x="248" y="110" width="160" height="120" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5" rx="8"/>
&lt;text x="328" y="134" font-family="monospace" font-size="13" font-weight="bold" fill="#14532d" text-anchor="middle">Debezium&lt;/text>
&lt;text x="328" y="151" font-family="monospace" font-size="10" fill="#14532d" text-anchor="middle">Connector&lt;/text>
&lt;text x="328" y="168" font-family="monospace" font-size="10" fill="#166534" text-anchor="middle">(Kafka Connect)&lt;/text>
&lt;rect x="264" y="178" width="112" height="38" fill="#bbf7d0" stroke="#16a34a" stroke-width="1" rx="4"/>
&lt;text x="320" y="193" font-family="monospace" font-size="9" fill="#14532d" text-anchor="middle">op: c / u / d&lt;/text>
&lt;text x="320" y="207" font-family="monospace" font-size="9" fill="#14532d" text-anchor="middle">before + after + LSN&lt;/text>
&lt;line x1="408" y1="170" x2="464" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="436" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Kafka&lt;/text>
&lt;text x="436" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Producer&lt;/text>
&lt;rect x="464" y="110" width="150" height="120" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5" rx="8"/>
&lt;text x="539" y="134" font-family="monospace" font-size="13" font-weight="bold" fill="#713f12" text-anchor="middle">Kafka&lt;/text>
&lt;text x="539" y="151" font-family="monospace" font-size="10" fill="#713f12" text-anchor="middle">topic:&lt;/text>
&lt;text x="539" y="165" font-family="monospace" font-size="9" fill="#92400e" text-anchor="middle">rag.public.documents&lt;/text>
&lt;rect x="478" y="178" width="122" height="38" fill="#fef08a" stroke="#ca8a04" stroke-width="1" rx="4"/>
&lt;text x="539" y="193" font-family="monospace" font-size="9" fill="#713f12" text-anchor="middle">configurable retention&lt;/text>
&lt;text x="539" y="207" font-family="monospace" font-size="9" fill="#713f12" text-anchor="middle">at-least-once&lt;/text>
&lt;line x1="614" y1="170" x2="668" y2="170" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;text x="641" y="162" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Kafka&lt;/text>
&lt;text x="641" y="173" font-family="monospace" font-size="9" fill="#475569" text-anchor="middle">Consumer&lt;/text>
&lt;rect x="668" y="110" width="132" height="120" fill="#fce7f3" stroke="#db2777" stroke-width="1.5" rx="8"/>
&lt;text x="734" y="134" font-family="monospace" font-size="12" font-weight="bold" fill="#831843" text-anchor="middle">Consumer&lt;/text>
&lt;rect x="682" y="146" width="104" height="36" fill="#fbcfe8" stroke="#db2777" stroke-width="1" rx="4"/>
&lt;text x="734" y="162" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">INSERT/UPDATE&lt;/text>
&lt;text x="734" y="175" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">→ upsert Qdrant&lt;/text>
&lt;rect x="682" y="190" width="104" height="28" fill="#fbcfe8" stroke="#db2777" stroke-width="1" rx="4"/>
&lt;text x="734" y="204" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">DELETE&lt;/text>
&lt;text x="734" y="215" font-family="monospace" font-size="9" fill="#831843" text-anchor="middle">→ delete Qdrant&lt;/text>
&lt;line x1="734" y1="230" x2="734" y2="288" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-deb)"/>
&lt;rect x="668" y="288" width="132" height="40" fill="#ede9fe" stroke="#7c3aed" stroke-width="1.5" rx="8"/>
&lt;text x="734" y="313" font-family="monospace" font-size="12" font-weight="bold" fill="#4c1d95" text-anchor="middle">Qdrant&lt;/text>
&lt;rect x="20" y="295" width="620" height="28" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1" rx="4"/>
&lt;text x="330" y="313" font-family="monospace" font-size="10" fill="#475569" text-anchor="middle">Typical end-to-end latency: 30-100 ms from commit in Postgres to upsert/delete in Qdrant&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="8-minimum-configuration">8. Minimum configuration&lt;/h2>
&lt;h3 id="postgresql-enable-logical-replication">PostgreSQL: enable logical replication&lt;/h3>
&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">-- Requires restarting Postgres
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">wal_level&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">logical&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">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_replication_slots&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SYSTEM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_wal_senders&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&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>&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">-- Reload configuration (wal_level requires a full restart)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_reload_conf&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">-- Dedicated user for Debezium
&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">USER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">debezium&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICATION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">LOGIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">PASSWORD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;cambiar_esto&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="k">GRANT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">debezium&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">-- REPLICA IDENTITY FULL to get a complete &amp;#39;before&amp;#39; on DELETEs and UPDATEs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">REPLICA&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IDENTITY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FULL&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;h3 id="debezium-connector-kafka-connect-rest-api">Debezium connector (Kafka Connect REST API)&lt;/h3>
&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-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;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.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;cambiar_esto&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;rag_db&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;rag&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.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;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;slot.name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;debezium_rag&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;snapshot.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;initial&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;tombstones.on.delete&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;true&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;unwrap&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.unwrap.type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;io.debezium.transforms.ExtractNewRecordState&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.unwrap.drop.tombstones&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;false&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.unwrap.delete.handling.mode&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rewrite&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>Register the connector:&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">curl -X POST http://kafka-connect:8083/connectors &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d @connector-config.json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Check its status:&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">curl http://kafka-connect:8083/connectors/postgres-debezium/status
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="minimal-consumer-in-python">Minimal consumer in Python&lt;/h3>
&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">confluent_kafka&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Consumer&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&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="n">Filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">MatchValue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&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">consumer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Consumer&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;bootstrap.servers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;kafka:9092&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;group.id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;qdrant-sync&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;auto.offset.reset&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;earliest&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;enable.auto.commit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">subscribe&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;rag.public.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">qdrant&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;qdrant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&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="k">while&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">poll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">timeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">msg&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">event&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">op&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;op&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">if&lt;/span> &lt;span class="n">op&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;c&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;u&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span> &lt;span class="c1"># INSERT or UPDATE&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">doc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;after&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="c1"># ... vectorise and upsert into Qdrant&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">elif&lt;/span> &lt;span class="n">op&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;d&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># DELETE&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="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;before&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="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&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">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;doc_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">doc_id&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">consumer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">commit&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="9-on-premise-deployment">9. On-premise deployment&lt;/h2>
&lt;p>The Debezium stack does not compete for GPU. On a node with &lt;strong>4×H100 SXM (320 GB, NVLink)&lt;/strong> serving the inference LLM, the CDC pipeline runs entirely on general-purpose (CPU-only) nodes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Recommended resources&lt;/th>
&lt;th>Role&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kafka Connect + Debezium&lt;/td>
&lt;td>2-4 cores, 4-8 GB RAM&lt;/td>
&lt;td>Read the WAL, emit events&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kafka brokers (×3)&lt;/td>
&lt;td>4 cores, 32 GB RAM each&lt;/td>
&lt;td>High availability, retention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qdrant-sync consumer&lt;/td>
&lt;td>2 cores, 4 GB RAM&lt;/td>
&lt;td>Vectorise plus upsert/delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qdrant&lt;/td>
&lt;td>8 cores, 64 GB RAM&lt;/td>
&lt;td>Vector store&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Debezium connector is notably light: in production at 10,000 events/second, the connector typically uses less than 1 core and 2 GB of RAM. JVM memory (Kafka Connect runs on the JVM) must be capped explicitly with &lt;code>-Xmx4g&lt;/code> to stop the GC from causing pauses.&lt;/p>
&lt;p>For high availability, Kafka Connect supports &lt;strong>distributed&lt;/strong> mode with multiple workers. If a worker dies, the connector is reassigned automatically to another worker within seconds, and the replication slot guarantees no events are lost during the switchover.&lt;/p>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Debezium with MySQL, MongoDB and Oracle&lt;/strong>: each connector uses the native log mechanism (binlog on MySQL, oplog on MongoDB, LogMiner on Oracle). The resulting event API is similar, but the configuration details and limitations differ.&lt;/li>
&lt;li>&lt;strong>Debezium Server&lt;/strong>: standalone mode without Kafka Connect, with direct sinks to HTTP, S3, Redis Streams or NATS. Useful when the Kafka infrastructure is too complex for the use case.&lt;/li>
&lt;li>&lt;strong>Schema Registry&lt;/strong>: how Avro with Confluent Schema Registry or Apicurio manages event schema evolution (adding columns, changing types) without breaking existing consumers.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: why at-least-once is enough for most RAG cases (an idempotent upsert into Qdrant with the same vector does no harm) and when exactly-once is needed (financial counters, inventory deductions).&lt;/li>
&lt;li>&lt;strong>Outbox pattern plus Debezium combined&lt;/strong>: Debezium reading the &lt;code>outbox&lt;/code> table instead of the business table&amp;rsquo;s WAL directly, the Transactional Outbox + CDC pattern that combines the best of both worlds.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant: ingestion through microservices&lt;/a> — the post where CDC with Debezium is used as an alternative to the outbox pattern to keep PostgreSQL and Qdrant in sync.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: fundamentals&lt;/a> — the corpus curation that Debezium keeps fresh in near real time.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">LLMOps pipeline: the six stages&lt;/a> — the Data stage of the master map, where CDC is the continuous ingestion mechanism.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Data versioning with DVC and lakeFS&lt;/a> — versioning the corpus that Debezium feeds incrementally.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM and LLM&lt;/a> — monitoring the cluster where the Debezium consumer runs alongside the inference stack.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Debezium Documentation — PostgreSQL Connector. &lt;a href="https://debezium.io/documentation/reference/stable/connectors/postgresql.html">debezium.io/documentation/reference/stable/connectors/postgresql.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Logical Replication. &lt;a href="https://www.postgresql.org/docs/current/logical-replication.html">postgresql.org/docs/current/logical-replication.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Write-Ahead Logging. &lt;a href="https://www.postgresql.org/docs/current/wal-intro.html">postgresql.org/docs/current/wal-intro.html&lt;/a>&lt;/li>
&lt;li>PostgreSQL Documentation — Replication Slots. &lt;a href="https://www.postgresql.org/docs/current/logicaldecoding-explanation.html">postgresql.org/docs/current/logicaldecoding-explanation.html&lt;/a>&lt;/li>
&lt;li>Confluent — Kafka Performance Benchmarks (2023). &lt;a href="https://www.confluent.io/blog/kafka-fastest-messaging-system/">confluent.io/blog/kafka-fastest-messaging-system&lt;/a>&lt;/li>
&lt;li>Gunnar Morling — Outbox Pattern. &lt;a href="https://www.morling.dev/blog/sending-messages-as-part-of-database-transactions/">morling.dev/blog/sending-messages-as-part-of-database-transactions&lt;/a>&lt;/li>
&lt;li>Debezium — SMT documentation. &lt;a href="https://debezium.io/documentation/reference/stable/transformations/">debezium.io/documentation/reference/stable/transformations&lt;/a>&lt;/li>
&lt;li>Qdrant Documentation — Filtering. &lt;a href="https://qdrant.tech/documentation/concepts/filtering/">qdrant.tech/documentation/concepts/filtering&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>PostgreSQL + Qdrant in RAG ingestion: the postman who keeps two worlds in sync</title><link>https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/</link><pubDate>Thu, 04 Jun 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>TL;DR&lt;/strong> — In a production RAG system, PostgreSQL holds the official truth about documents and Qdrant holds the vectors for search. Keeping them in sync is not trivial: if you delete a document from Postgres and do not invalidate its chunks in Qdrant, the system returns answers from ghost documents. There are two patterns to avoid this: the &lt;strong>outbox pattern&lt;/strong> (atomic transaction plus asynchronous worker, at-least-once) and &lt;strong>CDC with Debezium&lt;/strong> (reading the Postgres WAL directly, low latency, higher complexity). This article explains when to use each one, how to orchestrate them as microservices and what numbers to expect with &lt;code>bge-m3&lt;/code> on on-premise hardware.&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="the-postman-and-the-civil-registry">The postman and the civil registry&lt;/h2>
&lt;p>Picture a town with two complementary offices.&lt;/p>
&lt;p>The first is the &lt;strong>civil registry&lt;/strong>: it keeps the official census. Every time someone is born, dies or moves house, the registry is the first to know. It is slow, structured, transactional. If the registry says someone exists, they exist. If it says they died, they are dead. &lt;strong>PostgreSQL is the civil registry of your documents.&lt;/strong>&lt;/p>
&lt;p>The second is the &lt;strong>postman&amp;rsquo;s notebook&lt;/strong>: a copy optimised for finding any resident in seconds, organised by area, phonetic names and habitual routes. The postman cannot update the registry, but can search at speeds the registry would never reach. &lt;strong>Qdrant is the postman&amp;rsquo;s notebook.&lt;/strong>&lt;/p>
&lt;p>The problem is synchronisation. If the registry records a death but nobody tells the postman, he will keep trying to deliver letters to an address that no longer exists. In RAG, that translates into indexed chunks of documents that have already been deleted, edited or replaced: ghost documents that pollute the results.&lt;/p>
&lt;p>How does the registry tell the postman?&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Outbox pattern&lt;/strong>: every time the registry updates its ledger, it notes the change on an &lt;em>outbox sheet&lt;/em>. A messenger clerk reads that sheet periodically and updates the postman&amp;rsquo;s notebook. Guaranteed, asynchronous, fault tolerant.&lt;/li>
&lt;li>&lt;strong>CDC with Debezium&lt;/strong>: the postman has a direct phone line to the registry. Every time the clerk writes something new, the phone rings and the postman updates his notebook in near real time.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="the-consistency-gap-problem">The consistency gap problem&lt;/h2>
&lt;p>In a naive RAG architecture, the flow is:&lt;/p>
&lt;ol>
&lt;li>The user uploads a document → it is inserted into Postgres with metadata.&lt;/li>
&lt;li>A worker splits it into chunks, generates embeddings and upserts into Qdrant.&lt;/li>
&lt;li>Retrieval uses Qdrant to find relevant chunks and Postgres to hydrate metadata.&lt;/li>
&lt;/ol>
&lt;p>So far so good. The problem shows up in &lt;strong>updates and deletes&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The user &lt;strong>edits&lt;/strong> a document → Postgres updates the row, but the vectors of the old chunks are still in Qdrant. Retrieval returns stale context.&lt;/li>
&lt;li>The user &lt;strong>deletes&lt;/strong> a document → Postgres removes the row, but the chunks remain in Qdrant. Retrieval returns chunks from a document that should no longer exist.&lt;/li>
&lt;li>The permission system &lt;strong>revokes a tenant&amp;rsquo;s access&lt;/strong> → Qdrant has no way of knowing unless there is explicit synchronisation.&lt;/li>
&lt;/ul>
&lt;p>This is not a theoretical problem. In living corpora (corporate wikis, knowledge bases updated daily), the &lt;em>consistency gap&lt;/em> accumulates noise progressively. An internal study on production pipelines shows that without active reconciliation, 3-8% of indexed chunks correspond to documents that no longer exist in the source of truth after 30 days of operation.&lt;/p>
&lt;p>The answer is not &amp;ldquo;reindex everything every night&amp;rdquo;. With 10M chunks and a non-trivial embedding model, that costs hours of compute and causes unavailability windows. The answer is &lt;strong>change propagation with guarantees&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="outbox-pattern-the-outbox-sheet">Outbox pattern: the outbox sheet&lt;/h2>
&lt;h3 id="mechanism">Mechanism&lt;/h3>
&lt;p>The outbox pattern solves the problem of &amp;ldquo;writing to two systems in the same operation&amp;rdquo; without distributed transactions (which are expensive and fragile).&lt;/p>
&lt;p>The idea is simple: &lt;strong>PostgreSQL is the single coordinator&lt;/strong>. When the ingestion microservice processes a document, it performs two writes in the &lt;em>same local transaction&lt;/em>:&lt;/p>
&lt;ol>
&lt;li>It inserts or updates the document in the &lt;code>documents&lt;/code> table.&lt;/li>
&lt;li>It inserts an event into the &lt;code>outbox_events&lt;/code> table.&lt;/li>
&lt;/ol>
&lt;p>If the transaction fails, both writes are rolled back. If it succeeds, both are committed atomically. There is no inconsistent intermediate state.&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">-- Relevant tables
&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&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="n">tenant_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>&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="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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">content&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">checksum&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>&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">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;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">outbox_events&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">BIGSERIAL&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="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">aggregate_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">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">-- document id
&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;document.created&amp;#39; | &amp;#39;document.updated&amp;#39; | &amp;#39;document.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="n">processed_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="c1">-- NULL = pending
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&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">-- Example of an atomic insert
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">BEGIN&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">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&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">,&lt;/span>&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 class="n">content&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">checksum&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">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">4&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">RETURNING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&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">_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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &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">outbox_events&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aggregate_id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_type&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">payload&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">VALUES&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="s1">&amp;#39;document.created&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">jsonb_build_object&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;tenant_id&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&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;title&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&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;checksum&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">4&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 class="k">COMMIT&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;h3 id="the-outbox-worker">The outbox worker&lt;/h3>
&lt;p>A separate process (the &lt;em>outbox worker&lt;/em>) polls &lt;code>outbox_events&lt;/code> where &lt;code>processed_at IS NULL&lt;/code>, processes each event (chunking, embedding, upsert into Qdrant) and marks the row as processed:&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">UPDATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">outbox_events&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">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">processed_at&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&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="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&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>&lt;strong>Guarantee&lt;/strong>: at-least-once. If the worker fails between the Qdrant upsert and the &lt;code>UPDATE&lt;/code>, the event will be reprocessed. Qdrant tolerates idempotent upserts (same point &lt;code>id&lt;/code> = overwrite), so reprocessing does not create duplicates.&lt;/p>
&lt;p>&lt;strong>Latency&lt;/strong>: it depends on the polling interval. With polling every 500ms, p50 latency is ~250ms; p99, ~500ms. Acceptable for most RAG cases, where the user does not expect to see a document indexed in under a second.&lt;/p>
&lt;hr>
&lt;h2 id="cdc-with-debezium-the-direct-phone-line">CDC with Debezium: the direct phone line&lt;/h2>
&lt;h3 id="mechanism-1">Mechanism&lt;/h3>
&lt;p>&lt;em>Change Data Capture&lt;/em> (CDC) reads PostgreSQL&amp;rsquo;s &lt;strong>Write-Ahead Log (WAL)&lt;/strong> directly. Postgres writes every change to the WAL before applying it to the tables; it is the mechanism it uses for replication and recovery. Debezium subscribes to a &lt;em>logical replication slot&lt;/em> and turns those events into structured messages.&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">-- Enable logical replication in postgresql.conf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- wal_level = logical
&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">-- Create a replication slot for Debezium
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">pg_create_logical_replication_slot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;debezium_slot&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;pgoutput&amp;#39;&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>The full flow:&lt;/p>
&lt;pre tabindex="0">&lt;code>Postgres WAL → Debezium connector → Kafka/NATS → Indexer consumer → Qdrant
&lt;/code>&lt;/pre>&lt;p>Debezium emits events with the record&amp;rsquo;s &lt;em>before/after&lt;/em> structure:&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;op&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;d&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;before&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;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;550e8400-e29b-41d4-a716-446655440000&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;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acme&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;checksum&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sha256:abc123&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="nt">&amp;#34;after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&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>With &lt;code>&amp;quot;op&amp;quot;: &amp;quot;d&amp;quot;&lt;/code> (delete), the consumer knows it must delete every point in Qdrant whose payload contains that &lt;code>document_id&lt;/code>.&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="c1"># Consumer: delete by payload filter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">qdrant_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">FilterSelector&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;document_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="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;before&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="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="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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="upsides-and-downsides">Upsides and downsides&lt;/h3>
&lt;p>CDC removes polling and cuts latency to &lt;strong>tens of milliseconds&lt;/strong> (WAL propagation time plus consumer processing). But it adds operational complexity: you need to manage the replication slot (unconsumed slots retain WAL indefinitely, which can fill the disk), the message broker and the consumer offset state.&lt;/p>
&lt;hr>
&lt;h2 id="comparison-outbox-vs-cdc">Comparison: outbox vs CDC&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Outbox pattern&lt;/th>
&lt;th>CDC with Debezium&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Typical latency&lt;/strong>&lt;/td>
&lt;td>250ms – 2s&lt;/td>
&lt;td>20ms – 200ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Delivery guarantee&lt;/strong>&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;td>At-least-once&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Operational complexity&lt;/strong>&lt;/td>
&lt;td>Low (Postgres only)&lt;/td>
&lt;td>High (Debezium + broker)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>WAL retention risk&lt;/strong>&lt;/td>
&lt;td>None&lt;/td>
&lt;td>High if the slot stalls&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Idempotency required&lt;/strong>&lt;/td>
&lt;td>Yes (in the indexer)&lt;/td>
&lt;td>Yes (in the consumer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-table support&lt;/strong>&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>Automatic (any table)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Backpressure&lt;/strong>&lt;/td>
&lt;td>Natural (polling)&lt;/td>
&lt;td>Needs explicit design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to choose it&lt;/strong>&lt;/td>
&lt;td>Corpus &amp;lt; 100k docs/day, small team&lt;/td>
&lt;td>Corpus &amp;gt; 1M docs/day, low latency critical&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Rule of thumb&lt;/strong>: start with outbox. Move to CDC when the change volume goes past ~50k events/hour or when latency measured in seconds becomes unacceptable for the use case (for example, real-time news indexing).&lt;/p>
&lt;hr>
&lt;h2 id="microservice-architecture">Microservice architecture&lt;/h2>
&lt;p>The ingestion pipeline is made up of three microservices with cleanly separated responsibilities:&lt;/p>
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="svg-title svg-desc">
&lt;title id="svg-title">RAG ingestion microservice architecture&lt;/title>
&lt;desc id="svg-desc">Diagram showing the flow from Postgres through Ingestor, Indexer and Reconciler to Qdrant&lt;/desc>
&lt;!-- Background -->
&lt;rect width="780" height="420" fill="#0f1117" rx="8"/>
&lt;!-- Postgres -->
&lt;rect x="20" y="160" width="130" height="100" rx="6" fill="#1e2433" stroke="#4a90d9" stroke-width="1.5"/>
&lt;text x="85" y="200" text-anchor="middle" fill="#4a90d9" font-family="monospace" font-size="13" font-weight="bold">PostgreSQL&lt;/text>
&lt;text x="85" y="218" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">documents&lt;/text>
&lt;text x="85" y="234" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">outbox_events&lt;/text>
&lt;!-- Ingestor -->
&lt;rect x="210" y="60" width="140" height="90" rx="6" fill="#1e2433" stroke="#7c4dff" stroke-width="1.5"/>
&lt;text x="280" y="96" text-anchor="middle" fill="#7c4dff" font-family="monospace" font-size="13" font-weight="bold">Ingestor&lt;/text>
&lt;text x="280" y="114" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">chunking&lt;/text>
&lt;text x="280" y="130" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">embedding&lt;/text>
&lt;!-- Indexer -->
&lt;rect x="210" y="190" width="140" height="90" rx="6" fill="#1e2433" stroke="#00c853" stroke-width="1.5"/>
&lt;text x="280" y="226" text-anchor="middle" fill="#00c853" font-family="monospace" font-size="13" font-weight="bold">Indexer&lt;/text>
&lt;text x="280" y="244" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">outbox worker&lt;/text>
&lt;text x="280" y="260" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">Qdrant upsert&lt;/text>
&lt;!-- Reconciler -->
&lt;rect x="210" y="320" width="140" height="70" rx="6" fill="#1e2433" stroke="#ff6d00" stroke-width="1.5"/>
&lt;text x="280" y="354" text-anchor="middle" fill="#ff6d00" font-family="monospace" font-size="13" font-weight="bold">Reconciler&lt;/text>
&lt;text x="280" y="372" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">periodic diff&lt;/text>
&lt;!-- Qdrant -->
&lt;rect x="430" y="160" width="130" height="100" rx="6" fill="#1e2433" stroke="#e91e8c" stroke-width="1.5"/>
&lt;text x="495" y="200" text-anchor="middle" fill="#e91e8c" font-family="monospace" font-size="13" font-weight="bold">Qdrant&lt;/text>
&lt;text x="495" y="218" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">collection&lt;/text>
&lt;text x="495" y="234" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">corpus&lt;/text>
&lt;!-- GPU Embedder -->
&lt;rect x="430" y="50" width="130" height="80" rx="6" fill="#1e2433" stroke="#ffd600" stroke-width="1.5"/>
&lt;text x="495" y="83" text-anchor="middle" fill="#ffd600" font-family="monospace" font-size="12" font-weight="bold">4×H100 SXM&lt;/text>
&lt;text x="495" y="101" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">bge-m3&lt;/text>
&lt;text x="495" y="117" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">~2000 chunks/s&lt;/text>
&lt;!-- Optional Kafka/NATS -->
&lt;rect x="430" y="320" width="130" height="70" rx="6" fill="#1e2433" stroke="#26c6da" stroke-width="1.5"/>
&lt;text x="495" y="352" text-anchor="middle" fill="#26c6da" font-family="monospace" font-size="12" font-weight="bold">Kafka/NATS&lt;/text>
&lt;text x="495" y="370" text-anchor="middle" fill="#8899aa" font-family="monospace" font-size="11">(CDC path)&lt;/text>
&lt;!-- Arrow Postgres → Ingestor -->
&lt;line x1="150" y1="190" x2="210" y2="130" stroke="#7c4dff" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Postgres → Indexer -->
&lt;line x1="150" y1="210" x2="210" y2="230" stroke="#00c853" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Arrow Postgres → Reconciler -->
&lt;line x1="150" y1="240" x2="210" y2="340" stroke="#ff6d00" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Ingestor → GPU -->
&lt;line x1="350" y1="100" x2="430" y2="90" stroke="#ffd600" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Arrow GPU → Indexer -->
&lt;line x1="495" y1="130" x2="380" y2="210" stroke="#ffd600" stroke-width="1.2" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Indexer → Qdrant -->
&lt;line x1="350" y1="230" x2="430" y2="210" stroke="#00c853" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Arrow Reconciler → Kafka -->
&lt;line x1="350" y1="355" x2="430" y2="355" stroke="#26c6da" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Arrow Kafka → Qdrant -->
&lt;line x1="495" y1="320" x2="495" y2="260" stroke="#26c6da" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr)"/>
&lt;!-- Reconciler → Qdrant direct -->
&lt;line x1="350" y1="345" x2="430" y2="230" stroke="#ff6d00" stroke-width="1.2" stroke-dasharray="3,4" marker-end="url(#arr)"/>
&lt;!-- Marker definitions -->
&lt;defs>
&lt;marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#667788"/>
&lt;/marker>
&lt;/defs>
&lt;!-- Legend -->
&lt;text x="590" y="80" fill="#ccddee" font-family="monospace" font-size="11" font-weight="bold">Outbox flow:&lt;/text>
&lt;line x1="590" y1="92" x2="630" y2="92" stroke="#00c853" stroke-width="1.5"/>
&lt;text x="635" y="96" fill="#8899aa" font-family="monospace" font-size="10">sync&lt;/text>
&lt;text x="590" y="115" fill="#ccddee" font-family="monospace" font-size="11" font-weight="bold">CDC flow:&lt;/text>
&lt;line x1="590" y1="127" x2="630" y2="127" stroke="#26c6da" stroke-width="1.5" stroke-dasharray="4,3"/>
&lt;text x="635" y="131" fill="#8899aa" font-family="monospace" font-size="10">async&lt;/text>
&lt;text x="590" y="150" fill="#ccddee" font-family="monospace" font-size="11" font-weight="bold">Reconciler:&lt;/text>
&lt;line x1="590" y1="162" x2="630" y2="162" stroke="#ff6d00" stroke-width="1.5" stroke-dasharray="3,4"/>
&lt;text x="635" y="166" fill="#8899aa" font-family="monospace" font-size="10">periodic&lt;/text>
&lt;/svg>
&lt;h3 id="microservice-1-ingestor">Microservice 1: Ingestor&lt;/h3>
&lt;p>Responsibilities: receive documents, split them into chunks and request embeddings. It does not write to Qdrant directly.&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="c1"># ingestor/main.py (simplified)&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">langchain_text_splitters&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">RecursiveCharacterTextSplitter&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">splitter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">RecursiveCharacterTextSplitter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunk_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># tokens, not characters&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunk_overlap&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">64&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">length_function&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token_count&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">ingest_document&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Document&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Session&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">splitter&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">begin&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &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;UPDATE documents SET checksum=$1 WHERE id=$2&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="p">[&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">checksum&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">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;&amp;#34;&amp;#34;INSERT INTO outbox_events (aggregate_id, event_type, payload)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> VALUES ($1, &amp;#39;document.updated&amp;#39;, $2)&amp;#34;&amp;#34;&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="p">[&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;chunks&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tenant_id&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;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1.0.0&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="microservice-2-indexer">Microservice 2: Indexer&lt;/h3>
&lt;p>It reads the outbox, generates embeddings by calling the inference server (vLLM or TEI) and upserts into Qdrant.&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="c1"># indexer/worker.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">asyncio&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&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="n">PointStruct&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">VectorParams&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Distance&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">qdrant&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">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;qdrant-service&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">process_event&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">payload&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">event&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="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">payload&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;chunks&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">doc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Batch embedding on TEI (Text Embeddings Inference)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embeddings&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">embed_batch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">chunks&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bge-m3&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">points&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">PointStruct&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">doc_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">_&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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">vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">emb&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">payload&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;document_id&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">payload&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;tenant_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="s2">&amp;#34;chunk_index&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">i&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;text&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i&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;model_version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">payload&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;model_version&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="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="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">emb&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">enumerate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">embeddings&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># If it is an update, delete the previous chunks first&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">event&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 class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;document.updated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;document.deleted&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">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">points_selector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">filter_by_doc_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&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>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">event&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 class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;document.deleted&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">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">upsert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">points&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">points&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="microservice-3-reconciler">Microservice 3: Reconciler&lt;/h3>
&lt;p>The reconciler is the safety net. Periodically (every hour, for example) it compares the set of &lt;code>document_id&lt;/code> values in Postgres with the set of &lt;code>document_id&lt;/code> values in Qdrant. IDs present in Qdrant but absent from Postgres are &lt;em>ghosts&lt;/em>: they get deleted.&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="c1"># reconciler/diff.py&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">def&lt;/span> &lt;span class="nf">reconcile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pg_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">await&lt;/span> &lt;span class="n">fetch_all_doc_ids&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">qdrant_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">await&lt;/span> &lt;span class="n">scroll_all_doc_ids&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tenant_id&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="c1"># paginated scroll&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">orphans&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">qdrant_ids&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">pg_ids&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">orphans&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">warning&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Orphan chunks for &lt;/span>&lt;span class="si">%d&lt;/span>&lt;span class="s2"> documents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">orphans&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">doc_id&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">orphans&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">delete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">filter_by_doc_id&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc_id&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="k">return&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">orphans&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="throughput-and-ingestion-maths">Throughput and ingestion maths&lt;/h2>
&lt;h3 id="embedding-throughput">Embedding throughput&lt;/h3>
&lt;p>The &lt;code>bge-m3&lt;/code> model (1024 dimensions, dense + sparse + colbert support) on a node with &lt;strong>4×H100 SXM (320 GB NVLink)&lt;/strong> running via vLLM or HuggingFace TEI reaches roughly &lt;strong>2,000 chunks/second&lt;/strong> with batch size = 256 and 512-token sequences.&lt;/p>
$$\text{throughput} = 4 \times 500 \text{ chunks/s/GPU} = 2{,}000 \text{ chunks/s}$$
&lt;blockquote>
&lt;p>The figure of 500 chunks/s per GPU comes from public TEI benchmarks with bge-m3 on H100 SXM5, batch=256, seq_len=512 &lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>.&lt;/p>
&lt;/blockquote>
&lt;h3 id="total-re-ingestion-time">Total re-ingestion time&lt;/h3>
&lt;p>For a corpus of &lt;strong>10M chunks&lt;/strong>:&lt;/p>
$$t = \frac{10{,}000{,}000 \text{ chunks}}{2{,}000 \text{ chunks/s}} = 5{,}000 \text{ s} \approx 83 \text{ minutes}$$
&lt;p>That is pure embedding time. Adding Qdrant write latency (~0.5ms per upsert batch of 100 points):&lt;/p>
$$t_{\text{qdrant}} = \frac{10{,}000{,}000}{100} \times 0.5\text{ ms} = 50{,}000 \text{ ms} = 50 \text{ s}$$
&lt;p>Estimated total for a full re-ingestion: &lt;strong>~85-90 minutes&lt;/strong> on a 4×H100 node.&lt;/p>
&lt;h3 id="storage-cost-in-qdrant">Storage cost in Qdrant&lt;/h3>
&lt;p>Each &lt;code>bge-m3&lt;/code> vector has &lt;strong>1024 dimensions&lt;/strong> in &lt;code>float32&lt;/code> (4 bytes):&lt;/p>
$$\text{size per vector} = 1{,}024 \times 4 \text{ B} = 4{,}096 \text{ B} = 4 \text{ KB}$$
&lt;p>For 10M chunks (dense vectors only):&lt;/p>
$$\text{total vectors} = 10^7 \times 4{,}096 \text{ B} = 40.96 \text{ GB}$$
&lt;p>Adding the JSON payload (estimated at ~500 bytes/chunk):&lt;/p>
$$\text{payload} = 10^7 \times 500 \text{ B} = 5 \text{ GB}$$
&lt;p>HNSW index (roughly 1.2× the vector size for $m=16$):&lt;/p>
$$\text{HNSW} \approx 40.96 \text{ GB} \times 1.2 = 49.15 \text{ GB}$$
&lt;p>&lt;strong>Estimated total on disk: ~95 GB&lt;/strong> for 10M chunks with dense bge-m3.&lt;/p>
&lt;p>With &lt;code>scalar&lt;/code> quantization (int8), the vector size shrinks 4×:&lt;/p>
$$\text{with int8 quantization} \approx \frac{40.96}{4} + 5 + \frac{49.15}{4} \approx 27.5 \text{ GB}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Vectors&lt;/th>
&lt;th>HNSW&lt;/th>
&lt;th>Payload&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>float32, no quantization&lt;/td>
&lt;td>40.96 GB&lt;/td>
&lt;td>49.15 GB&lt;/td>
&lt;td>5 GB&lt;/td>
&lt;td>~95 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>int8 scalar quantization&lt;/td>
&lt;td>10.24 GB&lt;/td>
&lt;td>12.29 GB&lt;/td>
&lt;td>5 GB&lt;/td>
&lt;td>~28 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>binary quantization&lt;/td>
&lt;td>1.28 GB&lt;/td>
&lt;td>1.54 GB&lt;/td>
&lt;td>5 GB&lt;/td>
&lt;td>~8 GB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Binary quantization loses recall precision (~2-5% in NDCG@10), but it lets you host much larger corpora in RAM. For production where recall is critical, int8 is the usual balance point.&lt;/p>
&lt;hr>
&lt;h2 id="recommended-on-premise-hardware">Recommended on-premise hardware&lt;/h2>
&lt;p>For a continuous ingestion pipeline in production:&lt;/p>
&lt;p>&lt;strong>Embedding node&lt;/strong>: 4×H100 SXM (320 GB, NVLink), 2× 64-core CPU (EPYC 9654), 1 TB DDR5 RAM, 100 GbE. Runs vLLM or TEI serving &lt;code>bge-m3&lt;/code>. Sustained throughput: ~2,000 chunks/s with an asynchronous batch pipeline.&lt;/p>
&lt;p>&lt;strong>Qdrant node&lt;/strong>: 32-core CPU, 256 GB RAM (to keep the HNSW index in memory with 10M chunks and no quantization), 2 TB NVMe (Qdrant snapshot and WAL writes). Qdrant recommends that the HNSW index fit in RAM for p99 latency &amp;lt; 5ms.&lt;/p>
&lt;p>&lt;strong>PostgreSQL node&lt;/strong>: 16-core CPU, 128 GB RAM, 4 TB NVMe for the WAL (especially relevant if you use CDC with a logical replication slot; the slot retains WAL until Debezium consumes it).&lt;/p>
&lt;p>&lt;strong>Broker (if CDC)&lt;/strong>: 3-broker Kafka with 500 GB NVMe per node, or NATS JetStream with 3 nodes for more modest loads.&lt;/p>
&lt;hr>
&lt;h2 id="kubernetes-manifests">Kubernetes manifests&lt;/h2>
&lt;h3 id="indexer-deployment">Indexer Deployment&lt;/h3>
&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&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">metadata&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">rag-indexer&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-pipeline&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">spec&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">replicas&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&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">matchLabels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-indexer&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">template&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">metadata&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">labels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-indexer&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">spec&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">containers&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">indexer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.example.com/rag-indexer:1.0.0&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">env&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">POSTGRES_DSN&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">valueFrom&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">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">pg-credentials&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">dsn&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_HOST&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qdrant-service.qdrant.svc.cluster.local&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">EMBEDDING_ENDPOINT&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://tei-service.embeddings.svc.cluster.local:8080&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">POLL_INTERVAL_MS&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;500&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">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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;512Mi&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;500m&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">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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2Gi&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="reconciler-cronjob">Reconciler CronJob&lt;/h3>
&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">batch/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CronJob&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">metadata&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">rag-reconciler&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rag-pipeline&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">spec&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">schedule&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 * * * *&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># hourly&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">concurrencyPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Forbid&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">jobTemplate&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">spec&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">template&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">spec&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">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&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">containers&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">reconciler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.example.com/rag-reconciler:1.0.0&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">env&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">POSTGRES_DSN&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">valueFrom&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">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">pg-credentials&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">dsn&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_HOST&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qdrant-service.qdrant.svc.cluster.local&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">SCROLL_PAGE_SIZE&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">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1000&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">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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;256Mi&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;250m&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">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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1Gi&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="production-gotchas">Production gotchas&lt;/h2>
&lt;h3 id="reindexing-when-the-embedding-model-changes">Reindexing when the embedding model changes&lt;/h3>
&lt;p>This is the most painful problem. If you move from &lt;code>bge-m3&lt;/code> to &lt;code>nomic-embed-text-v2&lt;/code>, the vectors are &lt;strong>incompatible&lt;/strong>: they live in different embedding spaces and the cosine distances between them are meaningless.&lt;/p>
&lt;p>The answer is &lt;strong>dual-index aliasing&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>Create a new collection in Qdrant: &lt;code>corpus_v2&lt;/code>.&lt;/li>
&lt;li>Re-embed the whole corpus with the new model and load &lt;code>corpus_v2&lt;/code>.&lt;/li>
&lt;li>When the new collection is complete and validated (recall test), switch the &lt;code>corpus_prod&lt;/code> alias from &lt;code>corpus_v1&lt;/code> to &lt;code>corpus_v2&lt;/code>.&lt;/li>
&lt;li>Delete &lt;code>corpus_v1&lt;/code> once traffic has migrated.&lt;/li>
&lt;/ol>
&lt;p>During the migration the two indexes coexist. The retriever uses the alias, not the collection&amp;rsquo;s direct name.&lt;/p>
&lt;h3 id="index-versioning">Index versioning&lt;/h3>
&lt;p>Store &lt;code>model_version&lt;/code> in the payload of every point in Qdrant. This lets you:&lt;/p>
&lt;ul>
&lt;li>Filter by version during retrieval (useful in model A/B testing).&lt;/li>
&lt;li>Have the reconciler detect points with an old version and reprocess them selectively.&lt;/li>
&lt;li>Audit: know which model generated each embedding.&lt;/li>
&lt;/ul>
&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="c1"># Filter by model version at retrieval time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;model_version&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0.0&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">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tenant_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">match&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">MatchValue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tenant_id&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="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&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;h3 id="namespace-per-tenant-multi-tenancy">Namespace per tenant (multi-tenancy)&lt;/h3>
&lt;p>There are two strategies in Qdrant:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Pros&lt;/th>
&lt;th>Cons&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Collection per tenant&lt;/strong>&lt;/td>
&lt;td>Total isolation, no extra filter&lt;/td>
&lt;td>N collections = N HNSW indexes in RAM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Payload filter per tenant&lt;/strong>&lt;/td>
&lt;td>A single collection, less RAM&lt;/td>
&lt;td>The filter adds ~10-15% search latency&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For fewer than 100 tenants with large corpora (&amp;gt; 1M chunks/tenant), use a collection per tenant. For hundreds or thousands of tenants with small corpora, use a payload filter with &lt;code>tenant_id&lt;/code> indexed:&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="n">qdrant&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create_payload_index&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;corpus&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">field_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tenant_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="n">field_schema&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">PayloadSchemaType&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">KEYWORD&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;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Streaming corpus updates with near-real-time CDC&lt;/strong>: selective chunk invalidation when only one section of a document changes (incremental chunking based on content diff, not full re-chunking).&lt;/li>
&lt;li>&lt;strong>Multi-tenant corpus isolation with per-chunk ACLs&lt;/strong>: going beyond the &lt;code>tenant_id&lt;/code> filter towards group, role or even individual document permissions applied at retrieval time.&lt;/li>
&lt;li>&lt;strong>Federated corpus&lt;/strong>: corpora distributed across cross-border silos where regulation (GDPR, CCPA) prevents centralising the embeddings; federated search patterns without moving data.&lt;/li>
&lt;li>&lt;strong>Zero-downtime incremental reindexing using dual-index aliasing&lt;/strong>: the full model migration protocol with rollback, recall regression testing and progressive traffic splitting.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">End-to-end document ingestion: from PDF to indexed chunk&lt;/a> — the full ingestion pipeline (parsing, chunking, dedup, metadata) around this synchronisation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">Taking RAG to the CPU: separating the data plane from the generation plane&lt;/a> — ingestion and index building are CPU work, not GPU work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">The curated corpus this architecture has to index&lt;/a> — curation and filtering strategies before ingestion.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">The retrieval that consumes this vector store&lt;/a> — how the reranker and hybrid retrieval use what we build here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">The embedder that generates the vectors&lt;/a> — a comparison of bge-m3, nomic-embed-text-v2 and multivector models.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The Data stage of the LLMOps master map&lt;/a> — where this pipeline sits within the full cycle.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">Versioning the raw corpus before ingestion&lt;/a> — how DVC and LakeFS manage lineage before anything reaches PostgreSQL.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/">Debezium and CDC: the notary who listens to changes before anyone asks for them&lt;/a> — the CDC deep dive this article introduces: the Postgres WAL, replication slots, pgoutput and the full comparison with the outbox pattern.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>HuggingFace Text Embeddings Inference — benchmarks oficiales con modelos de la familia bge en hardware A100/H100. &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a>&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item><item><title>RAG on Kafka: a technical reference architecture for streaming datalakes, with fresh embeddings and vector stores that are always up to date</title><link>https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/</link><pubDate>Thu, 21 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The piece that blocks enterprise GenAI projects most in 2026 is not the model, nor even the guardrails: it is &lt;strong>data ingestion for RAG&lt;/strong>. Companies hold valuable information in OLTP databases, in operational logs, in SaaS systems, and all of it is quietly changing every second. Batch RAG systems that reindex every night arrive late (the model&amp;rsquo;s answer is backed by a snapshot from 18 hours ago) and open the door to operational hallucinations even when the retriever is perfect. The dominant answer in production in 2026 is to build the RAG piece on top of Kafka as the source of truth: an immutable log, massive throughput, managed schema evolution, and a mature stream processing ecosystem (Flink, Kafka Streams, RisingWave) that makes it possible to transform and embed events as they happen, delivering them to vector stores (Milvus, Qdrant, Weaviate, pgvector) in milliseconds. The canonical pattern: source → CDC with Debezium → Kafka topics → Flink SQL with an embedding UDF → sink connector to a vector store → serving with vLLM or equivalent. The 2026 developments that change the game: Confluent Tableflow turns Kafka topics into Iceberg/Delta tables automatically (read from Snowflake/Databricks/Trino with no ETL, 30-50 % lower TCO); native Flink SQL brings &lt;code>openai_embedding()&lt;/code> and vector search integrated with Cosmos DB and Amazon S3 Vectors; Confluent&amp;rsquo;s official MCP server lets AI agents query Kafka/Flink/Tableflow in natural language. This post develops the end-to-end architecture with manifests, Flink SQL code and concrete numbers.&lt;/p>
&lt;blockquote>
&lt;p>This is the second post in the MLOps series specific to LLMs. The first (&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">2026 landscape&lt;/a>) set out the framework. Here we go down to the most operational piece of the stack: how a real enterprise system connects to an LLM agent while keeping RAG fresh without falling into explosive complexity.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-kafka-as-the-single-source-of-truth">The analogy: Kafka as the &amp;ldquo;single source of truth&amp;rdquo;&lt;/h2>
&lt;p>Anyone who has spent time in distributed systems has seen the pattern again and again: an immutable, append-only, replicated, time-ordered log has become the canonical primitive for rebuilding complex systems. DBAs know it as the write-ahead log (PostgreSQL WAL, MySQL binlog). Event system developers know it as event sourcing. Data architects know it as the Kappa architecture. Kafka is the massive, distributed, mature implementation of that primitive: a log that lives on disk, partitioned to scale, replicated for durability, retained by time or size, readable from any point in history.&lt;/p>
&lt;p>When you think about RAG, this is exactly what you need. A well-designed RAG system has two critical questions: how do you keep the index fresh? And how do you rebuild the index when something breaks? Kafka answers both naturally: fresh because every change at the source is published as an event to the log and the pipeline processes it in milliseconds; rebuildable because the whole log is there: you drop the vector store, you have the Kafka topic from offset 0, and you build the index back exactly as it was.&lt;/p>
&lt;p>There is a second layer to the analogy as well. For a modern GenAI architecture, Kafka plays the role of the WAL of the entire system. Just as the Postgres WAL is the gospel of the database&amp;rsquo;s state (if you lose the DB but keep the WAL, you can rebuild it), the Kafka log is the gospel of the state of the business as a whole: orders, users, transactions, documents. Connecting your AI agent to Kafka means connecting it to the real pulse of the system, not to stale snapshots.&lt;/p>
&lt;h2 id="the-static-rag-problem">The static RAG problem&lt;/h2>
&lt;p>Before presenting the architecture, it is worth pinning down what concrete problem we are solving. The antipattern that trips up most GenAI projects:&lt;/p>
&lt;ol>
&lt;li>A team builds RAG on a static dataset: it dumps Confluence documents, product PDFs, database snapshots.&lt;/li>
&lt;li>It embeds the lot with a nightly cron that regenerates the index every 24 hours.&lt;/li>
&lt;li>It ships the product.&lt;/li>
&lt;li>&lt;strong>Day 2&lt;/strong>: a user asks about a change that happened two hours ago. RAG does not have it; the model answers about the old version.&lt;/li>
&lt;li>The team adds fragile logic: &amp;ldquo;if the query mentions a recent date, escalate to a human agent&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Day 30&lt;/strong>: the dataset has moved so much that half the RAG is out of date. The team decides to refactor and migrate to streaming.&lt;/li>
&lt;/ol>
&lt;p>It is the repeated story of so many projects that the ecosystem has learned the lesson: streaming from day 1, even when the volume is low. The operational complexity of a well-designed streaming pipeline is constant; the complexity of migrating from batch to streaming on a live project is enormous.&lt;/p>
&lt;h2 id="from-lambda-to-kappa-to-streaming-rag">From Lambda to Kappa to Streaming RAG&lt;/h2>
&lt;p>Three architectures in historical order:&lt;/p>
&lt;p>&lt;strong>Lambda (the classic big-data design from 2014)&lt;/strong>: two parallel pipelines, one batch for accuracy and one streaming for freshness. The query combines both. It works, but it means maintaining two pipelines.&lt;/p>
&lt;p>&lt;strong>Kappa (Jay Kreps, 2014, mainstream since 2020)&lt;/strong>: a single streaming pipeline. Batch is a special case of streaming (reprocessing from the beginning). It simplifies a great deal.&lt;/p>
&lt;p>&lt;strong>Streaming RAG (emerging in 2025-2026)&lt;/strong>: a specific variant of Kappa where the pipeline&amp;rsquo;s output is embeddings indexed in a vector store that the LLM queries at runtime. The Kafka log is the source of truth, the vector store is a queryable projection.&lt;/p>
&lt;p>The mental conversion: think of the vector store as the materialised view of the Kafka log. If the view is corrupted, you rebuild it from the log. If you want a new view (a different embedding model, a different chunking strategy), you create another consumer of the log and build a second view in parallel.&lt;/p>
&lt;h2 id="the-reference-architecture">The reference architecture&lt;/h2>
&lt;p>On to the diagram. I am going to present the canonical architecture that stabilised in 2026, showing where each component fits:&lt;/p>
&lt;pre tabindex="0">&lt;code>[OLTP DB (Postgres)] [Other sources]
│ │
│ WAL via logical decoding │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Debezium / Kafka Connect (Sources) │
└──────────────────────────────────────────────────────────┘
│
▼ produces events
┌──────────────────────────────────────────────────────────┐
│ Kafka cluster │
│ ┌───────────────────────────────────────────────────┐ │
│ │ topic: orders.raw (3 partitions, RF=3) │ │
│ │ topic: users.raw (3 partitions, RF=3) │ │
│ │ topic: documents.raw (6 partitions, RF=3) │ │
│ └───────────────────────────────────────────────────┘ │
│ + Schema Registry (Avro/Protobuf) │
└──────────────────────────────────────────────────────────┘
│
▼ consumes and transforms
┌──────────────────────────────────────────────────────────┐
│ Flink SQL streaming jobs │
│ - chunking text │
│ - calls to the embedding model (UDF) │
│ - enrichment with metadata │
│ - sink to a curated topic: documents.embedded │
└──────────────────────────────────────────────────────────┘
│
┌───────────┼────────────────────┐
▼ ▼ ▼
[Vector store] [Tableflow] [Iceberg/Delta]
Milvus/Qdrant auto-convert for analytics
/pgvector/ topics →
Weaviate tables
│
▼ queried at runtime
┌──────────────────────────────────────────────────────────┐
│ LLM serving (vLLM / SGLang) + Retriever │
│ - receives the agent&amp;#39;s query │
│ - searches top-K in the vector store │
│ - builds prompt + context │
│ - generates an answer with citations │
└──────────────────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>The five layers you can see (source, ingestion (CDC), transport (Kafka), processing (Flink), storage (vectors + tables)) are the ones that structure any serious RAG on a datalake in 2026. Let us go through each one.&lt;/p>
&lt;h2 id="layer-1--sources-your-oltp-as-the-starting-point">Layer 1 — Sources: your OLTP as the starting point&lt;/h2>
&lt;p>The typical source is an OLTP database (Postgres, MySQL, SQL Server). It is where the live state of the business lives. The technique for extracting changes in real time is Change Data Capture (CDC): reading the database&amp;rsquo;s transaction log (PostgreSQL WAL, MySQL binlog) and turning each commit into a Kafka event.&lt;/p>
&lt;p>The OSS standard is &lt;a href="https://debezium.io/">Debezium&lt;/a>. It supports Postgres, MySQL, SQL Server, MongoDB, Oracle, Cassandra and others. The typical deployment is a Kafka Connect cluster with Debezium connectors.&lt;/p>
&lt;p>An example Debezium configuration for 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>For each commit in the database, this produces an Avro event on the corresponding topic (&lt;code>ecommerce.public.orders&lt;/code>, &lt;code>ecommerce.public.users&lt;/code>, and so on) carrying the change: type (INSERT/UPDATE/DELETE), values before and after, commit timestamp, position in the WAL.&lt;/p>
&lt;p>A simpler alternative for 2026: &lt;a href="https://risingwave.com/">RisingWave&lt;/a> can read the Postgres WAL directly, with no Debezium or Kafka Connect in between. When the case is CDC only, with no other sources, it is operationally simpler. For architectures with multiple sources (CDC + APIs + scrapers + logs), Debezium remains the standard piece.&lt;/p>
&lt;h2 id="layer-2--kafka-as-transport-and-persistence">Layer 2 — Kafka as transport and persistence&lt;/h2>
&lt;p>The Kafka cluster is where all events land. Key operational decisions:&lt;/p>
&lt;h3 id="topics-raw-vs-curated">Topics: raw vs curated&lt;/h3>
&lt;p>The convention that settled in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>*.raw&lt;/code>&lt;/strong>: the raw event exactly as it arrived. Untransformed CDC, unparsed application logs.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.cleaned&lt;/code>&lt;/strong>: after dedup, schema validation, type normalisation.&lt;/li>
&lt;li>&lt;strong>&lt;code>*.enriched&lt;/code>&lt;/strong>: after adding metadata (geolocation, cross identifiers, and so on).&lt;/li>
&lt;li>&lt;strong>&lt;code>*.embedded&lt;/code>&lt;/strong>: the event with its vector embedding already computed.&lt;/li>
&lt;/ul>
&lt;p>Multi-stage topics allow debugging per layer and partial reprocessing: if you change the embedding model, discarding &lt;code>*.embedded&lt;/code> and rebuilding from &lt;code>*.enriched&lt;/code> costs hours; rebuilding from &lt;code>*.raw&lt;/code> costs days.&lt;/p>
&lt;h3 id="schema-registry">Schema Registry&lt;/h3>
&lt;p>Without a schema registry, topics break silently when someone changes the schema at the source. &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> or the OSS &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> are the dominant options.&lt;/p>
&lt;p>Common formats:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Avro&lt;/strong>: versioned schema, strict evolution rules. The historical default.&lt;/li>
&lt;li>&lt;strong>Protobuf&lt;/strong>: compatible with gRPC stacks, good performance.&lt;/li>
&lt;li>&lt;strong>JSON Schema&lt;/strong>: textual, debuggable by eye, less efficient.&lt;/li>
&lt;/ul>
&lt;p>For RAG on Kafka we recommend Avro by default. Schema evolution matters because source tables change over time, and an unversioned schema breaks downstream consumers.&lt;/p>
&lt;h3 id="partitions-replication-and-retention">Partitions, replication and retention&lt;/h3>
&lt;p>Operational decisions for RAG topics:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Partitions&lt;/strong>: typically 3-12. More partitions means more parallelism in the Flink consumer, but more overhead. The rule of thumb: partitions = expected peak events/s ÷ 1000.&lt;/li>
&lt;li>&lt;strong>Replication factor&lt;/strong>: 3 as a minimum in production. Replication protects against broker failure; with RAG, the cost of losing a topic can be weeks of re-embedding.&lt;/li>
&lt;li>&lt;strong>Retention&lt;/strong>: for topics that feed RAG, use long retention or compaction by key. If document &lt;code>doc-42&lt;/code> changes 100 times, compaction keeps only the last state per key, leaving a smaller, rebuildable log. For data that is never updated (historical logs), retention by time (90 days, 1 year).&lt;/li>
&lt;/ul>
&lt;h3 id="cross-cluster-replication">Cross-cluster replication&lt;/h3>
&lt;p>For multi-region or multi-cloud deployments, MirrorMaker 2 or &lt;a href="https://docs.confluent.io/platform/current/multi-dc-deployments/cluster-linking/index.html">Cluster Linking&lt;/a> (Confluent) replicate topics between Kafka clusters. RAG can then query the local cluster without crossing regions.&lt;/p>
&lt;h2 id="layer-3--flink-as-the-streaming-processor">Layer 3 — Flink as the streaming processor&lt;/h2>
&lt;p>&lt;a href="https://flink.apache.org/">Apache Flink&lt;/a> is the dominant stream processing piece in 2026. Apache 2.0, a mature distribution, a broad ecosystem. The main alternative is Kafka Streams (simpler, Java-only); RisingWave is the emerging option for pure SQL cases.&lt;/p>
&lt;p>What Flink adds to Kafka:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stateful streaming&lt;/strong>: time-based aggregations, joins between streams, sessions.&lt;/li>
&lt;li>&lt;strong>Exactly-once semantics&lt;/strong>: with checkpoint coordination.&lt;/li>
&lt;li>&lt;strong>Watermarks&lt;/strong>: correct handling of out-of-order events.&lt;/li>
&lt;li>&lt;strong>UDFs in Python/Java&lt;/strong>: including calls to LLM models.&lt;/li>
&lt;/ul>
&lt;h3 id="flink-sql-the-most-operational-piece">Flink SQL: the most operational piece&lt;/h3>
&lt;p>Flink SQL is the most usable part of Flink for data engineers who are not streaming experts. Here is a realistic RAG pipeline example:&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. Define the source: Kafka topic with CDC events from documents
&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. Define the sink: vector store via an intermediate Kafka topic
&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 for chunking (defined in Python or 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: chunk, embed, write to the 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>What happens here, line by line:&lt;/p>
&lt;ul>
&lt;li>The &lt;code>documents_raw&lt;/code> table reads the CDC topic in upsert-kafka mode (each new event on the same key replaces the previous one). This correctly reflects the semantics of &amp;ldquo;this is the latest version of doc 42&amp;rdquo;.&lt;/li>
&lt;li>The &lt;code>documents_embedded&lt;/code> table will be the intermediate topic where Flink writes the embedded chunks.&lt;/li>
&lt;li>The &lt;code>chunk_text&lt;/code> UDF (defined in Python or Java) splits each doc into 512-token chunks with an overlap of 64.&lt;/li>
&lt;li>The &lt;code>INSERT INTO&lt;/code> query runs continuously: every new event in &lt;code>documents_raw&lt;/code> is chunked, each chunk is embedded with &lt;code>OPENAI_EMBEDDING&lt;/code> (a built-in Flink SQL function in Confluent Cloud 2026), and written to the embedded topic.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> can be swapped for a custom function that calls a self-hosted model (vLLM with an encoder), SentenceTransformers, or a managed service. The syntax is the same; you change the provider.&lt;/p>
&lt;h3 id="watermarks-and-late-events">Watermarks and late events&lt;/h3>
&lt;p>For cases where an event can arrive late (for example, the Postgres WAL falls behind because of a network blip), Flink lets you define watermarks:&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>This tells Flink &amp;ldquo;assume no event arrives more than 5 minutes late relative to the event timestamp&amp;rdquo;. For temporal joins and aggregations, Flink uses the watermark to decide when to &amp;ldquo;close&amp;rdquo; a window.&lt;/p>
&lt;h2 id="layer-4--sinks-to-vector-stores">Layer 4 — Sinks to vector stores&lt;/h2>
&lt;p>The last step is indexing the embeddings in a vector store. Three patterns in 2026:&lt;/p>
&lt;h3 id="pattern-a--direct-kafka-connect-sink">Pattern A — direct Kafka Connect sink&lt;/h3>
&lt;p>Each vector store has its official connector:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/zilliztech/kafka-connect-milvus">Milvus&lt;/a>&lt;/strong>: official Zilliz sink connector. Supports 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>: official sink connector. Supports dense, sparse, multi-vector.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong>: has no dedicated connector, but the &lt;a href="https://www.confluent.io/hub/confluentinc/kafka-connect-jdbc">JDBC Sink Connector&lt;/a> is used with custom SQL.&lt;/li>
&lt;li>&lt;strong>Weaviate&lt;/strong>: community connector.&lt;/li>
&lt;li>&lt;strong>LanceDB&lt;/strong>: community connector.&lt;/li>
&lt;/ul>
&lt;p>An example Milvus sink configuration:&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>Three parallel tasks (&lt;code>tasks.max: 3&lt;/code>) consume the embedded topic and write to the Milvus collection. Latency from &amp;ldquo;event in Kafka&amp;rdquo; to &amp;ldquo;indexable vector in Milvus&amp;rdquo; is typically &amp;lt;5 seconds.&lt;/p>
&lt;h3 id="pattern-b--pgvector-with-a-direct-cdc-pipe">Pattern B — pgvector with a direct CDC pipe&lt;/h3>
&lt;p>For teams that already live in PostgreSQL, pgvector is the lowest-friction option. The pattern: the same source Postgres cluster holds a second DB for embeddings with the pgvector extension enabled; the Flink pipeline writes to it directly over 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">-- On the Postgres cluster with pgvector enabled
&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>Advantages: the same DBA operates everything, transactionality across tables, trivial joins with relational metadata. Limitation: beyond 10M vectors, pgvector performance starts to give way compared with dedicated systems.&lt;/p>
&lt;h3 id="pattern-c--confluent-tableflow--iceberg--flink-sql-vector-search">Pattern C — Confluent Tableflow → Iceberg + Flink SQL vector search&lt;/h3>
&lt;p>This is the 2026 development that changes the mechanics. &lt;a href="https://www.confluent.io/product/tableflow/">Confluent Tableflow&lt;/a> materialises Kafka topics automatically as Apache Iceberg or Delta Lake tables. Features:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No ETL pipeline&lt;/strong>: you do not write Flink/Spark jobs to move Kafka into a table. Tableflow does it.&lt;/li>
&lt;li>&lt;strong>Automatic schema evolution&lt;/strong>: changes to the topic schema are reflected in the table.&lt;/li>
&lt;li>&lt;strong>Unified catalogue&lt;/strong>: the table appears in Glue, Unity Catalog, Snowflake, Databricks. Any analytical engine queries it without copying data.&lt;/li>
&lt;li>&lt;strong>Native CDC&lt;/strong>: handles inserts, updates and deletes correctly.&lt;/li>
&lt;li>&lt;strong>30-50 % lower TCO&lt;/strong> according to the figures Confluent publishes against traditional pipelines.&lt;/li>
&lt;/ul>
&lt;p>And since 2026, Tableflow + Flink SQL offer native vector search integrated with Cosmos DB and Amazon S3 Vectors. The RAG query can be made directly in 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;user query&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>This unifies layers that used to be separate (vector store + analytics). For many cases, it removes the need to maintain a dedicated vector store.&lt;/p>
&lt;h2 id="confluents-official-mcp-server">Confluent&amp;rsquo;s official MCP server&lt;/h2>
&lt;p>One piece added in 2026 deserves a mention: Confluent has published an official MCP server that exposes Kafka, Flink and Tableflow as tools accessible to AI agents over MCP. Any MCP client (Claude Desktop, Cursor, your own agents) can:&lt;/p>
&lt;ul>
&lt;li>List topics, read recent messages, publish to topics.&lt;/li>
&lt;li>Run Flink SQL queries in natural language (&amp;ldquo;give me the orders from the last 24 hours worth more than 1000 €&amp;rdquo;).&lt;/li>
&lt;li>Query Tableflow Iceberg tables.&lt;/li>
&lt;li>Manage Kafka Connect connectors.&lt;/li>
&lt;/ul>
&lt;p>This closes the circle: as well as reading data from the datalake through RAG (with vector search), your AI agent can write data to the log (through MCP) and trigger transformations (through Flink SQL in natural language). It is the deepest point of fusion between LLM ops and data ops this year.&lt;/p>
&lt;p>A connection with the earlier series: this MCP server emits traces with the OpenTelemetry GenAI MCP semantic conventions we covered in the &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability post&lt;/a>. The spans show up in Langfuse, Phoenix or your OTel backend with the right cardinality. Zero instrumentation code.&lt;/p>
&lt;h2 id="vector-stores-a-2026-comparison">Vector stores: a 2026 comparison&lt;/h2>
&lt;p>The five dominant options:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vector store&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>Operation&lt;/th>
&lt;th>Where it fits&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>Your DBA&lt;/td>
&lt;td>&amp;lt;10M vectors, Postgres-heavy team&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 or managed&lt;/td>
&lt;td>Mid-scale, performance focus&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 or Zilliz Cloud&lt;/td>
&lt;td>Large-scale, scalability focus&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 or managed&lt;/td>
&lt;td>Native hybrid search, semantically 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 or serverless&lt;/td>
&lt;td>Small-medium, simplicity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The choice depends on:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scale&lt;/strong>: pgvector falls short beyond 10M vectors. Milvus and Qdrant scale to billions.&lt;/li>
&lt;li>&lt;strong>Hybrid search&lt;/strong>: Weaviate brings lexical + vector natively. Others support it but less integrated.&lt;/li>
&lt;li>&lt;strong>Operation&lt;/strong>: pgvector if you already run Postgres. Qdrant if you want simplicity. Milvus if you need maximum scale.&lt;/li>
&lt;li>&lt;strong>Cloud managed&lt;/strong>: Zilliz Cloud for Milvus, Qdrant Cloud for Qdrant, Pinecone if you want pure SaaS (with no OSS behind it).&lt;/li>
&lt;/ul>
&lt;h2 id="freshness-vs-accuracy-the-operational-trade-off">Freshness vs accuracy: the operational trade-off&lt;/h2>
&lt;p>A critical decision any RAG system on Kafka must answer: when is a new document considered &amp;ldquo;live&amp;rdquo; in the index?&lt;/p>
&lt;p>Three options:&lt;/p>
&lt;p>&lt;strong>Synchronous streaming&lt;/strong>: the event reaches Kafka, Flink embeds it, the sink writes it to the vector store, and only then is it considered live. Typical latency: 1-5 seconds. The best freshness. But if the embedding model fails or the vector store is slow, events pile up in the topic.&lt;/p>
&lt;p>&lt;strong>Asynchronous streaming with a baseline&lt;/strong>: the event is considered live immediately; a background process embeds it when it can. In the meantime, queries asking for that document do not find it. Typical latency: 5-60 seconds. Acceptable for most applications.&lt;/p>
&lt;p>&lt;strong>Micro-batch&lt;/strong>: processing happens in mini-batches every 1-5 minutes. Less efficient than continuous streaming but more stable under variable load. Latency: 1-5 minutes.&lt;/p>
&lt;p>The decision depends on the product&amp;rsquo;s SLA. For customer support chatbots, 5-60 seconds is acceptable. For systems that react to critical events (financial prices, alarms), synchronous streaming is necessary.&lt;/p>
&lt;h2 id="schema-evolution-and-re-embedding">Schema evolution and re-embedding&lt;/h2>
&lt;p>When the embedding model changes (you move from &lt;code>text-embedding-3-small&lt;/code> to &lt;code>text-embedding-3-large&lt;/code>, or from OpenAI to Cohere), the vectors already in the index are incompatible: different dimensions, different semantic spaces. The distance between an old vector and a new one means nothing.&lt;/p>
&lt;p>The standard pattern for handling this: &lt;strong>dual-index&lt;/strong> during the migration.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>T0&lt;/strong>: the active index is V1 (embedding model A).&lt;/li>
&lt;li>&lt;strong>T1&lt;/strong>: a parallel pipeline starts writing to a V2 index (embedding model B), consuming the topic from offset 0 (reprocessing the whole log).&lt;/li>
&lt;li>&lt;strong>T2&lt;/strong>: V2 has caught up with the present.&lt;/li>
&lt;li>&lt;strong>T3&lt;/strong>: you switch the retriever over to V2.&lt;/li>
&lt;li>&lt;strong>T4&lt;/strong>: a week later, you discard V1.&lt;/li>
&lt;/ol>
&lt;p>The Kafka log makes this pattern feasible because it is &lt;strong>immutable and reproducible&lt;/strong>. Without the log, this pattern becomes a weeks-long data migration project.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;h3 id="topics-without-adequate-retention">Topics without adequate retention&lt;/h3>
&lt;p>Configuring topics with 7-day retention on the grounds that &amp;ldquo;I already have the vector store&amp;rdquo; means losing the ability to rebuild if the vector store fails. &lt;strong>Long retention (90+ days) or compaction by key&lt;/strong> for topics that feed RAG.&lt;/p>
&lt;h3 id="heavy-cdc-during-peak-load">Heavy CDC during peak load&lt;/h3>
&lt;p>Debezium reading the WAL at peak hours can hit the source database&amp;rsquo;s performance. &lt;strong>A dedicated read replica&lt;/strong> for Debezium, not the production primary. Or use logical replication restricted to just the tables you need.&lt;/p>
&lt;h3 id="embedding-cost-run-away">Embedding cost run-away&lt;/h3>
&lt;p>&lt;code>OPENAI_EMBEDDING&lt;/code> on every event of a topic with millions of messages a day comes to thousands of USD per month. Strategies: filter before embedding (only embed what adds value); deduplicate by content hash; use self-hosted open-source embedding models (BGE, E5, GTE) when the cloud cost is prohibitive.&lt;/p>
&lt;h3 id="slow-re-embedding-due-to-limited-throughput">Slow re-embedding due to limited throughput&lt;/h3>
&lt;p>Recomputing 10M embeddings with the OpenAI API at 3000 req/min takes 55 hours. If you wait for an incident to re-embed, that is two days without service. &lt;strong>Embedding throughput is an explicit capacity planning item&lt;/strong>; reserve capacity or keep an offline job ready to start.&lt;/p>
&lt;h3 id="downstream-schema-breaks">Downstream schema breaks&lt;/h3>
&lt;p>A change to the raw topic&amp;rsquo;s schema breaks Flink jobs downstream. &lt;strong>Schema Registry with BACKWARD compatibility made mandatory&lt;/strong>; never ALLOW_ALL. And test schema evolution in CI.&lt;/p>
&lt;h3 id="a-vector-store-with-no-backup">A vector store with no backup&lt;/h3>
&lt;p>Your vector store holds 50M vectors. It is the only copy (the topics expired). A failure wipes it. &lt;strong>Vector stores must be backed up&lt;/strong> like any primary persistence layer. For Milvus/Qdrant: periodic snapshots. For pgvector: plain pg_dump.&lt;/p>
&lt;h3 id="multi-region-without-cross-cluster-replication">Multi-region without cross-cluster replication&lt;/h3>
&lt;p>Your RAG serves users in the US and the EU. The vector store is in US-east. Latency from the EU = 100 ms+ per query. &lt;strong>MirrorMaker or Cluster Linking&lt;/strong> to replicate topics and vector stores in both regions.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Hybrid search in production&lt;/strong>: combining BM25/lexical + vector + reranker. A topic for its own post.&lt;/li>
&lt;li>&lt;strong>Multimodal RAG&lt;/strong>: indexing images, audio and video alongside text. Multimodal embeddings (CLIP, Imagebind), a specific architecture.&lt;/li>
&lt;li>&lt;strong>GraphRAG&lt;/strong>: using structured knowledge (knowledge graphs) as well as vector retrieval. Microsoft GraphRAG, LlamaIndex KnowledgeGraphQueryEngine.&lt;/li>
&lt;li>&lt;strong>RAG with multi-tenant ACLs&lt;/strong>: filtering by permissions at runtime. The pattern uses metadata in the vector store + server-side filters.&lt;/li>
&lt;li>&lt;strong>Query rewriting with an LLM&lt;/strong>: using a first LLM to expand the query before retrieval (HyDE, multi-query, step-back prompting).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Kafka and 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 and 2026 architecture:&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>Streaming RAG:&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>Previous post: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps specific to LLMs in 2026: the landscape&lt;/a>.&lt;/li>
&lt;li>Data series: &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant at the ingestion stage&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — the four pieces that close the Data block: streaming, ingest, curation and retrieval.&lt;/li>
&lt;li>Post-tracing series: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;li>eBPF series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>