Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Query Language

Every read and write in Dr Strange executes as a logical plan — an explicit pipeline of operators. A plan may be authored directly as a serializable structure, or written in an openCypher-subset language that compiles to the same plan. The two are equivalent; the language is a surface over the plan.

This chapter explains what each construct is for. Appendix B states the complete grammar — every clause, every default, and everything that is deliberately not supported.

The logical plan

A plan is a source followed by a sequence of steps. Each row flows through the pipeline carrying a current node and the path that produced it.

Sources:

SourceRows produced
ScanAllevery node in the plane
ScanLabel(label)nodes carrying label
SeekKeys(keys)nodes resolved from external keys
VectorTopK{…}the k nodes nearest a query vector
KeywordTopK{…}the k nodes best matching a text query (BM25)
Hybrid{…}the k nodes ranked by fused vector + keyword + proximity
Algo{…}the nodes a graph algorithm produced, each carrying its result

Every source yields rows the remaining steps then process, so retrieval and algorithms are not terminal operations: a keyword hit, a fused hit, or a PageRank result can be traversed from, filtered, sorted, and bounded like any scanned node.

Steps:

StepEffect
Expand{dir, edge_type}traverse one hop (direction, optional edge type)
ExpandVar{…}traverse a variable number of hops
Filter(expr)keep rows whose predicate holds
Distinctdeduplicate by current node
Sort(keys)order rows
Skip(n) / Limit(n)offset / bound the result

A plan may end in a projection: a list of named columns, each either an expression evaluated per row or an aggregate folded over a group of rows, with its own distinct, order_by (by column index), skip and limit. It is a tail rather than a step because it turns node rows into value rows — nothing can expand or filter a node after it. A plan without one returns nodes, exactly as it always has.

The plan is serializable. MATCH (:Person)-[:KNOWS]->(q) RETURN q LIMIT 50 corresponds to:

{
  "source": { "ScanLabel": "Person" },
  "steps": [
    { "Expand": { "dir": "Out", "edge_type": "KNOWS" } },
    { "Limit": 50 }
  ]
}

A plan in this form runs directly:

$ drsg --db graph.drsg query - --plane social < plan.json

The Cypher subset

Reads are expressed with MATCH … RETURN, optionally refined by WHERE, ORDER BY, SKIP, LIMIT, and DISTINCT:

MATCH (p:Person)-[:KNOWS]->(q:Person)
WHERE p.age >= 18
RETURN q
ORDER BY q.name
LIMIT 50

A MATCH is a node pattern, optionally chained through relationship patterns (-[:TYPE]->, <-[:TYPE]-, or undirected). WHERE predicates combine property comparisons (=, <>, <, <=, >, >=), label tests (n:Label), string predicates (CONTAINS, STARTS WITH, ENDS WITH), membership (IN), and the boolean operators AND, OR, NOT. A read returns the matched subgraph — the nodes and edges on every matching path, not merely the final nodes.

Text and membership predicates

MATCH (d:Doc) WHERE d.title CONTAINS "graph"    RETURN d
MATCH (p:Person) WHERE p.name STARTS WITH "Al"  RETURN p
MATCH (f:File) WHERE f.path ENDS WITH ".pdf"    RETURN f

Matching is byte-wise: no case folding, the same posture = takes. Non-string scalars promote to their text form, so d.year STARTS WITH "20" works whether year was stored as 2026 or "2026" — which matters on soft-schema data, where the same field may differ in type from node to node. Values with no canonical text form (Null, byte strings, vectors, lists, maps) simply do not match.

IN is membership, and is deliberately not spelled CONTAINS: for a list of strings, “contains” could mean either “has this element” or “some element has this substring”, and nothing in the syntax would pick one.

MATCH (d:Doc) WHERE "graph" IN d.tags  RETURN d   -- element of a list property
MATCH (d:Doc) WHERE "author" IN d.meta RETURN d   -- key of a map property
MATCH (n) WHERE n.year IN [2020, 2021] RETURN n   -- literal list

A list is tested by element, using the same equality = uses, so 7 matches a stored 7.0. A map is tested by key, not value. A literal list on the right is expanded into equalities at compile time; any other right-hand side is evaluated per row.

Missing properties

A predicate over a property the node lacks is not satisfied — and neither is its negation. d.year <> 2020, NOT d.year = 2020, NOT d.year IN [2020] and NOT (d.title CONTAINS "x") all skip a node without the property, as in openCypher; d.year = null matches nothing. Ask about absence with IS NULL / IS NOT NULL, and combine when both are wanted:

MATCH (d:Doc) WHERE d.year <> 2020 OR d.year IS NULL RETURN d

The engine reaches this by guarding <> and NOT with IS NOT NULL on the properties they read, rather than with three-valued logic, so one case is stricter than openCypher: under NOT, a compound predicate whose false branch would have absorbed the null — NOT (d.a = 1 AND d.b = 2) keeps a node with b = 3 and no a in openCypher, and drops it here.

Anchoring on a known entity

key(n) reads a node’s external key — the stable identifier it was created with. It is an ordinary term, usable anywhere an expression is, but an equality (or IN) on the query’s first variable is compiled into a SeekKeys source: an index lookup rather than a scan followed by a filter.

MATCH (n:Doc) WHERE key(n) = "paper-42" RETURN n
MATCH (n) WHERE key(n) IN ["ada", "alan"] RETURN n

This is the shape that matters for a graph whose identity lives in the key rather than in a property — the common case for LLM-ingested material — and it anchors a traversal from a specific entity:

MATCH (n)-[:CITES]->(p:Paper)
WHERE key(n) = "paper-42"
RETURN p

Projections and aggregation

RETURN n (or *) hands back whole records — nodes with their properties. Any other RETURN projects: each item becomes a column, named by its AS alias or, without one, by the item as written.

MATCH (f:Fn) RETURN f.file, f.line AS line, key(f)

Because a row carries the whole path that produced it, a column may read any variable the pattern bound, not only the last:

MATCH (a:Author)-[:WROTE]->(p:Paper) RETURN a.name, p.year

An aggregate folds a group of rows into one value: count, sum, avg, min, max, and collect (which keeps the group’s values as a list). The grouping key is every column that is not an aggregate — Cypher’s implicit GROUP BY, so what a query lists beside its aggregates is what it is broken down by:

MATCH (a:Author)-[:WROTE]->(p:Paper)
RETURN a.name, count(*) AS papers, collect(DISTINCT p.year) AS years
ORDER BY papers DESC
LIMIT 10

count(*) counts rows; every other fold reads a value and skips the rows whose value is null — or is something the fold cannot use, such as a year one node stored as text, which is what keeps an aggregate over soft-schema data answering rather than failing. Over a group that gave it nothing, count and sum are 0, avg, min and max are null (the mean of no values is unanswerable, not zero), and collect is the empty list. A projection with no grouping key at all is one group over the whole match, so RETURN count(*) answers 0 rather than nothing when the match found nothing.

In a projecting query, DISTINCT, ORDER BY, SKIP and LIMIT apply to the projected rows: DISTINCT compares whole tuples (two nodes sharing a file are one row), and ORDER BY names a column — by its alias, by the expression it returned, or by the aggregate it folds (ORDER BY count(*) DESC finds the count column however the RETURN spelled or aliased it).

MATCH (f:Fn) RETURN DISTINCT f.file ORDER BY f.file SKIP 10 LIMIT 10
MATCH (a:Author)-[:WROTE]->(p:Paper) RETURN a.name, count(*) ORDER BY count(*) DESC

A node cannot share a RETURN with columns, since a node is not a value: return its properties instead. Every surface renders a projected result the same way — as columns and rows rather than nodes and edges.

Writes

The write clauses mutate the plane and report change counts rather than rows:

ClauseEffect
CREATEcreate nodes and edges
MERGEmatch an existing pattern or create it
SETadd or overwrite properties or labels
REMOVEremove properties or labels
DELETEdelete a node or edge
DETACH DELETEdelete a node together with its incident edges
CREATE (a:Person {name:"Ada"})
MERGE (b:Person {name:"Alan"})
CREATE (a)-[:KNOWS {since: 1936}]->(b)

Values may be supplied as $name parameters rather than interpolated into the query text, which keeps the query stable and avoids escaping. A key: may be a parameter too — MERGE (n:Person {key: $k}) upserts on the string $k resolves to (a non-string is an error, not a property):

MATCH (p:Person) WHERE p.age >= $min RETURN p
MERGE (n:Person {key: $k}) ON CREATE SET n.seen = 1

Literals and identifiers

Strings take either quote and the usual escapes — \', \", \\, \n, \t, \r, \b, \f, \uXXXX — so a value that contains a quote is written inside the literal, never around it; an unknown escape or an unterminated string is a syntax error. Numbers are ints (42) or floats (3.5, 1e9, 2.5E-3). Identifiers (variables, labels, types, property keys) are Unicode words (n.名字, café), or anything between backticks when the plain form cannot spell the name (n.`first name`, (:`order`)). Keywords and function names are case-insensitive (COUNT(*), Score()). Expressions may nest at most 64 levels deep, where parentheses, NOT, unary - and each operator of an AND/OR/+ -/* / chain count one level (so a chain of 64 conjuncts is the limit); deeper is a syntax error rather than a crash. An IN [...] list is not a chain and may be as long as the query body allows.

What the subset leaves out

Each of these is refused with an error that names the rewrite; none is silently misread:

  • A second MATCH, OPTIONAL MATCH, UNION, UNWIND, WITH, and a pattern with several paths (MATCH (a)-->(b), (a)-->(c)): a query holds one linear path, ending in one RETURN. Run one query per pattern or branch and combine the results in the caller.
  • Relationship variables (-[r:KNOWS]->): an edge cannot be bound, returned or filtered on. Drop the variable: -[:KNOWS]->.
  • Inline property predicates in a MATCH node ((n:Person {name: "x"})): write them in WHERE (WHERE n.name = "x", or key(n) = "…"). In CREATE and MERGE the same map is the node’s properties.
  • List and map literals as values (RETURN [1, 2], n.x = {a: 1}): a list is only the right side of IN (or a vector after NEAR), a map only a CREATE/SET property map.
  • The % and ^ operators; arithmetic is + - * /.
  • Cross-variable predicates (p.year < q.year), returning the rows of an earlier variable (RETURN p after a hop — project p.name instead), and unbounded variable-length hops (*, *2..).

A syntax error is reported near its cause: a typo late in a CREATE names that spot, not the statement’s first word.

Similarity search in a query

The SEARCH clause makes similarity a source of rows, so a semantic lookup and a traversal compose in one statement:

SEARCH (d:Doc) ON embedding NEAR "how does time-travel work" TOPK 10 RETURN d

ON <property> selects the vector property and may be omitted — every NEAR defaults to embedding, the property the document-ingestion pipeline writes, so SEARCH (d:Doc) NEAR "…" is the short form. TOPK <k> bounds the result. A text argument (NEAR "…") is embedded server-side; a literal vector (NEAR $vec) requires no provider. This clause compiles to a VectorTopK source, from which traversal may continue:

SEARCH (d:Doc) ON embedding NEAR "time travel" TOPK 10
-[:CITES]->(p:Paper)
WHERE p.year >= 2020
RETURN p

Keyword search in a query

The same verb searches words instead of meaning when NEAR is replaced by MATCHING. It compiles to a KeywordTopK source over the BM25 index declared on that (label, property) pair, and each row carries its relevance as score():

SEARCH (d:Doc) ON body MATCHING "graph database" TOPK 10
RETURN d ORDER BY score() DESC

Both a label and ON <property> are required here. The keyword index is declared per (label, property), so there is nothing to search without them — and unlike the vector property, keyword properties follow no convention worth defaulting to. Unlike vector search, which falls back to an exact scan, keyword search returns nothing when no index is declared.

Hybrid retrieval in a query

HYBRID fuses up to three ranked channels into a single ordering. Each channel is optional (at least one of VECTOR or KEYWORD is required), may carry a WEIGHT, and they may appear in any order. Only the channel’s defining part is required — HOPS for GRAPH, a query for the other two — so VECTOR NEAR "…" and GRAPH HOPS 2 are the short forms; the vector property defaults to embedding and the per-hop decay to 0.5, matching the RPC, MCP and CLI:

HYBRID (d:Doc)
  VECTOR ON embedding NEAR "graph database internals" METRIC cosine WEIGHT 1.0
  KEYWORD ON body MATCHING "LSM storage engine" WEIGHT 1.0
  GRAPH HOPS 2 DECAY 0.5 WEIGHT 0.5
  CANDIDATES 100 TOPK 10
RETURN d ORDER BY score() DESC

This is the plane.hybrid retrieval of Chapter 3 expressed in the language, running through the same fusion engine: each channel is min-max-normalized before the weighted sum, and score() carries the fused result.

Graph algorithms

Graph algorithms are read-only, transient computations over a single snapshot of a plane. They are available as a query source, so their output feeds the rest of the pipeline:

CALL pagerank(damping: 0.85, iterations: 20) ON (n:Paper)
RETURN n ORDER BY score() DESC LIMIT 10

CALL shortest_path(from: "ada", to: "alan", dir: "both") ON (n)
RETURN n

CALL components() ON (n:Doc) RETURN n
CALL louvain(max_levels: 10) ON (n:Doc) RETURN n

ON (v[:Label]) does double duty: it scopes the algorithm to a label’s induced subgraph (omit the label for the whole plane) and binds the variable the rest of the query names. Every argument is optional and defaults to the engine’s own, and an unknown algorithm or argument is an error rather than a silently ignored setting.

Because the row model carries one current node, each algorithm reports its per-node result through the score channel:

AlgorithmRow orderscore()
pagerankmost important firstthe rank
componentsgrouped by componenta dense 0-based component index
louvaingrouped by communitya dense 0-based community index
shortest_pathsource → targetthe node’s position along the path

shortest_path takes its endpoints as external keys (a string) or node ids (a whole number), optionally a dir of out/in/both and a weight naming a numeric edge property. An unknown endpoint or an unreachable target yields no rows rather than an error.

Since an algorithm is a source, its result composes:

CALL pagerank() ON (n:Paper)
-[:CITES]->(q:Paper)
WHERE q.year >= 2020
RETURN q ORDER BY score() DESC LIMIT 10

The same algorithms are also available as a direct command, which reports the raw result rather than a row stream:

$ drsg --db graph.drsg algo pagerank      --plane social --top 10
$ drsg --db graph.drsg algo components    --plane social
$ drsg --db graph.drsg algo shortest-path --plane social --src 1 --dst 42
$ drsg --db graph.drsg algo louvain       --plane social

Time-travel

Any read may be pinned to a past point — a commit sequence or a timestamp — and observe the graph exactly as it was then. It is inherently read-only: it selects the snapshot a query reads, and cannot alter history.

In the language this is the AS OF clause, written last so it reads as a modifier over the whole query. It accepts a commit sequence, an RFC-3339 instant, or TIME followed by unix-epoch milliseconds, and it applies to every source — a scan, a retrieval seed, or an algorithm:

MATCH (p:Paper)-[:CITES]->(q:Paper) RETURN q LIMIT 10 AS OF 41337

SEARCH (d:Doc) ON body MATCHING "outage" TOPK 5
RETURN d AS OF "2026-07-01T00:00:00Z"

CALL pagerank() ON (n:Paper) RETURN n AS OF TIME 1782864000000

The same address is available outside the language: as an as_of (commit sequence) or as_of_ms (timestamp) argument on the read methods of the RPC API and the SDKs, as PlaneHandle::as_of(…) in the embedded API, and as the Time-travel slider and historical search in the dashboard. Every form uses “at or before” semantics: a value between two commits resolves to the latest commit not after it.

A time-travelling read cannot use the vector or keyword indexes, which are built from the latest commit; its similarity searches scan the pinned snapshot and its keyword and hybrid searches compute BM25 over it instead — correct (the matches and scores are the snapshot’s own, so a node deleted or edited after the point is still found as it was), but unindexed. Time-travel requires the native backend.

The queryable window is reported by plane.history as an oldest/latest pair of commit sequences. History is retained without bound by default; a retention window may be configured to bound it, in which case a read older than the window is rejected.

Because a change event carries the commit sequence at which it landed, an agent can read as_of(seq) and as_of(seq - 1) to reconstruct the exact before and after of any committed change (see Chapter 6).