← Back to DevBytes

How to Build a Streaming RAG Pipeline with Kafka

How to Build a Streaming RAG Pipeline with Kafka

Retrieval-Augmented Generation (RAG) has become the standard pattern for grounding large language models in private or rapidly changing data. Most RAG tutorials, however, describe a static world: documents are ingested once, embedded, and stored in a vector database before queries ever arrive. Real applications rarely work this way. Documents arrive continuously, embeddings go stale, and users expect answers that reflect the latest information. A streaming RAG pipeline built on top of Apache Kafka solves this problem by treating ingestion, embedding, indexing, and retrieval as continuous, decoupled events.

What Is a Streaming RAG Pipeline?

A streaming RAG pipeline is a RAG architecture in which every stage — document ingestion, chunking, embedding, vector storage, and even query handling — is connected through an event stream rather than batch jobs or synchronous API calls. Kafka acts as the central nervous system, carrying events between producers and consumers that each handle one responsibility.

In a traditional batch RAG setup, you might run a nightly job that crawls a knowledge base, chunks documents, calls an embedding API, and writes vectors to Pinecone or pgvector. In a streaming setup, the moment a new document is published — a support ticket, a wiki edit, a Slack message — an event lands on a Kafka topic, and downstream consumers react in real time. By the time a user asks a question, the relevant context is already indexed.

Why Kafka for RAG?

Kafka provides several properties that map cleanly onto the demands of a production RAG system:

Architecture Overview

The pipeline consists of five logical stages, each backed by a Kafka topic:

Each topic is consumed by a small, focused service. This separation lets you swap embedding models, change vector stores, or add enrichment steps without rewriting the whole system.

Setting Up Kafka Locally

The fastest way to get a Kafka broker running for development is Docker Compose. Save the following as docker-compose.yml:

version: "3.8"
services:
  kafka:
    image: bitnami/kafka:3.7
    ports:
      - "9092:9092"
    environment:
      KAFKA_CFG_NODE_ID: 0
      KAFKA_CFG_PROCESS_ROLES: controller,broker
      KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
      KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT

Start it with docker compose up -d. You now have a single-node Kafka cluster running in KRaft mode, without Zookeeper.

Producing Raw Documents

The first stage publishes documents to the raw-documents topic. In production this would be a Kafka Connect source connector pulling from Postgres or S3. For the tutorial, we will use a Python producer.

import json
from kafka import KafkaProducer

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

def publish_document(doc_id: str, source: str, text: str):
    event = {
        "doc_id": doc_id,
        "source": source,
        "text": text,
        "timestamp": __import__("time").time(),
    }
    producer.send("raw-documents", value=event)
    producer.flush()

publish_document("doc-001", "wiki", "Kafka is a distributed event streaming platform...")
publish_document("doc-002", "support", "How do I configure retention for a Kafka topic?")

Notice that we key events by doc_id implicitly through topic partitioning. In a real system, pass key=doc_id.encode() to send so that all events for the same document land on the same partition and stay ordered.

Chunking Consumer

The chunking consumer reads raw documents, splits them into overlapping text chunks, and publishes each chunk to the chunks topic. Keeping chunking as a separate stage means you can re-chunk the entire corpus by rewinding the consumer offset when you change your chunking strategy.

import json
from kafka import KafkaConsumer, KafkaProducer

consumer = KafkaConsumer(
    "raw-documents",
    bootstrap_servers="localhost:9092",
    group_id="chunker",
    auto_offset_reset="earliest",
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

def chunk_text(text: str, size: int = 500, overlap: int = 50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + size
        chunks.append(text[start:end])
        start = end - overlap
    return chunks

for msg in consumer:
    doc = msg.value
    for i, chunk in enumerate(chunk_text(doc["text"])):
        producer.send("chunks", value={
            "chunk_id": f"{doc['doc_id']}-c{i}",
            "doc_id": doc["doc_id"],
            "source": doc["source"],
            "text": chunk,
            "index": i,
        })
    producer.flush()

Embedding Consumer

The embedding consumer reads chunks, calls an embedding model, and publishes vectors to the embeddings topic. This is the stage most likely to hit rate limits or latency, which is exactly why it benefits from Kafka's buffering. If the embedding API is slow, events simply queue in the topic.

import json
from kafka import KafkaConsumer, KafkaProducer
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

consumer = KafkaConsumer(
    "chunks",
    bootstrap_servers="localhost:9092",
    group_id="embedder",
    auto_offset_reset="earliest",
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

for msg in consumer:
    chunk = msg.value
    vector = model.encode(chunk["text"]).tolist()
    producer.send("embeddings", value={
        "chunk_id": chunk["chunk_id"],
        "doc_id": chunk["doc_id"],
        "source": chunk["source"],
        "text": chunk["text"],
        "vector": vector,
    })
    producer.flush()

For production workloads, replace the local model with a call to an API like OpenAI or a self-hosted inference server, and add batching — encode multiple chunks per API call to reduce cost and latency.

Indexing into the Vector Store

The final ingestion consumer writes embeddings to a vector database. The example below uses pgvector with psycopg, but the pattern is identical for Pinecone, Weaviate, or Qdrant.

import json
import psycopg
from kafka import KafkaConsumer

consumer = KafkaConsumer(
    "embeddings",
    bootstrap_servers="localhost:9092",
    group_id="indexer",
    auto_offset_reset="earliest",
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)

conn = psycopg.connect("dbname=rag user=postgres")
conn.autocommit = True

for msg in consumer:
    rec = msg.value
    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO chunks (chunk_id, doc_id, source, text, embedding)
            VALUES (%s, %s, %s, %s, %s)
            ON CONFLICT (chunk_id) DO UPDATE SET text = EXCLUDED.text,
                                                  embedding = EXCLUDED.embedding
            """,
            (rec["chunk_id"], rec["doc_id"], rec["source"],
             rec["text"], rec["vector"]),
        )

The ON CONFLICT clause makes the indexer idempotent. If you replay the topic after a crash, duplicate writes simply update the existing row. Idempotency is essential in any streaming pipeline because exactly-once semantics are expensive and often unnecessary when downstream writes are deduplicated.

Handling User Queries as Events

So far the pipeline handles ingestion. For a fully streaming system, user queries can also flow through Kafka. A query service publishes the question, a retrieval consumer performs vector search and calls the LLM, and the answer is published back to a response topic the user's session is subscribed to.

import json
import psycopg
from kafka import KafkaConsumer, KafkaProducer
from openai import OpenAI

client = OpenAI()
conn = psycopg.connect("dbname=rag user=postgres")

consumer = KafkaConsumer(
    "user-queries",
    bootstrap_servers="localhost:9092",
    group_id="rag-responder",
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)

producer = KafkaProducer(
    bootstrap_servers="localhost:9092",
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

for msg in consumer:
    query = msg.value
    q_vec = client.embeddings.create(
        input=query["question"], model="text-embedding-3-small"
    ).data[0].embedding

    with conn.cursor() as cur:
        cur.execute(
            "SELECT text FROM chunks ORDER BY embedding <-> %s LIMIT 5",
            (q_vec,),
        )
        context = "\n\n".join(r[0] for r in cur.fetchall())

    prompt = f"Answer using this context:\n{context}\n\nQuestion: {query['question']}"
    answer = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    ).choices[0].message.content

    producer.send("query-responses", value={
        "query_id": query["query_id"],
        "answer": answer,
    })
    producer.flush()

Best Practices

Conclusion

Building a RAG pipeline on Kafka transforms retrieval-augmented generation from a fragile batch job into a resilient, continuously running system. Each stage — ingestion, chunking, embedding, indexing, and retrieval — becomes an independent consumer that can scale, fail, and recover without affecting the others. The result is a pipeline that stays fresh as your data changes, tolerates outages gracefully through Kafka's durability and replay, and grows cleanly as you add new sources, models, or vector stores. Start with the single-node setup above, make every consumer idempotent, and you will have a foundation that scales from a prototype to a production-grade real-time knowledge service.

— Ad —

Google AdSense will appear here after approval

← Back to all articles