cdc-streaming-ingestion

v2026.09.24

Real-time RAG ingestion. CDC (Debezium, Postgres logical replication), Kafka/ Pulsar topics for doc events, stream processing (Flink, Kafka Streams) to embedding service, exactly-once semantics, late-arriving updates, tombstones (deletes), upsert to vector DB, schema evolution. Full Debezium + Kafka -> vector DB example. USE WHEN: user mentions "CDC RAG", "Debezium RAG", "Kafka RAG", "real-time embeddings", "streaming ingestion", "Flink embeddings", "Pulsar RAG", "logical replication RAG" DO NOT USE FOR: batch scheduled ingestion - use `ingestion-orchestration`; query-time freshness weighting - use `time-aware-retrieval`; evaluation - use `rag-evaluation`

GitHub
安装命令
npx skhub add claude-dev-suite/cdc-streaming-ingestion
Markdown
SKILL.md

CDC / Streaming Ingestion

When to Go Real-Time

Scheduled ingestion (see ingestion-orchestration) suffices when freshness tolerance is minutes-to-hours. Switch to streaming when:

  • Users expect < 10s from source write to retrievable.
  • Source is high-throughput (> 100 writes/sec).
  • Deletes must propagate quickly (compliance).
  • Downstream consumers beyond RAG also need the stream.

Architecture

[Postgres]  --WAL-->  [Debezium]  -->  [Kafka topic: docs.changes]
                                               |
                                 +-------------+-------------+
                                 |                           |
                           [embed worker]              [other consumers]
                                 |
                           [Vector DB upsert]
                                 |
                         [Kafka topic: docs.indexed]   (offset log for observability)

Separation of concerns:

  • CDC produces change events.
  • Kafka is the durable, replayable event log.
  • Stream processor embeds and upserts.
  • Vector DB is the sink.

CDC Source: Postgres + Debezium

Postgres logical replication exposes WAL changes. Debezium Connect consumes it and publishes to Kafka.

Postgres setup

ALTER SYSTEM SET wal_level = 'logical';
SELECT pg_reload_conf();

CREATE PUBLICATION rag_pub FOR TABLE documents, articles;

CREATE USER debezium WITH REPLICATION LOGIN PASSWORD '***';
GRANT SELECT ON documents, articles TO debezium;

SELECT pg_create_logical_replication_slot('debezium_rag', 'pgoutput');

Debezium connector config

{
  "name": "postgres-rag",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "pg.internal",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${secret}",
    "database.dbname": "app",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_rag",
    "publication.name": "rag_pub",
    "topic.prefix": "rag",
    "table.include.list": "public.documents,public.articles",
    "tombstones.on.delete": "true",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter": "io.confluent.connect.avro.AvroConverter",
    "value.converter.schema.registry.url": "http://schema-registry:8081",
    "heartbeat.interval.ms": "30000",
    "heartbeat.action.query": "INSERT INTO debezium_heartbeat (ts) VALUES (now()) ON CONFLICT DO NOTHING"
  }
}

Key points:

  • pgoutput plugin comes with Postgres 10+; no separate install.
  • tombstones.on.delete=true sends a null-value record after a delete for log-compacted downstream topics.
  • Heartbeats keep the replication slot active on low-traffic tables.
  • Avro + schema registry for schema evolution discipline.

Event shape (Debezium op codes: c create, u update, d delete, r snapshot-read):

{
  "before": { "id": 42, "title": "Old title", ... },
  "after":  { "id": 42, "title": "New title", ... },
  "source": { "lsn": 987654321, "txId": 445, "ts_ms": 1744726800000 },
  "op": "u",
  "ts_ms": 1744726800012
}

Stream Processor: Python + Kafka

For moderate throughput, a Python consumer with aiokafka is enough.

import asyncio, json
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct

oai = AsyncOpenAI()
qdr = AsyncQdrantClient(url="http://qdrant:6333")

async def process():
    consumer = AIOKafkaConsumer(
        "rag.public.documents",
        bootstrap_servers="kafka:9092",
        group_id="rag-embedder",
        enable_auto_commit=False,           # manual commit after successful upsert
        auto_offset_reset="earliest",
    )
    producer = AIOKafkaProducer(bootstrap_servers="kafka:9092",
                                 enable_idempotence=True, acks="all")
    await consumer.start(); await producer.start()

    try:
        async for msg in consumer:
            if msg.value is None:
                # Tombstone -> delete from vector DB
                doc_id = json.loads(msg.key.decode())["id"]
                await qdr.delete(collection_name="kb",
                                 points_selector={"filter": {"must": [
                                     {"key": "doc_id", "match": {"value": doc_id}}
                                 ]}})
                await consumer.commit(); continue

            evt = json.loads(msg.value.decode())
            op = evt["op"]
            doc = evt["after"] if op in ("c", "u", "r") else evt["before"]
            doc_id = doc["id"]

            if op == "d":
                await qdr.delete(collection_name="kb",
                                 points_selector={"filter": {"must": [
                                     {"key": "doc_id", "match": {"value": doc_id}}
                                 ]}})
            else:
                chunks = chunk_doc(doc)
                texts = [c["text"] for c in chunks]
                emb = await oai.embeddings.create(model="text-embedding-3-small", input=texts)
                points = [
                    PointStruct(
                        id=f"{doc_id}-{i}",
                        vector=emb.data[i].embedding,
                        payload={"doc_id": doc_id, "chunk_index": i,
                                 "text": chunks[i]["text"], "lsn": evt["source"]["lsn"]},
                    )
                    for i in range(len(chunks))
                ]
                # Delete old chunks then upsert new — handles chunk count change
                await qdr.delete(collection_name="kb",
                                 points_selector={"filter": {"must": [
                                     {"key": "doc_id", "match": {"value": doc_id}}
                                 ]}})
                await qdr.upsert(collection_name="kb", points=points)

            await producer.send("rag.indexed", key=msg.key,
                                value=json.dumps({"doc_id": doc_id, "op": op}).encode())
            await consumer.commit()
    finally:
        await consumer.stop(); await producer.stop()

asyncio.run(process())
  • enable_auto_commit=False with manual commit after upsert => at-least-once delivery.
  • Delete-then-upsert handles chunk count changes on update.
  • Emit to rag.indexed topic for observability.

Exactly-Once Semantics

Kafka exactly-once (EOS) requires transactional writes. The vector DB is usually not transactional with Kafka, so you cannot get true EOS end-to-end. Alternative: make upserts idempotent.

# Use stable point IDs: f"{doc_id}-{chunk_index}"
# Use upsert (not insert) in the vector DB.
# Include LSN in payload; on reprocess, skip if stored LSN >= event LSN.

async def idempotent_upsert(point: PointStruct):
    existing = await qdr.retrieve(collection_name="kb", ids=[point.id], with_payload=True)
    if existing and existing[0].payload.get("lsn", 0) >= point.payload["lsn"]:
        return  # already applied a newer or equal version
    await qdr.upsert(collection_name="kb", points=[point])

Result: at-least-once delivery + idempotent sink = effectively exactly-once.

Late-Arriving Updates

CDC can reorder across partitions. Pin all events of a given document to the same partition (by primary key) so updates arrive in order per document.

Debezium does this by default when message.key is the table primary key. Verify:

kafka-console-consumer --topic rag.public.documents --property print.key=true
# Key: {"id":42} for all events about document 42

Cross-document ordering is not guaranteed; it doesn't matter for embedding.

Tombstones (Deletes)

Debezium produces two messages on delete:

  1. The delete event (op=d, before populated, after null).
  2. A tombstone (null value) for log compaction.

Handle both in the consumer:

  • On op=d: delete from vector DB.
  • On null value (tombstone): also delete (defensive; safe to re-delete).

Filter by op == "d" if your vector DB delete is costly:

if evt.get("op") == "d":
    ...  # delete

Flink Streaming (high throughput, stateful)

For > 10k events/sec or stateful joins (enrich from another topic before embedding), use Flink.

# Flink SQL (PyFlink)
from pyflink.table import EnvironmentSettings, TableEnvironment

env = TableEnvironment.create(EnvironmentSettings.in_streaming_mode())

env.execute_sql("""
CREATE TABLE documents (
    id BIGINT,
    title STRING,
    body STRING,
    updated_at TIMESTAMP(3),
    PRIMARY KEY (id) NOT ENFORCED
) WITH (
    'connector' = 'kafka',
    'topic' = 'rag.public.documents',
    'properties.bootstrap.servers' = 'kafka:9092',
    'format' = 'debezium-json',
    'scan.startup.mode' = 'earliest-offset'
)""")

env.execute_sql("""
CREATE TABLE enriched AS
SELECT d.id, d.title, d.body, u.tenant_id, u.role
FROM documents d
LEFT JOIN users FOR SYSTEM_TIME AS OF d.updated_at AS u ON d.author_id = u.id
""")
# Sink to another topic consumed by the embed worker.

debezium-json format understands the CDC envelope natively. Temporal joins enrich events with slowly changing dimensions.

Schema Evolution

  1. Use Avro with a schema registry (Confluent, Redpanda, Apicurio).
  2. Enforce backward-compatible changes: add optional fields only.
  3. Track schema version in vector DB metadata.
  4. On breaking change, publish to a new topic (rag.public.documents.v2) and run both consumers during migration.
# In the upsert payload
payload = {"doc_id": doc_id, "schema_version": "v2", "lsn": lsn, ...}

When the embedder version changes (different embedding dimension/model), do a blue-green re-index off the CDC stream — start a new consumer group on earliest, write to a new collection, switch alias.

Backpressure and Rate Limits

Embedding APIs have rate limits. Surge events can blow them. Options:

  1. Batch events before embedding (collect for 1-2s or 64 docs).
  2. Use a concurrency semaphore.
  3. Configure consumer max.poll.records to cap per-poll batch.
import asyncio
semaphore = asyncio.Semaphore(8)

async def embed_batch(batch: list[dict]):
    async with semaphore:
        texts = [b["text"] for b in batch]
        return await oai.embeddings.create(model="text-embedding-3-small", input=texts)

Monitoring

  • Consumer lag: kafka.consumer.lag per topic/partition. Alert > 1000.
  • Replication slot lag: pg_replication_slots.confirmed_flush_lsn vs current WAL.
  • Embedding throughput: events/sec, rolling P95.
  • Vector DB upsert errors: by error class.
  • End-to-end latency: now() - evt.ts_ms percentile dashboard.
-- Postgres: watch replication slot lag
SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS bytes_behind
FROM pg_replication_slots WHERE slot_name = 'debezium_rag';

An unconsumed slot holds WAL on disk. Alert above a few GB.

Anti-Patterns

Anti-PatternFix
Auto-commit offset before upsertData loss on crash; manual commit after success
No tombstone handlingDeletes never propagate; stale vectors linger
Single partitionNo parallelism; repartition by primary key
Embedding one event at a timeBatch to amortize API overhead
No schema registrySilent schema drift breaks consumers weeks later
Forgetting to drop the replication slotWAL accumulates forever; disk fills
Cross-document ordering dependenciesKey by primary key; do not require global order
Same consumer group for test and prodTest consumes prod; isolate groups
Re-embedding on every update regardless of contentContent-hash gate still applies
No end-to-end latency SLOYou cannot prove freshness without one

Production Checklist

  • Postgres wal_level=logical, publication created, replication user configured
  • Debezium connector deployed with heartbeats enabled
  • Schema registry (Avro) in use
  • Tombstones on delete enabled
  • Consumer group with manual offset commit
  • Idempotent upsert with stable point IDs + LSN gate
  • Batching + concurrency semaphore on embedding API
  • Dead-letter topic for poison messages
  • Replication slot lag alert
  • Consumer group lag alert
  • End-to-end latency SLO (e.g., P95 < 10s)
  • Content-hash gate to skip no-op updates
  • Blue-green re-index procedure for embedding model changes
  • Runbook for replication slot recovery (drop + recreate from snapshot)
  • Observability topic (rag.indexed) consumed by dashboards
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/rag/cdc-streaming-ingestion

默认分支

main

最新提交

9496306

Tree SHA

fe4e2f1