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

引言

🌐 English · 中文

Dr Strange 是一个用 Rust 编写的 AI 原生嵌入式图数据库。

它是一个从设计之初就面向 AI 工作负载的图数据库,而不是在传统图数据库上事后加装 AI 功能。嵌入(embedding)是一等值类型,相似度检索与图遍历并行工作;引擎还直接向 智能体(agent)暴露自然语言查询、实时变更流、时间旅行这些原语。

与 SQLite 一样,它是嵌入式的:一个链接进应用的库,背后是单个磁盘上的数据库, 无需运维任何服务。而与 SQLite 不同的是,它也可以对外提供服务:drsg serve 暴露一个 JSON-RPC 2.0 接口、一个浏览器仪表盘,以及一个 WebSocket 变更流,并配有 六种语言的客户端 SDK。

目标读者

  • 应用开发者:构建知识图谱、GraphRAG 流水线或智能体的长期记忆,需要在单一系统 中同时具备图与向量存储。
  • AI 与智能体工程师:期望数据库承担更多工作,比如把问题编译成查询、在变更 提交时主动推送、按需重建历史状态。
  • 读者:对 AI 原生存储引擎的构造感兴趣者。

本书结构

前几章确立基础要点:Dr Strange 是什么、如何构建,以及其"AI 原生"设计意味着什么。 中间几章是查询语言以及各访问面的参考:Web 界面、各语言 SDK、命令行,以及面向 LLM 智能体的 MCP 服务。随后两章覆盖代码智能这一半:解析器插件系统,以及它能为 编码智能体带来什么。最后一章阐述其架构。

状态说明。 本书始于 v1.0 版本,并随功能落地持续更新。

什么是 Dr Strange?

Dr Strange 是一个用 Rust 编写的 AI 原生嵌入式图数据库。这句话包含三项主张, 下文逐一说明:它存储的是图,它是嵌入式的,它是 AI 原生的。所谓 AI 原生,是指它从设计之初就面向 AI 工作负载,而非事后加装。

数据模型:以"平面"组织的属性图

Dr Strange 存储的是一张属性图(property graph):

  • 节点(Node) 是实体,比如一个人、一篇文档、一件商品。每个节点都有一组 标签(label,即它的类型,如 Person、Doc),以及一段自由形式的 属性(property) 映射。
  • 边(Edge) 是节点之间有向、带类型的关系,例如 (alice) -[:KNOWS]-> (bob)。 边同样可以携带属性。
  • 属性 是带类型的值:字符串、数字、布尔、字节块、列表、嵌套映射,以及至关 重要的向量(vector,即嵌入)。向量是一等值类型,而非事后才补上的功能。

节点除了引擎内部的数字 id,还可以携带调用方提供的 外部键(external key): 一个稳定、对人友好、在所属平面内唯一的标识。

Dr Strange 按 平面(plane) 组织数据:平面是存在于同一个数据库文件中、彼此 独立的具名图命名空间。平面就像 schema 或 namespace,你可以为每个项目、每个租户 或每次实验各用一个平面。id 在整个数据库范围内全局唯一,但每一次查询都只在某一个 平面的上下文中执行,因此各平面之间保持干净的隔离。一个全新的数据库总是包含一个 startup 平面。

"AI 原生"的主张

多数为数据库添加 AI 能力的做法,是在传统引擎上附加一列向量,再外呼一个模型服务。 Dr Strange 反过来:AI 应用所需的能力,本身就是引擎的核心特性。

  • 嵌入是一等公民。 向量和其它属性值一样用 HNSW 原生建立索引,相似度检索因此 既快,又运行在查询引擎内部,而不是外部服务里。
  • 检索融合多路信号。 向量相似度、BM25 关键词相关性、图上邻近度会融合成一个 统一的排序结果,这正是 GraphRAG 的核心。
  • 引擎与智能体对话。 用自然语言提问,拿回一个查询计划并执行它,就能得到一张 连通的子图;订阅一个平面,变更一提交就能实时收到;还能读取图在任意历史时刻的 样子。

一个具体例子:GraphRAG

假设你已经把一批文档导入为图:Doc 节点带有 embedding 属性,通过 CITES、 MENTIONS 边连到 Concept 节点。一次 GraphRAG 查找就是一条查询:

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

它先把问题嵌入为向量,检索出最相似的五篇文档;由于结果本身就是图节点,遍历可以 直接从这里继续,进入它们提及的概念、找到引用它们的文档,组装出纯向量库单凭自身 无法企及的扎实上下文。

Dr Strange 的定位

  • 像 SQLite 一样,它是嵌入式的:一个库加一个磁盘上的数据库,无需运行任何 服务。需要仪表盘、HTTP/WebSocket 接口和多客户端访问时,也可以额外启动一个服务 (drsg serve)。
  • 不同于传统图数据库(如 Neo4j),向量与混合检索内建于核心,整套接口也是 围绕智能体设计的:自然语言、变更流、时间旅行。
  • 不同于纯向量数据库,它的存储单位是一张图,得到的是关系、遍历与图算法, 而不仅仅是最近邻。

对于围绕知识图谱、GraphRAG 流水线或智能体长期记忆构建的应用,Dr Strange 意在 作为容纳这一切的单一存储。

特性一览

能力它带给你什么
平面(Planes)一个数据库中容纳多张相互独立的图
一等嵌入向量属性,原生 HNSW 索引
混合检索向量 + 关键词(BM25)+ 图邻近度的融合检索
查询语言可序列化的逻辑计划,以及一套 openCypher 子集
图算法PageRank、连通分量、最短路径、Louvain
自然语言查询用自然语言提问 → 生成计划 → 执行
时间旅行读取图在某个历史提交或时间戳时的样子
变更流订阅一个平面,实时接收其变更
代码图化沙箱化的 wasm 解析器插件将代码仓库转化为已解析的调用图(官方支持 8 种语言)
提交同步监视serve watch 将每次提交折叠进平面
智能体工具context · search · describe · grep · trace · impact · fathom · snippet · history · recall · wire,每个问题一次往返
备份 / 恢复一致、保 id 的整库快照
只读副本serve --follow 镜像一个正在运行的服务,用于跨集群扩展读取
访问方式Web 界面、六种语言 SDK、命令行,以及 MCP 服务

v1.0 意味着什么

v1.0 是一个完整的、嵌入式的、AI 原生的图数据库,具备上述全部能力:一个存储引擎 (一个手写的 LSM,另有一个遗留可选项),以及一套稳定的、SDK 由其生成的 JSON-RPC 线协议。接下来的各章将带你使用它的每一个部分。

v2.0 提供了什么

v2.0 把代码仓库自身的源码树也变成了一种图。沙箱化的 wasm 解析器插件把源文件解析 为一张已解析的调用图,包含符号、带调用位置的调用者,以及其它每一种结构性边,全程 无需模型参与,开箱即支持八种语言。serve watch 让生成的平面随代码仓库自身的提交 历史保持同步,每次提交落地就把这次的变更折叠进图中。十二个智能体动词——context、 search、describe、grep、trace、impact、fathom、snippet、history、 deployed、recall、wire——把这张图变成编码 智能体一次往返即可拿到的答案,每一个都会说明自己结果的边界:给出候选清单而不是 猜一个匹配,用 UnresolvedRef 台账条目代替一条悄悄出错的边。drsg init 把这 一切合并成一条命令,一次做完图化、监视,以及为当前使用的宿主工具写入 MCP 配置。 插件与编码智能体两章详细展开了这部分内容。

快速上手

本章介绍如何安装 Dr Strange(用发行版二进制或从源码构建均可)、如何初始化数据库、 在命令行中发起查询,以及如何运行服务,包括本地运行和以容器镜像方式部署。

环境准备

安装发行版二进制除 curl(Windows 上为 PowerShell)之外别无所需。以下准备仅适用于 其他方式:

  • 从源码构建:当前的 Rust 工具链(stable 通道),经 rustup 安装。
  • Web 仪表盘:bun 编译单页应用,以及可选的 just 作为任务运行器。
  • 容器工作流:Docker(Engine 24+;仅在自行构建镜像时才需要 BuildKit)。

构建通过 rustls/ring 链接 TLS,无需 OpenSSL 工具链。

安装发行版二进制

每个打了标签的发行版都会发布 Linux、macOS 与 Windows 的二进制。安装脚本会选取与宿主 平台匹配的归档包,校验其发布的 SHA-256,并将二进制置入 PATH。可安装的二进制有两个: 命令行工具与服务端 drsg,以及面向 LLM 智能体的 MCP 服务 drsg-mcp(第 8 章)。

Linux

# 命令行与服务端 —— drsg
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh

# MCP 服务 —— drsg-mcp
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp

macOS(脚本相同,Apple 芯片与 Intel 版本均有发布)

# 命令行与服务端 —— drsg
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh

# MCP 服务 —— drsg-mcp
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp

Windows,在 PowerShell 中执行。第二种写法将脚本作为代码块运行,因为以管道送入 iex 的脚本无法接收参数。

# 命令行与服务端 —— drsg
PS> irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1 | iex

# MCP 服务 —— drsg-mcp
PS> & ([scriptblock]::Create((irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1))) -Bin drsg-mcp

三个选项可调整安装行为,每个都有对应的环境变量,便于在非交互场景中使用:

选项(Windows)环境变量作用
--bin drsg-mcp(-Bin)DRSG_INSTALL_BIN安装哪个二进制——drsg(默认)、drsg-mcp 或 all
--version v1.1.0(-Version)DRSG_VERSION指定某个发行版本,而非最新版
--dir <path>(-Dir)DRSG_INSTALL_DIR安装目录

安装目录默认为 ~/.local/bin,Windows 上为 %LOCALAPPDATA%\Programs\drsg\bin,且脚本 会将该目录加入用户 PATH。在 Linux 与 macOS 上,若该目录尚不在 PATH 中,请将其写入 shell 配置:

$ export PATH="$HOME/.local/bin:$PATH"

升级

drsg update 用与安装脚本相同的方式解析最新发行版本——走 releases/latest 的 重定向,而不是对未认证调用者有速率限制的 API——再与当前运行的构建比较。无事可做 时它会明说并停下:

$ drsg update
drsg 2.27.1 is the latest release — nothing to do

确有新版本时,它会先打印即将执行的命令,然后变成它:进程被首次安装所用的同一 个安装脚本替换,因此退出码就是安装脚本自己的退出码,也不会留下一个父进程等在一个 刚刚被覆盖掉的二进制里。

$ drsg update
drsg 2.27.0 -> 2.27.1
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/v2.27.1/scripts/install.sh | sh -s -- --bin 'drsg' --version 'v2.27.1' --dir '/home/me/.local/bin'
Dr Strange v2.27.1 (x86_64-unknown-linux-gnu)
  downloading dr-strange-v2.27.1-x86_64-unknown-linux-gnu.tar.gz
  checksum verified
  installed /home/me/.local/bin/drsg

两个阶段都要穿过网络,也都遵循代理设置:版本检查是 drsg 自己发出的请求(参见 在 HTTP 代理之后工作),而它随后交棒的安装脚本由 curl 执行,读取的是同一批变量。在需要代理的网络里,最先失败的是版本检查,因此一次 够不到 GitHub 的 update 根本走不到安装脚本那一步。

它运行的安装脚本是随所装版本一起打了标签的那一份,而非 master 上的副本, 并把该版本作为 --version 一并传入:校验归档的脚本就是与它一同审阅、一同发布的 脚本,移动分支改变不了升级所执行的内容。两个安装脚本都要求归档的 .sha256 校验文件——缺失、格式不对或不匹配都是硬性失败,没有 sha256sum/shasum 可用 也是。--insecure-skip-checksum(PowerShell 中为 -InsecureSkipChecksum;两者 及 drsg update 均可用 DRSG_INSECURE_SKIP_CHECKSUM=1)会带着警告照装不误:它 是给不发布校验文件的镜像用的,名字已说明代价。

安装目录取的是正在运行的这个二进制所在的目录,而不是安装脚本的默认值——升级必须 替换 PATH 上的那一份,而不是在别处放一份更新的、让旧的继续被运行。若 drsg 装在无写权限的位置,用 --dir 覆盖。同一目录下若装有 drsg-mcp,它会随 drsg 一并更新,无需另行指定——两个二进制属于同一个发行版本,若智能体宿主拿上一版的 服务端去对接这一版的 drsg,没有任何东西会提醒它。不想这样时,用 --bin 指明 要更新的对象:drsg、drsg-mcp 或 all。

比最新发行版本更新的构建——来自源码,或来自最后一个标签之后的分支——会被告知 自己在前面,不会安装任何东西:update 从不回退。在 Windows 上则什么都不会运行, 因为正在运行的可执行文件被锁定、无法被覆盖;它转而打印出可在新终端里粘贴执行的 命令。

归档包及其校验和也可以从发行页 直接下载。安装脚本只是对同一批产物的便捷封装,两个脚本都放在 scripts/,可以在 使用前自行查看。

从源码构建

Dr Strange 是一个 Cargo 工作区。编译命令行二进制 drsg:

$ cargo build --release -p drsg

产物为 target/release/drsg。默认构建选用原生 LSM 存储引擎;遗留的 redb 后端仍可 通过特性开关启用。

Web 仪表盘由 web crate 的构建脚本在编译期嵌入二进制。若要嵌入编译好的仪表盘而非占位 页,请在构建二进制之前先编译单页应用:

$ just web-build          # bun install && vite build
$ cargo build --release -p drsg

面向 LLM 智能体的 MCP 服务(第 8 章)是一个独立的二进制 drsg-mcp:

$ cargo build --release -p dr-strange-mcp

产物为 target/release/drsg-mcp。将其置于 PATH 中,或在宿主配置里以绝对路径引用它。

磁盘布局

--db 参数用于选择数据库路径。在原生后端下,数据库是一个目录,里面存放着 预写日志(WAL)与有序的 SST 文件,此外还有两个存放检索索引的伴生文件:

graph.drsg/          数据库(WAL + SST 文件)
graph.drsg.hnsw      向量索引伴生文件
graph.drsg.bm25      关键词索引伴生文件

数据库在首次访问时创建,无需单独的初始化步骤。

创建一张图

先创建一个平面,再插入数据。openCypher 子集会编译为引擎直接执行的同一个逻辑计划:

$ drsg --db graph.drsg plane create social

$ drsg --db graph.drsg cypher --plane social \
    'CREATE (a:Person {name:"Ada"}),
            (b:Person {name:"Alan"}),
            (a)-[:KNOWS]->(b)'

查询结果:

$ drsg --db graph.drsg cypher --plane social \
    'MATCH (p:Person)-[:KNOWS]->(q:Person) RETURN q'

检视所得数据的形态与软 schema:

$ drsg --db graph.drsg stats
$ drsg --db graph.drsg catalog --plane social

向量索引与相似度检索

向量是普通的属性值。在某个 (标签, 属性) 对上声明索引,然后对其发起查询。对文本 查询做嵌入是在服务端执行的,因此进程环境中需要一个提供方密钥(例如 OPENAI_API_KEY)。针对字面量向量的查询则不需要任何提供方。

$ drsg --db graph.drsg index ensure Doc embedding --plane social

$ OPENAI_API_KEY=… drsg --db graph.drsg cypher --plane social \
    'SEARCH (d:Doc) ON embedding NEAR "a friendly greeting" TOPK 5 RETURN d'

由于结果均为图节点,可从其继续遍历——即第 1 章介绍的 GraphRAG 模式。

运行服务

$ drsg --db graph.drsg serve

此命令启动 JSON-RPC 2.0 接口、WebSocket 变更流以及内嵌的仪表盘,并报告所绑定的 地址(默认 127.0.0.1:7700)。

鉴权。 未配置令牌时,只有同源的浏览器界面可以调用接口。若要允许来自 SDK 或 curl 的程序化访问,请配置一个共享令牌,并以 Bearer 凭据形式携带:

$ DRSG_TOKEN=please-change-me drsg --db graph.drsg serve

监听回环以外的任何地址时令牌是必需的:未设置 DRSG_TOKEN(或 [server] token) 的 drsg serve --addr 0.0.0.0:7700 会拒绝启动,因为离开回环后同源检查不再构成任何 保护。在这类地址上,提供的仪表盘页面也不携带令牌——浏览器每个标签页会询问一次 (见 Web 界面一章),且仪表盘自身的来源须列入 allowed_origins。HTTP 上以 Authorization: Bearer <token> 发送令牌,WebSocket 升级请求也优先如此;/ws 上的 ?token= 仅为无法设置请求头的浏览器而设,查询字符串往往会被代理和浏览器记录。

连续五次以上出示错误令牌的对端会收到 429 与一个逐次加倍、至多五分钟的 Retry-After;一次正确的令牌即清除它。

经由 API 指定提供方时,名字只能是某个预设,或恰好是服务端已配置的那个 embed_provider;base URL 只从 CLI 与配置文件接受,绝不从请求接受。

配置文件

服务、日志与提供方设置也可以通过一个 TOML 配置文件提供,而非逐个使用命令行标志与 环境变量。配置文件的解析顺序为:--config <路径>,其次 $DRSG_CONFIG,再次为 当前目录下的 ./drsg.toml(若存在)。系统会拒绝未知的键。

[server]
addr = "0.0.0.0:7700"                       # 监听地址(命令行 --addr 覆盖此项)
token = "please-change-me"                  # 共享 API 令牌(→ DRSG_TOKEN);会成为进程环境变量,建议改为在启动环境中导出
max_concurrent = 1024                       # 并发请求上限
write_timeout_secs = 30                     # 一次写等待单写者槽位多久后以可重试的超时失败(0 表示一直等)
query_timeout_secs = 60                     # 一次请求内的查询可运行多久后以同样的方式停止(0 表示跑到结束)
retain_commits = 20                         # 时间旅行可回溯的提交数;更早的版本在压缩时回收(0 表示全部保留)。对每个打开数据库的 drsg 命令生效,不只是 serve
source_root = "/srv/myrepo"                 # grep/snippet 智能体工具读取的源码树(serve watch 会用 --dir 设置它)
mount_root = "/srv/repos"                   # `drsg mount` 可以从哪一个目录下打开数据库(默认:你的主目录);该目录内平面所记录的树也因此可被 grep/snippet 读取
allowed_origins = ["https://app.example.com"]  # 额外允许的浏览器来源
# 把 /mcp 放到域名后面:DRSG_ALLOWED_HOSTS=memory.example.com(环境变量;仅在设置了 token 时生效,见 MCP 一章)

[server.tls]                                # 存在此节 ⇒ 以 HTTPS 提供服务
cert = "/etc/drsg/cert.pem"                 # PEM 证书链
key  = "/etc/drsg/key.pem"                  # PEM 私钥

[logging]
dir = "/var/log/drsg"                       # 滚动日志文件所在目录

[llm]                                       # 提供方密钥,导出至进程环境
OPENAI_API_KEY = "sk-…"
DEEPSEEK_API_KEY = "…"
DASHSCOPE_API_KEY = "…"
DRSG_CHAT_OMIT = "temperature,max_tokens"   # 可选:对话请求中省略的字段,供拒绝它们的模型使用

[digest]                                    # 服务端 AIgest 调优
concurrency = 8                             # 并发进行的分块抽取调用数
chunk_chars = 4000                          # 目标分块大小
embed_provider = "openai"                   # search / write_nodes / watch 重新向量化所用的嵌入提供方
embed_model = "text-embedding-3-small"      # 其模型(各提供方均有默认值)
embed_key_env = "OPENAI_API_KEY"            # 存放密钥的环境变量
ignore_files = ["gitignore"]                # 由谁的忽略文件决定什么是源码;[] 表示两者都不读
tracked_only = false                        # 置为 true 则只读取 git 已跟踪的文件
code_only = false                           # 置为 true 则只读取处理器认领的文件(不含 Markdown/PDF)

[plugins]                                   # 预处理沙箱调优(均可省略)
fuel = 200000000000                         # 每次沙箱调用的指令预算(0 为不设限)
memory_mb = 3072                            # 每次调用的线性内存上限,MiB;按照 wasm32 标准,最高支持 4096
deadline_secs = 300                         # 单次调用的墙钟上限,秒(0 为不设限)
total_memory_mb = 6144                      # 进程内所有调用合计可持有的线性内存,MiB(0 为 memory_mb 的两倍)
hostcall_mb = 1024                          # 单次调用最多能从沙箱拷出的数据量,MiB(0 为 wasmtime 默认的 128)

[fetch]                                     # URL 导入(第 3 章)
enabled = true                              # 置为 false 则彻底拒绝 URL 抓取
max_pages = 10                              # 单次爬取保留的页数上限
max_depth = 3                               # 请求可申请的沿链接深度上限
concurrency = 4                             # 并发请求数
allow_private = []                          # 见下文——通常保持为空

[network]                                   # 本程序自身发出的请求走向何处
proxy = "http://127.0.0.1:7897"             # 也可为 socks5://…;被 ALL_PROXY / HTTPS_PROXY / HTTP_PROXY 覆盖
no_proxy = ".internal, 192.168.1.10"        # NO_PROXY 形式的绕行清单;被 NO_PROXY 覆盖

[fetch] 改变的是服务端的网络姿态,在启用其中任何一项之前值得先读一读。当 URL 导入处于开启状态(默认如此)时,客户端可以指定一个地址,而后由服务端去连接它。 服务端在网络中所处的位置通常更为特权,因此系统会拒绝一切不可路由的地址:回环地址、 RFC-1918 私有网段、链路本地地址(169.254.0.0/16,云实例元数据服务在此应答凭据) 等等。检查针对的是解析之后的地址而非主机名,并在每一次重定向跳转时重新执行; 一个内部携带 IPv4 地址的 IPv6 地址(v4-mapped、6to4、Teredo、NAT64 的 64:ff9b::/96)按那个 IPv4 地址判定。

allow_private 用来重新放行特定的 CIDR 网段(例如设为 ["10.0.0.0/8"],以便 读取内网 wiki)。它是上述规则唯一经过深思的例外,而不是关闭这层防护的开关: 暴露给不受信任客户端的服务端应当让它保持为空。若要完全拒绝 URL 抓取,请设置 enabled = false。

在 HTTP 代理之后工作

[network] 管辖的是 drsg 代表你发出的请求:drsg plugin install、 drsg update、drsg digest <url>、LLM 提供方,以及副本拉取快照。可以写在配置 文件里,也可以导出 ALL_PROXY、HTTPS_PROXY 或 HTTP_PROXY 中的任意一个(每个 同时按小写形式读取)。socks5:// 与 http:// 同样受支持。

其中有三条规则值得了解,因为每一条都是经过权衡的决定,而非偶然:

由调用方指定的 URL 绝不走代理。 JSON-RPC 上的 /digest/fetch 与 plugin.install 接受调用方给出的 URL,而上文所述的地址防护正是用来阻止这个 URL 指向服务端自身的回环地址或云元数据端点。代理会代替服务端解析名字并建立连接,防护 因此再无地址可供判断——它在代理之下无法生效,drsg 也不会假装它仍然生效。这些请求 保持直连并受防护约束。当配置了代理时,drsg serve 会在启动时说明这一点。

回环地址永不走代理,无论如何配置。本机模型服务——ollama 预设即 http://localhost:11434/v1——并不是任何人为访问外网而设置 https_proxy 时想要 改道的目标。除此之外的地址一律遵循配置;只有 localhost 与回环网段是自动的。

环境变量优先。 已设置的变量会覆盖配置文件,因此无需改动任何文件即可让单条命令 走另一个代理:

https_proxy=http://127.0.0.1:1080 drsg plugin install ts   # 仅本次运行
https_proxy= drsg plugin install ts                        # 已设置但为空:直连

no_proxy 接受 NO_PROXY 形式的清单,以逗号或空格分隔:精确主机名、.suffix 或 *.suffix 通配、IP 字面量、带端口的条目(example.com:8080),或用 * 表示 全部。不支持 CIDR 网段。环境中的 NO_PROXY 会替换配置文件中的清单,而不是与 之合并。

优先级是固定的:进程中已设置的环境变量始终优先于配置文件中对应的取值,而 --addr 标志覆盖 [server].addr。提供 [server.tls] 会将服务切换为 HTTPS。

只读副本(serve --follow)

以 --follow 启动的第二个 drsg serve 会以只读方式镜像一个正在运行的实例—— 用于在一组智能体之间扩展读取,而无需让每一次查询都挤过同一个进程:

$ drsg --db replica.drsg serve --addr 127.0.0.1:7701 \
    --follow ws://master-host:7700 --follow-token please-change-me

无论令牌是否有效,任何写入 RPC 都会被拒绝。启动时——以及此后每次断线重连 时——副本都会从主库的 /snapshot 端点拉取一份完整、一致的快照,随后跟随其 /ws/wal 获取后续提交;每次重连都会从零开始重新同步(arch/01 §9),因此 --db 必须指向一个空目录,或指向这个副本自己曾经使用过的目录(由一个 .drsg-follower 标记文件记录)——除此之外的目录会被拒绝,而不是被静默清空。

--follow-token(或 DRSG_FOLLOW_TOKEN)是提交给主库的凭据,与这个副本自己 用来约束其下游客户端的 DRSG_TOKEN 相互独立。当主库的地址不是回环地址时, --follow 应当指向 wss:// 而非 ws://——否则承载令牌的连接会以明文传输, 这与 [server.tls] 对入站连接的要求是同一个道理。

容器镜像

一个多架构镜像(linux/amd64 与 linux/arm64)已发布至 GitHub 容器镜像仓库 (GHCR)。直接拉取并运行,无需构建:

$ docker run -p 7700:7700 -v drsg-data:/data \
    -e DRSG_TOKEN=please-change-me \
    ghcr.io/wangyingsm/dr-strange:latest

docker run 会在首次使用时拉取镜像。若需可复现的部署,请以版本标签 ghcr.io/wangyingsm/dr-strange:1.0.2 替代 :latest。运行时镜像绑定到 0.0.0.0:7700,数据库存放在 /data 卷中(原生后端下数据库是一个目录,这个卷 负责把它持久化保存)。提供方密钥通过环境变量传入。

容器中 必须 设置 DRSG_TOKEN:镜像绑定 0.0.0.0,而 drsg serve 在没有令牌时 拒绝非回环绑定,不会把 API 与仪表盘开放给任何能连到该端口的人(容器会退出并 打印指出该变量的消息)。所有客户端都出示这个令牌——SDK 与 curl 作为 bearer, 仪表盘通过它的提示框。在 Docker 宿主机上以 http://localhost:7700 打开仪表盘 无需其他设置;浏览器若以其他名字访问容器——http://graph.example:7700——则该 来源必须列入 DRSG_ALLOWED_ORIGINS(逗号分隔,或 [server] allowed_origins): 离开回环后页面不再携带令牌,未列出的来源会被当作跨站请求拒绝。

对于持久化部署,docker-compose.yml 拉取同一镜像并定义了一个具名卷。它把 DRSG_TOKEN 标记为必填,未设置时 docker compose up 会带消息停下,而不是启动一个 随即退出的容器;DRSG_ALLOWED_ORIGINS 设置了就会透传:

$ DRSG_TOKEN=please-change-me docker compose up
$ DRSG_TOKEN=please-change-me DRSG_ALLOWED_ORIGINS=http://graph.example:7700 docker compose up

若想改为在本地构建镜像,仓库提供了一个多阶段 Dockerfile:它会编译仪表盘、将其 内嵌进二进制,并产出一个精简的运行时镜像:docker build -t dr-strange:latest .。

下一步

  • 第 3 章 —— AI 原生: 嵌入、混合检索、自然语言查询与文档导入。
  • 第 4 章 —— 查询语言: openCypher 子集及其底层的逻辑计划。
  • 按访问方式:第 6 章 —— SDK(应用代码)、第 7 章 —— 嵌入式命令行(运维)、 第 8 章 —— MCP(LLM 智能体)。

AI 原生

本章介绍让 Dr Strange 成为一个为 AI 工作负载而设计的数据库的那些能力:原生的 向量与关键词索引、融合式混合检索、自然语言查询,以及 LLM 驱动的文档导入。时间 旅行与变更流对智能体工作负载同样关键,分别在第 4 章介绍, 并在本章末尾引用。

向量属性与 HNSW 索引

取值为向量的属性就是一个嵌入。引擎会为它原生建立索引,因此相似度检索发生在查询 引擎内部,而不是外部服务里。

索引在某个 (标签, 属性) 对上声明一个距离度量。凡带有该标签、又带有该向量属性的 节点,引擎都会为其构建一个 HNSW 索引,并在 每次写入提交时保持索引与数据一致:

$ drsg --db graph.drsg index ensure Doc embedding --plane social
$ drsg --db graph.drsg index ensure Doc embedding --plane social --metric dot

度量可取 cosine(默认)、dot 或 l2。声明是幂等的,但如果用不同的度量重复 声明同一个对,就会报错。构建好的索引持久化在数据库旁的 .hnsw 伴生文件中,并 在打开时重新加载。

查询通过 SEARCH … NEAR … TOPK 形式使用索引。如果没有声明索引,引擎会回退到 精确的暴力扫描,这也正好用作校验索引结果的基准(oracle)。

关键词(BM25)索引

词面相关性是语义相似度的补充。关键词索引声明在某个 (标签, 属性) 对上,属性 取值为字符串,索引按 Okapi BM25 对文档排序:

$ drsg --db graph.drsg index keyword Doc body --plane social --lang english

每个索引都指定一个分析器语言(english/en、french/fr 等 Snowball 支持 的语言),用来决定使用的词干还原器和停用词表。建立索引前,字段值会先转小写、 分词、去停用词,再做词干还原。构建好的索引持久化在 .bm25 伴生文件中。

混合检索

有效的检索很少只依赖单一信号。混合检索最多组合三路信号,并返回一个排序结果:

  • 向量:基于某个已声明向量属性的嵌入相似度。
  • 关键词:基于某个已声明关键词属性的 BM25 相关性。
  • 图:从最强的向量/关键词命中点向外扩展的邻近度,随跳数衰减。
$ drsg --db graph.drsg hybrid "how does time-travel work" \
    --plane social --label Doc \
    --vector embedding --keyword body \
    --graph-hops 1 --k 10 --embed openai

每一路信号先贡献一个候选集,各自的分数在本信号内做 min-max 归一化,再按加权和 合并(默认权重:向量 1、关键词 1、图 0.5),最终返回前 k 个。要启用某一路 信号,指定它的属性即可;图信号则由 --graph-hops 启用。关键词信号还需要一个 标签,因为 BM25 索引本身就是按标签划定范围的。

自然语言查询

plane.ask 把一个自然语言问题编译为查询、执行它,并返回一张连通的子图。模型 依据该平面的软 schema(标签、属性类型,以及边类型之间的连接关系),生成一个 可序列化的逻辑计划。

$ drsg --db graph.drsg ask "which companies does Ada work for?" \
    --plane social --chat deepseek --embed qwen

其过程是一个智能体循环,而非一次单独的调用:

  • 分解。 把复合问题拆分成若干独立的子问题,每个子问题对应一个计划。
  • 有据的工具。 模型可以调用两个基于嵌入的工具 find_edge 和 find_entity, 按相似度检索该平面自身的边描述与实体,从中选出真实存在的边类型和种子节点, 而不是凭空猜测。
  • 修复。 解析或执行失败的计划会连同错误一起返回给模型,进行有限次数的修复 尝试。

查询是只读的:逻辑计划没有写算子,因此 ask 无法改动图。--dry-run 只 返回生成的计划而不执行,结果中还带有一份工具调用与修复过程的轨迹(trace), 供检视。

文档导入(AIgest)

导入把非结构化文本转化为图结构:先把文档分块,LLM 从每个块中抽取实体和带类型的 关系,抽取结果再经过清理(见下);实体被嵌入向量;某个实体如果命中已有节点就 链接过去(避免重复),否则新建节点;最终通过批量写入路径落库。

# 仅预览(默认行为):报告将要写入的实体与关系。
$ drsg --db graph.drsg digest notes.md --plane social --chat openai --embed openai

# 提交此次抽取。
$ drsg --db graph.drsg digest notes.md --plane social --apply

抽取精度

各个块是彼此独立抽取的,没有机制促使结果收敛:同一类事物可能得到多个标签,同一 种关系可能得到多个边类型,同一个实体可能得到多个名字。而且块是按位置合并的, 一个实体的属性以最先提到它的那个块为准,之后即便有更完整、更准确的提及, 也会被丢弃。

抽取之后有三道清理流程,不能逐项单独配置:一个选项决定运行到第几道,因为它们 构成一条成本递增的序列:

--mode流程所换取的
coarse词表归一一类事物一个标签,一种关系一个边类型
fine(默认)+ 身份消解一个实体一个节点
super+ 逐实体精修属性取自全篇文档
  • 词表归一规范化标签集合与边类型集合。它处理的是集合,所以无论文档多长, 模型调用次数都是常数;只在大小写或分隔符上有差异的名字,不需要模型就能归并。
  • 身份消解合并指称同一事物的实体,将边的端点改写到留存者上,并折叠由此产生的重复 边与自环。它还会以精确匹配核对每个 key 是否已存在于平面中,因此重新导入同一篇文档 是链接而非再造。
  • 逐实体精修把每个实体和提及它的所有段落及相关关系一并重读,在完整图景 之下重新确认它的属性。若某实体除产生它的那个块之外再无新内容可读,则直接 跳过、不产生调用。这道流程专门用来修复「首块获胜」的问题,代价也相应更高: 输入 token 约为 fine 的 15 倍,因为每个符合条件的实体都要带着自己的 段落,单独发起一次请求。

名字只要被规范化或合并过,文档原本的写法就会以 _label_as_written / _type_as_written / _key_as_written 保留在旁边。这些下划线前缀的属性属于 溯源信息,不会出现在模型读取的 schema 摘要里,所以保留别名不会增加读路径的 开销,同时也让文档自身的措辞始终可以追溯回去。

各个入口都可导入文档:命令行、MCP 的 digest 工具,以及仪表盘的 AIgest 页。 Word、PowerPoint、Excel、OpenDocument、RTF、EPUB、CSV 与 PDF 会先转换为 Markdown,并在 写入前预览拟议的实体与关系。提交预览时不再产生任何额外的模型调用。

从 URL 读取

文档可以用地址指定,而不必上传。服务端抓取该页面,将其转换为 Markdown,在预算 范围内沿其链接继续抓取,并组装成一篇文档。此后它和直接粘贴进来的文档走完全 相同的导入流程。

$ drsg --db graph.drsg digest https://example.com/paper \
    --plane papers --topic "attention mechanism" --pages 6 --apply

一个页面的出链是一份经过挑选的参考书目:作者已经替我们判断过什么与之相关。困难 在于这些链接同时也指向 cookie 政策与导航栏,因此相关性要判定两次。跳数本身 决定不了相关性:深度衡量的是爬取走了多远,而不是某个页面是否真的切题。

  • 抓取之前,每个链接只依据手头已有的信息评分:锚文本、title 属性,以及 URL 路径 中的词。不联网,不调用模型。只有得分最高的候选才值得一次请求。
  • 抓取之后,再依据页面的实际正文重新评分;名不副实者被丢弃,代价恰好是一次请求。

两者使用与 BM25 索引相同的分析器,因此链接中的 “transformers” 能够匹配目标词 “Transformer”。--topic 会锐化目标;若留空,则以页面自身的主题作为爬取的目标。 跳数衰减只保留了一个作用:评分打平时优先选择离根页面更近的页面。

每个页面的地址都记录在文本之中,且页面边界必定开启一个新的块,因此任何一个块都不会 混入两篇文档:

<!-- drsg:source https://example.com/paper -->

# Attention Is All You Need
…

真正起控制作用的是各项预算:页数、深度、单次响应大小、总下载量、耗时。只要 被预算刷掉,就一定会被报告——命令行会打印保留了什么、丢弃了什么,仪表盘则以 带勾选框的列表呈现同样的内容。

URL 抓取默认启用,但默认不具备触及私有网络的能力:服务端会拒绝连接回环地址、 私有地址,以及链路本地地址(169.254.0.0/16,云实例元数据服务正是挂在这里) 等一切不可路由的地址;检查针对的是解析后的地址而不是主机名,并且每次 重定向跳转都会重新检查一遍。如果运维者确实需要读取内网资源,可以精确地重新 放行某个网段——这是唯一的例外,做法见 第 2 章。爬虫会遵守 robots.txt,表明自己的 身份,并且在对同一主机的请求之间留出间隔。

已知限制:如果页面正文是由浏览器里的 JavaScript 组装出来的,抓取到的就只是一具 没有正文的空壳,因为本项目不包含无头渲染器。

读取代码库

把 digest 指向一个目录,它会遍历整棵树,把每个文件交给能处理它的处理器。源码 文件是被解析的,不是被阅读的:拿到的是编译器级解析器所确知的事实,而不是 模型对文本的理解。

解析器是插件——安装一次、随处可用的沙箱化 WebAssembly 组件:

$ drsg plugin install https://github.com/…/releases/download/rust-v2.0.0/rust.wasm
installed rust@3  sha256:fdb01bb6967f
  handles: .rs

安装时会锁定制品的 SHA-256,并在每次加载时重新校验:磁盘上的文件如果被改动过, 就会被拒绝加载,而不是被悄悄运行。沙箱内插件能碰到的只有三件事:列出文件、 读取文件、查询这棵树的名字,而且全部以被导入的目录为根,按解析后的路径检查。 沙箱里没有文件系统,没有网络,没有环境变量,时钟被冻结,指令数和内存都有硬性 预算(drsg.toml 的 [plugins] 节)。哪怕一个组件只是声明导入了文件系统接口, 安装时也会被指名拒绝。

$ drsg --db graph.drsg digest ./crates/dr-strange-core/src --plane code --apply
preprocessed by rust@3 (5528 facts)
no prose left to read — digested without a model call
  0 chat request(s); tokens 0 in / 0 out / 0 embed
applied: wrote 1139 nodes, 2739 edges

请重读第三行:当时没有设置任何 API key,也没有发出任何请求。 语法树不需要 靠推断才知道 parse() 调用了 lex()——这本来就是它已经知道的事。把文件当散文 交给模型,只是在花 token 换一个比手头已有答案更差的结果。凡是解析器已经确知 的地方,就不会再去问模型。

每个处理器都带着自己的一套词汇,这套词汇由处理器本身固定下来,不是针对每份 文档临时发明的——这正是事后不需要再做调和的原因,也是某个处理器产出的标签 与属性要到该处理器自己那里查、而不是记录在这里的原因。想了解细节,请查阅 你正在使用的那一个;drsg digest --handler <name> 就是用来指定它的名字的。

每一条事实都带有 _generated_by,其中记有处理器的名字与版本,因此解析所得的事实与 模型的猜测始终可以区分。当两者认领同一个 key 时,以解析器为准,模型给出的那一份 被丢弃并计数。

上例指向的是 src/ 而非 crate 根目录,这正是「不调用模型」与「调用模型」之间的分 界:crate 根目录下有 Cargo.toml,通常还有 README,而它们确实是需要阅读的散文。当 指向某个 src/ 目录时,crate 仍会以其上层目录命名,因此导入同一 plane 的两个 crate 依然是两个 crate。

真实的代码库不止一种语言,所以整棵树按文件逐个路由:Rust 交给解析器解析, Markdown 与配置文件变成交给模型的散文,读不了的文件则跳过并计数。解析器 拿不准的部分会被报告出来,不会被悄悄略去:调用另一个 crate 的函数不构成边, 一个同时匹配两个函数的名字则会被留白,而不是靠猜:

note: 553 call(s) named nothing defined here — calls into other crates and
      the standard library are not edges

--handler rust 强制指定处理器,而不按扩展名路由。插件自身的设置位于 drsg.toml 的 [plugins.<name>] 节,原样透传——[plugins.rust] include_source = true 会把每个 函数自身的源码存到它的节点上以供取回,默认关闭,因为那几乎等于把整个代码库复制进 图里。

遍历会遵守 .gitignore——一个项目对「什么是衍生物、什么是源码」的自我声明,胜过 本工具所能猜测的任何清单——并且总是跳过 target/、node_modules/ 之类的目录。对 未改动的树运行两次,得到的图逐字节相同。

.dockerignore 默认不读。它回答的是另一个问题——什么该进入送往 Docker 守护 进程的构建上下文——而两者的答案常常相反:一个在 CI 中构建、只发布 dist/ 的前端 项目,把 src/ 排除在镜像之外是正确的,这并不意味着源码是衍生物。用 --ignore-files 选择:

$ drsg digest .                                    # 仅 .gitignore(默认)
$ drsg digest . --ignore-files gitignore,dockerignore
$ drsg digest . --ignore-files ''                  # 两者都不读,按树的原样读取

drsg.toml 中的 [digest] ignore_files 为每次运行设定它,drsg init 与 drsg serve watch 也接受同一个标志。无论怎么选,drsg 自己的底线不变:点文件、 上述构建目录,以及 drsg 自己的库与 logs/,一律跳过。

读的是工作区,而不是某个提交

遍历读取的是工作区。忽略文件说的是一个项目认为什么是衍生物,git 的索引说的是 它已经为什么负起了责任,这两份清单并不相同。一个未跟踪的生成报告既没有被忽略、也 不是源码,而默认情况下它会被读取。

--tracked-only 只读取 git 已跟踪的文件:

$ drsg digest . --tracked-only

一个已跟踪但有未提交改动的文件仍然按当前内容读取——这个标志筛选的是路径而不是 内容,代价是一次 git ls-files,而不是每个文件一次 git cat-file。在非 git 仓库 中它会报错,而不是悄悄什么都不读。

无论哪种方式,plane 都会记录工作区相对它所命名的提交偏离了多少,而从它读出的每个 答案都会说明这一点:

synced: commit ec31cac58593 — parsed from a working tree with 2 modified and
1 untracked, so the graph holds what that commit does not

那里的沉默意味着工作区与提交一致——或者这个 plane 是在 drsg 开始记录这项信息之前 写下的。两者共用同一种沉默,这是刻意的:为一个从未测量过的 plane 断言「干净」, 正是这次改动所要终结的那种越权断言。

是代码,还是它旁边的文档

Markdown、PDF、电子表格,以及任何已安装处理器都不认领的扩展名,都由内置的文档读取 器当作散文读取。在一张关于文档的图里这正是要点;而在一张关于代码库的图里,这就 是一个 plane 从几百个节点涨到几千个的原因。--code-only 只读取处理器认领的文件:

$ drsg digest . --code-only

一个有用的副作用:需要模型的正是散文,所以对一棵纯代码的树做 code-only 摄取,完全 不会发起任何 chat 调用。read 刻意没有一并收窄——处理器会拉取它拿到的那个文件 周围的文件,比如一份清单或一个被包含的头文件,而那些文件不必被任何人认领。

[digest] tracked_only 与 [digest] code_only 为每次运行设定这两项。每个标志只负责 把对应设置打开:不传它并不等于要求相反的行为,因此配置文件仍然说得上话。

知道读了些什么

一棵把自己大部分声明掉的树,摄取出来几乎是空的,而单看数字没人会去读。因此当一次 摄取排除掉了它所看到的一半以上文件时,会不经询问就说出来,并指名是哪个文件造成的:

$ drsg digest .
warning: 5 of 7 files were withheld by ignore rules — .dockerignore (5). If that
is not what you meant, `--ignore-files` chooses which of them apply.

无论是否有异常,-v 都会给出这份账目,每多一个 v 就多说一些:

(无)静默,仅保留上面那条警告
-vread 812 of 840 files (96.7%)
-vv追加每个被排除的文件,以及排除它的那条规则
-vvv追加每个被读取的文件,以及读取它的处理器

分母是 drsg 自己的底线过滤之后剩下的文件数,而非磁盘上的全部文件:把一个 8 万个 文件的 node_modules/ 计入,百分比就失去了意义。drsg serve watch 通过它的日志 报告同样的内容,因为它的输出是日志而不是终端。

这一能力被刻意限定为仅限本地:只能用在你自己机器上的 drsg 与 stdio MCP 服务器,绝不包括共享的 drsg serve。解析之所以值得花这个代价,是因为处理器 能够拉取它拿到的那个文件周围的文件;而共享服务器唯一能提供的文件系统, 就是它自己的那一个。

提供方与密钥

语义检索的嵌入、ask、文档导入,这些 LLM 相关功能都会调用一个外部提供方。 已内置 OpenAI、DeepSeek、Qwen 与 Ollama 的预设,任何其它端点 均可用 base URL 指定。对话提供方与嵌入提供方各自独立选择,这一点在某个提供方 只提供其一而非其二时尤为重要(例如 DeepSeek 只提供对话,可以搭配 Qwen 来获取 嵌入)。

密钥从服务端环境读取,绝不从请求中读取。 每个提供方读取其惯用的密钥变量 (OPENAI_API_KEY、DEEPSEEK_API_KEY、DASHSCOPE_API_KEY……);这些也可以通过配置 文件的 [llm] 节提供(见第 2 章)。

面向智能体的原语

另有两项能力(在别处记述)补全了面向智能体的接口,并与上述能力相互组合:

  • 时间旅行 —— 按提交序号或时间戳读取任意历史状态,以审计或复现较早的结果。 见第 4 章。
  • 变更流 —— 订阅一个平面,在变更提交时即时收到它,使智能体得以对图作出反应, 而非轮询它。见第 6 章。

查询语言

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

Web 界面

drsg serve 提供一个内嵌于二进制的单页仪表盘,供你在浏览器中检视与操作数据库。它 通过 HTTP 使用与各 SDK 相同的 JSON-RPC 接口,并通过一条 WebSocket 获取实时更新。由于 是内嵌的,它不发起任何外部网络请求:所有资源均由同一来源(origin)提供,所有渲染 都在本地完成。

访问与鉴权

打开 drsg serve 报告的地址(默认 http://127.0.0.1:7700)。在回环地址上无需任何 设置:未配置令牌时,同源来源检查即授权本地界面;配置了 DRSG_TOKEN 时,服务端会把 令牌写入提供给回环浏览器的页面(以应用读取的 <meta> 元素形式,绝不以脚本形式), 界面再以 Bearer 凭据出示。无论如何,跨源请求都会被拒绝。

若监听其他地址(如 --addr 0.0.0.0:7700,容器镜像即如此),服务端必须配置令牌才会 启动,且绝不把令牌写入页面——因为该页面会发给任何能访问该端口的人。服务端第一次 回答“未授权”时会弹出输入框,把 DRSG_TOKEN 粘贴进去即可;它保存在标签页的会话存储 中(关闭标签页即消失),随每次调用以 Authorization: Bearer 发送,在 WebSocket 上则以 ?token= 发送(浏览器套接字唯一能携带的形式)。此时仪表盘自身的来源也须列入 allowed_origins / DRSG_ALLOWED_ORIGINS,因为它不是回环地址。

同一台机器上的反向代理(或端口转发)之后的回环监听,是网络部署而非本地部署:互联网上的 每个客户端都以回环对端地址到达服务端。凡能辨别之处,服务端都按网络部署对待——凡请求经代理 转发(带 Forwarded、X-Forwarded-*、X-Real-IP、Via)或寻址到非本机名称(Host)的 页面,绝不写入令牌;把代理的来源列入 allowed_origins(仪表盘在代理之后要能工作就必须如此) 会关闭所有页面的令牌写入,并要求启动时必须配置令牌(非回环的允许来源也从不算作本地界面)。 不加任何头、且保持回环 Host 的代理无法与本地浏览器区分,此时请设 DRSG_PAGE_TOKEN=0 ([server] page_token = false),页面一律不带令牌;仪表盘会像在其他地址上一样索要令牌。 只要监听前面有任何东西,就请配置令牌。

每个响应都带有 Content-Security-Policy,脚本与样式只能从服务端自身加载;仪表盘按此 策略构建,前置反向代理应原样透传而非替换它。

错误的令牌最多可出示五次;此后服务端对该客户端带令牌的请求回答 429 Too Many Requests,并附带 Retry-After,每再失败一次等待时间翻倍,最长五分钟,直到出示正确 令牌为止。不带令牌的请求既不计入也不拦截,因此无令牌的本地仪表盘不会触发它,页面与 /health 对其他人也照常应答。客户端以连接地址区分;在同一台机器的反向代理之后,则以代理 在 X-Forwarded-For 中给出的地址区分,因此一个乱猜者不会把代理后的所有人一起锁住。 一个 JSON-RPC 批次最多 64 个请求。

界面中凡是需要选择提供方的地方——语义搜索、自然语言查询、AIgest——只提供预设 (openai、deepseek、qwen、ollama),API 也只接受这些预设,或服务端配置的那一个 提供方([server] embed_provider)。base URL 在线上会被拒绝:服务端会以自己的网络、 替持有凭据的任何人去调用它;想用本地或自建端点的运维者应在服务端配置。会描述服务端 自身磁盘或其提供方回复的错误,只以类别加引用号返回(storage error (ref 00002a)), 细节在服务端日志中按该引用号可查。

界面包含三个视图,从顶部选择:Dashboard、Explore 与 AIgest。

Dashboard(仪表盘)

Dashboard 呈现数据库的健康状况与平面管理。

  • 健康状况。 这里展示一组统计数据:平面、节点、边、标签、边类型、已声明索引、 平均度、提交数(提交序号)与磁盘占用。它们通过 WebSocket 实时推送,会随数据库变化 而更新,一个连接指示器显示实时/离线状态。
  • 平面卡片。 每个平面一张卡片,显示其名称、描述与节点/边计数。卡片可选定该平面 (作为全局上下文)、将其导出为 JSONL(一个可被命令行 import 读回的下载),或在 一个键入确认的对话框后删除它。一张"新建平面"卡片用于创建平面。
  • 来源。 卡片会说明其平面来自何处、又可以询问谁。来自挂载数据库的平面带有该挂载 的名称;附着了其他平面(plane.attach)的平面,每一个附着带一个标签,位于另一个 实例上的标注为 remote,其 URL 见悬浮提示。既非挂载、也未附着任何平面的卡片不显示 这一行。
  • 已挂载的数据库。 当本进程在自有平面之外还服务其他库时(db.mount),平面卡片 下方会列出每一个:名称、打开它的路径、它贡献了多少平面,以及这些平面是否以该挂载名 为前缀——只有当两个挂载提供了同名平面时才会如此。其中每个平面都是一个可选定它的 按钮。没有任何东西被复制,也没有任何东西被重新消化;这些平面保留各自的名称,并出现 在上方的卡片之中。该小节会说明挂载被限定在哪个目录之下,未挂载任何库时则不出现。

Explore(浏览)

Explore 是一块由选项卡工具栏驱动的交互式图画布。选中一个节点或边会打开一个检视器, 显示其标签/类型与属性(向量被折叠在一个控件之后,而非直接打印);双击一个节点会 扩展其邻域;从一个节点拖拽到另一个节点会打开新建边对话框,并预填两个端点。

阅读一张稠密的图

任何规模的平面都超出一块画布一次所能呈现的范围,因此 Explore 先画出骨架,其余 的部分由你来索取。

  • 按重要性取种子。 初始视图取的是连接最多的 40 个节点及它们之间的边,而不是 扫描到的最先 40 个,为的是给出一个可读的形状,而非一份任意的样本。Show more(显示更多) 会将其扩展到 100、200、500。排序依据是度数;graph.seed 也提供 PageRank,但 PageRank 会把权重汇聚到汇点上,以至于一个中心节点的得分 可能低于它自己的邻居。
  • 图例即过滤器。 点击一个标签即可隐藏该类别,再次点击则恢复。这里做的是隐藏 而不是淡出:你关掉的类别,就不再属于当前要看的问题。
  • 拥挤的中心节点会被折叠。 某个节点的叶节点(只与它相连、别无他连的邻居) 一旦超过二十个,就会折叠成一个标注数量的圆点,点击即可展开。没有任何内容被 丢弃,你已经选中的叶节点也绝不会被折叠。
  • 扇形会被分组。 在该阈值以下,一个中心节点的叶节点会按标签成弧段排布在其四周, 于是环形读起来是若干区域,而非一片模糊。
  • 重要性会撑开布局。 与连接紧密的节点相接的边会被排布得更长,好让繁忙的邻域有 余地被看清。
  • 大图的布局在主线程之外运行。 超过 400 个节点时(例如在真实的平面上点 Show all),力导向布局会在 Web Worker 中运行有限的几秒钟,页面保持响应; 图形在视野中逐渐收敛,运行结束后定形。
  • Expand one hop 是分批发出的。 扩展边缘每次点击最多取 300 个节点,以每批 64 个的 JSON-RPC 批量请求发送,并说明有多少留给下一次点击、有多少调用失败。
  • 选中即聚焦。 选中项及其直接邻居保持全强度,再外一圈变暗,更远处则隐去并舍弃 标签。选中一条边会同时聚焦它的两个端点,因为一条边本身就是关于两个事物的 陈述。

节点大小反映其连接的紧密程度(并计入折叠在它之下的部分),图例则将颜色映射到标签。

工具栏选项卡:

  • Filters / Operations —— 从某个标签(或整个平面)为画布播种。
  • Algorithms —— 运行 PageRank、连通分量、最短路径或 Louvain,并将结果叠加到当前 图上(得分体现为节点大小/颜色,分量/社区体现为颜色分组)。
  • Hybrid —— 融合的向量 + 关键词 + 图邻近度检索,其各路信号与标签在栏中选择。
  • Ask —— 一个自然语言问题;生成的计划会被展示(带一个复制控件),其连通结果被 绘制出来。
  • Time-travel —— 一个覆盖提交历史的滑块(见下文)。
  • Live —— 变更流(见下文)。

Query(查询)

与 Dashboard、Explore 并列的独立视图,也是全应用唯一书写查询的地方:Explore 负责把 图画出来,这个视图负责向它提问并阅读答案(见第 4 章)。

编辑器是多行的,因为值得占一个页面的查询很少只有一行:⌘/Ctrl + Enter 运行, 回车即换行,Tab 接受键入时给出的关键词补全幽灵提示。编辑器下方给出若干示例查询作为 起点,输入的文本也会在刷新后保留。

结果呈现为查询所要的形状:

  • 投影(RETURN n.file, count(*) AS n)以表格返回,并给出行数、列数与耗时, 还有一个复制控件,把整张表以制表符分隔的文本放进剪贴板——含表头,可直接粘进 电子表格;
  • 返回完整记录的查询(RETURN n)按键、标签以及放得下的属性逐条列出;
  • 写操作报告其变更计数。

无法编译的查询会显示解析器自己的消息——它指名的是语法构造,而不是一个字符位置。

搜索

顶部带有一个针对当前平面的快速搜索,有两种模式:

  • 文本 —— 在键、标签与字符串属性上进行子串匹配。
  • 语义 —— 按嵌入相似度排序(带一个嵌入提供方选择器)。

选中一个结果会在 Explore 中聚焦到该节点或边。搜索同样遵从时间旅行游标:在钉住某个 历史提交后,它搜索图在该提交时当时的样子。

时间旅行

在原生后端的服务上,Explore 的 Time-travel 选项卡会探测可查询窗口 (plane.history),并在其可用时呈现一个覆盖提交序号的滑块,其最新端带有一个 Live 控件。向后拖动会将图重绘为该提交时的样子;播种与节点扩展读取历史快照。画布上 的一个标记在所有选项卡上都指示所钉住的提交,关闭后视图会恢复为实时。顶部搜索反映 同一个游标。在非原生后端上,该选项卡不出现。

滑块覆盖的是保留的窗口,而不是有史以来的每一次提交:plane.history 从 [server] retain_commits 保留的下限开始(省略该键时为 20;设 retain_commits = 0 则不限),读数会显示这个 窗口有多少次提交,db.stats 也以 retain_commits 报告该设置。需要更深的历史请提前 调高——越过下限的版本会在压缩时回收,之后就无法再访问。

Wires(线路)

HTTP 路由是解析器唯一无法转化为边的引用:处理端与调用端往往位于不同的语言、不同的 仓库,而它们之间只有一个字符串。因此预处理插件记录的是每一侧所声明的内容——处理端 记 serves,调用端记 requests,二者都写作 VERB /path/{},其中每个可变段都已归一 ——匹配则在有人询问时计算,而不被存储。

Explore 的 Wires 选项卡读取当前平面上的这些声明并将其配对。读取路由所用的选择与 wire 动词完全相同——只取声明了任一侧的节点,别无其他——因此任意规模的平面回答的都是 它的路由,而不是它自身。

  • matched 列出每个请求,以及每一个回应它的声明。无人回应的请求同样会被列出,并以 标记与底色示意,而不是被隐藏:它是更值得关注的一半,通常意味着其处理端位于这张图未 持有的仓库中。
  • requests 与 serves 各自单独列出一侧,每条路由同时给出归一后的形式与原文, 因而 /api/orders/{} 可以对照源码所写的 /api/orders/{id} 来读。
  • 动词过滤与路径子串过滤对三种视图均有效,且每个符号都是一个链接,点击会把该节点连同 其邻域置于画布中心。

两侧按动词与路径匹配:路径必须相等,动词相等或其中一侧为 *——未指明动词的一侧匹配 该路径上的所有动词。路径按精确字符串比较,因为插件已在建图时将其归一;没有插件记录的 路由,或写成 VERB path 之外形式的值,会被跳过而不是臆测。

Live 变更流

Explore 的 Live 选项卡通过 WebSocket 打开一个 plane.watch 订阅,并在提交落库时 将其流式呈现,最新者在前:每一条显示操作(创建 / 更新 / 删除,以颜色区分)、种类 (节点 / 边)、键或 id、标签,以及提交序号。该流可以暂停与恢复,并可收窄到单个标签; 选中一个节点变更会在画布中聚焦到它。

AIgest

AIgest 视图把一篇文档导入当前平面(见第 3 章):先上传或粘贴 文本——支持 Markdown、纯文本,以及 Word、PowerPoint、Excel、OpenDocument、RTF、 EPUB、CSV 与 PDF(这些格式会先转换为 Markdown,让模型看到的是标题、表格与列表, 而不是一堆散乱的字符);再选择对话与嵌入提供方;最后点 Preview(预览),模型 就会抽取实体与关系并给出方案。Write to graph(写入图) 会提交预览好的方案, 不会再产生任何额外的模型调用。可选项包括链接到既有节点(避免重复)与跳过嵌入 生成。

上传控件旁还有一行 URL:粘贴一个地址,可以再填一个主题,然后点 Fetch(抓取)。返回的不是直接填进文本框的正文,而是一份列表:该页面及它 链接到的页面,各自带着相关性得分、标题与大小,超过阈值的默认勾选。取消勾选某 一项,就能把它从文档中移除,且不会产生任何新的请求;一个可展开的折叠区会列出 未被保留的页面及其原因。在这份列表看起来没问题之前,不会有任何内容变成 token (第 3 章)。

Mode(模式) 决定抽取结果被清理到什么程度:coarse、fine(默认)或 super,这个选择会和提供方一起被记住,因为它是一项长期偏好,而不是每次运行都要 重新决定的临时选项。选中 super 时,控件正下方会给出提示:它会把每个实体连同 它的全部段落一并重读,输入 token 用量约为 15 倍。各模式各自换来什么,见 第 3 章。

设计

仪表盘会跟随主题明暗(即观看者的浅色/深色偏好),并且完全自包含:因为它是内嵌 且同源的,不依赖任何外部 CDN、字体或服务,除了所属服务自己的 API 与 WebSocket 之外,也不会发起任何其它请求。

SDK

Dr Strange 提供六种语言的客户端库:TypeScript、Python、Go、Java、C 与 Zig。 每一种都通过 JSON-RPC 2.0 与正在运行的 drsg serve 通信,且其带类型的方法接口是 由服务端的 OpenRPC schema 生成的,因此每个 SDK 都与线协议精确一致,并在各版本间 与之保持同步。(Zig 客户端是对生成的 C 客户端的一层胶水封装,因此继承同样的保证。)

获取 SDK

各 SDK 位于仓库的 sdk/<语言> 目录下,版本号与工作区一致,均为 2.27.1。目前没有任何 一个 SDK 发布到语言包仓库(没有 PyPI、npm、Maven Central 发行版,也没有 sdk/go/vX 标签),因此都需从本仓库的检出安装:

语言位置从仓库安装
TypeScriptsdk/typescript在该目录 bun install && bun run build,然后在 package.json 中写 "drsg": "file:…/sdk/typescript"
Pythonsdk/pythonpip install …/sdk/python(或 pip install "drsg @ git+https://github.com/wangyingsm/dr-strange.git#subdirectory=sdk/python")
Gosdk/gogo get github.com/wangyingsm/dr-strange/sdk/go@<commit>,或用 replace … => …/sdk/go 指令
Javasdk/java在该目录 ./mvnw install,然后依赖 io.github.wangyingsm:drsg:2.27.1
Csdk/cmake → libdrsg.a + include/drsg.h(需要 libcurl + json-c)
Zigsdk/zigzig build,或将 src/drsg.zig 作为模块引入(Zig 0.16)

各目录的 README.md 给出了该语言的具体步骤。

连接与调用

客户端由一个基地址与一个令牌构造;令牌默认取自 DRSG_TOKEN 环境变量,并以 Authorization: Bearer 凭据的形式随每个请求(包括变更流背后的 WebSocket 升级请求) 一同发送,绝不放在 URL 中。(只有在标准 WebSocket 构造器无法设置请求头的运行时—— 浏览器窗口或 Worker,以及 Deno——TypeScript 客户端才会在套接字上回退到 ?token=; Bun 与 Node 发送请求头。)方法名与 RPC 方法一一对应, 并适配各语言的命名习惯:

语言构造客户端调用示例
TypeScriptnew Drsg({ baseUrl, token })await db.nodeCreate({ … })
PythonDrsg(base_url=…, token=…)db.node_create(…)
Godrsg.New(drsg.WithBaseURL(…), drsg.WithToken(…))db.NodeCreate(ctx, …)
Javanew Drsg(baseUrl, token)db.nodeCreate(…)
Cdrsg_client_new(base_url, token)drsg_node_create(…)
Zigtry drsg.Client.init(base_url, token)c.drsg_node_create(client.handle, …)

其形态是统一的。以 TypeScript 为例:

import { Drsg } from "drsg";

const db = new Drsg({ baseUrl: "http://127.0.0.1:7700", token: process.env.DRSG_TOKEN });

await db.nodeCreate({ plane: "social", key: "ada", labels: ["Person"] });
await db.nodeCreate({ plane: "social", key: "alan", labels: ["Person"] });
await db.edgeCreate({ plane: "social", src: "ada", dst: "alan", type: "KNOWS" });

const stats = await db.dbStats();
console.log(stats.nodes, stats.edges);

其它语言遵循同一套方法接口,只是各自用本地习惯的写法:Go 在每次调用中传入一个 context.Context;Python 与 Java 直接抛出异常;C 返回一个由调用方持有的 json_object,并通过一个出参报告失败。

错误处理

应用级失败(未知平面、非法计划)是一个 JSON-RPC 错误;被拒绝的凭据对应错误码 -32001。各 SDK 都会把它包装成带类型的错误:TypeScript 与 Python 中的 DrsgError / DrsgAuthError(此外 TypeScript 客户端中超过 timeoutMs 的请求抛出 DrsgTimeoutError,Python 中 非 JSON-RPC 的响应或畸形的变更流帧抛出 DrsgProtocolError),Go 中带 IsAuthError 的 *drsg.Error,Java 中的 DrsgException / DrsgAuthException,以及 C 中一个填充好的 drsg_error(配 drsg_is_auth_error)。

变更流

除 Zig 外,每个 SDK 都能打开一条长连接 WebSocket,订阅某个平面的变更流(第 3 章) (Zig 绑定只封装 C 客户端的请求/响应接口;需要变更流的 Zig 程序可直接调用 C 库的 drsg_watch), 接收每一个已提交的 ChangeEvent:结构为 { plane, seq, truncated, changes },其中每个 变更为 { kind, op, id, labels?, record? }。订阅遵循各语言自然的并发模型:

TypeScript —— 一个回调;套接字自动重连。close() 停止它。

const sub = db.watch("social", (event) => {
  for (const c of event.changes) console.log(event.seq, c.op, c.kind, c.id);
});
// sub.close();

Python —— 一个阻塞式生成器;迭代以消费,跳出以断开。

for event in db.watch("social"):
    for c in event["changes"]:
        print(event["seq"], c["op"], c["kind"], c["id"])

Go —— 一个通道(channel);取消 context 以停止。拨号与握手同样遵守 context(无截止时间则为 30 秒),服务端断开时通道关闭,超过 drsg.MaxFrameBytes 的消息会结束订阅。

events, _ := db.Watch(ctx, "social")
for e := range events {
    for _, c := range e.Changes {
        fmt.Println(e.Seq, c.Op, c.Kind, c.ID)
    }
}

Java —— 一个监听器;返回的 Subscription 关闭它(先发送关闭帧,再中止套接字,沉默的对端无法让它悬着)。监听器抛出的异常以 WARNING 记录日志,订阅继续。客户端本身是 AutoCloseable 的:close() 结束其订阅并回收自建的 HttpClient(通过 Client.Options 共享的除外)。

var sub = db.watch("social", null, event -> {
    for (var c : event.changes()) System.out.println(event.seq() + " " + c.op() + " " + c.kind());
});
// sub.close();

C —— 一个回调;drsg_watch 阻塞,直到回调返回非零值(如有需要,请在一个线程上 运行它;drsg_watch_cancellable 接受一个 drsg_watch_ctl,其他线程可通过 drsg_watch_ctl_cancel 取消)。一个 drsg_client 只包装一个 libcurl 句柄,不能被两个 线程同时使用,因此 watch 线程需要自己的客户端。超过 DRSG_WS_MAX_MESSAGE_BYTES 的消息会以 DRSG_TRANSPORT_ERROR_CODE 结束该 watch。

static int on_change(struct json_object *event, void *userdata) {
    /* 检视 event["changes"];返回非零值以停止 */
    return 0;
}
drsg_error err;
drsg_watch(client, "social", NULL, on_change, NULL, &err);

一个可选的标签会将订阅收窄到对该标签节点的变更。投递是尽力而为的:落后过多的订阅者 会丢弃溢出部分,而不会拖住写入方。

由于每个事件都携带其落库时的提交序号,订阅者可以分别读取该序号和前一个序号处的 as_of 状态,从而重建一次变更前后的确切状态(第 4 章)。

代码生成

每个 SDK 带类型的方法接口都由 crates/dr-strange-web/openrpc.json 生成,这也是服务端 从 rpc.discover 返回的唯一权威来源。每个 SDK 都携带一个小型代码生成器与一个漂移 (drift)测试;一旦已提交的客户端不再与该 schema 匹配,测试便会失败,因此这些库无法 在无声无息中偏离线协议。生成的接口之下是手写的部分:传输层、错误类型,以及 WebSocket 的 watch。

嵌入式命令行

drsg 是命令行界面。它直接打开数据库,属于嵌入式访问,而不是连到某个正在运行的 服务,因此适合本地脚本化、批量导入、检视、备份与运维。每次调用都接受一个全局的 --db <路径>(默认 graph.drsg);多数命令还接受一个 --plane(默认 startup)。

由于它在进程内打开数据库,drsg 无法操作一个已经被 drsg serve(或另一个 drsg) 打开的数据库:数据库会取得一把排他锁,第二次打开会直接报错,而不会把数据库弄坏。并发 访问请改用面向该服务的 SDK 或 RPC 接口,命令行只用于离线操作。

命令参考

命令用途
init [--dir] [--rebuild]为智能体引导本仓库:图化、以分离方式拉起 serve watch、写出 .mcp.json(见第 10 章)。在不含 digest 特性的 --no-default-features 构建中,它退化为创建一个空数据库——多数命令也会在首次使用时创建
plane list | create | drop | show平面生命周期
import <文件> --plane加载 JSONL 节点/边
export --plane将一个平面导出为 JSONL
get <节点> --plane按 id 或 @外部键 获取一个节点
query <计划> --plane运行一个序列化的逻辑计划(JSON,或 -)
cypher <查询> --plane运行一条 openCypher 子集语句(或 -)
catalog [--plane]打印软 schema(单个平面或整个数据库)
algo <名称> --plane运行一个图算法
hybrid <查询> --plane融合的向量 + 关键词 + 图邻近度检索
index ensure | keyword声明一个向量或关键词索引
ask <问题> --plane自然语言查询
digest <文件|目录|url> --plane [--mode]导入一篇文档、一个代码仓库,或一个页面及其链接
search <查询> --plane语义查找:嵌入查询文本,返回最近的节点
context | describe | trace | impact | fathom <名称> --plane已图化代码平面上的智能体动词
impact <名称> --plane [--depth] [--across [attached|all]]--across 把半径扩到本仓库之外:本平面所附着的那些平面,或本实例持有的每一个平面
snippet <名称 | path:start-end> --plane一个符号的源码文本,或被解析树中任一文件的某个行区间
grep <模式> --plane [--regex] [--path] [--all-files]在该树上做文本检索,被 ignore 的文件除非指定 --all-files 否则不在范围内;每个命中都标出所在的符号
traverse <键> --plane [--edge-type]一跳(或数跳)之外的邻居
history --plane [--limit]平面背后的代码仓库:HEAD、分支与标签指向、变基、最新的提交
deployed [<目标>] --plane [--ops] [--history]某个运行中的东西是由哪次提交构建的,以及哪些东西在跑某次提交:通过镜像标签所携带的修订号,把运维平面连到它旁边的历史平面
recall <名称> --at <修订> --plane [--vs <修订>] [--pattern]某个修订版本时的代码:文件、行区间、目录或符号;在那棵树上检索;或相对更早修订版本的差异
wire <路径 | 名称> --plane [--verb]什么在提供某个 HTTP 路径、什么在请求它;跨平面内的每种语言,也跨它所附着的平面
attach <名称> --plane [--url] [--as] [--both]记录本平面可以询问的平面:本实例上的,或由另一个 drsg 提供服务的
detach <名称> --plane移除一条附着(名称不存在时只做报告,不算错误)
attachments --plane [--probe]本平面附着了什么;--probe 会逐个询问远端的附着现在是否应答
mount <路径> [--as] [--url]立即在一个正在运行的 drsg 上提供另一个数据库的平面——这三个动词说的是它的 RPC,而不是打开某个文件,因为存储的锁在服务器手里
unmount <名称> [--url]停止提供某个挂载,并释放其存储的锁——若有折叠仍在进行中并持有它,会如实说明(名称不存在时如实报告,而不是报错)
mounts [--url]那台服务器提供了什么:每个挂载及其目录与平面,以及每个平面所记录的树
queries [<id>] [--limit]已经运行过的 Cypher 查询;cypher --history <id> 可再跑一次
vectorize --plane为一个平面的节点生成向量嵌入,以供相似度检索
plugin install | list | remove管理预处理插件(沙箱化的 wasm 解析器)
snapshot <out> / restore <in>整库备份与恢复
stats / check汇总计数 / 完整性扫描
serve [--addr]运行 Web 仪表盘 + JSON-RPC 接口 + MCP 端点
serve watch [--dir]对外服务,并让代码平面随每次提交保持同步
update [--bin] [--dir]若有更新的发行版,就把本二进制替换掉

被导入的文件可以是 Markdown 或纯文本,也可以是 Word、PowerPoint、Excel、 OpenDocument、RTF、EPUB、CSV 与 PDF;后一类会先转换为 Markdown,让模型读到的是标题、 表格与列表,而不是一堆散乱的字符。格式依据文件内容判断,扩展名写错也不影响。目录会 被逐文件遍历、分发:源码经已安装的解析器插件变成事实,文档则作为散文交给模型。

全局选项为 --db <路径> 与 --config <路径>(配置文件,见 第 2 章)。

平面

$ drsg --db graph.drsg plane create social
$ drsg --db graph.drsg plane list
$ drsg --db graph.drsg plane show social
$ drsg --db graph.drsg plane drop social

数据进出

导入与导出都用 JSONL:每行一个节点或边,一个平面因此可以经由文件系统往返,也便于跟 其它工具集成:

$ drsg --db graph.drsg import nodes.jsonl --plane social
$ drsg --db graph.drsg export --plane social > social.jsonl
$ drsg --db graph.drsg get @ada --plane social

节点的 external_key 是它在平面内的身份标识,因此 --on-conflict 决定了当导入的键 已经存在时的处理方式:

策略效果
error(默认)不导入任何内容,并列出发生冲突的键
skip保留已有节点,丢弃传入的那一行
update覆盖已有节点的属性;若该行带有 labels,一并覆盖标签
$ drsg --db graph.drsg import nodes.jsonl --on-conflict update
imported 0 nodes, 0 edges, 2 existing updated

在 skip 与 update 下,文件中的边仍会解析到平面中已存在的节点,因此关系可以挂载到 既有数据上。边本身不带键,所以总是追加写入。这一策略约束的是节点身份,而不是边的 去重。没有 external_key 的行不可能发生冲突,始终会被插入。

查询

一条语句既可以是 openCypher 子集,也可以是一个序列化的计划;两者均接受 - 以从标准 输入读取。--param 将一个 $name 占位符绑定到一个 JSON 值:

$ drsg --db graph.drsg cypher --plane social \
    'MATCH (p:Person) WHERE p.age >= $min RETURN p' --param min=18

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

$ drsg --db graph.drsg catalog --plane social

每一条成功执行的查询都会被记录下来——无论它来自 CLI、仪表盘,还是通过 MCP 发起的 智能体。queries 按时间倒序列出已执行的查询,每行包含 id、时间、平面以及折叠为单行 的查询文本;带上 id 则单独打印该条查询的原文。cypher --history 会重新执行一条已 记录的查询,并默认使用它当初所针对的平面,除非 --plane 另有指定:

$ drsg --db graph.drsg queries --limit 5
4	2026-09-05 08:14:02Z	social	MATCH (p:Person) WHERE p.age >= $min RETURN p
3	2026-09-05 08:11:47Z	social	MATCH (p:Person) RETURN count(*) AS people

$ drsg --db graph.drsg queries 3 > last.cypher
$ drsg --db graph.drsg cypher --history 3

历史记录有容量上限——默认为两百条——新记录到来时最旧的一条会被清除。重新执行最近 一条已记录的查询只会更新它的时间戳,而不会再添加一份副本。请注意 drsg history 是另一条命令:它列出的是某个仓库的提交历史。

图算法

$ 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

检索与导入

声明索引、运行融合检索、以自然语言提问,并导入文档(第 3 章)。 由 LLM 支撑的命令从环境读取提供方密钥:

$ drsg --db graph.drsg index ensure  Doc embedding --plane social
$ drsg --db graph.drsg index keyword Doc body      --plane social --lang english

$ drsg --db graph.drsg hybrid "how does time-travel work" \
    --plane social --label Doc --vector embedding --keyword body --graph-hops 1

$ drsg --db graph.drsg ask "which companies does Ada work for?" \
    --plane social --chat deepseek --embed qwen

$ drsg --db graph.drsg digest notes.md --plane social --apply

$ drsg --db graph.drsg digest paper.md --plane papers --mode super --apply

$ drsg --db graph.drsg digest https://example.com/paper --plane papers \\
    --topic "attention mechanism" --pages 6 --apply

以 http(s):// 开头的参数会被抓取,而不是从磁盘读取:页面会被转换为 Markdown,并在 --pages/--depth 范围内沿链接继续抓取,只保留与页面自身主题(以及给出的 --topic,如果有)相关的内容(第 3 章)。协议头是 必需的:裸的 example.com 也可能是一个合法的文件名,与其去猜使用者的本意,不如 要求写明。命令会打印保留了什么、丢弃了什么,但不提供交互式挑选,那是仪表盘页面 列表要做的事。

--mode 决定抽取结果会被清理到何种程度:coarse 归一标签与边类型的词表,fine (默认)在此之上合并指称同一事物的实体,super 再把每个实体连同其全部段落一并 重读,结果最准确,但输入 token 用量约为 15 倍(第 3 章)。

备份与完整性

snapshot 在单一提交序号上写出一个一致的整库快照包;restore 将其重建进一个空 数据库,并保留 id、提交序号与已构建的检索索引(第 11 章)。 stats 与 check 报告计数,并扫描每个平面的可读性:

$ drsg --db graph.drsg snapshot backup.drsgsnap
$ drsg --db fresh.drsg  restore  backup.drsgsnap
$ drsg --db graph.drsg stats
$ drsg --db graph.drsg check

check 还会指出仍为旧格式、其数据块没有校验和的存储段。压缩会重写它所合并的段, 因此写入频繁的库会自行升级;而稳定在压缩阈值以下的库则会一直保留未校验的块——这些 块中的损坏会被当作数据读出,而不是报错。upgrade 逐条重写这些段,不回收任何版本, 库中内容保持原样:

$ drsg --db graph.drsg upgrade
upgraded 2 storage runs to the checksummed format

提供服务

serve 是嵌入式模型的例外:它打开数据库,随后将其通过网络暴露给仪表盘、各 SDK 与 MCP 服务。

$ DRSG_TOKEN=please-change-me drsg --db graph.drsg serve --addr 0.0.0.0:7700

服务及其配置见第 2 章,仪表盘见 第 5 章,客户端见第 6 章。

MCP

drsg-mcp 是一个 Model Context Protocol 服务,它内嵌 Dr Strange,并将数据库作为一组工具暴露给 LLM 智能体。兼容的宿主中, 智能体可以直接检索、遍历、查询、运行算法、以自然语言提问、导入文档,也可以写入 图,全程无需专门的集成代码。

Dr Strange 如何契合智能体

MCP 本身就是 JSON-RPC 2.0,与 Web 后端说的是同一套协议,因此 MCP 服务是一等的 接口,而不是一层适配器。drsg-mcp 内嵌 dr-strange-core,在进程内打开数据库 (和命令行一样),再通过 stdio 提供该协议:宿主启动这个进程,双方在标准输入 输出上交换 JSON-RPC 消息。日志写入 stderr 与一个滚动文件,绝不写入承载协议的 stdout。

运行与配置

drsg-mcp 是一个独立的二进制。一行命令即可安装发行版本 (第 2 章):

$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp

在 Windows 的 PowerShell 中:

PS> & ([scriptblock]::Create((irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1))) -Bin drsg-mcp

它同样可以从源码构建:cargo build --release -p dr-strange-mcp。数据库以 --db <路径> 指定,也可以直接作为一个裸参数给出;其次读 $DRSG_DB,再退回到默认 值 graph.drsg。--help 与 --version 只打印信息随即退出:

$ drsg-mcp --db /path/to/graph.drsg
$ drsg-mcp /path/to/graph.drsg          # 同样的意思,简写

数据库必须已经存在——这个服务从不创建数据库。用 drsg digest <目录> --apply --db <路径> 建立,或在仓库中运行 drsg init。空数据库对任何问题都只会答「什么也没 找到」,这和一次出了错的图化毫无区别;因此路径不存在时直接报错。

未指名数据库时,它会先去找一个服务。 它读取最近的 .mcp.json——像 git 找自己的 目录那样逐级向上,到仓库根(第一个 .git)为止;不属于当前用户或任何人可写的文件会被 跳过,因为它指名的服务将接收宿主的会话及其携带的 Authorization——若其中声明了一个 drsg 服务且该服务有应答,本进程便把宿主的会话 中继过去,而不再打开任何数据库。于是在一个由 drsg init 准备好的仓库里(它图化 源码树、拉起一个 drsg serve … watch,并把该服务的 URL 写进 .mcp.json),只会说 stdio 的宿主同样能接到那个已经持有数据库的进程,其平面还跟随着仓库的提交(另见 下一节)。

中继按原样双向转发消息,因此宿主看到的是那个服务的工具集——包括较新的服务才有、而 本二进制从未听说过的工具。指名了数据库(--db、$DRSG_DB)则跳过这次查找:既然 调用者说明了想要哪张图,就不该被送去另一张。若无人应答、没有 .mcp.json,或其中 并无 drsg 服务,就依旧在本进程里打开数据库。

它通常由宿主启动,而不是手动运行。宿主通过命令、参数与环境变量来配置它,图工具 所需的 LLM 提供方密钥就放在环境变量里传入:

{
  "mcpServers": {
    "dr-strange": {
      "command": "drsg-mcp",
      "args": ["--db", "/path/to/graph.drsg"],
      "env": {
        "OPENAI_API_KEY": "sk-...",
        "DEEPSEEK_API_KEY": "...",
        "DASHSCOPE_API_KEY": "..."
      }
    }
  }
}

crates/dr-strange-mcp/mcp.json 提供了一份可直接编辑的该配置副本:把 command 设为 drsg-mcp(位于 PATH 中)或该二进制的绝对路径,让 args 指向你的数据库, 只提供你实际用到的提供方密钥。

由于它在进程内打开数据库,同一时刻只能有一个进程能直接打开某个数据库:可以是 一个 drsg-mcp、一条 drsg 命令,或一个 drsg serve,但不能两个同时打开。这 一点是强制的,不只是建议——第二次打开会直接报错失败,而不会把数据库弄坏。

上面的中继,正是为了让这条规则不再挡路而存在;而在中继不适用之处,它依旧生效。同一 个项目开着两个编辑器,就会各自派生一个 drsg-mcp:有 watch 服务在跑时,两者都中继 到它,共享同一个数据库;没有时,先来的打开文件,后到的拒绝启动。若干智能体需要共享 同一份记忆的场景,见下一节。

跨智能体共享:drsg serve 上的 /mcp

drsg-mcp 按设计内嵌运行:把宿主指向一个路径即可工作,无需运行任何附加服务,也 无需配置。这对单个智能体是正确答案,但也正因为如此,两个宿主没法直接共享一个 数据库:两边都要打开同一个文件。

drsg serve(arch/08)已经为 JSON-RPC 接口和仪表盘解决了"一个写者、多个客户端" 的问题。它在 POST /mcp 上暴露完全相同的工具集,走 MCP 的 Streamable HTTP 传输, 让多个智能体宿主可以指向同一个服务,不必各自内嵌一份数据库副本:

{
  "mcpServers": {
    "dr-strange": {
      "url": "http://127.0.0.1:7700/mcp",
      "headers": { "Authorization": "Bearer <DRSG_TOKEN>" }
    }
  }
}

(远程 MCP 配置的确切格式请查阅你所用宿主的文档。上面的 url/headers 字段只是 示例,不是通用格式。)

/mcp 的鉴权方式与 /rpc 完全一致:未设置 DRSG_TOKEN 时,只有同源的浏览器 UI 受信任,任何程序化客户端都会被拒绝,读操作也不例外,因为零配置的桌面安装不该在 本地悄悄暴露一个开放的 API。要从其他地方(包括另一个智能体宿主)访问 /mcp,请 在服务端设置 DRSG_TOKEN,并以 bearer token 的形式传递它。

把它放到某个域名后面之前,有一点需要先知道:MCP 传输层会校验请求的 Host 头, 借此削弱针对本机服务的 DNS 重绑定攻击,对不认识的名字返回 403。回环地址 (localhost、127.0.0.1、::1)始终放行。配置了 bearer token 之后,服务端绑定 的地址也会放行,DRSG_ALLOWED_HOSTS(逗号分隔,主机名或 host:port)里的每个 名字同样放行——所以把 /mcp 放到 https://memory.example.com 后面,只需 DRSG_ALLOWED_HOSTS=memory.example.com。未设 token 时,无论环境变量怎么写,列表 都只有回环地址:未设 token 的服务端信任自己的同源 UI,而重绑定页面要冒充的正是 这个身份,Host 检查就是用来挡它的。有了 token,每个请求都必须携带这种页面读不 到的秘密,这道检查就不再比 token 多挡住什么,运营者可以放宽它。ROADMAP §10 针对 的场景——同一台机器上的多个智能体宿主——什么都不用配。

每个会话都会拿到自己的 DrStrange 实例,但它们共享服务端打开的同一个 Database:一个会话的写入会立刻对其他所有会话可见,就像两个浏览器标签页访问 /rpc 那样。这正是关键所在——核心内部的 write_gate 会把并发写者串行化,所以 这么做是安全的,不只是方便。

rebuild 会重新读取某个 plane 建立时所依据的整棵树——当一个回答说「自本 plane 建立 以来插件发生了变化」,指的正是这件事:折叠只会把新装的解析器作用于某次提交触及的文 件。它只接受一个 plane 名字:目录来自该 plane 自己记录的 synced_root,因此重建无 法被指向一棵它从未据以建立的树。代价是把整个仓库重新解析一遍,完成后促使它被调用的 那条警告也随之消失。这条警告的含义见插件一章。

有一项能力的差异来自传输方式本身,而不是配置。digest 接受一个 path,由服务端 读取该文档(Word、PowerPoint、Excel、OpenDocument、RTF、EPUB、CSV、PDF、Markdown 或纯文本),但只有 stdio 服务接受这种用法:这个进程运行在你自己的机器上、以你 自己的身份运行,它读到的文件本来就是 agent 能打开的文件。共享的 drsg serve 就 会拒绝这么做——一旦允许调用方指定任意路径,一个已通过认证的远程 agent 就能把 服务器上的任意文件读进图里,再用查询取回。在 /mcp 上,请改为直接以 text 传入 文档内容。

digest 的 LLM 调用,花的始终是服务进程自身的提供方密钥(不管本地还是远程,都 绝不会来自工具参数)。write_nodes/write_edges 仍然保留逐次调用的批量原子性, 因为不管走哪条路径,工具代码都在同一进程内针对同一个 Database 运行,不会代理到 /rpc,也不会改变任何工具的行为。[digest] 配置段对 digest 工具的作用,和它对 /rpc 上 digest.run 的作用完全一致:调低 concurrency 来规避提供方限流时, 两个入口都会遵守,不存在一个听、另一个不听的情况。(内嵌的 drsg-mcp 二进制没有 配置文件,沿用内置默认值。)

会话生命周期

正常退出的 host 会发送 DELETE /mcp,会话随即消失。被 SIGKILL 的 host(比如 编辑器重启它的 MCP 子进程)什么都不会发,服务端就改用定时器回收:空闲超过 10 分钟,或者会话创建后 60 秒内没有收到 initialize。这时该会话的 worker task、它的 DrStrange,以及缓冲的消息都会一并释放。

空闲窗口取 10 分钟而不是 5 分钟,原因很具体:传输层会把正在执行的工具调用也算作 空闲,因为它的 keep-alive 计时器只认流量,而一次工具调用从派发到返回结果之间不会 发送任何东西。所以在一个本就安静的会话上,运行时间超过这个窗口的工具会被中途 拆断。10 分钟足以覆盖任何现实场景中的 digest;如果你经常要跑更久的任务,要么让 会话保持有流量,要么就要预期它可能需要重试。

回收之后,每个死会话都会留下一条 map 条目。它只有几十字节,但并非无害:这个 会话 id 上的下一个请求会收到 500,而不是规范要求的 404,于是本该据此重新 initialize 的客户端不会重连,这个会话就一直坏着,直到 host 重启为止。这两点都是 传输层的问题,该在上游修,而不是在这里绕过去。如果你在脚本里循环创建会话,记得 手动关闭,就能完全避开这个问题。

工具并发

工具调用和 HTTP 请求是分开限流的:全进程同时最多 16 个工具调用(如果 max_concurrent 更小,则取更小的那个)。二者不是一回事——max_concurrent 统计的 是请求,其中大多数开销很低;而每次工具调用,要么是一次全图扫描、一次批量写入, 要么是一次向 LLM 扇出的 digest。而且传输层在工具调用刚被入队时就会应答,请求 上限在工作真正开始之前就已经释放了,根本管不到它。超出的调用会排队而不是直接 失败:服务器繁忙时该让 agent 等,而不是把它拒之门外——但不会无限期地等:每次 调用有一个覆盖排队与执行的截止时间,默认 300 秒,可用 DRSG_MCP_TOOL_DEADLINE_SECS 调整(0 关闭)。超时的调用以工具级错误返回,并说明 是服务器繁忙还是执行过慢;已在运行的调用体不会被中断,但会一直占着它的名额直到 结束,所以上限仍然算得上它。

工具

工具类别用途
list_planes读列出各平面及其节点/边计数
describe_plane读某个平面的软 schema(标签、属性、边类型、键前缀)
get_node读按 id 或外部键获取一个节点
search读语义查找——嵌入查询文本,返回最近的 k 个节点
context读已图化代码平面上一个符号的完整邻域——首选的智能体动词
describe读一个符号的属性,只看节点的轻量视图
grep读在被监视的源码树上做文本检索——字面或正则,可用 path 限定范围并带上下文行;每个命中都标出所在的符号;被 ignore 的文件除非指定 all_files 否则不在范围内
trace读两个符号之间图中记录的最短调用路径
impact读所有能到达该符号的东西,按距离分组,解析无法确定绑定的那些只计在总数旁边、从不计入总数;across(attached 或 all)还会指出这个半径触及了哪些其他仓库——依据它们构建声明的坐标,以及它们源码为之立下替身的符号
fathom读一个符号周边区域的构成——标签、边类型与枢纽
snippet读一个符号的源码文本,或文件的某个行区间(path:start-end);会说明区间落在哪个符号里、如何继续往下读
history读一个仓库历史的概览——HEAD 在哪、分支与标签指向何处、哪些分支被变基过,以及最新的若干提交;读的是 git 检出目录被图化时一并写入的 _git 平面
deployed读某个运行中的东西是由哪次提交构建的,以及哪些东西在跑某次提交——把清单被图化时写入的 _ops 平面,经由镜像标签所携带的修订号,连到它旁边的 _git 平面;浮动标签、只带 digest 的引用,以及这段历史并不持有的修订号,都会被如实说出来,而不是用“什么都没有”来回答
recall读某个 git 修订版本时的代码——一个文件、一个行区间、一个目录,或一个符号当时的声明(按当时的文件重新解析得到);pattern 在那棵树上检索,vs 给出一个名字相对更早修订版本的差异;修订版本经由 _git 平面解析
wire读什么在提供某个 HTTP 路径、什么在请求它——跨平面内的每种语言匹配路由,也跨它所附着的平面;每次调用时计算,从不存储
attach写记录本平面可以询问的平面:本实例上的,或由另一个 drsg 提供服务的(url);both 会把返程也记下来
detach写按附着时所用的名称移除一条附着
attachments读本平面附着了什么;probe 会逐个询问位于别处的附着现在是否应答
mount写立即把另一个数据库的平面与本数据库的一同提供服务,无需重启——一个进程服务多个仓库,每个仓库保留自己的存储、自己的写入者与自己的平面名。受 [server] mount_root 限制
unmount写停止提供某个已挂载的数据库,释放其存储的锁,并停止向它折叠的那个 watcher;freed 说明该锁是否已经释放——仍在进行中的折叠会持有它直到结束。不删除任何东西
mounts读本服务器提供了什么、各部分从何而来——每个挂载及其目录与平面,以及每个平面被解析自的那棵树;客户端正是据此把工作目录映射到平面
traverse读从某个节点进行邻域扩展(1 跳及以上)
query读运行一个序列化的逻辑计划
cypher读 / 写运行一条 openCypher 子集语句——任何具名动词都未预见的问题,由它兜底;DELETE/REMOVE 需要 confirm: true
algo读一个图算法(pagerank / components / shortest_path / louvain)
hybrid读融合的向量 + 关键词 + 图邻近度检索
ask读一个自然语言问题,编译为计划并执行
write_nodes写创建节点(批量)
write_edges写按端点键创建边(批量)
create_plane写创建一个空平面
drop_plane写删除一个平面及其内容(需确认)
digest写导入一篇文档(默认为 dry-run;apply: true 需要 confirm: true;mode 决定抽取精度)
rebuild写重新读取某个 plane 建立时所依据的整棵树,让一张被两套插件分别解析过的图重新成为一次完整的解析

与系统其余部分的映射

这些工具做的事,和命令行、JSON-RPC 接口能做的完全一样,只是照顾了智能体的使用 方式:search / traverse / query / cypher / algo / hybrid / ask 对应 第 4 章与第 3 章的查询与检索接口; write_nodes / write_edges / create_plane / drop_plane / digest 对应写入 与导入接口。每个工具都依据该平面的软 schema 行事,而 describe_plane 就是用来 暴露这份 schema 的,让智能体能在动手之前先了解这张图。

精确地指定一个节点

节点的外部键带前缀,而前缀恰恰是任何命名习惯都看不出来的那一段。Rust 键以 crate 为前缀(dr_strange_core::compact::resolve);TypeScript 键以最近一个 package.json 里声明的 name 为前缀,其后是该文件相对于这个清单所在目录的 路径,去掉扩展名——结尾的 /index 折叠为其所在目录,而根目录的 index 就代表这 个包本身。若文件之上没有任何 package.json,前缀取这棵树的标签,对本地目录来说 就是目录名。

于是有一条值得记住的推论:TypeScript 键并不以仓库目录为前缀。一个目录名叫 bridge、而清单里声明为 highway_bridge-3.0 的项目,它的键都在 highway_bridge-3.0/ 之下,按目录名去查将一无所获。

有两处让这件事可被发现,而不必靠记:describe_plane 会给出 key_prefixes——每个 产出方的节点实际使用的前缀,由扫描得来而非声明得来,因此不会与已存的键脱节;而一 次没有命中的查找,会列出该平面确实持有的前缀,因为名字是按键的片段匹配的: src/App 无需知道前缀,也能找到 highway_bridge-3.0/src/App。

安全

  • 提供方密钥从服务端环境读取,绝不从工具参数读取——智能体无法通过一次调用 外泄或提供密钥。
  • 读操作是非破坏性的。 尤其是 ask,它编译为一个只读计划,无法改动图。
  • 破坏性写入受到保护,增量写入则不受。 drop_plane、带 DELETE 或 REMOVE 的 cypher 语句、以及 apply: true 的 digest 都需要 confirm: true,缺少时会 拒绝并指出该标志;digest 默认为 dry-run,返回拟议的节点与边以供检视,而非直接 写入。write_nodes、write_edges 和 CREATE/MERGE/SET 无需确认:它们添加的 内容可由之后的删除撤销,而一个事事都要的标志只会被客户端不假思索地传上。
  • 文件只从源码树内读取。 grep 与 snippet 打开的每个路径都必须解析到它所属 的树之内:..、绝对路径、指向树外的符号链接一律拒绝。操作者挂接的树(serve watch 的目录或 [server] source_root)始终可读;平面自身的 synced_root 是 数据,stdio 服务以你的身份运行、可以照读,而共享的 drsg serve 只在它位于挂接 的树内时才读;未挂接任何树时,/mcp 不读任何文件。
  • 镜像平面不接受手工改写。 由 digest 或 serve watch 与源码树保持同步的 平面记录着它所反映的提交,cypher 在这类平面上拒绝 CREATE/MERGE/SET/REMOVE/DELETE:下一次折叠会把平面与源码树重新对 账,覆盖对解析器所有节点的修改、重建被删除的节点,这样的写入会被无声地撤销。 write_nodes / write_edges 在那里仍然可用——折叠不会动它不拥有的节点—— 而智能体自己的平面接受任何语句。

在同样这些镜像平面上,cypher 对 RETURN n 以智能体动词共用的紧凑文本作答—— 一个计数、同步的提交、每个节点一行——而不是每个节点一条 JSON 记录;投影查询在 任何平面上都返回表格。语句解析失败时,返回会附上它所触及的那几条子句的语法, 因此工具清单只需承载意图与两个例子,而不必每一轮都携带整门语言。

示例:一个智能体工作流

一次典型的智能体会话会组合这些工具:

  1. describe_plane,以了解作用范围内的标签、属性与边类型。
  2. search 或 hybrid 检索相关节点,再以 traverse 收集其邻域,为模型提供扎实 的上下文。
  3. ask 或 cypher,针对图回答一个具体问题。
  4. digest(先 dry-run,再应用),将新的原始材料作为实体与关系并入图。

由于存储的是一张图,智能体可以在同一个会话里从检索直接转入遍历——这正是 第 1 章所说的、由模型端到端驱动的 GraphRAG 循环。

插件

drsg digest 遍历代码仓库时,不会让模型去猜测代码的结构,而是把每个源码文件分发给 一个预处理插件去处理。插件是一个沙箱化的 WebAssembly 组件,内部跑的是编译器级 的解析器,返回的是事实:解析器确信无疑的节点与边。AST 不需要推断 parse() 调用了 lex(),它本来就知道。

插件位于独立的仓库 dr-strange-extension。 与数据库分开是有意为之:官方身份并不意味着步调必须锁死,解析器修一个缺陷不必等 数据库发版,数据库发版也不必等十条工具链都跟上。这个仓库就是扩展的公共地:官方 插件、契约的规范副本,以及编写自己插件所需的 SDK(后者是编码智能体 一章后半部分的主题)。

一次 digest 如何使用插件

路由器按扩展名分配文件:每个已安装的插件声明自己处理的扩展名集合(.rs、.py、 .html……),没有插件认领的文件回落到内置的文档读取器,作为散文交给模型。被认领 的文件如何处理由插件决定,分两个阶段:

  1. parse——宿主把分派的文件切成固定大小的块,对它们并行运行 parse, 每次调用一个全新的沙箱实例,彼此不共享任何状态。每次调用返回一个部分结果 (partial):宿主只负责搬运、从不读取的一段不透明字节。
  2. assemble——只调用一次,携带按块序排列的全部部分结果。跨文件解析 (导入、头文件、桶式再导出、接口满足)就发生在这里,在插件内部,因为这是语言 语义,而数据库拒绝持有任何语言语义。结果不得依赖块边界落在何处。

输入是拉取而非推送。插件拿到的是文件路径,需要什么就通过宿主读取,代码 解析器正是这样跟随一条导入进入相邻文件的。没有跨文件结构的格式可以把 assemble 当作拼接;SDK 恰好提供了这样的默认实现。

契约

插件与宿主之间的契约是一个小小的 WIT world:drsg:preprocess@1.0.0,规范副本位于扩展仓库根目录,drsg 侧存有受校验的 副本(任何副本漂移都会让 just check-wit 失败):

interface host {
  %list: func(suffix: string) -> result<list<string>, string>;
  read:  func(path: string) -> result<list<u8>, string>;
  label: func() -> option<string>;
}

interface preprocessor {
  describe: func() -> manifest;                          // name, version, extensions
  parse:    func(subject: input, options: list<tuple<string, string>>)
              -> result<list<u8>, string>;               // one chunk → an opaque partial
  assemble: func(partials: list<list<u8>>, options: list<tuple<string, string>>)
              -> result<output, string>;                 // all partials, in order → facts
}

这三个 host 函数就是能力授予的全部:插件能触及的只有写在这里的东西。 %list 返回被消化根目录下可读的路径,且已排序:未排序的目录顺序会让两次运行 的输出不同,而重新导入同一棵树本应得到同一张图。read 返回单个文件的字节,任何 解析后越出根目录的路径都会被拒绝(检查落在解析后的路径上,.. 与符号链接都绕不 过去);根目录之内、但 %list 不会列出的路径同样被拒绝——.gitignore、隐藏文件与 构建目录的规则对两者一视同仁,所以插件不能靠直接点名去读 .env 或被忽略的凭据 文件。label 在输入内容无法自述名字时提供一个名字。

插件的 output 包含节点、边、散文,以及一份报告:事实数、散文字符数、跳过 的输入数,外加一段说明什么没能解析、为什么没能解析的文字注记。计数并点名,而不是悄悄丢弃: 一张偏薄的图应当由它的报告来解释,而不是靠换参数重跑导入来排查。options 携带 操作者配置中 [plugins.<name>] 一节的插件自有设置,原样透传。

插件不得调用 LLM——这是规则

插件永远不调用语言模型。官方插件不调用,第三方插件同样不调用:这条规则适用于每 一个插件,而且由沙箱强制执行,而非仅仅要求作者自觉。沙箱没有网络 (wasi:sockets 在加载时按名拒绝)、没有环境变量,除三个宿主函数外也没有文件 系统,因此插件根本无从触及任何模型服务,也拿不到调用所需的密钥。

分工正是意义所在。插件的职责是解析器能证明的东西,且是确定性的;真正需要模型的 内容(一段文档注释的含义、一份 README、任何解析器无法断言为事实的东西)则作为 散文返回,由宿主的 digest 流水线决定是否交给模型,在操作者的密钥、预算与 --mode 之下进行。只产出事实的代码仓库,导入全程不发起一次模型调用。

这条界线在图里同样保持可见:

  • 解析得到的事实携带 _generated_by(如 rust@3+fdb01bb6:插件名、版本,加上产出 这条事实的构件——后缀是该 wasm 已钉住的 SHA-256 的前几个字节,因此同一版本插件的 不同字节产出的事实不会被读成同一来源)而非 _model,因此永远能与 模型的抽取区分开。当两者声称同一个键时,事实获胜,模型的主张被丢弃并计数。
  • 确定性是契约的一部分,不是一个愿望。沙箱冻结时钟、按固定序列发放熵、对目录列表 排序,因此同一棵树消化两次,得到逐字节相同的事实。插件内部的模型调用恰恰会破坏 这一点。

沙箱

每个插件都是运行在全面拒绝授权之下的 wasm32-wasip2 组件。插件的语言运行时可以 导入 wasi:filesystem(Go 的运行时在插件第一行代码执行之前就会这么做),但其 背后的预打开表是空的,什么也读不到;wasi:sockets 在加载时按名拒绝;时钟被冻结; 熵是固定的;每次调用都在指令与内存预算之下运行。

插件在沙箱里崩溃时,它写到 stderr 的内容、以及陷入的原因(trap code)本身,都会 一并收进报给操作者的错误信息,方便定位问题。插件的全部产出都以返回值的形式交回 宿主,写数据库的永远只有宿主。

一次 parse 调用只处理一个文件,所以插件在某个文件上栽了跟头,损失也就是这 一个文件:它会被跳过并计数,报告里会点名。最常见的元凶是生成代码——.pb.go 里那个 由上千段字符串拼接而成的描述符,会让递归下降的打印器一路走出插件链接时定下的栈; 这是插件作者要修的,任何宿主配置都抬不高那个栈。但如果插件在它认领的每一个文件 上都失败,那就是另一回事了,这一趟仍然会被判定为失败。

assemble 没有这层边界,这个差别值得记住。 它一次性地在全部 partial 上运行 一遍,因此那里的一次 trap 不是一个文件的损失:它会让整个插件失败,连带正在折叠这 棵树的那趟分发一起失败。于是,一个在仓库中占比微不足道的语言里的单个文件,就足以 让平面变空——而在 drsg init --rebuild 下(它会先丢弃平面再重新折叠),这是破坏性 的,而非一次无操作。在宿主把出错插件与整趟分发隔离开之前,请把 assemble 的失败 读作一次故障,而不是报告里的一处缺口:操作者的应对是先装回该插件的上一个版本,等 解析器修好。

预算可在 drsg.toml 中调整(第 2 章):

[plugins]
fuel = 200000000000    # 每次沙箱调用的指令预算(0 为不设限)
memory_mb = 3072       # 每次调用的线性内存上限,MiB;按照 wasm32 标准,最高支持 4096
deadline_secs = 300    # 单次调用的墙钟上限,秒(0 为不设限)
total_memory_mb = 6144 # 进程内所有调用合计可持有的线性内存,MiB(0 为 memory_mb 的两倍)
hostcall_mb = 1024     # 单次调用最多能从沙箱拷出的数据量,MiB(0 为 wasmtime 默认的 128)

[plugins.rust]         # 插件自有设置,原样透传
include_source = true

deadline_secs 是单次调用的墙钟上限(默认 300 秒,0 为不设限)——它不是工作量的预算 (那是 fuel 的职责,且是确定性的),而是关掉 fuel 之后兜住不返回插件的那张网。 total_memory_mb 是进程内所有调用合计可持有的线性内存(默认为 memory_mb 的两倍): parse 每核一次调用,只有单次上限的话,多核机器会被一个个 store 逐渐填满。两者都可以在 shell 里临时覆盖——DRSG_PLUGINS_DEADLINE_SECS 与 DRSG_PLUGINS_TOTAL_MEMORY_MB,单位与 0 的含义相同——环境变量优先于配置文件,与其他 DRSG_* 变量一致。被拒绝时错误会点名撞到的是哪一个上限。 关掉 fuel 也意味着每次加载都要从 wasm 重新编译插件(预编译产物带有 fuel 插桩),首次加载会提示一次。

hostcall_mb 限制单次调用最多能从沙箱拷出多少数据,包括 host 函数的参数和导出函数的返回值。 不写或写 0 时沿用 wasmtime 自己的 128 MiB。parse 每次只返回一个文件的中间结果,远在上限之下; assemble 一次返回整棵树,所以大仓库最先撞到它,这时报错会点名这个配置项: plugin `go` copied more out of the sandbox in one call than the 128 MiB limit allows。 在 drsg.toml 里调大:

[plugins]
hostcall_mb = 1024

这一项没有对应的环境变量。

拉取模型还带来一条边界:预处理在文件所在之处运行。CLI 与 stdio MCP 服务会经过 预处理;经网络送达共享 drsg serve 的字节保持为散文。唯一的刻意例外是 serve watch:操作者把服务端指向其自己机器上的一个仓库(这是一次明确的文件系统 授权),于是提交折叠会经过已安装的插件。

官方目录

十三个官方插件覆盖常用语言与两类横切来源,每个都包装一个成熟的解析器而非重新发明:

插件处理的扩展名底层解析器
rust.rssyn
go.goGo 自带的 go/parser,经 TinyGo 编译
ts.ts .tsx .mts .cts .js .jsx .mjs .cjsswc——同时支持 ESM 与 CommonJS
py.py .pyi .pywruff 的解析器
java.javatree-sitter-java
kotlin.kt .ktstree-sitter-kotlin-ng
csharp.cstree-sitter-c-sharp
c.c .htree-sitter-c
web.html .htm .csstree-sitter html/css/js——一个插件同时处理,class="btn" 才能绑定到定义 .btn 的样式表
toml.tomltoml
xml.xmlquick-xml——按文件自己声明的方言
git(一个 git 检出目录)直接读取 git 自己的对象库,用 flate2 解 zlib 流。它按“来源是一个检出目录”分发,而非按扩展名;写出的历史平面就放在代码平面旁边
deps(清单文件,按文件名)toml_edit 与 quick-xml:package.json、go.mod、pyproject.toml、requirements.txt、pom.xml。清单是一个文件名而非扩展名——Cargo.toml 仍归 toml 插件

每个插件以 <plugin>-vX.Y.Z 标签按自己的节奏发布;CI 构建组件并在 发布页发布 <plugin>.wasm 与其 SHA-256。

这些 Release 的清单——catalog.json——与插件一同放在 extensions 仓库,而不在这个 二进制里。这正是要点:编译进 drsg 的目录会让每次插件发布都变成一次 drsg 发布,而 drsg 只是在重复一个既有事实。因此 drsg plugin install 去抓取它,一个插件名就足 以完成安装:

$ drsg plugin install rust          # 目录中的一个名字
$ drsg plugin install              # 或交互式列表,0 = 全部
$ drsg plugin list --available     # 目录本身,并标注本地安装状态

抓取这份清单并不意味着盲目相信它。每个条目固定制品的 SHA-256,在下载后、把字节 当作组件看待之前就先校验;每个条目还说明自己面向哪种宿主——contract 是它构建时 依据的 WIT world,min_drsg 是它声称可用的最低宿主版本。本构建无法满足的条目会 连同原因一起列出,而不是被隐藏:一个从列表里悄悄消失的插件是一个待答的支持 问题,而「需要 drsg >= 3.0.0」本身就是答案。多个条目可以共用一个名字,这正是一个 插件继续服务旧宿主的方式——每个宿主安装它能运行的最新条目。

每次成功抓取都会缓存在已安装插件旁边,因此离线状态下的 drsg plugin install 仍 会列出清单并说明它有多旧。既无缓存又无网络时,它会报错并给出该 URL——因为不带目录 也一样能装插件:一个路径或一个 URL 不需要任何清单。

在代理之后,或完全没有网络

drsg plugin install 遵循 ALL_PROXY、HTTPS_PROXY 与 HTTP_PROXY(同时按小写 形式读取),也遵循 drsg.toml 中的 [network] proxy;参见 快速开始。socks5:// 与 http:// 同样受支持。

当运行 drsg 的机器完全没有出网路径时,改从文件安装。目录只是一张查找表,制品的完整 性并不依赖于是否抓取过它——两种方式都会校验 SHA-256:

# 在一台能访问 GitHub 的机器上——目录同时给出制品地址与其哈希,
# 因此应当从目录里读取,而不是去猜测发布路径。
# 同一名字可能有多个条目,各对应一代宿主;挑选你的 drsg 能运行的那个
# (`drsg plugin list --available` 会打印这一判断过程)。
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange-extension/master/catalog.json \
    | jq -r '.plugins[] | select(.name=="ts") | "\(.version)\t\(.url)\t\(.sha256)"'
2.0.0	https://github.com/…/ts.wasm	9f86d081…

$ curl -fLo ts.wasm 'https://github.com/…/ts.wasm'
$ echo '9f86d081…  ts.wasm' | sha256sum -c -
ts.wasm: OK

# 把 ts.wasm 带过去,然后在离线机器上:
$ drsg plugin install ./ts.wasm

从路径安装同样会把该文件的 SHA-256 固定在插件库中,与从目录安装别无二致,之后每次 加载都会重新校验。请在安装之前自行核对校验和:从路径安装无法与一份从未抓取过的目录 条目作比对,因此这一步正是目录本来会替你完成的那一步。

安装时会在插件库中固定制品的 SHA-256,之后每次加载都重新校验,磁盘上被改动的文件 会被拒绝而不是被悄悄运行。对同名插件再次安装即是升级路径。

在图建好之后才装上的插件

安装插件不会回头改写一张已经存在的图。被监视的仓库是一个提交一个提交地折叠的, 因此新解析器只作用于此后各次提交所触及的文件,而未被触及的文件仍保留旧插件集的产 物——半棵树是这样解析的,另外半棵是那样。这并不是损坏,这张图只是两次解析的混合。

所以每个 plane 都会记录它建立时所安装的插件构建,并在两者不再一致时说出来:

synced: commit ec31cac58593
plugins changed since this plane was built (added since: ts@2+d99f6db4) — files
the difference claims are parsed only where a later commit touched them; rebuild
to make the graph whole

这一行会出现在从该 plane 读出的每一个回答里,包括「没有符号匹配」——而那正是它最要 紧的地方,因为一次「找不到」既可能意味着那个符号不存在,也可能意味着它所在的文件从 未被解析过。

比较用的是完整身份 name@version+build,因此同一版本重新构建过的插件也算作变化: 同一版本以不同方式构建,解析结果可以不同。两个方向都会报告——added since 指向其 所声明的文件从未被读过的解析器,no longer installed 指向图中那些来自插件库已不再 持有之物的事实。

解药是重建,它会重新读取整棵树:

$ drsg init --rebuild

读到这条警告的 agent 无需离开对话即可执行它,用 MCP 的 rebuild 工具——它只接受一个 plane 名字,并读取该 plane 自己记录的目录,因此无法被指向别处。无论用哪种方式,一旦 图重新成为一次完整的解析,警告都会自行消失。

在 drsg 开始记录这项信息之前建立的 plane 什么也不会说:未知并不等于已偏离,而对每一 张更早的 plane 都发出警告,只会教会读者忽略这条警告。

安装还会把插件编译一次,并将编译产物保存在 wasm 旁边,以它自己的哈希固定。此后 每次加载——每次 digest,serve watch 折叠的每个提交——都在毫秒内反序列化这份 产物,而不再重新编译;对官方解析器而言,编译意味着数秒 CPU 和数百 MiB 内存。 编译产物只属于生成它的那个 drsg 构建:drsg update 之后,或在它出现之前建立的 插件库中,第一次加载仍会像以前一样编译,并把结果保存下来供下一次使用。

每个官方解析器的承诺

十个解析器是一个家族,遵循同一条纪律:

  • 键使用语言自己的全限定名:crate::module::fn、pkg.Type.Method、 file.c::func、index.html#map,绝不发明 id。
  • 一切都携带位置:定义带 file 与 line,边带它书写所在的行号。
  • 已解析的边说明自己如何被解析:每条边携带 _resolved_by(命中了哪条规则)、 _confidence(一个档位,不是小数)与 _ref(调用处的原文)。
  • 无法确知的东西被计数,绝不猜测。 源码没有声明接收者类型的调用,会成为带 _reason 的 UnresolvedRef 台账条目(可在图中查询、在 context 中呈现), 而不是一条貌似合理的边。错误的边把智能体引向错误的地方;诚实声明的缺失,把它 引向 grep。
  • 树外之物用替身表示,以书写原文作键、标记 External:记录"这段代码使用了 那个东西",而不假装读过从未见过的代码。

这些承诺正是编码智能体一章的立足点,也是社区插件应当保持 的,那一章的后半部分会逐步演示。

编码智能体

上一章讲了解析器插件如何把一个代码仓库变成事实。这一章讲这些 事实能为编码智能体带来什么,以及如何为官方目录尚未覆盖的语言构建你自己的插件。

四条命令,就能把一个代码仓库变成可查询、随提交同步的图:

# 安装解析器插件:可以给出官方目录中的名字;不带参数时打开该目录的交互式
# 选择器(0 = 全部);也可以直接给出任意 .wasm 路径或 URL。
$ drsg plugin install

# 将一个代码仓库图化为一个以其命名的平面
$ drsg --db codes.drsg digest ~/src/myrepo --apply --no-embed

# 启动 API + MCP 服务,并让平面随每次提交保持同步
$ drsg --db codes.drsg serve watch --dir ~/src/myrepo

# 一个符号的完整邻域,一次调用
$ drsg --db codes.drsg context 'WriteTxn::delete_node' --plane myrepo

--no-embed 跳过向量嵌入,因为解析本身不需要任何模型。之后运行 drsg vectorize, 平面就能支持语义检索。

drsg init 把图化和启动服务这两步合并成一条命令。用它之前插件要先装好;在 代码仓库自身目录下运行后,它会把工作目录图化为一个以其命名的平面,在后台启动一个 serve watch 进程(监听地址随机选定,令牌也随机生成),并写入 .mcp.json。这是 Claude Code 自己的约定,GitHub Copilot 也会原样读取它。随后,它还会为 Cursor、 OpenCode、Gemini CLI、Codex CLI、Trae 或 Kimi Code 各自写入一份匹配的 MCP 配置——但只有这个工具自己的标记(它会创建的目录,或者已经拥有的配置文件)已经 出现在这个仓库里、或者出现在这台机器上,才会写入。Qoder 与 CodeBuddy 不需要 额外的文件:两者都读取项目自己的 .mcp.json,所以 init 只把这一点说出来, 而不是再写一份。

令牌写在哪里,取决于每个客户端能做什么。.mcp.json、.cursor/mcp.json 和 .trae/mcp.json 直接携带令牌——Cursor 和 Trae 都是从桌面启动的 IDE,没有 shell 环境可供读取——这三个文件都会进入 init 维护的 .gitignore 段落。 .opencode.json 与 .gemini/settings.json 在 init 之前就存在,很可能已被 提交,因此它们改为按各自客户端的语法写入一个环境变量引用({env:DRSG_TOKEN}、 $DRSG_TOKEN),正如 Codex 条目一直采用的 bearer_token_env_var,以及 Kimi Code 通过写下变量名的 bearerTokenEnvVar;init 会打印出在这里启动这些 客户端之前需要执行的 export DRSG_TOKEN=$(drsg token --unhide)。这一行从 .mcp.json 里读出令牌,而不是把令牌本身打印出来,因为 init 的输出会留在终端回滚、 CI 日志和智能体的对话记录里。负责读回令牌的是 drsg token,因此这一行不依赖任何额外 安装的工具;当这个目录尚未运行过 init 时,它也会直接说明该怎么做。

drsg token 默认只打印遮蔽后的形式——AAAA…LmXw <40 characters>,足以分辨不同 仓库持有的是哪一个令牌,又不足以拿去使用。--unhide 才会打印令牌本身,专供上面那种 命令替换使用:此时令牌直接进入环境变量,不会出现在屏幕上。若令牌太短,两端字符就会 泄露过多,这时会被整体遮蔽。

Trae 把项目级 MCP 放在 Settings > Beta > Enable Project MCP 之后,没有打开 这个开关时,init 写入的文件不会生效;init 打印的那一行会提醒这一点。

对 Claude Code,它还会安装两个钩子:写在仓库的 .claude/settings.local.json (每个用户自己的文件,不会进入团队共享的代码树)里,脚本放在插件库旁边。配置 告诉智能体图在哪里;钩子告诉它何时去用。SessionStart 钩子把规则放进智能体 的上下文——每个代码问题先问 drsg,shell 是最后手段——恢复会话和压缩上下文之后 它仍然在。PreToolUse 钩子在智能体对代码执行 rg、grep、cat 或 sed -n 的那一刻,改为指出能回答这个问题的动词。命令前加一个 DRSG_RAW=<理由> 即可原样 执行,而说出理由就是它全部的代价:git 指历史,outside 指任何 plane 都不持有 的文件,mine 指自己的日志,logs 指程序输出,lag 指本次会话刚改过的文件。 文档与配置没有对应的理由:plane 挂载的代码树持有其中的每一个文本文件,grep 和 snippet 回答 markdown 或 TOML 的问题与回答 Rust 的问题一样直接,而曾经声称相反 的 =notcode 已经废弃——它会在被输入的那一刻得到纠正,而不是被放行。不带理由的 DRSG_RAW=1 留给以上都叫不出名字的那一种情况,并且会被计数而不是默许——每第 二十五次重新打印一遍动词表,随后紧接着的重试照常通过(间隔由 DRSG_NAG_EVERY 决定)。重定向和 heredoc 是写入,直接放行;而模式里带箭头(rg '>')或带这个 逃生口本身(grep -n DRSG_RAW=1 x.rs)的搜索仍然是搜索,照样会被改道——只有真正 位于命令开头的环境变量赋值才算逃生口。其他宿主没有钩子机制,它们从服务端自身的 MCP instructions 里得到同一条规则——每个宿主都会把它放进系统提示。

$ drsg init
plane 'myrepo' bootstrapped — serve watch pid 48213, http://127.0.0.1:51900/mcp
  + wrote ./.mcp.json
  + Claude Code: hooks in ./.claude/settings.local.json — a shell search or read on code is redirected to the drsg tools (DRSG_RAW=git|outside|mine|logs|lag <command> passes unremarked; bare DRSG_RAW=1 is counted)
  + Cursor: wrote ./.cursor/mcp.json
  + Gemini CLI: wrote ./.gemini/settings.json (no token inside it — export DRSG_TOKEN=$(drsg token --unhide) before launching `gemini` here)

令牌的去向取决于各客户端能做什么:.mcp.json 与 .cursor/mcp.json 直接写入令牌 (Cursor 是桌面应用,启动时没有可读取的 shell 环境),并被加入 init 维护的 .gitignore 块;.opencode.json 与 .gemini/settings.json 在 init 之前就已存在、 多半已提交,因此只写入该客户端自身语法的环境变量引用({env:DRSG_TOKEN}、 $DRSG_TOKEN),与 Codex 条目一样——init 会打印启动这些客户端前要执行的 export DRSG_TOKEN=…。

服务进程没了,就再运行一次 drsg init。 它在后台启动 serve watch,并把这个 进程的地址与令牌记录在 .mcp.json 里;但没有任何东西会重启它——MCP 的 http 条目 告诉客户端去哪里连接,而不是去启动什么,所以没有哪个智能体会把它拉起来,它也不会在 重启、崩溃或被杀掉之后存活。重新运行 init 就是恢复的办法,而且任何时候运行都是 安全的:它会先探测记录下来的端点(GET /health,服务端正是为此让这个端点免鉴权), 服务还活着就原样保留、连数据库都不打开,服务已经死了则用同一个地址和令牌重启, 并且不重新解析源码树——平面会从它记录的提交继续。

$ drsg init                       # 已在运行:无事可做
drsg is already serving . at http://127.0.0.1:51900/mcp — reusing it, the plane is untouched

$ drsg init                       # 重启机器之后:地址与令牌都不变
plane 'myrepo' restarted — serve watch pid 51002, http://127.0.0.1:51900/mcp

复用这条路径上不打开数据库,正是它可以安全地对着一个已在运行的服务执行的原因:一个 平面同一时刻只允许一个进程打开,若 init 为了检查而去打开它,那本身就成了它要避免的 那次冲突。重启前后每个智能体的配置都依然有效,因为地址和令牌是从 .mcp.json 里恢复 的,而不是重新生成的;如果那个记录下来的端口此时已被别的进程占用,init 会换一个空闲 端口并明确说明。想让端点彻底固定下来,就在 drsg.toml 的 [server] 下写死 addr 与 token。

--rebuild 会丢弃平面并重新读取整棵源码树。 一个源文件意味着什么,是由解析器 插件决定的;因此升级过的插件只有在平面重新经它折叠之后才会体现出来——续跑的平面对 那些此前已经看过的文件,保留的仍是旧解析器的理解。--rebuild 会先停掉持有数据库的 服务进程(它同时也持有每个插件的旧副本),从整棵树重建,然后在同一地址、同一令牌上 回来,因此任何智能体的配置都不会在脚下发生变化:

$ drsg plugin install rust        # 更新版本的解析器
$ drsg init --rebuild
stopping serve watch pid 51002 — the plane is about to be rebuilt
plane 'myrepo' rebuilt — serve watch pid 51188, http://127.0.0.1:51900/mcp

一如既往,只重建事实:嵌入向量会在下一次 drsg digest 时回来。

所以:每个仓库运行一次,此后只要服务没了就再运行一次;而当某个插件改变了源码树的 含义时,就带上 --rebuild 再运行一次。

事实为智能体带来什么

依赖 grep 工作的智能体,每个问题都要重建一遍结构:搜索、打开文件、阅读、推断谁 调用了什么,再来一遍。已图化的平面把这份工作提前做完了,只做一次,由解析器完成。 于是结构性问题就变成了一次往返,而不是一轮搜索加阅读的循环。

十二个动词承担主要工作,在 MCP(第 8 章)与 CLI(第 7 章) 上完全一致——grep、snippet 与 recall 读取平面被解析自的那棵源码树,平面若未 记录,则由 --root 指明:

动词它回答的问题
context关于一个符号的一切——定义、带调用位置的调用者、被调用者、引用,以及在没有任何边相连时按跨度包住它或被它包住的那些节点——首选动词
search“我不知道名字”:在平面的向量嵌入上做语义 top-k
describe一个符号的属性——只看节点的轻量视图
grep在被监视的源码树上做文本检索,文档与配置和代码一样在内——字面或正则,可按路径限定、带上下文行,每个命中标出所在符号;该树的 ignore 文件排除掉的内容除非指定 all_files 否则不在搜索范围内;有界且带计数
trace一个符号如何到达另一个:图中记录的最短调用路径
impact影响范围:所有能到达该符号的东西,按距离分组,解析无法确定绑定的那些只计在总数旁边、不计入总数——加上 --across 后,还包括这个半径触及了哪些其他仓库
fathom一个符号身处怎样的地方:几跳之内的区域,按标签与边类型计数,并给出枢纽
snippet一个符号的源码文本,或文件的某个行区间(path:start-end)——智能体不再需要的那条 sed -n
recall某次提交时代码的样子——一个文件、一个符号当时的声明、那棵树上的检索,或两个修订版本之间的差异——智能体不再需要的那条 git show 与 git log -p
history代码背后的仓库:HEAD 在哪里、分支与标签指向什么、哪些被变基过,以及最新的提交
deployed某个运行中的东西是由哪次提交构建的,以及哪些东西在跑某次提交——把清单构成的运维平面连到它旁边的历史平面
wire什么在提供某个 HTTP 路径、什么在请求它——跨平面内的每种语言匹配,也跨它所附着的平面

grep 与 snippet 只打开它们所属那棵树之内的文件——服务端所监视的目录,或某个 平面被摄取时所依据的那个目录——并拒绝任何离开它的路径(..、绝对路径、指向检出 之外的符号链接);stdio 与共享服务端在这一点上的差别见第 8 章。

每个回答都是紧凑的、每行一条事实的文本,尺寸是按模型的上下文窗口定的,而不是按 终端。context 会收紧各分组的条数上限,把自己保持在固定预算之内,并写明省略了 什么。

在 serve watch 之下,图会跟踪每一次提交。变更过的文件重新经过插件,平面随之 就地更新:新符号被创建,消失的符号被删除,边被改写,最终收敛到和一次全量重新 图化完全相同的结果。每个回答都以 synced: commit <sha> 开头,智能体因此知道 自己推理的是哪一版代码。工作区里未提交的修改,在真正提交之前都不可见,而回答 会通过注明提交号把这一点说清楚。

历史是另一半。对 git 检出目录的图化还会写入 <name>_git,而 recall 能读出该 平面所记录的任意一次提交时的代码——按 sha、分支、标签、日期或 HEAD~n 指定—— 于是“重构之前这个函数是什么样子”只需一次调用,无需检出旧版本。符号的位置由当时 的文件重新解析得出,而不是沿用今天的行号;那次提交中并未声明的符号会被如实报告为 不存在,并列出该文件当时声明了什么。

运维是第三个平面。对 Compose 文件、Kubernetes 清单或 Terraform 配置的图化会写入 <name>_ops——工作负载、容器、它们运行的镜像,以及底下那一层基础设施——而 deployed 通过两端都会指名的那一个字符串,也就是镜像的标签,把它连到历史上。 只要流水线把修订号写进了那个标签,**“这个服务跑的是哪次提交”**就只是一次调用。 若没有写进去,答案会说清是哪一种原因——浮动标签、只锁定字节而不说明来历的 digest、这里的历史并不持有的修订号——因为一个对这三种情况都回答“什么都没有”的 动词,会让读者无法区分缺口与事实。平面是硬命名空间,所以这是对两个平面的一次 读取,而不是它们之间的一条边。

路由:图不再止于一个仓库的地方

一条调用边需要解析器看得见的被调用方,而一次 HTTP 请求没有:页面调用 /api/v1/orders/42,处理它的却是另一种语言写的控制器,往往还在另一个仓库里, 两者之间只有一个字符串。这样的图里两个文件都在,却没有任何连接。

每种语言声明路由的写法各不相同——带路径的注解(@GetMapping、@app.get、 #[get])、以方法名表示动词的注册调用(app.get('/x', h)、r.GET、 http.HandleFunc),或是带路径参数的客户端调用。插件把这三种写法统一记录到 本就存在的那个符号上:声明记为 serves,调用记为 requests,都是 VERB /path/{} 的形式,每个可变段都归约为 {}。目前 Go、Java、Python、Rust 与 TypeScript 插件会写入它们。

图中不会新画任何东西。没有 Route 节点,也没有 SERVES 边——合成出来的边会把 一个猜测固化下来,而运行时拼出的路径正是猜测。wire 在每次调用时计算这一对应 关系,并报告它无法匹配的部分:到不了任何声明的请求、没有人请求的声明(一个死端点, 这本身就有价值),以及源码中从未声明的前缀。未写出动词的一侧会匹配该路径上的 所有动词,并且会把这一点说出来。

attach 是另一半。前端与后端是分开的仓库,因此也是分开的平面;一条附着记录下 把它们连起来的那一条事实——本平面可以询问那个平面。它自身不携带任何词汇:问题 是什么、两侧按什么键匹配,都属于提问的那个动词——因此任何需要跨图的动词读的都是 同一条记录。

$ drsg attach fe --plane be --both     # 本实例上的两个平面,双向
$ drsg attach front --plane be --url http://127.0.0.1:36500 --as fe
$ drsg attachments --plane be --probe  # 它们现在各自是否应答?

本实例上的平面直接读取。由另一个 drsg 提供服务的平面通过 plane.query 读取—— 那是每个 drsg 早已提供的读取方法——因此跨实例既不新增协议,也不新增 SDK 方法; 联系不上的附着会连同原因一起列出,而不是被跳过。每一行都会标明它来自哪张图, 因此跨越两个仓库的回答仍然说得清每条事实住在哪里。

“附着”的两种含义。 源码树处于同步状态的平面也被称为已附着 (serve watch、synced_root),那指的是一个平面背后的那棵树;而这里的附着 指的是本平面可以询问的另一个平面。两者互不相关。

一个公共库,以及依赖它的那些仓库

「改这个类会影响哪些业务仓?」问的正是 impact 早已回答的那个问题,只是往外 挪了一个仓库。公共库里一个类的改动是局部的,它的后果却不是;而在单个平面里走 出来的半径看不见那些后果。

impact --across 把这个问题也在其他图里回答,依据的是两侧本就已经陈述了的 两个键——没有新提取任何东西,也没有任何插件需要改动:

连接本平面持有依赖方持有
构建Manifest.declares——它自己的 manifest 所发布的坐标一个以该坐标为键的节点,version 与 scope 记在 DEPENDS_ON 边上
代码被改动的符号,以及它半径内的每一个键同一个键的替身,带 External 标签

坐标是从本平面读出来的,绝不是映射上去的。一个 Maven 坐标 (com.acme:common-base)与一个 Java 包(com.acme.common…)之间,不猜就无法 互相映射——这正是 deps 插件记录构建依赖、却拒绝把它和任何 import 连起来的原因。 而站在这个库里就无需去猜:它自己的 pom.xml 说明了它发布哪个坐标,而依赖方的 pom.xml 给它的声明用的是同一个键。

$ drsg impact com.acme.common.BaseEntity --plane commonlib --across all
com.acme.common.BaseEntity  Class  src/main/java/com/acme/common/BaseEntity.java:3-6
depth 1 (1):
  com.acme.common.Dated  Class  src/main/java/com/acme/common/Dated.java:3-5  [EXTENDS]
total affected within depth 3: 1
note: recorded call edges only — calls the parser could not resolve (dynamic dispatch, untyped receivers) are absent, so this is a lower bound.

across  commonlib, svcorders, svcbilling
in svcorders
  declares  com.acme:common-base  2.4.1  [runtime]  pom.xml
  com.acme.common.Dated  ← 1
    com.acme.orders.Order  Class  src/main/java/com/acme/orders/Order.java:5-8  [EXTENDS]
in svcbilling
  declares  com.acme:common-base  2.3.0  [runtime]  pom.xml
2 graph(s) read, 2 hold(s) something: 2 declare(s) a dependency, 1 reach(es) it in code
1 of the 2 key(s) offered are referenced elsewhere

svcorders 是经由 Dated 而非被改动的那个类本身被触及的——那是半径所持有的一 个键,而不只是它的种子。svcbilling 声明的依赖停在一个更旧的版本上,并且完全 没有用到它,这同样是一个值得拿到的回答。

两种范围,因为有两种环境以不同方式提出这个问题。只写 --across 读的是本平面所 附着的那些平面:显式,而且这是唯一能到达另一个 drsg 的范围。--across all 读的 是本实例持有的每一个平面——于是一台被交付了三十个仓库的机器无需任何配置就能被跨 仓追问;空平面与 …_git 历史平面不在其中。两种范围都会说明自己读了哪些图,以及 哪些读不到。

不写 --across 时,回答逐字节不变。wire 无需被要求就跨图,因为一条路由的两半 天然住在不同的仓库里;而一个半径在单个仓库之内本就是完整的——加入更多图会改 变那个总数的含义,所以由调用方来说要看多远。

由另一个 drsg 提供服务的图回答的是节点,因为 plane.query 返回的就是节点,而版本 是一条关于边的事实。于是它报告自己持有哪些键——这本身就是「它内部有东西引用了这些 键」这句陈述,因为替身只会在有引用把它铸出来的地方存在——并指出读取其余部分的那次 调用:

across  commonlib, far
in far  at http://127.0.0.1:36600
  declares  com.acme:common-base  (the manifest and its version are read there)
  com.acme.common.Dated  ← referenced; what references it is read there
  ask that drsg for the rest: `impact <key> --plane svcorders`

它还守住了另一条区分:持有某个键却不带 External 的图并不是依赖方,而是同一份 代码的又一份——被 vendor 进来,或者被图化了两次——它会被报告为 holds its own,而 不是被算作一个使用者。

诚实是基石

智能体会依据工具的回答直接行动,所以这个家族的规则——错误的边比缺失的边更糟—— 塑造了每一个回答:

  • 歧义的名字从不猜测。 两个符号都叫 delete_node?回答是候选清单,用精确键 重试只多花一次调用——比一个被信心十足地采纳的错误答案便宜得多。
  • 调用清单是一个明示的下界。 解析器没能解析的调用,会变成带原因的 UnresolvedRef 条目,直接呈现在 context 里。于是"谁调用了它"这个问题,回答 会带着未解析的残余一起返回,而不是被悄悄缩短。
  • 图在回答之内点明自己的盲区:诚实脚注、synced: 行、省略计数。智能体可以 自行决定何时回落到 grep;grep 动词就在同一套工具里,回落只是多一次往返, 不用换工具。

在与 ripgrep 工作流、以及两款开源代码图 MCP 工具的基准对比中,条件是一致的: 同样的语料、同样的任务,每个智能体只用一种工具。结果是,这套组合完成了每一种 任务形态(调用者、影响面、调用链、复合审计),每项任务只需 2–4 次工具调用, 边际 token 开销最低,而且是唯一一个会在回答中明示自身边界的工具。方法与完整 表格见 AGENT-BENCHMARKS.md。

为新语言构建插件

插件系统是开放的:依照 SDK 构建的社区解析器,与官方插件以完全相同的方式安装、在 完全相同的沙箱中运行,遵循同一份契约与同一条 不得调用 LLM 的规则。每个官方插件都 遵循同一种模式,也是给新插件最值得借鉴的建议:包装一个成熟的、最好是该语言 公认的解析器(syn、swc、ruff、tree-sitter),而不是自己动手写一个;把解析器 保持成普通的原生库,外面只套一层薄薄的组件封装,这样它的测试完全不需要 wasm 工具链。

Rust

依赖 SDK,实现两阶段契约;没有跨文件工作的格式也可以只实现单函数的简化接口:

[dependencies]
dr-strange-ext = { git = "https://github.com/wangyingsm/dr-strange-extension" }

[lib]
crate-type = ["cdylib"]
#![allow(unused)]
fn main() {
use dr_strange_ext::{Input, Manifest, Output, OutputExt, Simple, host, node, output, simple_plugin};

struct MyPlugin;

impl Simple for MyPlugin {
    fn describe() -> Manifest {
        Manifest { name: "mine".into(), version: "1".into(), extensions: vec!["xyz".into()] }
    }

    /// One subject at a time; the SDK derives parse/assemble from this.
    fn process(subject: Input, _options: &[(String, String)]) -> Result<Output, String> {
        let mut out = output();
        if let Input::Files(paths) = subject {
            for path in paths {
                let bytes = host::read(&path)?;
                out.nodes
                    .push(node(&path, "Thing").prop("bytes", bytes.len() as i64).build());
            }
        }
        Ok(out.finish())
    }
}

simple_plugin!(MyPlugin);
}
$ cargo build --release --target wasm32-wasip2
$ drsg plugin install target/wasm32-wasip2/release/my_plugin.wasm

真正的语言解析器会直接实现生成出来的 Guest trait:parse 每块返回一个序列化 的部分结果,assemble 在全部部分结果之上做跨文件解析,还会通过宿主绑定拉取 相邻文件。官方的 plugins/rust 就是一个现成的完整范例。

Go

实现 ext.Plugin 接口,用 TinyGo 构建(≥ 0.41,wasm-tools 需在 PATH 上):

package main

import ext "github.com/wangyingsm/dr-strange-extension/sdk/go"

type mine struct{}

func (mine) Describe() ext.Manifest {
    return ext.Manifest{Name: "mine", Version: "1", Extensions: []string{"xyz"}}
}

func (mine) Parse(subject ext.Subject, options map[string]string) ([]byte, error) {
    // Pull files via ext.List / ext.Read; serialize your partial.
    return []byte{}, nil
}

func (mine) Assemble(partials [][]byte, options map[string]string) (ext.Output, error) {
    return ext.Output{Nodes: []ext.Node{{Key: "k", Label: "Thing"}}}, nil
}

func init() { ext.Register(mine{}) }
func main() {}
$ tinygo build -target=wasip2 -scheduler=none -gc=leaking \
    --wit-package ./wit --wit-world drsg:preprocess-build/plugin-go -o mine.wasm .

这些构建参数每一个都有各自的作用(扩展仓库的 justfile 解释了原因)。Go SDK 里贯穿一条规则:凡是从 ABI 里取出来的数据,要先复制再使用,因为 cm 切片只是 一个视图,垃圾回收器可能在你使用的时候把底下的数据挪走。

好解析器要保持什么

家族承诺虽是约定,但智能体的信任建立在 它们之上,新插件应当逐条保持:用语言自己的全限定名作键、每条事实带 file/line、 每条边带解析印记、用未解析台账代替猜测、树外代码用 External 替身。用该语言 生态中的真实源码对解析器做原生测试。构建前运行 just check-wit,确保契约的受控 副本与规范副本一致。

想把它提供给所有人:贡献先从在扩展仓库 开一个 issue 开始,写明你打算包装的解析器,再以 plugins/<名称>/{parser,component} 的形式落地,并附带原生测试套件。CI 会在每次推送时构建所有组件、运行所有测试。

架构

本章阐述其内部架构。Dr Strange 以清晰的分层构建,每一层与下一层之间仅有一道狭窄的 接缝,因此引擎既可以嵌入使用,也可以对外提供服务,还可以在存储层替换实现,而不 影响上层。

分层

层职责
存储具备 MVCC 快照的持久键/值,置于一个后端 trait 之后
缓存解码对象的记忆化,以提交序号打版本戳
计算计划执行、图算法、混合检索融合
APIDatabase / PlaneHandle 接口:查询、写入、索引、时间旅行、变更流、快照
平面为一切数据划定范围的贯穿性命名空间

在核心之上,是各个包装层:JSON-RPC Web 后端与仪表盘、各语言 SDK、命令行、MCP 服务, 以及为自然语言查询与导入提供动力的 LLM 层。

提交序号

有一个原语贯穿每一层:提交序号(commit sequence),即赋予每次已提交写入的 单调递增编号。它是存储引擎的 MVCC 版本、缓存的一致性戳、索引伴生文件上的新鲜度戳、 时间旅行读取的地址,也是每个变更事件所携带的序号。靠这一个引擎级的单一时钟,各层 就能保持一致,无需另设协调机制。

存储

图层针对一个后端 trait 编写:begin_read 产生一个稳定的 MVCC 快照,begin_write 产生一个串行化的单写者事务。在这层之上,是一组固定的逻辑表(节点、边、正向/反向 邻接、标签与属性索引、外部键、平面元数据)。键采用大端序,字节序也就是扫描序, 因此每个按平面或按节点划定的范围,都是一段连续的前缀。

默认后端是一个手写的 LSM 引擎:

  • 一次写入先追加到预写日志(WAL):一条带长度前缀、CRC32 校验的批记录,在变更 发布之前会先 fsync。写入随后进入一个按提交序号打戳的版本化内存 memtable。
  • 当 memtable 超过阈值,就会刷写成一个不可变、有序的 SST 文件(每个 SST 各带 一个布隆过滤器),同时轮换 WAL。
  • 一次读取把 memtable 叠加在各 SST 之上(最新的 run 在前),返回其中第一个不高于 该快照的版本。
  • 合并压缩(compaction) 把累积的 run 合并为一个,回收已无任何读者能观测到的 版本。

由于每个版本都携带其提交序号,且 SST 不可变,读者在其整个生命周期内不持有任何锁; 旧快照的数据在被合并回收之前,也始终可以访问。

时间旅行与保留

时间旅行几乎是从 MVCC 设计中免费得来的。begin_read_at(seq) 可以把一次读取钉在 任意历史提交序号上;读取路径本来就只返回不高于某个快照的版本,因此无需改动即可 服务这次请求。唯一的要求是历史版本能在合并压缩中存活下来,办法是用一个保留 窗口给合并 GC 设定下限(默认无界;换成有界窗口就是用历史换磁盘空间)。解析 时间戳地址时,会对每次提交记录的墙钟时间做二分查找,定位到对应的那次提交。只有 原生后端保留旧版本,因此时间旅行是原生独有的。

缓存

执行器从不直接接触存储,而是通过一个双层缓存读取。L1 缓存的作用域是单次 查询:在查询的生命周期内,它会记忆化已解码的节点、边与邻接,并都绑定在该查询的 快照上。L2 缓存跨查询共享,是一个有界的、打了版本戳的对象缓存:只有当某个 条目的戳与读者的提交序号一致时,才会返回它。因此任何一次写入,都会让面向更早 快照的旧条目静默失效。正是这条精确匹配序号的规则,让历史读取用缓存也是安全的: 绝不会拿到比请求更新的版本。

计算

执行器在一串行(row)之上运行逻辑计划,每一行携带其当前节点与产生它的路径, 因此一次读取得以返回连通的子图,而非孤立的端点。图算法运行在一个针对平面邻接 构建的稠密索引帧(frame)之上,只读,且只作用于单一快照。混合检索会独立收集 每一路信号,先在每路信号内部对分数做归一化,再按加权和融合成一个结果;其中图 信号是从最强的命中点开始播种扩展的。

索引

向量(HNSW)与关键词(BM25)索引是内存中的注册表,其声明记录在存储中。打开 数据库时,如果伴生文件(.hnsw / .bm25)上打的提交序号戳与数据一致,注册表 就直接从伴生文件加载;不一致则从键/值存储重建——键/值存储才是权威来源。一致性 在提交时维护:写事务先缓冲自己隐含的索引变更,等数据持久化之后,再在写锁保护下 把这些变更应用到注册表。做快照时会把当前活跃的注册表序列化成伴生文件字节;恢复 时如果保留了 id,就能直接重新加载它们,无需重建。

变更流

一个 Database 接受一个提交时刻的观察者。写事务会先缓冲自己对节点与边做的 变更;提交时,一旦数据持久化,这些变更就会按实体折叠、在已提交的快照上解析成 记录(剥离嵌入与内部属性),再交给观察者。Web 层会注册一个观察者,把每个变更集 发布进一个广播通道,由每个 plane.watch WebSocket 订阅者各自抽取。投递是尽力 而为的:落后的消费者会丢弃溢出的部分,而不会拖住写入方。

后端

存储后端是一个编译期选择:原生 LSM 引擎(默认)、遗留的 redb 引擎,或一个用于 测试的内存引擎。依赖旧版本的能力(时间旅行及其保留窗口)只存在于原生后端,因此 也通过特性开关做了门控;读取接缝本身则无论在哪个后端都能编译。

线协议契约

各包装层都讲 JSON-RPC 2.0:drsg serve 后端、MCP 服务与各 SDK 都用它, 接口则由一份 OpenRPC schema 描述——这份 schema 是唯一的权威来源:SDK 由它 生成,服务端也从 rpc.discover 返回它;一旦 schema、分发表与生成的客户端三者 出现分歧,漂移(drift)测试就会失败。变更流订阅是同一协议的一个 WebSocket 扩展。

设计文档

逐层的详细设计笔记位于仓库的 arch/ 目录下——那是本章所概述内容的权威 来源。

附录 A:JSON-RPC 接口清单

本附录是 drsg serve 所暴露 JSON-RPC 2.0 方法的参考。位于 crates/dr-strange-web/openrpc.json 的 OpenRPC schema 是权威来源:服务端会从 rpc.discover 返回它,各 SDK 也由它生成。

每一条目都列出方法名、访问层级(read / write / admin;在单一共享令牌下, 三者用的是同一个令牌)、一行摘要及其参数。参数写作 name 类型,类型一律是 JSON 值;! 标记必填参数。Properties 是属性映射的方言({"$vector":[…]}、 {"$desc":…,"$value":…}、{"$bytes":[…]},以及用于自身键以 $ 开头的嵌套映射的 {"$map":{…}};整数必须能放进有符号 64 位),NodeRef 则指一个节点 id 或一个外部键。

发现与数据库

  • rpc.discover · read —— OpenRPC 服务描述。参数:无。
  • db.stats · read —— 平面/节点/边计数、标签、边类型、索引、提交数、磁盘占用、内存占用(进程常驻集,以及已加载插件持有的字节数)。参数:无。
  • db.catalog · read —— 跨所有平面的软 schema 目录。参数:无。
  • db.mount · admin —— 把另一个数据库的平面与本数据库的一同提供服务(issue #44)。该目录必须已经是一个数据库,且必须位于 [server] mount_root 之下;被其他进程持有的存储会被拒绝。参数:path、as?(为该挂载的平面名加前缀)。
  • db.unmount · admin —— 停止提供某个挂载,并释放其存储的锁。freed 说明该锁在调用返回之前是否已经释放:仍在进行中的折叠会持有它直到完成。名称不存在时如实报告,而不是报错。参数:name。
  • db.mounts · read —— 本实例提供了什么:每个挂载及其目录与平面,以及每个平面所记录的源码树。参数:无。

平面

  • plane.list · read —— 每个平面及其 id、名称、计数、属性。参数:无。
  • plane.catalog · read —— 单个平面的软 schema。参数:plane string!。
  • plane.indexes · read —— 某个平面上已声明的检索索引。参数:plane string!。
  • plane.history · read —— 时间旅行窗口(仅原生后端)。参数:无。
  • plane.create · admin —— 创建一个空平面。参数:name string!, properties Properties。
  • plane.rename · admin —— 重命名一个平面。参数:plane string!, to string!。
  • plane.set_props · admin —— 替换一个平面的属性映射。参数:plane string!, properties Properties!。
  • plane.delete · admin —— 删除一个平面及其内容。参数:plane string!。
  • plane.attach · admin —— 记录本平面可以询问的平面:本实例上的平面,或由另一个 drsg 提供服务的平面(url)。参数:plane string!, name string!, url string, as string, both boolean。
  • plane.detach · admin —— 按名称移除一个附着;名称不存在时为空操作。参数:plane string!, name string!。
  • plane.attachments · read —— 本平面记录的附着;不会联系任何一方。参数:plane string!。

节点与边

  • node.get · read —— 按 id 或外部键获取一个节点。参数:plane string!, id integer, key string。
  • node.create · write —— 新增一个节点(可选键与标签)。参数:plane string!, key string, labels array, properties Properties。
  • node.update · write —— 修补属性(set/unset)与标签。参数:plane string!, id integer, key string, set Properties, unset array, labels array。
  • node.delete · write —— 删除一个节点,并级联其边。参数:plane string!, id integer, key string。
  • edge.create · write —— 在两个节点间新增一条有向边。参数:plane string!, src NodeRef!, dst NodeRef!, type string!, properties Properties。
  • edge.update · write —— 修补属性(set/unset)或类型。参数:plane string!, edge integer!, set Properties, unset array, type string。
  • edge.delete · write —— 删除一条边。参数:plane string!, edge integer!。

查询与检索

  • plane.neighbors · read —— 一跳扩展,以 {node, edge} id 对返回。参数:plane string!, id integer!, direction string, type string, as_of integer, as_of_ms integer。
  • plane.query · read —— 运行一个序列化的逻辑计划。参数:plane string!, plan object!, as_of integer, as_of_ms integer。
  • plane.cypher · write —— 运行一条 openCypher 子集语句(写门控)。参数:plane string!, query string!, embed string, params object。
  • plane.find · read —— 对一个平面的文本或语义搜索。参数:plane string!, q string!, limit integer, semantic boolean, provider string, embed_model string, as_of integer, as_of_ms integer。
  • plane.search · read —— 在某个属性上的向量 top-k。参数:plane string!, property string!, query array!, label string, k integer, metric string。
  • plane.hybrid · read —— 融合的向量 + 关键词 + 图邻近度检索。参数:plane string!, q string!, label string, vector_prop string, keyword_prop string, metric string, graph_hops integer, graph_decay number, w_vector number, w_keyword number, w_graph number, k integer, candidates integer, provider string, embed_model string。
  • plane.algo · read —— 作用于一个平面或某个标签子集的图算法。参数:plane string!, algo string!, label string, limit integer, damping number, max_iters integer, tolerance number, src integer, dst integer, dir string, weight string, max_levels integer, min_gain number。
  • plane.ask · read —— 自然语言查询 → 计划 → 执行。参数:plane string!, question string!, dry_run boolean, max_attempts integer, limit integer, provider string, model string, embed_provider string, embed_model string。
  • graph.seed · read —— 一块由若干节点及其诱导边构成的初始画布。参数:plane string!, label string, limit integer, order string(scan | degree | pagerank,默认 scan), as_of integer, as_of_ms integer。指定排序时,返回的是得分最高的节点而非扫描时最先遇到的那些,并附带其 scores。若要取骨架,建议用 degree:PageRank 会把权重汇聚到汇点,不适合这个用途。
  • graph.expand · read —— 围绕某个节点的、防枢纽的一跳邻域。参数:plane string!, id integer!, direction string, type string, limit integer, as_of integer, as_of_ms integer。

索引与导入

  • index.ensure · admin —— 在 (标签, 属性) 上声明一个向量或关键词索引。参数:plane string!, label string!, property string!, kind string, metric string, language string。
  • digest.run · write —— 经由 LLM 从文本抽取节点/边方案(dry-run)。参数:plane string!, text string!, chat string, embed string, model string, embed_model string, source string, no_embed boolean, link boolean, concurrency integer, chunk_chars integer, mode string(coarse | fine | super,默认 fine——见第 3 章;super 的输入 token 用量约为 15 倍)。
  • digest.write · write —— 写入一个先前算得的方案(不调用 LLM)。方案中已被本平面占用的键默认跳过——蒸馏把每个实体都当作新的提出,因此提到已知的东西是常态。参数:plane string!, nodes array!, edges array, on_conflict string(error / skip / update,默认 skip)。
  • plane.vectorize · write —— 为平面中的每个节点生成向量嵌入(文本未变的节点会跳过),并按标签确保 embedding 上的向量索引。提供方密钥来自服务端环境。参数:plane string!, embed string, embed_model string, metric string。

插件

  • plugin.list · read —— 已安装的预处理插件,与 drsg plugin list --json 输出同一种记录。参数:无。
  • plugin.catalog · read —— 官方目录,读取自 extensions 仓库的 catalog.json 而非编译进二进制,因此插件发布不需要 drsg 发布。返回 {stale, schema, source, plugins};每个条目带有 name、version、claims、url、sha256 与 compat(ok、needs_host、other_contract),可与 plugin.list 关联比对。缓存一小时;stale: true 表示抓取失败,返回的是服务端保留的最后一份副本。参数:无。
  • plugin.install · write —— 从 http(s) URL 下载、校验、固定哈希并存入插件(RPC 上拒绝服务器本地路径)。参数:url string!。
  • plugin.remove · write —— 按名称卸载插件。参数:name string!。

WebSocket 订阅

/ws 端点应答上述相同的请求/响应方法,并额外支持变更流(以下为 WebSocket 专有):

  • plane.watch · 客户端 → 服务端 —— 订阅某个平面的变更。参数:plane string!, label string。
  • plane.unwatch · 客户端 → 服务端 —— 停止订阅。参数:无。
  • plane.change · 服务端 → 客户端 —— 一个已提交的变更集。字段:plane, seq, truncated, changes。

错误码

码含义
-32700解析错误
-32600无效请求
-32601方法未找到
-32602无效参数
-32000应用错误(错误平面、悬挂端点、冲突……)
-32001未授权(凭据缺失或无效)

附录 B:查询语言文法

这是 openCypher 子集的完整文法,也就是解析器实际接受的形式。仪表盘把查询页签标注为 GraphQL,但这门语言并不是 GraphQL,而是一个 openCypher 子集,本附录正是它的参考。 第 4 章讲的是各构造用来做什么,本附录讲的则是什么能被 解析。

记号约定:| 表示择一,[…] 表示可选,{…} 表示零次或多次,'…' 表示一个字面 记号。关键字不区分大小写(match 等同于 MATCH);标识符则区分。

语句

statement      ::= read-query | write-statement

读语句编译为一个逻辑计划并返回行;写语句改动平面并返回变更计数。

读

read-query     ::= source { beam } [ where ] return
                   [ order-by ] [ skip ] [ limit ] [ as-of ]

每一种数据源都恰好绑定一个节点模式,之后可以继续接一段关系尾巴:无论种子来自检索、 算法,还是普通的 MATCH 节点,带类型跳转的写法都完全一样。

source         ::= 'MATCH'  pattern
                 | 'SEARCH' node-pat vector-seed  tail
                 | 'SEARCH' node-pat keyword-seed tail
                 | 'HYBRID' node-pat { hybrid-part } tail
                 | 'CALL'   ident '(' [ call-args ] ')' 'ON' node-pat tail

tail           ::= { rel-pat node-pat }

vector-seed    ::= [ 'ON' ident ] 'NEAR' vec-arg [ 'METRIC' metric ] [ 'TOPK' uint ]
keyword-seed   ::= 'ON' ident 'MATCHING' string [ 'TOPK' uint ]

hybrid-part    ::= 'VECTOR'  [ 'ON' ident ] 'NEAR' vec-arg
                     [ 'METRIC' metric ] [ 'WEIGHT' number ]
                 | 'KEYWORD' 'ON' ident 'MATCHING' string [ 'WEIGHT' number ]
                 | 'GRAPH'   'HOPS' uint [ 'DECAY' number ]
                     [ 'SEEDS' uint ] [ 'WEIGHT' number ]
                 | 'CANDIDATES' uint
                 | 'TOPK' uint

call-args      ::= call-arg { ',' call-arg }
call-arg       ::= ident ':' value

HYBRID 至少需要 VECTOR 或 KEYWORD 之一,GRAPH 只是在二者找到的结果上做 提升。各部分的书写顺序任意,但每种至多出现一次。

遍历

beam           ::= 'BEAM' node-pat direction [ ':' ident ] [ 'ON' ident ]
                   'NEAR' vec-arg [ 'METRIC' metric ] 'WIDTH' uint 'DEPTH' uint

direction      ::= 'OUT' | 'IN' | 'BOTH'

pattern        ::= node-pat { rel-pat node-pat }
node-pat       ::= '(' [ ident ] [ ':' ident ] ')'
rel-pat        ::= [ '<' ] '-' [ '[' rel-body ']' ] '-' [ '>' ]
rel-body       ::= [ ident ] [ ':' ident ] [ var-len ]
var-len        ::= '*' [ uint ] [ '..' [ uint ] ]

关系的方向由其箭头决定:-[…]-> 为出边,<-[…]- 为入边,-[…]- 为双向;<-…-> 会被拒绝。关系变量可以书写,但不会被绑定。var-len 必须有上界(*1..3、*2、 *..4);无界的 * 或 *2.. 会明确报错。

尾部子句

where          ::= 'WHERE' expr
return         ::= 'RETURN' [ 'DISTINCT' ] return-item { ',' return-item }
return-item    ::= ident | '*'                (* 行本身 *)
                 | ( expr | aggregate ) [ 'AS' ident ]
aggregate      ::= 'count' '(' '*' ')'
                 | agg-func '(' [ 'DISTINCT' ] expr ')'
agg-func       ::= 'count' | 'sum' | 'avg' | 'min' | 'max' | 'collect'
order-by       ::= 'ORDER' 'BY' order-key { ',' order-key }
order-key      ::= ( expr | ident ) [ 'ASC' | 'DESC' ]
skip           ::= 'SKIP' uint
limit          ::= 'LIMIT' uint
as-of          ::= 'AS' 'OF' ( uint | string | 'TIME' int )

RETURN n 或 RETURN * 交回完整记录,且只能指名模式中的最后一个变量。其余任 何一项都是投影:它成为一列,列名取 AS 别名,未写别名时取该项的原文;它可以读 取模式所绑定的任一变量。节点不能与列同处一个 RETURN——节点不是值。

aggregate 在每个组上折叠;分组键是所有不是聚合的列。折叠会跳过 null,也跳过它无 法采用的值;当一组什么也没提供时,count 与 sum 为 0,avg/min/max 为 null,collect 为空列表。

在投影查询中,DISTINCT 比较整个投影行,ORDER BY 指名所返回的某一列(用别名,或 用它所返回的那个表达式),SKIP/LIMIT 计的是投影行。而在 RETURN n 之下,这四者 仍取各自的节点语义。AS OF 位于最后:裸整数为提交序号,带引号的字符串为一个 RFC-3339 时刻,TIME 后接 unix 纪元毫秒数。

表达式

由最松到最紧:

expr           ::= or-expr
or-expr        ::= and-expr { 'OR' and-expr }
and-expr       ::= not-expr { 'AND' not-expr }
not-expr       ::= 'NOT' not-expr | comparison
comparison     ::= additive [ 'IS' [ 'NOT' ] 'NULL'
                            | 'IN' '[' [ expr { ',' expr } ] ']'
                            | cmp-op additive ]
cmp-op         ::= '=' | '<>' | '!=' | '<' | '<=' | '>' | '>='
additive       ::= multiplicative { ( '+' | '-' ) multiplicative }
multiplicative ::= unary { ( '*' | '/' ) unary }
unary          ::= '-' unary | primary
primary        ::= '(' expr ')' | param | literal | term
term           ::= ident '.' ident            (* 属性 *)
                 | ident ':' ident            (* 标签判定 *)
                 | function
function       ::= 'score' '(' ')'
                 | 'hops' '(' ')'
                 | 'key' '(' ident ')'
                 | ( 'similarity' | 'distance' )
                   '(' ident '.' ident ',' vec-arg [ ',' metric ] ')'

score() 是该行的 score 通道,取值是种子的相关度,或算法给出的逐节点结果。 hops() 是路径长度。key(n) 是节点的外部键;用在源变量上时,key(n) = "…" 和 key(n) IN […] 会编译成一次键查找,而不是先扫描再过滤。x IN [a, b] 只是等值 判断的语法糖。

一个 WHERE 条件只能引用一个模式变量,编译器会把它安放在这个变量在流水线中的 位置上。

写

write-statement ::= 'CREATE' create-path { ',' create-path }
                  | 'MERGE'  create-path { merge-on }
                  | 'MATCH'  pattern [ 'WHERE' expr ] mutate-op { mutate-op }

merge-on       ::= 'ON' ( 'CREATE' | 'MATCH' ) 'SET' set-item { ',' set-item }

mutate-op      ::= 'SET' set-item { ',' set-item }
                 | 'REMOVE' remove-item { ',' remove-item }
                 | [ 'DETACH' ] 'DELETE' ident { ',' ident }
                 | 'CREATE' create-path { ',' create-path }
                 | 'MERGE'  create-path { merge-on }

set-item       ::= ident '.' ident '=' value
                 | ident ':' ident
                 | ident '+=' prop-map
remove-item    ::= ident '.' ident | ident ':' ident

create-path    ::= create-node { create-rel create-node }
create-node    ::= '(' [ ident ] [ ':' ident ] [ prop-map ] ')'
create-rel     ::= [ '<' ] '-' '[' ':' ident [ prop-map ] ']' '-' [ '>' ]
prop-map       ::= '{' [ prop-entry { ',' prop-entry } ] '}'
prop-entry     ::= ident ':' value

在 create-node 中,取字符串值的 key: 条目设定节点的外部键,而不会成为一个属性。 新建的边必须有向,且必须指明类型。MATCH … mutate-op 作用于模式的末端变量。

终结符

ident          ::= ( letter | '_' ) { letter | digit | '_' }     (* Unicode 字母 *)
                 | '`' { 除 '`' 外的任意字符 } '`'
uint           ::= digit { digit }
int            ::= [ '-' ] uint
number         ::= [ '-' ] digit { digit } [ '.' digit { digit } ] [ exponent ]
exponent       ::= ( 'e' | 'E' ) [ '+' | '-' ] digit { digit }
string         ::= '"' { string-char | escape } '"'
                 | "'" { string-char | escape } "'"
string-char    ::= 除闭合引号与 '\\' 外的任意字符
escape         ::= '\\' ( '\'' | '"' | '\\' | 'n' | 't' | 'r' | 'b' | 'f' | 'u' hex hex hex hex )
vector         ::= '[' [ number { ',' number } ] ']'
vec-arg        ::= string | vector
metric         ::= 'cosine' | 'dot' | 'l2'
param          ::= '$' ident
literal        ::= number | string | 'true' | 'false' | 'null' | vector
value          ::= param | literal

字符串内部的反斜杠开始一个转义——\'、\"、\\、\n、\t、\r、\b、\f、\uXXXX—— 因此同类引号以转义写出,字面反斜杠写作 \\(Windows 路径写作 "C:\\data"), 未知转义或未闭合的字符串是语法错误。标识符是 Unicode 单词,或当普通写法拼不出该 名字时放在反引号之间的任何内容。记号之间的空白没有意义,也不支持注释。$name 参数写在值的位置上,解析时会从调用方的参数映射里取值, 这样传值就不必担心注入问题。

默认值

省略项取值缘由
NEAR 之前的 ON <属性>embedding摄取流水线所写入的属性
METRICcosine
TOPK10
HYBRID … CANDIDATES100融合前每通道的候选池
GRAPH … DECAY0.5与 RPC、MCP 及命令行各接口一致
GRAPH … SEEDS10每通道取作种子的最高命中数
WEIGHT向量 1.0、关键词 1.0、图 0.5
CALL pagerank 参数damping: 0.85, iterations: 20, tolerance: 1e-6
CALL louvain 参数max_levels: 10, min_gain: 1e-9
CALL shortest_path 的 diroutfrom 与 to 为必需

MATCHING 没有默认属性:关键词索引按 (标签, 属性) 声明,这些属性没有约定俗成的 命名,所以 ON 是必需的;出于同样的理由,标签也是必需的。

算法

CALL 'pagerank'      ( [ damping: number ] [ iterations: uint ] [ tolerance: number ] )
CALL 'components'    ( )                      (* 别名:connected_components *)
CALL 'louvain'       ( [ max_levels: uint ] [ min_gain: number ] )
CALL 'shortest_path' ( from: key-or-id , to: key-or-id [ , dir: string ]
                       [ , weight: string ] )

key-or-id 可以是一个字符串(外部键),也可以是一个整数(节点 id)。每个算法都 通过 score() 报告逐节点结果:pagerank 给出 rank,components 和 louvain 给出 从 0 开始的紧凑分组序号,shortest_path 给出该节点在路径上的位次。未知的算法名或 参数名一律报错,绝不会被悄悄忽略。

尚不支持

以下每一项都会明确报错,而绝不会被静默地错误编译:

  • WITH 流水化——投影是尾部,其后不再接任何子句。
  • 返回模式中最后一个变量之外的任何变量的行(一次跳跃之后的 RETURN p);它的 值仍可投影(RETURN p.name)。
  • 非投影查询按模式中最后一个变量之外的变量排序。
  • 跨越两个变量的谓词——WHERE p.year < q.year。
  • 复用模式变量,那将表达一个图约束。
  • 分支模式——每条语句只允许一条线性路径。
  • 无界的可变长度关系——*、*2..。
  • 无向或未指明类型的新建边。
  • 注释,以及 WITH。

附录 C:是否需要 LLM

Dr Strange 只在一组特定且有界的功能里调用外部语言模型,其余的一切都不需要任何模型 即可运行:图存储、向量与关键词索引、基于预先算得向量的混合检索、图算法、时间旅行、 变更流、备份,以及经由解析器插件的代码图化(代码仓库只产出解析事实,导入全程不发起 一次模型调用)。

本附录明确界定:究竟哪些功能需要模型支持、如何在不使用任何模型的情况下运行 Dr Strange,以及如何将这些模型功能指向一个本地模型而非托管 API。无论哪种情形,提供方 API 密钥都从服务端环境(或 [llm] 配置节)读取,绝不从请求或工具参数读取。

哪些功能需要 LLM 支持

模型在两种情形下被调用:为文本生成嵌入(把一个字符串变成一个向量),以及 对话(生成结构或计划)。依赖其一或两者的功能:

功能需要原因
为文本相似度查询生成嵌入(SEARCH … NEAR "文本"、语义 plane.find、智能体工具 search、从文本出发的混合向量信号)嵌入提供方查询字符串在检索前于服务端被嵌入
自然语言查询(ask / plane.ask)对话提供方(+ 用于有据工具的嵌入提供方)模型将问题编译为计划,并可选地调用基于嵌入的 find_edge / find_entity 工具
文档导入(digest / AIgest)对话 + 嵌入提供方模型抽取实体与关系,随后清理抽取结果(--mode);实体被嵌入

其余一切都无需模型:

  • 存储与检索你已有的向量。 向量是普通属性;声明一个索引,并以字面量向量 检索它(SEARCH … NEAR $vec)。只有文本查询才需要嵌入。
  • 关键词检索。 BM25 纯为词面。
  • 图查询与遍历。 MATCH、SEARCH … NEAR $vec、plane.query、 plane.neighbors、graph.seed / graph.expand。
  • 图算法。 PageRank、连通分量、最短路径、Louvain。
  • 时间旅行、变更流,以及备份/恢复。
  • 混合检索的关键词信号与图信号,以及给定字面量向量时的向量信号。

简而言之:仅在把文本变成向量、以及驱动 ask 与 digest 时才需要模型。若你以自己的 流水线为数据生成嵌入,并以字面量向量、关键词与图进行查询,则 Dr Strange 无需任何 模型。

在不使用 LLM 的情况下运行 Dr Strange

有两种相互独立的方式在无模型下运行:直接不使用模型功能,以及构建一个将模型代码完全 排除在外的二进制。

不使用模型功能

模型支撑的操作只有真正被调用时,才会去访问对应的提供方。不设置任何提供方密钥、 并避开 ask、digest 与文本嵌入查询,系统其余部分就能完整地运行。用你自己的 流水线为数据生成嵌入、把向量存成属性,再按下面的方式查询:

  • 字面量向量相似度 —— SEARCH (d:Doc) ON embedding NEAR $vec TOPK 10;
  • 关键词 —— 一个 BM25 索引(index keyword …);
  • 图 —— MATCH、遍历,以及各图算法。

一个对外提供服务的实例仍然暴露 ask 与 digest,但没配置提供方密钥时,两者都会 返回明确的错误;其它一切不受影响。

不带模型代码进行构建

命令行工具将模型功能门控在 digest Cargo 特性之后,该特性会引入 LLM crate。不带它 构建,即可得到一个完全没有模型依赖的精简二进制:

$ cargo build --release -p drsg --no-default-features --features native-backend

所得的 drsg 省去了 ask 与 digest 命令,其它命令都保持不变:平面、导入/导出、 查询、索引、混合检索(关键词与字面量向量信号)、算法、快照/恢复,以及 serve。

使用本地 LLM / 模型

模型功能并不要求托管 API。一个提供方要么是一个预设名,要么是一个base URL (base URL 是运维者的设置——CLI 参数与配置文件接受它;发给 drsg serve JSON-RPC API 的 请求只能指定预设,或服务端已配置的那个 URL), 因此任何 OpenAI 兼容的端点都可以提供对话与嵌入,本地端点也不例外。

Ollama

Ollama 在本地暴露一个 OpenAI 兼容的 API。内置的 ollama 预设 指向 http://localhost:11434/v1,无需密钥,默认对话用 llama3.1、嵌入用 nomic-embed-text:

$ ollama pull llama3.1
$ ollama pull nomic-embed-text

$ drsg --db graph.drsg ask "which companies does Ada work for?" \
    --plane social --chat ollama --embed ollama

$ drsg --db graph.drsg digest notes.md --plane social --apply \
    --chat ollama --embed ollama

按需以 --model 与 --embed-model 覆盖模型。

任意 OpenAI 兼容服务

vLLM、LM Studio、llama.cpp 等讲 OpenAI API 的本地推理服务,把它们的 base URL 当作提供方传入即可指定,同时要显式命名模型:

$ drsg --db graph.drsg ask "…" --plane social \
    --chat  http://localhost:8000/v1 --model       my-chat-model \
    --embed http://localhost:8000/v1 --embed-model my-embed-model

如果该端点需要密钥,把它设进环境变量,再用密钥环境变量选项指明变量名即可;不需要 密钥的本地服务则什么都不用配置。

在仪表盘、服务与 MCP 中

所有用到模型的地方,都是同一套提供方。AIgest 页与语义搜索选择器会列出这些预设, 包括 ollama;服务与 MCP 宿主从各自的环境读取提供方密钥(无需密钥的本地服务则不 读取)。对话提供方与嵌入提供方各自独立选择,因此本地对话模型可以搭配本地或托管的 嵌入模型,反过来也一样。