尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

DeepChat 内存向量存储 v2:以纯表 + 精确扫描重构 DuckDB 侧车存储并安全迁移 v1 数据 DeepChat 内存向量存储 v2以纯表 精确扫描重构 DuckDB 侧车存储并安全迁移 v1 数据【免费下载链接】deepchatDeepChat - 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.ts、legacyV1Reader.ts、memoryVectorStoreFormat.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 扩展现有查询 SQLORDER BY array_cosine_distance(...) LIMIT k使用的是 DuckDB 核心函数无需改动——只是没有索引时它以精确扫描方式执行。存储格式 v2 契约格式契约的要点是文件名本身就是格式判别符且在任何原生调用之前决定见 spec.mdv2 存储在agentId.v2.duckdb旧版 v1 存储在agentId.duckdb格式探测绝不能依赖打开文件来判断——打开 v1 文件本身就是危险动作会触发 WAL 重放与 HNSW catalog 加载静态的后缀名不携带运行时状态这与被否决的「同进程恢复的 generation 后缀」方案需要无界的运行时 generation 管理有本质区别。表结构与格式版本自检v2 使用纯表结构没有自定义索引。源码 memoryVectorStoreFormat.ts 中的createMemoryVectorStoreV2FormatPlan()给出了精确的建表 SQLCREATE 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()严格按以下步骤执行删除任何陈旧的stagingPath与${stagingPath}.wal在stagingPath构建完整的 v2 存储schema、format_version、嵌入身份preserve 路径还要拷贝行数据验证schema 存在、format_version 2、嵌入身份匹配preserve 路径还要核对源/目标行数一致CHECKPOINT、关闭并断言${stagingPath}.wal已不存在原子重命名stagingPath→v2Pathfs.renameSync置committed true并markCommitted()打开并验证最终存储若这次最终打开失败保留已提交的当前文件、持久化隔离标记、把恢复留给下一个进程——重命名仍是提交点。此外在重命名之前还会做一次「当前主文件仍不存在、孤儿当前 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路径 Av1 带残留 WAL → 重建rebuild带 WAL 的 v1 文件完全不开重放可疑的 HNSW WAL 正是损坏的触发点。直接destroyFilev1 文件通过publishFreshV2()发布全新空 v2。热路径上的覆盖率验证verifyVectorCoverage会发现 SQLite 中标记为已嵌入的行没有对应向量从而触发reindexEmbeddings(force)——以 SQLite 为事实来源重建嵌入。路径 Bv1 无 WAL → 保留preserve这是零重新嵌入成本的优化路径核心是LegacyV1Reader准备内置 VSS 扩展但不把共享的物化 promise 绑定到任何调用方的放弃围栏上迁移绝不执行网络INSTALL vss内置扩展不可用 → 安全删除 v1、发布空 v2否则由 legacyV1Reader.ts 的LegacyV1Reader在中立的纯内存 DuckDB 连接上加载 VSS并对 v1 文件做只读ATTACH把 VSS 和旧版访问完全隔离在 v2 热路径之外拷贝前先读取恰好一条合法的旧版embedding_meta(provider, model, dim)身份行只有精确身份匹配才允许保留缺失、重复、畸形或不匹配的元数据安全地重建为空且不触发隔离以memory_id排序、按 keyset 分页LEGACY_V1_MIGRATION_PAGE_SIZE 50读取memory_id, embedding——分页同时约束读侧的 JS 堆而不只是写侧行数据流入publishFreshV2()staging 构建 → 验证含源/目标行数 → checkpoint → 重命名提交然后 best-effort 删除 v1 文件拷贝使用单事务、仅 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 退役返回清理处置completed或pending-restart。pending-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进程内CHECKPOINT修复在主进程内原生重放 HNSW WAL可能卡死而非报错不可限界的原生调用进程外修复或迁移可杀死的子进程触碰 v1 文件时隔离性最强但跨平台打包与编排一个带原生模块的子进程对「罕见的一次性路径且有安全回退重建」而言代价不合理进程内分类恢复非致命 preserve 失败重新打开了「按错误类型逐类证明closeSync与文件删除安全性」的分析之门每种新错误类型都需要新的证明。测试矩阵单元、原生集成与崩溃恢复任务清单对每一类行为都要求了对应测试且大部分已落地见 memoryVectorStore.test.ts 与 memoryVectorStoreV2Native.test.tspublishFreshV2() 单元测试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 清扫失败不丢弃也不重迁移已提交的 v2v2 带残留 WAL → 正常打开、文件原封不动。子进程仅是测试工具不影响生产端对子进程迁移的拒绝原生集成测试使用真实 DuckDB 内置 VSS非 mock SQL构建无 WAL 的 v1 HNSW 存储端到端跑 preserve 迁移断言 keyset 分页、行数、format_version自检与迁移后存储的精确查询结果Windows CI 覆盖同卷重命名语义、残留句柄行为、v1 删除失败EBUSY后已提交 v2 保持权威。后续工作与规模逃生舱任务清单中唯一未勾选项是后续独立变更迁移窗口结束后执行移除内置 VSS 扩展及其物化/网络安装机制从构建中删除installRuntime:duckdb:vssv1 处理简化为「销毁 重建」。规格文档还记录了一个已记录但未构建的规模逃生舱如果精确扫描代价rowCount × dimensions把实测 p95 召回延迟推向召回软时限则在预热时从纯表构建内存 ANN 索引——绝不持久化。触发条件是这两个测量值而非固定行数阈值1024 维 5 万行与 4096 维 1.2 万行代价相同。今天构建它意味着每次打开都付出索引构建代价、换取在无收益规模下的近似结果。总结DeepChat 的内存向量存储 v2 是一个「以可靠性换取索引复杂度」的典型工程决策用 DuckDB 核心的纯表 WAL 重放彻底消除 HNSW 持久化带来的损坏类缺陷用精确扫描换取确定性的召回质量再用「staging 构建 原子重命名」的统一提交原语、以文件存在性驱动的打开决策树、文件优先/标记最后的隔离恢复、以及放弃围栏保护的 v1 保留迁移把整条升级路径做成零数据丢失且崩溃语义确定。对关心嵌入式向量存储工程化细节的开发者而言这份设计与实现是极佳的参考样本。【免费下载链接】deepchatDeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表