Querying
One dialect over a property graph, a vector index and a lexical index. This page is about what gets into the graph in the first place, and what a caller sees when they ask.
The P8QL grammar is the reference for the nine modes and every modifier, and Graph algorithms is the page for the questions a walk cannot answer at all — ranked relatedness, the best routes between two things, what connects a result set, and what all of that costs. The four things a reference cannot tell you are who the dialect is written for, which rows become nodes, why the property graph costs no migration, and why a query can look empty when it is working correctly.
Written for a model to write
P8QL exists because the caller doing most of the asking here is a language model, and the modes are named after the moves an investigation is made of rather than after the Postgres features that implement them. Resolve a name, walk out from a thing, find things that mean the same, find things that say the same, ask what exists at all.
What we are trying to do here is find an operator from a name somebody typed badly, and compare that against writing the same intent in SQL.
select aiq.query('FUZZY LOOKUP "meridien" LIMIT 5');
-- the obvious hand-written equivalent
select n.entity_type, k.key,
round(similarity(k.key, 'meridien')::numeric, 3) as score
from aiq.node_keys k join aiq.nodes n on n.id = k.node_id
where k.key % 'meridien'
order by score desc, k.key limit 5;
entity_type | key | score
-------------+---------------+-------
operator | meridian | 0.500
operator | meri | 0.400
operator | meridian bulk | 0.353
vessel | meridian dawn | 0.353
vessel | meridian star | 0.353
{"mode": "LOOKUP",
"plan": {"ok": true, "args": ["meridien"], "fuzzy": true, "limit": 5, …},
"rows": [{"node_id": "1cf8519b…", "entity_type": "operator",
"key": "meridian", "match_kind": "fuzzy", "score": 0.5,
"input_key": "meridien"},
{"node_id": "d18825eb…", "entity_type": "operator",
"key": "meridian bulk", "match_kind": "fuzzy", "score": 0.3529412,
"input_key": "meridien"},
{"node_id": "eac3f853…", "entity_type": "vessel",
"key": "meridian dawn", "match_kind": "fuzzy", "score": 0.3529412,
"input_key": "meridien"},
{"node_id": "fbaa9eb3…", "entity_type": "vessel",
"key": "meridian star", "match_kind": "fuzzy", "score": 0.3529412,
"input_key": "meridien"}],
"unresolved": []}
Why it works — the abstraction is over an investigation procedure, not over one query
Those are five keys and four things. Meridian Line carries two of them — meri
canonical and meridian short — so the trigram query returns a row per matching
key, and a caller that hydrates each one fetches the same operator twice.
LOOKUP answers "which nodes match this string", which is the question that was
actually asked, so it returns the operator once and reports which key matched,
and nothing is hidden by the deduplication.
The mode is also a cascade rather than a query: exact canonical first, then
short and alias, and only then trigram, each stage short-circuiting if it found
anything. You can watch it short-circuit — FUZZY LOOKUP "meridian" comes back
with "match_kind": "short" and "score": 1, having never reached the trigram
stage, because the string it was given is a key that exists. Fuzzy matching a
name that is spelled correctly is a way to get a worse answer slowly, and that
ordering is the sort of thing you write once rather than in every prompt.
Each mode stands over a different Postgres feature — pg_trgm here, pgvector
under SEMANTIC, tsvector under TEXT, SQL/PGQ GRAPH_TABLE under GRAPH —
and a model writing raw SQL has to pick the right one, with the operator that
matches the index, and an embedding from the same model the column was written
with. The failures there are quiet ones: a distance operator that does not match
the opclass returns rows in the wrong order rather than an error, and a vector
from the wrong model returns a number rather than an error. The dialect turns
those into refusals — SEMANTIC will not run against a space written by a
different model, and a modifier that means nothing for a mode is rejected rather
than ignored.
Two things in the response are there for the same reason. plan is what the
parser understood, so a model can see that its query meant what it thought
before reading a single row, and unresolved names which of the keys it asked
for came back with nothing — the difference between "no such company" and "that
company has no edges", which is the distinction an agent most often gets wrong.
None of this is an attempt to stop anyone writing SQL. Plain SQL is a mode of the dialect precisely because these seven will not cover a real question, and the escape hatch is the floor everything else sits on.
Start by asking what can be asked
What we are trying to do here is find out what this database holds, before writing a query against it.
select aiq.query('SCHEMA');
select aiq.query('SCHEMA "graph"');
Why it works — the capability document is derived where it can be and verified where it cannot
SCHEMA is the mode an agent reads before writing its first query, and it is a
versioned capability document rather than a dump. Anything the catalog can prove
is read from it — entity types off the registry, relations off the edge
catalogue, models off aiq.embedding_models. Anything a catalog cannot express
is written by hand, and then every hand-written example is run back through the
compiler on each read.
That last part is what makes it trustworthy. A spelling this deployment no longer accepts reports itself instead of being served to an agent as authoritative, which a static document cannot do.
Identity: what becomes a node
A row becomes a node if you can find it with LOOKUP, or if it is the endpoint
of an edge. A document is both, since you name it and events reference it. A
chunk is neither.
What we are trying to do here is get a table into the graph without copying it.
select aiq.register_entity_table(
p_entity_type => 'vessel',
p_source_table => 'harbour.vessels',
p_key_expr => 'n.name',
p_summary_expr => $$n.name || ' (IMO ' || n.imo || ')'$$,
p_org_expr => 'n.org_id',
p_include_where => $$n.status <> 'scrapped'$$);
Why it works — the registry is a curated index, not a second copy of your data
Registration records how to read your table as nodes rather than copying rows
into a parallel store. The projection is kept in step by a statement-level
trigger, so writing ten thousand included rows costs one set INSERT into
nodes and node_keys rather than ten thousand trigger firings, and a bulk
COPY disables it and pays one rebuild pass instead.
Letting chunks in would put the registry on the wrong side of the scale
argument: a chunk is machine-generated volume that joins in after identity is
already known. The same rule is why a workflow artefact gets the stored
resource status rather than ready — step output is machine-generated too.
include_where is why a scrapped vessel stays in your table and stops being an
identity anybody can look up. Nothing is deleted; it simply stops being findable
by name.
org_expr is what keeps a tenant's vessel its tenant's. This is the sample's
own registration, repeated, and registering again replaces the whole row — so
an argument you leave out goes back to its default rather than keeping what was
there. Without p_org_expr the default is null, and the next rebuild projects
every vessel with no org and visibility = public, readable by every tenant.
There is a second way in. An uploaded file can be read by a structured-output
extractor whose nodes and edges land through aiq.upsert_graph, so the things
your documents mention sit in the graph beside the things your tables hold.
That is a flag on the ingestion pipeline and it is off by default, because an
embedding per chunk is cheap and a completion per window is a different order of
money.
The property graph costs no migration
What we are trying to do here is query a graph without having adopted a graph database.
select * from aiq.nodes where entity_type = 'vessel'; -- ordinary SQL
select aiq.query('GRAPH "Bulk Harmony" DEPTH 2'); -- the same rows, walked
Why it works — SQL/PGQ defines a property graph as a view over ordinary tables
CREATE PROPERTY GRAPH in PG19 is read-only catalog metadata over
nodes, edges and hard-link junctions, compiled to ordinary joins at query
time. There is no separate graph store, no migration to adopt it, and no second
copy of anything to keep in step — which is the whole reason this collection
targets PG19 rather than bolting a graph layer on.
One PG19 limitation is worth knowing before you run into it: element pattern
quantifiers are not supported, so variable-length paths use DEPTH rather than
{1,3} syntax.
Over REST, and the two things that look like bugs
What we are trying to do here is query as a real caller, with their own identity attached.
curl -X POST http://localhost:3000/rpc/query \
-H 'Content-Type: application/json' \
-H 'Content-Profile: aiq' \
-H "Authorization: Bearer $TOKEN" \
-d '{"p_query": "LOOKUP \"meridian\""}'
Content-Profile: aiq is not optional. PostgREST exposes several schemas
and the first one in PGRST_DB_SCHEMAS is the default, which in the shipped
compose file is content — so a POST /rpc/query without the header looks for
content.query, does not find it, and answers 404 with PGRST202: Could not
find the function content.query. That reads like the function was never
installed. It is there; you asked the wrong schema for it. Reads use
Accept-Profile and writes and RPC use Content-Profile.
Why it works — and why an empty result is usually correct
RLS applies, so results come back filtered to what the caller can see. Two things make results look like they are missing, and neither is a bug.
Querying as a superuser bypasses RLS unconditionally, so you see more rather than less, and the filtered views start behaving oddly around you. This is the failure this collection refuses to install into: every schema checks at load time that its owner is not a superuser, because an owner-privileged view owned by one silently disables RLS for every caller of that view.
No token at all is not narrow visibility, it is a wall: PostgREST falls back
to web_anon, which holds no table grants and no usage on aiq, so
/rpc/query answers permission denied for schema aiq with a 401. That is a
different failure from the one below and it says so.
A token with no orgs claim is the quiet one. It authenticates, RLS applies,
and tenanted rows are simply not there — so the query succeeds, returns zero
rows, and LOOKUP reports the name unresolved. Nothing distinguishes that from
data you never loaded, which is why it is worth minting the claim deliberately:
percolate auth token --email you@example.com --orgs <org-uuid>
The fix in both cases is the identity, not a policy change.
Reading a base table over REST is also a 403 by design — authenticated
holds grants on the _api views, not on agentic.agents or aiq.nodes
themselves. GET /agents_api is the readable surface; the base tables are
reached through the SECURITY DEFINER functions and nothing else.
Row-level security here is inside the ranking rather than a filter applied after
it, which is worth seeing once — the cookbook runs one SEARCH under two claims
and the order of the results changes, not only their number.
Next: graph algorithms, which asks harder questions of the same graph; the P8QL grammar has the modes themselves.