尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

给Leanote笔记接上RAG:自托管知识库语义检索问答实践

发布时间:2026/9/7 20:50:47

资讯中心
01
ARTICLE

给Leanote笔记接上RAG:自托管知识库语义检索问答实践

给Leanote笔记接上RAG:自托管知识库语义检索问答实践
我在自托管的 Leanote 里存了五年笔记Markdown 为主按笔记本和标签组织数据全部落在自己的服务器上。笔记量一上来最痛的其实不是没地方放而是要用的时候想不起来它在哪全文搜索只能做关键词匹配一旦标题和正文里没有那个词它就永远躺在那儿不出现。前阵子我找一份早先整理的“分布式事务补偿方案”明明写过试了“事务”“补偿”“最终一致性”三个词都一无所获最后是靠着记忆里的笔记本目录才翻出来。这件事让我下定决心给 Leanote 接一套 RAG 检索增强生成链路做成了自己的知识库问答系统AI 能基于我自己的笔记做语义检索和自动回答。如果你也在用一个能导出 Markdown 的笔记工具而且攒了一定量的笔记资源这篇文章的思路和代码可以直接搬过去。1. 为什么偏偏选 Leanote对比全文搜索、Notion AI 和 Obsidian Copilot1.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-basebge-m3中文友好、1024 维、本地可跑不依赖外网 API向量库Qdrant、Chroma、Milvus、FAISSQdrant支持过滤、混合检索、自带 API单机部署简单重排模型bge-reranker-v2-m3bge-reranker-v2-m3cross-encoder 型精排效果在中文场景里稳生成模型Qwen、DeepSeek、GPT、ClaudeQwen2.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 笔记干净地喂给 RAG3.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, flagsre.DOTALL) # 去掉 HTML 标签残留保留文本 raw re.sub(rdiv[^]*, \n, raw) raw re.sub(r/div, \n, raw) raw re.sub(rbr\s*/?, \n, raw) raw re.sub(rspan[^]*, , 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_onheaders_to_split_on, strip_headersFalse ) splits splitter.split_text(cleaned_md)strip_headersFalse这个参数很关键保留标题检索到这一块时我们能看到它属于哪个章节上下文更完整。切成块之后再检查长度如果某一节太长比如超过 800 个 token再用递归字符分割器按段落边界二次切分如果太短就与下一节合并避免出现只有一行字的碎片块。参数上我的经验值是中文笔记单块长度 200~600 字之间重叠半个句子约 10%~15%。太长会让向量语义发散太短会让上下文不足。重叠量不用太大够保证句子不被截断就行。4.2 嵌入模型选型别被“大模型幻觉”带偏嵌入模型的选择直接决定向量检索的天花板。在中文笔记场景里我的建议是模型维度上下文长度中文效果部署方式bge-m310248192优秀本地 2GB 显存m3e-base768512良好本地 CPU 也能跑text-embedding-3-small15368191良好云端 APItext-embedding-3-large30728191优秀云端 API成本高很多人有个错觉embedding 模型越大越好。实际上检索任务需要的是“句子级语义相似度”不是“生成能力”。我之前拿一个大参数模型做过对比实验在个人笔记这个量级上和 bge-m3 的效果差异微乎其微但推理速度和显存开销却差了一个量级。在个人知识库场景bge-m3 基本是平衡得最好的选择中文效果好支持 8K 上下文还能在长文档分块时保留更多语义。如果你完全不想用本地模型text-embedding-3-small 也够用但核心笔记数据出本地这事你自己权衡。生成模型选择我也说一句本地跑 Qwen 系列量化版或者走 DeepSeek API这两种方式在中文技术问答上效果都足够。注意不要用普通对话模型的默认温度参数知识库问答场景我固定用temperature0.1让模型尽量忠实于检索到的内容而不是自由发挥。4.3 向量库索引参数日常量级不用过度调优向量库我选的 Qdrant单机一个 Docker 容器就起来了。对于个人知识库这个量级几千篇笔记、几万块文本HNSW 索引的参数不用大动默认的m16、ef_construction100已经够快。真正值得花时间的是元数据设计。我会在写入 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 倒排索引负责精确匹配专有名词和代码标识符向量检索负责语义召回。两者结果用 RRFReciprocal 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(), keylambda x: x[1], reverseTrue)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_fp16True) pairs [(query, chunk) for chunk in top50] scores reranker.compute_score(pairs) top5 [chunk for _, chunk in sorted( zip(scores, top50), keylambda x: x[0], reverseTrue )][:5]这个设计遵循一个原则召回阶段要宽精排阶段要严。召回用轻量模型快速找到 50 个潜在候选重排用重型模型精挑细选。如果一开始就把 top5 限定死了漏召回是没有任何补救机会的。5.3 提示词设计让大模型“只说自己笔记里的话”检索做完生成环节的提示词决定了答案的最终形态。我用的中文提示词框架如下你可以直接抄你是一个个人知识库问答助手。请只根据下面提供的知识片段回答用户问题不要使用片段之外的知识。 规则 1. 如果知识片段中找不到答案直接回答“我笔记里没有相关内容”不要编造。 2. 每条回答末尾用 [来源1][来源2] 标注你参考的知识片段编号。 3. 回答控制在 500 字以内如果涉及到步骤用列表形式输出。 知识片段 [1] 标题分布式事务补偿方案内容... [2] 标题TCC 实现笔记内容... 用户问题...注意三点温度参数必须调低temperature0.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 之后那些沉在底层的经验才开始真正被重新激活。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。