A semantic knowledge base (KB) describes a database's tables and columns, and the relationships its foreign keys declare between them. Most real databases also join tables without a connecting foreign key. The SQL running against the database records those joins on every execution.
Query history reads the recorded SQL from pg_stat_statements and keeps the statements joining tables in the KB's schemas. Use the following functions for each task:
| Task | Use |
|---|---|
| Setting up query history | pg_stat_statements and aidb.create_semantic_kb() |
| Importing history | aidb.import_query_history() |
| Checking why statements are skipped | aidb.classify_query_history_statement() |
| Excluding an application's SQL | aidb.update_semantic_kb_history_filter() |
| Browsing the stored statements | aidb.list_query_history() |
| Searching past queries in English | aidb.semantic_kb_search() with the history source |
| Reviewing joins that have no declared foreign key | aidb.relationship_candidates() |
| Accepting a join so the join-route functions use it | aidb.accept_relationship_candidates() |
Run aidb.import_query_history() yourself or from a scheduled job, because nothing is learned automatically. You decide which proposed joins to accept. Query history never changes how data is stored or queried.
Work through the following sections in order for a typical workflow:
- Setting up query history: Enable
pg_stat_statements, prepare your data, and create a model and a KB. - Importing history: Read the recorded SQL into the history store, and see Checking why statements are skipped if any statements are missing.
- Excluding an application's SQL: Keep the SQL of text-to-SQL applications out of the store.
- Browsing and searching history: List what was learned, and search past queries in English.
- Working with join candidates: Review the proposed joins, and accept the ones you trust.
Create the shop schema and roles in Preparing your data to follow the examples. For every function's parameters and return columns, see the reference.
Concepts
Learn the following terms before you set up query history:
History store: One table per KB, aidb_internal.query_history_<kb_name>, with one row per distinct statement. Each row holds the normalized text, the tables read, the join conditions, the run count, the first and last time seen, the role that ran it, and an embedding for search.
Import: One run of aidb.import_query_history(). It reads pg_stat_statements for the current database, keeps qualifying statements, embeds the new ones, and proposes join candidates. You can repeat an import at any time, and each run refreshes what's already stored.
Qualifying statement: A single SELECT (a WITH query included) sent by a client, run at least min_calls times (default 5), and reading at least two tables, all in the KB's schemas.
Join predicate: A column equality between two joined tables, such as o.customer_id = c.id.
Join candidate: A join proposed by history as a relationship, with source = 'history' and status = 'proposed'. It records how many stored statements use the join and how often they ran. The join-route functions ignore a candidate until you accept it.
Excluded role: A role listed on the KB so that its statements, and its members' statements, aren't learned from. Use it for text-to-SQL applications. See Excluding an application's SQL.
Setting up query history
Query history requires pg_stat_statements. It's the only source of query history, and no other function fills the store. Set it up in three stages: enable pg_stat_statements, prepare your data, and create a model and a KB.
Enabling pg_stat_statements
Enable pg_stat_statements in three steps:
Load the library on the server. Add
pg_stat_statementsto theshared_preload_librariesparameter inpostgresql.conf. See Configuring shared_preload_libraries.shared_preload_libraries = 'aidb, pg_stat_statements'
Note
If
shared_preload_librariesalready lists other extensions, appendpg_stat_statementsusing a comma separator. The order doesn't matter.Restart Postgres.
shared_preload_librariesis a cluster-wide setting, and a change takes effect only after a restart. A restart interrupts all connections to the server, so schedule it accordingly.After the restart, check that the library is loaded:
SELECT current_setting('shared_preload_libraries') AS preloaded;
Outputpreloaded ---------------------------------- aidb, pgvector, pg_stat_statements (1 row)
The output lists the libraries the server loads, and
pg_stat_statementsmust be among them.Create the extension in the database:
CREATE EXTENSION pg_stat_statements;
Then check that the extension is installed:
SELECT extname, extversion FROM pg_extension WHERE extname IN ('aidb', 'pg_stat_statements') ORDER BY 1;
Outputextname | extversion --------------------+------------ aidb | 7.9.0 pg_stat_statements | 1.12 (2 rows)
The output shows each installed extension with its version, and both
aidbandpg_stat_statementsmust appear.In a database without the extension, the import stops with an error instead of returning nothing:
SELECT * FROM aidb.import_query_history('shop_kb');
OutputERROR: pg_stat_statements is not installed in this database. Query history reads from it and has no other source. Install it with CREATE EXTENSION pg_stat_statements; note that it also requires shared_preload_libraries, which needs a restart.
Install and load both
Installing the extension without loading the library is the usual mistake. CREATE EXTENSION succeeds, but nothing is recorded. AIDB can't change either setting for you. If a managed PostgreSQL service doesn't let you set them, query history is unavailable.
Preparing your data
Choose the schemas the KB will learn from. The data should already exist in your database, because query history reads statements over tables in those schemas. To follow the examples, create the following schema and roles. The shop tables have no foreign keys, and crm is a second schema used to show what the import keeps out:
CREATE SCHEMA shop; CREATE TABLE shop.customers(id int PRIMARY KEY, region text); CREATE TABLE shop.orders(id int PRIMARY KEY, customer_id int NOT NULL, placed_on date); CREATE TABLE shop.returns(id int PRIMARY KEY, order_id int); CREATE TABLE shop.instalments(order_id int, no int, due date, PRIMARY KEY (order_id, no)); CREATE TABLE shop.payments(id int PRIMARY KEY, order_id int NOT NULL, no int NOT NULL); CREATE TABLE shop."Vouchers"(id int PRIMARY KEY, order_id int NOT NULL); CREATE SCHEMA crm; CREATE TABLE crm.notes(id int PRIMARY KEY, customer_id int); CREATE ROLE text2sql_app LOGIN; -- a text-to-SQL application CREATE ROLE text2sql_worker LOGIN IN ROLE text2sql_app; -- a member of it CREATE ROLE analyst LOGIN; -- a person CREATE ROLE dba_admin SUPERUSER; -- an unlisted superuser GRANT USAGE ON SCHEMA shop, crm TO text2sql_app, analyst; GRANT SELECT ON ALL TABLES IN SCHEMA shop, crm TO text2sql_app, analyst;
Creating a model and a KB
Create an embedding model and a KB over those schemas. Give the KB a vector index so the history store gets one too (see Keeping the store clean), and use auto_processing => 'Live' so the store stays clean as tables change. The example creates the KB over shop only:
SELECT aidb.create_model('bge_small', 'llamacpp_embeddings', '{"local_path": "/path/to/bge-small-en-v1.5-f16.gguf", "n_ctx": 512}'::jsonb); SELECT aidb.create_semantic_kb('shop_kb', 'bge_small', ARRAY['shop'], auto_processing => 'Live', vector_index => '{"type": "hnsw"}');
create_semantic_kb -------------------- shop_kb (1 row)
Importing history
Import your database's query history by running aidb.import_query_history(). The function reads pg_stat_statements for the current database, stores the statements that qualify, and proposes join candidates. These tasks don't run automatically, so run the function after the workload changes, or from a scheduled job.
Run the import as a role that can read every role's statements: a superuser or a member of pg_read_all_stats. Otherwise, pg_stat_statements hides other roles' statements and the import skips most of the workload as redacted. See Controlling access and auditing.
All arguments are optional:
| Argument | Default | Description |
|---|---|---|
kb_name | NULL | The KB to import into. Omit it when the database has one KB, and pass it when the database has more than one. |
min_calls | 5 | Minimum number of runs before a statement is learned from. |
max_statements | 1000 | Maximum statements read per import, most-run first. Excluded roles' statements don't count against it. |
derive_candidates | true | Pass false to store statements without proposing joins. |
Re-importing is cheap. A statement that's already stored isn't embedded again. Its run count and last-seen time are refreshed. If the KB's embedding model changes, statements stored under the old model are embedded again on the next import.
To import history in your own environment:
Let the workload run. The import reads the statistics
pg_stat_statementshas collected, and ignores statements that ran fewer thanmin_callstimes.Run the import:
SELECT * FROM aidb.import_query_history('<kb_name>');
Example
To reproduce the example on a test database, import its workload in three steps:
Reset
pg_stat_statementsso the counts match the example. Run this step only on the example database, because a reset discards all the statistics collected so far.SELECT pg_stat_statements_reset() IS NOT NULL AS reset;
Outputreset ------- t
Run the workload. The import ignores statements that ran fewer than
min_callstimes (default 5), so run the following script five times. For example, save it asworkload.sqland runfor i in 1 2 3 4 5; do psql -d <database> -f workload.sql; done. To run it once, passmin_calls => 1to the import in the next step, which changes the counts in the outputs that follow. The workload has no foreign keys, so history is the only source of its joins.-- workload.sql: run as the KB's owner, or as a superuser SELECT c.region FROM shop.customers c JOIN shop.orders o ON o.customer_id = c.id; SELECT count(*) FROM shop.customers c JOIN shop.orders o ON o.customer_id = c.id; SELECT p.id FROM shop.payments p JOIN shop.instalments i ON i.order_id = p.order_id AND i.no = p.no; SELECT r.id FROM shop.returns r JOIN shop.orders o ON o.id = r.order_id; SELECT v.id FROM shop."Vouchers" v JOIN shop.orders o ON o.id = v.order_id; SELECT n.id FROM shop.customers c JOIN crm.notes n ON n.customer_id = c.id; SELECT 1 FROM shop.orders WHERE id = 1; \d shop.orders SET ROLE text2sql_app; SELECT o.placed_on FROM shop.orders o JOIN shop.customers c ON c.id = o.customer_id; RESET ROLE; SET ROLE text2sql_worker; SELECT o.id FROM shop.orders o JOIN shop.customers c ON c.id = o.customer_id; RESET ROLE; SET ROLE analyst; SELECT c.id FROM shop.orders o JOIN shop.customers c ON c.id = o.customer_id; RESET ROLE; SET ROLE dba_admin; SELECT o.customer_id FROM shop.orders o JOIN shop.customers c ON c.id = o.customer_id; RESET ROLE;
Run the import. The workload includes the application role
text2sql_appand its membertext2sql_worker, before either is excluded. The first import keeps everything that qualifies:SELECT statements_read, statements_ingested, statements_embedded, rows_pruned, candidates_derived, skipped, outcome FROM aidb.import_query_history('shop_kb');
Outputstatements_read | statements_ingested | statements_embedded | rows_pruned | candidates_derived | skipped | outcome -----------------+---------------------+---------------------+-------------+--------------------+------------------------------------------+--------- 22 | 9 | 9 | 0 | 4 | {"out_of_scope": 9, "not_qualifying": 4} | ok (1 row)
The import read 22 statements, kept 9, and skipped the other 13. The result columns, in the order of the output:
| Column | Description |
|---|---|
statements_read | Statements in pg_stat_statements for this database that ran at least min_calls times, including those then skipped. |
statements_ingested | Statements kept, new or already stored. |
statements_embedded | Statements that needed a new embedding. It's 0 on a re-import of an unchanged workload. |
rows_pruned | Stored statements removed because a table or join column they use no longer exists, or left the KB's schemas. |
candidates_derived | New join candidates proposed by this run. |
skipped | A JSON object counting skipped statements by skip reason. See Checking why statements are skipped. |
outcome | How the import went: ok, nothing_to_read, or mostly_unreadable. See the table that follows. |
The function also returns import_id, an ID for the run. The audit log records it. See Controlling access and auditing.
The outcome column, last in the output, is one of the following:
outcome | Description |
|---|---|
ok | At least one statement was kept. |
nothing_to_read | Nothing met the bar. This is normal on a new database. On a busy one, min_calls is probably too high. |
mostly_unreadable | More than half of what the import tried to read was redacted or text_unavailable. The caller usually lacks pg_read_all_stats. |
Checking why statements are skipped
Examine the skipped column returned by aidb.import_query_history() to find out why the import ignored a statement. A skipped statement is one the import read from pg_stat_statements but didn't store, and each one is counted under a single skip reason. The import stores only SQL sent directly by a user or client, and only when it joins tables in the KB's schemas. In the example output, 9 statements were out_of_scope and 4 were not_qualifying:
| Skip reason | The statement wasn't kept because |
|---|---|
not_qualifying | It reads fewer than two tables, so there's no join to learn. |
out_of_scope | It reads a table outside the KB's schemas, such as the system catalog behind psql's \d, or another schema. |
excluded_role | It was run by an excluded role, or by a member of one. |
unresolvable_relation | It names a table in a covered schema that doesn't exist there, usually a dropped or renamed table. |
unparseable | PostgreSQL's parser rejected it, it isn't a single SELECT, or a FROM clause couldn't be fully read. |
redacted | pg_stat_statements hid the text, because the caller can't read that role's statements. |
text_unavailable | pg_stat_statements returned no text, which happens after its query-text file is garbage-collected. |
self_generated | It carries AIDB's own marker for SQL that AIDB generated. |
The following sections explain the rules behind the skip reasons above: which SQL the import never reads, what makes a statement out_of_scope, and which column equalities count as joins. For excluded_role, see Excluding an application's SQL.
Leaving out SQL that AIDB runs itself
Don't expect AIDB's own SQL in the store. Statements run inside a function are never imported, including everything AIDB runs on a user's behalf: semantic aliases, Tools Hub tools, Agent Hub steps, and the Model Context Protocol (MCP) server. pg_stat_statements marks each statement as sent by a client or run inside a function, and the import reads only client-sent statements. No configuration is needed.
Keeping statements inside the KB's schemas
Keep the statements you want learned inside the KB's schemas. The import keeps a statement only if every table it reads is in one of them, and skips everything else as out_of_scope: psql's \d queries over pg_catalog, other KBs' traffic, and joins reaching another schema.
aidb.classify_query_history_statement(statement, kb_name) applies the same rule as the import, so you can test any statement before importing:
SELECT aidb.classify_query_history_statement( 'SELECT n.id FROM shop.customers c JOIN crm.notes n ON n.customer_id = c.id', 'shop_kb')->>'skip_reason' AS other_schema;
other_schema -------------- out_of_scope (1 row)
SELECT aidb.classify_query_history_statement( 'SELECT c.relname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace', 'shop_kb')->>'skip_reason' AS catalog;
catalog -------------- out_of_scope (1 row)
The rule covers three further cases:
- Unqualified names.
pg_stat_statementsdoesn't record thesearch_patha statement ran under. A name with no schema, such asFROM customers, counts only if exactly one schema in the database has a table of that name, and that schema is covered. - Temporary tables. A session finds its temporary tables first, so a temporary table with the same name makes an unqualified name ambiguous, and the statement is skipped.
- Every table must be read. A
FROMclause the import can't fully read is skipped asunparseable, instead of being kept with a table missing, because the missing table could be one outside the KB.
SELECT aidb.classify_query_history_statement( 'SELECT 1 FROM customers c JOIN orders o ON o.customer_id = c.id', 'shop_kb')->'relations' AS unqualified;
unqualified ----------------------------------- ["shop.customers", "shop.orders"] (1 row)
CREATE TEMP TABLE customers(id int); SELECT aidb.classify_query_history_statement( 'SELECT 1 FROM customers c JOIN shop.orders o ON o.customer_id = c.id', 'shop_kb')->>'skip_reason' AS with_temp_table;
with_temp_table ----------------- out_of_scope (1 row)
Writing joins as column equalities
Write joins as column equalities among the ANDed conditions of an ON or WHERE clause. The import records a column equality as a join only in that form. The following aren't joins:
a.x = b.y OR a.z = b.wNOT a.x = b.y- An equality inside
CASEor a function call - An equality in the select list or
HAVING a.x = b.y + 1
Two parts of one statement that reuse the same aliases, such as the branches of a UNION, are kept apart.
SELECT aidb.classify_query_history_statement( 'SELECT 1 FROM shop.orders o JOIN shop.customers c ON c.id = o.customer_id OR c.region = $1')->'predicates' AS disjunction;
disjunction ------------- [] (1 row)
A join over several columns is read as one join:
SELECT jsonb_pretty(aidb.classify_query_history_statement( 'SELECT 1 FROM shop.payments p JOIN shop.instalments i ON i.order_id = p.order_id AND i.no = p.no', 'shop_kb'));
jsonb_pretty
--------------------------------------------------
{ +
"qualifies": true, +
"relations": [ +
"shop.instalments", +
"shop.payments" +
], +
"predicates": [ +
{ +
"left_column": "no", +
"right_column": "no", +
"left_relation": "shop.instalments",+
"right_relation": "shop.payments" +
}, +
{ +
"left_column": "order_id", +
"right_column": "order_id", +
"left_relation": "shop.instalments",+
"right_relation": "shop.payments" +
} +
], +
"skip_reason": null, +
"had_unresolved": false +
}
(1 row)Statements that read one table, and statements other than SELECT, are never kept:
SELECT aidb.classify_query_history_statement('SELECT 1 FROM shop.orders WHERE id = $1')->>'skip_reason' AS one_table;
one_table ---------------- not_qualifying (1 row)
SELECT aidb.classify_query_history_statement('UPDATE shop.orders SET placed_on = now()')->>'skip_reason' AS not_a_select;
not_a_select -------------- unparseable (1 row)
Tip
When an import keeps less than you expected, pass the statement to aidb.classify_query_history_statement() with the KB's name. It reports the same skip reason the import would.
Excluding an application's SQL
Exclude the role of each text-to-SQL application, per KB. An application running its own generated SQL would otherwise teach the KB from its own output. A wrong join it produced once would be stored and returned in search results as a past query, and every repeat would strengthen it.
After the first import, the application's statements are in the store:
SELECT executed_by, count(*) AS statements FROM aidb.list_query_history('shop_kb') GROUP BY 1 ORDER BY 1;
executed_by | statements -----------------+------------ analyst | 1 dba_admin | 1 kb_owner | 5 text2sql_app | 1 text2sql_worker | 1 (5 rows)
Exclude the application's role with aidb.update_semantic_kb_history_filter():
SELECT jsonb_pretty(aidb.update_semantic_kb_history_filter('shop_kb', ARRAY['text2sql_app']));
jsonb_pretty
-----------------------------
{ +
"purged": 2, +
"current": [ +
"text2sql_app" +
], +
"previous": [ +
], +
"candidates_retired": 0+
}
(1 row)Setting the list deletes the statements those roles already contributed, and reports how many under purged. Here that's one statement from text2sql_app, and one from text2sql_worker, which is a member of it:
SELECT executed_by, count(*) AS statements FROM aidb.list_query_history('shop_kb') GROUP BY 1 ORDER BY 1;
executed_by | statements ----------------+------------ analyst | 1 dba_admin | 1 kb_owner | 5 (3 rows)
From then on, each import skips those roles' statements and counts them as excluded_role:
SELECT statements_read, statements_ingested, statements_embedded, rows_pruned, candidates_derived, outcome, jsonb_pretty(skipped) AS skipped FROM aidb.import_query_history('shop_kb');
statements_read | statements_ingested | statements_embedded | rows_pruned | candidates_derived | outcome | skipped
-----------------+---------------------+---------------------+-------------+--------------------+---------+-------------------------
22 | 7 | 0 | 0 | 0 | ok | { +
| | | | | | "out_of_scope": 9, +
| | | | | | "excluded_role": 2,+
| | | | | | "not_qualifying": 4+
| | | | | | }
(1 row)excluded_role: 2is the two application roles.out_of_scope: 9is the join intocrm, plus the eight catalog queries psql's\dsends. The number of\dqueries depends on the psql version.not_qualifying: 4is statements that read fewer than two tables, such asSELECT 1 FROM shop.orders WHERE id = 1.statements_embedded: 0because all seven statements kept were already stored.
Checking which roles are excluded
Check which roles the exclusion covers:
- The listed roles, and every role that's a member of one through
GRANT. - On PostgreSQL 16 and later, a membership counts only if it lets the member act as the role (
INHERITorSET). ACREATEROLEuser gets anADMIN-only membership in every role it creates, and that membership doesn't exclude it. - A superuser is excluded only if listed. In the example,
dba_admin, an unlisted superuser, is still learned from. pg_stat_statementsrecords the role a statement ran as. An application that runsSET ROLEmust have the role it switches to listed.
The KB keeps each role by name together with its object identifier (OID). A renamed role stays on the list. After a dump and restore, which gives roles new OIDs, the name is what matches. aidb.refresh_semantic_kb() records each listed role's current name and OID.
Checking what happens to candidates
Expect candidates to follow their statements. A proposed candidate supported only by the purged statements is removed with them and counted under candidates_retired. It's deleted, or retired if someone wrote a description or label on it. An already accepted candidate stays accepted, and the statements and executions counts in its provenance follow the remaining statements, down to zero.
Removals and count reductions run only for a caller who can read every role's history: a superuser or a member of pg_read_all_stats. For anyone else, an import can raise counts but never lower them, because they see only part of the evidence.
Clearing the list
Pass an empty array to clear the list. NULL is refused, so you can't clear it by accident:
SELECT jsonb_pretty(aidb.update_semantic_kb_history_filter('shop_kb', ARRAY[]::text[]));
jsonb_pretty
-----------------------------
{ +
"purged": 0, +
"current": [ +
], +
"previous": [ +
"text2sql_app" +
], +
"candidates_retired": 0+
}
(1 row)The examples that follow assume the exclusion is still active. Set the list again before you continue:
SELECT jsonb_pretty(aidb.update_semantic_kb_history_filter('shop_kb', ARRAY['text2sql_app']));
jsonb_pretty
-----------------------------
{ +
"purged": 0, +
"current": [ +
"text2sql_app" +
], +
"previous": [ +
], +
"candidates_retired": 0+
}
(1 row)Assigning a role to each application
Give each text-to-SQL application its own login role, not one shared with human users, and list the role on every KB it queries. Exclusion can't split a login used by both an application and human users.
Don't list aidb_users. Every AIDB user holds it, so listing it excludes everyone's statements. The function warns but doesn't refuse:
SELECT jsonb_pretty(aidb.update_semantic_kb_history_filter('shop_kb', ARRAY['aidb_users']));
WARNING: aidb_users holds every aidb user's grants, so excluding it excludes every aidb user's statements from query history
update_semantic_kb_history_filter isn't available as a Tools Hub tool, so an agent can't change the list that governs what it learns from. Each change is written to the audit log.
Browsing and searching history
Browse the stored statements, or search them in English to find a past query that answers a question.
Browsing history
Run aidb.list_query_history() to list the statements the import stored for a KB: the normalized SQL, the tables each statement reads, how often it ran, and the role that ran it. Results come back most-run first. Pass kb_name when the database has more than one KB. The other parameters are optional and narrow the results:
| Parameter | Keeps rows that |
|---|---|
relation | Read this table |
min_calls | Ran at least this many times |
seen_since | Ran since this time |
executed_by | Were run by this role |
limit_rows | (default 100) caps the number of rows |
Callers see only their own statements unless they hold pg_read_all_stats. See Controlling access and auditing.
normalized_sql is the text as pg_stat_statements normalized it: constants become $1, $2, and runs of whitespace become one space.
Example: list the statements that read shop.payments:
SELECT normalized_sql, referenced_objects, exec_count, executed_by FROM aidb.list_query_history('shop_kb', relation => 'shop.payments');
normalized_sql | referenced_objects | exec_count | executed_by
-----------------------------------------------------------------------------------------------------+----------------------------------+------------+-------------
SELECT p.id FROM shop.payments p JOIN shop.instalments i ON i.order_id = p.order_id AND i.no = p.no | {shop.instalments,shop.payments} | 5 | kb_owner
(1 row)Example: show the join conditions a statement uses. Each row carries them in join_predicates:
SELECT normalized_sql, referenced_objects, join_predicates, exec_count, first_seen IS NOT NULL AS has_first_seen, executed_by FROM aidb.list_query_history('shop_kb', relation => 'shop.returns');
-[ RECORD 1 ]------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
normalized_sql | SELECT r.id FROM shop.returns r JOIN shop.orders o ON o.id = r.order_id
referenced_objects | {shop.orders,shop.returns}
join_predicates | [{"l": {"name": "orders", "alias": "o", "column": "id", "schema": "shop", "relation": "shop.orders"}, "r": {"name": "returns", "alias": "r", "column": "order_id", "schema": "shop", "relation": "shop.returns"}, "block": 0}]
exec_count | 5
has_first_seen | t
executed_by | kb_ownerExample: list the tables the stored statements read. The store names each table as SQL would, quoting where needed: shop.orders, or shop."Vouchers" for a mixed-case name:
SELECT DISTINCT o AS object FROM aidb.list_query_history('shop_kb'), unnest(referenced_objects) o ORDER BY 1;
object ------------------ shop."Vouchers" shop.customers shop.instalments shop.orders shop.payments shop.returns (6 rows)
The relation parameter of list_query_history() accepts the stored form, the unquoted form, or a name as SQL reads it, where SHOP.ORDERS is shop.orders:
SELECT (SELECT count(*) FROM aidb.list_query_history('shop_kb', relation => 'shop."Vouchers"')) AS stored_form, (SELECT count(*) FROM aidb.list_query_history('shop_kb', relation => 'shop.Vouchers')) AS unquoted_form, (SELECT count(*) FROM aidb.list_query_history('shop_kb', relation => 'SHOP.ORDERS')) AS sql_form;
stored_form | unquoted_form | sql_form
-------------+---------------+----------
1 | 1 | 6
(1 row)Searching history
Run aidb.semantic_kb_search() to find past queries that answer a question in English. The function searches the stored statements by meaning and returns each one as a result of type Query, so you or an agent can start from SQL that already ran. Each stored statement is its own result, even when several statements read the same tables. Pass the question and the KB name, and use these parameters to narrow the search:
| Parameter | Use |
|---|---|
sources => ARRAY['history'] | Search history alone. Omit it to search every source. |
top_k | Set the number of ranked results to return. |
Example: find past queries about the regions customers order from:
SELECT rank, entity_type, object_ref, left(definition, 72) AS statement FROM aidb.semantic_kb_search('which regions do customers order from', 'shop_kb', sources => ARRAY['history'], top_k => 5);
rank | entity_type | object_ref | statement
------+-------------+-----------------------------+--------------------------------------------------------------------------
1 | Query | shop.customers, shop.orders | SELECT c.region FROM shop.customers c JOIN shop.orders o ON o.customer_i
2 | Query | shop.customers, shop.orders | SELECT c.id FROM shop.orders o JOIN shop.customers c ON c.id = o.custome
3 | Query | shop.customers, shop.orders | SELECT o.customer_id FROM shop.orders o JOIN shop.customers c ON c.id =
4 | Query | shop.orders, shop.returns | SELECT r.id FROM shop.returns r JOIN shop.orders o ON o.id = r.order_id
5 | Query | shop.customers, shop.orders | SELECT count(*) FROM shop.customers c JOIN shop.orders o ON o.customer_i
(5 rows)For the other sources and how results are fused, see Searching a semantic KB.
Working with join candidates
Review the joins that history proposes, and accept the ones you trust.
Reviewing candidates
Run aidb.relationship_candidates() to review every join in the stored statements not already known to the KB. Each import proposes them as candidates. The example workload's four joins have no foreign keys, so all four are proposed:
SELECT left_object, left_columns, right_object, right_columns, confidence, statements, executions FROM aidb.relationship_candidates('shop_kb') ORDER BY left_object, right_object;
left_object | left_columns | right_object | right_columns | confidence | statements | executions
---------------+---------------+------------------+---------------+------------+------------+------------
shop.Vouchers | {order_id} | shop.orders | {id} | 0.75 | 1 | 5
shop.orders | {customer_id} | shop.customers | {id} | 0.75 | 4 | 20
shop.payments | {no,order_id} | shop.instalments | {no,order_id} | 0.75 | 1 | 5
shop.returns | {order_id} | shop.orders | {id} | 0.75 | 1 | 5
(4 rows)- There's one candidate per join, with its evidence.
orders→customersis backed by the four stored statements that use it. The excluded application roles' statements aren't counted. - A join over several columns is one candidate.
payments→instalmentsjoins on bothorder_idandno. - The same table joined twice under two aliases gives two candidates, one per join.
The candidate's shape comes from the database's own definitions:
SELECT left_object, right_object, cardinality, is_nullable, status, join_expr FROM aidb.list_relationships('shop_kb', source => 'history') ORDER BY left_object, right_object;
left_object | right_object | cardinality | is_nullable | status | join_expr ---------------+------------------+-------------+-------------+----------+----------------------------------------------------------------------------------------------- shop.Vouchers | shop.orders | many-to-one | f | proposed | shop."Vouchers".order_id = shop.orders.id shop.orders | shop.customers | many-to-one | f | proposed | shop.orders.customer_id = shop.customers.id shop.payments | shop.instalments | many-to-one | f | proposed | shop.payments.no = shop.instalments.no AND shop.payments.order_id = shop.instalments.order_id shop.returns | shop.orders | many-to-one | t | proposed | shop.returns.order_id = shop.orders.id (4 rows)
- Direction. The side whose columns a unique index covers is the "one" side, and it's placed on the right. If both sides are unique, the join is one-to-one. If neither is, it's many-to-many, and the order is alphabetical.
- Nullability.
is_nullableis true when the "many" side's columns allowNULL.returns.order_iddoes.
Filter on relationship_candidates() | Keeps candidates that |
|---|---|
source | (default history) were proposed by this source |
relation | Have this table on either side, written schema.table without quotes |
min_exec_count | Are backed by at least this many executions |
limit_rows | (default 100) caps the number of rows |
The join-route functions don't use a candidate:
SELECT count(*) AS routes FROM aidb.find_join_path('shop_kb', 'shop.returns', 'shop.orders');
routes
--------
0
(1 row)Accepting candidates
Accept candidates by passing their IDs to aidb.accept_relationship_candidates(), with kb_name second. Where you know better than the database's definitions, override the proposed shape with the cardinality and is_nullable arguments. Here shop.returns.order_id allows NULL, but every return has an order:
SELECT * FROM aidb.accept_relationship_candidates( ARRAY(SELECT relationship_id FROM aidb.relationship_candidates('shop_kb') WHERE left_object = 'shop.returns'), 'shop_kb', is_nullable => false);
relationship_id | accepted | reason
-----------------+----------+----------
4 | t | approved
(1 row)Accepting makes the claim yours. source becomes manual, provenance keeps the evidence and records who approved it, and the join-route functions start using it:
SELECT left_object, right_object, source, status, cardinality, is_nullable, provenance FROM aidb.list_relationships('shop_kb', relation => 'shop.returns');
-[ RECORD 1 ]+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
left_object | shop.returns
right_object | shop.orders
source | manual
status | approved
cardinality | many-to-one
is_nullable | f
provenance | {"model": "bge_small", "derived_at": "2026-10-15T11:08:55.749657+05:30", "executions": 5, "statements": 1, "approved_by": "kb_owner", "asserted_by": "manual:kb_owner", "history_refreshed_at": "2026-10-15T11:08:55.822742+05:30"}SELECT path_rank, hops, route FROM aidb.find_join_path('shop_kb', 'shop.returns', 'shop.orders');
path_rank | hops | route
-----------+------+-----------------------------
1 | 1 | shop.returns -> shop.orders
(1 row)Where neither side has a unique index, the proposed direction is only alphabetical. cardinality => 'one-to-many' states that the left side is the "one" side. The relationship is turned round (endpoints, columns, and join expression) and stored as many-to-one.
An ID that isn't a candidate is reported per row, not raised:
SELECT * FROM aidb.accept_relationship_candidates(ARRAY[9999]::bigint[], 'shop_kb');
relationship_id | accepted | reason
-----------------+----------+---------------------------------------------
9999 | f | no such relationship in this knowledge base
(1 row)Acceptance is by explicit ID only. The function deliberately takes no filter, to prevent approving in bulk what nobody reviewed in detail.
Refreshing the evidence for accepted joins
Re-run the import to refresh the evidence on an accepted join. A join that already exists isn't proposed again, whether you accepted it earlier or the foreign-key crawl found it. Each import refreshes the statements and executions counts on the relationship instead. After three more runs of the returns join:
SELECT statements_ingested, statements_embedded, rows_pruned, candidates_derived, outcome FROM aidb.import_query_history('shop_kb');
statements_ingested | statements_embedded | rows_pruned | candidates_derived | outcome
---------------------+---------------------+-------------+--------------------+---------
7 | 0 | 0 | 0 | ok
(1 row)SELECT count(*) AS returns_candidates FROM aidb.relationship_candidates('shop_kb', relation => 'shop.returns');
returns_candidates
--------------------
0
(1 row)SELECT provenance->'executions' AS executions, provenance->'statements' AS statements, provenance ? 'history_refreshed_at' AS refreshed FROM aidb.list_relationships('shop_kb', relation => 'shop.returns');
executions | statements | refreshed ------------+------------+----------- 8 | 1 | t (1 row)
Nothing history proposes is used until a person accepts it by ID. For how accepted relationships behave once approved, see Relationships.
Keeping the store clean
Remove stale statements with two mechanisms: schema-change handling, and the pruning each import does. A stored statement over a table no longer in the database is an example of a now-failing query. Rows are removed when their tables go, not when they get old, so there's no time-based retention. See Limitations.
Removing statements when the schema changes
Expect a schema change to remove statements when the KB's auto_processing is Live or Background:
DROP TABLEremoves every stored statement that reads the table.- Renaming a table or schema removes the statements that name the old one.
- Dropping or renaming a column removes the statements whose join uses it.
SELECT count(*) AS rows_before FROM aidb.list_query_history('shop_kb');
rows_before
-------------
7
(1 row)DROP TABLE shop.payments; SELECT count(*) AS rows_after_drop FROM aidb.list_query_history('shop_kb');
rows_after_drop
-----------------
6
(1 row)ALTER TABLE shop.returns RENAME TO refunds; SELECT count(*) AS rows_after_rename FROM aidb.list_query_history('shop_kb');
rows_after_rename
-------------------
5
(1 row)Pruning at every import
Run an import to prune stale statements. Each import removes stored statements whose tables no longer exist in the KB's schemas, or whose join columns were dropped or renamed, and reports the count as rows_pruned. This covers KBs without schema-change handling (Disabled or Manual), changes made while no handler ran, and changes no handler covers, such as ALTER TABLE ... SET SCHEMA.
Candidates follow their statements. A proposed candidate whose supporting statements are all removed is removed too, as with excluded roles.
Keeping history search fast
Create the KB with a vector index so history search stays fast as the store grows. The history store gets an index too:
SELECT indexrelid::regclass AS index, pg_get_indexdef(indexrelid) LIKE '%USING hnsw%' AS hnsw FROM pg_index WHERE indrelid = 'aidb_internal.query_history_shop_kb'::regclass ORDER BY 1;
index | hnsw ------------------------------------------+------ aidb_internal.query_history_shop_kb_pkey | f aidb_internal.qh_objects_shop_kb | f aidb_internal.vdx_qh_shop_kb | t (3 rows)
Controlling access and auditing
Control who can run the query history functions and see the stored statements, and review what they did.
Checking function permissions
Check the grants on the query history functions. Every query history function is granted EXECUTE to aidb_users:
SELECT p.proname, array_to_string(p.proacl, ', ') AS grants FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'aidb' AND p.proname IN ('import_query_history', 'list_query_history', 'relationship_candidates', 'accept_relationship_candidates', 'classify_query_history_statement', 'update_semantic_kb_history_filter') ORDER BY 1;
proname | grants -----------------------------------+---------------------------------------------------------- accept_relationship_candidates | aidb_owner=X/aidb_owner, aidb_users=X/aidb_owner classify_query_history_statement | aidb_owner=X/aidb_owner, aidb_users=X/aidb_owner import_query_history | aidb_owner=X/aidb_owner, aidb_users=X/aidb_owner list_query_history | aidb_owner=X/aidb_owner, aidb_users=X/aidb_owner relationship_candidates | aidb_owner=X/aidb_owner, aidb_users=X/aidb_owner update_semantic_kb_history_filter | aidb_owner=X/aidb_owner, aidb_users=X/aidb_owner (6 rows)
As with the rest of the semantic KB, the stores behind these functions carry no table-level grant, so in practice the KB's owner or a superuser is the caller. See Permissions.
Restricting what each caller sees
Check the row-level security on the history store, forced even for its owner. A caller reads, updates, and deletes only the statements that a role they're a member of ran, unless they hold pg_read_all_stats:
SELECT policyname, cmd, qual FROM pg_policies WHERE schemaname = 'aidb_internal' AND tablename = 'query_history_shop_kb' ORDER BY policyname;
-[ RECORD 1 ]-------------------------------------------------------------------------------------------------------------------------------- policyname | qh_deleter_shop_kb cmd | DELETE qual | (pg_has_role(CURRENT_USER, executed_by, 'MEMBER'::text) OR pg_has_role(CURRENT_USER, 'pg_read_all_stats'::name, 'MEMBER'::text)) -[ RECORD 2 ]-------------------------------------------------------------------------------------------------------------------------------- policyname | qh_reader_shop_kb cmd | SELECT qual | (pg_has_role(CURRENT_USER, executed_by, 'MEMBER'::text) OR pg_has_role(CURRENT_USER, 'pg_read_all_stats'::name, 'MEMBER'::text)) -[ RECORD 3 ]-------------------------------------------------------------------------------------------------------------------------------- policyname | qh_updater_shop_kb cmd | UPDATE qual | (pg_has_role(CURRENT_USER, executed_by, 'MEMBER'::text) OR pg_has_role(CURRENT_USER, 'pg_read_all_stats'::name, 'MEMBER'::text)) -[ RECORD 4 ]-------------------------------------------------------------------------------------------------------------------------------- policyname | qh_writer_shop_kb cmd | INSERT qual |
pg_stat_statements applies the same rule to its own text. Without pg_read_all_stats, other roles' statements come back hidden and are skipped as redacted. The role that runs scheduled imports should hold pg_read_all_stats, or be a superuser. Otherwise most of the workload is redacted.
Running query history from Tools Hub
Use the registered Tools Hub tools to let an agent run query history for the person it acts for: import_query_history, list_query_history, relationship_candidates, and accept_relationship_candidates. update_semantic_kb_history_filter and classify_query_history_statement aren't registered.
Reviewing the audit log
Query the audit log to review imports, acceptances, changes to the exclusion list, and updates to the evidence counts on existing relationships. Each is recorded in aidb_internal.semkb_audit_log:
SELECT fn, count(*) FROM aidb_internal.semkb_audit_log WHERE kb_name = 'shop_kb' AND fn IN ('update_history_filter', 'import_query_history', 'accept_relationship_candidate', 'refresh_relationship_evidence') GROUP BY fn ORDER BY fn;
fn | count -------------------------------+------- accept_relationship_candidate | 1 import_query_history | 3 refresh_relationship_evidence | 4 update_history_filter | 2 (4 rows)
fn | Written when |
|---|---|
import_query_history | An import runs. |
update_history_filter | The exclusion list changes. Records the previous and new lists and what was purged. |
accept_relationship_candidate | A candidate is accepted. |
refresh_relationship_evidence | An import or an exclusion-list change updates the evidence counts on existing relationships. Lists their IDs. |
For reading the audit log with aidb.semantic_kb_audit(), see Auditing changes.
Common errors
Look up an error message to find its cause and fix.
| Message | Cause and fix |
|---|---|
ERROR: pg_stat_statements is not installed in this database. Query history reads from it and has no other source. Install it with CREATE EXTENSION pg_stat_statements; note that it also requires shared_preload_libraries, which needs a restart. | See Setting up query history. |
ERROR: Semantic knowledge base 'no_such_kb' not found | The kb_name is wrong. |
ERROR: Invalid parameters: no such role: no_such_role | A role passed to update_semantic_kb_history_filter doesn't exist. |
ERROR: Missing value: exclude_roles is required: pass the roles to exclude, or an empty array to clear the list | NULL was passed. Pass ARRAY[]::text[] to clear the list. |
WARNING: aidb_users holds every aidb user's grants, so excluding it excludes every aidb user's statements from query history | aidb_users was listed. See Deployment requirements. |
ERROR: Invalid parameters: cardinality must be one of many-to-one, one-to-many, one-to-one, many-to-many, not 'sideways' | An override passed to accept_relationship_candidates isn't a cardinality. |
ERROR: Missing value: candidate_ids is required: pass the ids relationship_candidates reported. This function deliberately accepts no filter -- approving in bulk what nobody reviewed in detail is the thing it exists to prevent | No IDs were passed. Acceptance is by explicit ID only. |
WARNING: semantic_kb 'shop_kb': pg_stat_statements' highest call count fell from 8 to 5 since the last import, so the view was reset or has evicted statements. Stored exec_count is a lower bound and is never reduced; traffic between the reset and this import was not recorded. | pg_stat_statements was reset, or dropped statements to make room, between imports. Stored counts are kept, but runs in between are lost. Import more often, or raise pg_stat_statements.max. |
There are no custom SQLSTATEs, so match errors by message text. Invalid parameters: ... marks an unusable value, and Missing value: ... marks an absent one.
Filters are matched, not validated. A misspelled relation returns zero rows, not an error.
Limitations
Plan around the following limitations:
- No time-based retention. Statements are removed when their tables or join columns go, or when their role is excluded, never for age.
- Self-joins aren't proposed.
employees e JOIN employees m ON e.manager_id = m.idis read, but no candidate is proposed. The foreign-key crawl finds a self-referencing foreign key. - Text normalization merges some statements. Whitespace runs are collapsed throughout the stored text, including inside
--comments and quoted names. A--comment then appears to run to the end of the stored statement, and two quoted names that differ only in spacing are stored as one statement. The joins learned aren't affected. relationship_candidates(relation => ...)takes the unquoted form only,schema.table, unlikelist_query_history(), which also takes the quoted and SQL forms.- PostgreSQL 18 groups statements by name. From PostgreSQL 18,
pg_stat_statementsidentifies a table by name rather than by OID. The same SQL over same-named tables in two schemas is one entry, recorded under the text of whichever schema ran it first. A KB over the other schema may therefore skip it asout_of_scope. - Exclusion matches the role a statement ran as, not the session user. See Checking which roles are excluded.
Related documentation
See the following pages for related topics:
- Relationships, which covers the foreign-key crawl, join-path finding, and how accepted relationships are used.
- Searching a semantic KB, which covers
semantic_kb_search()and how sources are fused. - Semantic knowledge bases reference, the full API reference for every query history function.
- Native tools, which lists the Tools Hub tools for query history.