DeepChat 内存向量存储 v2:以纯表 + 精确扫描重构 DuckDB 侧车存储并安全迁移 v1 数据
2026/9/17 20:07:21 网站建设 项目流程

DeepChat 内存向量存储 v2:以纯表 + 精确扫描重构 DuckDB 侧车存储并安全迁移 v1 数据

【免费下载链接】deepchat🐬DeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat

本文以docs/architecture/memory-vector-store-v2/下的规划、规格与任务清单为主体,结合src/main/memory/infra/memoryVectorStore.tslegacyV1Reader.tsmemoryVectorStoreFormat.ts等源码实现,系统讲解 DeepChat 如何将 per-agent 的 DuckDB 向量侧车存储从「持久化 HNSW 索引」重构为「纯表 + 精确扫描」,以及如何通过原子重命名、隔离标记与放弃围栏(abandon fence)等机制完成 v1 → v2 的零数据丢失迁移。读完本文,你将掌握其存储格式契约、打开决策树、publishFreshV2()提交原语与崩溃恢复语义,并了解对应的单元/集成/崩溃恢复测试覆盖。

背景:为什么必须放弃持久化 HNSW

在 v1 格式中,DeepChat 在每个 agent 的 DuckDB 侧车文件(app_db/AgentMemory/<agentId>.duckdb)内持久化了一个 HNSW 索引,并依赖hnsw_enable_experimental_persistence = true这一实验性开关。问题出在上游 duckdb-vss 扩展对自定义索引的 WAL 重放支持尚未实现:任何非正常关机都可能留下一个 WAL,其重放会污染 HNSW 索引,触发 DuckDB 的Duplicate keys not allowed in high-level wrappers内部错误,进而使整个 DuckDB 实例失效。这构成了一整类损坏风险——非正常关机随时可能把整个向量库拖入不可用状态,并被迫全量重新嵌入(re-embed)。

v2 的决策(见 spec.md)非常直接:彻底移除持久化 ANN 索引,查询改为精确的暴力扫描(exact brute-force scan)。在 DeepChat 的记忆规模下,索引没有带来收益:

  • 对纯FLOAT[dim]列做精确 top-k 的代价是O(rowCount × dimensions):以 344 行 × 1024 维 ≈ 0.7M FLOPs 的规模估算,在 DuckDB 向量化引擎中亚毫秒即可完成,并远在召回软时限之内;代价随两个因子线性增长,因此「逃生舱」(见下文)由测量驱动而非固定行数阈值;
  • 结果是精确的而非近似的,召回质量严格提升;
  • 打开时零索引构建、upsert/delete 零索引维护、无实验性持久化开关、热路径上不再有 VSS 扩展;
  • 现有查询 SQL(ORDER BY array_cosine_distance(...) LIMIT k)使用的是 DuckDB 核心函数,无需改动——只是没有索引时它以精确扫描方式执行。

存储格式 v2 契约

格式契约的要点是文件名本身就是格式判别符,且在任何原生调用之前决定(见 spec.md):

  • v2 存储在<agentId>.v2.duckdb,旧版 v1 存储在<agentId>.duckdb
  • 格式探测绝不能依赖打开文件来判断——打开 v1 文件本身就是危险动作(会触发 WAL 重放与 HNSW catalog 加载);
  • 静态的后缀名不携带运行时状态,这与被否决的「同进程恢复的 generation 后缀」方案(需要无界的运行时 generation 管理)有本质区别。

表结构与格式版本自检

v2 使用纯表结构,没有自定义索引。源码 memoryVectorStoreFormat.ts 中的createMemoryVectorStoreV2FormatPlan()给出了精确的建表 SQL:

CREATE TABLE memory_vector ( memory_id VARCHAR PRIMARY KEY, embedding FLOAT[<dimensions>] ); CREATE TABLE embedding_meta ( provider VARCHAR NOT NULL, model VARCHAR NOT NULL, dim INTEGER NOT NULL, format_version INTEGER NOT NULL );

其中format_version = 2(常量MEMORY_VECTOR_STORE_FORMAT_VERSION = 2)作为打开后的带内自检保留在embedding_meta中,专门防御「v1 文件被改名成 v2 路径」这类场景;一旦不匹配,该存储被标记为不可用并进入重建路径。嵌入身份规则(provider/model/dim 指纹、per-agent 隔离、缓存键)与 v1 完全一致。

崩溃一致性

v2 存储旁残留.wal是安全的:纯表 WAL 重放是 DuckDB 核心的、久经考验的能力。非正常关机不再需要重建或重新嵌入——这是与 v1 相比最大的可靠性收益。

每个 agent 的侧车文件族

每个 agent 在AgentMemory目录下会有一组相关文件(路径由 composition.ts 的memoryVectorDbPaths(agentId)createMemoryVectorStorePaths生成):

文件含义
<agentId>.v2.duckdbv2 主存储,一旦存在即代表「已提交、权威」
<agentId>.v2.duckdb.walv2 的临时 WAL,崩溃后可安全重放
<agentId>.v2.duckdb.quarantine隔离标记:运行时故障或 preserve 迁移失败时写入;存在即令下一个进程销毁该 agent 的全部存储文件,最后删除标记,然后才发布全新存储
<agentId>.v2.duckdb.migrating迁移暂存文件(staging),永远不是合法存储,见到即删
<agentId>.duckdb(+.wal)旧版 v1 存储及其 WAL

提交点:publishFreshV2()

每一条创建路径(全新创建、隔离恢复、重建、preserve 迁移)都在.migrating暂存路径构建,验证通过后原子重命名到最终路径——这就是提交点。任何路径都绝不会直接在最终路径上初始化。因此<agentId>.v2.duckdb存在即意味着「已提交的权威存储」,其权威性不依赖事后清理是否成功(删除可能失败;而同目录下对不存在目标的重命名在 Windows 和 POSIX 上都是原子的)。

源码 memoryVectorStore.ts 中的publishFreshV2()严格按以下步骤执行:

  1. 删除任何陈旧的stagingPath${stagingPath}.wal
  2. stagingPath构建完整的 v2 存储(schema、format_version、嵌入身份;preserve 路径还要拷贝行数据);
  3. 验证:schema 存在、format_version = 2、嵌入身份匹配,preserve 路径还要核对源/目标行数一致;
  4. CHECKPOINT、关闭,并断言${stagingPath}.wal已不存在;
  5. 原子重命名stagingPathv2Pathfs.renameSync),置committed = truemarkCommitted()
  6. 打开并验证最终存储:若这次最终打开失败,保留已提交的当前文件、持久化隔离标记、把恢复留给下一个进程——重命名仍是提交点。

此外在重命名之前还会做一次「当前主文件仍不存在、孤儿当前 WAL 已被清除」的最终断言,任何失败都会收敛为终态恢复错误(terminal recovery error)而非租约级重试。initialize()只会在 staging 路径上运行,永远不对v2Path运行——防止崩溃中途在最终路径留下半成品文件。

打开决策树:四种状态如何收敛

MemoryVectorStore.create()(见 memoryVectorStore.ts)按文件系统状态逐步决策,格式判定完全不依赖打开文件:

  • 步骤 0 — 隔离标记存在(<v2Path>.quarantine:在任何句柄获取之前进入两阶段恢复。首先销毁该 agent 的全部存储文件(v2 主文件 + wal、staging 主文件 + wal、旧版 v1 主文件 + wal),保留标记;然后以「标记删除」作为销毁阶段的最后一步。只有标记删除成功后才允许正常全新发布。标记删除失败则什么都不发布、保持准入关闭——它不能反复销毁一个刚刚重新嵌入的健康存储。嵌入重建随后由覆盖率验证(coverage verification)触发。写入标记的进程自己绝不删除或重新打开存储文件,它同时关闭该 agent 的向量准入,从而保证标记只会被「持有零句柄的后续进程」处理。在 presenter 启动时,还会扫描整个AgentMemory目录,对已删除 agent 的残留标记执行同样的「文件优先、标记最后」销毁。
  • 步骤 1 —stagingPath(或其.wal)存在:staging 永远不是提交点,必定是撕裂状态——无条件删除两者后继续。恢复删除失败则持久化/保留标记、终止本进程的向量准入,不进行租约级重试。
  • 步骤 2 —v2Path存在:已提交、权威——直接打开(即使带残留.wal也安全),验证带内format_version = 2与嵌入身份(不匹配 → 不可用 → 重建路径)。若 v1 文件仍在,它们是提交后删除失败的遗留物:best-effort 清扫(失败只记日志,下次启动再扫),绝不丢弃已提交的 v2。结构元数据不匹配视为不可用并进入重建;原生打开/读取失败对本进程是终态的:不触碰致命实例,对其他打开失败尝试安全关闭、持久化标记、把恢复留给后续进程。若v2Path不存在但${v2Path}.wal存在,先删除这个孤儿 WAL 再继续;删不掉则进入终态恢复而非租约级重试。
  • 步骤 3 — 只有v1Path存在:进入迁移(详见下节)。
  • 步骤 4 — 两者都不存在:通过publishFreshV2()发布全新空 v2。重命名前立刻断言当前主文件仍不存在、清除任何新出现的孤儿当前 WAL;失败变成终态恢复并保留原始初始化原因。

这套决策树的崩溃语义完全确定:重命名前崩溃 → v1(若有)完好 + staging 垃圾(步骤 1 清理,步骤 3/4 重做);重命名后崩溃 → 权威 v2 + v1 遗留(步骤 2 清扫)。没有任何路径会丢弃已提交的 v2,也没有任何路径能在v2Path留下半成品文件。

v1 → v2 迁移:重建与保留两条路径

仅当v1Path存在且v2Path不存在时触发一次性迁移(在打开时执行)。两条子路径以「v1 是否残留 WAL」为分界(见 plan.md):

路径 A:v1 带残留 WAL → 重建(rebuild)

带 WAL 的 v1 文件完全不开:重放可疑的 HNSW WAL 正是损坏的触发点。直接destroyFilev1 文件,通过publishFreshV2()发布全新空 v2。热路径上的覆盖率验证(verifyVectorCoverage)会发现 SQLite 中标记为已嵌入的行没有对应向量,从而触发reindexEmbeddings(force)——以 SQLite 为事实来源重建嵌入。

路径 B:v1 无 WAL → 保留(preserve)

这是零重新嵌入成本的优化路径,核心是LegacyV1Reader

  1. 准备内置 VSS 扩展,但不把共享的物化 promise 绑定到任何调用方的放弃围栏上;迁移绝不执行网络INSTALL vss
  2. 内置扩展不可用 → 安全删除 v1、发布空 v2;否则由 legacyV1Reader.ts 的LegacyV1Reader中立的纯内存 DuckDB 连接上加载 VSS,并对 v1 文件做只读ATTACH(把 VSS 和旧版访问完全隔离在 v2 热路径之外);
  3. 拷贝前先读取恰好一条合法的旧版embedding_meta(provider, model, dim)身份行:只有精确身份匹配才允许保留;缺失、重复、畸形或不匹配的元数据安全地重建为空且不触发隔离;
  4. memory_id排序、按 keyset 分页(LEGACY_V1_MIGRATION_PAGE_SIZE = 50)读取memory_id, embedding——分页同时约束读侧的 JS 堆,而不只是写侧;
  5. 行数据流入publishFreshV2()(staging 构建 → 验证含源/目标行数 → checkpoint → 重命名提交),然后 best-effort 删除 v1 文件;
  6. 拷贝使用单事务、仅 INSERT 的分页填充,v1 在提交前始终完好。

LegacyV1Reader.readPage()对每页还做严格校验:memory_id必须严格递增(否则抛「page is not strictly keyset ordered」),嵌入数组长度必须等于预期维度且所有值为有限数——任何异常都会中止迁移。

放弃围栏(abandon fence)与 60 秒无进展时限

整个 preserve 步骤受一个进度刷新的 60 秒无进展截止时限约束(V1_PRESERVE_IDLE_TIMEOUT_MS = 60_000,见 runtimeConstants.ts):每次成功的原生 await 和页面插入后刷新。单个原生挂起因此被有界,又不会惩罚持续有进展的大规模迁移。

关键设计是截止时限到期必须同时停住仍在运行的流程——仅让 deadline 竞速返回控制权并不取消在途的异步迁移。每个迁移尝试携带一个 epoch/围栏(源码中的MigrationAbandonFence,见 legacyV1Reader.ts):

  • 截止时限到期把该尝试标记为abandoned
  • 流程在每次原生 await 之后每个文件系统副作用之前重新检查围栏;
  • 一旦被放弃,不得再 query/close/checkpoint/rename/delete;迟到的结算(无论成功或失败)只记日志、绝不恢复准入;
  • 原始 promise 保持被观察到结束,使迟到的 rejection 不会变成 unhandled rejection。

否则一个被隔离的进程可能在 70 秒时恢复拷贝/checkpoint/重命名/删除——即被隔离进程提交存储,并可能与快速重启的下一个进程的标记恢复流程竞态。MigrationAbandonFence.markProgress()/assertActive()的实现保证了这一点:markCommitted()之后不再允许abandon(),避免已提交的存储被误标放弃。

原生失败后的终态处理

一旦旧版原生访问开始,任何失败——错误或无进展截止时限到期——都遵循同一治理原则:写入隔离标记、为本进程余下时间关闭该 agent 的向量准入、召回退化为仅 FTS、进程内不关闭也不删除任何东西(wedged 的 open/read 仍持有 v1 句柄;致命错误后的closeSync是不可限界的同步原生调用)。泄漏的实例随进程消亡;下次启动命中步骤 0,清扫一切并从 SQLite 重建。Preserve 只是「永远正确的重建路径」之上的优化:一旦旧版原生访问不安全,就永久回退而不是分类重试——瞬时失败只代价一次重新嵌入,而误分类为「安全恢复」可能冻结应用。

生命周期管理:重置、退役与健康状态

  • resetVectorStore/destroyFile目标为 v2 路径,同时清扫 staging 与旧版 v1 文件(各自的主文件 + wal);
  • 显式重置与 agent 退役返回清理处置:completedpending-restartpending-restart意味着逻辑操作可在持久化隔离标记落盘后完成,被锁定的向量文件有意留给下一个进程;
  • Manager 状态收敛为单一health: 'healthy' | 'suspect' | 'quarantined'字段,accepting仅是持有计数托管的临时准入门:每个关闭准入的所有者(身份转换、租约内身份切换、超时观察、排空并关闭)持有一个 hold,结算时释放,仅当健康状态下最后一个 hold 释放时准入才重新打开。所有者从不单独依赖租约 epoch 推断所有权,避免「过渡排空期间观察到超时导致健康状态下准入仍关闭」;
  • 被隔离的 agent 在进程内绝不排空、关闭、删除或 await;标记持久化后,clear/delete 可完成逻辑工作并上报cleanupPendingRestart = true。agent 删除在删除仓库状态前执行此清理预检,标记持久化失败则中止删除;
  • 重新索引把pending-restart视为本进程的终态:SQLite 中重新排队的行保持 pending 等待下次启动,provider 排空与向量存储预热都不运行。放弃一个 agent 只从共享的嵌入预热中移除该 agent 的成员资格,共享 promise 与其他 agent 仍被跟踪。

被否决的恢复方案

规划文档明确记录了三类被否决的方案(见 plan.md):

  1. 进程内CHECKPOINT修复:在主进程内原生重放 HNSW WAL,可能卡死而非报错(不可限界的原生调用);
  2. 进程外修复或迁移(可杀死的子进程):触碰 v1 文件时隔离性最强,但跨平台打包与编排一个带原生模块的子进程,对「罕见的一次性路径且有安全回退(重建)」而言代价不合理;
  3. 进程内分类恢复非致命 preserve 失败:重新打开了「按错误类型逐类证明closeSync与文件删除安全性」的分析之门,每种新错误类型都需要新的证明。

测试矩阵:单元、原生集成与崩溃恢复

任务清单对每一类行为都要求了对应测试,且大部分已落地(见 memoryVectorStore.test.ts 与 memoryVectorStoreV2Native.test.ts):

  • publishFreshV2() 单元测试:staging 构建并重命名;v2Path永远不会以半构建状态被观察到;format_version = 2被记录;把 v1 文件改名为v2Path会在打开后自检失败并路由到重建;
  • 查询正确性query()在无LOAD vss时返回精确 top-k 顺序;
  • 标记最后销毁:标记删除失败则不发布任何内容;标记删除成功后发布失败会重新进入终态恢复,manager 为下一进程重新持久化标记;
  • 迁移路径:v1 无 WAL → 向量经 staging + 原子重命名保留、v1 文件消失、不请求重新嵌入;v1 带 WAL → 在打开可疑数据前销毁文件、全新 v2、经覆盖率验证触发重建;
  • preserve 失败处理(fake timers 模拟截止时限):标记写入、准入关闭、失败进程内无 close/delete;同进程热重试不处理标记;新上下文中下一次create()清扫 v1 + staging + marker 并重建——持续失败的 preserve 永远不会跨启动循环;
  • 放弃围栏:截止时限到期后 wedged 读取迟到结算 → 无拷贝、无重命名、无 v1 删除、准入保持关闭、迟到结算仅记录、无 unhandled rejection;
  • 崩溃恢复(子进程模拟非正常关机):重命名前崩溃(v1 + staging 均在)→ staging 删除、从 v1 重做迁移;重命名后崩溃(v2 + v1 均在)→ v2 权威打开、v1 被清扫;v1 清扫失败不丢弃也不重迁移已提交的 v2;v2 带残留 WAL → 正常打开、文件原封不动。子进程仅是测试工具,不影响生产端对子进程迁移的拒绝;
  • 原生集成测试:使用真实 DuckDB + 内置 VSS(非 mock SQL)构建无 WAL 的 v1 HNSW 存储,端到端跑 preserve 迁移,断言 keyset 分页、行数、format_version自检与迁移后存储的精确查询结果;
  • Windows CI 覆盖:同卷重命名语义、残留句柄行为、v1 删除失败(EBUSY)后已提交 v2 保持权威。

后续工作与规模逃生舱

任务清单中唯一未勾选项是后续独立变更(迁移窗口结束后执行):移除内置 VSS 扩展及其物化/网络安装机制,从构建中删除installRuntime:duckdb:vss,v1 处理简化为「销毁 + 重建」。

规格文档还记录了一个已记录但未构建的规模逃生舱:如果精确扫描代价(rowCount × dimensions)把实测 p95 召回延迟推向召回软时限,则在预热时从纯表构建内存 ANN 索引——绝不持久化。触发条件是这两个测量值而非固定行数阈值(1024 维 5 万行与 4096 维 1.2 万行代价相同)。今天构建它意味着每次打开都付出索引构建代价、换取在无收益规模下的近似结果。

总结

DeepChat 的内存向量存储 v2 是一个「以可靠性换取索引复杂度」的典型工程决策:用 DuckDB 核心的纯表 WAL 重放彻底消除 HNSW 持久化带来的损坏类缺陷,用精确扫描换取确定性的召回质量,再用「staging 构建 + 原子重命名」的统一提交原语、以文件存在性驱动的打开决策树、文件优先/标记最后的隔离恢复、以及放弃围栏保护的 v1 保留迁移,把整条升级路径做成零数据丢失且崩溃语义确定。对关心嵌入式向量存储工程化细节的开发者而言,这份设计与实现是极佳的参考样本。

【免费下载链接】deepchat🐬DeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询