Leanote + RAG 做一个个人知识库
本文介绍如何为 Leanote 增加 RAG 数据出口,使用本地模型完成文本和图片索引,再通过向量召回、重排和 Agent 查询个人知识库。
写在前面
继上一次给 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 的效果不只取决于最后的模型。前面的正文格式、分片大小、图片处理和索引字段,都会影响后面的召回质量。
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>
后续方便前文提到的安卓 App 自动登陆,扩展了 token 的用途。
2.2 统一正文格式
Leanote 里的笔记格式有两种,富文本 和 Markdown 笔记。如果索引侧同时处理多套格式,后面的分片规则会越来越复杂。
所以 RAG API 的单篇笔记接口统一返回 Markdown:
- Markdown 笔记直接使用原来的 Markdown 内容。
- 富文本先转换为 Markdown。
- 返回
ContentFormat: markdown。 - 通过
SourceFormat标记原始内容是 Markdown 还是 HTML。
图片也在这一步统一处理。正文中的图片地址会改写为 RAG 接口对应的地址,这样索引侧只需要识别一种图片地址,Agent 后面也可以从统一接口获取原图。
2.3 笔记和图片获取接口
当前 RAG API 主要提供四个接口,分别负责不同的数据获取任务。
- 笔记列表接口:用于全量索引,支持按页获取笔记,也可以按笔记本、标签和更新时间过滤。只返回没有放入废纸篓、没有删除,并且允许对 RAG 暴露的笔记。
- 更新笔记列表接口:用于增量索引,根据更新时间获取发生变化的笔记,并支持由更新时间和笔记 ID 组成的 cursor。
- 单篇笔记接口:根据笔记 ID 获取完整的 Markdown 正文和元数据。笔记 ID 必须是合法格式,并且笔记不能在废纸篓中、不能被删除,也不能属于设置为不对 RAG 暴露的笔记本。
- 图片获取接口:根据图片 ID 获取原图,供 OCR、Vision 和 OpenClaw 使用。接口会检查当前用户是否有权限访问文件,以及图片所属笔记本是否允许对 RAG 暴露。
这里还给笔记本增加了“不对 RAG API 暴露”的设置,这主要是考虑使用在线 Agent 的数据隐私。
返回数据示例:
至此,Leanote 侧的准备工作都已结束,后续则是索引工具的工作。
3. 分片和索引
3.1 文本索引
索引的主程序基本流程如下:
- 从 RAG API 的笔记列表接口分页拉取笔记元数据。
- 调用单篇笔记接口获取 Markdown 正文。
- 切分正文,默认每个 chunk 大约 900 个字符,重叠 120 个字符;分隔符会优先考虑 Markdown 标题、段落、换行和句号,尽量不要在一个完整段落中间截断。
- 使用本地
llama-cpp-python加载 embedding 模型生成向量。 - 把向量和 payload 写入 Qdrant。
索引工具还做了一些额外支持:
- 支持全量构建和增量构建,全量构建时重建 collection,日常则使用增量模式:先调用更新笔记列表接口找到变化的笔记,再按
note_id删除旧的 points,写入新的 points。 - 索引过程会把状态写入
.rag-state.json,全量和增量都有独立的断点信息。中途因为模型、网络或机器问题中断后,可以继续处理,不需要每次从第一篇重新开始。
一个正文 chunk 写入 Qdrant 时,payload 里除了向量对应的原文,还会带上:
- 笔记标题、笔记本和标签。
- 笔记 ID、更新时间和 chunk 下标。
- 原始格式和统一后的内容格式。
- 关联图片,以及图片的 OCR 和 Vision 状态。
source_type,用来区分正文、OCR 和图像语义。
检索结果因此可以回到原笔记,而不是只剩下一段没有出处的文本。
索引过程示例:
3.2 图片索引
图片不能只保留 URL。很多笔记里的信息在截图、扫描件和流程图中,如果正文只保存一行 Markdown 图片语法,向量检索基本找不到这些内容。
对于图片的处理,我同时从 OCR 和图像语义识别两个方向入手。
- OCR 主要针对有大量文字的图片,比如扫描件,或是大段文字的截图。
- 图像语义识别则主要针对流程图和界面截图,这一类图片往往不能只获取其中的文本信息,还要理解图片的含义。
图像处理的基本流程如下:
- 从 Leanote 获取图片,并记录图片在正文中的位置、说明文字和
file_id。 - 对图片进行 OCR,主要提取扫描件或大段文字截图中的文字,同时记录识别结果和置信度。低质量、空结果、获取失败和处理错误不会默认并入向量化文本,避免错误 OCR 反过来污染召回结果。
- 对流程图、界面截图等图片进行可选的图像语义识别。这里使用
llama-cpp-python加本地图像识别模型。- 提示词会要求模型描述图片类型、主要对象、区域、流程关系和用途;
- 流程图重点描述节点、箭头方向、分支和阶段推进,截图则重点描述页面用途和主要模块。
- 将 OCR 和 Vision 的结果写入 sidecar 文件,例如
.rag-image-enrichment.json。OCR 用来提供图片中的文字,Vision 用来提供结构、对象和语义,不能把 Vision 的描述当成图片原文。 - 索引脚本读取 sidecar,将图片结果合并到对应的正文 chunk,同时为有效的 OCR 和 Vision 结果分别建立
source_type=ocr和source_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 结果整理和降级
重排完成后,查询脚本还会按下面的顺序整理结果:
- 先按重排后的顺序截取指定数量的结果;没有配置 reranker 时,则直接使用向量召回的排序。
- 过滤相关度过低的片段,并限制同一篇笔记最多保留的命中数量,避免一篇长笔记占满结果。
- 把每条结果整理成统一格式,保留标题、笔记本、标签、正文摘要和来源类型。其中
note表示正文,ocr表示图片文字,vision表示图片语义。 - 将结果逐条组成上下文,并限制单条摘要和上下文总长度,避免一次给 Agent 输入太多内容。
- 收集所有命中结果中的关联图片并下载到本地,作为附件交给 Agent。这样回答图片问题时,可以结合原图、OCR 和 Vision 结果,而不是只根据图片地址猜测。
查询脚本最终主要返回三部分内容:一是整理后的命中结果和来源信息,方便 Agent 判断内容来自哪篇笔记;二是已经拼接好的上下文,作为生成回答的主要依据;三是下载成功的图片附件,供 Agent 直接查看。
如果完全没有召回结果,脚本会标记为 no_hit;有候选但相关度不够时,会标记为 low_relevance。如果同一来源同时出现正文、OCR 或 Vision 等不同类型的证据,则标记为 source_conflict,让 Agent 优先参考得分较高的内容,同时保留其他来源作为补充。这样即使检索结果不理想,也不会让 Agent 在没有依据的情况下直接生成答案。
5. 生成效果
最后,Agent 通过查询脚本返回的结果,以及SKILL.md中对结果各部分的说明,把整理好的上下文交给本地模型生成最终回答。
生成效果示例:
最终的生成效果还取决于所选择的模型。
6. 小结
对于 Leanote 的升级改造目前也告一段落了。
后续可能还有小修小补,但是大的需求应该是没有了。
基于这 1000 来篇笔记,后面看看能不能把之前的个人经验做一些输出。










