用LLM打造个人知识库:从RAG到本地维基的实践
2026/9/14 7:21:11 网站建设 项目流程

老话讲,好记性不如烂笔头。可我这人是反过来的,烂笔头记了一堆,真到用的时候,脑子照样一片空白。五年下来,我的笔记散落在 Markdown 文件、网页剪藏、PDF 批注、还有各种临时写在手机便签里的只言片语中。每次想找点什么,都要在十几个文件夹里翻来翻去,最后经常放弃,重新搜索一遍互联网。这种挫败感攒到今年年初,我决定换个思路:既然记笔记的核心障碍是"整理"和"检索",与其手动给每篇笔记打标签、建目录、做双链,不如让大语言模型(LLM)接盘。这就是 llm_wiki 这个项目的由来。

一句话说清楚它是个什么东西:llm_wiki 是一个“LLM 替你管理知识库”的个人本地维基系统。它把你丢进来的文档自动清洗、切片、生成摘要、打上标签、建立语义关联,然后给你提供一个"能聊天"的检索入口——你可以像问一个熟悉你全部笔记的人那样,问它"去年那篇讲某个算法收敛性的文章里提到的边界条件到底是什么",它不仅能告诉你答案,还会引用原文片段、标注出处。项目本身支持本地优先部署、模型无关,可以对接 OpenAI 兼容接口,也可以跑本地 Ollama 模型。

这中间最折腾的,不是调 API 或者写检索代码,而是内容结构化那一层——怎么让模型稳定地输出能用的"知识元数据",以及怎么让检索结果在"找得到"和"答得准"之间取得平衡。这篇文章把我从规划到跑通、再到日常使用了三个月的完整经验写出来,包括每个环节为什么这么设计、哪些地方容易翻车、以及可以直接抄走的配置方案。如果你也攒了一堆笔记但根本不想整理,或者你已经在用 RAG 做问答但总被垃圾召回困扰,这篇文章应该能帮你省掉不少弯路。

1. 为什么是 "LLM + Wiki" 而不是"再试试新笔记软件"

先聊点真实的痛点。市面上的笔记软件我几乎轮了个遍:印象笔记、Notion、Obsidian、思源笔记,都用过半年以上。它们解决的是"记录"和"双向链接"的问题,但对我来说最耗时的其实是三个环节:整理归类、内容关联、以及想不起来关键词时的模糊检索。这三个环节恰恰是规则和人工最不擅长、而 LLM 最擅长的事。

最初的灵感其实来自一个很简单的反推:如果只是把文档存进去,然后按关键词搜索,那我直接用 grep 都行,为什么还需要一套系统?我需要的是——丢一篇很长的技术报告进去,模型能告诉我这篇报告的主旨、涉及哪些技术概念、和我笔记里哪几篇旧内容存在关系。这显然超出了传统标签体系的表达能力,但又在 LLM 的能力范围内。

wiki 这种组织形式在这里帮了大忙。它不像传统笔记强调文件夹层级,而是强调"词条"和"页面之间的链接"。llm_wiki 沿用了这个思路:每篇文档都被视为一个 page,但链接关系不再由我手动维护,而是由模型在导入时自动生成。指向关系来自摘要中的关键实体、语义相似度计算、以及用户后续提问时产生的关联反馈。也就是说,wiki 结构是 LLM 基于内容自发生长出来的,不是我预先规划好的

这套设计还有个额外的红利:懒人友好。以前我写笔记,要想着"这里该打个标签""那里该建个链接",现在完全不用。我只需要把资料丢进去,系统自己处理。从一个"使用者"的角度看,llm_wiki 给我的感觉更像一个"图书管理员",而不是"另一个笔记软件"。

2. llm_wiki 的整体方案:本地优先、模型无关、双通道召回

2.1 架构总览:四条主链路

整个系统可以拆成四条主链路,理解这四条链路就能明白所有后续细节:

  1. 导入链路:接收 Markdown、纯文本、PDF、网页 HTML 等多种输入,做清洗、格式化、分割成块。
  2. 结构化链路:每个分块过一遍 LLM,生成摘要、关键词、实体列表、以及该块与已有页面的潜在关联。
  3. 存储链路:原始分块和结构化元数据写入 SQLite 作为主存储,同时把分块向量化后写入本地向量数据库。
  4. 检索与问答链路:用户提问后,先做关键词检索(BM25),再做向量检索(Embedding 相似度),融合结果后拼接提示词交给 LLM 回答,并附带可追溯的引用来源。

这四条链路不是串行,而是两条异步、两条同步:导入和结构化是异步的,通常丢进去一批文档后由后台队列处理;检索和问答是同步的,用户提问必须在秒级内返回。异步处理要用消息队列吗?没有,我用的是 SQLite 做任务表 + 一个简单的 Worker 轮询,本地单用户场景完全够用。别一上来就上 Kafka,没有那个必要。

2.2 为什么我坚持本地优先

本地优先带来的最大好处是隐私可控。笔记里有太多不适合上云的内容,比如个人健康记录、工作中的内部资料、还有各种未成形的想法。这些内容如果通过 API 发给第三方模型,心里总是不踏实。而 llm_wiki 的默认配置是:Embedding 模型本地跑,LLM 可选本地或远程。哪怕全选远程 API,也支持在发送前做脱敏处理。这个"即使上云也可脱敏"的设计很重要,它让我在后续想接入更强大模型的时侯多一个选择。

另一个原因是离线可用。我有过在地铁上突然想查一个旧笔记却因为没网而只能干瞪眼的经历。本地部署之后,只要笔记本上跑着一个小模型(哪怕只有 7B 参数),基本的摘录、检索、问答全都能做。这种可靠性是云端笔记软件给不了的。

2.3 模型无关的抽象层设计

模型无关是 llm_wiki 最早确定的设计原则。今天可能觉得 OpenAI 的 API 很好用,明天可能出了个更好的开源模型,如果代码里写死了调用方式,换模型就会很痛苦。所以我定义了一个统一的CompletionProvider接口:

  • chat(messages, options) -> response
  • embed(texts) -> vectors

然后分别对接了 OpenAI 兼容的 HTTP API、Ollama 本地模型、以及一个用于快速测试的 Mock 模型。Mock 模型是我自己写的,专门返回固定结构的内容,用于跑通流程时不做真正的模型调用。接入新模型只需要实现这个接口,完全不用动核心逻辑。

这个抽象层同时也管住了成本。我可以在全局配置"哪些操作走小模型,哪些操作走大模型",比如摘要和实体提取用本地 7B 模型,而最终回答用户问题用更强的云端模型。通过这种分级调度,日常费用被压到了很低——大部分时候接近零成本。

2.4 双通道召回:不要让向量模型单打独斗

接下来是整个系统里我认为最容易被人忽略、但恰恰最影响体验的一环:召回。很多入门教程会让你把所有文档切片后扔进向量数据库,然后提问时直接做相似度搜索。这种方案在文档量少、主题单一的时候好像不错,但我拿真实笔记测下来的结果很一般——向量检索对"明确含义、术语一致"的提问表现好,对"关键词很具体但不带语义"的提问反而不如传统关键词搜索

比如我想找之前记的"SQLite WAL 模式参数",如果只做向量检索,模型可能把我带偏到别的数据库事务相关的内容上。但结合 BM25 全文检索,"SQLite" 和 "WAL" 这两个词一出,精确命中的排序自然就上来了。所以 llm_wiki 采用了双通道召回:BM25 结果和向量结果各取 Top N,然后用 RRF(Reciprocal Rank Fusion)算法合并排序。这套方案在实际效果上比单纯向量检索的召回准确率高出一大截,而且实现成本并不高,大概一百来行代码。

两个通道的召回结果合并后,还要根据文档的重要程度(比如被引用次数、访问频率)做一次轻量重排,再截取 Top K 作为最终上下文。整个链路我都记录日志了,方便出问题时回溯到底当时召回的是什么内容。

3. 核心模块落地:从"文件进来"到"答案出去"

3.1 导入与清洗:垃圾进,垃圾出

llm_wiki 的导入模块是我最先写的,因为后面所有环节都依赖干净文本。这里说的"清洗"并不是简单地把 HTML 标签去掉,而是要处理一堆现实世界的脏数据:

  • 网页剪藏里的广告、导航栏、页脚;
  • PDF 里因为双栏排版导致的文本顺序错乱;
  • Markdown 里的 Mermaid 代码块(LLM 可读但发送成本高);
  • 一些临时笔记里包含的乱码和 emoji 变体。

清洗策略并没有用复杂的机器学习模型,而是"规则先行,LLM 兜底"。预先用训练好的提取模板处理掉 80% 的常见垃圾;剩下的、比如 PDF 排版错乱导致语义不通的段落,才丢给 LLM 做一次"重写为流畅的 Markdown"。这种做法的好处是很显然的——快且便宜。

清洗后的文本进入切分环节。切分是我踩坑最多的地方,后面我会专门花一节来说。这里先记住一个原则:不要按固定 token 数硬切,最好按段落的语义边界切,同时让相邻块之间保留重叠部分。llm_wiki 默认配置是每块 600 token,重叠 80 token。这样既能保证每块内容相对完整,又不会让上下文信息在切分处断裂。

3.2 结构化:让模型产出稳定的元数据

每一快被清洗好的文档分块,在入库之前都需要生成一组元数据。这部分我设计了一个固定的 Prompt 模板,要求模型以 JSON 格式返回:

{ "summary": "两到三句话的概要,保留关键数字和结论", "keywords": ["词1", "词2", "..."], "entities": ["人名/机构名/技术名词/产品名"], "relations": [ {"target_title": "已有的页面标题", "relation_type": "supports/conflicts/extends"} ] }

这里有个非常关键的细节:relations 里的 target_title 必须是从已有页面标题集合中检索出来的,不能由模型凭空造句。最开始我尝试让模型自由填写关联,结果它总能编出一些和现有内容长得像但其实不存在的页面名,导致知识网络里全是断链。后来我改了一个思路:在 Prompt 中加入"当前已有页面标题列表"(比如取前 100 个最相关的标题),要求模型只从里面挑,剩下的关联宁可不要。这一改,断链率大幅降低。

元数据写回 SQLite 之后,原始分块会做一次向量化,和元数据一起存入向量库。向量化的时机也需要注意:比起每写一条就嵌入一次,我更推荐批处理,每攒够 32 个分块或者隔 30 秒嵌一次。这样能减少模型调用次数,对本地嵌入服务也更友好。

3.3 语义检索与重排:召回不是终点,排好序才算

前面说了双通道召回,这里再展开讲讲合并后的重排逻辑。RRF 合并公式非常简单:

score(doc) = Σ 1 / (k + rank_i(doc))

其中k是一个常量,通常取 60。rank_i是文档在第 i 个检索通道中的排名。这个公式的思路是:某篇文档如果在多个通道里的排名都比较靠前,那它的综合分就高。和加权求和不同,RRF 不要求两个通道的分数在同一量纲下,所以实现起来非常省心。

重排阶段我加了一个可选的 "LLM Reranker" 模块:把召回到的 Top 15 文档分块和用户提问一起交给模型,让它按相关度从高到低排序,并返回前 5 个作为最终上下文。这个重排器很贵,所以默认是关闭的。只有在用户提问比较长、而且双通道召回结果里文档太多的时候才自动开启。实际体验下来,它对最终答案质量的提升非常明显,但也会增加 3~5 秒的耗时。可以把开关放在前端界面上,让用户自己权衡。

3.4 问答:告诉模型"你不知道也没关系"

问答链路是用户直接感知的部分。llm_wiki 的问答模块除了拼上下文外,还额外做了三件事:

  1. 限定范围:在系统提示词里明确说"你是一个个人知识库助手,只能基于提供的内容回答。如果内容中找不到答案,请回答未知,不要臆造。"
  2. 引用溯源:要求模型在回答中标注引用编号[1][2],和最终展示的参考来源一一对应。
  3. 追问建议:在回答末尾附加三个与当前问题相关的后续提问建议,这些建议是 LLM 根据召回结果生成的,帮助用户继续深挖。

这样做下来,用户得到的就不是一段干巴巴的答案,而是"答案 + 证据 + 延展路径"。我自己用了这么久,默认最常用的功能反而是那个"后续追问建议"——很多我自己没想到的关联,都是它提示出来的。

4. 最容易翻车的三个环节:踩坑记录与排查链路

4.1 向量切分:固定长度切出来的全是废话

四个字总结我最初的切分策略:惨不忍睹。我一开始图省事,直接把文档按 512 个 token 等长切块。测试的时候问"张三是哪个团队的",结果 LLM 回答了一堆不相关的内容。排差了一圈,最后定位在切分上——张三的名字出现在第一块末尾,而"他是 A 团队负责人"出现在第二块开头,中间正好被切开。向量化之后,两块的内容都很稀疏,都没有完整表达"张三是 A 团队负责人"这个信息。

解决思路也不复杂:切分时优先选段落边界,如果一个段落太长,再在里面找二级标题、句子边界下刀。我用的是按 Markdown 标题和空行分段的逻辑,结合一个递归切分函数:

  1. 如果当前文本长度在[min_tokens, max_tokens]内,直接作为一个块;
  2. 如果太长,先尝试按#####标题切;
  3. 再不行就按双换行切;
  4. 最后才按句子结束符(句号、问号、感叹号)切。

相邻块保留 1~2 句话的重叠。这套规则之后,召回的命中率提升得很明显。这个小细节值得每个做 RAG 的人认真对待。

4.2 结构化 Prompt:既要稳定输出,又不要上下文爆仓

第一次让 LLM 生成元数据时,我给的 Prompt 是个自由发挥题:"请分析以下内容并返回摘要、关键词、实体、关联。"模型确实按格式写了,但输出特别不稳定:有时候关键词有 20 个,有时候只有 1 个;关系列表里还出现了根本不在已有页面标题列表里的标题,而且摘要风格来回横跳。

后来我把 Prompt 改成了"任务说明 + 输出 Schema + 示例 + 注意事项"四段式,并且在 Schema 里严格限定了枚举值和数量范围:

关键词:3~8 个,名词短语,不要包含标点 实体:尽量用人名/机构名/技术名词,最多 5 个 relations: 只从给定标题列表中选择,如果无法确定就输出空数组 summary: 最多 60 字

同时,我在解析 JSON 的时候做了容错,即使模型输出里带点杂七杂八的文本,也能提取出合法 JSON。这个"extract_json函数"我实测很有效——它先用正则找到第一对{和最后一个},然后直接json.loads;如果失败,就把字符串交给一个修错 Prompt 再跑一次。顺便一提,所有元数据生成之后,我都存了"原始输出"字段,方便以后模型升级后重新生成时做对照。

4.3 引用溯源:模型记错了出处,比不引用更坑

引用是 llm_wiki 用户感知最强的功能之一,但实现它让我发现了 LLM 的另一个毛病:它会在引用编号上撒谎。明明给它了 5 条参考内容,它却回答 [6] [7] 这样的编号;有时它引用的内容确实存在于某一块里,但对答案的支撑关系很弱。这个问题的根源在于:"引用"和"生成文本"是在同一个过程里完成的,模型无法严格保证自己为某句话分配的编号对应的原始块确实支持这句话。

我的解决办法是引入"事后验证":

  1. 从模型回答中提取所有引用编号;
  2. 把这些编号对应的原文块调出来;
  3. 计算原始块与包含引用的句子的余弦相似度;
  4. 如果相似度低于阈值(比如 0.35),删除该引用编号,并在界面上打上"弱相关"标记。

实测下来,这一步能过滤掉大量幻觉引用。虽然后处理会多耗 100ms 上下(因为要重新走一遍嵌入),但这个成本在可信度面前不值一提。

5. 从部署到日常使用:可以直接抄的配置方案

5.1 环境依赖清单

llm_wiki 本身是 Python 写的,依赖并不多,核心就这几个:

  • fastapi:提供 API 服务
  • sqlite-vecchromadb:向量存储(我个人偏好sqlite-vec,因为它和 SQLite 主存储可以共用一套备份机制)
  • httpx:调用模型 API
  • beautifulsoup4pypdf:处理网页和 PDF
  • python-frontmatter:解析 Markdown 元数据

部署时我用 Docker Compose 起两个容器,一个是应用本身,另一个是 Ollama 服务(跑本地 Embedding 和 LLM)。如果只想用云端 API(比如 OpenAI),那只需要一个应用容器就够了,配置里填好 API Key 和 Base URL 即可。

5.2 核心配置项

配置文件采用 YAML 格式,下面是精简版:

llm: provider: "ollama" # 可选: openai / ollama / mock chat_model: "qwen2.5:14b" embedding_model: "bge-m3" base_url: "http://localhost:11434" storage: sqlite_path: "./data/llm_wiki.db" vector_table: "vec_chunks" chunk_size: 600 chunk_overlap: 80 retrieval: bm25_weight: 1.0 vector_weight: 1.0 rrf_k: 60 top_k: 15 final_top_k: 5 enable_llm_rerank: false pipeline: batch_size: 32 worker_interval_seconds: 30

这几个参数里,chunk_sizetop_k对最终效果影响最大。我自己的经验是:内容偏向长文本和深度技术文档时,chunk_size调到 800 更好;偏向碎片化笔记时,400 更合适。没有一劳永逸的参数,最好做一个后台"盲测对比",每天随机抽几个问题,在两组参数下分别运行,看哪组召回准确率高,然后定期调整。

5.3 典型使用流程

日常使用我基本是这样操作的:

  1. 把网页正文复制到剪贴板,用浏览器插件(或快捷键调起一个本地脚本)往 llm_wiki 的/api/import接口 POST 一段文本;
  2. 系统自动清洗、切分、生成元数据并入库;
  3. 过 30 秒左右,新内容可以在"最近更新"里看到;
  4. 需要找内容时,我在网页端的输入框直接问问题;
  5. 系统返回带引用的回答,同时右侧栏显示相关页面列表。

这套流程真正做到了"导入即忘"。以前记笔记是需要刻意去做的事,现在反而是看到了好内容就往里丢,丢完就再也不看了,需要时通过问话来取。

6. 模型选择与成本控制:低配电脑也能玩

6.1 不同模型的定位

我把模型分成三档:

用途推荐模型举例参数量级备注
本地摘要/实体提取Qwen2.5、Llama 3.17B~14B要求中文和英文都要稳
本地 EmbeddingBGE-M3、GTE-Qwen300M~1B关键指标是检索命中率
云端强推理问答GPT-4o、Claude Sonnet-回答复杂综合问题时效果好

本地模型选型时,我重点看的是"长上下文下的稳定性"和"JSON 输出能力"。很多 7B 模型单独写摘要没问题,但让它输出一段严格 JSON 时常会少括号或者多字段。如果发现这类情况,可以在 Prompt 里加 few-shot 示例,但更省事的方法是直接换一个经过指令微调的模型,比如 Qwen2.5 Instruct。

6.2 成本与延迟实测

我用的是一台 CPU-only 的老笔记本(8 核 16 线程),用 Ollama 跑 7B 模型做摘要,速度大概是 10 token/s,处理一个 600 token 的分块大概需要 60 秒左右。批量导入一篇 5000 字的文档大约要等 8~10 分钟。这个速度能接受,但如果你经常导入大量文档,还是建议搞一块 8GB 显存的显卡,速度能提升 20 倍以上。

云端 API 的价格其实也不贵。我在完全可用云端模型跑摘要的情况下,1000 篇短笔记大概消耗 150 万 token,按当前主流 API 价格算约 6 块钱人民币。最贵的是开启 LLM Reranker 之后,因为它每次都对 15 块内容做一次排序,一次问答可能额外烧掉 3000 token。所以我把 Reranker 的默认开关设成了 false,只在"语义复杂、答案分散"的场景下才手动开。

6.3 一个省钱的混合调度技巧

我在配置里加了一个规则:question_len > 100才启用云端模型,否则用本地模型。这么做的好处是日常简单问答几乎零成本,只有拿它当正经知识库研究复杂问题时才花几分钱。对于本地跑不动的更大模型,我还会用 Streaming 输出,让首 token 尽快出现,体感上快很多。

7. 后续还能玩的方向:知识图谱、自动维护、多人协同

llm_wiki 跑通到现在已经三个月,日常使用的核心功能都稳定了,我开始琢磨几个能把它推向更深处方向:

第一个是知识图谱可视化。现在模型已经生成了实体和关系,但它们目前只存在 SQLite 里,没有一张可视化网图。我打算在网页端增加一个力导向图,把文档作为节点、实体关系作为边,点击任意节点能看到关联文档。这能让用户在浏览时发现意外的知识连接。对这个系统来说,本质上是把已有的关系数据再加工一下。

第二个是自动维护与旧闻感知。很多维基系统都有"陈旧页面"问题——当某个技术名词的定义在新文档里发生了变化,旧文档里的相关段落却还留在原来的语义里。llm_wiki 可以做一个"内容差异检测"模块:当新文档的实体与旧文档重叠度较高时,抽出新旧摘要中的关键结论,提醒用户在旧文档中做一次校对更新。

第三个方向是多人协同。目前系统是单用户模式,所有文档共享一个向量空间。如果后续让一个团队使用,就需要加入权限控制、命名空间隔离、以及共享知识库之间的互相引用。这部分工作量大,但思路是清楚的:把当前page表加上ownerteam_id字段,向量检索时多一个scope过滤条件就行。

另外还想提一个使用层面的想法:不要只把它当"知识库",也可以把它当"项目复盘库"。我每完成一个小项目,就把项目相关的零散笔记、聊天记录、数据结果全丢进去,过几天再问一问"这个项目有哪些风险被我忽略了"。LLM 给出的回答有时会从意想不到的角度串起线索——这个用法反而成了我目前最喜欢的功能。

8. 写在最后的一个小技巧

最后贡献一条我踩过不少坑才总结出来的技巧:无论你的模型和检索参数调得多好,都不要省略"原文存档"这层。llm_wiki 在存储向量块时,永远会同时保存这个块在原始文档中的起始位置、结束位置和原始 Markdown 源码。导出引用时,系统给出的不是"模型生成的一句话",而是"原文里真实存在的那一段"。这一点在认真做知识管理的人眼里,比任何花哨的 AI 功能都重要——因为可验证性才是知识库长期可信的基石

如果你也想搭一个类似的系统,我的建议是别急着模仿我整套设计,先从"把文档导入后让模型自动打标签和做摘要"这个最小功能开始,跑通一条最简单链路之后再逐步加上向量检索、双通道召回、引用验证这些进阶模块。每一步增加的复杂度都应该以你实际使用中的痛点为依据,否则很容易掉进"为造轮子而造轮子"的坑里。

llm_wiki 目前的代码我还放在自己 Git 仓库里维护,等再稳定一些我会整理成开源项目。短期内我更想专注把"知识图谱可视化"和"陈旧页面检测"这两个功能做扎实。这几个方向上的探索,后续我会继续更新在这个系列里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询