AI Agents
Every fact an agent reasons over is a query. A materialized view is that query computed once and kept current, so the agent reads a row instead of running the computation. This guide is the pattern catalogue: eight view shapes that cover most agent context, the RisingWave SQL for each, how to shape them for a prompt, and how to keep them fast.
Last reviewed 2026-11-09. Covers RisingWave 2.6 and later.
Companion guide: Real-Time Context for AI Agents →
Why Views
An agent asks the same questions over and over: how healthy is this account, what did this user just do, which invoice is current. There are three ways to answer. Run the query on every turn, which is live but puts joins and aggregates on the production database thousands of times a day. Precompute the answer with a job and cache it, which is fast but as stale as the last run and needs invalidation logic nobody wants to own. Or define the answer as a materialized view and let the database keep it current as the inputs change.
The third option is only attractive if maintenance is incremental. A view that is recomputed from scratch every minute is just a cache with a schedule. RisingWave maintains views by processing each change as it arrives and updating only the output rows it affects, so a new ticket changes one account's open_tickets and nothing else is touched. That is what makes a view the right read model for agents: compute moves to write time, where it happens once, and read time becomes a lookup.
| Dimension | Query on every turn | Job plus cache | Incremental view |
|---|---|---|---|
| Compute per agent turn | Full joins and aggregates, every turn | None for the cache hit, full for the miss | One point lookup |
| Freshness | Live, but slow and load-heavy | As old as the last job run | One to two seconds |
| Consistency across facts | Each query its own moment | Each key its own moment | One barrier across all views |
| Who maintains the logic | Agent code, per prompt | A job plus cache invalidation code | One SQL definition |
| Token cost | Raw rows summarised by the model | Whatever the job stored | Shaped in SQL |
| Failure mode | Production database overload | Silent staleness | Visible backfill progress |
How It Works
When you run CREATE MATERIALIZED VIEW, RisingWave compiles the query into a streaming dataflow: a graph of operators (filter, join, aggregate, Top-N, window) connected by change streams. Each operator keeps the state it needs to react to the next change, such as the current aggregate per group or the rows of a join input, in a storage engine backed by object storage. A change to a source row flows through the graph as an insert, a delete or an update pair, and each operator emits only the output changes that follow.
Once a second, a barrier passes through every dataflow in the cluster. Everything before the barrier is committed together, so a reader sees all views as of the same barrier. That is also the unit of freshness: after an event arrives, its effect is readable at the next barrier. Views built on other views are part of the same graph, so a cascade advances as one.
Two properties of the change stream matter when you design views. A stream is append-only if it only ever carries inserts; sources, and views that preserve that property, can carry vector indexes and can feed EMIT ON WINDOW CLOSE sinks. A stream that can carry deletes or updates (anything with aggregation, a CDC table, a Top-N) is fully supported in views, joins and lookups, but not as the input of a vector index. The table maps each pattern to the operator that implements it and the state it holds.
| Pattern | Streaming operator | State it keeps |
|---|---|---|
| Entity state (join plus aggregate) | Stream hash join, hash aggregate | Join inputs per key, one aggregate row per group |
| Latest per key | Group Top-N (from ROW_NUMBER with a rank filter) | N rows per key |
| Rolling window (HOP) | Hop expansion, hash aggregate | One row per key per open window, dropped at the watermark |
| Finalised window (TUMBLE, EMIT ON WINDOW CLOSE) | Window aggregate with watermark-triggered emit | Open windows only; output is append-only |
| Event-time enrichment | Temporal join against a table or indexed view | The dimension side only |
| Ranked list | Group Top-N | N rows per group |
| Cascade | Any of the above, chained | Each level's own state; one barrier for the chain |
| Memory | Table plus Top-N or aggregate | Bounded by the view's retention clause |
The Patterns
The running example is a B2B SaaS company with an agent that helps customer-success managers: it flags accounts at risk, drafts outreach and answers questions about any account. Its inputs are product usage events on Kafka and billing and support tables in PostgreSQL. Each pattern below is one view the agent reads; together they are the agent's whole world. Start with the sources.
-- Product usage events from Kafka. The watermark lets windows close and state be dropped.
CREATE SOURCE usage_events (
account_id BIGINT,
user_id BIGINT,
feature VARCHAR,
event_type VARCHAR, -- 'use' | 'error' | 'export'
event_ts TIMESTAMPTZ,
WATERMARK FOR event_ts AS event_ts - INTERVAL '1 minute'
) WITH (
connector = 'kafka', topic = 'usage',
properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;
-- Billing and support come from PostgreSQL as change streams.
CREATE SOURCE saas_pg WITH (
connector = 'postgres-cdc', hostname = 'pg', port = '5432',
username = 'rw', password = '...', database.name = 'saas', schema.name = 'public'
);
CREATE TABLE accounts (account_id BIGINT PRIMARY KEY, name VARCHAR, plan VARCHAR,
seats INT, renewal_date DATE, owner_email VARCHAR)
FROM saas_pg TABLE 'public.accounts';
CREATE TABLE invoices (invoice_id BIGINT PRIMARY KEY, account_id BIGINT, amount NUMERIC,
status VARCHAR, due_date DATE, issued_at TIMESTAMPTZ)
FROM saas_pg TABLE 'public.invoices';
CREATE TABLE tickets (ticket_id BIGINT PRIMARY KEY, account_id BIGINT, priority VARCHAR,
status VARCHAR, opened_at TIMESTAMPTZ)
FROM saas_pg TABLE 'public.tickets';
-- Plan catalogue: small, slowly changing, used for enrichment.
CREATE TABLE plans (plan VARCHAR PRIMARY KEY, seat_limit INT, export_limit_per_day INT);One row per entity, joined from several sources and aggregated across its history. This is the view every agent starts from, and the one most teams build first. FILTER clauses keep several counts in one pass. Because both tickets and invoices are CDC tables, a ticket closing or an invoice being paid flows through as an update and the account's row changes within a barrier.
-- Pattern 1. Entity state: one row per account, joined from several sources.
-- RisingWave updates only the affected account's row when any input changes.
CREATE MATERIALIZED VIEW account_profile AS
SELECT a.account_id, a.name, a.plan, a.seats, a.renewal_date,
COUNT(DISTINCT t.ticket_id) FILTER (WHERE t.status <> 'closed') AS open_tickets,
COUNT(DISTINCT t.ticket_id) FILTER (WHERE t.priority = 'urgent'
AND t.status <> 'closed') AS urgent_tickets,
COALESCE(SUM(i.amount) FILTER (WHERE i.status = 'overdue'), 0) AS overdue_amount,
MAX(i.issued_at) AS last_invoice_at
FROM accounts a
LEFT JOIN tickets t ON t.account_id = a.account_id
LEFT JOIN invoices i ON i.account_id = a.account_id
GROUP BY a.account_id, a.name, a.plan, a.seats, a.renewal_date;The most recent record per entity: the latest invoice, the current status, the last login. Write it as ROW_NUMBER over a partition with a rank filter and RisingWave recognises the shape as group Top-N, which keeps one row of state per key instead of the whole history. EXPLAIN shows a StreamGroupTopN operator when this has worked.
-- Pattern 2. Latest per key: the most recent invoice per account.
-- ROW_NUMBER with a rank filter is recognised as group Top-N and keeps one row of state per key.
CREATE MATERIALIZED VIEW latest_invoice AS
SELECT account_id, invoice_id, amount, status, due_date, issued_at
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY issued_at DESC) AS rn
FROM invoices
) t
WHERE rn = 1;Activity over the last N days or minutes, kept current. A HOP window of size seven days advancing every hour produces one row per account per hour, each covering the previous week; the agent reads the row with the latest window_end. The watermark on the source lets RisingWave drop windows that can no longer change. Choose the hop size by how often the agent needs a new figure, not by how often events arrive.
-- Pattern 3. Rolling window: usage over the last 7 days, advancing every hour.
-- The agent reads the row with the latest window_end; older windows are dropped once the watermark passes.
CREATE MATERIALIZED VIEW usage_7d AS
SELECT account_id, window_start, window_end,
COUNT(DISTINCT user_id) AS active_users_7d,
COUNT(*) FILTER (WHERE event_type = 'use') AS actions_7d,
COUNT(*) FILTER (WHERE event_type = 'error') AS errors_7d,
COUNT(DISTINCT feature) AS features_used_7d
FROM HOP(usage_events, event_ts, INTERVAL '1 hour', INTERVAL '7 days')
GROUP BY account_id, window_start, window_end;History the agent will cite should not change after it is written. A TUMBLE window with EMIT ON WINDOW CLOSE emits each day's row once, when the watermark passes the end of the day, and never revises it. The output is append-only, which makes it safe to sink to Kafka or Iceberg and safe to quote in a prompt as a fact rather than an estimate.
-- Pattern 4. Finalised window: one immutable row per account per day, emitted when the day closes.
-- Use this for history the agent cites ("usage fell 40% week over week"), not for the live figure.
CREATE MATERIALIZED VIEW daily_usage AS
SELECT account_id, window_start AS day,
COUNT(DISTINCT user_id) AS active_users,
COUNT(*) FILTER (WHERE event_type = 'use') AS actions,
COUNT(*) FILTER (WHERE event_type = 'export') AS exports
FROM TUMBLE(usage_events, event_ts, INTERVAL '1 day')
GROUP BY account_id, window_start
EMIT ON WINDOW CLOSE;Attach dimension attributes to events as they happen: the plan in force at the time of an export, the merchant category at the time of a payment. A temporal join with FOR SYSTEM_TIME AS OF PROCTIME() looks up the dimension table at processing time and stores only the dimension side, so the event stream is never buffered. The join condition must include the lookup table's primary key or an indexed column.
-- Pattern 5. Event-time enrichment: attach the plan limits in force when each export happened.
-- The temporal join stores only the plans side, not the event stream, so state stays small.
CREATE MATERIALIZED VIEW exports_vs_limit AS
SELECT e.account_id, e.event_ts, e.user_id, p.export_limit_per_day
FROM usage_events e
JOIN accounts a FOR SYSTEM_TIME AS OF PROCTIME() ON a.account_id = e.account_id
JOIN plans p FOR SYSTEM_TIME AS OF PROCTIME() ON p.plan = a.plan
WHERE e.event_type = 'export';Agents need short lists, not histograms: the five most used features, the three biggest open tickets, the top merchants. Aggregate first, then Top-N per group. Keeping the limit in the view, rather than in the read query, bounds state and bounds the tokens every agent spends on that list.
-- Pattern 6. Ranked list: the five most used features per account over the rolling window.
-- First aggregate per feature, then Top-N. The agent gets a short list instead of a feature histogram.
CREATE MATERIALIZED VIEW feature_usage_7d AS
SELECT account_id, feature, window_end, COUNT(*) AS uses
FROM HOP(usage_events, event_ts, INTERVAL '1 hour', INTERVAL '7 days')
WHERE event_type = 'use'
GROUP BY account_id, feature, window_end;
CREATE MATERIALIZED VIEW top_features_7d AS
SELECT account_id, window_end, feature, uses
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY account_id, window_end ORDER BY uses DESC) AS rn
FROM feature_usage_7d
) t
WHERE rn <= 5;Derived facts are views over views. The health score below reads the profile and the rolling window and adds a CASE expression that encodes the customer-success team's policy. Changing the policy is a new view, not a prompt change, and every agent and dashboard that reads the view sees the same classification. Because the whole cascade advances at one barrier, the score never combines a fresh ticket count with a stale usage figure.
-- Pattern 7. Cascade: a health score defined over the views above.
-- First pin the current rolling window per account (pattern 2 applied to pattern 3).
CREATE MATERIALIZED VIEW usage_7d_latest AS
SELECT account_id, window_end, active_users_7d, actions_7d, errors_7d, features_used_7d
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY window_end DESC) AS rn
FROM usage_7d
) t
WHERE rn = 1;
-- Then derive the score. Every level advances at the same barrier, so the score
-- never mixes a new ticket count with an old usage figure.
CREATE MATERIALIZED VIEW account_health AS
SELECT p.account_id, p.name, p.plan, p.seats, p.renewal_date,
u.active_users_7d, u.errors_7d, p.open_tickets, p.urgent_tickets, p.overdue_amount,
CASE
WHEN p.urgent_tickets > 0 OR p.overdue_amount > 0 THEN 'red'
WHEN u.active_users_7d < p.seats * 0.3 OR u.errors_7d > 50 THEN 'amber'
ELSE 'green'
END AS health
FROM account_profile p
LEFT JOIN usage_7d_latest u ON u.account_id = p.account_id;Memory is a table the agent writes to and a view that summarises it. The Top-N keeps the five most recent notes per account so the summary stays small, and because the view lives next to the live data, a further view can check memory against reality, for example promises about renewal pricing on accounts whose plan has since changed.
-- Pattern 8. Memory: the agent writes, a view summarises, the context query joins.
CREATE TABLE agent_notes (
note_id VARCHAR PRIMARY KEY,
account_id BIGINT,
kind VARCHAR, -- 'promise' | 'risk' | 'preference' | 'outreach'
content VARCHAR,
created_at TIMESTAMPTZ
);
CREATE MATERIALIZED VIEW account_notes_recent AS
SELECT account_id,
ARRAY_AGG(kind || ': ' || content ORDER BY created_at DESC) AS notes
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY created_at DESC) AS rn
FROM agent_notes
) t
WHERE rn <= 5
GROUP BY account_id;The agent's read is one point lookup that assembles the views into a JSON object. Field names travel with values, so the model needs no explanation of the schema, and the agent code does no reshaping. The index on account_health turns the outer lookup into a key read. With the limits inside the views, this object stays under about a thousand tokens for a typical account.
-- Serve: a point lookup that returns the whole context as one JSON object.
CREATE INDEX account_health_by_id ON account_health (account_id);
SELECT jsonb_build_object(
'account', to_jsonb(h),
'last_invoice', (SELECT to_jsonb(l) FROM latest_invoice l WHERE l.account_id = h.account_id),
'top_features', (SELECT jsonb_agg(jsonb_build_object('feature', f.feature, 'uses', f.uses))
FROM top_features_7d f
WHERE f.account_id = h.account_id
AND f.window_end = (SELECT MAX(window_end) FROM top_features_7d
WHERE account_id = h.account_id)),
'last_30_days', (SELECT jsonb_agg(jsonb_build_object('day', d.day, 'active_users', d.active_users)
ORDER BY d.day)
FROM daily_usage d
WHERE d.account_id = h.account_id
AND d.day >= CURRENT_DATE - INTERVAL '30 days'),
'notes', COALESCE(n.notes, ARRAY[]::VARCHAR[])
) AS context
FROM account_health h
LEFT JOIN account_notes_recent n ON n.account_id = h.account_id
WHERE h.account_id = $1;All syntax above is documented in the RisingWave Top-N by group, joins, time windows and emit on window close pages.
Alternatives
The phrase "materialized view" covers very different refresh models. For agents the questions are how the view is refreshed, how fresh it can be, whether it can be defined over streams with event time, and whether the system can serve a point lookup fast enough to sit inside an agent turn.
| System | Refresh model | Freshness | Streaming features | Serving |
|---|---|---|---|---|
| RisingWave | Incremental, continuous, no command | About one second | Kafka and CDC sources, windows, temporal joins, Top-N, EMIT ON WINDOW CLOSE, vector indexes | Indexed point lookups over the PostgreSQL protocol; sinks to Redis, Iceberg, Kafka |
| PostgreSQL materialized views | REFRESH MATERIALIZED VIEW, full recompute | As of the last refresh | None; tables only | Indexed reads on the same database |
| Snowflake dynamic tables | Scheduled incremental or full refresh to a target lag | One minute at best, typically longer | Snowpipe Streaming ingest; no event-time windows or watermarks | Warehouse queries, seconds per scan |
| Databricks materialized views (Lakeflow) | Scheduled or triggered pipeline runs | Minutes | Structured Streaming inside the pipeline | SQL warehouse queries |
| Materialize | Incremental, continuous | Sub-second | Kafka and CDC sources, temporal filters | Indexed reads; state held in memory, so cost scales with state size |
| Flink SQL plus a sink | Incremental, continuous | Sub-second | Full streaming SQL | None built in; results must be sunk to another store for lookups |
Freshness figures are the systems' documented or typical behaviour as of the review date; vendor-run benchmarks are not used here. For a deeper comparison of incremental maintenance engines see incremental materialized views.
Tuning
Almost every performance problem with a streaming view is a state problem. State grows when an operator has to remember more than it should: a regular join between two event streams remembers every event, a window without a watermark never closes, a ROW_NUMBER without a rank filter keeps the whole partition. The patterns above are chosen to avoid each of these, and the tools below show whether they have.
-- See the streaming plan before you create the view. Look for StreamGroupTopN and temporal join operators.
EXPLAIN CREATE MATERIALIZED VIEW latest_invoice AS
SELECT ... ;
-- Create large views in the background and watch backfill progress.
SET BACKGROUND_DDL = true;
CREATE MATERIALIZED VIEW account_profile AS ... ;
SHOW JOBS;
-- How much state does each view hold?
SELECT * FROM rw_catalog.rw_table_stats ORDER BY total_key_count DESC;
-- Which jobs run at what parallelism?
SELECT * FROM rw_catalog.rw_streaming_parallelism;Design Decisions
Several narrow views, one per fact or time grain, joined in the read query or in a thin cascade view. Narrow views backfill faster, fail independently, can be granted separately to different agents, and are easier to change. A single wide view that joins ten inputs is also the one most likely to accumulate join state.
No. Use a hopping window with default emit semantics for the live figure the agent acts on, and a tumbling window with EMIT ON WINDOW CLOSE for the history it cites. The first changes constantly and is read for its latest row; the second is append-only, never revised, and safe to copy into memory or a lakehouse.
Temporal join whenever one side is a dimension that should be read as of now: accounts, plans, products, merchants. It stores only that side and reacts only to event-side changes, which is what enrichment means. Regular join when both sides change and a change on either side must update the result, for example open tickets against overdue invoices.
In the view for anything shared across agents (Top-N keeps five features, not fifty), and in the read query for anything specific to one prompt (the last 30 days rather than the last 90). Never in the agent code: if the model has to summarise raw rows you have already paid the tokens.
Views are immutable definitions, so create the new one under a new name, let it backfill in the background with SET BACKGROUND_DDL, verify it, then switch the agent's query and drop the old view. Keeping both for a day costs some state and nothing else.
When the agent looks it up by a key that is not the view's distribution key, or when a temporal join needs to look up a view by a column that is not its primary key. For the common case, a profile keyed by account_id and read by account_id, RisingWave's own storage already serves the point lookup.
When Not to Use This
Run the eight patterns on RisingWave Cloud or a single Docker container.
Start Free