文章

Leanote + RAG 做一个个人知识库

本文介绍如何为 Leanote 增加 RAG 数据出口,使用本地模型完成文本和图片索引,再通过向量召回、重排和 Agent 查询个人知识库。

Leanote + RAG 做一个个人知识库

写在前面

继上一次给 Leanote 做了些优化之后,用了一段时间,软件的功能已经没有特别想优化的地方了。

然而这些年积累下来,笔记已经有 1000 多篇。需要找内容时,主要还是靠 Leanote 的搜索,或者凭记忆翻笔记本。笔记数量少的时候还可以,积累多了以后,很多内容虽然写过,但实际用单纯的搜索机制很难再被找到和利用。

所以这次想做的事情很明确:把 Leanote 里已经积累的文章,转化成一个可以用 AI Agent 直接提问的个人知识库。

0. 前言

这篇文章先简单讲一下 RAG 的基本流程,再介绍如何把 Leanote 接进这条链路,以及在这个过程中遇到的一些问题和解决方案。

和上一篇文章一样,这些代码基本都是我用 AI 辅助开发出来的,很多地方都带着我自己的使用习惯,未必适合其他人;另外因为主要是自己用,不少地方也做了 hardcode,所以这部分就不打算开源了。这里主要介绍目的、思路和最终效果。

1. RAG 的基本流程

RAG 全称是 Retrieval-Augmented Generation,简单说就是:回答问题之前,先从自己的资料里找相关内容,再把找到的内容交给模型生成答案。

虽然现在的 AI 模型已经支持超长上下文了,但是不可能每次提问都把 1000 多篇文章一次性塞进模型,这样不仅会消耗大量算力和内存,也会让模型的注意力分散,导致回答质量下降。

RAG 的作用是提前把文章处理成许多较小的片段,建立索引。用户提问时,先把问题转换成向量,再从索引里召回相关片段,必要时再重排,最后把有限的上下文交给模型。

1.1 分片

如果一篇文章太长,里面就可能包含多个相关性不高的内容,如果直接转换成向量,向量就会把它们”揉在一起”,变成一个模糊的内容,这样在用向量检索时就会变得不准确。

所以长文章需要先进行分片,也就是把一篇文章拆成多个较小的片段(chunk),每个片段单独生成向量,写入向量数据库。每个片段前后会保持一些重叠的部分 (overlap),这样能减少相关性高的内容被截断的可能性。

1.2 索引

提问时,将问题也转成向量,在同一个向量空间里做相似度搜索,这样可以定位到具体哪一段内容相关。

和单纯的搜索进行对比,搜索是基于关键词的,包含关键词的文章会全部列出来。

  • 有些目标文章里没有和关键词完全相同的词,这时候就很难找到这篇文章。
  • 有些文章里有和关键词相同的词,但语义完全不相关,这时候搜索也会返回很多无关内容。 这其实就是在搜索时必须要精确知道要搜什么词,而且文章多的时候,还要知道具体哪个文章才是要找的,这也是我前面说的单纯的搜索机制很难找到文章了。

而向量检索是基于语义的。即使问题和文章里没有完全相同的词,向量检索也能找到语义相关的内容。

要注意提问一定要用索引时相同的 embedding 模型和参数。因为不同模型的向量空间不一样,如果用不同的模型生成向量,就无法在同一个向量空间里比较相似度。

1.3 召回和重排

召回,顾名思义,就是先从索引里找出一批候选片段。

现在的向量数据库在搜索的时候会用一些算法来计算向量相似度,比如 HNSW、IVF、PQ 等。它们的特点是可以在海量向量里快速找到相似的向量,但不保证召回的结果是最优的。具体这些算法是什么,这里不展开介绍了,有兴趣的朋友随便找个 AI 问问都能解释的很清楚。

为了保证结果的精度,就需要在召回的候选中进行重排。

重排会使用一个专门的模型对候选重新做一次排序,把和问题真正相关的片段排到前面。这个模型通常是一个 Cross Encoder,它会把问题和候选片段一起输入,计算它们的相关性分数,然后按分数排序。

Cross Encoder 的特点是可以更精确地计算相关性,但它的计算量比向量检索大很多,所以通常只对召回的少量候选做重排。

1.4 整体流程综述

召回和重排之后,通常还会有一层过滤和整理:去掉相关度太低的结果,避免某一篇文章占满整个上下文,并区分不同来源的内容,方便后面组织回答,这通常由一些既定脚本完成。最后把整理好的少量片段拼成上下文,交给模型生成最终回答;如果没有找到足够相关的内容,也应该直接说明,而不是让模型硬编答案。

整个过程可以简单画成下面这样(图由AI生成):

RAG 流程图

这里要注意,RAG 的效果不只取决于最后的模型。前面的正文格式、分片大小、图片处理和索引字段,都会影响后面的召回质量。

2. 准备 RAG 的数据出口

Leanote 原生的数据格式和 API 并不适合做 RAG 索引,这一节主要是为 Leanote 增加到 RAG 的数据出口。

2.1 两步认证和 Token 认证

RAG API 会读取个人笔记内容,不能直接做成一个没有保护的接口。

Leanote 原生只支持一个登陆密码,鉴于安全性考虑,Web 登录这边增加了 TOTP 两步认证。

登录密码正确以后,如果账号启用了两步认证,还需要输入动态验证码;也支持把当前设备设置为信任设备,在一段时间内不重复输入验证码。

两步认证

而 API 访问则使用单独的 Bearer Token。Token 的主要处理方式是:

  • 支持创建多个 Token。
  • 数据库只保存 Token hash,不保存完整 Token。
  • 支持启用、禁用和删除。
  • 创建成功时只展示一次原始 Token。
  • 记录最近使用时间和最近使用 IP。
  • 关键的认证动作写入审计记录。

索引脚本和 Agent 都使用 Token。请求时放在 HTTP Header 中:

Authorization: Bearer <LEANOTE_RAG_TOKEN>

Token 认证界面

后续方便前文提到的安卓 App 自动登陆,扩展了 token 的用途。

2.2 统一正文格式

Leanote 里的笔记格式有两种,富文本 和 Markdown 笔记。如果索引侧同时处理多套格式,后面的分片规则会越来越复杂。

所以 RAG API 的单篇笔记接口统一返回 Markdown:

  • Markdown 笔记直接使用原来的 Markdown 内容。
  • 富文本先转换为 Markdown。
  • 返回 ContentFormat: markdown
  • 通过 SourceFormat 标记原始内容是 Markdown 还是 HTML。

图片也在这一步统一处理。正文中的图片地址会改写为 RAG 接口对应的地址,这样索引侧只需要识别一种图片地址,Agent 后面也可以从统一接口获取原图。

2.3 笔记和图片获取接口

当前 RAG API 主要提供四个接口,分别负责不同的数据获取任务。

  1. 笔记列表接口:用于全量索引,支持按页获取笔记,也可以按笔记本、标签和更新时间过滤。只返回没有放入废纸篓、没有删除,并且允许对 RAG 暴露的笔记。
  2. 更新笔记列表接口:用于增量索引,根据更新时间获取发生变化的笔记,并支持由更新时间和笔记 ID 组成的 cursor。
  3. 单篇笔记接口:根据笔记 ID 获取完整的 Markdown 正文和元数据。笔记 ID 必须是合法格式,并且笔记不能在废纸篓中、不能被删除,也不能属于设置为不对 RAG 暴露的笔记本。
  4. 图片获取接口:根据图片 ID 获取原图,供 OCR、Vision 和 OpenClaw 使用。接口会检查当前用户是否有权限访问文件,以及图片所属笔记本是否允许对 RAG 暴露。

这里还给笔记本增加了“不对 RAG API 暴露”的设置,这主要是考虑使用在线 Agent 的数据隐私。

返回数据示例:

返回数据示例

至此,Leanote 侧的准备工作都已结束,后续则是索引工具的工作。

3. 分片和索引

3.1 文本索引

索引的主程序基本流程如下:

  1. 从 RAG API 的笔记列表接口分页拉取笔记元数据。
  2. 调用单篇笔记接口获取 Markdown 正文。
  3. 切分正文,默认每个 chunk 大约 900 个字符,重叠 120 个字符;分隔符会优先考虑 Markdown 标题、段落、换行和句号,尽量不要在一个完整段落中间截断。
  4. 使用本地 llama-cpp-python 加载 embedding 模型生成向量。
  5. 把向量和 payload 写入 Qdrant。

索引工具还做了一些额外支持:

  1. 支持全量构建和增量构建,全量构建时重建 collection,日常则使用增量模式:先调用更新笔记列表接口找到变化的笔记,再按 note_id 删除旧的 points,写入新的 points。
  2. 索引过程会把状态写入 .rag-state.json,全量和增量都有独立的断点信息。中途因为模型、网络或机器问题中断后,可以继续处理,不需要每次从第一篇重新开始。

一个正文 chunk 写入 Qdrant 时,payload 里除了向量对应的原文,还会带上:

  • 笔记标题、笔记本和标签。
  • 笔记 ID、更新时间和 chunk 下标。
  • 原始格式和统一后的内容格式。
  • 关联图片,以及图片的 OCR 和 Vision 状态。
  • source_type,用来区分正文、OCR 和图像语义。

检索结果因此可以回到原笔记,而不是只剩下一段没有出处的文本。

索引过程示例:

索引过程示例

3.2 图片索引

图片不能只保留 URL。很多笔记里的信息在截图、扫描件和流程图中,如果正文只保存一行 Markdown 图片语法,向量检索基本找不到这些内容。

对于图片的处理,我同时从 OCR 和图像语义识别两个方向入手。

  • OCR 主要针对有大量文字的图片,比如扫描件,或是大段文字的截图。
  • 图像语义识别则主要针对流程图和界面截图,这一类图片往往不能只获取其中的文本信息,还要理解图片的含义。

图像处理的基本流程如下:

  1. 从 Leanote 获取图片,并记录图片在正文中的位置、说明文字和 file_id
  2. 对图片进行 OCR,主要提取扫描件或大段文字截图中的文字,同时记录识别结果和置信度。低质量、空结果、获取失败和处理错误不会默认并入向量化文本,避免错误 OCR 反过来污染召回结果。
  3. 对流程图、界面截图等图片进行可选的图像语义识别。这里使用 llama-cpp-python 加本地图像识别模型。
    • 提示词会要求模型描述图片类型、主要对象、区域、流程关系和用途;
    • 流程图重点描述节点、箭头方向、分支和阶段推进,截图则重点描述页面用途和主要模块。
  4. 将 OCR 和 Vision 的结果写入 sidecar 文件,例如 .rag-image-enrichment.json。OCR 用来提供图片中的文字,Vision 用来提供结构、对象和语义,不能把 Vision 的描述当成图片原文。
  5. 索引脚本读取 sidecar,将图片结果合并到对应的正文 chunk,同时为有效的 OCR 和 Vision 结果分别建立 source_type=ocrsource_type=vision 的独立检索点。这样命中图片内容以后,仍然可以定位到原笔记,并找到对应的原图。

扫描件提取示例:

扫描件提取示例

对于扫描件来说,OCR已经足够精准。

流程图提取示例:

流程图提取示例

从示例看出流程图的识别无论是 OCR 还是本地模型 Vision 都不够精准,文本框中的内容窜行,也没有识别出各文本框之间的关系。

好在这里的目标不是让模型完全理解图片,而是尽量把图片里的信息转化成可检索的文本,方便后续回答时结合图片和文本。

这也提示我写笔记的时候只放一张图不行,还要至少加一点图片的描述来方便搜索。

至此,索引阶段已经结束,全量构建一次后,只需定期做增量构建即可。后续则是 Agent 的工作。

4. 召回和重排

4.1 向量召回

Agent 使用本地 skill 查询 Qdrant,不需要再启动一个额外的 HTTP 检索服务。

Skill 中包含

  • 环境信息,如 Qdrant 地址、collection 名称、embedding 模型路径等。
  • 查询脚本,用于把用户问题转成向量,查询 Qdrant,并返回候选结果。
  • SKILL.md,用于描述 skill 的功能和使用方式。
  • 安装指引以及安装脚本,用于初始化 Python 依赖环境。

用户提问后,查询脚本使用和建索引时相同的 embedding GGUF 模型生成 query 向量,然后从指定 collection 查询候选结果。

这里最重要的约束是:查询模型必须和建索引模型一致。如果更换模型或向量维度,需要重新创建 collection 并全量索引。

召回结果示例:

召回结果示例

从召回结果来看,第一条就是个标题,第四五条是具体的符文之语的图片OCR,虽然也能用做大模型的输入,但是不够精确。

4.2 Cross Encoder 重排

召回是初筛,向量检索从大量内容中快速找候选。重排是精筛,使用 Cross Encoder 模型对候选重新计算相关性分数,把真正相关的片段排到前面。

通过在Skill中额外配置 Cross Encoder 本地模型。helper 使用 llama.cpp 的 rank pooling,把问题和候选 chunk 一起输入,重新计算相关性分数,再按这个分数排序。

这一步是可选的:没有 reranker 模型时,系统直接使用 Qdrant 的向量分数;有模型时,查询会慢一些,但对候选结果的排序更细。

重排结果示例:

重排结果示例

从重排的结果来看,这两条正是我想要的。

4.3 结果整理和降级

重排完成后,查询脚本还会按下面的顺序整理结果:

  1. 先按重排后的顺序截取指定数量的结果;没有配置 reranker 时,则直接使用向量召回的排序。
  2. 过滤相关度过低的片段,并限制同一篇笔记最多保留的命中数量,避免一篇长笔记占满结果。
  3. 把每条结果整理成统一格式,保留标题、笔记本、标签、正文摘要和来源类型。其中 note 表示正文,ocr 表示图片文字,vision 表示图片语义。
  4. 将结果逐条组成上下文,并限制单条摘要和上下文总长度,避免一次给 Agent 输入太多内容。
  5. 收集所有命中结果中的关联图片并下载到本地,作为附件交给 Agent。这样回答图片问题时,可以结合原图、OCR 和 Vision 结果,而不是只根据图片地址猜测。

查询脚本最终主要返回三部分内容:一是整理后的命中结果和来源信息,方便 Agent 判断内容来自哪篇笔记;二是已经拼接好的上下文,作为生成回答的主要依据;三是下载成功的图片附件,供 Agent 直接查看。

如果完全没有召回结果,脚本会标记为 no_hit;有候选但相关度不够时,会标记为 low_relevance。如果同一来源同时出现正文、OCR 或 Vision 等不同类型的证据,则标记为 source_conflict,让 Agent 优先参考得分较高的内容,同时保留其他来源作为补充。这样即使检索结果不理想,也不会让 Agent 在没有依据的情况下直接生成答案。

5. 生成效果

最后,Agent 通过查询脚本返回的结果,以及SKILL.md中对结果各部分的说明,把整理好的上下文交给本地模型生成最终回答。

生成效果示例:

生成效果示例 - Agent + Gemini

生成效果示例 - OpenClaw + Qwen

最终的生成效果还取决于所选择的模型。

6. 小结

对于 Leanote 的升级改造目前也告一段落了。

后续可能还有小修小补,但是大的需求应该是没有了。

基于这 1000 来篇笔记,后面看看能不能把之前的个人经验做一些输出。

本文由作者按照 CC BY 4.0 进行授权