RAG
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 →
The Problem
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 staleness | Batch re-index pipeline | In-stream pipeline |
|---|---|---|
| New or edited document | Retrievable after the next scheduled run, often hours | Retrievable seconds after publication |
| Deleted or restricted document | Keeps being retrieved until the next full rebuild | Excluded on the next barrier via the latest-version view |
| Price, stock, availability in the answer | Frozen into chunk metadata at index time | Joined live from CDC at query time |
| Permissions and region | Filtered on stale metadata | Filtered on current metadata |
| Embedding model upgrade | Full re-embed job, index swap | New view with the new model, backfilled, then switch the query |
| Pieces to operate | Scheduler, embedding job, vector store, metadata sync | One streaming database |
Architecture
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.
| Stage | Batch pipeline | In-stream on RisingWave |
|---|---|---|
| Ingest | Crawler or export on a schedule | Kafka topic of document versions; CDC for metadata tables |
| Chunk | Python splitter in the job | regexp_split_to_array and unnest in a materialized view |
| Embed | Batch calls to the embedding API | openai_embedding in the view, one call per new chunk |
| Index | Upsert into the vector store | HNSW index on the append-only embeddings view |
| Versions and deletes | Delete by id, or rebuild | Latest-version view joined at read time |
| Metadata | Copied into vector payloads | CDC tables joined live |
| Retrieve | Vector query, then filter in code | One SQL query: distance, filters, rerank |
| Feedback | Logged elsewhere | Table 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
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.
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.
-- 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';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.
-- 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);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.
-- 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;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.
-- 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;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.
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?")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.
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 sWhen 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.
-- 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
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.
| Approach | Index lag | Metadata | Deletions | Systems |
|---|---|---|---|---|
| Scheduled re-index into Pinecone, Weaviate, Qdrant or Milvus (Airflow or cron) | Hours, per schedule | Copied into payloads at index time; drifts between runs | Explicit delete calls, or full rebuild | Scheduler, embedding job, vector store, source sync |
| LangChain or LlamaIndex ingestion pipeline with a document store | Per pipeline run; incremental with hashing | In the document store; refreshed per run | Record manager tracks and removes orphans | Pipeline runner, document store, vector store |
| PostgreSQL plus pgvector with triggers or cron | Seconds with triggers, minutes with cron | Live, same database | Row deletes | One, until embedding load and HNSW builds compete with the application |
| Elasticsearch or OpenSearch ingest pipelines with a kNN index | Seconds to minutes | Indexed with the document; updated by reindexing the document | Document deletes | Search cluster, ingest pipeline, source connectors |
| Managed RAG (Bedrock Knowledge Bases, Vertex AI Search) | Per sync job, typically minutes to hours | Metadata files synced with the documents | Removed on sync | Managed; limited control over chunking, filters and freshness |
| Kafka plus Flink plus a vector database | Seconds | Enriched in the Flink job | Job issues deletes to the store | Streaming platform, Flink, vector store, embedding service |
| RisingWave, in-stream (this page) | Seconds | Joined live from CDC at query time | Resolved at read time via the latest-version view | One, plus optional sinks |
Design Decisions
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.
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.
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.
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.
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.
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
Run the pipeline on RisingWave Cloud or a single Docker container.
Start Free