Cherry Studio 知识库 V2 迁移深度解析:KnowledgeVectorMigrator 向量存储迁移全流程

Cherry Studio 知识库 V2 迁移深度解析:KnowledgeVectorMigrator 向量存储迁移全流程 Cherry Studio 知识库 V2 迁移深度解析KnowledgeVectorMigrator 向量存储迁移全流程【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300 assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio导读KnowledgeVectorMigrator是 Cherry Studio V2 数据迁移流水线中专用于知识库向量数据的迁移器它将 v1 时代按知识库base独立的embedjs向量数据库重建为 v2 时代每个知识库一套 7 表结构的index.sqlite索引存储KnowledgeIndexStore。本文以迁移器文档与 KnowledgeVectorMigrator.ts 源码为准完整讲解其数据源、目标存储、六大核心转换、内存与文件安全契约、校验规则与跳过语义帮助你理解一次不重新向量化的向量库迁移是如何在字节级、路径级和内存级三个维度上保证与运行时一致的。读完本文你将掌握 Cherry Studio 知识库 V2 迁移的设计取舍、目录展开向量重归属机制、以及迁移器如何防御 Windows 文件锁与 V8 堆内存溢出。迁移器在 V2 迁移流水线中的位置KnowledgeVectorMigrator注册于 migratorRegistry.tsnew KnowledgeMigrator(), new KnowledgeVectorMigrator(), new ChatMigrator(),其类定义声明readonly id knowledge_vector readonly name KnowledgeVector readonly description Rebuild legacy knowledge vectors into the per-base index.sqlite store readonly order 3.5几个关键位置语义order 3.5它严格排在KnowledgeMigrator负责把 Redux Dexie 导出迁移进knowledge_base/knowledge_item表之后执行因为向量迁移必须依赖前者产出的已迁移库与条目行。两阶段模型与流水线中所有迁移器一致它实现prepare()与execute()两个阶段——prepare()只读、只规划、只统计execute()才真正重建存储并落库。共享数据ctx.sharedData它通过三个共享键从KnowledgeMigrator接手映射关系KNOWLEDGE_BASE_ID_REMAP_SHARED_DATA_KEYlegacy base id → 迁移后 base id、KNOWLEDGE_ITEM_ID_REMAP_SHARED_DATA_KEYlegacy item id → 迁移后 item id、KNOWLEDGE_DIRECTORY_CHILD_LOADER_REMAP_SHARED_DATA_KEY迁移后 base id → loader id → 目录展开子条目 id。注意KnowledgeMigrator在 order 1.8 只对 legacy 向量库做count(*)加一次length(vector)探测而KnowledgeVectorMigrator在 order 3.5 要逐行读取pageContent/uniqueLoaderId/vector并重解 base 重映射——因此探测通过、真正读取时失败如损坏页、瞬时文件锁、重映射断裂只会发生在 3.5源码markBaseUnmigrated的注释明确说明了这一可达路径。数据源与目标存储数据源数据来源文件/路径迁移后的知识库身份与维度SQLiteknowledge_baseknowledge_base表迁移后的知识条目身份SQLiteknowledge_itemknowledge_item表Legacy loader 元数据Reduxknowledge.bases[].items[]ReduxStateReader.getCategory(knowledge)Legacy 分块向量每库独立的 legacy 向量库ctx.sources.knowledgeVectorSource.openBase(base.id)流式源码中Redux 状态读取为const knowledgeState ctx.sources.reduxState.getCategoryLegacyKnowledgeStateWithLoaders(knowledge) const migratedBases await ctx.db.select().from(knowledgeBaseTable)路径安全要求source reader 由MigrationContext以ctx.paths.knowledgeBaseDir初始化必须读取迁移解析出的 v1 userData 路径而非 v2 路径注册表或app.getPath()KnowledgeVectorMigrator自身也应持续使用 reader 抽象而不是内联拼接向量库路径。这与KnowledgeMigrator文档中的 Path Safety 约束一致README-KnowledgeMigrator.md。目标存储目标位置为迁移后 base 的运行时路径{knowledgeBaseDir}/{migratedBaseId}/.cherry/index.sqlite即每库一套 7 表索引存储。构建路径是createKnowledgeIndexStoreAtPath——与运行时KnowledgeVectorStoreService打开存储所用的同一个工厂driver → schema →ensureIndexMeta→KnowledgeIndexStore随后按条目调用KnowledgeIndexStore.rebuildMaterial。因此迁移产物与运行时自己构建的存储逐字节一致每个迁移条目对应一个material其 legacy 分块成为该 material 的search_unit。源码中的布局常量与注释明确了运行时路径的唯一事实来源// Runtime vector store material layout — source of truth: // src/main/features/knowledge/pathStorage.ts // (CHERRY_META_DIR / VECTOR_STORE_FILE / MATERIAL_ROOT_DIR). Runtime opens // {knowledgeBaseDir}/{baseId}/.cherry/index.sqlite by the migrated (new) base id... const KNOWLEDGE_META_DIR .cherry const KNOWLEDGE_VECTOR_STORE_FILE index.sqlite const KNOWLEDGE_MATERIAL_ROOT_DIR raw测试文件 KnowledgeVectorMigrator.test.ts 专门用runtimeVectorStorePath(baseId)与runtimeMaterialPath(baseId, relativePath)两个镜像函数做读回断言——如果迁移器写到了运行时不会打开的位置测试就会失败这正是该回归测试要防御的 bug。六大核心转换1. Loader 身份重映射无嵌入模型的可恢复失败库在 base 级被跳过它们保留 SQLite 中的 base/items 行用户选择新模型后必须重建。uniqueLoaderId不作为持久化字段保留而是被解析回迁移后的knowledge_item即material_id。uniqueIds[]优先级高于 legacy 单值uniqueId源码buildLoaderTargetMap中先遍历uniqueIds非空即用之。一条 legacy 向量行只有在能映射到已存在的 V2knowledge_item.id时才有效未映射的 legacy 行被视为无效索引残留而非必须保留的业务数据源码classifyVectorRow中的unmapped_loader分支明确注释了这一点。2. 可索引条目过滤只有映射到可索引 V2 条目类型的向量才会迁移可索引类型为file、url、note源码常量INDEXABLE_KNOWLEDGE_ITEM_TYPES new SetKnowledgeItemType([file, url, note])。被 v1 索引的directory的容器级向量正常路径下会被重归属到按每个嵌入 loader id 合成的 per-file 子条目一个file子条目对应一个嵌入 loader id见KnowledgeMigrator.expandLegacyDirectoryItem从而文件夹无需重新向量化即可保持可搜索。容器级向量仅作为兜底被跳过并告警——即 legacy 向量源不可读或某嵌入文件没有可迁移向量时——此时文件夹被保留为迁移失败墓碑directory_not_migrated。该过滤不会从knowledge_item删除directory行只是阻止容器级向量写入 V2 存储。目录子条目的合成逻辑来自collectDirectoryGroups每个子条目携带groupId 容器id而迁移只在目录展开子条目上设置非空groupId因此按行分组可捕获 base 的全部子条目。源码特意强调不能从 loader 重映射的值来推导分组因为两个重叠/重复的 v1 文件夹共享同一文件的 loader id 时KnowledgeMigrator的 last-write-wins 重映射只保留后一个子条目按行扫描才能把未分到向量的子条目也正确降级。独立条目与目录的 loader 冲突collectStandaloneLoaderOwners防止目录重归属偷走独立条目的向量——md5(path)会让独立添加的文件与文件夹内的文件碰撞同一个 loader id解析时独立条目保持向量所有权并记录directory_child_loader_conflict告警。3. Material 组装Route A——保留 v1 切分每个迁移条目一个material其relative_path通过共享的toMaterialRelativePath助手从迁移后的knowledge_item推导与运行时索引任务完全一致——文件使用其存储的relativePath有处理产物时用处理产物路径。迁移的 url 则改为固定指向为它在raw/下物化的快照文件由content.text加 OKF frontmatter 印章生成替换助手原本会返回的 item-id 虚拟路径。存储填充其余行current_content_hash、时间戳没有origin/index_policy/file_ext列content不携带text_format。条目的 legacy 分块正文按 legacy 读取顺序拼接为单一规范content.text以文档分隔符\n\n源码常量DOCUMENT_SEPARATOR连接每个分块成为一个search_unit其[char_start, char_end)切片精确切回自身正文并附带 body 的search_text行unit_index为条目的读取顺序。function buildMigratedUnits(pageContents: string[]): RebuildMaterialInput[units] { const units: RebuildMaterialInput[units] [] let cursor 0 pageContents.forEach((pageContent, index) { if (index 0) cursor DOCUMENT_SEPARATOR.length const charStart cursor const charEnd cursor pageContent.length cursor charEnd units.push({ unitType: chunk, unitIndex: index, charStart, charEnd }) }) return units }这是合成拼接而非重新切分第一次真正的 reindex 会用运行时切分器重新切分并收敛。迁移按库记录日志。4. 嵌入复用零重新向量化legacyvector载荷从F32_BLOB解码为Float32Array端到端保持驻留大小仅为number[]的一半通过encodeVectorBlobraw little-endian float32写入embedding表以正文的embedding_text_hash为键。字节与运行时编码完全一致因此无需重新向量化存储可跨引擎移植。相同分块正文库内或跨库折叠为一行embeddinghashEmbeddingText去重 INSERT OR IGNORE。不支持的向量编码在unsupported_vector_encoding下跳过与真正缺失载荷分开统计。向量长度与库记录dimensions不一致的在dimension_mismatch下跳过而不是破坏整个库的暴力余弦扫描。5. 身份再生成legacy 分块行 id 不重用unit_id/content_hash/search_text_id由存储根据 material id、内容与偏移确定性推导。6. 身份戳ensureIndexMeta写入单一meta身份行schema 版本 base id使运行时打开存储时无需重新引导并在base_id不匹配时拒绝被换入/外来的index.sqlite。构建契约快照嵌入模型、维度、切分器配置哈希有意不存储——模型/维度变更会创建新库切分器变更会重建派生索引。内存契约最多驻留一个条目的文本 一批向量prepare() 阶段prepare()将每个库的 legacy 行流式扫描一次openBase().reader.iterateRows()只保留每个条目的 rowid 列表计数expectedUnitCount、expectedEmbeddingCount、sourceRowCount按原因聚合的跳过统计采样封顶绝不每个被拒行一条消息每个 url/note 条目的预留快照路径通过向量无关的文本投影 point-read 该条目自身行推导。PreparedBasePlan接口刻意不持有任何向量或分块文本。它保留的唯一按分块结构是 rowid 列表且该列表确实随迁移总块数线性增长每个分块一个 JS numberpacked SMI 数组——100 万分块时约 10.5 B/分块1000 万时约 8.0 B/分块node --expose-gc下围绕Mapstring, number[]构建的堆增量实测。文档给出的典型语料估算注意这是估算而非代码强制上限迁移器只要求dimensions为正整数不约束pageContent长度或库的分块数普通语料下一个分块在 legacy 库中占用 ≥5 KB1024 维 4 KB float32 向量 ~1 KB 文本因此计划开销约为 legacy 文件磁盘大小的 1/500100 MB 计划意味着 ~50 GB 的 v1 向量库耗尽 V8 的 4 GB old-space 需要约 4 亿分块约 2 TB 源数据观测到的最重语料约 7.5 万分块 ≈ 0.7 MB 计划。低维度、极短分块的语料会改变这一比例。保留该计划是可接受的权衡——它换来的是对 legacy 库按条目重读而非第二次全量扫描——其合理性来自 v1 一次嵌入调用一个分块的索引方式从未产生过接近该量级的数据而非存在代码检查的上限。execute() 阶段execute()一次只重读一个条目其文本整体通过向量无关的列投影读取loadTextRowsByRowids——内容 schema 每个 material 一行文本因此拼接文本在 schema 不变的前提下不可再压缩其向量以固定≤500 rowid的 point-read 批次读取loadRowsByRowids由rebuildMaterial的Iterable嵌入输入在其写事务内惰性拉取。const VECTOR_STREAM_BATCH_SIZE 500 // 与 reader 的 IN 子句批次 ROWID_BATCH_SIZE 对齐function* iterateLegacyEmbeddingBatches(...): GeneratorRebuildMaterialEmbeddingInput { for (let offset 0; offset rowids.length; offset VECTOR_STREAM_BATCH_SIZE) { const batch rowids.slice(offset, offset VECTOR_STREAM_BATCH_SIZE) const rows reader.loadRowsByRowids(batch) if (rows.length ! batch.length) { throw new Error(...) } // fail-closed ... } }url/note 快照文件在该条目的回合内写入绝不跨库缓冲解码后的向量端到端保持Float32Array驻留为number[]的一半。OOM 历史源码注释完整记录最初在 prepare→execute 之间保留每个库的所有 material峰值达到所有库向量之和28 库语料耗尽 V8 堆后续的按库重读仍一次性加载整个库——单个大库六位数分块 × 高维度就能独自耗尽且因迁移每次启动都从头重跑而循环崩溃。OOM 回归防护测试KnowledgeVectorMigrator.test.ts钉死了PreparedBasePlan的精确键集合与每个阶段的读取形态prepare 流式execute 经投影读文本、向量按 ≤500 rowid 分批——一个 501 分块的条目必须以 500 1 到达。此外流式扫描每 1024 行STREAM_ROW_YIELD_INTERVAL主动让出事件循环一次避免六位数行的库在整个扫描期间冻结迁移 UI。文件安全契约原地构建不做 rename迁移器将每个重建的存储直接构建在运行时路径{migratedBaseId}/.cherry/index.sqlite——无临时文件、无 rename。文档解释了 rename 曾是迁移在 Windows 上最脆弱的环节WAL 模式下打开的 SQLite 存储可能使文件在close()之后仍被锁住wal_checkpoint(TRUNCATE)、PERSIST_WAL与数秒等待都不可靠地释放它叠加 AV/搜索索引器扫描刚写入的文件无DELETEshareMoveFileEx需要源文件的DELETE权限于是 rename 抛出EBUSY/EPERM库丢失存储。重试只能等掉瞬态 AV 扫描等不掉永不释放的句柄。原地构建完全移除了移动操作——close()后残留的任何锁都无害因为没有东西移动或重开该文件运行时只在 bootstrap 之后很久才打开它。源码中对文件系统瞬态锁做了多层防御const REMOVE_RETRY_OPTIONS { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const const TRANSIENT_FS_LOCK_CODES new Set([EPERM, EACCES, EBUSY]) const FS_RETRY_MAX_ATTEMPTS 8 const FS_RETRY_BASE_DELAY_MS 100 const FS_RETRY_MAX_DELAY_MS 1500retryOnTransientFsLock以指数退避100ms → 最大 1500ms保护raw/快照writeFile与mkdirfs.rm的内置重试不覆盖写/建目录且其 errno 集合显著遗漏了EACCES。崩溃原子性的取舍原地构建以 rename 的崩溃原子性中断的构建会在运行时路径留下部分索引换取上述鲁棒性。这是安全的因为迁移闸门对任何未完成的运行都从头重跑verifyAndClearNewTables()清行、KnowledgeMigrator重新铸新 uuid 目录、运行时绝不在迁移中途打开存储每库 catch 会在捕获失败时擦除部分产物崩溃遗留的目录永远不会被knowledge_base行引用因此永远不会被挂载与 rename 路径产出的死磁盘一致目标处的index.sqlite{-wal,-shm}家族在重新构建前被移除带可存活 EBUSY 的重试WAL 通过PRAGMA wal_checkpoint(TRUNCATE)折回主文件运行时打开的是一个自包含的存储。全有或全无发布 快照 pin一个库全有或全无地发布将其 url/note 行固定到快照是发布的最后一步——只有该事务提交后存储才算提升。此前任何抛出构建、关闭或 pin 事务都会擦除索引并将库标记为可恢复的failed。零 pin 不得记为成功一个completed的 url/note 条目没有relativePath是deriveConceptId守卫的不变量违例且没有任何机制修复它——index-documents跳过completed条目所以 ensure-snapshot 永远不会重新捕获已完成的迁移永远不会重跑。标记为failed的库转而走恢复流程把条目重新加入一个新库其行不是completed因此确实会被索引。该恢复只有一种情况有损url 条目会重新抓取实时页面而不是重读本迁移器写入但从未 pin 的raw/快照——因此已经失效的链接不可恢复。legacy 源永不移动或删除v1 的 legacy embedjs 库{knowledgeBaseDir}/{legacyBaseId}绝不被移动或删除。每个迁移库获得新 uuid重建的 V2 存储位于不同路径{migratedBaseId}/.cherry/index.sqlite永不与 legacy 扁平路径冲突——legacy 源无需搬迁。用户在失败、放弃甚至成功的迁移后回滚到 v1知识库依然可用。重试天然幂等legacy 源仍在原处重试直接通过KnowledgeVectorSourceReader重读原始 legacy 库。当前限制单库失败非致命单个库的失败不会中止整个迁移。当一个库的向量存储无法重建——无论prepare()完全无法读取/映射其 legacy 源还是execute()在重建或发布中途失败——该库被跳过、标记为可恢复的failed/missing_vector_store行UI 据此显示 re-index 入口失败以告警呈现execute()仍返回success: true其余库继续迁移。这防止单个库的迁移错误把用户挡在应用之外失败库在 UI 内恢复而不是重跑整个迁移。整个迁移只在结构性/完整性错误迁移器抛出或validate()的对账失败时整体失败绝不会因某库数据无法迁移而失败。唯一例外如果库自身的failed/missing_vector_store标记无法写入应用数据库迁移器抛出且整个迁移失败。该标记是阻止库进入运行时打开路径的唯一手段因此迁移记录为completed却没有该标记会让库永久无法搜索且无路可退失败反而让下次启动从头重跑。磁盘空间不回收成功迁移后v1 legacy 向量库以及复制的 legacy 上传文件以孤儿形式留在磁盘上磁盘空间不回收。回收被有意留给未来一个独立的清理步骤并以用户放弃 v1 为前提。校验每个成功迁移的库重建存储的行数必须与 prepare 时的规划一致material数 每个迁移条目一个search_unit数 这些条目保留的分块总数embedding数 库内不同的 embedding 文本哈希数每个search_text行必须解析到已存储的embedding零未覆盖单元——这是重建自愈不变量在迁移期的形态没有支撑向量的单元会静默缺席向量搜索。流式批次还有 fail-closed 的漂移防御iterateLegacyEmbeddingBatches行数/解码/维度不匹配会抛出中止事务文本扫描之后pageContent变化产生的哈希没有单元推导它存储的 embedding 覆盖检查会将其转为回滚。被跳过的数据清单库级跳过不在迁移后knowledge_base中的库标记为failed或embeddingModelId null的库dimensions无效的库legacy 库文件缺失、解析为目录、不含vectors表、或扫描中途不可读的库迁移后 id 无法映射回 legacy 知识库 id 的库重映射后的 legacy id 不在 legacy Redux 状态中的库。行级跳过uniqueLoaderId无法映射到迁移后knowledge_item.id的向量行映射到非索引容器类型如directory的向量行——仅限兜底路径legacy 源不可读或嵌入文件没有可迁移向量正常路径会重归属到 per-file 子条目vector载荷缺失或为空的向量行载荷存在但通过不支持的运行时编码暴露的向量行向量长度与库记录dimensions不一致的向量行。跳过的语义被跳过的库根本不产生存储这与产生空存储不同。因此上述每个库级跳过都会在execute()的 flush 中被标记为可恢复的failed/missing_vector_store而非completed仅两个例外根本没有迁移后knowledge_base行的库prepare()迭代的是迁移后的行无行可标记以及已为failed的库它携带自己的错误——如missing_embedding_model——不得覆盖。标记的原因没有任何机制把knowledge_base行与文件系统对账。一个没有存储的completed库首次运行时打开会创建并缓存一个空白的index.sqlite搜索结果永远为空没有失败徽标也没有恢复入口。恢复是 UI 内的 restore 流程把条目重新加入新库并重新向量化而不是重跑迁移——迁移本身仍然完成。如果某库下所有 legacy 向量行都被跳过该库仍会被规划其重建的 V2 存储预期为空仅 schema meta行。这是有意为之只有能被证明属于迁移后knowledge_item行的向量在 V2 中才保持有效。目录孤儿降级源码中还有一层精细的孤儿降级逻辑目录展开产生的子条目若未获得任何向量markEmptyDirectoryChildren会被降级为failed/directory_not_migrated其虚拟路径源无法 reindex若一组内所有子条目都为空容器本身也被降级。整个库被跳过时markDirectoryGroupsFullyOrphaned容器与所有子条目一起降级。降级写入flushDirectoryDegradations按 500 一批DEGRADE_UPDATE_CHUNK执行规避 SQLite 绑定变量上限且降级失败只记告警不抛出——丢失降级只留下某库内几个空文档而丢失库标记会让整个库静默不可搜索两者的爆炸半径完全不同KnowledgeVectorMigrator.ts。测试与回归保障KnowledgeVectorMigrator.test.ts约 3300 行围绕迁移器的核心不变量构建了多层回归防线运行时路径读回断言runtimeVectorStorePath镜像pathStorage.ts的{root}/{baseId}/.cherry/index.sqlite布局runtimeMaterialPath镜像raw/布局——迁移器写到运行时不会打开的位置即失败OOM 回归守卫钉死PreparedBasePlan的精确键集合与每阶段读取形态prepare 流式execute 按 ≤500 rowid 分批501 分块条目必须以 500 1 到达字节级等价验证读回时用KnowledgeIndexStore、encodeVectorBlob、hashEmbeddingText等运行时同一套实现断言存储内容skip 语义验证unmapped_loader、dimension_mismatch、缺失/空载荷、非索引容器类型、legacy 库缺失/目录/非 embedjs 格式等各分支directory 展开与重归属验证legacy sitemap 映射为 url、目录容器级向量重归属到合成子条目、冲突时的独立条目所有权保留。与 KnowledgeMigrator 的协作与恢复流KnowledgeVectorMigrator无法脱离其前置迁移器独立理解。两阶段的协作要点目录展开KnowledgeMigrator.expandLegacyDirectoryItem为每个嵌入 loader 源合成一个file子条目并记录 loader id → 子条目 id 的映射KnowledgeVectorMigrator据此把容器级向量重归属到子条目README-KnowledgeMigrator.md。缺失嵌入模型的可恢复失败当 legacy 库的嵌入模型 id 存在于 Redux 但不存在于 V2user_model例如ollama::dengcao/Qwen3-Embedding-0.6B:Q8_0KnowledgeMigrator将其保存为embeddingModelId null、status failed、error missing_embedding_modelKnowledgeVectorMigrator因嵌入模型契约无法验证而跳过该库的向量。恢复走knowledge:restore-base运行时流程——以源库配置与所选模型创建新库、仅复制根条目、运行正常索引流——原始 failed 库保留供用户确认后再删除。维度解析KnowledgeMigrator从 legacy 向量库vectors表的首个非空 blob 长度length(vector)/4解析dimensionsKnowledgeVectorMigrator用同一值做dimension_mismatch校验。总结KnowledgeVectorMigrator是 Cherry Studio V2 迁移中最能体现与运行时逐字节一致设计哲学的实现复用createKnowledgeIndexStoreAtPath工厂与rebuildMaterial使迁移产物与运行时自建存储无差别通过 F32_BLOB →Float32Array→encodeVectorBlob的端到端浮点通道实现零重新向量化以prepare 流式 execute 分批 point-read的内存契约消化了 OOM 崩溃历史以原地构建 全有或全无发布 快照 pin 处理了 Windows 文件锁与崩溃原子性的矛盾最终以 500 分批的写入、封顶采样的告警和逐库非致命的失败语义保证任意规模的语料都能在不阻塞用户的前提下完成迁移。【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300 assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考