在本地用 LLM 给自己造一个 Wiki 知识库,这事我琢磨了挺久,最近终于把 llm_wiki 这个项目跑通了,整体效果超出预期。说白了,llm_wiki 就是把大语言模型和 Wiki 这套知识管理方式结合起来,让你能把散落在各处的工作笔记、技术文档、会议记录、研究资料全部汇总到一个私人知识库里,然后用自然语言去提问、检索、甚至让模型帮你归纳总结。这项目非常适合那些日常信息量巨大、又不想把所有资料传到云端给第三方平台的技术人、研究者、产品经理和写作爱好者,尤其适合对数据隐私有要求的人。
我最初做这个项目,是因为发现自己本地攒了几千个 Markdown 文件,散落在不同的仓库、文件夹和笔记本里,想找一个之前写过的技术方案,光是回想存在哪就花半天。后来尝试了几款在线知识库工具,总觉得不太对味,数据上传到别人的服务器总归不踏实,而且通用搜索工具对文档内容的理解太浅,搜出来一堆不相关的片段。所以我就琢磨,能不能用现在已经很成熟的大语言模型,自己在本地搭一套能"读懂"文档内容的智能知识库。这就是 llm_wiki 的由来。下面我完整拆解一下这个项目的设计思路、核心实现,以及我在实操中踩过的坑。
1. 整体设计:为什么是本地 Wiki,而不是直接用在线工具
在动手写代码之前,我先花了不少时间想清楚整个项目要解决什么问题。这步挺关键,因为如果后面发现方向错了,返工成本会很大。
首先,我明确了这个项目的第一需求是"私有化"。我的工作笔记里有不少客户信息、市场分析和内部技术架构讨论,这些东西直接丢到网络上,我是真不放心。就算很多在线知识库工具提供加密传输和存储,但从安全边界来讲,数据离开本机的那一刻,你的控制权就已经让渡了一部分。所以 llm_wiki 的核心定位从一开始就定成:所有数据、索引、模型调用,全部在本地完成。
第二个需求是"语义检索"。传统的关键词搜索,比如 grep 或者大多数笔记软件的搜索功能,本质是字符串匹配。当我搜"图像分割方案的准确率优化"时,如果文档里写的全是"mIoU 提升""segmentation head 调参"这类表述,关键词匹配基本搜不到。而 LLM 能理解语义,我可以把文档切成块,用嵌入模型转成向量,存进本地向量数据库,查询时把问题也转成向量,做相似度检索。这是从"字面匹配"到"语义理解"的思维转变。
第三个需求是"交互友好"。我不想每次查点东西都开终端敲 SQL 或者跑 Python 脚本,我想要一个能自然对话的界面。我可能问它:"上个月我和老王讨论的那个模型蒸馏方案,关键结论是啥?"它应当能给出准确的段落和摘要,甚至告诉我这篇笔记在哪个文件里。
基于这三点,llm_wiki 的整体架构就清晰了,它由四个部分组成:
- 文档导入与解析层:把各种格式的本地文档(Markdown、TXT、PDF、Word 等)解析成纯文本。
- 文本切分与向量化层:把长文本按语义边界切成小段,然后用嵌入模型把每个段转成向量。
- 存储与索引层:向量库负责存向量和原文的映射,元数据库记录文件来源、更新时间等信息。
- 问答与展示层:本地 Web 界面,用户输入问题,系统调 LLM 生成回答,同时展示参考来源。
这个四层架构,每一项都有现成工具能选,但选型思路各有门道,下面详细说说。
2. 核心技术选型:从模型到向量库,每一层都值得较真
2.1 嵌入模型与本地推理引擎怎么选
llm_wiki 的嵌入(Embedding)模型选择是第一步,也是比较影响效果的一步。嵌入模型的作用是把一段文字变成一个高维向量,向量和向量之间的距离代表语义的相近程度,这是向量检索的基础。
我测试过三种路线。第一种是直接调用在线 Embedding API,比如 OpenAI 的 text-embedding-ada-002。效果确实不错,中文和英文混合场景下表现稳定,但问题是它需要联网,数据要发到第三方服务器,这违背了私有化初衷。第二种是本地用小模型跑嵌入,比如 BAAI/bge-small-zh-v1.5,参数量不大,纯 CPU 也能跑,但实际测下来对长文档的语义捕捉不够细腻。第三种,也是我最后选定的方案,是用更通用的 bge-m3 模型。这个模型支持中文和英文混合输入,并且输出向量维度是 1024 维,语义区分度比小模型明显强。在本地单张老款 1080Ti 显卡上跑,嵌入 1000 个文档块大约需要几分钟,属于可接受范围。
选嵌入模型时要特别注意一个指标叫 MTEB,这是业界常用的嵌入模型评测基准。但别只看总分,要看它在你所用语言和场景下的表现。例如我工作中中文文档居多,就优先看中文任务分数。实测下来 bge 系列的中文效果在开源模型里是第一梯队的。
2.2 生成模型选哪种,决定问答质量的上限
嵌入模型负责"找得准",生成模型负责"答得好"。llm_wiki 里的问答环节,我用一个本地部署的 ChatGLM3-6B 来做生成。
有些人可能会问,为什么不用更大的模型?比如 13B 甚至 70B 参数的模型?原因很简单,我的工作机只有 24G 显存(一张 3090),跑 6B 模型做推理比较流畅。如果强行上 13B,虽然也能跑,但并发处理能力下降明显,体验会卡顿。
这里有一个关键点值得注意:生成模型的大小和嵌入模型的大小并不是同一个概念。生成模型决定的是回答语言组织的质量和推理深度,嵌入模型决定的是检索内容的准确度。在实际使用中,我发现 6B 级别的本地模型做知识库问答完全够用,因为它不需要它无中生有,只需要它根据检索到的上下文片段做总结归纳,这个任务复杂度并不高。所以如果你显存不算大,不必追求超大模型。
2.3 向量数据库:Chroma 足够轻量,但也有限制
向量数据库的选择也比较关键。市面上主流的有 Milvus、Qdrant、Weaviate 和 Chroma。我最后选了 Chroma,原因是它的轻量模式太方便了。
Chroma 可以以嵌入式模式运行,不需要单独起一个数据库服务。在你的 Python 进程里直接调用 Chroma 的 API,数据存在本地目录下,一个文件夹搞定所有。对于个人知识库这种使用场景,这简直是最优解。如果项目规模到了一定程度,需要多人协作或者更复杂的权限管理,那时再迁移到 Milvus 这类重量级方案也不迟。
但轻量也意味着它有些限制。比如 Chroma 的并发性能比较一般,多人同时访问时可能会成为瓶颈。不过在单用户私有用例下,我目前没有遇到过明显的性能问题。还有一个要注意的地方,Chroma 的数据结构相对简单,如果你需要复杂过滤逻辑,比如按特定标签、日期范围过滤后再检索,就要注意你的元数据设计是否合理。我在实际项目里就给每个文档块加了不少元数据,包括文件路径、一级标题、更新时间、文档类型等等,这些字段在后续过滤查询中非常有用。
2.4 交互界面:Gradio 是快速搭建的首选
界面这一层,我选择了 Gradio。原因很简单:它能让我用最少的代码搭建一个可用的 Web 聊天界面,且支持 Markdown 渲染,对我这种以 Markdown 笔记为主的项目特别友好。
为什么不选 Streamlit?Streamlit 更适合做数据展示类的应用,交互流畅度上,Gradio 在聊天对话场景下体验更自然。而且 Gradio 对多轮对话上下文的支持也更加原生,我只需维护一个消息列表传给后端就行。
界面虽然看起来简单,但细节需要打磨。比如我在界面上展示了"参考来源"这个板块,这非常重要。当你问一个问题,系统除了给你回答,还应该告诉你它依据的是哪个文件哪一段。这样做有两个好处,一是你能去核对信息的真实性,二是能帮助你逐步信任这个系统。这个功能在 Gradio 里实现起来不算复杂,只需在返回回答时同时把命中的文档块和源文件路径传回前端即可。
3. 实操搭建过程:从零到可用的完整步骤
这一部分我尽量写得细致一些,几乎每一步都会说明"为什么这么做",方便你直接照着做,也能理解其中的原理。整个过程分为环境准备、数据导入、索引构建、问答实现四个环节。
3.1 环境准备:Python 虚拟环境与依赖安装
我建议使用 conda 或者 venv 创建一个干净的虚拟环境,避免和系统 Python 环境打架。我的环境是基于 Python 3.10 的,这是目前兼容性最好的版本。然后安装以下依赖:
pip install chromadb>=0.4.0 pip install sentence-transformers>=2.2.0 pip install gradio>=3.40.0 pip install pypdf pip install python-docx pip install transformers accelerate关于 transformers 和 accelerate,如果你的显卡驱动和 CUDA 环境配置正常,transformers 会自动调用 GPU 进行推理。如果没 GPU,CPU 模式也能跑,但速度会明显慢,建议量力而行。
3.2 文档导入与解析:Markdown、PDF、Word 全覆盖
我平时文档以 Markdown 为主,但同事发来的资料经常是 PDF 或者 Word,所以文档解析层必须覆盖这三种格式。这里有一个细节,解析 PDF 时如果用 pypdf 直接提取,中文文档很容易出现乱码或者文字顺序错乱的问题。我实测下来,解决方法是优先提取 PDF 中文本层的文字,如果发现提取结果几乎不可读,就改用 OCR 方式。由于 llm_wiki 是本地项目,我直接用 PaddleOCR 做兜底。
这一步的逻辑大概是:
def extract_text(file_path): if file_path.endswith(".md"): with open(file_path, "r", encoding="utf-8") as f: return f.read() elif file_path.endswith(".pdf"): return extract_pdf_text(file_path) # 内部先尝试文本提取,失败则走 OCR elif file_path.endswith(".docx"): doc = Document(file_path) return "\n".join([p.text for p in doc.paragraphs])这个解析层是整个系统的基础,文档都解析不对,后面的所有环节都白搭。我在调试中发现,很多 PDF 文件的元信息特别多,比如页眉页脚、页码等,这些内容如果不清理,后续切分出的文本块就会很脏,向量检索时也会引入噪声。因此我在解析后补了一个预处理步骤,用规则表达式去掉页眉页脚和重复的空行。
3.3 文本切分策略:按段落切,而不是按固定字数切
切分文本是知识库构造中一个容易被忽视但影响巨大的环节。刚开始用 LangChain 的默认切分器按固定 token 数切,后来发现效果很差。比如一个技术文档里讲"模型架构"的章节,中间可能有一张表的说明,但这部分和上下文关联极强,按固定 token 切很容易把完整语义破坏掉。
我最终的方案是:优先按 Markdown 标题结构切分,遇到没有标题的文档就按段落切,每个块最大控制在 500 个 token 左右。这个逻辑用 Python 实现也比较直接:
def split_text_by_headers(text): lines = text.split("\n") chunks = [] current_title = "untitled" current_content = [] for line in lines: if line.startswith("#"): if current_content: chunks.append({"title": current_title, "content": "\n".join(current_content)}) current_title = line.lstrip("#").strip() current_content = [] else: current_content.append(line) if current_content: chunks.append({"title": current_title, "content": "\n".join(current_content)}) return chunks注意,每个 chunk 里还保留了标题字段,这是很有用的。因为后续插入向量库时,我会把标题和正文拼接起来一起做嵌入,这样检索时如果命中该块,模型不仅能看到正文,还能理解它属于哪个主题章节。
3.4 向量化与入库:一条龙流程
切分好了,接下来把每块文本送入嵌入模型生成向量,然后把向量、原文、元数据一起存入 Chroma。这一步需要注意的是:每插入一条数据时,要检查一下是否已经存在同内容的向量,防止重复入库。我用的策略是为每个文档生成一个 MD5 哈希作为唯一 ID,这样如果同一篇文档被重复导入,会被自动跳过。
import chromadb from chromadb.utils import embedding_functions client = chromadb.PersistentClient(path="./llm_wiki_db") collection = client.get_or_create_collection( name="wiki_docs", embedding_function=embedding_functions.SentenceTransformerEmbeddingFunction( model_name="BAAI/bge-m3" ) ) for chunk in chunks: doc_id = hashlib.md5((file_path + chunk["title"] + chunk["content"]).encode()).hexdigest() existing = collection.get(ids=[doc_id]) if existing["ids"]: continue collection.add( ids=[doc_id], documents=[chunk["title"] + "\n" + chunk["content"]], metadatas=[{ "source": file_path, "title": chunk["title"], "updated_at": timestamp }] )这里有个关于 Chroma 版本的小坑,不同版本的 API 有一些差异。新版 Chroma 把 embedding_function 的加载方式做了调整,建议直接用官方文档的写法,并且固定版本号,避免后续升级引发不兼容问题。
3.5 问答链路:检索增强生成(RAG)的完整实现
出门在外,核心功能还是要能回答用户问题。问答链路我采用标准 RAG 模式,也就是:先检索,再让 LLM 基于检索结果作答。这个流程可以拆成四步:
第一步,把用户的问题也用嵌入模型转成向量。第二步,用这个向量去 Chroma 里做相似度检索,取 top_k 个最相关的文档块,k 我一般设为 5。第三步,把这 5 个块拼接成一段"参考资料",加上用户的原始问题,一起构造 Prompt。第四步,把完整的 Prompt 送入本地大模型 ChatGLM3-6B,生成回答。
Prompt 模板我用的是这样一个结构:
请基于以下参考资料回答问题。如果参考资料中没有相关信息,请直接回答"知识库中未找到相关内容",不要编造。 参考资料: [1] (文件路径: xxx.md, 标题: 模型压缩方案) (文档内容...) [2] (文件路径: xxx.md, 标题: 蒸馏实验记录) (文档内容...) 问题:模型蒸馏时,温度参数一般设为多少比较合适? 回答:这个模板看起来简单,但很有效。"不要编造"这两个字非常重要。大模型有个天然毛病叫"幻觉",它可能会编造一些看似合理但实际不存在的内容。通过指令约束加参考资料限制,可以大大降低幻觉率。实测下来,在 llm_wiki 上问基于知识库的问题,回答准确率在 85% 以上。
关于 k 值的设置,我建议不要过大。很多初学者以为参考资料越多越好,其实并非如此。k 值过大,大量不相关的文本块会被强行拼进 Prompt,不仅会稀释有效信息的浓度,还会增长模型的推理时间。k 值太小则可能漏掉关键内容。我测试过 3、5、8 三个档位,5 在准确率和速度上最均衡。
4. 常见问题与排查技巧:我在实际部署中踩过的坑
4.1 向量检索结果不准,问题可能出在切分上
如果你发现检索出来的内容总是"差不多但差点意思",大概率不是嵌入模型的问题,而是文本切分太粗或者太细。切得太粗,比如把整个章节作为一个向量,那么这段文字的语义重心会被大量篇幅淹没,检索精度就会下降。切得太细,比如一句话一个块,那检索出来的上下文不够完整,大模型无法理解背景。我建议的原则是:每个块尽量表达"一个完整的小主题",既能独立看懂,又保留了上下文关联。
另外,一个容易踩的坑是:嵌入时需要把标题和正文放一起。如果你的库只嵌入正文,检索时命中的块可能不知道该内容属于哪个主题,回答起来缺少归属感,准确率自然下降。
4.2 模型回答有幻觉,教你三招压制
幻觉问题在 llm_wiki 里我也遇到过,尤其是问一些模糊的问题时。例如我明明没有在笔记里写过具体某个实验的结论,模型却给出了一个看似合理的结果。我总结了三招压制幻觉的方法。
第一招是在 Prompt 里强调"只能依据参考资料",这招最管用。第二招是在返回界面上显著展示参考来源,让用户有信息核实路径。第三招是设置一个相似度阈值,如果检索出的最相关文本块得分低于某个阈值,就默认拒绝回答,告知用户未找到相关内容。阈值需要你根据自己的文档情况动态调整,我这边设置的是 0.32,可以参考。
4.3 导入大量文档时内存爆掉,分批处理是关键
第一次导入一万多个文档块时,程序直接吃掉了我 32G 内存,然后被系统 OOM 杀掉。原因是嵌入模型批量处理时,一次性把太多文本放进了显存和内存。解决办法是分批处理,每批只处理 32 个文本块,处理完立即释放变量。这个改动看起来不起眼,但极大降低了内存峰值。
batch_size = 32 for i in range(0, len(chunks), batch_size): batch = chunks[i:i+batch_size] collection.add(documents=batch) time.sleep(0.5)加入一个极短的 sleep 是为了避免 CPU 和 GPU 之间数据搬运过频导致 IO 阻塞,实测下来整体吞吐量没有下降多少,但稳定性提升明显。
4.4 界面回答太慢:量化模型与流式输出双管齐下
本地模型回答速度是一个绕不开的话题。刚开始我把完整回答生成完之后才一次性显示在界面上,遇到长问题的推理时间有十几秒,非常煎熬。后来我做了两个改动:一是用 GPTQ 量化版本的 ChatGLM3-6B,推理速度提升约 30%,显存占用也从 14G 降到 10G 左右;二是在 Gradio 里开启流式输出,模型每生成一个 token 就实时显示,这样的体验提升是巨大的。用户在界面上看到回答"一行一行"出来,等待感完全不一样。
4.5 更新文档后索引不同步:建立版本校验机制
这个坑是我后来才发现的。当我在本地修改了一篇笔记,重新导入时,由于 MD5 是文件路径加标题加正文生成的,正文一变,ID 就变,旧的那份数据不会被自动清理,库就出现了重复内容。解决方式是:文档导入时先按 source 字段删除所有旧记录,再重新插入新内容。删除用 Chroma 的 collection.delete(where={"source": file_path}) 即可实现。
4.6 中文路径导致的兼容性问题
最后提醒一个非常实际的问题:尽量别让你的知识库文件路径包含中文和空格。这不是说系统不支持中文路径,而是在实际开发中,很多库对中文路径的处理存在潜在问题,特别是 PDF 解析和一些背后的编码处理。万一遇到莫名其妙的读取失败,可以先检查一下路径是不是存在特殊字符。
5. 扩展方向与进阶玩法
llm_wiki 目前的版本已经解决了我个人知识管理的大部分痛点,但它的潜力远不止于此。我在实际使用中已经在规划几个扩展方向。
第一个方向是多模态支持。现在的知识库还是纯文本,但我的资料里有大量图片和截图,比如架构图、实验结果图、白板照片。下一步我打算接入本地视觉模型,对图片进行描述并生成文本摘要,再和图片一起入库。这样用户就能通过文字搜索到图片内容了。
第二个方向是知识图谱融合。纯向量检索有个弱点,就是它无法理解实体之间的逻辑关系。比如"A 方案比 B 方案好"这种判断性结论,在向量空间里能被检索到,但如果你问"目前有哪些方案的对比结论",系统无法主动跨文档把分散的对比信息汇总成图。如果引入知识图谱,用 LLM 自动抽取实体和关系,就能实现更智能的多跳问答。
第三个方向是自动化定期更新。我设想让 llm_wiki 变成一个常驻后台服务,监视我指定的几个文件夹,一旦发现文件变更,就自动增量更新索引,不需要手动触发。这个本质上可以做成一个文件系统监听器加定时任务的组合。实际开发起来也不难,只需要把导入流程封装成函数,用 watchdog 库监控目录变化即可。
第四个方向是共享协作。既然是 Wiki,理论上支持多人协作是自然延伸。后续可以考虑加入用户权限体系和共享折叠,让团队内部也能用起来。不过这需要把向量库切换到更重量级的方案,同时引入 Web 服务层做鉴权,工程量会大不少,适合当作 v2.0 的规划。
写在最后的一点个人体会
llm_wiki 这个项目做下来,我最深的一点体会是:大模型时代,真正值钱的不只是模型本身,怎么组织和利用好你手头已有的信息,是一件更需要花心思的事。很多人以为搭一个本地知识库就是把文档喂给模型就完事了,但实际做下来你会发现,文本切分、索引更新、检索阈值、Prompt 设计……每一个细节都会影响最终的使用效果。这些经验不是看几篇教程就能全了解的,必须自己动手踩坑才能形成感觉。
如果你也想搭一个自己的 llm_wiki,我建议不要一开始就追求大而全的功能。先从最核心的链路开始,哪怕只是把你平时的 Markdown 笔记导入进去,跑通"本地文档 — 向量化 — 检索问答"这条线,你就能切实感受到它和传统搜索工具的巨大区别。之后再逐步加入 PDF、Word、图片,再慢慢调优效果。一步一步来,最后你收获的不只是一个工具,还有整套关于语义检索和 RAG 架构的实战理解。