RAG 知识库系统
学习目标
- 理解 Cherry Studio 知识库系统的整体架构与核心组件
- 掌握
KnowledgeService如何基于@cherrystudio/embedjs构建 RAG 管线 - 了解 LibSQL 向量数据库在本地知识库中的选型与使用方式
- 理解文档预处理(
PreprocessProvider)与重排序(Reranker)在 Cherry Studio 中的具体实现 - 掌握知识库的并发任务调度与延迟删除机制
前置知识
本章仅介绍 Cherry Studio 的具体实现方案。以下通用知识已在本课程其他章节覆盖:
- EmbedJS RAG 框架:
@cherrystudio/embedjs的核心 API、RAGApplicationBuilder使用方式、Loader 体系 - RAG 管线:RAG 管线的通用流程、分块策略、向量检索与上下文组装
阅读本章前,建议先完成上述通用知识章节的学习。
项目实践
2.1 知识库整体架构
Cherry Studio 的知识库系统围绕 KnowledgeService 构建,该服务位于 Electron 主进程(src/main/services/KnowledgeService.ts,25KB+),负责管理知识库的创建、数据加载、向量化检索与结果返回。
知识库条目配置通过 Redux knowledge slice 持久化在渲染进程状态中,实际的向量化数据则存储在磁盘的 getDataPath()/KnowledgeBase 目录下。
2.2 知识库创建与存储布局
// KnowledgeService 伪代码:创建知识库function createKnowledgeBase(config) { id = 生成 UUID dir = getDataPath() / "KnowledgeBase" / sanitizeFilename(id) libsqlPath = dir / "knowledge.db"
// 初始化 LibSQL 向量数据库 db = new LibSqlDb(libsqlPath)
// 初始化预处理管道 preprocessor = new PreprocessProvider()
// 初始化重排序器 reranker = new Reranker()
// 注册到内存映射表 knowledgeBases[id] = { config, db, preprocessor, reranker } return id}sanitizeFilename 确保知识库 ID 不会包含危险字符(如路径分隔符),避免目录穿越攻击。每个知识库在磁盘上对应一个独立目录,内含 LibSQL 数据库文件。
2.3 多源数据加载
KnowledgeService 通过 @cherrystudio/embedjs 的 Loader 体系支持四类数据源:
URL 数据源
// WebLoader 伪代码:加载单个 URLfunction addUrlSource(knowledgeBaseId, url, options) { loader = new WebLoader({ url: url, preprocess: 获取 PreprocessProvider(knowledgeBaseId), ...options }) // 提交到并发调度器 enqueueTask(knowledgeBaseId, loader)}站点地图数据源
// SitemapLoader 伪代码:递归加载站点地图function addSitemapSource(knowledgeBaseId, sitemapUrl, options) { loader = new SitemapLoader({ sitemapUrl: sitemapUrl, preprocess: 获取 PreprocessProvider(knowledgeBaseId), maxDepth: options.maxDepth, ...options }) enqueueTask(knowledgeBaseId, loader)}SitemapLoader 会自动解析 sitemap.xml 中列出的所有页面,并递归加载。这在文档站、博客等场景非常有用。
本地文件数据源
// addFileLoader 伪代码:加载本地文件function addFileSource(knowledgeBaseId, filePath) { loader = addFileLoader({ filePath: filePath, preprocess: 获取 PreprocessProvider(knowledgeBaseId) }) // addFileLoader 会根据文件扩展名自动选择合适的解析器 enqueueTask(knowledgeBaseId, loader)}addFileLoader 是 EmbedJS 提供的文件加载器工厂函数,根据文件扩展名(.pdf、.txt、.md 等)自动选择对应的解析策略。
笔记数据源
// NoteLoader 伪代码:加载 Cherry Studio 内部笔记function addNoteSource(knowledgeBaseId, noteIds) { loader = new NoteLoader({ noteIds: noteIds, preprocess: 获取 PreprocessProvider(knowledgeBaseId) }) enqueueTask(knowledgeBaseId, loader)}NoteLoader 是 Cherry Studio 特有的加载器,直接读取应用内部的笔记数据,无需经过文件系统。
2.4 文档预处理管道
PreprocessProvider 位于 src/main/knowledge/preprocess/ 目录下,在文档分块和向量化之前对原始文本进行处理。
预处理管道的具体实现位于 src/main/knowledge/preprocess/,其职责包括:
- 清理文档中的 HTML 标签和转义字符
- 将文档按语义边界分块(分块策略的通用知识参考 EmbedJS RAG 框架)
- 过滤长度不足或内容为空的无效块
- 为每个块附加元数据(来源 URL、文件名、时间戳等)
2.5 向量化与存储
Embeddings 模块位于 src/main/knowledge/embedjs/embeddings/Embeddings.ts,负责将文本块转换为向量:
// Embeddings 伪代码:向量化文本块function embedText(text) { // 根据用户配置的嵌入模型调用对应的 API // 支持 OpenAI、本地模型等多种后端 response = 调用嵌入模型 API(text) return response.embedding // 返回向量数组}向量存储在 LibSQL 数据库中。LibSQL 是 SQLite 的开源分支,支持向量相似性搜索操作。Cherry Studio 选择 LibSQL 的原因包括:
- 无需外部数据库服务,适合桌面应用的本地存储场景
- 单文件数据库,便于备份和迁移
- 支持向量索引,可进行近似最近邻搜索
// LibSqlDb 伪代码:存储向量function storeEmbed(chunk, embedding, metadata) { db.run(`INSERT INTO embeddings (content, embedding, metadata) VALUES (?, ?, ?)`, [chunk.content, 序列化(embedding), JSON(metadata)])}2.6 检索与重排序
检索时,用户查询先经过向量化,然后在 LibSQL 中进行相似性搜索,最后通过 Reranker 进行重排序:
// 检索伪代码:KnowledgeService 搜索方法function search(knowledgeBaseId, query, options) { // 1. 向量化查询 queryEmbedding = Embeddings.embedText(query)
// 2. 在 LibSQL 中进行向量相似性搜索 candidates = LibSqlDb.similaritySearch( queryEmbedding, topK: options.topK * 2 // 召回更多候选,供 Reranker 筛选 )
// 3. 重排序 reranker = 获取 Reranker(knowledgeBaseId) scored = reranker.rerank(query, candidates)
// 4. 过滤并返回 KnowledgeSearchResult[] results = scored .filter(item => item.score >= options.minScore) .slice(0, options.topK) .map(item => 构造 KnowledgeSearchResult(item))
return results}Reranker 位于 src/main/knowledge/reranker/ 目录下,其核心作用是:
- 对向量检索返回的候选结果进行精细的相关性打分
- 过滤掉相关性低的噪声结果
- 确保最终返回的上下文与用户查询高度相关
KnowledgeSearchResult 类型定义了搜索结果的结构,包含内容文本、相似度得分、来源元数据等字段。
2.7 并发任务调度
KnowledgeService 实现了多层级的并发任务调度机制,确保大量数据加载任务有序执行:
核心调度组件的职责:
-
LoaderTaskItem:表示单个加载器任务,具有三种状态:PENDING:等待执行PROCESSING:正在执行DONE:执行完成
-
LoaderTaskOfSet:管理一组LoaderTaskItem,支持批量提交、取消和状态查询 -
QueueTaskItem:将任务放入执行队列,等待调度器分发 -
EvaluateTaskWorkload:评估当前系统负载,动态调整并发度,避免资源耗尽
// 并发调度伪代码function enqueueTask(knowledgeBaseId, loader) { task = new LoaderTaskItem(loader, state: PENDING) taskSet = 获取 LoaderTaskOfSet(knowledgeBaseId) taskSet.add(task)
// 触发调度循环 scheduleNext(knowledgeBaseId)}
function scheduleNext(knowledgeBaseId) { workload = EvaluateTaskWorkload.评估(knowledgeBaseId) if workload.canAcceptMore { pendingTasks = 获取 PENDING 状态的任务 for task in pendingTasks.slice(0, workload.availableSlots) { task.state = PROCESSING 异步执行(task) .then(() => task.state = DONE) } }}2.8 延迟删除机制
知识库支持延迟删除功能,使用 knowledge_pending_delete.json 记录待删除的数据:
// 延迟删除伪代码function markForDelete(knowledgeBaseId) { pendingDeletes = 读取 knowledge_pending_delete.json pendingDeletes.push({ knowledgeBaseId, timestamp: 当前时间, status: "PENDING" }) 写入 knowledge_pending_delete.json}
function processPendingDeletes() { pendingDeletes = 读取 knowledge_pending_delete.json for item in pendingDeletes { if item.status == "PENDING" { try { 删除知识库(item.knowledgeBaseId) item.status = "DONE" } catch (error) { item.status = "FAILED" item.error = error.message } } } 写入 knowledge_pending_delete.json}延迟删除机制的好处:
- 避免误删后立即丢失数据(支持撤销)
- 批量处理多个删除操作,提高效率
- 失败时可以记录错误并重试
问题与规避
问题 1:大型站点地图加载导致内存溢出
现象:当使用 SitemapLoader 加载包含数百个页面的站点地图时,可能出现内存峰值过高的问题。
根因:所有页面在加载过程中同时进入预处理管道,向量化操作占用了大量内存。
规避方案:
// 限制 SitemapLoader 的并发度function addSitemapSafely(knowledgeBaseId, sitemapUrl) { loader = new SitemapLoader({ sitemapUrl: sitemapUrl, maxConcurrent: 3, // 限制并发页面数 delay: 500, // 页面间延迟(毫秒) preprocess: 获取 PreprocessProvider(knowledgeBaseId) }) enqueueTask(knowledgeBaseId, loader)}同时可通过 EvaluateTaskWorkload 监控内存使用,当超过阈值时自动降低并发度。
问题 2:文件名包含特殊字符导致路径错误
现象:用户上传包含 /、\、: 等字符的文件名时,知识库目录结构被破坏。
规避方案:Cherry Studio 使用 sanitizeFilename 对所有用户输入的文件名进行清理:
// sanitizeFilename 伪代码function sanitizeFilename(filename) { return filename .replace(/[\/\\:*?"<>|]/g, '_') // 替换危险字符 .replace(/\.\./g, '_') // 防止路径穿越 .trim()}所有涉及文件系统路径的地方都应使用此函数处理用户输入。
问题 3:向量检索返回结果过多但相关性低
现象:用户查询返回了大量结果,但都与查询意图关系不大。
规避方案:
- 提高
Reranker的最小分数阈值(minScore) - 增加预处理管道中的块过滤条件,去除低质量块
- 在 UI 层提供过滤选项,让用户按来源、时间等维度筛选
// 提高检索精度function searchWithPrecision(knowledgeBaseId, query) { return search(knowledgeBaseId, query, { topK: 5, // 减少返回数量 minScore: 0.7, // 提高最低分数阈值 useReranker: true // 强制启用重排序 })}设计取舍
为什么选择 LibSQL 而非外部向量数据库
Cherry Studio 是桌面应用,选择 LibSQL 的决策基于以下考量:
对于桌面应用而言,单机的向量存储规模通常可控(用户知识库一般不超过百万级向量),LibSQL 的性能完全够用。外部向量数据库的网络依赖和运维成本在桌面场景下得不偿失。
为什么实现自研 fork @cherrystudio/embedjs
Cherry Studio 使用自研的 @cherrystudio/embedjs 0.1.31 而非上游 embedjs,主要原因:
- 定制化 Loader:需要
NoteLoader等 Cherry Studio 特有的加载器 - 预处理管道集成:将
PreprocessProvider深度集成到 Loader 管线中 - 版本控制:避免上游 Breaking Change 影响知识库功能
- LibSQL 适配器:需要针对 LibSQL 优化的向量存储适配器
延迟删除 vs 立即删除
桌面应用中,误操作删除知识库是常见场景。延迟删除机制提供了撤销窗口,同时通过 knowledge_pending_delete.json 实现了状态持久化,即使应用重启也不会丢失待删除状态。
并发调度的复杂度取舍
KnowledgeService 实现了四层任务调度(LoaderTaskItem → LoaderTaskOfSet → QueueTaskItem → EvaluateTaskWorkload),相比简单队列增加了复杂度:
- 优势:可以根据系统负载动态调整并发度,避免资源耗尽;支持任务的批量管理和状态追踪
- 代价:增加了代码复杂度和维护成本
这种取舍在 Cherry Studio 场景下是合理的:用户可能同时向多个知识库添加大量数据源(如导入整个文档站),简单的 FIFO 队列无法应对这种负载波动。
参考来源
- KnowledgeService 源码
- Cherry Studio 技术文档: KnowledgeService
- EmbedJS RAG 框架
- RAG 管线通用知识
- 预处理管道:
src/main/knowledge/preprocess/ - 重排序模块:
src/main/knowledge/reranker/ - Embeddings 模块:
src/main/knowledge/embedjs/embeddings/Embeddings.ts