From Graph Patterns To Analytics And Visualization

GRAPH_TABLE is the right first tool because it demonstrates the core idea: match a graph pattern and return rows.

But graph work often goes one step further. You may want to rank important accounts, find connected groups, trace weighted paths, or visualize the network so a human can inspect it.

Article 3 used GRAPH_TABLE to find hubs, chains, cycles, and customer-risk joins. This article adds computed graph signals and visual exploration. You do not need a separate graph database after pattern matching; in-database algorithms and Graph Studio are the natural next step once you understand the graph.

Oracle gives you two practical next steps after basic pattern matching:

  • in-database graph algorithms with DBMS_OGA;
  • visual and notebook workflows in Graph Studio, backed by PGX for in-memory analytics.

Start With In-Database Algorithms

DBMS_OGA exposes graph algorithm functions that you can call from SQL. The function returns a temporary graph with computed properties, and GRAPH_TABLE reads those computed properties back as rows.

The syntax can look unusual at first, but the workflow is simple:

  1. call the algorithm on BANK_GRAPH;
  2. write the algorithm output to a vertex property;
  3. query that output property with GRAPH_TABLE;
  4. join the result to your relational tables.

Run the examples in this article in Autonomous Database Serverless on OCI if you want the algorithm examples to execute. At the time of writing, FreeSQL does not expose DBMS_OGA / DBMS_GAF, so the algorithm examples in this article will not work there.

If you are using ADB-S, run the setup from earlier articles first. Then check whether your database exposes the graph algorithm packages:

SELECT owner, object_name, object_type, status
FROM all_objects
WHERE object_name IN ('DBMS_OGA', 'DBMS_GAF')
ORDER BY owner, object_name, object_type;

If that query does not show both packages, skip the DBMS_OGA examples in this article and move to the Graph Studio section.

The algorithm path depends on DBMS_OGA and DBMS_GAF. If those packages are not visible in your environment, keep using the earlier SQL property graph and GRAPH_TABLE examples, then move to the Graph Studio section.

If DBMS_OGA is visible, run demo/04_bank_graph_algorithms.sql. (The code is at end of this article). The script checks package visibility first, skips cleanly where the packages are absent, and uses the current schema when it builds the Bellman-Ford seed vertex JSON.

Treat these algorithm outputs as features, not verdicts. A PageRank score, component id, or path distance can help you prioritize work, but it should not be presented as proof of fraud. That distinction keeps the article technically useful without overstating what graph analytics can decide on its own.

The useful part is not just that the algorithm runs. It is that the computed graph output can come back through GRAPH_TABLE, become SQL rows, and join to the same relational context you already use.

PageRank: Which Accounts Are Structurally Important?

PageRank is a useful first centrality measure because it gives every vertex an importance score based on the graph structure.

In a bank-transfer graph, PageRank is not a fraud model. It is a signal. An account with high graph importance may deserve attention when combined with other context such as transfer amount, customer risk, device history, or known investigation seeds.

SELECT gt.account_id,
ba.account_name,
gt.pagerank_score
FROM GRAPH_TABLE(
DBMS_OGA.PAGERANK(
bank_graph,
PROPERTY(VERTEX OUTPUT pagerank_score),
20,
0.0001,
0.85,
TRUE
)
MATCH (a IS account)
COLUMNS (
a.account_id AS account_id,
a.pagerank_score AS pagerank_score
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
ORDER BY gt.pagerank_score DESC;

Notice the same pattern from earlier articles. Even though an algorithm ran first, the output becomes SQL rows. That makes the score easy to sort, filter, join, or save.

You can also join the result back to customer or account data. For example, sort by PageRank and then show each account’s customer risk tier. The algorithm provides graph structure; SQL adds business context.

Weakly Connected Components: Which Accounts Belong Together?

Connected components find groups of vertices connected by edges. Weakly connected components treat directed edges as connected regardless of direction.

In a fraud investigation workflow, WCC is useful because it gives you a compact way to describe account clusters.

SELECT gt.component_id,
COUNT(*) AS account_count,
LISTAGG(ba.account_name, ', ') WITHIN GROUP (ORDER BY ba.account_id) AS accounts
FROM GRAPH_TABLE(
DBMS_OGA.WCC(
bank_graph,
PROPERTY(VERTEX OUTPUT component_id)
)
MATCH (a IS account)
COLUMNS (
a.account_id AS account_id,
a.component_id AS component_id
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
GROUP BY gt.component_id
ORDER BY account_count DESC, gt.component_id;

On a small graph, this may return one large component. That is fine. The useful pattern is the method: compute a graph-level grouping, then summarize it with SQL.

On a larger graph, connected components can help split an investigation into smaller clusters. They can also help you find isolated groups of accounts that do not interact with the rest of the network.

PageRank, Weakly Connected Components, and Bellman-Ford produce different kinds of graph features. Treat them as investigation inputs, not final decisions.

Bellman-Ford: Weighted Path Exposure

Bellman-Ford is a shortest-path algorithm. In a road network, the edge weight might be distance. In a transfer graph, the edge weight might be amount.

In this demo, a minimum cumulative transfer amount is not automatically a risk score. It is a path-based measure that may help investigation when paired with business rules.

The Bellman-Ford example starts from account 101 and uses transfer amount as the edge weight.

The seed vertex is account 101. The edge input property is amount, so the algorithm treats transfer amount as the path weight. The output property is minimum_transfer_exposure, which is then projected by GRAPH_TABLE and joined to bank_accounts.

This output is a weighted path signal from one starting account. In an investigation workflow, it could help you understand which accounts are reachable from a seed account under this weighting rule.

Move Into Graph Studio

SQL is enough to create and query the graph, but visualization helps beginners see what they built.

Graph Studio gives you a managed place to create, query, analyze, and visualize graphs in Autonomous AI Database. In this series, it is most useful for three things:

  • show BANK_GRAPH as a real graph object;
  • visualize a small subgraph around account 101;
  • run one notebook or query-panel example that mirrors the SQL article.

In Graph Studio, open the graph list, select BANK_GRAPH, and start with a small preview rather than the whole graph. Filter around account 101 or the 3-hop cycle so the visual reinforces the pattern queries instead of turning into a dense tangle.

If you use a notebook, run the same one-hop query from account 101 that you used in SQL. That helps connect the visual result back to the query:

SELECT *
FROM GRAPH_TABLE(
bank_graph
MATCH (src IS account) -[t IS transfer]-> (dst IS account)
WHERE src.account_id = 101
COLUMNS (
src.account_name AS source_account,
t.amount AS amount,
t.channel AS channel,
dst.account_name AS destination_account
)
)
ORDER BY amount DESC;

Where PGX Fits

PGX is Oracle’s in-memory graph analytics layer. It matters when you need richer algorithms, interactive analysis, notebook workflows, or high-performance graph analytics beyond the first SQL examples.

The practical path is incremental: start in SQL, add in-database algorithm signals when the packages are available, and move into Graph Studio or PGX when visualization or deeper analytics becomes useful.

That sequence keeps the mental model clean:

  1. SQL property graph: define the graph over database objects.
  2. GRAPH_TABLE: match graph patterns and return rows.
  3. DBMS_OGA: compute basic in-database graph signals.
  4. Graph Studio and PGX: visualize and analyze with a richer graph environment.

Where To Go Next

You now know how to recognize a graph-shaped problem, define a SQL property graph over relational tables, query connected patterns with SQL, join graph results to business context, and choose when to move into algorithms or visualization.

That is enough to start using graph features in real Oracle database work.

Try The Demo

This article has two demo paths.

For the algorithm path, use your own ADB-S instance. Run the setup steps below, then run the availability check and algorithm script. The script computes PageRank, Weakly Connected Components, and Bellman-Ford outputs, then returns the results as SQL rows.

For the FreeSQL path, stop before the algorithm script. FreeSQL is still useful for the table, graph, and GRAPH_TABLE pattern examples, but FreeSQL does not expose DBMS_OGA / DBMS_GAF.

After the SQL algorithm step, open Graph Studio in the same ADB-S environment and visualize a small part of BANK_GRAPH. Start with a filtered view around account 101 or the seeded transfer cycle. The goal is not to draw every relationship at once; it is to connect the SQL results back to a graph a person can inspect.

Here’s the code referenced above, which you can copy and paste into your environment. Please note that you need to run the set up steps 1 and 2 first, then step 4 – all are included below. If you followed the previous articles in this series, steps 1 and 2 are exactly the same code you ran in those articles. The code below contains comments that explains what it is doing.

-- Oracle AI Database 26ai graph demo
-- Step 1: create a tiny bank-fraud dataset.
--
-- Run this as a schema user that can create tables.
-- Start by removing objects from a previous run. The graph depends on the
-- tables, so it must be dropped before the tables can be recreated.
BEGIN
EXECUTE IMMEDIATE 'DROP PROPERTY GRAPH bank_graph';
EXCEPTION
WHEN OTHERS THEN
-- Ignore cleanup errors so the script can be rerun from a fresh schema or
-- after dependencies have already been removed.
NULL;
END;
/
-- Drop the edge table before the vertex tables because it has foreign keys to
-- bank_accounts. ORA-00942 means the table did not exist, which is safe here.
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE bank_transfers PURGE';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
-- Drop bank_accounts after bank_transfers because transfers reference accounts.
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE bank_accounts PURGE';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
-- Drop the parent customer table last because bank_accounts references it.
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE customers PURGE';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE != -942 THEN
RAISE;
END IF;
END;
/
-- Customers are the business owners of accounts. The risk_tier column gives us
-- a simple relational attribute to join back to graph query results later.
CREATE TABLE customers (
customer_id NUMBER NOT NULL,
full_name VARCHAR2(100) NOT NULL,
risk_tier VARCHAR2(20) NOT NULL,
CONSTRAINT customers_pk PRIMARY KEY (customer_id),
CONSTRAINT customers_risk_chk CHECK (risk_tier IN ('LOW', 'MEDIUM', 'HIGH'))
);
-- Accounts will become graph vertices in 02_bank_graph.sql. Each account is
-- still normal relational data, including a foreign key back to customers.
CREATE TABLE bank_accounts (
account_id NUMBER NOT NULL,
customer_id NUMBER NOT NULL,
account_name VARCHAR2(100) NOT NULL,
balance NUMBER(12,2) NOT NULL,
CONSTRAINT bank_accounts_pk PRIMARY KEY (account_id),
CONSTRAINT bank_accounts_customer_fk FOREIGN KEY (customer_id)
REFERENCES customers (customer_id)
);
-- Transfers will become directed graph edges. The source and destination
-- account IDs define the arrow direction for graph pattern matching.
CREATE TABLE bank_transfers (
transfer_id NUMBER NOT NULL,
src_account_id NUMBER NOT NULL,
dst_account_id NUMBER NOT NULL,
amount NUMBER(12,2) NOT NULL,
transfer_ts TIMESTAMP NOT NULL,
channel VARCHAR2(30) NOT NULL,
note VARCHAR2(200),
CONSTRAINT bank_transfers_pk PRIMARY KEY (transfer_id),
CONSTRAINT bank_transfers_src_fk FOREIGN KEY (src_account_id)
REFERENCES bank_accounts (account_id),
CONSTRAINT bank_transfers_dst_fk FOREIGN KEY (dst_account_id)
REFERENCES bank_accounts (account_id),
CONSTRAINT bank_transfers_amount_chk CHECK (amount >= 0)
);
-- Load a small mix of low-, medium-, and high-risk customers so later examples
-- can combine graph patterns with ordinary relational filters.
INSERT INTO customers (customer_id, full_name, risk_tier) VALUES
(1, 'Avery Chen', 'LOW'),
(2, 'Blake Rao', 'MEDIUM'),
(3, 'Casey Morgan', 'HIGH'),
(4, 'Drew Singh', 'LOW'),
(5, 'Emery Stone', 'HIGH'),
(6, 'Finley Brooks', 'MEDIUM');
-- Give some customers multiple accounts. This makes the graph slightly more
-- realistic without requiring a large dataset.
INSERT INTO bank_accounts (account_id, customer_id, account_name, balance) VALUES
(101, 1, 'Avery checking', 8400.00),
(102, 2, 'Blake checking', 1250.00),
(103, 3, 'Casey operating', 620.00),
(104, 4, 'Drew savings', 15000.00),
(105, 5, 'Emery wallet', 210.00),
(106, 6, 'Finley checking', 3700.00),
(107, 3, 'Casey reserve', 480.00),
(108, 5, 'Emery reserve', 305.00);
-- A 3-hop cycle: 101 -> 102 -> 103 -> 101.
-- This gives the GRAPH_TABLE examples a compact round-trip pattern to find.
INSERT INTO bank_transfers (
transfer_id,
src_account_id,
dst_account_id,
amount,
transfer_ts,
channel,
note
) VALUES
(1001, 101, 102, 500.00, TIMESTAMP '2026-05-01 09:00:00', 'ACH', 'invoice payment'),
(1002, 102, 103, 475.00, TIMESTAMP '2026-05-01 09:14:00', 'ACH', 'same-day transfer'),
(1003, 103, 101, 450.00, TIMESTAMP '2026-05-01 09:37:00', 'WIRE', 'round trip');
-- A fan-in pattern around account 105.
-- Multiple incoming transfers make account 105 stand out as an inbound hub.
INSERT INTO bank_transfers (
transfer_id,
src_account_id,
dst_account_id,
amount,
transfer_ts,
channel,
note
) VALUES
(1004, 104, 105, 850.00, TIMESTAMP '2026-05-02 10:00:00', 'WIRE', 'vendor settlement'),
(1005, 106, 105, 920.00, TIMESTAMP '2026-05-02 10:03:00', 'WIRE', 'vendor settlement'),
(1006, 107, 105, 760.00, TIMESTAMP '2026-05-02 10:06:00', 'WIRE', 'vendor settlement');
-- A second chain with a high-risk customer in the middle.
-- These rows support the two-hop path examples and the relational risk join.
INSERT INTO bank_transfers (
transfer_id,
src_account_id,
dst_account_id,
amount,
transfer_ts,
channel,
note
) VALUES
(1007, 105, 108, 700.00, TIMESTAMP '2026-05-02 11:10:00', 'ACH', 'split transfer'),
(1008, 108, 106, 690.00, TIMESTAMP '2026-05-02 11:30:00', 'ACH', 'split transfer'),
(1009, 106, 107, 300.00, TIMESTAMP '2026-05-03 13:00:00', 'CARD', 'refund'),
(1010, 107, 102, 290.00, TIMESTAMP '2026-05-03 13:20:00', 'CARD', 'refund');
-- Make the seed data visible to the following scripts.
COMMIT;
-- Quick smoke test: the expected row counts are 6 customers, 8 accounts, and
-- 10 transfers.
SELECT 'customers' AS table_name, COUNT(*) AS rows_loaded FROM customers
UNION ALL
SELECT 'bank_accounts', COUNT(*) FROM bank_accounts
UNION ALL
SELECT 'bank_transfers', COUNT(*) FROM bank_transfers;
-- Oracle AI Database 26ai graph demo
-- Step 2: create a SQL property graph over the bank tables.
-- A SQL property graph is metadata over existing relational tables. This
-- statement does not copy the rows from bank_accounts or bank_transfers; it
-- describes how Oracle should treat those rows as graph vertices and edges.
--
-- In this graph:
-- - bank_accounts rows become account vertices, keyed by account_id.
-- - bank_transfers rows become directed transfer edges, keyed by transfer_id.
-- - SOURCE KEY and DESTINATION KEY define transfer direction.
-- - PROPERTIES lists the columns that graph queries can project.
CREATE OR REPLACE PROPERTY GRAPH bank_graph
VERTEX TABLES (
bank_accounts KEY (account_id)
LABEL account
PROPERTIES (account_id, customer_id, account_name, balance)
)
EDGE TABLES (
bank_transfers KEY (transfer_id)
SOURCE KEY (src_account_id) REFERENCES bank_accounts (account_id)
DESTINATION KEY (dst_account_id) REFERENCES bank_accounts (account_id)
LABEL transfer
PROPERTIES (transfer_id, amount, transfer_ts, channel, note)
);
-- Confirm that the graph object exists in the current schema.
SELECT graph_name
FROM user_property_graphs
WHERE graph_name = 'BANK_GRAPH';
-- Inspect the graph elements. You should see one vertex element backed by
-- BANK_ACCOUNTS and one edge element backed by BANK_TRANSFERS.
SELECT graph_name, element_name, element_kind, object_name
FROM user_pg_elements
WHERE graph_name = 'BANK_GRAPH'
ORDER BY element_kind, element_name;
-- Inspect graph labels. Labels are the names used in MATCH patterns such as
-- (a IS account) and -[t IS transfer]->.
SELECT graph_name, label_name
FROM user_pg_labels
WHERE graph_name = 'BANK_GRAPH'
ORDER BY label_name;
-- Inspect the properties available for each label. If a column is not listed
-- here, a GRAPH_TABLE query cannot project it as a graph property.
SELECT graph_name, label_name, property_name
FROM user_pg_label_properties
WHERE graph_name = 'BANK_GRAPH'
ORDER BY label_name, property_name;
-- Capture the canonical graph DDL for source control or an article appendix.
-- This is also a useful way to compare what you wrote with what Oracle stores.
SELECT DBMS_METADATA.GET_DDL('PROPERTY_GRAPH', 'BANK_GRAPH') AS graph_ddl
FROM dual;
-- Oracle AI Database 26ai graph demo
-- Step 4: run in-database graph algorithms with DBMS_OGA.
--
-- IMPORTANT:
-- Run after 01_bank_schema_and_seed.sql and 02_bank_graph.sql.
-- This script requires DBMS_OGA and DBMS_GAF to be installed/visible.
-- The tested FreeSQL, ADB Free, and Oracle Database Free environments support
-- SQL property graphs and GRAPH_TABLE, but do not expose DBMS_OGA / DBMS_GAF.
--
-- The algorithm SQL is deliberately executed dynamically after the package
-- check. In environments where DBMS_OGA is not installed, static SQL can fail
-- during GRAPH_TABLE parsing with ORA-02000: missing MATCH keyword.
-- DBMS_OUTPUT prints the availability check and section headings. SQL Developer
-- Web and SQLcl can also display the result sets returned by DBMS_SQL.
SET SERVEROUTPUT ON SIZE UNLIMITED
DECLARE
-- The package count is used as a gate so FreeSQL and other environments
-- without DBMS_OGA / DBMS_GAF exit cleanly instead of failing mid-script.
l_package_count NUMBER;
-- Bellman-Ford needs a JSON seed vertex that includes the graph owner. Using
-- the current schema keeps the script portable across demo users.
l_graph_owner VARCHAR2(128) := SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA');
-- Each algorithm query is stored as text and opened dynamically only after
-- the package check succeeds.
l_sql VARCHAR2(32767);
-- Run one algorithm query, label the output, and return the cursor to the SQL
-- client as a result set. If one algorithm fails, the message explains which
-- section failed without hiding the earlier successful sections.
PROCEDURE run_result(p_title VARCHAR2, p_sql VARCHAR2) IS
l_cursor SYS_REFCURSOR;
BEGIN
DBMS_OUTPUT.PUT_LINE(CHR(10) || '=== ' || p_title || ' ===');
OPEN l_cursor FOR p_sql;
DBMS_SQL.RETURN_RESULT(l_cursor);
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('FAILED: ' || p_title);
DBMS_OUTPUT.PUT_LINE(SQLERRM);
END;
BEGIN
-- Confirm both graph algorithm packages are visible before referencing them
-- inside GRAPH_TABLE. They may be unavailable in FreeSQL and Free images.
SELECT COUNT(DISTINCT object_name)
INTO l_package_count
FROM all_objects
WHERE object_name IN ('DBMS_OGA', 'DBMS_GAF')
AND object_type IN ('PACKAGE', 'SYNONYM');
IF l_package_count < 2 THEN
DBMS_OUTPUT.PUT_LINE('DBMS_OGA / DBMS_GAF are not both visible in this environment.');
DBMS_OUTPUT.PUT_LINE('Skip the in-database algorithm examples.');
RETURN;
END IF;
DBMS_OUTPUT.PUT_LINE('DBMS_OGA / DBMS_GAF are visible.');
DBMS_OUTPUT.PUT_LINE('Using graph owner for Bellman-Ford seed JSON: ' || l_graph_owner);
-- PageRank scores each account by its graph importance based on transfer
-- relationships. The algorithm writes pagerank_score as a temporary vertex
-- property, and GRAPH_TABLE projects it back into SQL rows.
run_result(
'PageRank',
q'[
SELECT gt.account_id,
ba.account_name,
gt.pagerank_score
FROM GRAPH_TABLE(
DBMS_OGA.PAGERANK(
bank_graph,
PROPERTY(VERTEX OUTPUT pagerank_score),
20,
0.0001,
0.85,
TRUE
)
MATCH (a IS account)
COLUMNS (
a.account_id AS account_id,
a.pagerank_score AS pagerank_score
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
ORDER BY gt.pagerank_score DESC
]'
);
-- Weakly connected components groups accounts that are connected when edge
-- direction is ignored. In a fraud or risk workflow, components can reveal
-- clusters that should be reviewed together.
run_result(
'Weakly Connected Components',
q'[
SELECT gt.component_id,
COUNT(*) AS account_count,
LISTAGG(ba.account_name, ', ') WITHIN GROUP (ORDER BY ba.account_id) AS accounts
FROM GRAPH_TABLE(
DBMS_OGA.WCC(
bank_graph,
PROPERTY(VERTEX OUTPUT component_id)
)
MATCH (a IS account)
COLUMNS (
a.account_id AS account_id,
a.component_id AS component_id
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
GROUP BY gt.component_id
ORDER BY account_count DESC, gt.component_id
]'
);
-- Bellman-Ford computes weighted reachability from account 101 using transfer
-- amount as the edge weight. The JSON value identifies the seed vertex in the
-- graph, so the schema name is filled in at runtime below.
l_sql := q'[
SELECT gt.account_id,
ba.account_name,
gt.minimum_transfer_exposure
FROM GRAPH_TABLE(
DBMS_OGA.BELLMAN_FORD(
bank_graph,
JSON('{"GRAPH_OWNER":"__GRAPH_OWNER__","GRAPH_NAME":"BANK_GRAPH","ELEM_TABLE":"BANK_ACCOUNTS","KEY_VALUE":{"ACCOUNT_ID":101}}'),
PROPERTY(EDGE INPUT amount DEFAULT ON NULL 0),
PROPERTY(VERTEX OUTPUT minimum_transfer_exposure)
)
MATCH (a IS account)
COLUMNS (
a.account_id AS account_id,
a.minimum_transfer_exposure AS minimum_transfer_exposure
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
ORDER BY gt.minimum_transfer_exposure, gt.account_id
]';
-- Replace the placeholder after building the SQL string so the script works
-- regardless of the schema name used for the demo.
l_sql := REPLACE(l_sql, '__GRAPH_OWNER__', l_graph_owner);
run_result('Bellman-Ford', l_sql);
END;
/
Posted in Uncategorized | Tagged , , | Leave a comment

Hands-on: Run Oracle Database Metrics Exporter with Prometheus and Grafana

This article uses Oracle Database Metrics Exporter 2.3.1, checked against Oracle’s documentation on 2026-05-10. Container image: container-registry.oracle.com/database/observability-exporter:2.3.1. Default listen address: :9161. Default metrics path: /metrics.

In the first article in this series, we looked at why application telemetry stops at the service boundary — and why a slow RAG endpoint or AI request often cannot be diagnosed without knowing what Oracle Database was doing at the same moment. This article turns that idea into a working local setup.

We’ll run Oracle Database Metrics Exporter against a development Oracle Database, scrape it with Prometheus, connect Grafana, and build the starter dashboard. By the end you’ll have panels showing exporter health, database reachability, sessions, activity, wait categories, and top SQL — all in the same time window as your application metrics.

Oracle’s docs and GitHub materials sometimes use Oracle AI Database Metrics Exporter where the product page uses Oracle Database Metrics Exporter. You may also see observability-exporter in the container image path. In this article, those references point to the same exporter.

The stack

The exporter sits between Oracle Database and Prometheus. It connects to the database, runs the metric queries, and exposes the results at /metrics. Prometheus pulls those metrics on a schedule. Grafana queries Prometheus to build panels.

Grafana does not connect directly to Oracle Database in this setup. That distinction matters when something goes wrong: if a panel is empty, the problem is almost always earlier in the chain. We’ll verify each link before moving to the next.

What you need

  • A development Oracle Database — local container, OCI Always Free, or a private lab instance
  • SQL admin access to create a monitoring user in the target PDB or service
  • Docker
  • Local ports 9161, 9090, and 3000 free

For Prometheus and Grafana, use pinned stable releases. Check github.com/prometheus/prometheus/releases and github.com/grafana/grafana/releases, then set these once in your shell:

export PROMETHEUS_IMAGE="prom/prometheus:v3.3.0"
export GRAFANA_IMAGE="grafana/grafana-oss:11.5.0"

Substitute the current stable tags. All the container commands below reference these variables so you only change the version in one place.

Create a shared Docker network

The three containers need to reach each other by name. Create a dedicated network first:

docker network create oracle-obs-demo

If Oracle Database is already running as a local Docker container, attach it to this network:

docker network connect oracle-obs-demo <your-oracle-container>

With Oracle Database on the same Docker network, the exporter can reach it by container name. If Oracle Database is running on the host machine instead, use host.docker.internal as the hostname in the exporter connect string.

Create a monitoring user

The exporter needs its own database identity. Connect to the PDB or service you want to monitor and run:

-- Run with an admin account in the target PDB
create user exporter_demo identified by "<your_password>";
grant create session to exporter_demo;
grant select_catalog_role to exporter_demo;

SELECT_CATALOG_ROLE covers most of the default metrics without requiring a long list of individual view grants. It is a broad grant — convenient for a local demo, but not least-privilege for a shared environment. Narrowing this down is the main topic of the next article in this series.

With Oracle Database Free, the default PDB is FREEPDB1. Make sure you create the user in the service the exporter will connect to.

Configure the exporter

Create a local file with the connection details. Keep this file out of source control.

# exporter.env
DB_USERNAME=exporter_demo
DB_PASSWORD=<your_password>
DB_CONNECT_STRING=<host>:1521/<service-name>

Replace <host> with the address the exporter container can reach. If Oracle Database is another container on oracle-obs-demo, use its container name: for example, oracle-db:1521/FREEPDB1. If it’s running on the host, use host.docker.internal:1521/FREEPDB1.

echo "exporter.env" >> .gitignore

Start the exporter

docker run -d
--name oracle-exporter
--network oracle-obs-demo
--env-file ./exporter.env
-p 127.0.0.1:9161:9161
container-registry.oracle.com/database/observability-exporter:2.3.1

Check the logs to confirm a clean start:

docker logs oracle-exporter

Then verify the metrics endpoint:

curl -s http://localhost:9161/metrics | grep "^oracledb_up"

A working exporter shows:

oracledb_up 1

That 1 means the exporter connected to Oracle Database and ran its queries successfully. If you see 0, the HTTP server is running but the database connection is not — check the connect string, service name, credentials, and whether the user exists in the right PDB. If the endpoint is unreachable, check docker ps and docker logs oracle-exporter.

Before moving on, take a quick look at what the exporter actually exposes:

curl -s http://localhost:9161/metrics | grep "^# HELP oracledb_" | head -20

This lists the metric names and descriptions for version 2.3.1. The PromQL queries in the sections below assume the default metric names from this version.

Add Prometheus

Create the scrape configuration:

# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: oracle-exporter
metrics_path: /metrics
static_configs:
- targets:
- oracle-exporter:9161

The target is oracle-exporter:9161 — the exporter’s container name on the Docker network. Prometheus will resolve that name from inside the network. Using localhost:9161 would point at the Prometheus container itself.

Start Prometheus:

docker run -d
--name prometheus
--network oracle-obs-demo
-p 127.0.0.1:9090:9090
-v "$(pwd)/prometheus.yml:/etc/prometheus/prometheus.yml:ro"
"$PROMETHEUS_IMAGE"
--config.file=/etc/prometheus/prometheus.yml

Open http://localhost:9090 and go to Status → Targets. The oracle-exporter job should show UP.

Confirm both signals in the query UI before continuing:

up{job="oracle-exporter"}
oracledb_up

up{job="oracle-exporter"} is whether Prometheus can scrape the exporter. oracledb_up is whether the exporter can reach Oracle Database. Both should return 1 before building any panels.

Add Grafana

docker run -d
--name grafana
--network oracle-obs-demo
-p 127.0.0.1:3000:3000
"$GRAFANA_IMAGE"

Open http://localhost:3000 and log in. Default credentials for a fresh Grafana container are admin / admin.

Add Prometheus as a data source: Connections → Data sources → Add new data source → Prometheus. Set the server URL to:

http://prometheus:9090

Not http://localhost:9090 — Grafana is running inside Docker, where localhost means the Grafana container. Use the container name.

Save and test. When it shows connected, you’re ready to build panels.

Build the starter dashboard

Create a new dashboard. The panels below build from monitoring-chain health outward to database behavior. Start at the top and work down.

Is the monitoring chain working?

Two stat panels, side by side.

Prometheus → Exporter:

up{job="oracle-exporter"}

1 means Prometheus can scrape the exporter. 0 means the exporter is down or unreachable.

Exporter → Oracle Database:

oracledb_up

1 means the exporter has a working database connection. 0 means the exporter is alive but cannot reach the database.

These two panels separate the first layer of any debugging conversation: is the monitoring chain itself broken, or is the database the problem?

Exporter collection health

oracledb_exporter_last_scrape_error

Healthy value is 0. A nonzero value means the last collection had errors — often a permissions issue, a query timeout, or a broken custom metric configuration.

oracledb_exporter_last_scrape_duration_seconds

How long collection takes per scrape. If this climbs toward the scrape interval, the exporter is struggling to keep up — usually a slow query or high database load.

oracledb_exporter_scrapes_total

A running total of completed scrapes. Useful for confirming the exporter is actively collecting and not stalled.

Sessions

sum by (status, type) (oracledb_sessions_value)

Database sessions are not the same as active users. They include connection pool slots, background processes, and idle sessions. The useful question is not the absolute value — it is whether the shape changes at the same time as an application event. Did sessions spike when the slow request hit? Did inactive sessions accumulate after a deployment?

Activity

oracledb_activity_execute_count
oracledb_activity_parse_count_total
oracledb_activity_user_commits
oracledb_activity_user_rollbacks

These show whether the workload shape changed. A jump in execute or parse count often corresponds to a new query pattern, a connection pool opening more sessions, or a batch job starting. Compare them against application request volume on the same time axis.

The default Oracle activity metrics are gauges, not counters. Graph them directly. Only apply rate() after checking the # TYPE line for your exporter output.

Wait categories

oracledb_wait_time_user_io
oracledb_wait_time_system_io
oracledb_wait_time_concurrency
oracledb_wait_time_application
oracledb_wait_time_commit
oracledb_wait_time_network

Wait categories show where database time is going. They do not explain why a wait increased, but they tell you which direction to investigate next. User I/O rising with application latency is a different path than concurrency rising after a schema change.

The wait metrics are counters. For a rate-of-change view:

rate(oracledb_wait_time_commit[5m])

Top SQL

topk(10, oracledb_top_sql_elapsed)

This surfaces the SQL statements accumulating the most elapsed time. In a development setup it is the fastest way to find which query to look at next. The label set includes SQL identifiers and may include SQL text — useful locally, but worth reviewing before this panel goes into a shared dashboard.

Reading the dashboard

Once the panels are in place, run a real request through your application and watch the time window in Grafana.

Make a call to your RAG endpoint or Spring Boot AI service. During that window:

  • Did oracledb_sessions_value spike? Session pressure often shows up alongside connection pool wait time.
  • Did execute or parse count shift? More SQL activity usually means a different query pattern or a job running in parallel.
  • Did a wait category increase? User I/O rising with latency points toward reads. Concurrency rising points toward lock contention.
  • Did anything appear at the top of oracledb_top_sql_elapsed? That is the query to look at next.

None of this closes the investigation. What it gives you is a set of specific hypotheses to test — which is what the first article described as the goal.

Clean up

docker rm -f grafana prometheus oracle-exporter
docker network rm oracle-obs-demo
rm -f exporter.env

If you attached an existing Oracle Database container to the demo network, disconnect it first:

docker network disconnect oracle-obs-demo <your-oracle-container>

What’s next

The demo works. The question now is what it takes to run this reliably alongside a real application.

In the next article, we look at what productionizing the exporter actually requires: replacing the broad SELECT_CATALOG_ROLE grant with object-level grants for the metrics you enable, keeping the exporter endpoint private, reviewing which labels leave the database boundary, and making sure someone owns the component before it becomes a dependency nobody maintains.

Posted in Uncategorized | Tagged , , , , , | Leave a comment

Detect Fraud Patterns With Graphs

We have tables. We have a SQL property graph named BANK_GRAPH. Now we can ask graph-shaped questions.

The key tool is GRAPH_TABLE.

GRAPH_TABLE is a SQL table expression. You give it a graph and a pattern to match, and it returns rows. That last part matters. Once the graph match becomes rows, you can use the rest of SQL: joins, filters, grouping, ordering, and common table expressions.

Article 2 created BANK_GRAPH. This article uses that graph to show why pattern matching is worth learning: it lets you express fraud questions about hubs, chains, and cycles more directly than equivalent join-heavy SQL, while still returning rows that join to customer risk data.

In this article, we will query four fraud-style patterns:

  • inbound hubs;
  • two-hop transfer chains;
  • transfer cycles;
  • graph results joined to customer risk data.

Before you start, you’ll need to run the setup code from Article 2. I have copied that here so you can do it all in the one place. Make sure you use the “Run Script” button so it runs all of the statements, not just the first one.

Then run this code:

Ok, now you have everything that we did in the previous two articles back in place!

Now here’s the code for this example:

If you prefer to run everything in ADB-S, copy those three scripts into the same schema and run them in order. SQLcl and SQL*Plus users can run the files directly from the command line.

Before looking at each query, notice the repeated shape. The graph pattern lives inside GRAPH_TABLE, and the rest of the statement is ordinary SQL. That split is the core habit to learn: use graph syntax to find connected structures, then use SQL to summarize and enrich the results.

Pattern 1: Inbound Hubs

A simple first fraud question is: which accounts receive the most transfers?

In graph terms, we are looking for transfer edges that point into the same destination account.

SELECT gt.account_id,
ba.account_name,
COUNT(*) AS inbound_transfers,
SUM(gt.amount) AS inbound_amount
FROM GRAPH_TABLE(
bank_graph
MATCH (src IS account) -[t IS transfer]-> (dst IS account)
COLUMNS (
dst.account_id AS account_id,
t.amount AS amount
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
GROUP BY gt.account_id, ba.account_name
ORDER BY inbound_transfers DESC, inbound_amount DESC;

The pattern is the important line:

MATCH (src IS account) -[t IS transfer]-> (dst IS account)

Read it from left to right: find an account vertex, follow a directed transfer edge, and land on another account vertex.

The COLUMNS clause chooses which graph properties become SQL columns. Here we project the destination account id and transfer amount. The outer SQL query groups those rows to find inbound hubs.

For the demo data, account 105 should stand out because several transfers point into it. That result is not automatically suspicious, but it is a useful triage signal. In a real system, you would combine it with time windows, amount thresholds, customer risk, and historical behavior.

Pattern 2: Accounts In The Middle

A second question is more interesting: which accounts sit in the middle of two-hop money movement?

That pattern looks like this:

(source account)-[transfer]->(middle account)-[transfer]->(destination account)

The SQL graph query is direct:

SELECT gt.middle_account_id,
ba.account_name,
COUNT(*) AS times_in_middle,
SUM(gt.first_amount + gt.second_amount) AS total_chain_amount
FROM GRAPH_TABLE(
bank_graph
MATCH (src IS account) -[t1 IS transfer]-> (mid IS account) -[t2 IS transfer]-> (dst IS account)
COLUMNS (
mid.account_id AS middle_account_id,
t1.amount AS first_amount,
t2.amount AS second_amount
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.middle_account_id
GROUP BY gt.middle_account_id, ba.account_name
ORDER BY times_in_middle DESC, total_chain_amount DESC;

This is where graph syntax starts to feel natural. You are not mentally building a chain of self-joins. You are drawing the relationship pattern and asking Oracle to return the matches.

This query also shows why aliases matter. src, mid, and dst are not table names. They are pattern variables. The same account label appears three times, but each occurrence has a different role in the path. Good variable names make graph patterns much easier to explain on screen.

Pattern 3: Transfer Cycles

Fraud investigations often care about circular movement. Money leaves one account, moves through other accounts, and eventually returns.

The demo data has a simple cycle:

101 -> 102 -> 103 -> 101

In graph terms, we want to start at an account and return to the same account after a few transfer hops.

SELECT gt.account_id,
ba.account_name,
COUNT(*) AS cycle_count
FROM GRAPH_TABLE(
bank_graph
MATCH (a IS account) -[IS transfer]->{3,5} (a)
COLUMNS (
a.account_id AS account_id
)
) gt
JOIN bank_accounts ba
ON ba.account_id = gt.account_id
GROUP BY gt.account_id, ba.account_name
ORDER BY cycle_count DESC, gt.account_id;

The {3,5} quantifier is the part to notice. It asks for paths of three to five repetitions of the transfer step. The final (a) says the path must return to the same starting account.

The graph query expresses the business question almost exactly: “find accounts that can reach themselves through three to five transfers.”

Pattern 4: Join Graph Results To Customer Risk

Graph results are useful by themselves, but they become much more useful when you join them to business context.

For example, a cycle involving a low-risk account may be less urgent than a cycle involving a high-risk customer. Because GRAPH_TABLE returns rows, we can join cycle results to bank_accounts and customers.

WITH cycle_accounts AS (
SELECT account_id, COUNT(*) AS cycle_count
FROM GRAPH_TABLE(
bank_graph
MATCH (a IS account) -[IS transfer]->{3,5} (a)
COLUMNS (
a.account_id AS account_id
)
)
GROUP BY account_id
)
SELECT c.customer_id,
c.full_name,
c.risk_tier,
ba.account_id,
ba.account_name,
ca.cycle_count
FROM cycle_accounts ca
JOIN bank_accounts ba
ON ba.account_id = ca.account_id
JOIN customers c
ON c.customer_id = ba.customer_id
WHERE c.risk_tier IN ('MEDIUM', 'HIGH')
ORDER BY ca.cycle_count DESC, c.risk_tier DESC, ba.account_id;

This is one of the strongest reasons to learn graph inside Oracle Database. You do not have to choose between graph analysis and relational context. You can combine them in one SQL workflow.

That is the practical payoff of this article. GRAPH_TABLE does not trap you in a graph-only world. It gives you a row source. Once you have rows, the rest of your SQL skills still apply.

What To Watch For

Graph syntax makes connected questions easier to read, but it does not remove the need for careful modeling.

Use stable keys for vertices and edges. Expose only the properties readers or applications need. Keep labels clear. Make edge direction explicit. If your business question treats relationships as undirected, say that clearly and choose the right query or algorithm path.

Next Step

Pattern matching answers many practical questions. But sometimes you want a computed signal: an importance score, a connected group id, or a weighted distance from a seed account.

That is where in-database graph algorithms come in. The next article uses DBMS_OGA in ADB-S, because the tested FreeSQL environment does not expose the algorithm packages. It then moves the same graph into Graph Studio for visualization.

That is also where edge weights become concrete. In the next article, the amount property on a transfer edge becomes the weight for a path example, instead of remaining an abstract graph term.

Try The Demo

The hands-on part of this article is the pattern-query script. It uses the BANK_GRAPH you created in Article 2 and runs the four query families you just learned:

  • inbound hubs;
  • two-hop transfer chains;
  • transfer cycles;
  • graph results joined to customer risk.

Use the embedded FreeSQL runner near the top of the article for the quick path. If you prefer ADB-S, run the same script in the schema where BANK_GRAPH already exists.

When the script finishes, spend a minute with the output instead of rushing ahead. Look for account 105 in the inbound hub result, the middle-account counts in the two-hop result, and the medium/high-risk customers in the hybrid query. Those outputs are the bridge between graph pattern matching and the algorithm examples in the next article.

Posted in Uncategorized | Tagged , , , | Leave a comment

Add real business actions to your Spring AI app with Oracle (part 3 of 4)

By the end of Episode 2, the assistant could answer policy questions from Oracle Vector Store and remember conversations across requests. That combination already felt more like a real application than a bare LLM wrapper.

But it still could not act on anything.

Ask it about order ORD-1002 and it would explain the shipping policy. It could not look up the actual order. Ask it to return a product and it would describe the return process. It could not initiate the return. The knowledge was there. The memory was there. The connection to the backend was not.

Episode 3 adds that. Watch it here.

The distinction that matters

Before looking at the code, it is worth being precise about what tool calling is and what it is not.

The model does not get direct access to the database. It does not write SQL. It does not update rows. What it gets is the ability to request that the backend perform a specific named action. The backend then decides whether to allow it, validates the input against business rules, performs the operation if allowed, and returns a result. The model reads that result and generates a response.

This is the difference between AI orchestration and AI autonomy. The model orchestrates. The backend decides. That distinction is what makes the system trustworthy rather than unpredictable.

What changes

The architecture from Episode 2 carries forward unchanged. The memory advisor, the vector-store advisor, and the Oracle-backed persistence are all still there. The only additions are one tool class, one new entity, and one line in ChatClientConfig.

That line:

return chatClientBuilder
.defaultSystem(assistantProperties.systemPrompt())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
questionAnswerAdvisor
)
.defaultTools(agentTools)
.build();

defaultTools(agentTools) registers the AgentTools bean with the chat client. From that point, the model knows those tools exist and can request them during any conversation.

The system prompt was also updated to tell the model how to use the tools and to reinforce that tool results are authoritative:

app:
assistant:
system-prompt: >
You are ShopAssist, a concise and practical support assistant for a demo
electronics store. Use retrieved policy context when it is available.
Use tools for order status lookup, return initiation, and support ticket
creation. Treat tool results as the source of truth for business actions
and explain validation failures clearly. Do not invent order details.
Do not invent action outcomes.

The tool class

AgentTools.java contains three methods, each annotated with @Tool. That annotation is what exposes the method to the model. Spring AI reads the description and parameter annotations at startup and includes them in the model’s context so it knows what each tool does and when to use it.

@Tool(description = "Look up the current status and basic details for a ShopAssist order.")
@Transactional(readOnly = true)
public String lookupOrderStatus(
@ToolParam(description = "The ShopAssist order ID, for example ORD-1002.") String orderId
) { ... }
@Tool(description = "Initiate a return for an eligible delivered ShopAssist order after backend validation.")
@Transactional
public String initiateReturn(
@ToolParam(description = "The ShopAssist order ID, for example ORD-1001.") String orderId,
@ToolParam(description = "The customer's reason for the return.") String reason
) { ... }
@Tool(description = "Create a support ticket for an existing ShopAssist order.")
@Transactional
public String createSupportTicket(
@ToolParam(description = "The ShopAssist order ID, for example ORD-1002.") String orderId,
@ToolParam(description = "A short description of the customer issue.") String issue,
@ToolParam(description = "Ticket priority such as LOW, NORMAL, HIGH, or URGENT.", required = false) String priority
) { ... }

The return types are all String. That is intentional — plain text results are easy for the model to summarise naturally without needing to parse structured data.

lookupOrderStatus is marked @Transactional(readOnly = true). initiateReturn and createSupportTicket are @Transactional because they write to Oracle. The transaction boundary sits inside the tool method, so a validation failure exits cleanly without any partial database change.

How a tool call actually flows

When the model decides a tool is needed, it does not generate a response immediately. Instead it emits a structured tool-call request back to the application. Spring AI intercepts that, invokes the matching method, and sends the result back to the model as a tool message. The model then generates its final response with the result in context.

The full round trip for a return request looks like this: the user message arrives, the memory advisor loads conversation history, the vector-store advisor retrieves relevant policy documents, the model sees all of that and decides to call initiateReturn, the backend validates and either updates Oracle or returns a failure reason, and the model then writes the response to the user based on what the backend actually said.

Business rules in the backend

The return validation is worth looking at directly because it is the clearest example of the backend owning business policy:

private String initiateReturnForOrder(CustomerOrder order) {
if (order.getStatus() == OrderStatus.PREPARING_RETURN) {
return "A return has already been initiated for order %s."
.formatted(order.getOrderId());
}
if (order.getStatus() != OrderStatus.DELIVERED) {
return "Order %s is currently %s and is not eligible for return until it has been delivered."
.formatted(order.getOrderId(), order.getStatus());
}
if (daysSincePurchase(order) > RETURN_WINDOW_DAYS) {
return "Order %s is outside the %d-day return window."
.formatted(order.getOrderId(), RETURN_WINDOW_DAYS);
}
order.markPreparingReturn();
customerOrderRepository.save(order);
return "Return initiated for order %s. The order status is now %s."
.formatted(order.getOrderId(), order.getStatus());
}

Four checks in sequence: already returning, not yet delivered, outside the 30-day window, then success. Every failure exits early with a plain-text reason and touches nothing in the database. Only the last path calls customerOrderRepository.save().

The markPreparingReturn() call is a domain method on CustomerOrder. The status change lives on the entity, not scattered across service or tool layers.

The support ticket entity

SupportTicket is a simple JPA entity backed by the SUPPORT_TICKET table. Episode 3 adds it with Hibernate’s ddl-auto=update creating the table on first startup — no migration script.

@Entity
@Table(name = "SUPPORT_TICKET")
public class SupportTicket {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "support_ticket_seq")
@SequenceGenerator(name = "support_ticket_seq", sequenceName = "SUPPORT_TICKET_SEQ", allocationSize = 1)
@Column(name = "TICKET_ID", nullable = false, updatable = false)
private Long ticketId;
@Column(name = "ORDER_ID", nullable = false, length = 40)
private String orderId;
@Column(name = "ISSUE", nullable = false, length = 1000)
private String issue;
@Column(name = "PRIORITY", nullable = false, length = 20)
private String priority;
@Column(name = "STATUS", nullable = false, length = 20)
private String status;
@Column(name = "CREATED_AT", nullable = false)
private LocalDateTime createdAt;
...
}

The key ID uses GenerationType.SEQUENCE with an explicit @SequenceGenerator, which is the recommended Oracle pattern. Priority and status are plain strings rather than enums — the tool normalises whatever the model passes and defaults anything unrecognised to NORMAL.

Trying it

The application seeds four deterministic demo orders on startup: ORD-1001 (delivered, within the 30-day return window), ORD-1002 (shipped), ORD-1003 (delivered, outside the return window), and ORD-1004 (processing).

Order lookup:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"What is the status of order ORD-1002?"}' | jq

The assistant calls lookupOrderStatus, reads the live status from Oracle, and returns it. It does not guess or interpolate from the conversation.

Successful return:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"I want to return order ORD-1001 because the product was defective."}' | jq

The backend finds the order, confirms it is delivered and within the return window, calls markPreparingReturn(), and saves to Oracle. The status in CUSTOMER_ORDER is now PREPARING_RETURN.

Failed return:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"I want to return order ORD-1003 because the product was defective."}' | jq

ORD-1003 was delivered 45 days ago. The backend rejects it at the return-window check and returns a clear reason. Nothing is written to the database. The model relays the rejection to the user.

This failure case is as important as the success case. It demonstrates that the model cannot bypass business rules by asking politely.

Support ticket:

curl -s -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"Create a high-priority support ticket for ORD-1002 because shipping is stuck."}' | jq

The backend verifies the order exists, normalises the priority to HIGH, sets status to OPEN, and inserts a row into SUPPORT_TICKET.

Inspecting Oracle directly

After those four requests, you can see the backend state directly:

SELECT ORDER_ID, STATUS FROM CUSTOMER_ORDER;

ORD-1001 now shows PREPARING_RETURN. The other three are unchanged.

SELECT TICKET_ID, ORDER_ID, PRIORITY, STATUS FROM SUPPORT_TICKET;

One row: the high-priority ticket for ORD-1002.

The assistant is useful because it is connected to real application state. Not because the model became more capable.

Oracle is now storing four kinds of data

Episode 1 added relational order data and vector embeddings. Episode 2 added conversational state. Episode 3 adds support ticket rows written by tool calls.

All four live in the same Oracle database: CUSTOMER_ORDER, the vector store table, SPRING_AI_CHAT_MEMORY, and SUPPORT_TICKET. Still one database connection from the application. No additional infrastructure.

What is next

The application can now retrieve knowledge, remember conversations, and perform backend actions. But everything still happens synchronously inside a single HTTP request. The model calls a tool, waits for the result, and then responds.

That works for simple actions. It does not work for anything that takes time, depends on external systems, or needs to be retried. Episode 4 starts moving toward event-driven processing — work that can happen outside the request cycle and feed back into the conversation when it completes.

Repo: https://github.com/markxnelson/shopassist/tree/EP3

Posted in Uncategorized | Tagged , , , , , | Leave a comment

From Oracle Database to Grafana: What Oracle Database Metrics Exporter Does for Developers

Freshness note: At the time of writing, the current exporter release is 2.3.1, the default listen address is :9161, the default telemetry path is /metrics, and container examples use container-registry.oracle.com/database/observability-exporter:2.3.1. Treat these as version-specific details and recheck current documentation before relying on copy/paste commands.

Application telemetry stops too early

A Spring Boot AI service can look healthy in the places developers check first. The HTTP layer reports normal error rates. Application metrics show request latency, JVM behavior, token timing, and maybe HikariCP connection pool usage. Then a retrieval-augmented generation, or RAG, endpoint slows down, and the only obvious clue is that “the database call took longer.”

That clue is too thin when Oracle Database is part of the user experience. The database may hold vectors, JSON documents, relational records, queues, conversation state, ingestion metadata, and application state. A slow answer might come from a code change in the service, a different retrieval query, an ingestion job competing with search traffic, a session spike, a wait-class shift, or a SQL hotspot.

Application telemetry is necessary, but it often stops at the application boundary. Database-side metrics fill a different gap: they show what the database was doing during the same time window as application latency, pool pressure, model calls, deployments, and workload changes.

Oracle Database Metrics Exporter exposes Oracle Database metrics so observability tools can scrape, store, visualize, and alert on database behavior alongside application behavior. The payoff is not another dashboard for its own sake. The payoff is correlation by workflow: when a RAG endpoint slows down, developers and platform teams can inspect database-side signals in the same timeline as the service that depends on them.

What a database exporter does

A database exporter is a small service that connects to a database, collects selected metrics, and exposes those metrics through an endpoint that monitoring systems can scrape.

That definition matters because an exporter is a bridge, not the destination. It is not the database. It is not Grafana. It is not an application metrics library. It is not a trace collector. Its job is to turn database-side signals into input for the observability pipeline you already use.

In a Prometheus-style setup, the monitoring system usually follows a pull model. Prometheus or another compatible scraper periodically requests metrics from a target endpoint. The exporter is that target. It connects to Oracle Database, collects configured metrics, and exposes them over HTTP in a scrapeable format.

That changes the debugging conversation. Instead of asking for a separate database view after the fact, the application team can start with a shared timeline: request latency rose at 10:05, connection pool wait time rose at 10:06, sessions increased at 10:06, and database wait behavior changed at 10:07. Metrics do not finish the diagnosis, but they tell everyone where to look next.

What Oracle Database Metrics Exporter is

Tthe exporter is a bridge between Oracle Database instances and observability tools. The exporter exposes metrics in a format compatible with Prometheus-style scraping. metrics are OpenTelemetry and Prometheus-compatible. This article focuses on the Prometheus path because it is the most direct path to reason about: the exporter exposes metrics over HTTP, Prometheus or a compatible scraper collects and stores them, and Grafana visualizes them through a Prometheus-compatible data source.

In an OpenTelemetry-oriented platform, a common pattern is to use the OpenTelemetry Collector as part of the metrics pipeline, for example by scraping the exporter’s Prometheus-format endpoint with the Collector’s Prometheus receiver and forwarding metrics to a backend. That does not mean the exporter emits traces, performs automatic application-to-database trace correlation, or natively pushes OTLP metrics to every backend.

The exporter project is free and open source. It is available under your choice of the Universal Permissive License v1.0, or the MIT license . That statement applies to the exporter project itself; database, infrastructure, cloud service, managed backend, and SaaS observability costs are separate.

The exporter works across on-premises, cloud, Kubernetes, single-instance, clustered, and Autonomous Database deployments. Check the current docs for exact version, network, authentication, wallet, and privilege requirements in your environment.

The exporter can provide default metrics and custom SQL-based metrics defined in YAML or TOML. Custom metrics are powerful, but they need design review. Each query needs appropriate database grants and should be evaluated for execution cost, scrape interval, timeout behavior, label cardinality, and sensitive data exposure.

The path from Oracle Database to Grafana

Grafana is not collecting from Oracle Database directly in this path. The usual flow looks like this:

The default endpoint details are version-specific. In version 2.3.1, the exporter defaults to --web.listen-address=:9161 and --web.telemetry-path="/metrics".

The networking detail that trips up many first demos is point of view. Use the address reachable from Prometheus, not necessarily the address reachable from your terminal. For a same-host local test, this might be localhost:9161. In Docker Compose, localhost usually means the Prometheus container itself, so the target is usually the exporter service name. In Kubernetes, it would normally be a Service DNS name.

For a same-host local test where Prometheus can reach the exporter on localhost, the scrape target might look like this:

scrape_configs:
- job_name: "oracle-database"
static_configs:
- targets: ["localhost:9161"]

For a Docker Compose-style setup where exporter is the service name reachable from the Prometheus container, the target would look more like this:

scrape_configs:
- job_name: "oracle-database"
static_configs:
- targets: ["exporter:9161"]

These snippets show the shape of the scrape configuration, not a complete production Prometheus setup. The correct target depends on where Prometheus runs, how the exporter is exposed, and what network rules sit between the scraper and the exporter.

A first useful local check

The smallest useful demo is not a full dashboard. It is proving that the exporter can connect to a development database and expose one expected metric.

Oracle provides a local Docker Compose test stack with Oracle AI Database, the exporter, Prometheus, and Grafana, and the docs state that it provisions a dashboard. Treat that stack as local testing only. Do not expose sample database listener ports on shared or public hosts.

You can start up the demo system with a command like this – make sure you choose a password, don’t copy the example:

cd docker-compose
DB_PASSWORD='<choose-a-local-demo-password>' docker-compose up -d

Once the exporter is running and reachable from your shell, the first check is simple:

curl http://localhost:9161/metrics | grep oracledb_up

Expected shape:

oracledb_up{database="..."} 1

This proves that the exporter can observe the database from its own connection path. It does not prove application health, production readiness, or that every metric query succeeded. If oracledb_up is missing or zero, check exporter logs, database connectivity, credentials, privileges, service name, and metric configuration before trusting deeper panels.

You can log into the Grafana instance at http://localhost:3000 and you will need to enter “admin” as both the user and the password. It will ask you to create a new password. Then you can nagivate to “Dashboards” and open the “Oracle Dashboard”. You’ll see something like this:

What to look at first

Start with exporter health before interpreting database behavior. The default metrics include oracledb_exporter_last_scrape_error, oracledb_exporter_last_scrape_duration_seconds, and oracledb_exporter_scrapes_total.

A healthy oracledb_exporter_last_scrape_error value is 0. A nonzero value indicates exporter scrape errors, not necessarily a database outage. The cause might be credentials, network reachability, a timeout, a permissions problem, a custom metric query, or database load.

Then add a few database-side panels that help explain application symptoms.

oracledb_up is the basic availability signal from the exporter’s perspective:

oracledb_up

For sessions, Oracle’s 2.3.1 sample output shows oracledb_sessions_value with labels such as status and type, so this is a reasonable first query after validating your own output:

sum by (status, type) (oracledb_sessions_value)

Do not treat this as a direct count of active users. Database background sessions and configuration can make the result noisy. The useful question is whether the shape changes during the incident window: did sessions rise when the endpoint slowed down, did inactive sessions pile up, or did work shift after a deployment or ingestion job?

Activity metrics such as oracledb_activity_execute_count, oracledb_activity_parse_count_total, oracledb_activity_user_commits, and oracledb_activity_user_rollbacks can show whether workload changed. Graph activity metrics directly first. Only use rate() after confirming the metric type and behavior in the exporter output for your version, because the docs expose some cumulative-looking database activity values as gauges.

Wait-time metrics provide another useful triage layer. Default metrics include categories such as user I/O, system I/O, concurrency, application, commit, and network:

oracledb_wait_time_user_io
oracledb_wait_time_system_io
oracledb_wait_time_concurrency
oracledb_wait_time_application
oracledb_wait_time_commit
oracledb_wait_time_network

In the demo environment, there is probably not enough workload to see waits. In a real system, the value is in watching whether wait behavior changes during the same window as application latency.

The default metrics also include oracledb_top_sql_elapsed. Use it as a hotspot signal, not as a complete tuning workflow. In the sample, this metric includes labels such as SQL ID and truncated SQL text. That can be useful for triage, but it also raises label-cardinality and sensitive-text concerns. Review whether those labels are appropriate for your monitoring backend before enabling broad retention or alerting on them. You may not want all your SQL statement being stored in your monitoring backend!

Why AI applications make database metrics part of user experience

AI applications often make database behavior more visible to users, even when the application code hides the details. A RAG endpoint might perform vector search, filter JSON metadata, fetch relational records, update conversation memory, write audit rows, and call a model in a single request path. If one of those database steps slows down, the user experiences it as a slow answer.

Application metrics can show that the endpoint got slower. They may also show that the connection pool waited longer or that model latency was stable. But they usually cannot answer database-side questions by themselves:

Did sessions rise during ingestion? Did execute or parse activity change after a new retrieval strategy? Did waits move toward I/O, concurrency, commit, network, or application categories? Did a new SQL shape become a hotspot? Did the exporter itself start reporting scrape errors?

Oracle Database Metrics Exporter helps answer those questions in the same observability workspace used for service metrics. That does not make the exporter an AI-specific tool. It makes it useful for AI systems because AI request paths often combine application code, database retrieval, background ingestion, and model calls in ways that are difficult to debug from one layer alone.

Security basics before copy/paste becomes habit

The exporter needs database connectivity and credentials. For demos, environment variables are acceptable placeholders. For production-like setups, use configuration files, password files, vault integrations, wallet-based authentication, or platform secret management as appropriate.

There are several credential and authentication options, including username/password, environment-variable substitution, password files, OCI Vault, Azure Vault, HashiCorp Vault, Oracle Wallet with TLS or mTLS, Secure External Password Store, and external authentication. For mTLS connections, including Autonomous Database with mTLS, Oracle Wallet is required.

Privileges deserve the same care. For default metrics, the documentation lists SELECT_CATALOG_ROLE as one option and also lists object-level grants on specific catalog and dynamic performance views. Do not treat SELECT_CATALOG_ROLE as automatically least privilege. In production-like environments, create a dedicated monitoring user and grant the narrowest permissions that support the metrics you actually collect. For custom metrics, grant access only to the objects queried by those metrics.

Tune scrape interval and query timeout for your environment. Metric collection is database work. Its cost depends on the default metrics you enable, custom SQL, scrape frequency, query timeout, number of databases, network latency, and current database load. You should be aware that running the exporter is not “zero overhead” or “low overhead” – it depends on the metrics you define and how often you collect them. You should perform measurement in your own environment.

Network placement also matters. The exporter must reach the database listener. Prometheus or the OpenTelemetry Collector must reach the exporter endpoint. Grafana must reach the metrics backend, not necessarily the exporter. In production, keep database listeners and exporter endpoints inside the intended network boundary and expose only what your monitoring architecture requires.

How it fits with tools you already have

Oracle Database Metrics Exporter complements application telemetry; it does not replace it. Spring Boot Actuator and Micrometer show how the application experiences the database: connection pool wait time, request latency, error rates, JVM behavior, and application-specific counters. The exporter shows database-side signals. The useful workflow is to compare both in the same time window.

It also does not replace Oracle Enterprise Manager or deeper Oracle diagnostic workflows. Enterprise Manager remains a broader Oracle management and monitoring platform. Exporter metrics are a different integration primitive: a bridge into Prometheus-compatible developer and SRE metrics workflows.

For detailed SQL tuning, plan changes, workload analysis, and deeper database diagnosis, use the appropriate Oracle diagnostic tooling and DBA workflows, subject to your edition and licensing. Metrics can tell you where to look. They do not automatically explain every query plan, locking issue, schema design problem, or workload pattern.

The exporter path is most relevant when Prometheus-compatible metrics are the standard integration layer, or when platform teams want Oracle Database behavior available in the same metrics pipeline as the rest of their services.

Custom SQL collectors can also be useful, especially for application-specific signals. The tradeoff is that you own the query design, metric naming, labels, metric types, privileges, scrape endpoint, and operational behavior. A maintained database exporter reduces the need to build every one of those pieces from scratch.

The practical mental model

Think of Oracle Database Metrics Exporter as a narrow but valuable bridge:

Oracle Database behavior
→ exporter metric queries
→ scrapeable metrics endpoint
→ Prometheus-compatible storage
→ Grafana panels, alerts, and shared incident timelines

That bridge is useful because database behavior often explains application symptoms. It is especially useful for AI applications, where one slow response may involve retrieval queries, JSON filters, vector search, ingestion load, connection pools, and model calls.

Start small. Prove oracledb_up. Check exporter scrape health. Add one sessions panel. Add one activity or wait panel. Compare those panels against application latency during a real workload. Once the database appears in the same timeline as the application, the conversation changes from “the database was slow” to “here is what changed, when it changed, and where we should investigate next.”

Posted in Uncategorized | Tagged , , , , , , | 1 Comment

Build Your First SQL Property Graph In Oracle AI Database 26ai

The first article introduced the graph idea: vertices are things, edges are relationships, and properties describe both.

Now we will build one.

The easiest way to learn SQL property graphs in Oracle AI Database 26ai is to start with ordinary tables. That keeps the graph concrete. You can see the rows first, then see how the graph definition gives those rows a connected shape.

The point of this article is simple: you can create a queryable fraud graph on top of existing Oracle tables in one SQL statement, without duplicating the underlying data. By the end, you will have run the seed SQL, created BANK_GRAPH, and inspected the metadata Oracle stores for the graph.

In this article, we will create a small bank dataset and define a graph named BANK_GRAPH.

Start With Tables, Not Theory

Our fraud example has three tables:

  • customers, which stores customer names and risk tiers;
  • bank_accounts, which stores account records;
  • bank_transfers, which stores money movement from one account to another.

The transfer table is the key. Every transfer has a source account and a destination account. That is already an edge hiding in relational form.

This is why the example works well for a first graph. We do not have to invent a relationship. The relationship already exists in the schema; the graph definition just makes it explicit and queryable as a path.

The demo seed data includes a few useful patterns:

  • a 3-hop cycle: 101 -> 102 -> 103 -> 101;
  • a fan-in pattern where several accounts send money to account 105;
  • a two-hop chain where account 105 sends to 108, and 108 sends to 106;
  • customer risk tiers that we can join back to graph results later.

First, create the three tables, load the small dataset, and print row counts. Make sure you flip the switch to enable read/write so you can create new objects! If you do not, you will get an “insufficient privileges” error.

If you prefer to work in your own Autonomous Database Serverless instance, copy the same script into SQL Worksheet and run it there. SQLcl and SQL*Plus users can run the file directly from the command line.

When it finishes, you should see row counts for customers, accounts, and transfers.

This first tutorial deliberately avoids Object Storage and bulk-loading APIs. Those matter for production data, but they add noise to a beginner lesson whose real goal is graph modeling.

Define The Graph Vocabulary

The graph definition answers four questions:

  • Which table contains vertices?
  • Which table contains edges?
  • Which columns identify each vertex and edge?
  • Which properties should graph queries expose?

For this demo, bank_accounts becomes the vertex table and bank_transfers becomes the edge table.

Here is the essential graph definition:

CREATE OR REPLACE PROPERTY GRAPH bank_graph
VERTEX TABLES (
bank_accounts KEY (account_id)
LABEL account
PROPERTIES (account_id, customer_id, account_name, balance)
)
EDGE TABLES (
bank_transfers KEY (transfer_id)
SOURCE KEY (src_account_id) REFERENCES bank_accounts (account_id)
DESTINATION KEY (dst_account_id) REFERENCES bank_accounts (account_id)
LABEL transfer
PROPERTIES (transfer_id, amount, transfer_ts, channel, note)
);

The VERTEX TABLES clause says that rows in bank_accounts are account vertices. The KEY clause says account_id uniquely identifies each vertex.

The EDGE TABLES clause says that rows in bank_transfers are transfer edges. The SOURCE KEY and DESTINATION KEY clauses make the edge direction explicit.

That direction matters. A transfer from account 101 to account 102 is not the same as a transfer from 102 to 101.

The PROPERTIES clauses are equally important. They decide which table columns are visible to graph queries. If you leave a column out, it remains available to ordinary SQL, but it will not be projected as a graph property in the graph pattern. For a teaching demo, keep the exposed properties small and obvious.

Next, create BANK_GRAPH and run the metadata checks shown below.

If you are using ADB-S instead, copy the code into the same schema where you ran the seed script.

Inspect The Graph

After the graph is created, inspect the database metadata.

The first check is simple:

SELECT graph_name
FROM user_property_graphs
WHERE graph_name = 'BANK_GRAPH';

That confirms that Oracle Database knows about the graph object.

Next, inspect the graph elements:

SELECT graph_name, element_name, element_kind, object_name
FROM user_pg_elements
WHERE graph_name = 'BANK_GRAPH'
ORDER BY element_kind, element_name;

You should see the account vertex element and the transfer edge element.

Then inspect labels and properties. This is useful when you are teaching or debugging because it shows the vocabulary that graph queries can use.

SELECT graph_name, label_name, property_name
FROM user_pg_label_properties
WHERE graph_name = 'BANK_GRAPH'
ORDER BY label_name, property_name;

The output should include properties such as ACCOUNT_ID, ACCOUNT_NAME, BALANCE, AMOUNT, CHANNEL, and TRANSFER_TS.

This metadata check is more than a sanity test. If a later graph query cannot see amount or account_name, the metadata views tell you whether the graph definition exposed those properties in the first place.

What The Graph Does Not Do

Creating BANK_GRAPH does not change the purpose of the base tables. Applications can still use customers, bank_accounts, and bank_transfers as ordinary relational tables.

The graph also does not decide what is fraudulent. It gives you a way to ask connected-data questions. In later articles, those questions become signals: cycles, hub accounts, accounts in the middle of transfer chains, PageRank scores, and connected groups. A real fraud workflow would combine those signals with business rules, investigation history, KYC data, device information, and human review.

The graph helps you see connection patterns. It does not magically replace risk modeling.

A Quick Check Before Moving On

Before you continue, make sure three things are true.

First, the base tables have rows. Second, USER_PROPERTY_GRAPHS shows BANK_GRAPH. Third, USER_PG_LABEL_PROPERTIES shows the properties you expect to query later, especially ACCOUNT_ID, ACCOUNT_NAME, AMOUNT, CHANNEL, and TRANSFER_TS.

Those checks are small, but they save time. Most beginner graph-query mistakes come from one of two places: the graph object was not created in the schema you are querying, or the property you want was not exposed in the graph definition.

If those checks pass, we can focus on patterns instead of setup. That is exactly where graph starts to feel different from ordinary table queries in practice.

Why This Matters

The graph is now ready, but the original data has not stopped being relational data.

That is the point. You can still query customers, bank_accounts, and bank_transfers with ordinary SQL. The graph adds a new query surface for connected patterns.

In the next article, we will use GRAPH_TABLE to ask questions like:

  • Which accounts receive money from many sources?
  • Which accounts sit in the middle of transfer chains?
  • Which accounts are part of transfer cycles?
  • Which suspicious graph results belong to high-risk customers?

Those questions are where the graph model starts to pay off.

Next step: move to Article 3, where the graph stops being metadata and starts answering fraud questions.

Try The Demo

This article had two runnable moments that you already saw above!

First, load the small bank dataset. That gives you ordinary relational tables with customers, accounts, and transfers. Second, create BANK_GRAPH over those tables and inspect the graph metadata.

Use the embedded FreeSQL runners above if you want the fastest path. Step 1 loads the data; Step 2 creates and checks the graph. If you prefer to use your own ADB-S instance, copy the same SQL into SQL Worksheet and run the scripts in the same order.

When both steps finish, you should have a graph named BANK_GRAPH in your schema. Keep it there for the next article. We will use that same graph to query hubs, two-hop chains, cycles, and customer-risk joins.

Posted in Uncategorized | Tagged , , | Leave a comment

Add persistent chat memory to your Spring AI chatbot with Oracle (part 2 of 4)

The first version of this application could already answer support questions against real policy data. Oracle Vector Store was retrieving the right documents before generation, and the responses were grounded in what was actually in the database rather than in whatever the model happened to know from training.

But every request started from zero.

Send “My name is Maya” and then immediately ask “What’s my name?” and the assistant had no idea. Not because retrieval failed. Because the application itself was stateless. Conversation history was never stored anywhere. Each request arrived with only the current message and the policy documents that matched the similarity search. The moment the response was sent, everything about the exchange was gone.

That is the gap between a search engine with a chat interface and something that actually behaves like a conversation. Episode 2 closes it. Watch it here,

RAG and memory are different problems

It is worth being precise about this before looking at the code, because the two ideas are easy to conflate.

RAG — retrieval-augmented generation — gives the model access to information it would not otherwise have. When a customer asks about the refund policy, the application runs a similarity search against Oracle Vector Store, finds the relevant policy document, and includes it in the prompt. The model can then answer from real data rather than from training knowledge.

Conversational memory is a different thing entirely. It is not about what the model knows. It is about what the application remembers. Without memory, each request is independent. With memory, the application accumulates context across exchanges — facts the user has shared, questions already asked, decisions already made — and makes that context available to the model on every subsequent request.

Both matter. RAG makes individual answers accurate. Memory makes the conversation coherent. Episode 1 solved the first problem. Episode 2 solves the second.

What changes

The overall architecture stays the same. Spring Boot, Spring AI, Oracle Vector Store, and OpenAI are all unchanged from Episode 1. The only addition is persistent chat memory backed by a JDBC repository in Oracle.

One new dependency:

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>

One new configuration property:

spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always

That creates the SPRING_AI_CHAT_MEMORY table in Oracle on startup. No migration script, no manual DDL.

The memory bean

ChatMemoryConfig.java creates a ChatMemory bean backed by JdbcChatMemoryRepository:

@Bean
@ConditionalOnMissingBean(ChatMemory.class)
public ChatMemory chatMemory(DataSource dataSource, MemoryProperties memoryProperties) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(JdbcChatMemoryRepository.builder()
.dataSource(dataSource)
.build())
.maxMessages(memoryProperties.maxMessages())
.build();
}

MessageWindowChatMemory keeps a sliding window of the most recent messages per conversation. The window size is maxMessages, set to 100 in application.yml. As the conversation grows, older messages fall off the window so the prompt does not grow without bound.

The @ConditionalOnMissingBean annotation is worth noting. Tests substitute an in-memory ChatMemory implementation in TestSupportConfiguration, so the test suite can load the full application context without requiring a live database. The @ConditionalOnMissingBean is what makes that substitution work — if a test has already registered a ChatMemory bean, this bean definition is skipped.

Adding memory to the chat client

Episode 1 already had a QuestionAnswerAdvisor wired into the ChatClient for RAG retrieval. Episode 2 adds MessageChatMemoryAdvisor in front of it:

return chatClientBuilder
.defaultSystem(assistantProperties.systemPrompt())
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
questionAnswerAdvisor
)
.build();

The order matters, and it is worth understanding why.

Advisors in Spring AI form a chain that wraps around the model call. Each advisor can modify the request before it reaches the model and can inspect or modify the response on the way back out. They execute in registration order on the way in, and in reverse order on the way out.

Registering MessageChatMemoryAdvisor first means it runs before QuestionAnswerAdvisor. On the way in, the memory advisor loads the stored conversation history for the current conversation ID and adds those messages to the request. By the time QuestionAnswerAdvisor runs its similarity search, the full conversation context is already present. The retrieved policy documents are then appended on top of that. By the time the model sees the request, it has both the conversation history and the relevant policy knowledge.

If the advisors were in the wrong order — retrieval first, memory second — the memory advisor would add conversation history after retrieval had already run. That would still work for basic memory, but the similarity search would only have the current message to work with, not the full conversation context. For most queries that does not matter, but it can affect retrieval quality for follow-up questions where the topic is implicit from prior context rather than stated explicitly in the current message.

Scoping memory by conversation

Without scoping, all users would share the same memory, which would be disastrous. The chat endpoint now requires an X-Conversation-Id header:

@PostMapping("/chat")
public ChatResponse chat(
@RequestHeader("X-Conversation-Id") String conversationId,
@Valid @RequestBody ChatRequest request
) {
return new ChatResponse(
assistantService.answer(sanitizeConversationId(conversationId), request.message()),
"stateful-rag"
);
}

The caller is responsible for supplying and maintaining the conversation ID. The application does not generate or track session identifiers — that is intentional. For this demo, any string works. In production you would tie the conversation ID to an authenticated user session, but that is out of scope here.

The conversation ID is passed into the advisor chain at call time:

chatClient.prompt()
.user(message)
.advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.chatClientResponse();

Spring AI reads that parameter, loads the matching rows from SPRING_AI_CHAT_MEMORY, prepends them to the prompt, and writes the new exchange back to the table when the call completes. The scoping is entirely key-based. Two requests with different conversation IDs read and write completely separate sets of rows. There is no shared state between them.

Trying it

Start the application and send two messages with the same conversation ID:

curl -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"My name is Maya."}'
curl -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-1"
-d '{"message":"What'''s my name?"}'

The second request should come back with “Maya”. Now try the same question with a different conversation ID:

curl -X POST http://localhost:8080/api/v1/agent/chat
-H "Content-Type: application/json"
-H "X-Conversation-Id: demo-2"
-d '{"message":"What'''s my name?"}'

The assistant should not know. The rows for demo-1 are invisible to a request arriving with demo-2. Memory does not leak between conversations.

Inspecting what Oracle is storing

There is a debug endpoint that reads directly from ChatMemory:

curl "http://localhost:8080/api/v1/debug/memory?conversationId=demo-1"

It returns the stored messages with their roles:

[
{"role": "user", "text": "My name is Maya."},
{"role": "assistant", "text": "Nice to meet you, Maya! How can I help you today?"}
]

You can also go directly to the table:

SELECT * FROM SPRING_AI_CHAT_MEMORY;

These are ordinary rows. Conversation ID, role, content, timestamp. There is nothing opaque about the storage format. This also means the memory is durable — restart the application and the conversation history is still there, because it is in Oracle and not in process memory.

The model did not become smarter between episodes. The application stopped throwing away state.

Oracle is now storing three kinds of data

After Episode 1, Oracle was already handling two things: relational order data in CUSTOMER_ORDER, and vectorized policy knowledge in the Spring AI Oracle Vector Store table.

Episode 2 adds a third: conversational state in SPRING_AI_CHAT_MEMORY.

All three live in the same database. There is no separate vector store running alongside, no in-memory session cache to manage, no additional infrastructure to operate. The application connects to one Oracle instance and that instance holds everything the assistant needs — business data, knowledge, and conversation history.

That consolidation is not just operationally convenient. It also opens up possibilities that would be harder to achieve across separate systems. If you later wanted to run a query that joined conversation history with relational order data — for example, to find conversations where a customer mentioned an order that was then returned — all the data is in the same place. You do not have to reconcile records across multiple stores.

What is next

The assistant can now retrieve knowledge and remember conversations. But it still cannot act on any of the backend data. If a customer asks about the status of order ORD-1002, the assistant can only explain what the return policy says. It cannot look up the actual order.

Episode 3 changes that. Spring AI tool calling lets the assistant call narrow backend methods — order lookup, return initiation, support ticket creation — with the backend owning all the validation logic. The model handles the conversation. The backend handles the rules.

Repo: https://github.com/markxnelson/shopassist/tree/EP2

Posted in Uncategorized | Tagged , , , , | Leave a comment

What Is a Graph in Oracle AI Database 26ai?

If you have ever asked, “How is this thing connected to that thing?”, you have already asked a graph question.

A customer sends money to an account. That account sends money to two other accounts. One of those accounts sends money back to the original customer. In ordinary SQL, you can query each transfer. But when the real question is about chains, loops, hubs, and hidden intermediaries, the shape of the question becomes graph-shaped.

Oracle AI Database 26ai makes that graph shape part of the database instead of a separate side project. You can model a property graph over existing relational tables, query it with SQL graph syntax, join the results back to relational data, and then move into Graph Studio or PGX when you need visualization or heavier analytics.

Did you know that graphs were added to the SQL standard? Read about it here.

This series starts from zero. You do not need graph theory. You do not need a separate graph database. You need a basic comfort with SQL and a practical problem where relationships matter.

There are two ways you can try the runnable examples in this series. The first path is to use FreeSQL and this will work just fine for most of the examples: creating the tables, creating the graph, and querying patterns with GRAPH_TABLE. The second path is your own Autonomous Database Serverless instance on OCI for the full analytics chapter, because the DBMS_OGA algorithm examples require packages that are not available in the FreeSQL environment.

By the end of this article, you should be able to explain five ideas well enough to understand the graph DDL in the next article: vertex, edge, label, property, and directed relationship. You should also understand why Oracle’s 26ai graph approach is accessible from ordinary SQL instead of starting in a separate graph-only tool.

That is the only conceptual load for now. We will save weighted paths, algorithms, PGX, and natural-language querying for later articles, when those ideas have a working graph underneath them.

The Graph Mental Model

A graph has two main parts: vertices and edges.

A vertex is a thing. In a banking example, a vertex might be an account. In a social network, it might be a person. In a supply chain, it might be a warehouse, supplier, shipment, or part.

An edge is a relationship between things. In the banking example, a transfer from account 101 to account 102 is an edge. The edge has direction because money moved from one account to another.

Both vertices and edges can have properties. An account vertex can have an account id, customer id, account name, and balance. A transfer edge can have an amount, timestamp, channel, and note.

Labels give those things names in the graph model. In this series, account vertices use the label account, and transfer edges use the label transfer. Labels make graph patterns readable because you can ask for accounts connected by transfers instead of thinking only in table and column names.

That gives us a simple model:

  • account is a vertex;
  • transfer is an edge;
  • transfer direction goes from source account to destination account;
  • transfer amount and timestamp are edge properties;
  • customer id and balance are vertex properties.

The point is not to replace tables. The point is to describe relationships over data you already have.

That distinction helps avoid a common beginner mistake. You are not deciding whether the account data is “relational” or “graph.” It is relational data that also has a graph interpretation. Oracle lets you keep both views of the same facts.

Why Graph Questions Feel Different

Relational databases are excellent at storing and querying structured facts. A transfer table can tell you that account 101 sent 500 dollars to account 102. A customer table can tell you that account 102 belongs to a medium-risk customer.

Graph questions start when the relationship pattern matters.

For example:

  • Which accounts receive money from many different sources?
  • Which accounts sit in the middle of two-hop transfer chains?
  • Which accounts participate in round-trip transfer cycles?
  • Which high-risk customers are connected to suspicious paths?

You can answer some of these with ordinary joins, especially when the path length is fixed and short. But the queries become harder to read as soon as you want to ask about paths, cycles, or repeated relationship patterns.

That is where GRAPH_TABLE becomes useful. It lets you describe a graph pattern and return the matches as rows. Once the graph match is back in row form, you can use normal SQL again: filter it, aggregate it, join it, and sort it.

What Changed In 26ai

In Oracle AI Database 26ai, SQL property graphs are native database objects. You create them with CREATE PROPERTY GRAPH, and you query them with SQL graph syntax such as GRAPH_TABLE.

The important beginner idea is this: a SQL property graph is metadata over database objects. You do not have to copy all of your rows into a separate graph store just to start asking graph questions. The graph definition says which tables provide vertices, which tables provide edges, how the keys connect, which labels to use, and which properties to expose.

For the bank example in this series, the relational data is still stored in ordinary tables:

  • customers
  • bank_accounts
  • bank_transfers

The graph object simply gives those tables graph meaning:

  • bank_accounts becomes account vertices;
  • bank_transfers becomes transfer edges;
  • src_account_id and dst_account_id define edge direction.

That is why graph in 26ai is a good fit for developers and DBAs who already work with Oracle Database. You can start with SQL, keep the data where it is, and add graph-shaped queries where they help.

Why Not RDF?

Oracle supports RDF graphs too, but this series is about property graphs. RDF is the better fit when the work centers on formal semantics, ontologies, inferencing, and standards-based knowledge representation. Property graphs are the better starting point here because the question is operational and concrete: how are these accounts connected, and what suspicious patterns do those connections form?

The Bank Fraud Story We Will Use

The rest of this series uses a small bank-fraud style example. It is intentionally tiny so you can understand every row.

The demo has customers, accounts, and transfers. Some transfers form a simple cycle. Other transfers create a fan-in pattern where several accounts send money to the same destination. Another chain places a high-risk customer in the middle.

That gives us enough data to teach useful graph ideas without hiding the lesson inside a huge dataset.

Here is the shape of the data we will use throughout this series:

customers bank_accounts bank_transfers
---------- ------------- --------------
customer_id -> customer_id transfer_id
risk_tier account_id -> src_account_id
account_name -> dst_account_id
balance amount
transfer_ts
channel

The SQL property graph gives those tables this connected shape:

(account)-[transfer]->(account)

That one pattern, account connected to account by transfer, is enough to teach all four articles’ worth of graph concepts: paths, cycles, hubs, chains, ranking, connected groups, and hybrid SQL joins.

Where This Series Is Going

The next article builds the graph. We will create the tables, load seed data, define BANK_GRAPH, and inspect the graph metadata.

After that, we will query fraud patterns with GRAPH_TABLE. Then, in ADB-S, we will add in-database algorithms with DBMS_OGA and use Graph Studio to visualize the same graph.

The goal is not to memorize every graph feature. The goal is to build a practical mental model:

  1. start with relational data;
  2. define a SQL property graph;
  3. query graph patterns with SQL;
  4. join graph results to ordinary data;
  5. move into Graph Studio or PGX when you need visualization or deeper analytics.

That is the zero-to-hero path.

Try The Demo

The fastest way to make the ideas concrete is to run the tiny bank demo yourself. The first runnable step creates three ordinary relational tables:

  • customers
  • bank_accounts
  • bank_transfers

Those tables hold the facts. The next step creates BANK_GRAPH, a SQL property graph over the account and transfer rows. Nothing is copied into a separate graph store; the graph definition gives the existing rows a connected shape.

Note: this will ask you to login with your oracle.com account since it writes to the database, not just reads. It’s totally free. I recommend you read through the SQL first to understand what it does, then click on the “Run Script” button to execute it, and then scroll through the output to see what happened. Feel free to play around with it and change it however you like!

In the next article, you’ll load the seed data and create the graph. If you prefer to use your own Autonomous Database Serverless instance, copy the same SQL into your SQL Worksheet and run it there. Either way, keep the data small at first. The goal is to see the graph model clearly before adding larger datasets, algorithms, or visualization.

Once BANK_GRAPH exists, the later examples will use the same graph to find inbound hubs, transfer chains, cycles, PageRank scores, connected groups, and weighted paths.

Posted in Uncategorized | Tagged , , | 1 Comment

Build a stateless RAG chatbot with Spring AI and Oracle (part 1 of 4)

Hi everyone!

I just posted the first video in this series about building AI assistance with memory and agency. In this video, we built a simple customer support assistant using Spring Boot, Spring AI, and Oracle Database. It answers policy questions, but the key idea is that those answers are grounded in data stored in Oracle rather than coming from the model itself.

In this post I want to walk through how the application is actually put together, focusing on the pieces that make that retrieval flow work.

The full project is here: https://github.com/markxnelson/shopassist

If you clone the repo and open it up, it looks like a normal Spring Boot application. The difference is how Oracle is used underneath, and how that gets wired into the chat flow.

A good place to start is with how the database is brought up locally.

In compose.yaml, the application relies on Spring Boot’s Docker Compose support to start Oracle automatically:

services:
  oracle:
    image: gvenzl/oracle-free:23.26.1-slim
    ports:
      - "1521"
    environment:
      ORACLE_PASSWORD: oracle
      APP_USER: shopassist
      APP_USER_PASSWORD: shopassist

There’s nothing unusual here, but it’s worth noting that this is just a standard Oracle instance. There’s no separate “vector database” running anywhere. The same database is going to store both relational data and vector embeddings.

Once that container is running, the next piece is how the application connects to it.

In application.yml, you can see both the datasource configuration and the vector store configuration side by side:

spring:
  datasource:
    url: jdbc:oracle:thin:@localhost:${oracle.port}/FREEPDB1
    username: shopassist
    password: shopassist
  ai:
    vectorstore:
      oracle:
        initialize-schema: true
        index-type: NONE
        distance-type: COSINE

This is where Oracle AI Vector Search becomes visible in the application.

The initialize-schema flag tells Spring AI to create the underlying tables needed to store embeddings. Once that happens, the database is no longer just storing rows – it’s storing vectors that can be searched using similarity.

The distance-type: COSINE setting controls how similarity is calculated, which is what allows us to retrieve “related” documents even when the wording is different.

With that in place, the next question is how data actually gets into the vector store.

That happens in DataSeeder.java, and this is one of the more important pieces of the application because it defines what the system actually knows.

If you look at the seeder, you’ll see the policy documents being added like this:

vectorStore.add(List.of(
    new Document("""
        POL-RETURN-01:
        Returns are accepted within 30 days of purchase with a valid receipt.
        Refunds are issued to the original payment method.
        """),
    new Document("""
        POL-DAMAGE-01:
        Damaged items must be reported within 48 hours of delivery.
        Customers may request a replacement or refund.
        """),
    new Document("""
        POL-SHIPPING-01:
        Shipping delays may occur due to weather or carrier issues.
        Customers will be notified of significant delays.
        """),
    new Document("""
        POL-REFUND-01:
        Refunds are typically processed within 5-7 business days after approval.
        Processing times may vary by payment provider.
        """)
));

Each of these documents is embedded and stored in Oracle. That’s what allows us to later take a user’s question, convert it into a vector, and find the most similar documents using Oracle AI Vector Search.

The same class also seeds some relational order data, which we expose through /api/v1/orders. That isn’t used by the chatbot yet, but it becomes important later when we start combining retrieval with structured queries.

Once the data is in place, the next piece is how a request flows through the system.

If you open AgentController.java, the endpoint itself is intentionally simple:

@RestController
@RequestMapping("/api/v1/agent")
public class AgentController {
    private final ChatClient chatClient;
    public AgentController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }
@PostMapping("/chat")
    public String chat(@RequestBody ChatRequest request) {
        return chatClient.prompt(request.message()).call().content();
    }
}

On its own, this just looks like a thin wrapper around an LLM call. There’s no obvious retrieval happening here.

That’s because the retrieval is introduced through how the ChatClient is configured:

QuestionAnswerAdvisor.builder(vectorStore)
    .searchRequest(SearchRequest.builder().topK(3).build())
    .build();

When chatClient.prompt(...).call() is invoked, this advisor intercepts the request, runs a similarity search against Oracle, and retrieves the top three matching documents.

Those documents are then injected into the prompt that gets sent to the model.

So when a user asks something like “How long do refunds take?”, the system first retrieves the refund policy from Oracle and then asks the model to generate an answer based on that context.

One small addition that makes this much easier to see is the debug endpoint. Instead of going through the full chat flow, this endpoint lets you query the vector store directly and inspect what Oracle returns.

The controller for that looks like this:

@RestController
@RequestMapping("/api/v1/debug/policies")
public class PolicyDebugController {
    private final VectorStore vectorStore;
    public PolicyDebugController(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }
    @GetMapping("/search")
    public List<Document> search(@RequestParam String q) {
        return vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(q)
                .topK(3)
                .build()
        );
    }
}

There’s nothing complicated here, but it’s doing something important. Instead of calling the model, it calls vectorStore.similaritySearch(...) directly using the same search configuration.

So when you hit:

GET /api/v1/debug/policies/search?q=refund

you’re seeing exactly what Oracle returns for that query. The same embeddings, the same cosine similarity, the same top-K logic—just without the LLM step.

This makes it much easier to reason about the system. If the wrong documents show up here, the model never had a chance to produce the right answer. If the right documents show up here but the answer is still off, then you know to look at prompt construction instead.

At this point, the application is doing something useful, but it’s also very clearly limited because it is completely stateless.

If you try something like:

My name is Maya.

and then follow it with:

What’s my name?

the system won’t be able to answer. Each request is handled independently, and the only context available is the current message plus whatever documents were retrieved from Oracle.

There is no conversation history, and nothing is stored between requests.

This is the key distinction to understand at this stage. Oracle AI Vector Search gives us a way to retrieve relevant knowledge from stored data, but it does not give us memory. The system can answer policy questions because those policies are embedded and searchable, but it cannot remember something you just told it.

That limitation is intentional, because it sets up the next step. In the next episode we’ll introduce persistent chat memory, add conversation identifiers, and start maintaining context across requests.

For now, this version gives us a clean, grounded foundation: a Spring Boot service where Oracle stores embeddings, performs similarity search, and provides the context that the model uses to generate responses. Everything else we add will build on top of that.

Posted in Uncategorized | Tagged , , , , | 1 Comment

Exploring Oracle’s new AI Agent Memory Python Library with OpenAI

Hi everyone!

In this post, I want to show you a small but useful demo application that uses Oracle AI Agent Memory from Python. The complete code for this example is in the agent-memory repository. You can learn more about Oracle AI Agent Memory on the Oracle website.

The demo is a customer support assistant. That is a nice shape for an agent memory example because it gives us all the things agents usually need to remember: who the user is, what happened before, which device or account is involved, what the current case state is, and whether this new problem sounds like an earlier one.

The important point is that oracleagentmemory is the memory layer. It is not tied to one agent framework. You can use it with different frameworks and SDKs. This particular sample uses the OpenAI SDK for the agent-style tool-calling loop, and it uses Oracle AI Agent Memory as the durable memory backend.

In other words, OpenAI drives the agent turn. Oracle stores and retrieves the memory.

Let’s walk through it.

What we are building

The sample application does five things:

  • starts a local Oracle Database Free container
  • creates the Oracle AI Agent Memory managed schema
  • creates a small companion schema for customer, device, case, policy, JSON state, and graph data
  • runs a scripted support conversation with memory-aware tool calls
  • prints a database inspection report so we can see where the memory went

The scenario centers on Alex, a support user with a River House account and a Model X router. Alex had a prior Wi-Fi dropout issue. Later, Alex comes back and says video calls are unstable again. The agent needs to figure out whether that sounds related, what facts it already knows, what relationships matter, and what to do next.

That gives us a realistic demo without needing a real ticketing system, CRM, or router telemetry feed.

Before you begin

You need Python, Docker, uv, and an OpenAI API key.

The quickstart from the repository is:

cp .env.example .env
# Edit .env and set OPENAI_API_KEY.
uv sync
uv run agent-memory-demo run

The demo uses gvenzl/oracle-free:23.26.1-slim-faststart by default. That is helpful for a local demo because the database starts faster than a normal first-start image.

The repository also sets these defaults:

  • OPENAI_MODEL=gpt-5-mini
  • OPENAI_EMBEDDING_MODEL=text-embedding-3-small
  • OPENAI_MEMORY_LLM_MODEL=gpt-5-mini
  • ORACLE_MEMORY_TABLE_PREFIX=OAM_DEMO_
  • ORACLE_APP_TABLE_PREFIX=OAM_DEMO_APP_

The two prefixes matter. The OAM_DEMO_ tables are managed by Oracle AI Agent Memory. The OAM_DEMO_APP_ tables are the companion business tables created by this sample application.

That separation makes the demo easier to understand. We can see what the library owns, and we can also see the normal application data that the agent works with.

Why agent memory is not just one thing

When people first talk about agent memory, they often mean one thing: chat history. That is useful, but it is not enough.

A useful agent may need several kinds of memory:

Memory kindWhat it means in this demoOracle storage usedWhen to use it
Working or thread memoryThe current and previous support messagesRelational rows in managed Agent Memory tablesUse this when the agent needs conversation continuity.
Durable fact memoryPreferences, facts, and case summaries that should survive the current conversationManaged Agent Memory records, plus vector chunks for retrievalUse this when the agent should remember something later.
Profile memoryUser and agent profilesRelational rowsUse this for stable actor information such as user preferences or agent identity.
State memoryThe mutable status of a support caseJSON in the app-owned case tableUse this when the shape of the state may evolve over time.
Relationship memoryUser to account to device to case to policy pathsSQL Property Graph over relational vertex and edge tablesUse this when the important question is about connected things.
Similarity memoryPrior cases or memories that are semantically close to the current issueOracle VECTOR columns in record chunksUse this when the same thing may be described in different words.

That is the main architectural idea in the demo. Different memory types have different access patterns, so they should not all be forced into the same shape.

Relational data is great when identifiers, constraints, ownership, and joins matter. JSON is great when the shape of state changes as the case moves forward. Graph is great when paths and relationships are the point. Vector data is great when similarity matters more than exact matching.

The nice thing here is that all of those can live in Oracle Database. The agent does not need a separate relational database, graph database, document database, and vector database just to remember one support case.

The repository structure

The application code lives under src/agent_memory_demo.

The key files are:

FileWhat to look for
cli.pyThe Typer commands: run, interactive, inspect-db, verify-memory, and reset-db.
container.pyThe local Oracle container lifecycle.
config.pyEnvironment variable loading and defaults.
memory.pyCreation of the OracleAgentMemory client.
agent.pyThe OpenAI tool-calling loop and tool schemas.
tools.pyTool handlers for memory search, saving memory, context, JSON state, graph paths, and inspection.
schema.pyThe app-owned relational, JSON, and graph schema.
seed.pyDeterministic demo data.
inspect.pyDatabase inspection output, including vector storage evidence.

The sample is intentionally small, but it is not a toy in the sense of hiding the database. It shows the database because that is the point of the demo.

Creating the memory client

The memory setup happens in memory.py.

The demo creates an OracleAgentMemory client with:

  • an Oracle database connection
  • an embedding model
  • an LLM model for memory extraction and summaries
  • a schema policy
  • a table name prefix

The schema policy is important. Normal startup uses SchemaPolicy.CREATE_IF_NECESSARY, so the managed Agent Memory schema is created if it is not already there. The reset-db command uses the recreate policy for an explicit destructive reset.

That gives the sample a clean local developer workflow. You can run it, inspect it, reset it, and run it again without needing a manually installed database.

The agent loop

The OpenAI side of the sample lives in agent.py.

The agent loop sends a user message, instructions, and a list of function tools to the OpenAI SDK. When the model returns tool calls, the application executes the local Python handler, sends the tool output back, and repeats until the model produces final text.

The useful part is that the tools map to different memory operations:

ToolWhat it demonstrates
search_memoryScoped Agent Memory search.
save_memoryExplicit durable memory writes.
get_contextA thread context card.
update_case_stateJSON state updates in the support case table.
find_related_caseVector-backed semantic retrieval of similar case memories.
explain_relationshipsSQL Property Graph traversal.
inspect_memory_tablesDatabase evidence for the demo.

This is a useful pattern for agent applications. The model does not get direct database access. It gets tools. Each tool has a focused job, a scoped input shape, and a handler that decides what database operation is safe and appropriate.

The companion schema

The sample creates app-owned tables for customers, devices, support cases, policies, graph vertices, and graph edges.

This is separate from the managed Agent Memory schema. That is a good design choice because most real applications already have business data. Agent memory should not replace that data. It should work with it.

The support case table uses JSON for mutable state. A case might start as open, then get a next action, then become escalation-ready, then later get a resolution. That kind of state is structured, but it can change over time. JSON is a good fit.

The graph tables show relationships. Alex owns an account. The account has a router. The router has a case. The case uses a policy. That is exactly the kind of question where graph traversal is easier to read than a pile of joins.

The managed Agent Memory tables store threads, messages, memory records, actor profiles, and record chunks. The record chunks are where the vector-backed similarity story becomes visible.

Run the scripted demo

Start with the main command:

uv run agent-memory-demo run

The run command starts the Oracle container, creates demo schema objects, seeds deterministic data, runs a scripted support conversation, shows memory tool usage, prints the final assistant answer, and includes a database inspection report before the container is removed.

There are a few things to watch for in the output.

First, the demo creates both user and agent profiles. That shows profile memory, not just chat memory.

Second, it creates an initial thread and stores messages. That gives the agent working memory and a durable record of the conversation.

Third, it saves explicit memory. The demo records facts like Alex’s router and contact preferences.

Fourth, it creates a second thread for a follow-up problem and shows the difference between broad thread matching and exact thread matching. That is a subtle but important behavior. Sometimes you want memories from the same user and agent across threads. Sometimes you only want the current thread.

Fifth, it shows scope isolation. A search scoped to another user should not see Alex’s memories.

Finally, the OpenAI tool calls are printed with their JSON arguments and compact results. That makes the agent loop much easier to reason about because you can see what the model asked for and what the database returned.

The output is color-coded:

  • green for user messages
  • magenta for assistant messages
  • yellow for OpenAI tool calls and arguments
  • blue for Oracle database, graph, and tool-result evidence
  • cyan for progress and memory setup or search visibility

That may sound like a small thing, but it makes the demo much easier to follow while it runs.

Run the interactive demo

The repository also includes an interactive mode:

uv run agent-memory-demo interactive

This starts a memory-enabled assistant using the same command-scoped Oracle container lifecycle. It seeds the same companion data and stores your turns in a scoped Agent Memory thread.

One practical detail: the container is command-scoped. It exists while the command is running and is removed when the command exits. So if you want to inspect the database manually, leave the interactive session open.

The README says to wait for output like this:

Started Oracle demo database at localhost:32838/FREEPDB1
Seeded companion relational, JSON, graph, and policy data.
Interactive Oracle AI Agent Memory demo. Type 'quit' to exit.
you>:

Then, before inspecting the database, ask a prompt that creates some memory activity:

For user_id=user_alex and agent_id=support_agent, inspect memory tables and tell me what you can see in one sentence.

Now keep that terminal open and inspect the database from another terminal.

Inspect the database

There is a command for a quick database evidence report:

uv run agent-memory-demo inspect-db

This starts a temporary Oracle container, seeds the deterministic companion data, prints table counts, JSON case state, and graph paths, and then tears the container down.

One thing to know: inspect-db does not create Agent Memory records. That means managed memory table counts are expected to be zero for that command. Use run or verify-memory when you want to populate and inspect memory and vector chunk tables.

For hands-on SQL inspection, keep the interactive session open and connect from a second terminal. Replace the port with the one printed by your run:

sql agent_memory_demo/AgentMemoryDemo1@localhost:32838/FREEPDB1

A good first query is to list the demo tables:

SELECT table_name
FROM user_tables
WHERE table_name LIKE 'OAM_DEMO%'
ORDER BY table_name;

Then look at the columns and data types:

SELECT table_name, column_name, data_type
FROM user_tab_columns
WHERE table_name LIKE 'OAM_DEMO%'
ORDER BY table_name, column_id;

That is where the storage story becomes concrete. You should see the normal relational columns, the JSON columns in the app-owned tables, and, after running a memory-populating command, the vector-related storage in the managed record chunk table.

The graph edge indexes are also useful to inspect:

SELECT index_name, table_name, column_name
FROM user_ind_columns
WHERE index_name LIKE 'OAM_DEMO_APP_GRAPH_EDGE%'
ORDER BY index_name, column_position;

To see the JSON case state, run:

SELECT case_id,
title,
json_value(state_json, '$.status') AS status,
json_value(state_json, '$.next_action') AS next_action,
json_value(state_json, '$.escalation_ready') AS escalation_ready
FROM OAM_DEMO_APP_CASE
ORDER BY case_id;

This is a good example of why JSON is useful here. The case state is still queryable from SQL, but the state document can evolve as the workflow evolves.

Now look at the graph tables:

SELECT vertex_id, vertex_type, label
FROM OAM_DEMO_APP_GRAPH_VERTEX
ORDER BY vertex_type, vertex_id;
SELECT source_vertex_id, relationship_type, target_vertex_id
FROM OAM_DEMO_APP_GRAPH_EDGE
ORDER BY edge_id;

And confirm that the property graph exists:

SELECT object_name, object_type
FROM user_objects
WHERE object_name = 'OAM_DEMO_APP_PROPERTY_GRAPH';

Finally, after running a memory-populating command, look at the managed Agent Memory tables:

SELECT *
FROM OAM_DEMO_MEMORY
FETCH FIRST 5 ROWS ONLY;
SELECT *
FROM OAM_DEMO_RECORD_CHUNKS
FETCH FIRST 5 ROWS ONLY;

This query shows the vector columns:

SELECT column_name, data_type
FROM user_tab_columns
WHERE table_name = 'OAM_DEMO_RECORD_CHUNKS'
AND data_type = 'VECTOR';

And this one shows vector indexes:

SELECT index_name, index_type
FROM user_indexes
WHERE table_name = 'OAM_DEMO_RECORD_CHUNKS'
ORDER BY index_name;

That is the part I like most in this demo. We are not just saying that memory is persistent. We can actually look at the tables and see how different kinds of memory are represented.

Verify graph and vector behavior

The verify-memory command is a nice acceptance check:

uv run agent-memory-demo verify-memory

It seeds explicit Agent Memory records, runs graph traversal, runs vector-backed similarity search, and prints metadata evidence for the managed OAM_DEMO_RECORD_CHUNKS table.

The expected similar prior router case is case_wifi_dropout_001.

That matters because the follow-up issue does not have to use the exact same words as the earlier issue. Vector search can connect “video calls freeze” with a prior router dropout case because the meaning is similar.

This is the right place to use vectors. You are not asking for the one row with a known primary key. You are asking, “Have we seen something like this before?”

When to use each Oracle data type

Here is the practical version.

Use relational tables for the things you must identify and constrain: users, accounts, devices, cases, policies, threads, messages, and profile rows. Relational data gives you keys, constraints, indexes, joins, and ownership boundaries. That is still the backbone of most useful applications.

Use JSON for flexible state. In this sample, support case state lives in JSON because the state can change as the workflow changes. The update_case_state tool uses JSON_MERGEPATCH to update the state document without replacing the whole application model.

Use graph for connected context. If the agent needs to understand that Alex owns an account, the account has a router, the router has a case, and the case uses a policy, graph traversal makes that relationship path explicit. In this sample, explain_relationships uses a SQL Property Graph query to return user-account-device-case-policy paths.

Use vectors for similarity. If the user describes the same issue with different words, exact search is not enough. Vector search lets the agent find semantically similar memories and prior cases. In this sample, case summary memories are embedded into record chunks, and the find_related_case tool searches those chunks through Oracle Agent Memory.

The real value is not that any one of these exists. The value is that the sample can use all of them together.

Reset the demo

If you want to exercise the destructive reset path, the repository includes this command:

uv run agent-memory-demo reset-db

That resets the app-owned companion schema and recreates the managed Agent Memory schema. It is a demo command, not something to point at a production schema.

What this sample teaches

There are a few patterns here that are worth carrying into real applications.

First, keep the memory backend separate from the agent framework. This demo uses the OpenAI SDK tool loop, but the memory concepts are not OpenAI-specific. The agent needs tools. The memory system needs scoped APIs. Those two things meet at a clean boundary.

Second, scope everything. The demo uses user, agent, and thread boundaries. It also shows that another user should not see Alex’s memories. That is not just a demo flourish. It is table stakes for real multi-user agents.

Third, use the right data shape for the job. Chat messages, durable memories, JSON state, graph relationships, and vector chunks are not the same thing. Treating them differently makes the system easier to reason about.

Fourth, inspect the database. Agent demos can feel magical if all you see is a final answer. This demo is better because it shows the rows, JSON state, graph paths, and vector storage evidence. That makes the behavior testable and explainable.

Wrap up

We built and inspected a memory-enabled support assistant using oracleagentmemory, the OpenAI SDK, and a local Oracle Database container.

The sample shows working memory through threads and messages, durable memory through explicit memories and extracted facts, profile memory for users and agents, JSON state for support cases, relationship memory through SQL Property Graph, and similarity memory through vector-backed record chunks.

The important idea is simple: agents need more than chat history. They need memory that is durable, scoped, queryable, and connected to the data the application already trusts.

This demo gives you a compact way to see that pattern end to end.

Posted in Uncategorized | Tagged , , , , , , , , | Leave a comment