The P8QL grammar
P8QL is the query dialect this collection speaks: nine modes over one endpoint, compiled by a Rust parser that ships with the extension. This page is the whole grammar at version 0.1.6, and the last section shows you how to ask your own database for its version rather than trusting this one.
There is one thing worth knowing before the table, because it explains most of
what the parser refuses. A modifier that means nothing for a mode is rejected
rather than ignored, so TEXT "x" DEPTH 2 is an error instead of a query that
quietly does something other than what it says. That rule costs a little
convenience and buys the property that a query you can read is a query you can
trust.
The nine modes
Eight modes open with a keyword and the ninth is plain SQL, which is how
p8_query_grammar() lists them too. Every mode goes through
aiq.query(text, vector), which is POST /rpc/query over the REST surface. The second argument is the embedding, and only the two
vector modes use it.
| Mode | Syntax | What it is for |
|---|---|---|
LOOKUP |
[FUZZY] LOOKUP "<key>" [, "<key>" …] [LIMIT n] |
Resolve names to nodes. Takes a list, because an agent almost never holds exactly one entity |
GRAPH |
GRAPH "<key>" [DEPTH n] [TYPE <relation>] [LIMIT n] |
Walk relationships out from a named node, to a bounded depth |
RELEVANCE |
RELEVANCE "<key>" [, "<key>" …] [TYPE <relation>] [LIMIT n] |
Rank what is most related to one or more nodes. Not a walk — it returns an order |
PATH |
PATH "<a>", "<b>" [, "<c>" …] [DEPTH n] [TYPE <relation>] [LIMIT n] |
How are these connected? Two names give the best routes; three or more give the smallest subgraph joining them |
TEXT |
TEXT "<text>" FROM <source> [LIMIT n] |
Lexical search, needing no embedding anywhere |
SEMANTIC |
SEMANTIC "<text>" FROM <source> [USING <model>] [LIMIT n] |
Meaning-based search. You supply the vector |
SEARCH |
SEARCH "<text>" FROM <source> [USING <model>] [LIMIT n] |
Both rankings, fused |
SCHEMA |
SCHEMA ["<facet>"] [FROM <name>] |
What this database is. The only mode answerable before you know anything |
| SQL | <any read-only statement> |
The floor everything else sits on |
Each modifier has a domain, and a modifier written outside it is refused, never ignored:
| Modifier | Where it applies | What it means there |
|---|---|---|
DEPTH |
GRAPH, PATH |
bounds a traversal — hops out, or the longest route worth considering |
TYPE |
GRAPH, RELEVANCE, PATH |
filters a relation on an edge |
LIMIT |
everywhere but SCHEMA |
how many answers |
FROM, USING |
TEXT, SEMANTIC, SEARCH |
which corpus, which embedding space |
FUZZY |
LOOKUP |
trigram over node_keys, and only there |
RELEVANCE "acme" DEPTH 2 is an error, and the error says why: it ranks by how
much score reaches a node, not by how many hops away it is — reach for GRAPH.
PATH "acme" is an error too, because one name is neither a route nor a
connecting subgraph.
RELEVANCE and PATH are the two modes that run in the compiled extension
rather than in SQL, and the two that ship switched off.
Graph algorithms is their page.
Resolving a name you are not sure of
Looking something up by a name a person typed is the first query an agent makes, and it is almost never spelled the way the database has it.
What we are trying to do here is find a vessel from a half-remembered name, and fall back to fuzzy matching when the exact spelling misses.
select aiq.query('LOOKUP "meridien dawn"'); -- 0 rows
select aiq.query('FUZZY LOOKUP "meridien dawn" LIMIT 3');
select aiq.query('FUZZY LOOKUP "acme", "globex" LIMIT 5');
Why it works — `FUZZY` is a prefix because it changes what the query means
LOOKUP "acme" FUZZY, with the modifier trailing, is refused with a message
telling you what to write instead. A modifier that changes the meaning of a query belongs in front of the
thing it modifies, where you read it before you read the argument rather than
after you have already formed an expectation.
Commas between keys are optional, and both quote characters work
interchangeably. Single quotes matter more than they look: SQL and YAML both
take the double quote, so 'GRAPH ''R7'' DEPTH 2' inside a workflow document
would be unwritable without them. Without them that query parses successfully,
with the argument as the literal 'R7' — matching no key and raising no
error, which is the reason it is worth a paragraph.
Walking out from a node
What we are trying to do here is find who ultimately operates a ship, and what else was in the same port, from one walk.
select aiq.query('GRAPH "Bulk Harmony" DEPTH 2');
select aiq.query('GRAPH "MERB" DEPTH 2 TYPE subsidiary_of');
Why it works — `TYPE` narrows the walk rather than filtering its result
Restricting the relation is applied during the traversal, so a depth-2 walk with
a TYPE filter follows only edges of that relation at both hops rather than
walking everything and discarding most of it. On a well-connected node that is
the difference between a walk and a scan.
DEPTH defaults to 1. The rows come back with the path that reached them, which
is what lets a caller tell a sibling relationship — reached by going out to a
port and back in again — from a stored one.
Ranking, which a walk cannot do
GRAPH answers what is within n hops. That is the right question one and two
hops out, and it is the wrong shape for "what matters most about this", because
a depth cap returns everything at that distance in no particular order and
leaves the ranking to a caller who cannot do it.
What we are trying to do here is ask what is most related to two entities at once, without picking a depth.
select aiq.query('RELEVANCE "bulk harmony", "rotterdam" LIMIT 3');
{"mode": "RELEVANCE",
"rows": [{"key": "bulk harmony", "entity_type": "vessel", "score": 0.2429, …},
{"key": "rotterdam", "entity_type": "port", "score": 0.2424, …},
{"key": "meri", "entity_type": "operator", "score": 0.1667, …}],
"exhausted": false,
"unresolved": []}
Why it works — a list of seeds, a refused DEPTH, and two envelope keys that only this mode carries
It takes a list for the same reason LOOKUP does: an agent almost never
holds exactly one entity, and relatedness to a set of seeds is a different and
usually better question than relatedness to one of them. The two seeds above
score almost identically because each is strongly related to the other.
DEPTH is refused rather than ignored, and the error message is the mode's
argument in one sentence: it ranks by how much score reaches a node, not by how
many hops away it is.
Two keys appear on this envelope that most modes do not carry. unresolved is here for the
reason it is on LOOKUP — the underlying call drops a seed it cannot resolve,
and through a JSON envelope that makes "no such entity" indistinguishable from
"that entity has nothing near it". exhausted is here because this and PATH
are the two budgeted modes; putting it on the seven that cannot be truncated is
how a reader learns to stop reading it.
The mode ships switched off, so on a fresh database it answers with a sentence
containing aiq.enable_graph_algorithms('<your role>') rather than a permission
error naming a compiled function you have never heard of.
How are these connected
Two names is a route. Three or more is the smallest structure joining all of them — and that is the same mode rather than a second one, because a shortest path is the Steiner tree of two terminals.
What we are trying to do here is find how a ship reaches its ultimate parent, and then what joins three things at once.
select aiq.query('PATH "bulk harmony", "meri" LIMIT 2');
select aiq.query('PATH "bulk harmony", "rotterdam", "meri"');
{"mode": "PATH",
"rows": [{"hops": 2, "cost": 2.05, "nodes": ["bulk harmony", "merb", "meri"]},
{"hops": 3, "cost": 3.00, "nodes": ["bulk harmony", "rotterdam",
"meridian dawn", "meri"]}],
"exhausted": false, "unresolved": []}
Why it works — one mode with a minimum arity, and a `DEPTH` default of its own
PATH is the only mode with a minimum number of arguments. One name is
neither of the things it does, and whoever wrote it wanted GRAPH or
RELEVANCE; the error says so rather than guessing.
DEPTH bounds the longest route worth considering, and its default here is
6, not the 1 that GRAPH uses — a one-hop route is a direct edge, so
inheriting that default would have meant "only tell me about things already
adjacent". The substituted default appears in the returned plan, because a
default the caller cannot see is one they will discover from a short answer.
Cost with two names is the volume between them, square-rooted by searching from both ends at once; the best route comes back in about 8 ms on a four-million-edge graph and the alternates are what spend the budget. With three or more it is one multi-source walk, and the answer reports how many terminals it actually joined so a partial result cannot read as a whole one.
The three search modes, and why there are three
A corpus where lexical and semantic search agree cannot show you why both exist. The interesting case is a document that uses a rare exact token, beside another that says the same thing in entirely different words.
What we are trying to do here is find the same page three ways, and see the ranking each mode produces.
\set query_vector `curl -s https://api.openai.com/v1/embeddings -H "Authorization: Bearer $LLM_API_KEY" -H 'Content-Type: application/json' -d '{"model": "text-embedding-3-small", "input": "a boiler fault"}' | jq -c '.data[0].embedding'`
select aiq.query('TEXT "PSC-441" FROM chunks LIMIT 3');
select aiq.query('SEMANTIC "a boiler fault" FROM chunks LIMIT 3', :'query_vector');
select aiq.query('SEARCH "PSC-441 boiler" FROM chunks LIMIT 3', :'query_vector');
TEXT runs on its own. The other two take the vector as an argument, because
the database makes no model calls — so :query_vector is something you supply, and
that is the whole reason a vector query compiles to two tasks rather than one.
In a workflow you never write it: the compiler emits an embed step that calls
the model the registry names and hands the result to the query step. By hand,
the \set line is that call: psql runs it in your shell, with curl and
jq and the LLM_API_KEY the corpus was embedded with, and :'query_vector'
— with the quotes — hands the array over as one literal.
Do not paste a short literal in to see it work. A vector of the wrong width is
caught — query vector has 4 dimensions but model text-embedding-3-small
expects 1536 — but one of the right width and the wrong provenance is not,
and it ranks confidently against a space it never came from.
Why it works — the database makes no model calls, so the vector is an argument
SEMANTIC and SEARCH rank against a vector, and producing one is an HTTP call
to a model. No HTTP client extension is installed here deliberately, so the
vector arrives as the second argument to aiq.query and the database never
blocks on somebody else's latency.
In a workflow you do not write that call yourself. A p8ql: step in either mode
compiles to two tasks — a hidden embed step and the search that consumes it —
with the endpoint taken from aiq.embedding_models and the model name written
into the query so the vector and the space it is ranked against cannot be two
different models.
USING <model> names a registered embedding space explicitly. Leaving it out
takes the deployment's default, and the compiler refuses a document whose
USING disagrees with the model its embed step would call.
Asking the database what it is
What we are trying to do here is find out what this deployment accepts, before writing a query against it.
select aiq.query('SCHEMA'); -- the index of facets
select aiq.query('SCHEMA "workflow"'); -- one facet, in detail
select aiq.query('SCHEMA "graph"'); -- entity types, relations, models
Why it works — the answer is derived from the catalog, and verified by running it
Anything the catalog can prove is read from it rather than written down: the step kinds come off a check constraint, the callable functions off the allow-list, the edge catalogue off the table whose trigger enforces it. What a catalog cannot express is written by hand — and then every hand-written example is fed back through the compiler on each read, so a spelling this deployment no longer accepts reports itself instead of being served to an agent as authoritative.
That is the difference between a capability document and documentation. This
page can go stale; SCHEMA cannot.
Plain SQL is a mode
What we are trying to do here is run an ordinary read-only query through the same endpoint as everything else.
select aiq.query('select count(*) from aiq.nodes');
Why it works — SQL is recognised by how it starts, and executed somewhere else
There is no SQL keyword. The parser sniffs the first word against exactly five
read-only openers — SELECT, WITH, TABLE, VALUES, EXPLAIN — and
anything else is an honest dialect error. That matters more than it sounds:
treating every unrecognised first word as SQL would turn LOKUP "acme" into a
Postgres syntax error at position 1, about a language the caller was not
writing.
aiq.query returns a plan for this mode rather than rows, because executing
it is the job of aiq.sql_passthrough — a SECURITY INVOKER function, so the
statement runs as you and not as the definer that would otherwise be reading on
your behalf.
The consequence catches people in workflows, and it runs the opposite way to
what the split above suggests. aiq.query does not run SQL mode — it returns the
statement and a note — but workflow.p8ql, the step function, honours the note
by putting the statement through the passthrough. So the step executes, and
inside a step the invoker is the engine owner:
status | mode | rows
-----------+------+------------------------------
succeeded | SQL | [{"current_user": "app_owner"}]
app_owner owns every table and bypasses their RLS, so a plain-SQL step reads
across tenants. percolate.sql_policy = 'registered' refuses that from
0.1.6 onward. Every other mode reads across tenants inside a step
too, under either policy: a SEARCH over chunks ranks every org's uploads. That
is the beta's trade, and who it reaches is covered in full in
the cookbook. Called directly
rather than from a step you are the invoker, which is the case this section is
about and is unaffected.
Three spellings that were retired
The parser recognises each of these and answers with the sentence telling you what to write instead, rather than with "unknown mode".
| Was | Now | Why |
|---|---|---|
HYBRID "…" |
SEARCH "…" |
HYBRID named the implementation; SEARCH names the intent |
LOOKUP "…" FUZZY |
FUZZY LOOKUP "…" |
A modifier that changes meaning belongs before what it modifies |
SQL select … |
select … |
Ceremony on the one mode that needed no help identifying itself |
Getting this page from your own database
The grammar above is version 0.1.6 of the parser. Your deployment is the authority on its own version, and it will tell you:
What we are trying to do here is read the grammar out of the installed parser rather than out of a document.
select p8_query_grammar(); -- every mode, syntax and example
select workflow.compiler_capabilities(); -- and whether the parser is current
Why it works — the parser describes itself, so the description cannot drift from it
p8_query_grammar() is a function in the same Rust crate as the parser it
describes, which is why it is worth preferring to this page: the two ship
together and are versioned together. An agent that reads it gets the vocabulary
and the proof that the vocabulary is current, in one call.
compiler_capabilities() is the companion check. The compiled parser and the
SQL schema ship on separate clocks and will eventually disagree, so rather than
comparing version strings it probes the installed build with one canary per
feature and lists what is missing. Version skew otherwise presents as a syntax
error in a document that is not wrong.
Next: the workflow grammar, which is the same idea for the YAML side.