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

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