我先说个真实场景。这几年做技术文档沉淀,我陆续攒了上千份 Markdown、PDF、还有各种网页剪藏。工具从笔记软件换到 Git 仓库再到自建 Wiki,绕了一大圈之后发现:知识越存越多,能用起来的反而越来越少。每次想查一个细节,脑子里记得“肯定写过”,但 Ctrl+F 在几十个文档里翻来翻去,结果往往是无功而返。传统 Wiki 强在组织和分类,弱在语义理解——它像图书馆,你得先知道书名才能借书;而我真正想要的是一个能“听懂人话”的图书管理员。后来我干脆自己动手,用 LLM 技术做了一套个人知识库系统,项目就叫llm_wiki。这篇文章就围绕这个项目,把从零搭建到实战调优的完整过程复盘一遍,帮你们少走几个月的弯路。
先说结论:这个项目本质上是用 RAG(检索增强生成)思路,把本地文档变成可检索、可问答的“活 Wiki”。它不依赖复杂后端,能跑在个人电脑上,文档格式随意,Markdown / PDF / TXT 都能处理,查询时也不再靠关键词死磕,而是通过语义理解直接把答案放在你面前。如果你也是那种手头文档多、检索靠运气、整理靠毅力的知识工作者,这个项目值得你花一个周末复现一遍。
1. 项目整体设计与思路拆解
1.1 为什么传统 Wiki 不够用了,LLM 到底解决什么问题
传统 Wiki 的核心动作是“人工整理 + 按目录浏览 + 关键词检索”。这套模式在内容少、更新慢、结构稳定的时候没问题,但一旦文档量上来,问题就暴露得很明显:
首先,目录结构是写文档的人定的,查文档的人未必能按同样逻辑找到内容。比如我习惯把部署命令放在“运维笔记”里,同事找的时候可能先去翻“项目说明”。这种认知错位靠整理很难完全解决。其次,关键词检索只认字面匹配,同义词、近义改写、口语化描述统统失效。你搜“超时设置”,很可能漏掉标题是“timeout 调优”但内容完全相关的文档。最后,整个知识库的更新是无人值守的,旧文档过时、废弃、互相矛盾,靠人肉巡检根本做不过来。
LLM 解决这事的思路不是替代整理,而是把“语义理解”这一层加进去。你不需要告诉系统去哪查,只需要说人话——比如“上次我们线上出现过一口 SQL 把数据库打挂,后来怎么解决的?”系统会先把这句话转成向量,再对整个知识库做语义相似度检索,找到最相关的段落,然后把检索结果交给大模型组织成自然语言答案。整个过程里,文档还是那些文档,但知识库的交互方式完全变了。
1.2 整体架构:RAG 是骨架,不是全部
llm_wiki的整体架构不复杂,核心分成四层:
- 数据层:本地文件夹,存放 Markdown、PDF、TXT 等源文档,我用 git 做版本管理,历史回溯不丢。
- 索引层:对文档做解析、清洗、分段,用嵌入模型把每个段落转成向量,写入向量数据库。
- 存储层:向量数据库负责持久化向量数据和原文片段,查询时按相似度召回最相关段落。
- 生成层:把召回内容、用户问题、系统提示词拼成 prompt,调用大模型生成带引用来源的答案。
这个架构的选型逻辑很直白:不采用“把所有文档一股脑塞进模型上下文”的方案,虽然那种方式实现更简单,但大模型上下文窗口有限,几千页文档根本塞不下,而且成本高、响应慢。RAG 的思路是“先窄后宽”,先用向量检索缩小范围,再把最相关的片段喂给模型,既控制了成本,也保证了答案的可溯源。
我当时对比了三种主流方案:
| 方案 | 优点 | 缺点 | 我为什么没选 |
|---|---|---|---|
| 全量填充上下文 | 实现简单,不需要向量库 | 受上下文限制,成本高,无法扩展大量文档 | 文档量稍一增长就崩 |
| 微调专有模型 | 能学到领域专属表达 | 标注成本高,重训成本高,知识更新慢 | 文档每天都在更新,微调跟不上节奏 |
| RAG 检索增强 | 知识实时更新,答案可溯源,开销可控 | 检索质量会直接影响答案质量 | 它就是llm_wiki的骨架方案 |
一句话总结:如果你的文档量已经多到人工翻不动,又对答案的时效性、可溯源有要求,RAG 是正确的起点。llm_wiki就是按这条路落地的。
2. 核心组件解析:索引、检索与生成的三角关系
2.1 文档解析和文本切分是质量的第一道闸门
很多人做 RAG 项目,一上来就调模型、调 prompt,结果效果始终不理想。我踩了几次坑之后才意识到:检索质量的上限,在文本切分这一步就决定了。文档切得不好,后面再强的嵌入模型都救不回来。
文本切分的核心矛盾是“语义完整性”和“片段大小”之间的取舍。切得太粗,比如整个文档切成一块,向量化之后语义太泛,检索时拉回来的是一堆无关内容;切得太细,比如按每句话切,语义碎片化严重,无法表达一个完整观点,同样检索不准。
我最后采用的方法是“按标题层级做结构切分 + 按固定窗口做二次补充”:
- 先解析 Markdown 的
#、##、###结构,把文档按目录层级切成“章节级”片段。 - 对 PDF 这类结构化弱的格式,按段落边界 + 固定长度(默认 500 字)切分,并保留 50 字重叠,避免跨段语义被拦腰截断。
重叠(overlap)很多人会忽略,但这个小细节对检索质量影响很大。比如一个技术要点恰恰落在上一段的结尾和下一段的开头,如果没有重叠,检索时两边都召回不完整,答案就容易断章取义。实测下来 10%~20% 的重叠比例比较稳妥。
2.2 嵌入模型选型:决定检索“懂不懂人话”的关键
嵌入模型是 RAG 项目的重头戏。它负责把切好的文本转成稠密向量,向量之间的空间距离就代表语义相似度。选什么样的嵌入模型,直接决定你的知识库“听不听得懂中文口语表达”。
项目初期我是直接用通用的多语言 embedding API,后来本地化部署时换成了开源模型。对比之后我发现几个关键点:
- 中文场景下,通用英文模型效果明显偏弱,换用针对中文优化的模型后检索准确率提升非常直观。
- 模型的向量维度影响存储和检索速度,不是选越高的越好,够用就行。目前我的实践中 768 维左右是一个平衡点。
- 如果对数据私密性有要求,本地部署开源嵌入模型会更放心,数据不出机器。
我当时用的模型支持把文本转成 768 维向量,对中英文混合文本表现都不错。需要提醒的是,嵌入模型不要频繁更换。因为向量库里的所有向量都用同一个模型生成,一旦换模型,旧向量和新向量语义空间不统一,检索效果会大幅下降,只能重建索引。
2.3 向量数据库:选型不是越重越好
向量数据库负责存储向量并执行相似度检索。开源生态里可选方案很多,我按从“轻量到重量”筛选了一遍:
| 方案 | 适合场景 | 部署成本 | 备注 |
|---|---|---|---|
| Chroma | 个人项目、快速原型 | 极低,Python 内嵌 | 本地持久化方便,不需要单独服务 |
| FAISS | 对性能有要求的研究/生产 | 较低,纯本地库 | Meta 出品,检索速度极快 |
| Qdrant | 小团队、生产级功能 | 中,Docker 部署 | 支持过滤、分布式能力 |
| Milvus | 企业级大规模集群 | 高,组件多 | 功能全面但运维重 |
llm_wiki初期选的是 Chroma,理由很简单:个人知识库场景,数据量在几万条以内,Chroma 完全够用,而且不用额外起服务,直接嵌入 Python 进程。等将来文档量涨到百万级,再迁移到 Qdrant 也不迟。切忌一上来就搭重型集群,低成本跑通闭环才是个人项目的正道。
查询时,向量数据库会返回相似度最高的 Top-K 段落。这里我设置 K=5,也就是每次检索拉回最相关的 5 个片段。拉多了答案冗余,拉少了信息不完整,5 是一个比较稳的平衡点。
3. 实操过程与核心环节实现
3.1 环境准备:一键拉起基础依赖
整个项目用 Python 实现,虚拟环境建议用uv或venv,避免污染系统环境。需要装的核心依赖不多:
pip install chromadb sentence-transformers langchain langchain-community pypdf如果走在线 API 路线,还需要把openai或dashscope的 SDK 装上。装好之后第一步,我先写好全局配置文件,把所有路径、模型、参数集中管理,免得后面到处改:
# config.py import os DOC_DIR = "./docs" # 源文档目录 DB_DIR = "./db" # 向量库持久化目录 EMBED_MODEL = "BAAI/bge-small-zh-v1.5" # 中文嵌入模型 LLM_MODEL = "qwen-plus" # 可替换成任意兼容模型 TOP_K = 5 # 召回片段数 CHUNK_SIZE = 500 # 切分长度 CHUNK_OVERLAP = 50 # 切分重叠3.2 文档解析与切分:从零散文件到规整片段
文档解析是第一个容易踩坑的环节。Markdown 格式好处理,直接按文本读入即可,但 PDF 的排版是“每行一个对象”,需要额外提取。我封装了一个load_documents函数,负责遍历目录、识别格式、返回原始文本:
def load_documents(doc_dir): docs = [] for filename in os.listdir(doc_dir): path = os.path.join(doc_dir, filename) if filename.endswith(".md") or filename.endswith(".txt"): with open(path, "r", encoding="utf-8") as f: docs.append({"source": filename, "text": f.read()}) elif filename.endswith(".pdf"): from pypdf import PdfReader reader = PdfReader(path) text = "\n".join(page.extract_text() for page in reader.pages) docs.append({"source": filename, "text": text}) return docs解析完成后进入切分环节。我使用 LangChain 的MarkdownHeaderTextSplitter处理带结构的文档,再用RecursiveCharacterTextSplitter做兜底。实测下来,单独用固定窗口切分对 Markdown 不友好,会把列表和代码块截断,所以“结构优先 + 长度兜底”的组合最稳:
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter headers_to_split_on = [("#", "H1"), ("##", "H2"), ("###", "H3")] md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) rec_splitter = RecursiveCharacterTextSplitter( chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", " ", ""] ) all_chunks = [] for doc in load_documents(DOC_DIR): if doc["source"].endswith(".md"): docs = md_splitter.split_text(doc["text"]) for d in docs: chunks = rec_splitter.split_text(d.page_content) for i, c in enumerate(chunks): all_chunks.append({"source": doc["source"], "text": c}) else: for i, c in enumerate(rec_splitter.split_text(doc["text"])): all_chunks.append({"source": doc["source"], "text": c})注意separators列表里我把中文标点也放进去了,这个细节在中英文混合文档里特别重要。LangChain 默认的分隔符只考虑英文标点,对中文文本的效果很不理想,经常在句子中间硬切。加上了。!?;之后,切分位置明显更贴近语义边界。
3.3 向量化入库:把文本变成可检索的向量
向量化的过程就是调用 embedding 模型,把每个切片转成 768 维向量,连同原文、来源、元信息一起写入 Chroma。这里我用sentence-transformers加载本地模型,数据不出内网,离线也能跑:
from sentence_transformers import SentenceTransformer import chromadb from chromadb.config import Settings model = SentenceTransformer(EMBED_MODEL) client = chromadb.PersistentClient(path=DB_DIR) collection = client.get_or_create_collection("llm_wiki") for i, chunk in enumerate(all_chunks): vector = model.encode(chunk["text"]).tolist() collection.add( ids=[f"chunk_{i}"], embeddings=[vector], documents=[chunk["text"]], metadatas=[{"source": chunk["source"]}] )入库过程看起来简单,但有一个细节必须注意:Chroma 的add方法如果逐个调用,当文档量很大时性能会很差。我实际是每 256 条批量提交一次,这样既稳定又快:collection.add(ids=id_batch, embeddings=emb_batch, documents=doc_batch, metadatas=meta_batch)。这个批量大小对不同配置的机器都有不错表现,太多会内存紧张,太少了又浪费 IO。
入库完成后,最好顺手做一个“索引统计”验证:
print("Total chunks:", collection.count())这一步能帮你确认切割、入库是否完成。我之前有一次切分逻辑里正则写错,导致文档被切成上万个碎片,要不是检查 count 根本发现不了。
3.4 查询链路:检索、重排、生成的三步走
查询是llm_wiki最有价值的部分。我把整个链路拆成三步:向量检索、结果重排、大模型生成答案。第一步做粗召回,第二步做微调排序,第三步才交给 LLM 组织语言:
def ask(question): # Step 1: 向量检索粗召回 q_vec = model.encode(question).tolist() results = collection.query(query_embeddings=[q_vec], n_results=TOP_K) # Step 2: 拼接召回片段与来源 contexts = [] sources = [] for i in range(TOP_K): doc = results["documents"][0][i] source = results["metadatas"][0][i]["source"] contexts.append(f"[来源:{source}]\n{doc}") sources.append(source) # Step 3: 交给大模型生成答案 context_block = "\n\n---\n\n".join(contexts) prompt = f"""你是一个严谨的个人知识库助手。请基于以下检索到的文档片段回答用户问题。 如果片段中没有足够信息,请明确说“没找到相关内容”,不要编造。 回答时请标注对应来源。 文档片段: {context_block} 用户问题:{question} 你的回答:""" resp = call_llm(prompt) return resp, sources这里最关键的是 prompt 里的“不要编造”指令。大模型很擅长一本正经地胡说八道,如果检索到的片段信息不足,模型会倾向于“脑补”一个合理但不存在的答案。加入这条约束后,模型会老实很多,宁可回答“没找到”也不瞎编。
另外,每个片段都标注了来源文件。我在后续 UI 里会把来源一起展示出来,读者可以一键跳到原文核对。这个“可溯源”特性是传统 Wiki 很难做到的,也是llm_wiki最大的体验提升点。
3.5 效果演示:一次真实的查询过程
我拿项目早期积累的文档做了一轮实测。库里大概有 200 份文档,涵盖 Python 笔记、部署手册、架构复盘、踩坑记录。随便挑一个很口语化的问题——“上次 Redis 挂了之后我们是怎么恢复的?”
召回阶段,向量检索命中了 5 个片段,其中得分最高的三条分别来自:《Redis 故障复盘》、《缓存集群部署手册》、《线上问题处理记录》。三条内容合在一起,能拼出一个完整的恢复流程:先用哨兵切换主从,然后重启异常节点,再检查持久化文件一致性,最后做了全量预热。
最终的答案是:
根据《Redis 故障复盘》:当时主节点内存暴涨导致 OOM,哨兵在 20 秒内完成了主从切换。恢复操作分三步:1)下线异常节点;2)用 RDB 文件做恢复验证;3)重新加入集群并重启全量预热。详细命令参见《缓存集群部署手册》第 3 节。
这个回答质量很高,因为它既准确引用了原文的步骤,又注明了信息来源,完全没有编造内容。相比之下,传统关键词搜索用“Redis 挂了怎么恢复”去搜,大概率会因为字面不匹配而一无所获。
整个流程跑下来,单次提问的端到端延迟大概是 3 到 5 秒,其中大模型生成占了大部分时间,检索本身只要几百毫秒。这个速度对个人知识库场景完全够用。
4. 常见问题与排查技巧实录
4.1 检索质量差:召回内容不相关怎么定位
这是 RAG 项目最高频的问题。出现“召回不相关”时,我通常按顺序排查:
- 先单独跑一遍向量检索,看看
n_results返回的前几条是不是真的是最相关的。如果检索结果里没有正确答案,说明问题在索引侧;如果有正确答案但 LLM 答错了,说明问题在生成侧。 - 检查切分是否合理:一个逻辑完整的段落被切碎,会给检索增加很多干扰。修复方式是调大
CHUNK_SIZE或补充分隔符。 - 检查嵌入模型是否适合你的语言和领域。中文内容用英文模型,效果通常会差一大截。
我还在项目里加了“检索调试模式”,只看检索结果、不调用 LLM,专门用来做这种区分。把这个模式加进去之后,排查效率高了很多,不再是两眼一抹黑。
4.2 答案幻觉严重:模型开始一本正经胡说八道
幻觉问题的根源,一半在 prompt,一半在检索。prompt 侧,要明确告诉模型“信息不足时必须说不知道”,同时把片段边界括起来,让模型知道哪些是事实依据、哪些是用户问题。检索侧,把TOP_K从 5 降到 3 往往也能减少跨段落拼接导致的幻觉。片段越少,上下文越聚焦,模型编造的空间就越小。
另外还有一个实操技巧:在 prompt 里显式要求“如果片段之间互相矛盾,请指出矛盾点”。这样模型在遇到多个来源说法不一致时,不会帮你“强行圆场”,而是会如实告诉你“A 文档这么说,B 文档那么说,需要你来裁决”。这在实际使用中非常有价值。
4.3 新增文档后查不到:索引没有增量更新
llm_wiki刚跑通的时候,我往里加了几篇新文档,结果怎么查都查不到。排查半天才发现,入库脚本是独立的,新文档没有重新执行向量化,向量库里根本没有新增内容。
解决方案是在脚本里加一个“文档指纹”机制——记录每个文件的修改时间戳和哈希值,只在文件新增或改动时才重新切分和入库:
import hashlib def file_fingerprint(path): st = os.stat(path) with open(path, "rb") as f: content_hash = hashlib.md5(f.read()).hexdigest() return f"{st.st_mtime_ns}-{content_hash}"运行增量索引时,跳过指纹没变的文档,只处理新增和变动的。这样既省时间,也避免旧向量残留导致重复内容。这个机制非常实用,我现在每次写完新笔记,跑一下更新脚本,知识库就自动跟上节奏。
4.4 本地部署时内存与性能优化
本地跑llm_wiki,最怕的就是文档量大之后内存吃紧。嵌入模型本身要占几百 MB 内存,向量库也要常驻,再叠加 LLM 的内存开销,小内存机器很容易扛不住。
我的优化三板斧:
- 嵌入模型选小一点的变体,比如
bge-small-zh-v1.5而不是 large 版本。体积小一半,推理速度快,检索质量差距在个人场景可接受。 - 向量入库时用批量提交,避免频繁触发索引构建。
- Chroma 默认会把数据持久化到磁盘,查询时按需加载。如果内存实在紧张,可以设置
Settings(anonymized_telemetry=False, allow_reset=False, is_persistent=True),减少无用开销。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 检索结果全是无关内容 | 切分粒度过大/过小,嵌入模型不匹配 | 调整 CHUNK_SIZE,换中文嵌入模型 |
| LLM 答案偏离事实 | prompt 未约束,TOP_K 太大 | 加入“不知道就直说”约束,降低 TOP_K |
| 新文档查不到 | 索引未增量更新 | 加文件指纹机制,跑增量入库脚本 |
| 中文检索效果差 | 模型不支持中文 | 换用 bge 等中文优化嵌入模型 |
| 入库速度慢 | 单条写入,未批量提交 | 每 256 条批量 add 一次 |
| 回答不显示来源 | 元数据未处理 | 在入库时保存 source 到 metadatas,prompt 中引用 |
5. 进阶玩法:从个人 Wiki 到团队知识中台
5.1 接入团队 Wiki 与 WebHook 自动更新
llm_wiki跑稳定之后,我开始琢磨怎么让团队也能用上。最朴素的做法是起一个 Web 界面,但更轻量的是接入团队协作工具的 WebHook——文档平台一有更新,就触发一次增量索引,保证团队检索到的永远是最新内容。
整个链路不复杂:WebHook 收到“文档更新”事件 → 拉取变更文件 → 执行指纹比对 → 更新向量库。整个过程完全自动化,不人工干预。这一套落地之后,团队 Wiki 就不再是“只存不看”的文档仓库,而是变成了真正的业务大脑。
5.2 用 LangGraph 编排更复杂的 Agent 工作流
再往后走,可以把llm_wiki升级成一个真正的 Agent。比如多轮对话时,第一轮检索到的知识可以在后续对话中继续参与上下文,而不是每轮都重新检索。我尝试用 LangGraph 编排了一个简单的 Agent:先根据用户问题生成检索关键词,再并行检索多路知识源,最后汇总成回答。效果比单路 RAG 好不少,尤其适合跨部门、跨领域的问题。
这类扩展的核心思路就一句话:llm_wiki虽然以 Wiki 为名,但它的底层能力本质上是“任何私有知识的语义化接口”。一旦跑通了这套接口,你可以在上面叠加问答、写作、摘要、代码检索等各种能力。
5.3 混合检索与重排序模型
想要进一步压榨检索质量,可以用“稀疏检索 + 稠密检索”的混合方案。稀疏检索用 BM25 这类算法,保证关键词精确命中;稠密检索靠向量做语义扩展。两者取并集后再用重排序模型打分,能显著提升最终效果。
重排序我用的方案是bge-reranker-base,它会根据“问题-片段”对重新计算相关性得分,把最匹配的排在最前面。加了重排序之后,答案的精准度提升非常明显,尤其是面对长 tail 类问题。
6. 我的实操心得与避坑复盘
整套llm_wiki从想法到落地,前后花了我大概三个周末。回头看看,几个关键决策和踩过的坑值得好好记一记。
第一,别急着上重型架构。我见过很多做 RAG 的人,第一阶段就上 Kubernetes 集群、微服务、分布式向量数据库,结果连文档都没切好,基建却搭了一堆。个人项目先跑通最小闭环,用 Chroma + 本地嵌入式模型,把整个链路理顺了再逐步扩展,这才是正确的节奏。
第二,检索质量永远是最值得投入的部分。很多人以为 RAG 的核心是大模型,其实大模型在各家之间的能力差距有限,真正拉开体验差距的是索引侧。切分策略、embedding 选型、重排序这些环节,每一个都值得花时间调优。模型选错了可以换,切分策略不行,后面所有环节都得返工。
第三,保持本地优先的思维。llm_wiki的文档存在本地、向量存在本地、嵌入模型也在本地跑,即使断网也能用。这种“本地优先”的架构不仅隐私安全,还避免了 API 限流、费用失控这些在线服务才有的问题。等本地方案稳定了再按需引入在线大模型,那就是锦上添花的事。
第四,把回顾过往当作核心使用场景。我用下来最舒服的姿势,不是写文档的时候记笔记,而是隔几个月回来“考古”——搜“上次那个 xxx 后来怎么解决了”“这是哪个版本引入的变更”,真的一搜一个准。知识库能回答这些过去的问题,产生的价值比回答当下问题高得多。
最后说一个所有人都容易忽略的小细节:给知识库写个简单的日志。llm_wiki刚跑起来时,我把每次查询的问题和命中的来源都记录下来。一个月后翻这些日志,能清楚地看到自己被什么问题困扰最多、哪些文档被反复服用。这份数据反过来又推动我补齐文档盲区,形成一个正向循环。
如果你也打算折腾一个类似的项目,我的建议很简单:先挑 50 篇自己最常查阅的文档做实验,不要贪多。50 篇文档足够跑通全流程,也能暴露出大多数问题。等你把检索质量调到满意,再把规模放大,那时候你会发现自己对 RAG 的理解,已经远超刚开始照着模板抄作业的阶段了。