这是《记忆的网道》的续篇。网道写成的时候,我还在”真的在考虑使用单一的 sqlite 代替 duckdb+sqlite 的组合”,并列出了四个顾虑:json 的 contains 查询、与 PG 的风格差异、自动化的升级过程、以及换用 sqlite 是否值得。如今这一步已经走完:hypatia v0.3.0 发布,六个书架全部运行在新架构上。这篇文档记录这次架构升级的完整推理——为什么简化是正确的,以及它值不值。

一个错配的选择

我为 hypatia 选择 duckdb,是出于对它 OLAP 能力的欣赏:列存、向量化执行、SIMD,以及 PG 风格的查询语法。对于”读得多、写得少、批量分析”的场景,这些是巨大的回报。

但 hypatia 的真实负载不是 OLAP。它是一个记忆系统:每一轮对话都在追加 message,每次总结都在改写 knowledge 和 statement,多个 Agent 在同一台机器上频繁地小批量读写同一个书架。这是典型的 OLTP 形态——大量点查、高频小事务、并发访问。DuckDB 在这个象限里恰恰是弱项:单进程单写者、缺乏成熟的并发控制,它的列存和向量化执行对”按主键取一行、按标签过滤十行”毫无用武之地。

错配的代价随使用增长而显形。我的 default 书架膨胀到了 5GB,而迁移时清点,真实数据只有 16,191 条 knowledge 和 2,535 条 statement——折算下来约 100MB。也就是说,文件的 98% 是删除后永不回收的死空间。一个 OLAP 引擎没有为”频繁增删的记忆库”设计空间回收,而它的每一次全量扫描都要背着这 5GB 的包袱。

并发:从单写者到多读者

我的每一台机器上都部署了多个 Agent——openclaw、harness、opencode、claude。它们共享同一个 default 书架,读写冲突是日常而非例外。

DuckDB 对此几乎没有答案:单写者模型,且跨进程共享一个库文件是它明确不支持的用法。SQLite 则相反——WAL 模式下多读者与单写者并行、读写互不阻塞、写写冲突由 busy_timeout 自动重试。对”多个 Agent 无冲突协作”这个 hypatia 的核心场景,SQLite 不是妥协,而是升级。

更大的收益在原子性。旧架构里,一次写入要跨 duckdb(数据+向量)和 index.sqlite(全文检索)两个库,没有任何机制保证两边同时成功——中途失败就是撕裂态。新架构里,一次 CRUD 是 SQLite 内的单个事务:源表、文档锚点、JSON 倒排、全文索引同时生效。向量索引是唯一的例外,而它被有意设计为可重建的缓存——坏了就从 BLOB 列重算,不参与源真相。

指令集稳定之后,迁移成为可能

经过若干版本的迭代,hypatia 的知识图谱结构初步稳定:knowledge 与 statement(现已更名为 head/relation/tail 三元组)构成了图,content 承载格式化数据,JSE 指令集固定在二十个算子上——$knowledge、$statement、$triple、$k-hop、$search、$similar、$contains、$content、$gt/$lt 系、$like、$not-summaried……这套指令集就是 hypatia 与存储层之间的契约。

第一个版本的指令实现,是我基于对 PostgreSQL 和 DuckDB 的知识积累手工执行的。我知道 PG 的 jsonb @> 应该是什么语义,于是用 json_extract_string(content, '$.tags') LIKE '%value%' 在 duckdb 里近似它——语义上是子串误匹配,性能上是无索引全表扫。这些妥协在当年是合理的起步,但它们也让实现与语义长期纠缠在一起。

指令集稳定的意义在于:语义(契约)与实现(方言)第一次可以被分开对待。契约不变,实现可以整体迁移。把二十个算子的语义逐条翻译给 AI——@> 等价于 jq 的 contains()?| 是任一成员存在、GIN 是”倒排召回 + recheck”两段式——AI 就能把这套手工设计在 SQLite 上重新实现为路径树倒排 json_index$has 成员算子、以及一个对齐 PG @> 语义的 json_contains UDF。迁移工具则保证旧数据无损地跨过 SPO→HRT 的改名与双库到单库的合并。这次重构验证了一个工作模式:人守住语义契约,AI 完成方言翻译

相似检索:等待官方,拥抱文件系统

向量检索是去掉 duckdb 的路上唯一真正的硬骨头。SQLite 没有向量类型,官方的 vec1 扩展(public domain,支持 ANN、reranking、metadata 过滤)是正确的终态,但它还在实验期,未发布。

在等待期,我评估了市面上的全部候选:vectorlite 的 HNSW 快,但索引在内存、不落库、无事务,与”单文件多 Agent”根本冲突;sqlite-vec 已弃维护且十万级即触顶;sqlite-vector 架构干净但许可证附加条件;zvec 与 usearch 则是独立向量库——用了它们,等于回到”两个异构存储”的老路。

最终的方案是把问题拆开:向量的数据与向量的索引是两回事。embedding 以 BLOB 列存在 SQLite 里,是源真相;检索用的 HNSW 图是派生结构,放进文件系统的独立快照(usearch,Apache-2.0,Rust 绑定),坏了随时从 BLOB 重建。这换来真正的 ANN(无需训练、增量友好),不牺牲单源真相,代价只是书架目录里多一个可重建的文件。等 vec1 正式发布,它将以同一个 VectorIndex 抽象之下的另一个实现接入;亿级规模和多机共享,则由本机的 PostgreSQL(pgvector)承担——那是《网道》里说的另一个系统。

单一边界内的一致检索

新架构里,递归查询和全文检索终于住在数据的同一个库里。$k-hop 的递归 CTE、$search 的 FTS5(jieba 预分词 + porter 词干不变)、$has 与 $content 的倒排命中、$json-contains 的”召回 + recheck”,全部在同一个 SQLite 连接、同一个事务边界内完成。

跨库边界的消失带来两类此前不可能的一致性:写入时,数据与其全部索引原子生效,不再有”数据在了、索引没在”的撕裂态;查询时,JSON 倒排召回、全文匹配、图遍历共享同一个文档锚点(docs.id),先倒排筛候选、再向量近邻、或反之,都只是同一连接内的组合,而不是两个引擎间的手工协调。

空间的账

迁移给出的数字最有说服力:default 书架从 5GB 降到 100MB——98% 的空间是旧架构的死重。剩下的 100MB 里,约三分之二是 embedding 本身(每条 1024 维 float32),这部分是数据的固有成本,任何方案都省不掉。usearch 快照另计约 80MB,可随时删除重建。旧文件以 .bak 后缀保留,确认稳定后删除即可再回收一份。

如果未来真到亿级,空间的答案也不在本地单文件里,而在 pgvector——那是有意留下的另一层。

还剩下的路

$has-any 与 $has-all(对应 PG 的 ?|?&)只是倒排表上的两种聚合,随取随用。vec1 发布后需验证其持久化、事务与多进程行为,达标即作为 VectorIndex 的实现切入。多平台发布产物与 npm 生态的同步是工程收尾,不构成架构问题。

一句话:简化不是减法,而是把错配的通用能力换成一岗位的专用能力——duckdb 擅长的,交还给会用到它的场景;hypatia 需要的,SQLite 加一层薄薄的倒排和一个外置的向量文件,恰好够用,且全部在自己的掌控之中。