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

查询语言

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 章)。