Cherry Studio 知识库引擎架构解析:从多源摄取到混合检索与 Concept ID 工具面
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
导读
本文以 src/main/features/knowledge/README.md 为核心骨架,完整剖析 Cherry Studio 桌面端“按知识库(per-base)隔离”的知识库特性:它如何把文件、目录、URL 与笔记四类来源经摄取管线转换为 Markdown,再分块、嵌入并持久化到每个知识库独立的index.sqlite(better-sqlite3 + sqlite-vec),最终对外提供混合向量/BM25 检索与以 Concept ID 寻址的 Agent 工具(kb_search/kb_read/kb_tree/kb_manage)。读完本文,你将掌握该特性的四阶段流水线、五类任务 Job 的编排与恢复语义、条目状态机、重索引与恢复(Restore)的源码级差异,以及应用层互斥锁的并发模型,可直接对照仓库源码深入排查问题或二次开发。
一、特性总览:per-base 知识库的完整生命周期
知识库特性是一个以“知识库(base)”为单位的端到端系统。每个知识库拥有独立的:
- 来源层:用户添加的文件、目录、URL、笔记四类条目(item);
- 派生层:
raw/目录下的 Markdown 素材(文件原文副本、处理产物、URL/笔记快照); - 索引层:
.cherry/index.sqlite,由 better-sqlite3 驱动、sqlite-vec 支撑的同步本地索引,承载混合向量/BM25 检索。
对外暴露两类消费入口:
- 查询侧:混合检索(hybrid vector/BM25)与可见性过滤;
- Agent 工具侧:以 Concept ID 寻址的
kb_search/kb_read/kb_tree/kb_manage,其中kb_manage的删除/刷新写操作会委托回摄取编排层。
整条链路由 KnowledgeService.ts 作为生命周期门面(facade)统一注册任务处理器、执行启动恢复,并把每个公开方法委托给对应模块,自身不承载任何领域逻辑(源码第 37-51 行的注释明确标注了这一点)。
二、四阶段摄取流水线(Pipeline)
仓库 README 给出了摄入管线的整体蓝图,按阶段顺序展开为:
input preprocess index persist ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ ┌───────────────┐ pipeline/ │ sources/ │ ───> │ readers/ │ ──> │ indexing/ │ ──> │ vectorstore/ │ │ expand dirs, │ │ file → md text │ │ chunk, embed, │ │ index.sqlite │ │ url/note │ │ (pdf, docx, …) │ │ rerank │ │ (per base) │ │ snapshots │ └────────────────┘ └───────────────┘ └───────────────┘ └──────────────┘ heavy conversions (MinerU/PaddleOCR/…) run out-of-process via FileProcessingService, polled by a knowledge job四个阶段分别对应pipeline/下的四个子目录:
| 阶段 | 目录 | 职责 |
|---|---|---|
| 输入(input) | pipeline/sources/ | 目录展开、URL 抓取(Jina reader)、URL/笔记快照捕获、OKF frontmatter 写入 |
| 预处理(preprocess) | pipeline/readers/ | 文件 → Markdown/文本Document[](pdf/docx/epub/…) |
| 索引(index) | pipeline/indexing/ | 保偏移(offset-preserving)切分器 + 分块器、AiService嵌入/重排封装 |
| 持久化(persist) | pipeline/vectorstore/ | per-baseindex.sqlite生命周期、同步 better-sqlite3 驱动、向量删除与索引空间回收(vectorCleanup.ts) |
关键设计点:
- 重型转换进程外执行:MinerU、PaddleOCR 等文档处理是重量级操作,不阻塞主进程,而是经由
FileProcessingService在独立进程中运行,再由知识库侧的任务轮询其结果; - 编排与管线解耦:
pipeline/下没有任何代码会入队任务或修改条目状态——这类编排逻辑只存在于ingestion/与tasks/。这保证了管线各阶段是纯函数式的转换单元,便于单独测试(pipeline/各子目录均配有__tests__)。
三、目录地图:模块职责分层
README 的目录地图逐层定义了职责边界,结合源码可进一步印证:
| 目录/文件 | 角色 |
|---|---|
KnowledgeService.ts | 生命周期门面:注册任务处理器、执行启动恢复、委托全部公开方法、创建 per-base 共享变更锁(KeyedMutex)。无领域逻辑 |
base/ | 知识库领域:生命周期管理(KnowledgeBaseAdminService—— 带回滚的创建、删除、恢复)、失败库守卫(baseGuards.ts) |
ingestion/ | 写侧编排:准入检查、条目创建、添加冲突消解、任务入队、子树清理(subtreePurge.ts)、启动恢复 |
pipeline/sources/ | 输入阶段:目录展开、URL 抓取(Jina reader)、URL/笔记快照、OKF frontmatter |
pipeline/readers/ | 预处理阶段:文件 → Markdown/文本Document[]阅读器 |
pipeline/indexing/ | 索引阶段:保偏移切分器 + 分块器、AiService嵌入/重排封装 |
pipeline/vectorstore/ | 持久化阶段:per-baseindex.sqlite生命周期(KnowledgeVectorStoreService)、存储本身(indexStore/,同步 better-sqlite3 驱动)、向量删除与空间回收(vectorCleanup.ts) |
query/ | 读侧 + Concept ID 工具面:知识库发现与带可见性过滤的混合检索(KnowledgeQueryService);Concept ID 读/grep/树及kb_manage删除/刷新写操作(委托ingestion/,见KnowledgeConceptService) |
tasks/ | 任务处理器——管线执行器;prepareItem.ts是 prepare-root 处理器私有的辅助函数,负责把目录根展开为子条目 |
pathStorage.ts | raw/路径分配:无冲突命名、预留、base 文件路径 |
items.ts/types.ts | 共享条目词汇(类型别名、谓词、来源探测、素材路径推导);品牌化 id、队列名、幂等键 |
3.1 磁盘布局与路径安全(pathStorage.ts)
从 pathStorage.ts 可以看出,每个知识库目录下有两个关键位置(源码第 33-44 行):
{baseDir}/.cherry/:控制目录,存放派生的index.sqlite;{baseDir}/raw/:素材根目录,所有素材字节扁平存储于此,relativePath一律相对该根解析,即{baseDir}/raw/{relativePath}。
值得强调的是素材不再按导入动作类型分区——目录布局是内部实现细节,条目类型/来源一律从knowledge_item读取,绝不从路径推断。路径安全有三层防线:
KnowledgeRelativePathSchema做形状校验(不锚定根、无空字节、POSIX 合法分段);assertSafeKnowledgeRelativePath保留.cherry为保留前缀,任何相对路径的首段若是.cherry即被拒绝(源码第 371-382 行);assertResolvesBelow做宿主侧边界守卫,防止..\outside.pdf在 Windows 上逃逸出raw/(源码第 85-92 行)。
此外,reserveImportedFileRelativePath是唯一的去重入口:文件导入(上传 + v1→v2 迁移器复制)与 URL 快照捕获/恢复都经由它分配无冲突名称,冲突时自动追加_N数字后缀;若目标文件需要经处理器产生.md产物,则源文件与其“预期产物”路径会成对预留(源码第 140-161 行),避免后续处理器写出的paper.md与既有路径撞车。
四、任务系统:五类 Job 与恢复语义
所有任务都运行在 per-base 队列base.{baseId}上(types.ts中的knowledgeQueueName,源码第 92-94 行),并通过幂等键防止重复入队。
| Job | 做什么 | 由谁入队 |
|---|---|---|
knowledge.prepare-root | 把目录根展开为子条目,然后入队叶子索引 | ingestion(添加)、重索引处理器 |
knowledge.index-documents | 读 → 分块 → 嵌入 → 在同一个存储事务中rebuildMaterial | ingestion、prepare-root、fp-check |
knowledge.check-file-processing-result | 轮询 FileProcessingService 任务(每轮延迟 5s);成功则入队索引 | ingestion(需要转换的文件) |
knowledge.delete-subtree | 取消进行中的任务 → 删除向量 → 删除文件 → 删除行 | ingestion(删除)、启动恢复 |
knowledge.reindex-subtree | 校验来源 → 重新获取 → 删除向量 → 重置状态 → 重新入队索引 | ingestion(重索引) |
4.1 恢复策略:abandon vs retry
索引类任务与knowledge.reindex-subtree声明recovery: 'abandon'——应用重启绝不静默恢复它们。这是刻意的成本护栏:恢复会再次触发付费的嵌入 API 调用(indexDocumentsJobHandler.ts 第 62-65 行注释明确说明:“一次有意的退出不应重新花费嵌入 API”)。被中断的条目由启动恢复统一停放在failed状态。只有knowledge.delete-subtree使用recovery: 'retry',因为删除必须收尾。
作为补充,KnowledgeService.onAllReady会调用两类启动恢复(源码第 71-74 行):
recoverDeletingItems():扫描残留在deleting状态的根组,按每 500 个根为一组重新入队knowledge.delete-subtree(KnowledgeIngestionService第 460-497 行);recoverInterruptedItems():把因硬杀/崩溃而滞留于处理中状态的条目标记为failed,附带KNOWLEDGE_ITEM_ERROR_INDEXING_INTERRUPTED错误,清除“永久转圈”的 UI 状态并使其可手动重索引(第 449-458 行)。
4.2 index-documents 的运行时行为
从 indexDocumentsJobHandler.ts 可看到该 Job 的完整执行细节:
- 并发与重试:默认并发 5;重试策略最多 3 次、指数退避(初始 1s、上限 30s);超时 30 分钟(第 67-74 行);
- 进度阶段:通过
reportKnowledgeProgress上报reading→embedding→writing→done,UI 据此渲染条目状态;嵌入百分比写入共享缓存键knowledge.item.embedding_progress.${itemId},任务退出时附带 60s TTL 回收(第 46-51、398-409 行); - 快照兜底:URL/笔记在首次索引时若无快照(
relativePath),会先在锁外生成快照内容(URL 走网络抓取、笔记直接使用手头内容),再在锁内分配名称、写文件并持久化relativePath(ensureSnapshot,第 255-278 行); - 空文本拒绝:若分块结果为空(例如纯扫描/纯图片的 PDF 提取不出文本),不写入空素材,而是直接抛错让条目落为
failed并可重索引(第 99-109 行,错误信息为EMPTY_INDEXABLE_TEXT_ERROR); - 嵌入去重:按
hashEmbeddingText对分块正文去重,相同正文只嵌入一次;已有哈希的块复用已存向量,避免重索引时重复花费付费嵌入 API(第 306-323 行);嵌入按每批 10 个调用embedKnowledgeTexts,兼顾进度粒度与请求开销(第 35、333-343 行); - 原子收尾:素材重建(
store.rebuildMaterial)与条目状态翻转为completed在同一把 per-base 互斥锁内完成(writeItemMaterial,第 367-388 行),保证索引与状态的一致。
五、条目状态机与进度模型
README 明确了状态流转规则:
preparing(目录)/ processing → completed | failed 任意状态 → deleting → 行被删除 reading / embedding 是索引任务运行期间暴露的瞬时子阶段从types.ts的KnowledgeProgressDetail联合类型(源码第 37-68 行)可看到更细的进度原语:reading/embedding/writing/enqueuing/already-completed(带currentFile/totalFiles)、copying、scanning、deleting/done/item-gone(带可选的skippedMissingSource计数,记录重索引时因来源缺失或不可校验而跳过的根数量)、waiting(带pollRound与fileProcessingJobId,用于文件处理轮询阶段)、failed。
六、重索引(Reindex)与恢复(Restore):一问两答
README 强调了一条铁律:“重索引先重新获取来源,再重建”。没有按类型的例外——文件重新复制用户原始文件覆盖其raw/副本(若知识库配置了文档处理器则重新处理)、目录重新扫描原始文件夹、URL 重新抓取、笔记从其data.content重写快照。因此来源必须仍然存在。
这一差异在 items.ts 中体现为两个职责截然不同的探测函数:
classifyKnowledgeItemReacquireSource(第 88-96 行):回答“重索引要重新获取什么”。文件和目录都探测其原始磁盘路径(data.source)——绝不探测本库副本,因为副本正是要被覆盖的对象;笔记从data.content、URL 从网络重新获取。data.source连合法绝对路径都不是(v1 迁移的历史脏数据)时报missing而不是抛异常,避免变成不透明的重索引失败。重索引的准入门KnowledgeIngestionService.assertSubtreesCanReindex(KnowledgeIngestionService第 499-567 行)会据此在入队前就拒绝来源已消失的子树,并区分“真缺失”(提示删除后重新添加)与“无法校验”(瞬时/权限错误,建议重试);同时拒绝任何completed/failed之外的状态阻塞子树(提示“整个子树完成后才能重索引”)。classifyKnowledgeItemRestoreSource(第 65-76 行):回答“恢复要从本库拷出什么”。文件叶子直接复制本库的素材文件(indexedRelativePath ?? relativePath),目录用原始文件夹(data.source),笔记/URL 携带内容或快照。因为是从本库副本拷出,所以原文件即便被删除,恢复依然完好。
KnowledgeBaseAdminService.restoreBase(KnowledgeBaseAdminService.ts 第 111-187 行)进一步实现了部分恢复:逐个探测根条目的恢复来源,跳过missing的(v1 迁移的目录子项没有raw/文件、原文件已被删除等场景),避免单个缺失来源中止整批恢复;unverifiable的来源则保留(与重索引一致:绝不丢弃无法确认已消失的来源)。恢复结果会返回skippedMissingSourceCount,供上层感知被跳过的条目数。
此外,enableEmbeddingModel(KnowledgeIngestionService第 262-278 行)为从未配置过嵌入模型的 BM25-only 知识库原地补齐向量,但会先跑与重索引相同的准入检查——一个注定失败的回填(来源缺失、子树仍在运行)绝不允许先把模型提交上去却没有任何向量支撑,因为模型一旦提交就没有回滚点了。
七、并发模型:应用级 KeyedMutex
README 特别澄清了一个常见误解:per-base 变更锁是应用级互斥,不是 SQLite 本身的保护。
- 该锁是核心 KeyedMutex(通过
runExclusive获取),序列化跨主数据库、索引存储、文件系统三方的多步业务不变量(例如添加时的“先读冲突、再建行”序列); - per-base 驱动是同步的(better-sqlite3),单条语句天然原子,不需要锁;
- 处理器只在变更段落持锁,绝不跨越慢 I/O(网络抓取、文件读取、嵌入)持锁——这保证了长时间索引任务不会阻塞同库的其他写操作。
在 KnowledgeService.ts 第 47 行,private readonly knowledgeLockManager = new KeyedMutex()被注入KnowledgeIngestionService、KnowledgeBaseAdminService与各任务处理器,成为整个知识库写路径的串行化屏障。
八、读侧:混合检索的完整调用链
KnowledgeQueryService.search(源码第 51-92 行)揭示了检索的完整流程:
- 守卫:
assertBaseCanRunRuntimeOperation校验知识库可运行;查询经extractFtsTokens分词,无 token 直接报错(BM25 无命中可能); - 模式判定:
isCompletedVectorKnowledgeBase(base)为真走hybrid,否则bm25。这是每次调用实时计算的固定运行时策略,而非存储偏好——模式永远不会与知识库状态漂移(源码第 65-66 行); - 查询嵌入:仅 hybrid 模式才调用
embedKnowledgeQuery,BM25 纯词法检索跳过嵌入往返; - 候选放大:以
documentCount ?? 10作为 topK,按 5 倍超取(KNOWLEDGE_SEARCH_OVERFETCH_FACTOR),上限 200(KNOWLEDGE_SEARCH_CANDIDATE_CAP)。原因是索引存储只按素材状态过滤,条目级可见性过滤(缺失/他库/未完成)在调用方随后执行,超取保证最终集合不缩水到 topK 以下(第 35-37、70-82 行); - 可见性过滤 + 元数据重构:
loadVisibleItems一次性加载匹配素材对应的条目,丢弃缺失、属其他库、未completed的命中,并重建 chunk 元数据(条目类型/来源/chunk 索引/token 数),同时输出conceptId(deriveConceptId(item))与标题,供命中后衔接kb_read(第 170-206 行); - 重排与裁剪:有重排模型时对超取候选全集重排(
rerankKnowledgeSearchResults,无模型则透传),再裁到 topK,最后按base.threshold应用相关性阈值并打排名(第 89-91 行)。
九、向量存储:per-base 索引的打开与守护
KnowledgeVectorStoreService 负责 per-baseKnowledgeIndexStore实例的生命周期:
- 单例缓存:按 base id 缓存打开的存储实例,打开序列(驱动 → 版本感知 schema → meta)完全同步,单次 JS 事件循环内完成,天然保证同库并发打开的单飞(single-flight)不变量(第 37-59 行);
- 清理路径:
getIndexStoreIfExists只复用或打开磁盘上已存在的存储文件,避免清理操作“凭空创建”一个空索引(第 61-79 行); - 空索引哨兵:打开后若发现索引零素材而知识库仍有
completed条目,说明index.sqlite被删除/清空/替换过,直接记录 error 级日志,让“静默空结果”变得可诊断(reportInvisibleIndexContents,第 143-155 行); - 整体删除:
deleteStore关闭缓存实例并递归删除整个feature.knowledgebase.data/{baseId}目录——源文件、处理产物与index.sqlite一并清除(第 86-96 行)。
十、Concept ID:Agent 工具面的寻址原语
README 关联文档部分指出:Concept ID = 素材相对路径(material relative path),即raw/下的相对路径,是kb_read/kb_manage的寻址原语;它相对索引存储解析,并针对可见的knowledge_item重新校验。items.ts中的toMaterialRelativePath(第 39-47 行)定义了素材稳定相对路径的派生规则:文件用其存储路径(有处理产物时用indexedRelativePath),URL/笔记用其捕获快照路径(该路径真实存在于raw/下,缺失即视为不变量被破坏而非可回退情形)。这与数据层选型文档 docs/references/data/README.md 一脉相承。
十一、测试资产与深入路径
仓库为知识库特性配备了从单元到集成的完整测试矩阵,是深入理解各层行为的绝佳入口:
- 集成测试:KnowledgeService.integration.test.ts 覆盖门面级全流程;
- 索引存储:KnowledgeIndexStore.integration.test.ts、KnowledgeIndexStore.search.test.ts、KnowledgeIndexStore.rrf.test.ts(RRF 融合)验证混合检索;
- 任务处理器:indexDocumentsJobHandler.test.ts、reindexSubtreeJobHandler.test.ts、deleteSubtreeJobHandler.test.ts 等逐一定义各 Job 行为;
- 路径与并发:pathStorage.test.ts、pathStorage.win32.test.ts(Windows 反斜杠逃逸回归)、addConflicts.test.ts;
- 搜索与分块:search.test.ts、splitter.test.ts、tokenLimit.test.ts。
结语
从四阶段管线、五类 Job 的编排与恢复语义,到重索引/恢复的双探针设计、应用级互斥并发模型与混合检索调用链,Cherry Studio 的知识库特性呈现出一个清晰的“编排(ingestion/、tasks/)与管线(pipeline/)严格分离”的架构:管线是纯转换单元,编排负责准入、冲突消解与状态机推进,读侧与 Agent 工具面则通过 Concept ID 统一寻址。理解这些边界,无论是排查一次“索引后搜不到”、设计一次自定义重索引策略,还是评估在自有产品中复刻类似 per-base RAG 系统,都能直接受益。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考