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的职责不是迁移知识库业务主数据,而是聚焦于三件事:
- 读取 V1 每个 knowledge base 对应的 legacy
embedjs向量库; - 将旧的 chunk 向量数据转换为新的 better-sqlite3-backed
vectorstores布局; - 保证新向量数据能稳定关联回已经迁移完成的 V2
knowledge_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 身份;
- 提供 embedding
dimensions; - 决定哪些 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中读取id、baseId、groupId、type、data五个字段,并按baseId归组成migratedItemsByBaseId供后续使用(KnowledgeVectorMigrator.ts)。
2.3 Legacy loader metadata(Reduxknowledge.bases[].items[])
- 从 V1
uniqueId/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>)
- 读取 V1
embedjs的vectors表; - 提供原始 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 单表布局(id、external_id、collection、document、metadata、embeddings+ 普通索引 + 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):
- 优先使用 legacy item 的
uniqueIds[](每个非空uniqueId都建立映射); - 如果不存在
uniqueIds[],再回退到 legacy item 的uniqueId; - 只有已经成功迁移到 V2
knowledge_item的 item 才能参与映射。
这里有一个贯穿始终的重要约束:
- 只有能够映射到 V2
knowledge_item.id的 legacy 向量记录,才属于有效可迁移数据; - 无法映射到
knowledge_item.id的 legacy 向量,即使仍存在于旧embedjsDB 中,也视为无效残留数据; - 因此迁移器的目标不是"尽量保留旧向量文件中的所有内容",而是"只保留能被当前 V2 业务表证明合法归属的向量数据"。
4.2 目录(directory)向量的特殊处理:重新归属而非丢弃
V1 把目录下每个文件都登记在该目录 item 的 loader id 上,没有 per-file item。迁移时不再把这些容器级向量直接丢弃,而是:
KnowledgeMigrator.expandLegacyDirectoryItem为每个嵌入文件合成一个file子项(一个 loader id 对应一个子项);- 目录向量被重新归属(re-attribute)到这些子项上;
- 目录因此保持可检索,且无需重新 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 内容映射
旧向量记录中的内容字段按以下规则转换:
| 旧字段 | 新字段 |
|---|---|
pageContent | document |
knowledge_item.id | metadata.itemId与external_id |
knowledge_item.type | metadata.itemType |
source | metadata.source |
| chunk 顺序 | metadata.chunkIndex |
| chunk 文本 token 估算 | metadata.tokenCount |
当前实现不会保留所有旧 metadata,只保留迁移和检索必需的最小信息。迁移后的 metadata 必须满足 runtimeKnowledgeChunkMetadataSchema:itemId、itemType、source、chunkIndex、tokenCount都是必填字段。无法补出合法source的 legacy row 会被跳过,而不是写入不完整 metadata。
4.4 内容装配(Route A:保留 V1 切分)
迁移采用"Route A"策略——保留 v1 的切分结果,而不是重新切块:
- 每个迁移 item 生成一个
material,其relative_path通过共享的toMaterialRelativePath辅助函数从迁移后的knowledge_item派生(与 runtime 索引任务完全一致);文件使用存储的relativePath(有处理产物时用处理后 artifact 路径),url 则固定指向在raw/下为其物化的快照文件; - item 的 legacy chunk 文本按 legacy 读取顺序,用文档分隔符
\n\n(DOCUMENT_SEPARATOR)拼接成一个规范的content.text; - 每个 chunk 成为一条
search_unit,其[char_start, char_end)区间精确切回该 chunk 的原文,并配一条 body 的search_text行;unit_index为 per-item 读取顺序; buildMigratedUnits函数按游标累加DOCUMENT_SEPARATOR.length保证content.text.slice(charStart, charEnd) === body的不变量在构造上成立(KnowledgeVectorMigrator.ts)。
这是一种合成的拼接,不是新鲜的重新切分:第一次真正的 reindex 会用线上 splitter 重新切块并收敛。
4.5 Embedding 复用(不重新 embedding)
迁移器不会重新做 embedding,直接复用 V1 已存在的向量:
- 从 legacy
vector字段读取原始 little-endian float32 BLOB 字节; - 反序列化为
Float32Array(端到端使用Float32Array而非number[],驻留内存减半); - 通过
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,而是依赖:
baseId;external_id=knowledge_item.id;- 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 路径留下部分索引),但这个代价是安全的,因为:
- 迁移门控在任何未完成运行后都会从头重跑(
verifyAndClearNewTables()清空行,KnowledgeMigrator重新铸造全新 uuid 目录); - runtime 在迁移中途绝不打开 store;
- per-base catch 在捕获失败时会清掉部分产物;
- 崩溃遗留的目录不被任何
knowledge_base行引用,因此永远不会被挂载。
不变的安全铁律依然是:
- v1 legacy
embedjsDB({knowledgeBaseDir}/{legacyBaseId})在整个迁移过程中不被移动也不被删除——迁移失败、放弃或成功后回退 v1,知识库都可正常使用; - retry 天然幂等:legacy 源一直在原路径,retry 直接通过
KnowledgeVectorSourceReader重新读取原始 legacy DB; - 写入前的清理(
removeIndexStoreFiles删除index.sqlite{,-wal,-shm}家族)使用{ recursive: true, force: true, maxRetries: 5, retryDelay: 100 }以在EBUSY下幸存; writeFile/mkdir同样面临 Windows 瞬态锁(Defender/Search Indexer),源码用retryOnTransientFsLock包装(最多 8 次尝试、指数退避、上限 1500ms,覆盖EPERM/EACCES/EBUSY)(KnowledgeVectorMigrator.ts);- 构建完成后执行
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):
- 计数对账:每个成功 base 的重建 store 行数必须与 prepared 值一致:
material数 == 每个迁移 item 一个;search_unit数 == 这些 item 保留的 chunk 总数;embedding数 == 整个 base 的不同 embedding-text hash 数;
- 非空
external_id:每条迁移后的记录必须有非空external_id; metadata.itemId一致性:每条记录必须有metadata.itemId,且与external_id保持一致;- embedding 覆盖检查:每条
search_text行都必须能解析到已存储的embedding(零 uncovered units)——这是迁移期对"rebuild self-heal 不变量"的验证形式:没有底层向量的 unit 会静默缺席于向量检索; - 快照文件存在性: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):
knowledge_base中不存在对应 base(prepare()遍历的是已迁移行,因此无行可标);- base 已被标记
failed或embeddingModelId = null(缺 embedding 模型/已有失败原因,不覆盖其自身错误); dimensions无效(非正整数);- legacy DB 文件缺失 / 路径实际是目录 / 不是 embedjs 格式(无
vectors表)/ 扫描中途不可读; - 迁移后的 base id 无法映射回 legacy knowledge base id;
- 重映射后的 legacy id 在 legacy Redux 状态中不存在。
源码中 base 级 skip 分类包括:invalid_dimensions、unmapped_base、legacy_base_missing、invalid_path、missing、directory、not_embedjs、read_error、missing_embedding_model、already_failed(KnowledgeVectorMigrator.ts)。
8.2 row 级跳过
以下情况的单条向量记录会被跳过(classifyVectorRow的分类逻辑见 KnowledgeVectorMigrator.ts):
| 原因 | 触发条件 |
|---|---|
unmapped_loader | uniqueLoaderId无法映射回已迁移的knowledge_item.id |
non_indexable_container | 向量映射到不可索引的容器类型(如directory),且仅在 fallback 路径触发 |
unsupported_vector_encoding | vector 载荷存在但暴露为不受支持的 runtime 编码 |
missing_vector_payload | 向量记录缺少vector或vector为空 |
dimension_mismatch | vector 长度与 base 记录的dimensions不一致(避免破坏整个 base 的暴力余弦扫描) |
这些跳过通常会记录 warning,而不是让整个迁移流程中断。为控制内存,跳过按原因聚合计数并只保留最多 3 个样本(SKIP_WARNING_SAMPLE_LIMIT = 3),绝不对每一条被拒行打一条日志。
8.3 补充说明:全部跳过后 base 的预期结果
- 如果某个 base 的 legacy 向量记录最终全部被跳过,则该 base 在 V2 中会被重建为空 vector store(只有 schema +
meta行); - 这不是"回滚保留旧 DB"的场景,而是预期的数据清洗结果;
- 原因是这些被跳过的记录无法稳定关联到当前 V2
knowledge_item,因此不再被视为有效业务向量数据。
注意区分:被跳过的 base 不产出 store(不是空 store),与"规划了但内容为空"是两回事。
9. 内存契约:如何在超大语料下不 OOM
迁移器的内存设计是经过真实 OOM 事故迭代出来的。历史沿革:
- 初版:把所有 base 的 materials 从 prepare 保留到 execute,峰值是全部 base 向量之和——28 个 base 的语料就耗尽了 V8 堆;
- 改进版:per-base 重读,但仍一次性加载整个 base——单个大 base(六位数 chunk × 高维度)就能单独耗尽堆,导致迁移崩溃循环(每次重启从头再来)。
当前实现的契约是:任何时刻最多驻留"一个 item 的文本 + 一个批次(≤500 行)的向量":
prepare()通过openBase().reader.iterateRows()流式扫描每个 base 的 legacy 行一次,只保留 per-item 的 rowid 列表、计数、按原因聚合的跳过统计(封顶样本)以及每个 url/note item 预保留的快照路径。PreparedBasePlan刻意不持有任何向量或 chunk 文本(KnowledgeVectorMigrator.ts);- 扫描每 1024 行(
STREAM_ROW_YIELD_INTERVAL)让出一次事件循环,避免六位数行的 base 冻结迁移 UI; execute()逐 item 重读:文本通过无向量列投影loadTextRowsByRowids整体读取(content schema 每个 material 一条文本行,拼接后的文本不可再分),向量通过loadRowsByRowids以固定 ≤500 行(VECTOR_STREAM_BATCH_SIZE,与读取器ROWID_BATCH_SIZE对齐)的点读批次流式拉取,由rebuildMaterial在写事务内惰性消费——每个 pull 恰好是一次索引化SELECT(KnowledgeVectorMigrator.ts);- 解码后的向量端到端保持
Float32Array(驻留大小为number[]的一半); - 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。
当前已确认的衔接点:
- runtime 通过
KnowledgeVectorStoreService按base.id获取 store; - 实际 store provider 是
BetterSqlite3VectorIndex; - runtime 检索和写入都基于 better-sqlite3 vector store;
- 迁移器与 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 的共同前提是:
- V2 业务真相来自
knowledge_base/knowledge_item; - 运行时向量文件与迁移后的向量文件都属于同一类 better-sqlite3-backed vector store 体系;
- 运行时关联业务 item 仍应以
knowledge_item.id为稳定标识,而不是继续依赖 V1 loader identity。
11. 当前边界、限制与对后续实现的影响
11.1 边界与定位
当前迁移器只负责"向量数据重建",不负责:
- 重新切块;
- 重新 embedding;
- 重新生成业务 item;
- 校正旧知识库的业务配置;
- 设计最终 retrieval service 的 API。
因此它的定位是:一次性的迁移工具,不等同于运行时知识库索引服务。
11.2 对后续实现的影响
基于当前迁移器行为,后续 V2 运行时设计需要遵守以下前提:
- V2 业务真相仍然来自
knowledge_base/knowledge_item; - 新向量记录必须能通过
external_id稳定关联到knowledge_item.id; - 运行时不应继续依赖 V1
embedjs的uniqueLoaderId; - 如果未来需要重建索引,应按 V2 业务表重新生成,而不是继续依赖旧迁移逻辑。
12. 相关文档的定位关系
V2 知识库迁移与运行时设计由三份文档共同定义,各有分工:
| 文档 | 定义内容 |
|---|---|
| knowledge-schema.md | V2 业务 schema(knowledge_base/knowledge_item的列、groupId语义、dimensions解析规则、item 状态迁移规则) |
| knowledge-backend-decisions.md | 当前KnowledgeRuntimeService、data services、queue 和 runtime/vector 边界 |
| knowledge-vector-migrator.md(本文主题) | 旧向量数据如何迁移进新体系 |
三者的关系可以简化为:
- schema 定义业务结构;
- backend decisions 文档定义当前运行时边界;
- vector migrator 文档定义旧向量数据如何迁移进新体系。
13. 小结
KnowledgeVectorMigrator是一个"克制"的迁移器:它不重新切块、不重新 embedding、不迁移业务主数据,只做一件事——把 V1embedjs向量库中能被 V2 业务表证明合法归属的 chunk 向量,转换为与 runtime 完全同构的 per-base better-sqlite3 index store。它的关键设计决策可以总结为:
- 归属优先于保留:无法映射到 V2
knowledge_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),仅供参考