AI Agents

Materialized Views for 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 →

8
Patterns
Entity state, latest per key, rolling window, finalised window, enrichment, ranked list, cascade, memory
0
REFRESH Commands
Views update incrementally as rows arrive, committed at a one-second barrier
1 snapshot
Across Views
Every view advances together, so a derived view never mixes moments
to_jsonb
Prompt-Ready
Shape the context in SQL and hand the agent a compact JSON object

Why Views

Why should an agent read a view instead of running a query?

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.

DimensionQuery on every turnJob plus cacheIncremental view
Compute per agent turnFull joins and aggregates, every turnNone for the cache hit, full for the missOne point lookup
FreshnessLive, but slow and load-heavyAs old as the last job runOne to two seconds
Consistency across factsEach query its own momentEach key its own momentOne barrier across all views
Who maintains the logicAgent code, per promptA job plus cache invalidation codeOne SQL definition
Token costRaw rows summarised by the modelWhatever the job storedShaped in SQL
Failure modeProduction database overloadSilent stalenessVisible backfill progress

How It Works

What happens inside an incrementally maintained view?

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.

PatternStreaming operatorState it keeps
Entity state (join plus aggregate)Stream hash join, hash aggregateJoin inputs per key, one aggregate row per group
Latest per keyGroup Top-N (from ROW_NUMBER with a rank filter)N rows per key
Rolling window (HOP)Hop expansion, hash aggregateOne row per key per open window, dropped at the watermark
Finalised window (TUMBLE, EMIT ON WINDOW CLOSE)Window aggregate with watermark-triggered emitOpen windows only; output is append-only
Event-time enrichmentTemporal join against a table or indexed viewThe dimension side only
Ranked listGroup Top-NN rows per group
CascadeAny of the above, chainedEach level's own state; one barrier for the chain
MemoryTable plus Top-N or aggregateBounded by the view's retention clause

The Patterns

Eight materialized views for a customer-success agent

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.

SQL (RisingWave): sources and tables
-- 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);

Pattern 1. Entity state

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.

SQL (RisingWave)
-- 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;

Pattern 2. Latest per key

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.

SQL (RisingWave)
-- 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;

Pattern 3. Rolling window

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.

SQL (RisingWave)
-- 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;

Pattern 4. Finalised window

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.

SQL (RisingWave)
-- 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;

Pattern 5. Event-time enrichment

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.

SQL (RisingWave)
-- 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';

Pattern 6. Ranked list

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.

SQL (RisingWave)
-- 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;

Pattern 7. Cascade

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.

SQL (RisingWave)
-- 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;

Pattern 8. Memory

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.

SQL (RisingWave)
-- 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;

Shaping the output for the prompt

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.

SQL (RisingWave): the agent's per-turn read
-- 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

How do materialized views compare across systems?

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.

SystemRefresh modelFreshnessStreaming featuresServing
RisingWaveIncremental, continuous, no commandAbout one secondKafka and CDC sources, windows, temporal joins, Top-N, EMIT ON WINDOW CLOSE, vector indexesIndexed point lookups over the PostgreSQL protocol; sinks to Redis, Iceberg, Kafka
PostgreSQL materialized viewsREFRESH MATERIALIZED VIEW, full recomputeAs of the last refreshNone; tables onlyIndexed reads on the same database
Snowflake dynamic tablesScheduled incremental or full refresh to a target lagOne minute at best, typically longerSnowpipe Streaming ingest; no event-time windows or watermarksWarehouse queries, seconds per scan
Databricks materialized views (Lakeflow)Scheduled or triggered pipeline runsMinutesStructured Streaming inside the pipelineSQL warehouse queries
MaterializeIncremental, continuousSub-secondKafka and CDC sources, temporal filtersIndexed reads; state held in memory, so cost scales with state size
Flink SQL plus a sinkIncremental, continuousSub-secondFull streaming SQLNone 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

How do you keep views fast and state small?

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.

SQL (RisingWave): plan, progress and state
-- 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;
  • →Read the plan before creating a view. If a latest-per-key query shows StreamOverWindow instead of StreamGroupTopN, the rank filter was not recognised; check that the alias is not selected and the filter is on the rank column.
  • →Give every event stream a watermark. Without it, HOP and TUMBLE windows cannot close and interval joins cannot discard old rows.
  • →Prefer temporal joins for dimensions. A regular join against a 10-million-row customers table stores those rows per join; a temporal join reads them in place.
  • →Aggregate before you join. Joining usage_7d to account_profile is one row per account on each side; joining raw usage_events to accounts is every event.
  • →Create big views in the background with SET BACKGROUND_DDL = true and watch SHOW JOBS; the view becomes readable when backfill completes.
  • →Watch rw_catalog.rw_table_stats over time. A view whose key count grows without bound is a view whose state is not being cleaned.
  • →Separate serving from processing when the agent fleet grows. RisingWave compute nodes can be dedicated to serving so lookups never wait behind stream processing.

Design Decisions

Six questions to settle before you write the views

One wide view or several narrow ones?

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.

Should the live figure and the history be the same view?

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.

Regular join or temporal join?

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.

Where should the token budget be enforced?

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.

How do I evolve a view without breaking agents?

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 does a view need an index?

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

Three things a materialized view is the wrong tool for

  • →Facts that change with every read, such as a random sample or the result of a model call. Compute them in the agent; a view only helps when the same result is read more than once.
  • →Pure document retrieval with no structured state. A view with an embedding column and a vector index works, but if that is all you need, a vector store with a batch job is simpler.
  • →Historical point-in-time training sets. Materialized views give you the present and the finalised recent past; for labelled history with as-of semantics, use a feature store's offline store or the Iceberg archive.

Frequently Asked Questions

What is a materialized view, and why do AI agents need them?
How are RisingWave materialized views different from PostgreSQL materialized views?
How fresh is a materialized view in RisingWave?
Which SQL patterns are supported in streaming materialized views?
How do I keep a view's output small enough for a prompt?
Can one view be built on top of another?
What makes a streaming join expensive, and how do I avoid it?
How do I monitor and debug a materialized view?

Define the agent's world in SQL

Run the eight patterns 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.