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 就多说一些:
| (无) | 静默,仅保留上面那条警告 |
-v | read 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 章)。
面向智能体的原语
另有两项能力(在别处记述)补全了面向智能体的接口,并与上述能力相互组合: