查询语言
Dr Strange 中的每一次读与写,都作为一个逻辑计划执行:一条由算子组成、语义 明确的流水线。计划既可以直接写成可序列化的结构,也可以用一套 openCypher 子集语言书写,后者会编译成同一个计划。二者完全等价,这套语言只是计划之上的 一层表层表示。
本章讲解各构造的用途;完整文法见附录 B:每个子句、每项默认 值,以及每一项刻意不予支持的内容。
逻辑计划
一个计划由一个数据源(source),后接一串**步骤(step)**构成。每一行沿流水线 流动,携带其当前节点,以及产生它的那条路径。
数据源:
| 数据源 | 产生的行 |
|---|---|
ScanAll | 平面内的每个节点 |
ScanLabel(标签) | 携带该标签的节点 |
SeekKeys(键) | 由外部键解析出的节点 |
VectorTopK{…} | 与查询向量最近的 k 个节点 |
KeywordTopK{…} | 与文本查询最相关的 k 个节点(BM25) |
Hybrid{…} | 按向量 + 关键词 + 图邻近融合排序的 k 个节点 |
Algo{…} | 某个图算法产出的节点,各自携带其结果 |
每一种数据源产出的行都会继续流经后续步骤,因此检索与算法并非终结操作:一个关键词 命中、一个融合命中或一个 PageRank 结果,都能像任何扫描得到的节点一样,继续参与 遍历、过滤、排序与限量。
步骤:
| 步骤 | 作用 |
|---|---|
Expand{dir, edge_type} | 遍历一跳(方向,边类型可选) |
ExpandVar{…} | 遍历可变数量的跳数 |
Filter(expr) | 保留谓词成立的行 |
Distinct | 按当前节点去重 |
Sort(keys) | 对行排序 |
Skip(n) / Limit(n) | 偏移 / 限量结果 |
计划还可以以一个**投影(projection)**收尾:一组具名的列,每一列要么是逐行求值的
表达式,要么是在一组行上折叠出的聚合;投影自带 distinct、order_by(按列序号)、
skip 与 limit。它是尾部而非步骤,因为它把节点行变成了值行——其后再没有节点可供
扩展或过滤。不带投影的计划仍旧返回节点,与一直以来完全一致。
计划是可序列化的。MATCH (:Person)-[:KNOWS]->(q) RETURN q LIMIT 50 对应于:
{
"source": { "ScanLabel": "Person" },
"steps": [
{ "Expand": { "dir": "Out", "edge_type": "KNOWS" } },
{ "Limit": 50 }
]
}
以此形式表示的计划可直接运行:
$ drsg --db graph.drsg query - --plane social < plan.json
Cypher 子集
读操作以 MATCH … RETURN 表达,并可由 WHERE、ORDER BY、SKIP、LIMIT 与
DISTINCT 进一步细化:
MATCH (p:Person)-[:KNOWS]->(q:Person)
WHERE p.age >= 18
RETURN q
ORDER BY q.name
LIMIT 50
一个 MATCH 是一个节点模式,可选地通过关系模式(-[:TYPE]->、<-[:TYPE]-,或
无向)串接。WHERE 谓词组合了属性比较(=、<>、<、<=、>、>=)、标签测试
(n:Label)、字符串谓词(CONTAINS、STARTS WITH、ENDS WITH)、成员判断
(IN)以及布尔运算符 AND、OR、NOT。读操作返回所匹配的子图——每条匹配路径上
的节点与边,而不仅是末端节点。
文本谓词与成员判断
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
匹配按字节进行:不做大小写折叠,与 = 的立场一致。非字符串标量会提升为其文本形式,
因此无论 year 存为 2026 还是 "2026",d.year STARTS WITH "20" 都成立。这一点
对软模式数据很重要,因为同一字段在不同节点上的类型可能并不一致。没有规范文本形式的值
(Null、字节串、向量、列表、映射)则一律不匹配。
IN 表示成员判断,并且刻意不写作 CONTAINS:对于一个字符串列表,「contains」既可
理解为「含有该元素」,也可理解为「某个元素含有该子串」,而语法本身无法在二者之间做出
选择。
MATCH (d:Doc) WHERE "graph" IN d.tags RETURN d -- 列表属性中的元素
MATCH (d:Doc) WHERE "author" IN d.meta RETURN d -- 映射属性中的键
MATCH (n) WHERE n.year IN [2020, 2021] RETURN n -- 字面量列表
列表按元素判断,所用相等语义与 = 相同,因此 7 可匹配存储的 7.0。映射按键
判断,而非按值。右侧为字面量列表时会在编译期展开为若干等值判断;其余形式则逐行求值。
缺失的属性
针对节点所没有的属性的谓词不成立——它的否定同样不成立。d.year <> 2020、
NOT d.year = 2020、NOT d.year IN [2020] 与 NOT (d.title CONTAINS "x")
都会跳过没有该属性的节点,与 openCypher 一致;d.year = null 不匹配任何节点。
用 IS NULL / IS NOT NULL 询问缺失,两者都要时组合使用:
MATCH (d:Doc) WHERE d.year <> 2020 OR d.year IS NULL RETURN d
引擎通过在 <> 与 NOT 所读取的属性上加 IS NOT NULL 守卫来做到这一点,而非三值逻辑,
因此有一种情形比 openCypher 更严格:NOT 之下、某个为假的分支本可吸收 null 的复合谓词——
NOT (d.a = 1 AND d.b = 2) 在 openCypher 中保留 b = 3 且没有 a 的节点,这里会丢弃它。
锚定到某个已知实体
key(n) 读取节点的外部键,即创建该节点时所用的稳定标识。它是一个普通的项,凡可
写表达式之处皆可使用;但若它在查询的首个变量上构成等值(或 IN)判断,就会
编译为一个 SeekKeys 数据源:这是一次索引查找,而非先扫描再过滤。
MATCH (n:Doc) WHERE key(n) = "paper-42" RETURN n
MATCH (n) WHERE key(n) IN ["ada", "alan"] RETURN n
有些图的身份信息存于键中而不是某个属性里,LLM 摄取生成的素材常常就是这一类;对 这种图来说,这正是关键写法。它也能从某个特定实体出发,锚定一次遍历:
MATCH (n)-[:CITES]->(p:Paper)
WHERE key(n) = "paper-42"
RETURN p
投影与聚合
RETURN n(或 *)交回完整记录,即带着属性的节点。其余任何形式的 RETURN 都是
投影:每一项成为一列,列名取 AS 别名;未写别名时,就取该项在查询中的原文。
MATCH (f:Fn) RETURN f.file, f.line AS line, key(f)
由于每一行都携带着产生它的整条路径,一列可以读取模式所绑定的任一变量,而不限于最 后一个:
MATCH (a:Author)-[:WROTE]->(p:Paper) RETURN a.name, p.year
聚合把一组行折叠成一个值:count、sum、avg、min、max,以及 collect
(把该组的各个值收进一个列表)。分组键是所有不是聚合的列——即 Cypher 的隐式
GROUP 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(*) 计的是行数;其余每一种折叠都读取一个值,并跳过取值为 null 的行——也跳过
该折叠无法采用的值,例如某个节点把年份存成了文本。正是这一点,让软模式数据上的聚合
给得出答案,而不是让整条查询失败。若一组什么也没提供,count 与 sum 为 0,
avg、min、max 为 null(零个值的均值是无从作答,而非零),collect 则是空
列表。完全没有分组键的投影,就是整个匹配构成的一个组:因此匹配一无所获时,
RETURN count(*) 答的是 0,而不是没有行。
在投影查询中,DISTINCT、ORDER BY、SKIP 与 LIMIT 作用于投影后的行:
DISTINCT 比较的是整个元组(同处一个文件的两个节点在这里是一行),ORDER BY 指名
的是一列——用它的别名、用它所返回的那个表达式,或用它所折叠的聚合
(ORDER BY count(*) DESC 能找到计数列,无论 RETURN 如何拼写或起了什么别名)。
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
节点不能与列同处一个 RETURN,因为节点不是值:改为返回它的属性。各个界面渲染投影
结果的方式是一致的——给出 columns 与 rows,而非节点与边。
写操作
写子句改动平面,并报告变更计数而非返回行:
| 子句 | 作用 |
|---|---|
CREATE | 创建节点与边 |
MERGE | 匹配一个既有模式,或将其创建 |
SET | 新增或覆盖属性或标签 |
REMOVE | 移除属性或标签 |
DELETE | 删除一个节点或边 |
DETACH DELETE | 删除一个节点及其相连的边 |
CREATE (a:Person {name:"Ada"})
MERGE (b:Person {name:"Alan"})
CREATE (a)-[:KNOWS {since: 1936}]->(b)
值可以通过 $name 参数提供,而不必拼接进查询文本,这样既能保持查询稳定,又省去
了转义。key: 也可以是参数——MERGE (n:Person {key: $k}) 以 $k 解析出的字符串
为键做 upsert(非字符串是错误,而不会变成属性):
MATCH (p:Person) WHERE p.age >= $min RETURN p
MERGE (n:Person {key: $k}) ON CREATE SET n.seen = 1
字面量与标识符
字符串可用任一种引号,并支持常见转义——\'、\"、\\、\n、\t、\r、
\b、\f、\uXXXX——因此含引号的值写在字面量之内,而不会终止它;未知转义或未闭合的字符串
是语法错误。数字是整数(42)或浮点数(3.5、1e9、2.5E-3)。标识符(变量、
标签、类型、属性键)是 Unicode 单词(n.名字、café),或当普通写法拼不出该名字
时,放在反引号之间的任何内容(n.`first name`、(:`order`))。关键字与
函数名不区分大小写(COUNT(*)、Score())。表达式最多嵌套 64 层:括号、NOT、
一元 -,以及 AND/OR/+ -/* / 链中的每个运算符各算一层(因此 64 个合取项
的链是上限);更深是语法错误,而不是崩溃。IN [...] 列表不是链,长度只受查询体
大小限制。
子集不包含的部分
以下各项都会以一条指出改写方式的错误被拒绝,绝不会被悄悄误读:
- 第二个
MATCH、OPTIONAL MATCH、UNION、UNWIND、WITH,以及含多条路径的模式 (MATCH (a)-->(b), (a)-->(c)):一条查询只有一条线性路径、一个RETURN。 按模式或分支分别查询,在调用方合并结果。 - 关系变量(
-[r:KNOWS]->):边无法被绑定、返回或过滤。去掉变量:-[:KNOWS]->。 MATCH节点中的内联属性谓词((n:Person {name: "x"})):写进WHERE(WHERE n.name = "x"或key(n) = "…")。在CREATE/MERGE中同样的写法是节点属性。- 作为值的列表与映射字面量(
RETURN [1, 2]、n.x = {a: 1}):列表只能出现在IN右侧(或作为NEAR的向量),映射只能是CREATE/SET的属性表。 %与^运算符;算术只有+ - * /。- 跨变量谓词(
p.year < q.year)、返回较早变量的行(跳转之后的RETURN p——改为投影p.name),以及无上界的变长跳转(*、*2..)。
语法错误会报告在其成因附近:CREATE 末尾的笔误会指向那一处,而不是语句的第一个词。
查询中的相似度检索
SEARCH 子句将相似度作为行的一个数据源,于是一次语义查找与一次遍历得以在同一条
语句中组合:
SEARCH (d:Doc) ON embedding NEAR "how does time-travel work" TOPK 10 RETURN d
ON <属性> 用于选定向量属性,也可以省略:NEAR 默认取 embedding,也就是文档
摄取流水线写入的那个属性,因此 SEARCH (d:Doc) NEAR "…" 就是它的简写形式。
TOPK <k>
限定结果数量。文本参数(NEAR "…")在服务端被嵌入;字面量向量(NEAR $vec)则无需
任何提供方。此子句编译为一个 VectorTopK 数据源,可从其继续进行遍历:
SEARCH (d:Doc) ON embedding NEAR "time travel" TOPK 10
-[:CITES]->(p:Paper)
WHERE p.year >= 2020
RETURN p
查询中的关键词检索
把 NEAR 换成 MATCHING,同一个动词便检索词语而非语义。它编译为一个
KeywordTopK 数据源,作用于在该 (标签, 属性) 对上声明的 BM25 索引,且每一行都以
score() 携带其相关度:
SEARCH (d:Doc) ON body MATCHING "graph database" TOPK 10
RETURN d ORDER BY score() DESC
此处标签与 ON <属性> 均为必需。关键词索引按 (标签, 属性) 声明,二者缺一便无从
检索;且与向量属性不同,关键词属性并无一个值得作为默认的约定名。与可回退为精确扫描
的向量检索不同,未声明索引时关键词检索返回空结果。
查询中的混合检索
HYBRID 将至多三路排序通道融合为单一次序。各通道均为可选(VECTOR 与 KEYWORD
至少需其一),可携带 WEIGHT,且书写次序任意。每个通道只有其定义性的部分是必需
的:GRAPH 需要 HOPS,另两者需要一个查询。因此 VECTOR NEAR "…" 与
GRAPH HOPS 2 就是简写形式:向量属性默认取 embedding,逐跳衰减默认取 0.5,
这与 RPC、MCP 和命令行的默认值保持一致:
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
这正是第 3 章中 plane.hybrid 检索在语言中的表达,并经由同一套
融合引擎运行:每个通道在加权求和前先做 min-max 归一化,score() 则携带融合后的
结果。
图算法
图算法是在某个平面的单一快照之上进行的、只读且瞬态的计算。它们可作为查询的数据源 使用,因而其输出可继续供给流水线的其余部分:
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[:标签]) 身兼两职:它将算法限定在该标签的诱导子图上(省略标签则为整个平面),
并绑定查询其余部分所引用的变量。所有参数皆为可选,缺省即取引擎自身的默认值;未知的
算法名或参数名会报错,而不会被悄然忽略。
由于行模型只携带一个当前节点,各算法均通过 score 通道报告其逐节点结果:
| 算法 | 行的次序 | score() |
|---|---|---|
pagerank | 最重要者在前 | 该节点的 rank |
components | 按分量分组 | 从 0 起的紧凑分量序号 |
louvain | 按社区分组 | 从 0 起的紧凑社区序号 |
shortest_path | 源点 → 终点 | 该节点在路径上的位次 |
shortest_path 的两个端点以外部键(字符串)或节点 id(整数)给出,另可指定
out/in/both 之一的 dir,以及指向某个数值型边属性的 weight。端点未知或终点
不可达时返回空结果,而非报错。
由于算法本身即数据源,其结果可继续组合:
CALL pagerank() ON (n:Paper)
-[:CITES]->(q:Paper)
WHERE q.year >= 2020
RETURN q ORDER BY score() DESC LIMIT 10
同样这些算法也可以直接以命令调用,此时报告的是原始结果而非行流:
$ 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
时间旅行
任何读取都可以锁定在某个历史时刻——一个提交序号或一个时间戳,看到图在当时的确切 样子。这类读取本质上是只读的:它只是选定查询所读取的快照,不会改动历史。
在语言中,这就是 AS OF 子句,写在最后,读起来就是整条查询之上的一个修饰。它
接受一个提交序号、一个 RFC-3339 时刻,或 TIME 后接 unix 纪元毫秒数,并且对每
一种数据源都适用:扫描、检索种子或算法皆可:
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
同一个历史地址在语言之外亦可获得:作为 RPC 接口与各 SDK 读取方法上的 as_of
(提交序号)或 as_of_ms(时间戳)参数、嵌入式 API 中的 PlaneHandle::as_of(…),
以及仪表盘中的 Time-travel 滑块与历史搜索。所有形式均采用“在此刻或之前”的语义:
介于两次提交之间的取值,解析为不晚于它的最近一次提交。
时间旅行的读取无法使用向量索引与关键词索引,因为它们只按最新提交构建;这类相似度 检索转而扫描被钉住的快照,关键词与混合检索则直接在该快照上计算 BM25——结果依然正确 (匹配与得分都是快照自身的,此后被删除或修改的节点仍按当时的样子被找到),只是不经 索引加速。时间旅行需要原生后端。
可查询的窗口由 plane.history 以最旧/最新提交序号对的形式报告。历史默认无界保留;
可以配置一个保留窗口对其加以限制,此时早于该窗口的读取将被拒绝。
由于每个变更事件都携带其落库时的提交序号,智能体可以读取 as_of(seq) 与
as_of(seq - 1),从而重建任意一次已提交变更的确切前后状态(见第 6 章)。