跳转到内容

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 伪代码:加载单个 URL
function 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:向量检索返回结果过多但相关性低

现象:用户查询返回了大量结果,但都与查询意图关系不大。

规避方案

  1. 提高 Reranker 的最小分数阈值(minScore
  2. 增加预处理管道中的块过滤条件,去除低质量块
  3. 在 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,主要原因:

  1. 定制化 Loader:需要 NoteLoader 等 Cherry Studio 特有的加载器
  2. 预处理管道集成:将 PreprocessProvider 深度集成到 Loader 管线中
  3. 版本控制:避免上游 Breaking Change 影响知识库功能
  4. LibSQL 适配器:需要针对 LibSQL 优化的向量存储适配器

延迟删除 vs 立即删除

桌面应用中,误操作删除知识库是常见场景。延迟删除机制提供了撤销窗口,同时通过 knowledge_pending_delete.json 实现了状态持久化,即使应用重启也不会丢失待删除状态。

并发调度的复杂度取舍

KnowledgeService 实现了四层任务调度(LoaderTaskItemLoaderTaskOfSetQueueTaskItemEvaluateTaskWorkload),相比简单队列增加了复杂度:

  • 优势:可以根据系统负载动态调整并发度,避免资源耗尽;支持任务的批量管理和状态追踪
  • 代价:增加了代码复杂度和维护成本

这种取舍在 Cherry Studio 场景下是合理的:用户可能同时向多个知识库添加大量数据源(如导入整个文档站),简单的 FIFO 队列无法应对这种负载波动。

参考来源