我在自托管的 Leanote 里存了五年笔记,Markdown 为主,按笔记本和标签组织,数据全部落在自己的服务器上。笔记量一上来,最痛的其实不是没地方放,而是要用的时候想不起来它在哪:全文搜索只能做关键词匹配,一旦标题和正文里没有那个词,它就永远躺在那儿不出现。前阵子我找一份早先整理的“分布式事务补偿方案”,明明写过,试了“事务”“补偿”“最终一致性”三个词都一无所获,最后是靠着记忆里的笔记本目录才翻出来。这件事让我下定决心,给 Leanote 接一套 RAG 检索增强生成链路,做成了自己的知识库问答系统:AI 能基于我自己的笔记做语义检索和自动回答。如果你也在用一个能导出 Markdown 的笔记工具,而且攒了一定量的笔记资源,这篇文章的思路和代码可以直接搬过去。
1. 为什么偏偏选 Leanote:对比全文搜索、Notion AI 和 Obsidian Copilot
1.1 全文搜索的天花板:关键词命中不等于语义命中
传统全文搜索的原理是分词之后建立倒排索引,你搜索“补偿”,它就去倒排表里查“补偿”这个词命中了哪些文档。这个机制在技术笔记场景里非常脆弱:因为我笔记里写的是“事务回滚”,追的是“最终一致性”,你搜“补偿”是搜不到的;反过来,你搜“分布式事务”,笔记标题里只有“TCC 实现”,结果也是零命中的。语义相同、词面不同,全文搜索完全没有办法。
更要命的是,代码笔记里全是驼峰命名、下划线、特殊符号,比如processTransaction、retry_count,分词器切出来的 token 和你脑子里想的词往往对不上。这类场景下,关键词搜索的召回率低得让人绝望。
所以知识库的第一步不是“更快的搜索”,而是“能理解意思的搜索”。这正是 RAG 里向量检索的用武之地:把问题和笔记块都转成向量,算余弦相似度,意思相近就召回,哪怕一个字都不重合。
1.2 为什么不是 Notion AI 或 Obsidian Copilot
我也认真比较过市面上的方案。Notion AI 的问题很明显:第一,它要求把内容托管在 Notion 的云上,对自托管用户来说,数据主权直接没了,我的笔记里有很多内部方案、账号信息,不想经过第三方服务器;第二,Notion AI 的答案风格偏英文生态,对中文技术笔记的支持并不好;第三,它更像一个“整体工作区问答”,不给你检索中间环节的控制权,检索质量一旦不理想,你没有任何调优抓手。
Obsidian 生态里的 Copilot 插件其实已经是一套 RAG 了,但它的核心是把 Obsidian 的全局知识库做了向量索引,本质上绑定 Obsidian 的 vault 格式和插件机制。对于我这种已经深度使用 Leanote、不想迁移笔记软件的人来说,迁移成本太高,而且它把所有逻辑封装在插件里,出了问题很难拆开修。
1.3 Leanote 作为知识源到底好在哪
我选 Leanote 当 RAG 的数据源,不是因为它的编辑器多好用,而是因为它的数据形态对 RAG 太友好了:
- 数据完全自持:笔记存 MongoDB,附件存文件系统,随时可以全量导出,不存在被某个云服务绑架的问题。
- 原生 Markdown:每篇笔记本质上就是一个 Markdown 文件,有标题层级、列表、代码块、frontmatter,这些结构信息在分块阶段是金矿,能用来做“标题感知切分”,而不是盲切。
- 有开放 API 和备份包两种取数方式:我不用折腾爬虫,直接拿数据就能喂给下游。
- 部署轻量:Docker 起一个就完了,后端资源占用低,跑个 RAG 应用完全没压力。
说白了,Leanote 做的是“笔记管理”本身,RAG 做的是“让笔记内容可被语义发现”,二者是前后端关系,不是竞争关系。笔记工具继续承担日常记录,RAG 负责把历史沉淀激活。
2. RAG 链路整体设计:七道工序决定问答质量的上限
2.1 从笔记到答案,中间要过七道关
把 RAG 拆开看,其实是一条流水线。你可以把它想象成开一家小饭馆:先要买菜(数据抽取)、洗菜择菜(清洗)、改刀切配(分块)、给食材贴标签入库(向量化与索引)、客人点菜时去仓库里挑合适的食材(检索)、最后下锅炒(生成回答)。任何一道工序掉链子,端上来的菜都不对。
具体到我这个项目里,链路是:
- 数据抽取:从 Leanote 导出 Markdown 原文。
- 清洗:去掉 Leanote 附加的脚本、HTML 残留、无意义 metadata。
- 分块:按 Markdown 标题结构和正文长度切成可检索的文本块。
- 嵌入:每个文本块送入 embedding 模型,变成向量。
- 索引存储:向量 + 原文 + 元数据一起写入向量数据库。
- 检索与重排:用户问题时转成向量,混合 BM25 关键词检索,再做 Rerank 精排。
- 生成:把精排后的候选块拼进提示词,大模型基于这些块生成带引用的答案。
很多人一上来就研究第 7 步的提示词怎么写,其实前面的数据清洗和分块才是决定系统上限的环节。垃圾进,垃圾出,提示词写得再花哨也救不回来。
2.2 技术选型:能本地就本地,能轻量就轻量
这里给出我实际使用的技术选型,以及替换选项,方便你根据自己的环境决定:
| 环节 | 可选方案 | 我的选择 | 理由 |
|---|---|---|---|
| 数据源 | Leanote 备份包 / API / MongoDB 直连 | 备份包 + API 结合 | 备份包适合全量重建,API 适合增量同步 |
| 嵌入模型 | bge-m3、text-embedding-3-small、m3e-base | bge-m3 | 中文友好、1024 维、本地可跑,不依赖外网 API |
| 向量库 | Qdrant、Chroma、Milvus、FAISS | Qdrant | 支持过滤、混合检索、自带 API,单机部署简单 |
| 重排模型 | bge-reranker-v2-m3 | bge-reranker-v2-m3 | cross-encoder 型,精排效果在中文场景里稳 |
| 生成模型 | Qwen、DeepSeek、GPT、Claude | Qwen2.5-14B(本地量化) | 数据不出本地,隐私可控,中文能力足够 |
| 编排框架 | LangChain、LlamaIndex、纯手写 | 纯 Python + LangChain 的 Splitter | 链路透明可控,调参时知道问题出在哪一环 |
这套选型有一个核心原则:笔记是高度私有化的数据,能本地推理的模型绝不上云。embedding 阶段 bge-m3 量化后 2GB 显存就能跑,生成阶段用 14B 量化模型也就需要 8GB~10GB 显存,普通单卡就能扛住。如果实在没有本地推理条件,API 方案也可以,但建议至少做脱敏处理。
2.3 框架选择与代码组织
我见过不少人一上来就套 LangChain 全家桶,最后出了问题根本不知道是 LangChain 封装的 bug 还是自己的数据问题。我的建议是:分块和清洗自己写,检索和生成可以借助框架。LangChain 的MarkdownHeaderTextSplitter确实好用,但清洗逻辑最好自己控制,因为每个笔记软件的导出格式都不一样,没有现成的清洗器。
整个项目我按功能拆成了四个模块:
leanote_exporter.py:负责从 Leanote 取数据。cleaner.py:负责 Markdown 清洗和格式化。indexer.py:负责分块、向量化、写入 Qdrant。query_engine.py:负责混合检索、重排、提示词组装和生成。
模块化之后,任何一环的性能问题都可以单独测试。比如我发现检索召回率低,可以直接在indexer.py里调整分块大小重新建索引,不用动其他代码。
3. 数据解锁:把 Leanote 笔记干净地喂给 RAG
3.1 三种取数方式,什么时候用哪一种
Leanote 取数有两条相对成熟的路径:
方式一:Web 端导出备份包。登录自托管的 Leanote 后台,在设置里能导出整个账户的备份。备份文件是一个 zip,解压出来每一个笔记本对应一个目录,笔记是一个个.md文件,附带的还有 JSON 格式的元数据(创建时间、更新时间、标签)。这种方式最省事,适合一次性全量建索引。
方式二:直接查 MongoDB。如果 Leanote 是 Docker 部署的,数据落在 Mongo 里,可以用 mongodump 备份,然后解析notes集合,正文存储在 content 字段。这种方式适合做自动化增量同步,但需要熟悉 Leanote 的表结构,成本高一些。
方式三:开放 API。Leanote 有 token 认证的 API,可以拉取笔记本列表和笔记详情。这种方式适合做定时增量同步,但 API 文档比较旧,字段有时候对不上,需要自己抓包调试。
三种方式我实际都用过。我的最终建议是:全量重建用备份包,日常增量用 API。备份包最稳,API 增量更新时只处理有改动的笔记,能省掉大量重复向量化的时间。
3.2 Markdown 清洗:不解决这些问题,检索质量直接腰斩
Leanote 导出的 Markdown 并不是干干净净的,至少会遇到四类问题:
- frontmatter 噪声:笔记开头有一堆
Title:、Tags:、Created:之类的元数据,这些字段如果混进分块内容里,会让向量语义被标题和日期带偏。 - 图片附件路径:笔记里引用图片的路径通常是
/file/xxxx,对 RAG 来说这些链接没有语义价值,但会切碎正文。 - HTML 残留:从网页直接粘贴过来的内容偶尔会带上
<div>、<span>、内联 style,这些标签会干扰分块器的段落识别。 - 代码块的完整性:代码块是有整体语义的,分块器如果从代码块中间截断,检索出来的内容完全没法看。
我写了一个清洗函数,专门处理这四类问题:
import re from pathlib import Path def clean_leanote_md(raw: str) -> str: # 去掉开头的 frontmatter 元数据块 raw = re.sub(r'^---\s*\n.*?\n---\s*\n', '', raw, flags=re.DOTALL) # 去掉 HTML 标签残留,保留文本 raw = re.sub(r'<div[^>]*>', '\n', raw) raw = re.sub(r'</div>', '\n', raw) raw = re.sub(r'<br\s*/?>', '\n', raw) raw = re.sub(r'<span[^>]*>', '', raw) raw = re.sub(r'</span>', '', raw) # 图片路径改为占位文本,保留 alt 文字更有语义 raw = re.sub(r'!\[([^\]]*)\]\(/file/[^)]+\)', r'[图片: \1]', raw) # 压缩多余空行 raw = re.sub(r'\n{3,}', '\n\n', raw) return raw.strip()这段代码看起来简单,但每一步都是我踩过坑之后的沉淀。特别是 frontmatter 处理,如果保留Title:这种字段,分块时它会和正文拼在一起,向量里全是日期和标签的干扰信号,检索出来的内容会莫名其妙。
3.3 清洗之后,一定要人工抽查一遍
我强烈建议清洗完之后,随机抽 20 篇笔记人工读一遍。因为清洗规则是启发式的,总会有意外情况,比如某篇笔记用了特殊的代码高亮标记,某个表格在清洗后错位了。人工抽查能很快暴露规则覆盖不到的地方,比直接建索引后跑评测再回头排查高效得多。
4. 分块与向量化:同是笔记,为什么有的能召回、有的石沉大海
4.1 分块策略:标题感知切分才是正解
分块这件事,90% 的人第一步都会踩同一个坑:直接按固定字符数硬切。固定 500 字一刀切,看起来省事,实际上会把一个完整的“事务方案设计”段落拦腰截断,还会把代码块劈成两半。检索时你搜“事务补偿”,召回的是被切断的那一半,上下文不完整,生成质量自然拉胯。
我的方案是基于 Markdown 标题层级切分,让每一块的边界落在语义自然的停顿处:
from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "H1"), ("##", "H2"), ("###", "H3"), ] splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False ) splits = splitter.split_text(cleaned_md)strip_headers=False这个参数很关键:保留标题,检索到这一块时我们能看到它属于哪个章节,上下文更完整。切成块之后再检查长度,如果某一节太长(比如超过 800 个 token),再用递归字符分割器按段落边界二次切分;如果太短,就与下一节合并,避免出现只有一行字的碎片块。
参数上,我的经验值是:中文笔记单块长度 200~600 字之间,重叠半个句子(约 10%~15%)。太长会让向量语义发散,太短会让上下文不足。重叠量不用太大,够保证句子不被截断就行。
4.2 嵌入模型选型:别被“大模型幻觉”带偏
嵌入模型的选择直接决定向量检索的天花板。在中文笔记场景里,我的建议是:
| 模型 | 维度 | 上下文长度 | 中文效果 | 部署方式 |
|---|---|---|---|---|
| bge-m3 | 1024 | 8192 | 优秀 | 本地 2GB 显存 |
| m3e-base | 768 | 512 | 良好 | 本地 CPU 也能跑 |
| text-embedding-3-small | 1536 | 8191 | 良好 | 云端 API |
| text-embedding-3-large | 3072 | 8191 | 优秀 | 云端 API,成本高 |
很多人有个错觉:embedding 模型越大越好。实际上,检索任务需要的是“句子级语义相似度”,不是“生成能力”。我之前拿一个大参数模型做过对比实验,在个人笔记这个量级上,和 bge-m3 的效果差异微乎其微,但推理速度和显存开销却差了一个量级。
在个人知识库场景,bge-m3 基本是平衡得最好的选择:中文效果好,支持 8K 上下文,还能在长文档分块时保留更多语义。如果你完全不想用本地模型,text-embedding-3-small 也够用,但核心笔记数据出本地这事,你自己权衡。
生成模型选择我也说一句:本地跑 Qwen 系列量化版,或者走 DeepSeek API,这两种方式在中文技术问答上效果都足够。注意不要用普通对话模型的默认温度参数,知识库问答场景我固定用temperature=0.1,让模型尽量忠实于检索到的内容,而不是自由发挥。
4.3 向量库索引参数:日常量级不用过度调优
向量库我选的 Qdrant,单机一个 Docker 容器就起来了。对于个人知识库这个量级(几千篇笔记、几万块文本),HNSW 索引的参数不用大动,默认的m=16、ef_construction=100已经够快。真正值得花时间的是元数据设计。
我会在写入 Qdrant 时给每块文本带上这些字段:
{ "text": "分块后的正文内容", "source": "笔记标题", "notebook": "所属笔记本", "url": "Leanote 笔记链接", "chunk_index": 3, "updated_at": "2024-01-15" }有了source和notebook,生成答案时可以直接把引用锚到具体笔记;有了updated_at,增量同步时可以按时间过滤,只更新变更过的笔记。
5. 检索与生成:混合检索、重排和提示词约束的配合
5.1 纯向量检索的盲区:专有名词和代码符号
向量检索不是万能的。我的笔记里大量出现函数名、变量名、项目代号,比如processTransaction、sg_pay_2024这种。向量模型训练时没见过这些词,它们的嵌入表示基本是“随机的”,余弦相似度不可靠。
纯向量的结果是:你用“支付流程”去搜能搜到,但搜“sg_pay_2024”这种具体代号时,往往不如直接做关键词匹配。
解决方案是混合检索:BM25 倒排索引负责精确匹配专有名词和代码标识符,向量检索负责语义召回。两者结果用 RRF(Reciprocal Rank Fusion)融合:
def rrf_fuse(scores_list: list[list[tuple[str, float]]], k: int = 60) -> list[tuple[str, float]]: fused: dict[str, float] = {} for scores in scores_list: for rank, (doc_id, _score) in enumerate(scores): fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank + 1) return sorted(fused.items(), key=lambda x: x[1], reverse=True)RRF 不关心两路分数是否在同一量纲,它只看排序位置,天然适合融合不同检索器的结果。实测下来,混合检索在“代码符号”和“自然语言描述”两类 query 上的综合召回率,都明显高于单独用任何一种。
Qdrant 本身就支持 BM25 和稠密向量的混合检索,不需要自己搭两层,直接配置即可。这比自建 Elasticsearch 轻量得多。
5.2 重排:让 top50 变 top5 的关键一步
向量检索和混合检索擅长“召回”,但召回的结果集往往噪声比较大。这时需要重排模型上场。重排用的是 cross-encoder 结构:把问题和候选文本拼成一对,交给模型打分。它比 bi-encoder(普通 embedding)慢得多,但精度高得多。
我的流程是:先混合检索召回 50 块,再用 bge-reranker-v2-m3 算分,取前 5 块送给大模型:
from FlagEmbedding import FlagReranker reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True) pairs = [(query, chunk) for chunk in top50] scores = reranker.compute_score(pairs) top5 = [chunk for _, chunk in sorted( zip(scores, top50), key=lambda x: x[0], reverse=True )][:5]这个设计遵循一个原则:召回阶段要宽,精排阶段要严。召回用轻量模型快速找到 50 个潜在候选,重排用重型模型精挑细选。如果一开始就把 top5 限定死了,漏召回是没有任何补救机会的。
5.3 提示词设计:让大模型“只说自己笔记里的话”
检索做完,生成环节的提示词决定了答案的最终形态。我用的中文提示词框架如下,你可以直接抄:
你是一个个人知识库问答助手。请只根据下面提供的知识片段回答用户问题,不要使用片段之外的知识。 规则: 1. 如果知识片段中找不到答案,直接回答“我笔记里没有相关内容”,不要编造。 2. 每条回答末尾,用 [来源1][来源2] 标注你参考的知识片段编号。 3. 回答控制在 500 字以内,如果涉及到步骤,用列表形式输出。 知识片段: [1] 标题:分布式事务补偿方案,内容:... [2] 标题:TCC 实现笔记,内容:... 用户问题:...注意三点:
- 温度参数必须调低,
temperature=0.1或更低,否则模型会“创造性”地给你编一个看似合理的答案。 - 引文标注放在提示词里约束模型输出,回答末尾的
[来源1]可以在代码里再映射成笔记链接,用户点一下就能跳到原文。 - 放弃回答的授权必须明确写出来,否则大模型的本能是“强行接话”,笔记里没有的信息它也会编。
我迭代过很多版提示词,最大的感受是:与其费劲写复杂的思维链,不如把“放弃回答”和“引用来源”这两条约束写死,效果立竿见影。
6. 知识库上线前的体检:不用指标说话,你根本不知道哪里坏了
6.1 先建评测集:50 条就够了
很多人把 RAG 系统跑通之后,测几个问题觉得“看起来不错”,就直接用了。这是大忌。你根本不知道是检索环节好还是生成环节好,也不知道换一个分块参数是变好了还是变差了。要改参数,先要有度量。
我的做法是:从高频使用的笔记里挑 20~30 篇,针对每一篇设计 2~3 个问答对,凑 50 条左右。问答对的类型要有区分度:
- 语义改写类:问题和笔记原文用词不同,考验语义检索能力。
- 专有名词类:直接问函数名、项目代号,考验 BM25 混合检索能力。
- 跨笔记综合类:答案需要综合两篇笔记的内容,考验分块和召回的配合。
- 无答案类:笔记里没有的内容,考验模型会不会“强行回答”。
6.2 四个核心指标,逐项盯
我每次改动分块参数、换向量模型或改提示词,都用同一份评测集跑一遍,关注四个指标:
| 指标 | 含义 | 计算方式 | 我期望的范围 |
|---|---|---|---|
| 召回率 | 相关文档是否被召回 | 正确笔记出现在 top20 的比例 | > 90% |
| 命中率 | 关键答案块是否在 top5 | 正确答块出现在精排前 5 的比例 | > 85% |
| 忠实度 | 生成答案是否忠于检索内容 | 人工判断或 LLM 打分 | > 90% |
| 答案相关度 | 答案是否直接回应问题 | 人工判断或 LLM 打分 | > 85% |
这四个指标可以帮你在迭代时快速定位问题:如果召回率低,说明分块或向量化出了问题;如果召回率还行但命中率低,说明 rerank 需要调;如果前两个都高但忠实度低,问题出在提示词或生成参数;如果相关度低,大概率是检索到的内容根本不是用户要的那个粒度。
6.3 我实际遇到的三大高频问题,以及对应的调优路线
问题一:检索不到相关笔记。我遇到过的原因主要有三种:分块太大导致语义被稀释、frontmatter 混入正文干扰向量、嵌入模型和领域不匹配。调优线路是:先调分块大小(把 800 字改到 400 字)重试,不行再看清洗环节是否把标题弄丢了,最后考虑换嵌入模型。
问题二:检索到了,但生成答案与笔记内容不符。这种情况集中在提示词约束不够强、温度太高、或者候选块里同时存在语义相近但结论冲突的内容。我的解决办法:降低温度到 0.1,把“只依据提供的片段作答、无相关信息就直说”写进系统提示词,并加大重排力度,确保把冲突内容过滤掉。
问题三:回答太啰嗦、没有重点。这是生成模型的天性,不是检索的问题。我在提示词里加了两条硬约束:“回答限 500 字以内”“如果问题问的是步骤,请用编号列表”。效果非常明显。别指望模型自己“懂礼貌”,约束都要写在明面上。
三个问题排查完,我的知识库准确率从最初的 65% 左右,提升到了 88% 上下,其中大部分提升来自分块策略的修正和重排环节的引入,提示词只贡献了一小部分。
最后说点个人折腾下来的体会。RAG 这个项目真正花时间的不是写那几十行调用代码,而是数据清洗和分块调优。我一开始图省事直接固定长度切分,效果一塌糊涂,改成标题感知切分之后,检索质量明显上了一个台阶。如果你也想给自己的笔记加一个“AI 问答大脑”,建议先用 50 篇笔记把全链路跑通,再逐步加量,别一上来就追求全量导入。Leanote 的笔记是你长期积累的领域数据资产,接上 RAG 之后,那些沉在底层的经验才开始真正被重新激活。