
1. 为什么把LLM和Wiki放在一起1.1 传统Wiki最让人头疼的三个问题先说说我做llm_wiki之前的状况。我自己的知识管理史大概能分成三个阶段先是各种笔记软件里面堆了一堆文档然后是自建Wiki系统用Markdown写了几百个页面文件夹套文件夹标签打了几十个。表面上看整整齐齐但真正用起来的时候全是问题。第一个问题是“存进去就找不到了”。写的时候恨不得一个知识点开一个页面但三个月之后再回来搜索一个半生不熟的词往往什么都搜不出来。关键词检索只能匹配字面记不清原文写法基本就废了。第二个问题是分类越来越乱。刚开始还按主题分目录后来发现一个话题横跨多个目录同一个概念在七八个页面里都写过但每个页面之间根本没有关联。第三个问题是“回答问题还是得自己翻”。Wiki本质上是个静态仓库存的是文档不是答案。别人问我某个事情我需要自己回忆“这东西我写没写过”再回忆“写在哪个页面里”再打开复制粘贴。这个流程极其浪费精力。我不是没想过用标签体系、双链、知识图谱去补但这些方案都需要在“录入那一刻”就做大量人工维护而且维护成本是随着页面数量线性甚至指数增长的。我真正想要的是一个能自己读我的Wiki、知道里面写了什么、然后直接替我从里面找答案的系统。1.2 LLM恰好补上了Wiki最缺的那一块大语言模型出来之后我立刻想到的倒不是写文案、写代码而是把我那一堆Wiki页面交给它。当时想法很简单Wiki里的信息是结构化的、有上下文的而LLM擅长的是从大量文本中定位信息、概括信息、重新组织语言。两个东西放在一起等于给Wiki装了一个能理解内容的搜索引擎外加一个能用自然语言对话的前端。llm_wiki这个项目就是这么来的。它不是要把Wiki里的所有内容都塞进模型上下文里硬问那既不现实也没必要。它走的是“先检索、再生成”的路线用户提一个问题系统先去Wiki里把相关的内容片段找出来找出来的这些片段作为依据交给LLM让LLM基于这些原文组织回答。这样设计有几个好处。第一答案有出处每一句都能追溯到Wiki里的具体页面不会凭空胡编。第二新增内容不需要重新训练模型写一篇新笔记进去就能被立刻检索到。第三Wiki本身还是你熟悉的那个Wiki遵守的是你原来的目录结构和写作习惯LLM只是在外面加了一层“语义检索问答总结”的壳。1.3 llm_wiki到底解决什么问题一句话总结它让“写过的内容”真正变成“用得上的知识”。适合谁来参考这套方案如果你也有大量私人笔记、技术文档、读书摘录、内部资料并且面临“存了不少但真正用时找不到”的困境那llm_wiki的架构思路可以直接抄走。我后面会把它拆解成一整套可以落地的方案从架构设计、分块策略、向量检索、问答链路到常见坑全过一遍。文中的代码都是我实际在项目中跑过的简化版本参数也都是真实调过之后的结果可以直接参考。2. 整体架构与设计思路2.1 核心链路入库、索引、检索、生成整个llm_wiki分成四个环节入库把Wiki里的Markdown文件、随手写的文本、甚至PDF和网页内容清洗成标准化的纯文本。索引将文本按语义切分成小块每一块用Embedding模型转成向量同时建立关键词倒排索引。检索用户提问之后先用向量检索找出语义最接近的文本块再用关键词检索兜底精确匹配两边结果合并去重。生成把检索到的文本块拼接进Prompt交给LLM生成最终回答同时附上引用的页面链接和原文片段。这个链路看上去简单但每一步都有不少细节决定成败。索引做不好后面检索全是垃圾检索做不好LLM拿到错误上下文回答就是一本正经的胡说八道。所以我把绝大部分精力放在了前三步。2.2 为什么不用“全量塞进上下文”方案有个很自然的想法是我Wiki也就几万字全塞给LLM问不就行了我在项目早期确实这么试过但很快就放弃了。首先是Token成本问题。页面一多几千个文件加起来几十万字单次请求的上下文根本放不下即便放得下费用也不可接受。其次是准确性问题。LLM对长上下文中的细节定位能力远没有想象中那么强塞进去的资料越多模型越容易“挑错重点”尤其是当答案藏在中间某段的时候召回反而下降。最后是更新问题。如果每次提问都把全量资料跑一遍任何一篇文档改动都意味着整个问答系统的行为都可能发生变化完全不可控。所以RAGRetrieval-Augmented Generation这套“先检索再生成”的方案是目前最务实的选择。它不追求让模型“记住”所有内容而是让模型“看得到”相关内容。模型的任务从“回忆”变成了“根据给定材料回答问题”难度降低了不少回答的可靠性也高很多。2.3 技术选型时的几个权衡llm_wiki在技术选型上我没有追求新和贵而是尽量用稳定、好替换的方案。文本分块最开始用固定长度切分后来改成按文档结构优先切分。实测下来对Wiki这种本身有标题、列表、代码块结构的文档结构感知切分比纯长度切分好得多。Embedding模型我的Wiki以中文为主一开始用通用英文模型效果惨不忍睹。后来换了中文优化的Embedding模型语义检索质量有了质的提升。选型上没必要追最新的模型关键是拿自己的文档跑一批测试样本看实际召回率。向量存储数据量不大时用文件型向量库就够了比如Chroma、FAISS部署简单不引入额外服务。等页面数量到了几十万级再考虑上Milvus、Qdrant这类独立的向量数据库。LLM接口这个阶段各家模型的能力差异没有想象中那么大更重要的是Prompt设计和检索质量。我留了统一的LLM调用接口这样今天用这家模型、明天换那家模型只改配置就能切换。3. 核心功能的实操实现3.1 数据清洗与预处理细节这道工序最不起眼但坑最多。Wiki里的内容往往不只有正文还有各种结构性的东西YAML Front Matter标题、日期、标签、链接引用、代码块、无用的模板标记、HTML残留。这些内容如果不处理它们会混进文本块里干扰检索和生成。我写了一个预处理管线按顺序执行解析Markdown拆出元信息标题、标签、创建时间和正文。剥离代码块之外的链接语法把[文字](链接)变成“文字”保留链接文本但去掉URL后缀。去掉模板标记和注释例如% %、!-- --。统一编码为UTF-8去掉不可见字符和多余的空白行。按页面路径和标题生成文档ID后续更新靠这个ID定位。还有个容易被忽视的点重复内容。我Wiki里有大量内容是从网上摘录的有些摘录在多个主题下重复出现。直接库里建索引会导致检索结果被重复内容刷屏。我的做法是对正文文本做一次SimHash去重相似度超过阈值的文本块只保留一份并记录所有引用位置。3.2 分块策略大小、重叠与结构优先级分块是整个系统里对效果影响最大、也最容易调不明白的一环。块大小。我分别试过256、512、768、1024这几种Token长度。结果是在我的文档集上块越大检索召回越粗虽然单块信息更完整但答案往往分散在多个主题里浪费上下文块越小检索定位越准但单块可能装不下完整的信息导致LLM看到的上下文不连贯。最终我取的是大约500字左右一个块配合10%到20%的重叠。这个参数在不同语料上会有差异建议根据自己文档的常见段落长度来测。重叠的作用。加重叠是为了避免一个完整的知识点恰好被切在边界上。比如一个概念的定义句分成了前后两半检索时如果只命中前半句信息就不完整。有了重叠哪怕切点在中间两侧的上下文也能被保留下来。结构优先切分。我强烈建议不要直接按长度硬切。Markdown本身自带结构标题层级就是天然的分块边界。正确做法是先按#、##、###把文档拆成结构化块然后再对过长的块按长度二次切分。这样每个文本块在语义上尽量自洽检索出来的结果也更像“一段完整的内容”。代码块的单独处理。Wiki里难免有代码示例代码块如果跟着正文一起切分经常被拦腰截断既影响阅读也污染向量语义。我的做法是把代码块整体作为一个独立的块不参与正文切分并加上“代码语言”前缀让它跟其他文本块区分开。3.3 向量化与索引构建向量化这一步的核心是Embedding模型的选择。llm_wiki优先使用了针对中文优化的模型原因是通用英文模型在中文语义匹配上经常把“苹果”和“水果”这种关系搞对但很难区分“数据集”和“数据库”这种行业术语的细微差别。模型输入长度也要提前确认。有些Embedding模型单次最大输入只有512 Token这意味着分块大小不能超过模型上限否则会被直接截断。建议在实际分块之前先确认模型的max_tokens再反推块的大小。索引的维护我采用增量更新策略每当Wiki里新增或修改文件只对该文件重新分块和向量化然后按文档ID删除旧的向量写入新的。如果每次都全量重建文档多了以后重建耗时让人受不了。另外向量库建议定期做一次全量校验清理孤儿向量文档已删除但向量残留不然时间久了库里垃圾越来越多检索准确率会偷偷下降。除了向量索引我还同时维护了BM25关键词索引。原因是向量检索擅长语义匹配但对精确术语、编号、代码标识符不敏感。比如搜“Chunk_size”这种参数名向量检索给的可能是“文本块大小”的语义解释而不是那个真正的代码片段但BM25会精准匹配到代码里的原文。这俩互为补充合并结果后召回效果明显更稳。3.4 检索增强问答的Prompt设计有了检索结果最后一步是让LLM给出回答。Prompt我反复改了很多版最后稳定下来了一套规则核心就是三句话只依据提供的资料回答、资料不足以回答时直接说不知道、每条回答标明对应来源。用模板写出来大致是你是一个知识库问答助手。请根据下面提供的资料片段回答用户问题。 要求 1. 回答只能基于资料片段中的内容不得编造。 2. 如果资料片段不足以回答问题请直接回复“资料中没有找到相关内容”。 3. 回答末尾列出引用来源的文档名和章节标题。 资料片段 {context} 用户问题 {question}这里有一个重要细节{context}拼接的顺序会影响回答质量。我按检索相关性得分从高到低排序把最相关的内容放在最前面。LLM对上下文的注意力往往集中在前部相关性最高的内容靠前等于先让模型看重点。另外我在Prompt里把检索到的文本块都标上了来源编号例如[来源1]要求模型回答时在对应句末标注。这样用户能看到这句话来自哪个页面方便回溯原文校验。这个设计对建立信任特别重要回答错了能快速找到根源而不是对着一个“看上去很合理”的答案抓瞎。3.5 让Wiki自动长出关联关系光有问答还不够我加了一个锦上添花的模块自动提取每篇文档的主题标签和潜在关联页面。做法不复杂把一篇文章的标题加摘要发给LLM让模型输出3到5个标签同时从全局标签表里挑出可能相关的旧文档。这样每篇新文档入库时系统会自动在页面上生成“相关文档”和“标签”两栏。这个功能的实用价值在于以前靠人工维护的“双链”自动生成了而且生成逻辑是语义层面的不是简单的关键词重合。我的Wiki现在新增一篇文档它会自动跟几篇老文档建立链接知识网络是从数据里长出来的不是我一条条手动加的。4. 从零搭建最小可运行版本4.1 项目目录设计llm_wiki的整体代码结构可以简化成下面这个目录布局llm_wiki/ ├── config.yaml ├── requirements.txt ├── src/ │ ├── ingest.py # 文档清洗分块 │ ├── indexer.py # 向量化索引构建 │ ├── retriever.py # 混合检索重排序 │ ├── generator.py # LLM问答生成 │ └── utils.py # 通用工具函数 ├── data/ │ ├── wiki/ # 原始Markdown文档 │ └── chunks/ # 切分后的文本块缓存 ├── models/ # 本地Embedding模型文件 └── scripts/ └── run_server.py # 简单Web接口这个结构按“数据处理、索引构建、检索、生成”四个阶段分包职责清晰后面加新功能也不会把代码搅成一团。4.2 文本分块的参考实现下面是我实际在用的分块函数核心逻辑按照“标题切分优先、长度切分兜底”的原则实现import re from typing import List CHUNK_SIZE 500 CHUNK_OVERLAP 80 def split_markdown_by_heading(text: str) - List[str]: # 先按markdown标题切出结构化块 sections [] current_heading current_body [] for line in text.splitlines(): if re.match(r^#{1,3}\s, line): if current_heading or current_body: sections.append((current_heading, \n.join(current_body))) current_heading line current_body [] else: current_body.append(line) if current_heading or current_body: sections.append((current_heading, \n.join(current_body))) return sections def split_long_section(text: str) - List[str]: # 超出长度时按滑动窗口切分保留重叠 if len(text) CHUNK_SIZE: return [text] chunks [] start 0 while start len(text): chunk text[start:start CHUNK_SIZE] chunks.append(chunk) start CHUNK_SIZE - CHUNK_OVERLAP return chunks def chunk_document(text: str) - List[dict]: chunks [] doc_id utils.hash_text(text) for heading, body in split_markdown_by_heading(text): section_text f{heading}\n{body}.strip() if not section_text: continue for i, part in enumerate(split_long_section(section_text)): chunks.append({ doc_id: doc_id, chunk_index: i, heading: heading, text: part, }) return chunks这里doc_id用文档全文哈希生成内容没变就不会重复入库变化了也能准确定位到需要更新的文档。CHUNK_SIZE和CHUNK_OVERLAP是我调出来的经验值你换语料之后需要重新测。4.3 混合检索的参考实现检索模块是整个系统的核心。llm_wiki里用了向量检索和关键词检索两种方式最终合并结果。简化版的代码逻辑如下from rank_bm25 import BM25Okapi import numpy as np class HybridRetriever: def __init__(self, embeddings, vector_store): self.embeddings embeddings self.vector_store vector_store self.bm25_index None self.raw_corpus [] def build_bm25(self, chunks): tokenized [self._tokenize(c[text]) for c in chunks] self.bm25_index BM25Okapi(tokenized) self.raw_corpus chunks def search(self, query, top_k5): # 向量检索 query_vec self.embeddings.embed_query(query) vector_results self.vector_store.search(query_vec, top_ktop_k*2) # 关键词检索 bm25_scores self.bm25_index.get_scores(self._tokenize(query)) bm25_top_idx np.argsort(bm25_scores)[::-1][:top_k] # 合并去重, 兼顾两种得分 merged {} for chunk_id, score in vector_results: merged[chunk_id] merged.get(chunk_id, 0) score * 0.6 for idx in bm25_top_idx: chunk_id self.raw_corpus[idx][chunk_id] merged[chunk_id] merged.get(chunk_id, 0) bm25_scores[idx] * 0.4 sorted_chunks sorted(merged.items(), keylambda x: x[1], reverseTrue) return [self._get_chunk_by_id(cid) for cid, _ in sorted_chunks[:top_k]]两个检索结果合并时的权重比例向量检索与关键词检索按0.6比0.4。这个比例不是拍脑袋定的我分别测试了1比0、0.7比0.3、0.5比0.5、0.3比0.7几组权重按测试集上的召回率和人评结果选的0.6比0.4。如果你的文档里关键词术语特别多可以适当提高BM25的权重。4.4 问答生成与引用生成模块我不再重复贴代码核心就是调用之前说的Prompt模板。但要提醒一个实现细节在拼接上下文时一定要保留每个文本块的来源元数据文档路径、标题、分块编号并且Prompt里要求模型输出引用编号。这样用户点开引用就能跳到对应的Wiki页面而不是看见一段不知道从哪来的答案。4.5 一个完整的运行效果llm_wiki跑起来之后我随便拿自己的一篇旧笔记做测试。那篇笔记写的是“如何优化Docker镜像构建缓存”我自己都忘了具体写过什么。系统给出的回答是“可以用BuildKit开启缓存使用--mounttypecache缓存包管理器依赖目录在修改依赖文件前先复制依赖文件避免代码变动导致依赖层缓存失效。”而且末尾附上了来源页面和章节。这个答案不是我Prompt出来的是从原文里检索到的内容生成的说明链路是通的。5. 常见问题与排查实录5.1 检索不到内容答非所问这是最常见的坑。现象是用户问了一个问题系统回答“资料中没有找到相关内容”但实际上Wiki里是有相关内容的。第一步排查分块打开日志看检索出来的Top 5文本块到底是什么。如果Top 5文本块内容完全跑偏说明是召回出了问题。先检查查询本身和Wiki内容的用词差异比如用户问“模型训练不稳定怎么办”而Wiki里写的是“loss震荡处理”二者语义关联不强向量检索很容易抓不到。解决办法是做查询改写先把用户口语化的问题改写成Wiki原文中用词风格的关键词组合再做检索。第二步排查过滤条件我早期加过一个“只检索最近三个月文档”的过滤条件导致老文档永远召不回。后来把过滤条件做成可选参数默认关闭才解决。5.2 分块太小导致上下文碎片化如果回答内容明显有断裂感前一句和后一句逻辑接不上多半是分块太小答案被切散在多个块里。解决方法是调大CHUNK_SIZE同时观察回答引用上来的块是哪些。如果引用了多个相邻块说明原文档这段内容的主题连续性较强应该考虑让这部分不参与切分作为整块保留。另有一个优化是引入“上下文扩展”机制检索到一个块之后自动把它前后相邻的几个块也一并取来作为补充上下文。这样就算主块内容不完整邻居块能补足背景信息回答会连贯很多。5.3 幻觉问题控制不住幻觉是所有人做LLM应用都会遇到的问题。我的经验是幻觉不能靠系统提示词完全压制得从数据链路上解决。首先是检索质量如果检索回来的上下文本身就不相关LLM会为了“硬答”而编造。所以先在召回和重排上做文章。其次是Prompt措辞必须把“资料不足时如何回答”的指令写得非常清楚。我踩过的坑是写“如果资料中没有答案请说明”结果模型还是会绕来绕去地试图给出一个猜测。后来改成“如果资料中没有答案请直接回复资料中没有找到相关内容。不要尝试猜测。”效果立竿见影。最后可以在生成后加一道校验让LLM自己检查回答里每一句是否都能在上下文中找到依据标注出无法追溯的句子人为介入筛选。5.4 中文语境下的几个特殊问题我对llm_wiki最深的体会就是“中文文档库和英文文档库完全是两个世界”。首先Embedding模型对中文的分词和语义理解差异巨大通用英文模型在中文上经常出现严重的语义漂移。建议直接选择中文优化过的模型最好用自己领域的数据做一个小测试集对比候选模型对相似问题召回是否稳定。其次中文文档里大量使用全角标点、中文数字、中英混排。清洗阶段要把全角括号换成半角统一空格和标点否则同一个词“LLM”和“LLM ”会被当成不同Token检索结果忽好忽坏。第三中文没有天然的词边界BM25分词如果使用简单字符切割效果很差。我的办法是用一个轻量级中文词库做正向最大匹配分词不需要引入太重的NLP库反正只是辅助检索。5.5 性能与成本控制心得llm_wiki的日常使用成本大头在Embedding调用和LLM生成。Embedding费用相对低但调用次数多尤其是分块后文本块数量可能上万。我的优化方案是把文本块的Embedding结果缓存到本地文件内容哈希没变就直接读缓存不重复调用接口。实测下来一次全量索引之后日常增量更新基本不再产生额外的Embedding费用。LLM生成这边成本主要来自长上下文。为了防止用户一句话就把几十个检索块全塞进来我给上下文设置了配额上限比如最多取5个块、每块最长500字总量控制在2000字左右。这样大多数问题一个请求就能完成。另外常见问题做了一层缓存同一个问题哈希命中直接返回历史答案不再调用模型。5.6 问题排查速查表现象可能原因解决办法检索结果不相关查询改写不足增加查询扩展用Wiki风格改写用户问题答案断裂不连贯分块过大或块间重叠不够检查块是否跨越主题调小分块或增加重叠回答幻觉明显上下文不相关或Prompt指令弱强化Prompt约束增加“资料不足直接拒绝”语句中文匹配差Embedding模型不适合中文换用中文优化模型重建索引新增文档无法被检索增量索引未触发检查文件监听和文档ID冲突检索速度越来越慢向量库垃圾数据堆积定期全量重建索引清理孤儿向量爆Token上下文拼接过大设置上下文总长度上限限制召回块数量6. llm_wiki还可以往哪扩展这套系统跑通之后我明显感觉到以前攒下的资料终于“活”了。但要想用得更好后续还有几个方向值得动手。一个是多模态扩展。现在Wiki里主要是文本但我也存了不少截图、白板照片、流程图。下一步想把图片接进流程用多模态模型做OCR和图表内容提取再把提取出的文字作为虚拟文本块纳入索引。很多用文字说不清的东西在图表里一目了然这部分信息不应该被丢掉。另一个是自动写作。检索链路已经通了那就不止能问答还能辅助写新文档。写一篇新笔记时可以先给出一个主题系统从已有Wiki里检索相关的背景资料生成一个初稿框架我再往里面补充、修改。这个“辅助写作”场景我觉得比问答的应用频率更高。还有个性化排序。不同的人关注点不一样同一个Wiki我自己搜“数据库优化”和同事搜“数据库优化”期望的答案方向可能完全不同。可以引入用户操作行为点击了哪个引用、给哪些回答点了赞来调整检索排序权重做一个轻量级的学习式排序。最后是定期“知识体检”。用LLM自动扫描所有文档找出互相冲突的内容、过时的信息、失效的引用链接生成一份巡检报告。这个对维护长期积累的Wiki价值很大知识腐烂是很多知识库沉默的原因自动化体检能帮它保持新鲜度。我在实际维护llm_wiki的过程中最深的体会是这类系统的核心价值不在于“用了多先进的模型”而在于把普通的知识管理工具唤醒让积累变得可被检索、可被对话、可被复用。一个好的Wiki是一座矿但矿挖不出来等于没矿llm_wiki这套方案算是给这座矿装了一台能说话的挖掘机。你在搭建自己的版本时不必照抄我的所有选型但建议把“清洗—分块—检索—生成”这条链路走通之后再谈优化。一分耕耘一分检索资料的录入习惯和索引的维护频次最终决定你的知识库到底好不好用。