Cherry Studio 知识库向量迁移器(KnowledgeVectorMigrator)深度解析:从 V1 embedjs 到 V2 better-sqlite3 向量存储的完整迁移方案
2026/9/19 16:21:52 网站建设 项目流程

Cherry Studio 知识库向量迁移器(KnowledgeVectorMigrator)深度解析:从 V1 embedjs 到 V2 better-sqlite3 向量存储的完整迁移方案

【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio

本文围绕 CherryHQ/cherry-studio 的 V2 知识库向量迁移器KnowledgeVectorMigrator展开,系统讲解它如何把 V1embedjs旧向量库读取、转换并重建为 V2 的 better-sqlite3-backed 多表向量存储(index.sqlite),并确保迁移结果与已迁移完成的 V2knowledge_base/knowledge_item业务表稳定关联。读完本文,你将掌握该迁移器的职责边界、四类数据来源、字段转换规则、目录向量重新归属策略、文件安全与内存控制契约、校验与跳过规则,以及它如何与当前 runtime 的向量检索体系直接衔接。

本文对应的核心文档为 v2-refactor-temp/docs/knowledge/knowledge-vector-migrator.md,实现代码位于 KnowledgeVectorMigrator.ts(约 1474 行),配套的工程说明见 README-KnowledgeVectorMigrator.md。

1. 迁移器的职责边界:业务主数据与向量数据分离

V2 知识库迁移由两个迁移器协同完成,二者分工明确、互不越界:

迁移器负责范围核心产出
KnowledgeMigrator业务主数据迁移V2knowledge_base/knowledge_item业务表行
KnowledgeVectorMigrator向量数据迁移每个 base 的index.sqlite向量存储

KnowledgeVectorMigrator的职责不是迁移知识库业务主数据,而是聚焦于三件事:

  1. 读取 V1 每个 knowledge base 对应的 legacyembedjs向量库;
  2. 将旧的 chunk 向量数据转换为新的 better-sqlite3-backedvectorstores布局;
  3. 保证新向量数据能稳定关联回已经迁移完成的 V2knowledge_base/knowledge_item

由此得到一个贯穿全文的关键原则:source of truth 永远是 V2 业务表,而不是向量库。向量库只是业务数据派生的可重建索引,迁移器的所有取舍(映射、跳过、校验)都以"能否被 V2 业务表证明合法归属"为判据。

从源码看,KnowledgeVectorMigrator继承自BaseMigrator,声明为readonly id = 'knowledge_vector'readonly name = 'KnowledgeVector'readonly description = 'Rebuild legacy knowledge vectors into the per-base index.sqlite store',执行顺序order = 3.5(在业务主数据迁移KnowledgeMigrator之后运行),并实现了prepare()/execute()/validate()三段式迁移生命周期(KnowledgeVectorMigrator.ts)。

2. 数据来源:迁移器依赖的四类输入

迁移器运行时依赖四类输入,各自来源与作用如下(KnowledgeVectorMigrator.ts 的prepare()中对这些输入进行了统一装载):

2.1 已迁移的 knowledge base(SQLiteknowledge_base表)

  • 提供 base 身份;
  • 提供 embeddingdimensions
  • 决定哪些 base 需要尝试迁移向量库。

prepare()中通过ctx.db.select().from(knowledgeBaseTable)读取全部已迁移 base。dimensions必须满足typeof dimensions === 'number' && Number.isInteger(dimensions) && dimensions > 0,否则该 base 会被以invalid_dimensions原因跳过(KnowledgeVectorMigrator.ts)。这一规则与 knowledge-schema.md 中"dimensions为必填字段、解析失败则跳过整个 base"的约束一致。

2.2 已迁移的 knowledge item(SQLiteknowledge_item表)

  • 作为新的业务 item 身份来源;
  • 为 legacy loader identity 映射提供目标itemId

prepare()knowledgeItemTable中读取idbaseIdgroupIdtypedata五个字段,并按baseId归组成migratedItemsByBaseId供后续使用(KnowledgeVectorMigrator.ts)。

2.3 Legacy loader metadata(Reduxknowledge.bases[].items[]

  • 从 V1uniqueId/uniqueIds[]反查到已经迁移后的knowledge_item.id
  • 建立旧向量记录与新业务 item 的映射关系。

prepare()通过ctx.sources.reduxState.getCategory('knowledge')读取 V1 遗留 Redux 状态(KnowledgeVectorMigrator.ts)。同时从ctx.sharedData中读取三个由KnowledgeMigrator写入的共享映射:

  • KNOWLEDGE_BASE_ID_REMAP_SHARED_DATA_KEY:legacy base id → migrated base id;
  • KNOWLEDGE_ITEM_ID_REMAP_SHARED_DATA_KEY:legacy item id → migrated item id;
  • KNOWLEDGE_DIRECTORY_CHILD_LOADER_REMAP_SHARED_DATA_KEY:目录展开产生的 loader id → 子项 item id。

这些共享数据是"旧 loader identity → 新业务 item"映射的桥梁(KnowledgeVectorMigrator.ts)。

2.4 Legacy vector database(${getDataPath()}/KnowledgeBase/<baseId>

  • 读取 V1embedjsvectors表;
  • 提供原始 chunk 文本、source、vector。

迁移器不直接拼接向量库路径,而是通过MigrationContext初始化的KnowledgeVectorSourceReader抽象读取(README 明确要求:"must read from the migration-resolved v1 userData path, not from the v2 path registry orapp.getPath()")。该读取器位于 KnowledgeVectorSourceReader.ts,负责:

  • 路径解析:{knowledgeBaseDir}/{sanitizeFilename(baseId, '_')}
  • embedjs 格式探测:通过sqlite_master检查是否存在vectors表(isEmbedjsDatabase);
  • 打开失败分类:返回invalid_path/missing/directory/not_embedjs四种非 OK 状态(KnowledgeVectorSourceReader.ts);
  • 提供三种有界内存的读取形态(详见第 9 节内存契约)。

3. 目标存储:V2 的 per-base 多表 index store

迁移目标不是继续保留旧embedjs格式,而是生成新的 vectorstores 兼容存储。

3.1 目标文件路径

迁移后 base 的 runtime 路径为:

{knowledgeBaseDir}/{migratedBaseId}/.cherry/index.sqlite

路径常量在源码中明确声明(KnowledgeVectorMigrator.ts):

  • KNOWLEDGE_META_DIR = '.cherry'
  • KNOWLEDGE_VECTOR_STORE_FILE = 'index.sqlite'
  • KNOWLEDGE_MATERIAL_ROOT_DIR = 'raw'

由于迁移后 base 使用全新 uuid,V2 store 落在{migratedBaseId}/.cherry/index.sqlite,与 legacy 的扁平路径{knowledgeBaseDir}/{legacyBaseId}不同名、不冲突,因此 v1 源文件无需腾挪。

3.2 目标 schema

目标 schema 是schema.ts定义的多表 index store:meta/content/material/search_unit/search_text/embedding六个主表,外加外部内容 FTS5 表search_text_fts。旧的 embedjs/langchain 单表布局(idexternal_idcollectiondocumentmetadataembeddings+ 普通索引 + FTS 触发器)已被上述多表 schema 取代。

目标存储通过createKnowledgeIndexStoreAtPath工厂创建——这正是 runtime(KnowledgeVectorStoreService)打开存储所用的同一个工厂,因此迁移产出的 store 与运行时自己构建的 store 逐字节等价。工厂的标准打开序列为:driver → 版本感知 schema(createKnowledgeIndexSchema或版本不一致时resetKnowledgeIndexSchema)→ meta 身份(ensureIndexMeta)→KnowledgeIndexStore(createIndexStore.ts)。

ensureIndexMeta会写入唯一的meta身份行(schema 版本 + base id),这样 runtime 打开 store 时无需重新引导,并且会在base_id不匹配时拒绝一个被替换/串库的index.sqlite。值得注意:构建契约快照(embedding 模型、dimensions、切块器配置 hash)有意不存储——模型/维度变化会创建新 base,切块器变化则重建派生索引。

4. 核心转换规则

4.1 Loader identity 映射

V1 的向量记录使用uniqueLoaderId关联 loader。V2 迁移时不保留这个旧字段作为最终业务标识,而是把它映射成新的knowledge_item.id,并写入external_id

映射规则的优先级(见 KnowledgeVectorMigrator.ts 的buildLoaderTargetMap):

  1. 优先使用 legacy item 的uniqueIds[](每个非空uniqueId都建立映射);
  2. 如果不存在uniqueIds[],再回退到 legacy item 的uniqueId
  3. 只有已经成功迁移到 V2knowledge_item的 item 才能参与映射。

这里有一个贯穿始终的重要约束:

  1. 只有能够映射到 V2knowledge_item.id的 legacy 向量记录,才属于有效可迁移数据;
  2. 无法映射到knowledge_item.id的 legacy 向量,即使仍存在于旧embedjsDB 中,也视为无效残留数据;
  3. 因此迁移器的目标不是"尽量保留旧向量文件中的所有内容",而是"只保留能被当前 V2 业务表证明合法归属的向量数据"。

4.2 目录(directory)向量的特殊处理:重新归属而非丢弃

V1 把目录下每个文件都登记在该目录 item 的 loader id 上,没有 per-file item。迁移时不再把这些容器级向量直接丢弃,而是:

  1. KnowledgeMigrator.expandLegacyDirectoryItem为每个嵌入文件合成一个file子项(一个 loader id 对应一个子项);
  2. 目录向量被重新归属(re-attribute)到这些子项上;
  3. 目录因此保持可检索,且无需重新 embedding

KnowledgeVectorMigrator侧通过共享数据中的目录子项 loader 映射,把 loader id 指向子项 material,而不是目录容器(否则会因non_indexable_container被跳过)。同时它处理了一个隐蔽的冲突场景:一个 loader id 可能同时被目录展开和独立添加的 standalone item 认领(v1 的 loader id 是 path/content 哈希,md5(path) 可能让"独立添加的文件"与"文件夹内同路径文件"撞 id)。collectStandaloneLoaderOwners会优先把向量归属权判给 standalone item,并对冲突记录 warning 而不是静默窃取(KnowledgeVectorMigrator.ts)。

只有在 fallback 情况下——legacy 向量源不可读,或某个嵌入文件没有可迁移向量——才会跳过容器级向量,并把目录保留为directory_not_migrated失败墓碑(此时knowledge_item中目录行本身不会被删除,只是容器级向量不写入 V2 store)。

4.3 Chunk 内容映射

旧向量记录中的内容字段按以下规则转换:

旧字段新字段
pageContentdocument
knowledge_item.idmetadata.itemIdexternal_id
knowledge_item.typemetadata.itemType
sourcemetadata.source
chunk 顺序metadata.chunkIndex
chunk 文本 token 估算metadata.tokenCount

当前实现不会保留所有旧 metadata,只保留迁移和检索必需的最小信息。迁移后的 metadata 必须满足 runtimeKnowledgeChunkMetadataSchemaitemIditemTypesourcechunkIndextokenCount都是必填字段。无法补出合法source的 legacy row 会被跳过,而不是写入不完整 metadata。

4.4 内容装配(Route A:保留 V1 切分)

迁移采用"Route A"策略——保留 v1 的切分结果,而不是重新切块:

  1. 每个迁移 item 生成一个material,其relative_path通过共享的toMaterialRelativePath辅助函数从迁移后的knowledge_item派生(与 runtime 索引任务完全一致);文件使用存储的relativePath(有处理产物时用处理后 artifact 路径),url 则固定指向在raw/下为其物化的快照文件;
  2. item 的 legacy chunk 文本按 legacy 读取顺序,用文档分隔符\n\nDOCUMENT_SEPARATOR)拼接成一个规范的content.text
  3. 每个 chunk 成为一条search_unit,其[char_start, char_end)区间精确切回该 chunk 的原文,并配一条 body 的search_text行;unit_index为 per-item 读取顺序;
  4. buildMigratedUnits函数按游标累加DOCUMENT_SEPARATOR.length保证content.text.slice(charStart, charEnd) === body的不变量在构造上成立(KnowledgeVectorMigrator.ts)。

这是一种合成的拼接,不是新鲜的重新切分:第一次真正的 reindex 会用线上 splitter 重新切块并收敛。

4.5 Embedding 复用(不重新 embedding)

迁移器不会重新做 embedding,直接复用 V1 已存在的向量:

  1. 从 legacyvector字段读取原始 little-endian float32 BLOB 字节;
  2. 反序列化为Float32Array(端到端使用Float32Array而非number[],驻留内存减半);
  3. 通过encodeVectorBlob(原始 little-endian float32)写入新表的embedding表,以 body 的embedding_text_hash为键。

这意味着:迁移成本更低、不依赖在线模型调用、迁移阶段不会触发重新切块或重新嵌入,且迁移产出的字节与 runtime 编码完全一致,store 具备引擎可移植性。

细节上还有两个亮点:

  • 重复 body 折叠:相同 chunk body(material 内或跨 material)会折叠成一条embedding行(hash 键 +INSERT OR IGNORE),无需预去重;
  • fail-closed 漂移检测:若pageContent在文本 pass 后发生变更,会产生没有 unit 引用的 hash,store 的 embedding 覆盖检查会将其转为回滚(KnowledgeVectorMigrator.ts)。

4.6 Chunk identity 重建

旧 chunk row 的id不会直接复用。每一条迁移后的向量记录都会生成新的 UUID v4id(实际上unit_id/content_hash/search_text_id由 store 从 material id、内容和偏移量确定性派生)。

因此迁移的稳定关联语义不依赖旧 chunk id,而是依赖:

  1. baseId
  2. external_id=knowledge_item.id
  3. chunk 文本与 source 对应的向量记录。

5. 文件安全策略:从"临时文件 + 原子替换"演进到"原地构建"

这一节需要特别说明一个重要的实现演进。原设计文档 knowledge-vector-migrator.md 第 6 节描述的策略是"临时文件重建 + 目标路径原子替换 + v1 源原地不动":先写{targetDbPath}.vectorstore.tmp临时文件,校验成功后删除目标路径上空 store,再原子 rename 到目标路径(EBUSY时重试recursive + maxRetries + retryDelay)。

但当前实现已演进为"直接原地构建"——README 与源码均明确说明:"The migrator builds each rebuilt storedirectlyat its runtime path — no temp file, no rename"(KnowledgeVectorMigrator.ts)。演进的原因是:

  • index store 以 WAL 模式打开index.sqlite,在 Windows 上 WAL 模式已知会在close()之后仍对主 db 文件保持锁(wal_checkpoint(TRUNCATE)PERSIST_WAL、数秒等待都无法可靠释放);
  • 叠加杀毒软件/搜索索引器以无DELETE共享方式打开刚写入的文件,MoveFileEx需要源文件的DELETE权限,于是 rename 会抛EBUSY/EPERM,导致 base 丢失 store;
  • 重试只能等待瞬态 AV 扫描,无法等待一个永不释放的句柄;
  • 原地构建彻底移除了 move 操作:close()后残留的锁无害,因为这里不再移动或重新打开文件,runtime 只在 bootstrap 之后(迁移早已结束)打开它。

原地构建的代价是放弃了 rename 的崩溃原子性(中断的构建会在 runtime 路径留下部分索引),但这个代价是安全的,因为:

  1. 迁移门控在任何未完成运行后都会从头重跑(verifyAndClearNewTables()清空行,KnowledgeMigrator重新铸造全新 uuid 目录);
  2. runtime 在迁移中途绝不打开 store;
  3. per-base catch 在捕获失败时会清掉部分产物;
  4. 崩溃遗留的目录不被任何knowledge_base行引用,因此永远不会被挂载。

不变的安全铁律依然是:

  1. v1 legacyembedjsDB({knowledgeBaseDir}/{legacyBaseId})在整个迁移过程中不被移动也不被删除——迁移失败、放弃或成功后回退 v1,知识库都可正常使用;
  2. retry 天然幂等:legacy 源一直在原路径,retry 直接通过KnowledgeVectorSourceReader重新读取原始 legacy DB;
  3. 写入前的清理(removeIndexStoreFiles删除index.sqlite{,-wal,-shm}家族)使用{ recursive: true, force: true, maxRetries: 5, retryDelay: 100 }以在EBUSY下幸存;
  4. writeFile/mkdir同样面临 Windows 瞬态锁(Defender/Search Indexer),源码用retryOnTransientFsLock包装(最多 8 次尝试、指数退避、上限 1500ms,覆盖EPERM/EACCES/EBUSY)(KnowledgeVectorMigrator.ts);
  5. 构建完成后执行store.checkpoint()将 WAL 折叠回主文件(PRAGMA wal_checkpoint(TRUNCATE)),让 runtime 打开的是一个自包含的 store。

5.1 全有或全无的发布(all-or-nothing publication)

一个 base 只有在构建、close、以及快照 pin 事务全部成功后才算"已发布"(源码中的storePromoted标志)。url/note 行 pin 到其快照路径是发布的最后一步:

  • 任何更早的抛错(构建、关闭或 pin 事务)都会清掉索引并把 base 标记为可恢复的failed
  • 零 pin 不能被当作成功:一个completed的 url/note item 没有relativePath就违反了deriveConceptId守护的不变量,而没有任何机制能修复它(runtime 的 index-documents 跳过completeditem,已完成的迁移也永远不会重跑);
  • 失败的 base 会浮现 restore 流程,将 items 重新加入新 base(新行的状态不是completed,因此会真正被索引)。这个恢复仅在一个场景下有损:url item 会重新抓取线上页面而不是读取迁移写入的raw/快照,因此已失效的链接无法恢复。

6. 当前已接受的局限(不应误读为"未来理想方案")

6.1 base 级执行失败的处理

重要变更说明:原设计文档第 6 节曾表述"base 级执行失败属于迁移失败,execute()直接返回success: false"。当前实现已改为单 base 失败非致命:当一个 base 的向量 store 无法重建时(prepare()无法读取/映射其 legacy 源,或execute()在重建/发布中途失败),该 base 会被跳过并标记为可恢复的failed/missing_vector_store行(UI 显示 re-index 入口),失败以 warning 形式呈现,execute()仍返回success: true,其余 base 继续迁移(KnowledgeVectorMigrator.ts)。

唯一的例外:如果 base 自身的failed/missing_vector_store标记无法写入应用数据库,迁移器会抛出并使整个迁移失败。因为该标记是唯一把 base 挡在 runtime 打开路径之外的东西——一个被记录为completed但没有该标记的 base 将永远不可搜索且没有回头路;此时失败可以让下次启动从头重跑。

整个迁移只会在结构性/完整性错误上整体失败(迁移器抛出,或validate()的对账失败),绝不会因为单个 base 的数据无法迁移而整体失败。

6.2 磁盘占用翻倍与孤儿文件

迁移成功后 v1 legacy 向量库会作为孤儿文件留在磁盘上(连同已复制的 v1 上传文件),知识库磁盘占用大致翻倍。这是"保证 v1 可回退"的预期代价;如需在用户确认放弃 v1 后回收磁盘,需要单独的 cleanup 策略、实现和测试。

6.3 迁移前完整备份仍然必要

迁移器只保证单个 knowledge base 的 v1 向量 DB 原地完好,不等于完整 V1 备份。完整迁移失败后的全局恢复 source of truth 仍然是迁移前备份。

7. 校验规则:validate()的四重对账

当前实现会做至少以下校验,不满足即视为该 base 迁移失败(KnowledgeVectorMigrator.ts):

  1. 计数对账:每个成功 base 的重建 store 行数必须与 prepared 值一致:
    • material数 == 每个迁移 item 一个;
    • search_unit数 == 这些 item 保留的 chunk 总数;
    • embedding数 == 整个 base 的不同 embedding-text hash 数;
  2. 非空external_id:每条迁移后的记录必须有非空external_id
  3. metadata.itemId一致性:每条记录必须有metadata.itemId,且与external_id保持一致;
  4. embedding 覆盖检查:每条search_text行都必须能解析到已存储的embedding(零 uncovered units)——这是迁移期对"rebuild self-heal 不变量"的验证形式:没有底层向量的 unit 会静默缺席于向量检索;
  5. 快照文件存在性:url/note 的物化快照文件必须真实存在于{materialDirPath}/{relativePath}(验证时按plan.snapshotRelativePathByItemId逐一检查fs.existsSync)。

此外,validate()通过createKnowledgeIndexStoreAtPath(而非裸new Database)重新打开刚构建的 store——该工厂的 driver 设置了busy_timeout=5000,可以等待瞬态 Windows 锁,且 schema/meta 步骤在刚构建的 store 上是幂等的,不会改变计数结果。

8. 跳过规则:什么情况会被跳过而非强行写入

8.1 base 级跳过

以下情况整个 base 被跳过(不产出任何 store,并标记为可恢复的failed/missing_vector_store,而不是completed):

  1. knowledge_base中不存在对应 base(prepare()遍历的是已迁移行,因此无行可标);
  2. base 已被标记failedembeddingModelId = null(缺 embedding 模型/已有失败原因,不覆盖其自身错误);
  3. dimensions无效(非正整数);
  4. legacy DB 文件缺失 / 路径实际是目录 / 不是 embedjs 格式(无vectors表)/ 扫描中途不可读;
  5. 迁移后的 base id 无法映射回 legacy knowledge base id;
  6. 重映射后的 legacy id 在 legacy Redux 状态中不存在。

源码中 base 级 skip 分类包括:invalid_dimensionsunmapped_baselegacy_base_missinginvalid_pathmissingdirectorynot_embedjsread_errormissing_embedding_modelalready_failed(KnowledgeVectorMigrator.ts)。

8.2 row 级跳过

以下情况的单条向量记录会被跳过(classifyVectorRow的分类逻辑见 KnowledgeVectorMigrator.ts):

原因触发条件
unmapped_loaderuniqueLoaderId无法映射回已迁移的knowledge_item.id
non_indexable_container向量映射到不可索引的容器类型(如directory),且仅在 fallback 路径触发
unsupported_vector_encodingvector 载荷存在但暴露为不受支持的 runtime 编码
missing_vector_payload向量记录缺少vectorvector为空
dimension_mismatchvector 长度与 base 记录的dimensions不一致(避免破坏整个 base 的暴力余弦扫描)

这些跳过通常会记录 warning,而不是让整个迁移流程中断。为控制内存,跳过按原因聚合计数并只保留最多 3 个样本(SKIP_WARNING_SAMPLE_LIMIT = 3),绝不对每一条被拒行打一条日志。

8.3 补充说明:全部跳过后 base 的预期结果

  1. 如果某个 base 的 legacy 向量记录最终全部被跳过,则该 base 在 V2 中会被重建为空 vector store(只有 schema +meta行);
  2. 这不是"回滚保留旧 DB"的场景,而是预期的数据清洗结果;
  3. 原因是这些被跳过的记录无法稳定关联到当前 V2knowledge_item,因此不再被视为有效业务向量数据。

注意区分:被跳过的 base 不产出 store(不是空 store),与"规划了但内容为空"是两回事。

9. 内存契约:如何在超大语料下不 OOM

迁移器的内存设计是经过真实 OOM 事故迭代出来的。历史沿革:

  • 初版:把所有 base 的 materials 从 prepare 保留到 execute,峰值是全部 base 向量之和——28 个 base 的语料就耗尽了 V8 堆;
  • 改进版:per-base 重读,但仍一次性加载整个 base——单个大 base(六位数 chunk × 高维度)就能单独耗尽堆,导致迁移崩溃循环(每次重启从头再来)。

当前实现的契约是:任何时刻最多驻留"一个 item 的文本 + 一个批次(≤500 行)的向量"

  1. prepare()通过openBase().reader.iterateRows()流式扫描每个 base 的 legacy 行一次,只保留 per-item 的 rowid 列表、计数、按原因聚合的跳过统计(封顶样本)以及每个 url/note item 预保留的快照路径。PreparedBasePlan刻意不持有任何向量或 chunk 文本(KnowledgeVectorMigrator.ts);
  2. 扫描每 1024 行(STREAM_ROW_YIELD_INTERVAL)让出一次事件循环,避免六位数行的 base 冻结迁移 UI;
  3. execute()逐 item 重读:文本通过无向量列投影loadTextRowsByRowids整体读取(content schema 每个 material 一条文本行,拼接后的文本不可再分),向量通过loadRowsByRowids以固定 ≤500 行(VECTOR_STREAM_BATCH_SIZE,与读取器ROWID_BATCH_SIZE对齐)的点读批次流式拉取,由rebuildMaterial在写事务内惰性消费——每个 pull 恰好是一次索引化SELECT(KnowledgeVectorMigrator.ts);
  4. 解码后的向量端到端保持Float32Array(驻留大小为number[]的一半);
  5. url/note 快照文件在对应 item 的轮次内写入,绝不跨 base 缓冲。

rowid 列表是唯一刻意保留的 per-chunk 结构,它随迁移总 chunk 数线性增长,单 chunk 成本是一个压缩 SMI 数组中的一个 JS number:在 1M chunks 时约 10.5 B/chunk,10M 时约 8.0 B/chunk(node --expose-gc堆增量实测)。这是对典型语料的估算,而非代码强制上限——迁移器只要求dimensions是正整数,不约束pageContent长度或 base 的 chunk 数。对普通语料,一个 chunk 在 legacy DB 中占 ≥5 KB(1024 维 float32 向量 4 KB + 约 1 KB 文本),plan 约为 legacy 文件磁盘大小的 1/500:100 MB 的 plan 意味着约 50 GB 的 v1 向量 DB;耗尽 V8 的 4 GB old-space 需要约 4 亿 chunks(约 2 TB 源),而观测到的最重语料约 7.5 万 chunks(≈ 0.7 MB plan)。

OOM 回归守卫测试会钉死PreparedBasePlan的确切键集和每个阶段的读取形态(prepare 流式;execute 文本走投影、向量走 ≤500-rowid 批次——501-chunk 的 item 必须以 500 + 1 的形式到达)。

10. 与当前 Runtime 的衔接

迁移后的向量数据不是孤立的一次性产物,而是会被当前 runtime 直接按 knowledge base 打开和查询。

runtime 向量侧实现位于(注:文档中的src/main/services/...旧路径在当前仓库中已迁移至src/main/features/...):

  • KnowledgeRuntimeService.ts 对应的运行时服务;
  • KnowledgeVectorStoreService.ts:按base.id获取 store;
  • BetterSqlite3VectorIndex.ts:实际 store provider。

当前已确认的衔接点:

  1. runtime 通过KnowledgeVectorStoreServicebase.id获取 store;
  2. 实际 store provider 是BetterSqlite3VectorIndex
  3. runtime 检索和写入都基于 better-sqlite3 vector store;
  4. 迁移器与 runtime 共用同一个createKnowledgeIndexStoreAtPath工厂,因此迁移产出的 store 与 runtime 自建的 store 字节等价(createIndexStore.ts 的注释明确写道:"Shared by the runtime (KnowledgeVectorStoreService.openIndexStore) and the v1→v2 vector migrator so the sequence lives in exactly one place")。

因此,迁移器与 runtime 的共同前提是:

  1. V2 业务真相来自knowledge_base/knowledge_item
  2. 运行时向量文件与迁移后的向量文件都属于同一类 better-sqlite3-backed vector store 体系;
  3. 运行时关联业务 item 仍应以knowledge_item.id为稳定标识,而不是继续依赖 V1 loader identity。

11. 当前边界、限制与对后续实现的影响

11.1 边界与定位

当前迁移器只负责"向量数据重建",不负责:

  1. 重新切块;
  2. 重新 embedding;
  3. 重新生成业务 item;
  4. 校正旧知识库的业务配置;
  5. 设计最终 retrieval service 的 API。

因此它的定位是:一次性的迁移工具,不等同于运行时知识库索引服务。

11.2 对后续实现的影响

基于当前迁移器行为,后续 V2 运行时设计需要遵守以下前提:

  1. V2 业务真相仍然来自knowledge_base/knowledge_item
  2. 新向量记录必须能通过external_id稳定关联到knowledge_item.id
  3. 运行时不应继续依赖 V1embedjsuniqueLoaderId
  4. 如果未来需要重建索引,应按 V2 业务表重新生成,而不是继续依赖旧迁移逻辑。

12. 相关文档的定位关系

V2 知识库迁移与运行时设计由三份文档共同定义,各有分工:

文档定义内容
knowledge-schema.mdV2 业务 schema(knowledge_base/knowledge_item的列、groupId语义、dimensions解析规则、item 状态迁移规则)
knowledge-backend-decisions.md当前KnowledgeRuntimeService、data services、queue 和 runtime/vector 边界
knowledge-vector-migrator.md(本文主题)旧向量数据如何迁移进新体系

三者的关系可以简化为:

  1. schema 定义业务结构;
  2. backend decisions 文档定义当前运行时边界;
  3. vector migrator 文档定义旧向量数据如何迁移进新体系。

13. 小结

KnowledgeVectorMigrator是一个"克制"的迁移器:它不重新切块、不重新 embedding、不迁移业务主数据,只做一件事——把 V1embedjs向量库中能被 V2 业务表证明合法归属的 chunk 向量,转换为与 runtime 完全同构的 per-base better-sqlite3 index store。它的关键设计决策可以总结为:

  • 归属优先于保留:无法映射到 V2knowledge_item.id的旧向量一律视为无效残留,宁可得到空 store 也不写入不可证明归属的数据;
  • 复用优先于重建:embedding 字节原样复用(Float32Array端到端)、切分结果以 Route A 拼接保留,迁移全程零在线模型调用;
  • 安全优先于整洁:v1 源永不移动/删除、原地构建规避 Windows WAL 锁、全有或全无发布、单 base 失败隔离为可恢复状态,磁盘翻倍是"可回退"的预期代价;
  • 有界内存优先于简单实现:流式扫描 + rowid 计划 + 分批点读,把峰值内存钉死在"一个 item 文本 + 500 行向量"。

这套设计使迁移既可作为一次性工具安全执行,又能让产出的向量数据无缝接入 V2 运行时检索体系,是 Cherry Studio V2 知识库升级链路中承上启下的关键一环。如需深入源码,建议按 KnowledgeVectorMigrator.ts → KnowledgeVectorSourceReader.ts → createIndexStore.ts 的顺序阅读,并结合 README-KnowledgeVectorMigrator.md 对照实现契约。

【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio

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

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

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

立即咨询