首页 / 资讯中心 / 文章详情

用Markdown和向量索引搭建本地LLM Wiki问答知识库

用Markdown和向量索引搭建本地LLM Wiki问答知识库 ★ FEATURED ARTICLE
前阵子我在整理笔记的时候又被自己气到了一次明明三个月前写过一段关于“增量索引怎么做”的总结标签也打了目录也对可就是翻不出来。后来我干脆熬了一个周末把本地几百篇 Markdown 笔记接上了一套检索和问答流程效果出乎意料。这件事的起点是看到一位在 AI 圈子里很有分量的研究者分享了一个叫 LLM Wiki 的原型思路。它听起来并不酷笔记还是那些本地存放的 Markdown 文件不引入复杂的数据库也不需要手动维护知识图谱只是让大语言模型介入“检索”和“回答”这两个环节。但恰恰是这种简单组合让我重新审视了过去十来年知识管理这件事到底哪里出了问题。下面不讲某个具体产品只聊我复刻这套方案后得到的真实经验从传统分类为什么必然失效到 Markdown 加向量索引的设计逻辑再到一步步落地一个能跑起来的本地问答知识库。如果你和我一样收藏了三千篇资料、真正用过的不到三十条又正在纠结要不要把手头的笔记工具全部推翻那这篇文章应该能给你一个更清醒的判断。1. 从文件夹分类到语义检索知识管理真正缺的是什么1.1 传统知识管理的三个死穴我见过太多人包括我自己在整理笔记上投入了大量精力最后却被“找东西”这一步打败。传统知识管理工具通常靠三层结构来组织内容目录分类、标签体系、双链关系。听起来很科学但实际用起来它们各自都有明显的问题。第一层是分类悖论。我们整理目录时很爽因为归档动作让人产生一种“我已经掌握了这些知识”的错觉。可等到真正要检索的时候大脑根本不会按当时的分类思路去回忆。三个月前你把一篇关于“检索增强生成”的笔记放进了“AI/技术/生成式模型”目录三个月后你只记得那篇文章讨论过“怎么让模型记住项目背景”谁会去翻那个目录分类是静态的而人的记忆和需求是动态的静态分类永远追不上动态想法这就是分类悖论。第二层是存储与使用错位。记笔记时你并不知道未来会在什么场景、用什么关键词重新找到它。于是写的时候是“顺着逻辑写”找的时候是“顺着碎片化需求找”两者常常对不上。传统工具把这个难题扔回给用户要求你预判未来的检索意图这本质上是在强迫每个人成为预言家。第三层是维护成本与收益失衡。标签体系用上几个月就会开始混乱“深度学习”和“神经网络”算不算同一个标签要不要加一个“AI”作为总览这种维护工作不会产生任何新的内容价值但会持续消耗你的心力。时间一长整理这件事就变成了单纯的负担最后整个知识库被放弃维护沦为数字废纸堆。1.2 为什么双链没有终结这个问题后来双链笔记火过一阵很多人以为“建立连接”是知识管理问题的解药。双链确实解决了一部分问题它可以表达概念之间的关系读笔记时可以顺着链接跳跃到相关上下文。但它依然没有解决“从想法到位置”的路径问题。双链需要你主动构建连接这个动作的成本相当高。我观察过周围朋友的使用情况最常见的结局是前两周热情满满地给每条笔记都加上几个链接后面就坚持不下去了因为写笔记本身已经够累再要求“此刻就想清楚这个概念未来会和哪些内容发生关系”几乎等于要求你一边写作一边算命。双链关系是离散的不会主动生成如果笔记之间没有预先连接那些散落的知识依旧是孤岛。所以问题的本质不是“笔记之间有没有链接”而是“我们从语义上有没有办法跨越这些孤岛”。这需要的不是一个手动维护的关系网而是一种自动的、基于内容相似程度的关联能力。而这恰恰是向量检索能做的事。1.3 范式变化的一句话版本传统的知识管理是我记住内容放在哪然后按路径去取。LLM Wiki 的思路是我不需要记住内容放在哪只需要描述我正在回忆的东西让模型帮我在大量笔记里找到它。这个转变看起来只是把“搜索框”换成了“问答框”但背后涉及整个知识管理范式的切换——从“整理”为中心变成“提问”为中心。整理不再是目的让内容在需要时能够被语义联想出来才是目的。理解了这一点再看接下来的数据组织方式和工具选型就顺理成章了。2. LLM Wiki 的核心设计逻辑Markdown、向量索引、生成式问答2.1 为什么内容层必须选 Markdown很多笔记工具都有私有格式看起来保存的是文档实际上是一份加密的、只能被自家软件读取的数据库。我不喜欢这种把数字资产绑架在某个工具里的做法。LLM Wiki 这类方案最聪明的底层选择就是让 Markdown 成为唯一的“事实来源”。Markdown 是纯文本没有仔细读过的人可能低估它的意义纯文本意味着可以用任何工具打开可以在终端直接 grep可以用脚本批量处理可以丢进版本管理工具里记录每一次改动。就算有一天所有现成的笔记软件都倒闭了你的知识资产依然是一堆可读的 .md 文件这点就已经赢了。更关键的是Markdown 的写作成本极低。给标题加一个#用-列项目要点几乎不打断写作流。它还天然支持分段和层级这让后面的“切块”步骤有了语义基础。我的笔记格式很简单每个文件顶部加一段 front matter写标题、标签、日期正文保持短句、短段落。之所以要保持短段落不是为了好看而是因为后续要按块分割块太大会稀释语义块太小又失去上下文。Markdown 的标题层级正好是天然的切分边界这个细节在后面第三章会展开说。2.2 向量索引是怎么替你做分类的向量索引的原理不复杂嵌入模型会把一段文本转换成一串固定长度的数字这段数字就代表该文本在高维语义空间里的位置。语义相近的文本向量距离近语义不同的文本距离远。这样就不需要显式定义标签两个内容哪怕一个用“苹果”另一个用“iPhone”只要它们在语料中的语义接近向量就会靠拢。我第一次跑通这个流程时最大的感触是“终于不用预判未来要用什么关键词了”。以前找笔记要猜关键词猜错了只能换一组词再试。向量检索是你描述一段模糊的记忆“我记得有篇笔记讨论过模型怎么从日志里提取失败原因”这个查询语句不用和原文包含任何相同的关键词照样能把相关片段捞出来。这背后其实是在做一件事让检索接口从“关键词匹配”升级到“语义匹配”。当然向量不是万能的它对抽象概念、口语化描述、规范化缩写会有误差。但作为知识管理的检索第一层它已经比目录和标签高了不止一个维度。配套做一层关键词兜底就能把召回率拉到很可用的程度。2.3 生成式问答为什么比关键词搜索更像回忆向量检索返回的是一批相关片段这还只是“搜索”。LLM Wiki 真正让人上头的部分是最后一步把检索到的片段交给大语言模型让模型基于这些片段组织回答。搜索返回十条结果你还是需要一条条点开、扫读、总结。问答则直接把最相关的内容变成了一个带着来源的答案“你 1 月在项目复盘笔记里写过当时的主要瓶颈是数据标注耗时方案是…”。这种体验更接近“回忆”而不是“查找”。模型等价于替你做了一遍粗整合你只需要复核它给出的答案是否可靠。这里要特别强调一个工程细节问答时必须把检索结果拼进提示词并限定模型“只依据提供的笔记内容回答、不能编造、不确定就说不确定”。这样做的好处是明确把 LLM 当成“阅读助手”而不是“知识源头”。它负责组织语言、提炼重点但信息来源始终是你的笔记。这样即使模型能力不强也不会跑偏到幻觉里太多。3. 手撸一套 LLM Wiki从零搭一个本地问答知识库3.1 数据层先把笔记整理成可索引的 Markdown建议在本地建一个专用笔记目录比如~/notes里面按主题分子目录。子目录不要太深两层就够了因为真正的检索依赖向量而不是目录。目录存在的意义是让人浏览时有个大概框架而不是让查找依赖它。我建议每个文件都带 front matter结构如下--- title: 项目复盘推荐服务灰度环境 tags: [推荐, 灰度, 复盘] date: 2025-01-07 --- # 背景 这一段控制在三到五行内写清楚为什么要做这件事。 # 结论 一句话提炼最终结论能用一句话讲清楚就别写三段。 # 问题 - 问题一数据标注耗时 - 问题二日志缺少请求级别ID这样做的原因是后面切块的代码会优先按二级标题#和三级标题##来切front matter 我可以直接跨过有清晰标题的块检索命中后展示来源时也更友好。另外别把每条笔记写得太长。一篇笔记超过两千字时我会主动拆成几篇小笔记每篇聚焦一个问题。短笔记的向量更集中检索精准度明显高于长混合文档。这个习惯在还没有这套工具的时候就值得养成接上向量索引后会体现得更加明显。3.2 索引层切块、嵌入、增量更新索引层的职责是扫描目录里的所有 .md 文件按标题切块给每一块生成向量存进本地数据库。核心代码如下我用的是 Python 标准库加一个本地跑着的嵌入服务不引入任何重组件import os import re import json import math import sqlite3 import urllib.request from pathlib import Path NOTES_DIR Path(~/notes).expanduser() DB_PATH Path(~/notes/.wiki.db).expanduser() EMBED_URL http://127.0.0.1:11435/v1/embeddings # 本地嵌入服务的地址 def split_markdown(text, max_chars500): 按标题切块标题作为块的上下文保留。 lines text.splitlines() chunks [] current_title buffer [] def flush(): if buffer: content \n.join(buffer).strip() if content: chunks.append({title: current_title, text: content}) for line in lines: if re.match(r^#{1,3}\s, line): flush() buffer [] current_title line.strip() buffer.append(line) if len(\n.join(buffer)) max_chars: flush() buffer [] flush() return chunkssplit_markdown的逻辑很简单每次碰到 Markdown 标题行就把上一段写入块列表同时块长度超过max_chars时也会强制落块防止某个标题下内容太长导致向量被稀释。一般 300 到 600 字符比较合适后面我会专门说尺寸对答案质量的影响。切完块之后每块文本送去嵌入服务拿到向量存进 SQLite。为了支持增量更新我用文件修改时间mtime做判断内容没变的文件直接跳过def embed_text(text): data json.dumps({text: text}).encode(utf-8) req urllib.request.Request( EMBED_URL, datadata, headers{Content-Type: application/json}, ) with urllib.request.urlopen(req) as resp: return json.loads(resp.read())[embedding] def init_db(): conn sqlite3.connect(DB_PATH) conn.execute( CREATE TABLE IF NOT EXISTS files ( path TEXT PRIMARY KEY, mtime REAL ) ) conn.execute( CREATE TABLE IF NOT EXISTS chunks ( id TEXT PRIMARY KEY, path TEXT, title TEXT, text TEXT, embedding TEXT ) ) return conn def index_note(path, conn): stat path.stat() row conn.execute( SELECT mtime FROM files WHERE path ?, (str(path),) ).fetchone() if row and row[0] stat.st_mtime: return # 文件没有变化跳过 text path.read_text(encodingutf-8) chunks split_markdown(text) conn.execute(DELETE FROM chunks WHERE path ?, (str(path),)) for i, chunk in enumerate(chunks): chunk_id f{path}:{i} embedding embed_text(chunk[title] \n chunk[text]) conn.execute( INSERT OR REPLACE INTO chunks VALUES (?, ?, ?, ?, ?), ( chunk_id, str(path), chunk[title], chunk[text], json.dumps(embedding), ), ) conn.execute( INSERT OR REPLACE INTO files (path, mtime) VALUES (?, ?), (str(path), stat.st_mtime), ) conn.commit()索引时把标题和正文拼在一起再做嵌入等于给这块内容加了一个“小摘要”检索“项目复盘”这类词时更容易命中实测效果比只嵌正文好不少。3.3 应用层一个不到一百行的问答服务索引建好之后问答阶段的流程是给用户问题做嵌入用余弦相似度在所有块里找最接近的若干块拼进提示词再发给本地对话模型。代码核心就两块def cos_sim(a, b): dot sum(x * y for x, y in zip(a, b)) na math.sqrt(sum(x * x for x in a)) nb math.sqrt(sum(x * x for x in b)) return dot / (na * nb) if na and nb else 0.0 def search(conn, query, top_k5): vec embed_text(query) rows conn.execute( SELECT id, path, title, text, embedding FROM chunks ).fetchall() scored [] for row in rows: emb json.loads(row[4]) score cos_sim(vec, emb) scored.append((score, row[1], row[2], row[3])) scored.sort(keylambda x: x[0], reverseTrue) return scored[:top_k]检索部分返回的是(分数, 文件路径, 标题, 文本)的列表。注意这里为了演示直接全表扫描每查询一次做几次余弦计算对几千块规模完全可行如果笔记到了几万块就需要把向量存储换成支持 ANN 索引的嵌入式向量方案后面再说。问答部分把检索结果拼进一个带约束的提示词def ask(conn, question): results search(conn, question) context \n\n.join( f[来源{path} | {title}]\n{text} for _, path, title, text in results ) prompt f你是一个只依据我的笔记回答问题的助手。 请仅使用下面笔记内容作答不要编造。 如果笔记里没有相关信息请直接说“笔记里没有提到”。 回答末尾列出相关笔记文件名。 笔记 {context} /笔记 问题{question} reply chat_completion(prompt) return reply def chat_completion(prompt): data json.dumps({prompt: prompt}).encode(utf-8) req urllib.request.Request( http://127.0.0.1:11436/v1/chat, datadata, headers{Content-Type: application/json}, ) with urllib.request.urlopen(req) as resp: return json.loads(resp.read())[reply]主流程自然就是三行遍历 Markdown 文件增量索引输入问题输出答案。我把它封装成命令行工具日常使用直接在终端敲wiki ask 我上个月遇到过哪些部署问题立刻就能得到一份带着来源文件的回答。提示第一版不追求完美先跑通单机流程。所有组件都用本地服务让笔记一个字节都不出本机这是隐私层面最基本的安全感也是这套方案相对于云端知识库最大的加分项。4. 实测之后我学到的几件事分块、隐私与算力4.1 分块大小直接影响回答质量第一次全量索引时我怕丢上下文把max_chars设成了 2000结果很多检索结果都不太对。原因很简单一块 1500 字的文本里往往有两三个主题嵌入向量会把它们“平均”成一个四不像查询时既跟这个主题沾边又跟那个主题沾边整块内容都变得模糊。后来我降到 500 字符同时保留块所在的小标题召回质量立刻上了一个台阶。核心规律是单块聚焦一个子主题。不能只靠长度硬切因为硬切很容易在段落中间断句。所以我的切块逻辑是“标题优先、超长兜底”优先让 Markdown 标题做自然边界只有单个标题下内容特别长时才会强制截断。还有一个很容易踩的坑front matter 里的字段如果混进了正文嵌入模型会把“日期”和“标签”也当作语义的一部分。我的代码里没有单独剥离 front matter实际落地时建议先把它丢弃只保留正文部分。4.2 本地推理的意义笔记不出门很多人把云端笔记同步当作默认功能但知识管理尤其是个人的、零散的、很多时候不适合交给云端服务的内容。这套 LLM Wiki 最让我放心的点在于从嵌入到对话全部在本地完成。笔记本断网时终端里照样能问问题这不仅是隐私也是可用性。有一次我在火车上想查一份之前记录的离线排查脚本手边没有任何网络。我打开手机热点都不行但还是打开终端问了一句“那个应急脚本的操作步骤是什么”本地模型慢吞吞地回了三步操作。那一刻我确定了一件事知识管理工具的命脉不是功能多而是无论何时都能拿到自己的东西。当然本地小模型的回答质量确实不如云端大模型偶尔会出现语句不太通顺、归纳不够精准的情况。我的妥协方案是把“回答质量”和“隐私”分开个人库默认本地处理需要深度分析时再把抽取出的匿名片段拷贝到别处做二次加工。笔记本体始终不动。4.3 普通笔记本能不能跑答案是能但要看规模。我目前索引了两千八百多个块嵌入和问答用的都是本地服务CPU 推理嵌入阶段大约花了几分钟完成全量构建增量更新基本秒级完成。问答时最影响体验的是生成速度量化后的小模型在 CPU 上一个 200 字左右的回答大概要 5 到 10 秒可以接受但不跟手。如果觉得慢有几个不用换硬件的优化路线。把检索从全表扫描换成带向量索引的存储方案让相似度搜索这一步提速。用小一点的嵌入模型做第一版块向量维度降到 128 或 256检索速度会快很多。问答时用流式输出至少用户体验上没那么煎熬。我对算力的结论是普通笔记本跑个人知识库绝对没问题别被“本地大模型”四个字吓退。真正的瓶颈不在硬件而在笔记本身是否规范、块切分是否合理。5. 关于“改变知识管理”的两个诚实判断5.1 它解决的是“取”的问题不是“存”的问题复刻完这套方案后我对“可能改变知识管理”这件事有了更清醒的判断。它改变的不是“写作”和“存储”那一半而是“查找”和“回忆”那一半。笔记还是得你自己写观点还是得你自己想模型只是把你写过的内容在需要时以更接近记忆的方式还给你。所以我并不认同“有了这个就不需要记笔记”的说法。恰恰相反这套工具的价值上限取决于笔记质量的下限。笔记里都是碎片废话检索出来的也是碎片废话模型再厉害也救不回来。知识管理这件事前半段依然是笨功夫阅读、思考、用自己的话写下来。LLM Wiki 真正改变的是后半段让这些写下来的东西不再堆积成无人问津的数字废墟。5.2 和现有笔记工作流的嫁接方法如果你已经在用某款双链笔记工具不需要立刻搬家。大多数这类工具都支持导出 Markdown或者至少能用脚本把正文批量转成 .md 文件。我的建议是先在本地新建一个专用笔记目录接下来的新笔记直接写 Markdown老笔记用周末时间按主题慢慢搬不追求一次到位。搬的时候不要整篇复制粘贴重点做两件事一是把长文按子主题拆成多篇二是在开头写三五行“背景”和“结论”。这两件事做下来向量索引的质量会显著高于直接搬运全文。另一个重要提醒AI 生成的问答结果不要写回原笔记否则长期下来会把模型自己的输出和你的原始想法混在一起污染知识来源。我单独开了一个10_AI对话记录目录专门存放问答产生的“二次思考”跟原始笔记严格隔离。5.3 下一步可能发生的变化这套方案还处于“个人知识库原型”阶段但它的后续方向已经可以看见了。第一个可能的变化是知识图谱自动生成既然每块笔记都有了向量那就可以顺带做实体提取把笔记中出现的人名、项目名、概念名抽出来建索引然后按共现关系自动连线。第二个变化是定期回顾每周把本周新增笔记丢给模型让它按照“本周关注了什么、有哪些尚未解决的问题”生成一份回顾清单。第三个变化是多智能体协作检索、总结、关联每个环节都由不同的模型分工完成整套知识管理系统会从“被动回答”走向“主动整理”。这些方向都还只是开始但至少证明了一件事把大语言模型塞进知识管理的管道里不是做一个花哨的“AI 问答小助手”而是在重构我们和旧日记忆的交互方式。我个人的体会是工具永远不能替我们思考但它可以让思考的痕迹在几个月、甚至几年后依然被轻易唤醒。光是这一点就足以让我把这套本地方案长期用下去了。
阅读完成 · 觉得有帮助?
咨询建站