RAG

Real-Time RAG Pipeline

A RAG system is only as current as its index. This guide builds a pipeline where documents are chunked, embedded and indexed in-stream, versions and deletions resolve at read time, metadata filters come live from the database, and retrieval is one SQL query. With the comparison to batch re-indexing, and the code to run it.

Last reviewed 2026-11-09. Covers RisingWave 2.6 and later; the streaming lookup needs 2.7.

Companion guide: Real-Time Context for AI Agents →

Seconds
Index Lag
A published version is chunked, embedded and indexed within a few seconds, no re-index job
1 query
Hybrid Retrieval
Vector distance, live metadata filters and feedback rerank in one SELECT
0 deletes
On the Index
Versions and deletions resolved at read time against a latest-version view
HNSW
Native
vector(n) columns, HNSW indexes and openai_embedding inside RisingWave

The Problem

Why do RAG pipelines go stale, and why does it matter?

The standard RAG pipeline is a batch job: crawl or export the documents, split them, call the embedding API, upsert vectors and metadata into a vector store, repeat on a schedule. It works for a corpus that rarely changes. It fails quietly for anything operational, and the failures are of three kinds.

The first is document staleness: a returns policy edited at 10:00 is still answered from the old text until the nightly run. The second is deletion staleness, which is worse: a retracted announcement or a document moved behind a permission boundary keeps being retrieved until the index is rebuilt, because deletes are the step batch pipelines most often skip. The third is metadata staleness: price, stock and availability were copied into the vector payload when the chunk was indexed, and the model confidently quotes a price that changed an hour ago.

Teams patch these with more jobs: an hourly incremental run, a delete sweep, a metadata sync. Each adds a system and a way to drift. The alternative is to treat the pipeline as a stream: documents are versions on a topic, metadata is a change stream, and chunking, embedding and indexing are views that react to both.

Source of stalenessBatch re-index pipelineIn-stream pipeline
New or edited documentRetrievable after the next scheduled run, often hoursRetrievable seconds after publication
Deleted or restricted documentKeeps being retrieved until the next full rebuildExcluded on the next barrier via the latest-version view
Price, stock, availability in the answerFrozen into chunk metadata at index timeJoined live from CDC at query time
Permissions and regionFiltered on stale metadataFiltered on current metadata
Embedding model upgradeFull re-embed job, index swapNew view with the new model, backfilled, then switch the query
Pieces to operateScheduler, embedding job, vector store, metadata syncOne streaming database

Architecture

What does an in-stream RAG pipeline look like?

The stages are the same as in a batch pipeline. What changes is that each stage is a materialized view reacting to the stage before it, instead of a step in a job. Documents arrive as versions on a Kafka topic. A view splits each version into chunks; a second view embeds each chunk by calling the embedding API from SQL; an HNSW index is built over that view. Metadata tables arrive by CDC and are joined at query time, never copied into the index. A latest-version view resolves edits and deletions at read time. The retrieval query combines all of it, and a feedback table closes the loop.

StageBatch pipelineIn-stream on RisingWave
IngestCrawler or export on a scheduleKafka topic of document versions; CDC for metadata tables
ChunkPython splitter in the jobregexp_split_to_array and unnest in a materialized view
EmbedBatch calls to the embedding APIopenai_embedding in the view, one call per new chunk
IndexUpsert into the vector storeHNSW index on the append-only embeddings view
Versions and deletesDelete by id, or rebuildLatest-version view joined at read time
MetadataCopied into vector payloadsCDC tables joined live
RetrieveVector query, then filter in codeOne SQL query: distance, filters, rerank
FeedbackLogged elsewhereTable the app writes, view the query reranks with

One constraint shapes the design: RisingWave builds vector indexes only on append-only inputs. That is why documents are versions rather than an updatable table, why deletions are versions too, and why the current version is resolved by a join at read time rather than by deleting from the index. The constraint turns out to be a feature. The index never has to handle a delete, every version remains available for audit, and a rollback is a new version that repeats old content.

Reference Implementation

A product-knowledge assistant with a live index, in SQL and Python

The scenario is an e-commerce assistant that answers questions from product documentation and policies. Documents are published as versions to Kafka by the content system. Product price and stock live in PostgreSQL. Some documents apply to one region. The assistant must never cite a retracted document, never quote a stale price, and never recommend an out-of-stock product. Everything below runs on a single-node RisingWave started with the Docker command from the companion guide.

Step 1. Ingest versions and metadata

The topic carries every version of every document, including a final version with deleted set to true when a document is withdrawn. The products table arrives as a change stream, so a price update is an update to one row, visible on the next barrier.

SQL (RisingWave)
-- Every edit is a new version. A deletion is a version with deleted = true.
-- The topic is append-only, which is what the vector index downstream requires.
CREATE SOURCE doc_versions (
  doc_id       BIGINT,
  version      INT,
  title        VARCHAR,
  body         VARCHAR,
  product_id   BIGINT,           -- NULL for policies and general articles
  region       VARCHAR,          -- 'us' | 'eu' | 'all'
  deleted      BOOLEAN,
  published_at TIMESTAMPTZ
) WITH (
  connector = 'kafka', topic = 'doc_versions',
  properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;

-- Live metadata the answer must respect comes from the product database as change streams.
CREATE SOURCE shop_pg WITH (
  connector = 'postgres-cdc', hostname = 'pg', port = '5432',
  username = 'rw', password = '...', database.name = 'shop', schema.name = 'public'
);

CREATE TABLE products (
  product_id BIGINT PRIMARY KEY, name VARCHAR, price NUMERIC,
  in_stock BOOLEAN, discontinued BOOLEAN
) FROM shop_pg TABLE 'public.products';

Step 2. Chunk, embed and index in-stream

The embed function wraps openai_embedding so the model and key live in one place and the same function serves indexing and querying. Chunking splits on blank lines and drops short fragments; because unnest only adds rows, the chunk view stays append-only, and so does the embeddings view built on it, which is what allows the HNSW index. INCLUDE lists the columns the retrieval query needs so the index alone can answer it.

SQL (RisingWave)
-- The embedding model and key are defined once, in a SQL function used for both indexing and querying.
CREATE FUNCTION embed(VARCHAR) RETURNS VECTOR(1536) LANGUAGE SQL AS $$
  SELECT openai_embedding(
    '{"model": "text-embedding-3-small", "api_key": "sk-..."}'::jsonb, $1
  )::vector(1536)
$$;

-- Chunk in SQL: split on blank lines, drop fragments. unnest preserves the append-only property.
CREATE MATERIALIZED VIEW doc_chunks AS
SELECT d.doc_id, d.version, d.title, d.product_id, d.region, d.published_at,
       c.chunk
FROM doc_versions d,
     unnest(regexp_split_to_array(d.body, E'\n\s*\n')) AS c(chunk)
WHERE d.deleted = false
  AND length(c.chunk) > 40;

-- Embed in-stream. One API call per chunk per version, made once, when the version arrives.
CREATE MATERIALIZED VIEW chunk_embeddings AS
SELECT doc_id, version, title, product_id, region, published_at, chunk,
       embed(title || E'\n' || chunk) AS embedding
FROM doc_chunks;

-- The index. INCLUDE carries the columns the retrieval query returns and filters on.
CREATE INDEX chunk_embeddings_hnsw ON chunk_embeddings
USING HNSW (embedding)
INCLUDE (doc_id, version, title, product_id, region, published_at, chunk)
WITH (distance_type = 'cosine', m = 16, ef_construction = 64);

Step 3. Resolve versions, record feedback

doc_current is a group Top-N over all versions, including deletions, so it always knows which version is live and whether the document still exists. It is not append-only, which is fine: it is joined, not indexed. The feedback table is written by the application after each answer, and a view turns it into a helpfulness ratio per document version that the retrieval query uses to rerank.

SQL (RisingWave)
-- Which version of each document is current? Group Top-N over all versions, including deletions.
CREATE MATERIALIZED VIEW doc_current AS
SELECT doc_id, version, deleted, region, product_id, published_at
FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY doc_id ORDER BY version DESC) AS rn
  FROM doc_versions
) t
WHERE rn = 1;

-- Feedback the application writes after each answer: which chunks were shown, and did they help.
CREATE TABLE retrieval_feedback (
  feedback_id VARCHAR PRIMARY KEY,
  doc_id      BIGINT,
  version     INT,
  helpful     BOOLEAN,
  created_at  TIMESTAMPTZ
);

CREATE MATERIALIZED VIEW doc_feedback AS
SELECT doc_id, version,
       COUNT(*)                          AS shown,
       COUNT(*) FILTER (WHERE helpful)   AS helpful
FROM retrieval_feedback
GROUP BY doc_id, version;

Step 4. Hybrid retrieval in one query

The query does four things that a batch pipeline spreads over three systems and some application code: nearest-neighbour search through the index, exclusion of superseded and deleted versions, filtering on live region and stock, and a rerank that nudges documents users found helpful. Raising batch_hnsw_ef_search gives the index more candidates to survive the filters. The published_at column comes back with every chunk so the prompt can state how current its source is.

SQL (RisingWave): retrieve.sql
-- retrieve.sql: hybrid retrieval in one statement.
--   $1 question text, $2 caller's region
-- Vector distance from the index, current-version and deletion check, live stock filter,
-- and a small rerank from feedback. Raise ef_search when filters are selective.
SET batch_hnsw_ef_search = 200;

SELECT e.doc_id, e.version, e.title, e.chunk,
       p.name AS product, p.price, p.in_stock,
       e.published_at,
       (e.embedding <=> embed($1)) AS distance
FROM chunk_embeddings e
JOIN doc_current d
  ON d.doc_id = e.doc_id AND d.version = e.version          -- current version only
LEFT JOIN products p ON p.product_id = e.product_id
LEFT JOIN doc_feedback f ON f.doc_id = e.doc_id AND f.version = e.version
WHERE d.deleted = false
  AND (e.region = 'all' OR e.region = $2)
  AND (p.product_id IS NULL OR (p.in_stock AND NOT p.discontinued))
ORDER BY (e.embedding <=> embed($1))
         - 0.05 * COALESCE(f.helpful::float / NULLIF(f.shown, 0), 0)   -- helpful chunks rank higher
LIMIT 8;

Step 5. Plug it into LangChain

Because the retrieval is plain SQL over the PostgreSQL protocol, a LangChain retriever is a thin class. The same query works from LlamaIndex, from a bare psycopg call, or through the RisingWave MCP server for agents in an MCP host. The connection uses a read-only user with SELECT on the views and INSERT on retrieval_feedback only.

Python (LangChain, psycopg 3)
from typing import List
import re, psycopg
from langchain_core.documents import Document
from langchain_core.retrievers import BaseRetriever

RETRIEVE_SQL = open("retrieve.sql").read()

def bind(sql: str) -> str:
    """The SQL files use Postgres $n placeholders; psycopg wants %(name)s."""
    return re.sub(r"\$(\d+)", r"%(p\1)s", sql)

class RisingWaveRetriever(BaseRetriever):
    """Hybrid retrieval over RisingWave: vector distance plus live metadata, in one query."""
    dsn: str = "postgresql://rag_ro@rw:4566/dev"
    region: str = "us"

    def _get_relevant_documents(self, query: str) -> List[Document]:
        with psycopg.connect(self.dsn, autocommit=True) as conn, conn.cursor() as cur:
            cur.execute(bind(RETRIEVE_SQL), {"p1": query, "p2": self.region})
            rows = cur.fetchall()
        return [
            Document(
                page_content=chunk,
                metadata={"doc_id": doc_id, "version": version, "title": title,
                          "product": product, "price": price, "in_stock": in_stock,
                          "published_at": str(published_at), "distance": distance},
            )
            for (doc_id, version, title, chunk, product, price, in_stock,
                 published_at, distance) in rows
        ]

# Use it like any retriever. Each Document carries the live price and stock,
# so the prompt can say "as of now" and mean it.
retriever = RisingWaveRetriever(region="eu")
docs = retriever.invoke("Can I return a sale item after 30 days?")

Step 6. Measure freshness

Freshness is a number, and it should be monitored like any other. The probe publishes a document containing a nonce and polls until a retrieval finds it. Run it on a schedule and alert when the lag exceeds the budget; the usual causes are embedding API latency and a backfill in progress.

Python: freshness probe
import json, time, uuid, psycopg
from kafka import KafkaProducer

# Freshness probe: publish a version with a nonce, time until it is retrievable.
nonce = uuid.uuid4().hex
producer = KafkaProducer(bootstrap_servers="broker:9092",
                         value_serializer=lambda v: json.dumps(v).encode())
producer.send("doc_versions", {
    "doc_id": 999_999_999, "version": int(time.time()), "title": "freshness probe",
    "body": f"Probe document {nonce}. It exists only to measure indexing latency.",
    "product_id": None, "region": "all", "deleted": False,
    "published_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
})
producer.flush()
t0 = time.time()

rw = psycopg.connect("postgresql://rag_ro@rw:4566/dev", autocommit=True)
while True:
    row = rw.execute(
        "SELECT 1 FROM chunk_embeddings e JOIN doc_current d USING (doc_id, version) "
        "WHERE e.chunk LIKE %s LIMIT 1", (f"%{nonce}%",)).fetchone()
    if row:
        break
    time.sleep(0.2)
print(f"indexed and retrievable after {time.time() - t0:.1f} s")   # typically 2 to 5 s

Optional: retrieve as a stream

When questions themselves arrive as a stream, for example from a support queue, RisingWave 2.7 can run the nearest-neighbour lookup inside a materialized view. Each question row is enriched with its top candidates as it arrives, and the agent reads one row per question instead of running a query.

SQL (RisingWave 2.7+)
-- Optional: pre-retrieve for every incoming question as a stream (RisingWave 2.7 and later).
-- Each question row gets its top 5 chunk ids attached, so the agent reads one row per question.
CREATE SOURCE questions (
  question_id VARCHAR, text VARCHAR, region VARCHAR, asked_at TIMESTAMPTZ
) WITH (connector = 'kafka', topic = 'questions',
        properties.bootstrap.server = 'broker:9092') FORMAT PLAIN ENCODE JSON;

CREATE MATERIALIZED VIEW question_candidates AS
SELECT q.question_id, q.text, q.asked_at,
       array(
         SELECT row(e.doc_id, e.version, e.title)
         FROM chunk_embeddings e FOR SYSTEM_TIME AS OF PROCTIME()
         ORDER BY embed(q.text) <=> e.embedding
         LIMIT 5
       ) AS candidates
FROM questions q;

All of the above is documented RisingWave SQL: see the vector indexes, vector type, AI functions and Top-N by group pages.

Alternatives

How do RAG pipeline architectures compare on freshness?

Every approach below produces working RAG. The columns are the ones that separate them once the corpus starts changing: how long until a change is retrievable, whether metadata is live or copied, how deletions propagate, and how many systems are involved.

ApproachIndex lagMetadataDeletionsSystems
Scheduled re-index into Pinecone, Weaviate, Qdrant or Milvus (Airflow or cron)Hours, per scheduleCopied into payloads at index time; drifts between runsExplicit delete calls, or full rebuildScheduler, embedding job, vector store, source sync
LangChain or LlamaIndex ingestion pipeline with a document storePer pipeline run; incremental with hashingIn the document store; refreshed per runRecord manager tracks and removes orphansPipeline runner, document store, vector store
PostgreSQL plus pgvector with triggers or cronSeconds with triggers, minutes with cronLive, same databaseRow deletesOne, until embedding load and HNSW builds compete with the application
Elasticsearch or OpenSearch ingest pipelines with a kNN indexSeconds to minutesIndexed with the document; updated by reindexing the documentDocument deletesSearch cluster, ingest pipeline, source connectors
Managed RAG (Bedrock Knowledge Bases, Vertex AI Search)Per sync job, typically minutes to hoursMetadata files synced with the documentsRemoved on syncManaged; limited control over chunking, filters and freshness
Kafka plus Flink plus a vector databaseSecondsEnriched in the Flink jobJob issues deletes to the storeStreaming platform, Flink, vector store, embedding service
RisingWave, in-stream (this page)SecondsJoined live from CDC at query timeResolved at read time via the latest-version viewOne, plus optional sinks

Design Decisions

Six questions to settle before you build

Chunk by structure or by token count?

Start with structure (paragraphs, sections) in SQL: it is transparent, cheap and changes with a new view. Move to a tokenizer-aware splitter in the producer when chunks routinely exceed the embedding model's limit or when retrieval quality is measurably hurt by uneven chunk sizes. Either way, embed the title with each chunk so short chunks keep their context.

How do I control embedding spend?

Embed once per chunk per version and never at query time more than once per question. Avoid re-publishing unchanged documents, since every version is embedded; if the producer cannot guarantee that, publish chunk-level events with a content hash and let the topic's compaction or the producer dedupe them. Watch the openai_embedding failure counter in Prometheus for retry storms.

Filter before or after the nearest-neighbour search?

Write the filters in the same query and let RisingWave combine them with the index scan. When filters are selective (one region, in-stock items only), raise batch_hnsw_ef_search so enough candidates survive the filter to fill the LIMIT. Check recall on a sample of questions after changing either the filters or ef_search.

How do deletes and permission changes reach the answer?

Never by deleting from the index. A deletion is a new version with deleted = true; a permission change is a new version with a new region or an updated row in a CDC table. The retrieval query joins the current-version view and the live metadata, so the chunk stops being returned on the next barrier.

Should I keep my existing vector database?

If it holds a large corpus and the application already depends on its API, yes: keep it for retrieval and use RisingWave for the structured, time-sensitive part of the prompt, or sink changed chunks from chunk_embeddings to it so it stays fresh without a batch job. Consolidate when the second pipeline becomes the thing that is always stale.

How do I evaluate a real-time pipeline?

Measure three things continuously: indexing lag with the probe above, retrieval quality with a fixed question set scored against expected documents, and answer quality with the feedback table. The feedback view feeds back into ranking, so the evaluation loop and the serving loop share one table.

When Not to Use This

Three cases where a batch pipeline or a dedicated store is the better choice

  • →A static corpus that changes a few times a year. Embed it once with any tool and store it anywhere; freshness is not the problem.
  • →Retrieval that depends on lexical ranking, multi-vector or sparse-dense fusion as the primary signal. Use a search engine or vector database built for that, and bring RisingWave in for the live structured context.
  • →Hundreds of millions of vectors with sub-10-millisecond p99 at very high QPS. Keep chunking, embedding and version resolution in RisingWave and sink the embeddings to a dedicated serving store.

Frequently Asked Questions

What is a real-time RAG pipeline?
How fresh can the index be?
Do I still need a dedicated vector database?
How are document updates and deletions handled?
Does it work with LangChain and LlamaIndex?
What does in-stream embedding cost?
Can I combine vector search with keyword search?
Where should chunking happen?

Make the index as current as the source

Run the pipeline on RisingWave Cloud or a single Docker container.

Start Free
The engine for real-time streaming data.
Analyze, search, and decide.
GitHubXLinkedInSlackYouTube
Sign up for our to stay updated.