1. 从“存知识”到“编译知识”:LLM Wiki 到底在解决什么问题
第一次看到“LLM Wiki”这个说法,很多人会下意识把它理解成“又一个知识库工具”。但如果你真的搭过几套 RAG 系统,就会明白它想解决的痛点完全不在“存”这个层面。传统知识库的核心逻辑是:把文档切块、向量化、存进向量数据库,用户提问时召回最相似的几个片段,塞给大模型生成答案。这套流程在 Demo 阶段看起来很美好,一旦进入真实场景,问题就暴露了——召回片段之间没有结构关系,模型拿到的是几段孤立的文字,遇到需要跨文档推理、需要理解概念层级、需要追溯知识来源的问题时,回答质量断崖式下跌。
LLM Wiki 的思路是把“知识库”升级成“知识编译器”。这个比喻很关键:编译器不是简单地存储源代码,而是把源代码解析成抽象语法树、做语义分析、生成中间表示、最终产出可执行目标代码。对应到知识管理上,LLM Wiki 不再把文档当成一堆待检索的文本块,而是让大模型主动参与知识的抽取、结构化、关联和持续维护,最终形成一套可读、可查、可推理、可追溯的知识网络。换句话说,传统 RAG 是“检索增强生成”,LLM Wiki 更接近“编译增强生成”——先编译,再检索,最后生成。
这套思路适合谁?如果你只是想让模型回答几个 FAQ,传统 RAG 足够了。但如果你面对的是持续演化的领域知识、需要多跳推理的复杂问题、或者要求答案能追溯到具体概念和关系的场景,那 LLM Wiki 值得认真研究。它尤其适合技术团队做内部知识中枢、研究机构做领域知识沉淀、以及个人做长期可维护的第二大脑。下面我会从整体设计、核心细节、实操落地、问题排查四个层面,把这套东西拆开讲清楚。
2. 整体设计与思路拆解:为什么不能只做向量检索
2.1 传统 RAG 的三个结构性瓶颈
先说清楚传统 RAG 到底卡在哪里,不然没法理解 LLM Wiki 为什么要这么设计。
第一个瓶颈是语义碎片化。文档被切成 500 到 1000 字的块之后,每个块都是孤立的。一个概念的定义可能在 A 文档,它的应用场景在 B 文档,限制条件在 C 文档。用户问“这个概念在什么情况下不适用”,向量检索可能只召回 B 和 C,漏掉 A 里的关键前提,模型就开始编。
第二个瓶颈是关系缺失。向量相似度衡量的是文本表面的语义接近程度,它不知道“A 是 B 的子类”“C 依赖 D”“E 和 F 互斥”这些结构化关系。遇到需要沿关系链推理的问题,纯向量检索基本无能为力。
第三个瓶颈是知识不更新。文档改了,你得重新切块、重新向量化、重新入库。如果知识之间有交叉引用,改一处可能影响多处,但向量库不会自动帮你维护一致性。
2.2 LLM Wiki 的核心设计哲学
LLM Wiki 的设计哲学可以用一句话概括:让大模型做知识的编译器和维护者,而不是只做最后的答题者。
具体来说,它把整个流程拆成四个阶段。第一阶段是知识抽取,用 LLM 从原始文档中识别出实体、概念、定义、关系、约束条件,输出结构化的知识单元。第二阶段是知识编译,把这些知识单元组织成有层级的 Wiki 结构,每个条目有明确的标题、定义、属性、关联条目和来源引用。第三阶段是知识检索,用户提问时先定位到相关 Wiki 条目,再沿条目间的链接做有限跳转,收集足够上下文。第四阶段是知识维护,当新文档进来或旧文档更新时,LLM 负责判断哪些条目需要新增、修改或废弃,并保持引用一致性。
这个设计和传统 RAG 最大的区别在于:知识在入库时就已经被结构化了,检索时拿到的是有组织的知识单元,而不是散落的文本块。这就像图书馆,传统 RAG 是把书撕成页随便堆着,LLM Wiki 是编好目录、做好索引、标好交叉引用。
2.3 方案选型:为什么是 Wiki 而不是纯图数据库
有人会问,既然要结构化,为什么不直接上知识图谱?图数据库确实能表达关系,但纯图结构对普通用户不友好,而且构建和维护成本高。LLM Wiki 选择了一条中间路线:用 Wiki 的页面结构承载知识,用页面内的结构化字段和页面间的链接表达关系。
这样做的好处是,Wiki 天然适合人读,也适合机器解析。每个页面就是一个知识单元,页面标题是概念名,正文是定义和说明,元数据字段记录属性,链接指向相关概念。LLM 可以很容易地把一个页面编译成一段上下文,也可以沿着链接做多跳检索。相比纯图数据库,Wiki 的编辑和维护门槛低得多,而且版本控制、diff、回滚这些能力可以直接复用现成工具。
另一个关键选型是编译时机。LLM Wiki 选择在入库时编译,而不是查询时编译。查询时编译虽然灵活,但每次提问都要跑一遍 LLM 做抽取和结构化,延迟高、成本高、结果还不稳定。入库时编译是一次性投入,之后检索就是查表加有限跳转,速度和稳定性都好得多。当然,入库编译对抽取质量要求更高,后面会讲怎么保证。
3. 核心细节解析与实操要点:知识单元怎么设计
3.1 知识单元的最小结构
LLM Wiki 的基本单位是知识条目,一个条目对应一个概念、实体或方法。每个条目至少包含以下字段:
| 字段名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| title | string | 条目名称,唯一标识 | 是 |
| aliases | list | 别名、缩写、同义词 | 否 |
| definition | text | 一句话定义 | 是 |
| body | markdown | 详细说明 | 是 |
| attributes | dict | 结构化属性键值对 | 否 |
| relations | list | 指向其他条目的关系 | 否 |
| sources | list | 来源文档和位置 | 是 |
| confidence | float | 置信度 0-1 | 否 |
| last_updated | date | 最后更新时间 | 是 |
这个结构看起来简单,但每个字段的设计都有讲究。title必须唯一,因为它是检索的锚点。aliases解决同一概念多种叫法的问题,比如“大语言模型”和“LLM”应该指向同一个条目。definition要求一句话说清楚,逼着 LLM 做提炼,避免把整段原文抄进来。relations是关系表达的核心,常见关系类型包括is_a、part_of、depends_on、contradicts、used_for等。
3.2 关系类型的设计原则
关系类型不能随便定,否则会变成一锅粥。我的经验是,关系类型应该满足三个条件:方向明确、语义单一、可传递性清晰。
方向明确是指 A 到 B 的关系和 B 到 A 的关系必须区分。比如is_a是单向的,“苹果是一种水果”不能反过来说“水果是一种苹果”。语义单一是指一个关系类型只表达一种含义,不要用related_to这种万能关系,它等于没说。可传递性清晰是指要明确哪些关系可以传递,比如is_a可以传递(苹果是水果,水果是食物,所以苹果是食物),但depends_on不一定能传递。
实操中我建议先定义 8 到 12 种核心关系,覆盖大部分场景,然后根据领域需要扩展。下面是一组经过验证的关系类型:
is_a:子类关系,可传递part_of:部分整体关系,可传递depends_on:依赖关系,不可传递causes:因果关系,不可传递contradicts:矛盾关系,对称used_for:用途关系,不可传递derived_from:派生关系,可传递similar_to:相似关系,对称但不可传递
3.3 抽取提示词的关键设计
知识抽取的质量直接决定整个系统的上限。提示词设计有几个关键点。
第一,要求 LLM 输出结构化 JSON,而不是自由文本。JSON 便于程序解析和校验,也逼着 LLM 按字段组织信息。第二,给出明确的抽取规则和示例。比如“只抽取文档中明确陈述的知识,不要推断”“如果同一概念多次出现,合并为一个条目”“关系必须指向文档中已出现的概念”。第三,要求标注来源位置。每个知识条目必须附带原文出处,方便追溯和验证。第四,设置置信度字段。让 LLM 对自己抽取的内容打分,低置信度的条目后续人工复核。
一个实际可用的抽取提示词骨架是这样的:
你是一个知识抽取器。请从以下文档中抽取知识条目,输出 JSON 数组。 每个条目包含: - title: 概念名称,唯一 - aliases: 别名列表 - definition: 一句话定义,不超过 50 字 - body: 详细说明,保留原文关键信息 - attributes: 结构化属性 - relations: 关系列表,每项包含 type 和 target - sources: 来源位置,格式为 [文档名, 段落号] - confidence: 0-1 之间的置信度 抽取规则: 1. 只抽取文档明确陈述的内容,不要推断 2. 同一概念只出现一次,合并所有相关信息 3. 关系类型从以下列表选择:is_a, part_of, depends_on, causes, contradicts, used_for, derived_from, similar_to 4. 如果文档中没有足够信息填充某字段,留空而不是编造 文档内容: {document}3.4 编译阶段的去重与合并
抽取出来的条目会有重复和冲突,编译阶段要处理这些问题。去重的核心是判断两个条目是否指向同一概念。简单做法是用标题和别名字符串匹配,但不够可靠。更稳的做法是用嵌入向量算相似度,超过阈值就认为是同一概念,然后合并。
合并时要注意几点。definition取更准确的那个,或者让 LLM 重新生成一个综合版本。body做并集,但要去掉重复内容。relations做并集,冲突的关系要标记出来人工确认。sources全部保留,因为多个来源能提高可信度。confidence取加权平均,来源越多、置信度越高。
注意:去重阈值不要设得太低,否则会把相关但不同的概念合并。我的经验是余弦相似度 0.92 以上才考虑合并,0.85 到 0.92 之间标记为“疑似重复”人工复核。
4. 实操过程与核心环节实现:从零搭一套 LLM Wiki
4.1 环境准备与工具选型
先列一下我实际用到的工具栈,都是开源或免费可用的。
| 环节 | 工具 | 用途 | 备注 |
|---|---|---|---|
| 文档解析 | MarkItDown / Unstructured | 把 PDF、Word、HTML 转成 Markdown | MarkItDown 轻量,Unstructured 格式支持全 |
| 知识抽取 | 任意支持 JSON 输出的 LLM | 从文档抽取知识条目 | 本地可用 Ollama 跑小模型,云端可用主流 API |
| 存储 | SQLite + 文件系统 | 存条目元数据和 Markdown 正文 | 简单可靠,方便版本控制 |
| 向量检索 | FAISS / Chroma | 条目级语义检索 | 条目数量少时 FAISS 足够 |
| Wiki 渲染 | MkDocs / Obsidian | 把条目渲染成可浏览的 Wiki | Obsidian 适合个人,MkDocs 适合团队 |
| 编排 | Python 脚本 | 串联整个流水线 | 不需要复杂框架,脚本足够 |
选型逻辑很简单:能用文件系统就不用数据库,能用脚本就不用框架。LLM Wiki 的核心复杂度在知识建模和抽取质量上,不在工程架构上。过早引入重型框架只会增加调试成本。
4.2 文档解析与预处理
第一步是把各种格式的文档统一转成 Markdown。这一步看起来简单,但坑不少。PDF 里的表格、公式、多栏排版经常解析错乱,扫描件还需要 OCR。我的做法是:优先用 MarkItDown 处理结构化文档,遇到复杂 PDF 用 Unstructured 的 hi_res 模式,扫描件走 OCR 流程。
预处理阶段要做几件事。去掉页眉页脚和页码,这些内容会污染抽取结果。合并被错误分段的段落,PDF 解析经常把一句话拆成多行。保留标题层级,因为标题是判断概念层级的重要线索。给每个段落编号,方便后续标注来源位置。
import re def preprocess_markdown(text): # 去掉页码行 text = re.sub(r'^\s*\d+\s*$', '', text, flags=re.MULTILINE) # 合并被拆断的句子 text = re.sub(r'([^\n。!?.!?])\n([^\n#\-\d])', r'\1\2', text) # 给段落编号 paragraphs = text.split('\n\n') numbered = [] for i, p in enumerate(paragraphs): if p.strip(): numbered.append(f'[P{i}] {p.strip()}') return '\n\n'.join(numbered)4.3 知识抽取的批量处理与限流
文档多的时候,抽取要批量跑。这里有几个实操要点。按文档分批,每批不超过 LLM 的上下文窗口,留出提示词和输出的空间。并发控制,本地模型并发 2 到 4 路,云端 API 看限流策略,一般 5 到 10 路。失败重试,JSON 解析失败或字段缺失时重试,重试两次还失败就记录到待处理队列。
import json from concurrent.futures import ThreadPoolExecutor def extract_batch(doc_chunk, llm_client, max_retries=2): for attempt in range(max_retries + 1): try: response = llm_client.generate( prompt=EXTRACT_PROMPT.format(document=doc_chunk), temperature=0.1, response_format={"type": "json_object"} ) data = json.loads(response) return validate_entries(data) except (json.JSONDecodeError, KeyError) as e: if attempt == max_retries: log_failure(doc_chunk, str(e)) return [] return [] def process_documents(docs, llm_client, workers=4): all_entries = [] with ThreadPoolExecutor(max_workers=workers) as executor: futures = [executor.submit(extract_batch, d, llm_client) for d in docs] for f in futures: all_entries.extend(f.result()) return all_entries温度设 0.1 是为了让输出稳定,抽取任务不需要创造性。response_format强制 JSON 输出,能大幅降低解析失败率。
4.4 条目合并与 Wiki 生成
抽取完成后,把所有条目汇总,做去重合并。合并后的条目写入 SQLite,同时每个条目生成一个 Markdown 文件,文件名用条目 ID。Markdown 文件里用[[条目名]]的语法做内部链接,这样 Obsidian 或 MkDocs 能自动渲染成可点击的 Wiki。
def merge_entries(entries, similarity_threshold=0.92): merged = [] for entry in entries: matched = find_similar(entry, merged, similarity_threshold) if matched: merged[merged.index(matched)] = merge_two(matched, entry) else: merged.append(entry) return merged def write_wiki(entries, output_dir): for entry in entries: filename = f"{entry['id']}.md" content = render_markdown(entry) with open(f"{output_dir}/{filename}", 'w') as f: f.write(content)render_markdown负责把条目结构转成 Markdown,包括 frontmatter 元数据、定义、正文、关系链接和来源引用。frontmatter 用 YAML 格式,方便后续程序读取。
4.5 检索流程的实现
检索分两步。第一步是条目定位,用用户问题的嵌入向量在条目向量库里找最相似的 N 个条目。第二步是上下文扩展,从定位到的条目出发,沿关系链接跳转 1 到 2 跳,收集相关条目。最后把收集到的条目按相关度排序,拼成上下文送给 LLM 生成答案。
def retrieve(query, entry_index, entry_store, top_k=5, max_hops=2): query_vec = embed(query) candidates = entry_index.search(query_vec, top_k) collected = set() frontier = [c['id'] for c in candidates] for hop in range(max_hops): next_frontier = [] for eid in frontier: if eid in collected: continue collected.add(eid) entry = entry_store.get(eid) for rel in entry.get('relations', []): next_frontier.append(rel['target']) frontier = next_frontier return [entry_store.get(eid) for eid in collected]跳数不要太多,2 跳通常足够。跳数多了会引入不相关条目,反而干扰生成。
5. 常见问题与排查技巧实录
5.1 抽取质量不稳定的排查思路
抽取质量不稳定是最常见的问题,表现是同一批文档跑两次,抽出来的条目数量和内容差异很大。排查顺序是这样的。
先看提示词是否足够明确。如果提示词里没有给出字段定义和抽取规则,LLM 每次理解都不一样。解决方法是把规则写死,给出正例和反例。再看温度是否太高,抽取任务温度应该设 0 到 0.2。然后看文档预处理是否干净,页眉页脚、乱码、错误分段都会干扰抽取。最后看模型能力是否够,小模型在复杂文档上确实力不从心,可以考虑换更大的模型或者把文档拆得更细。
5.2 条目重复和冲突的处理
条目重复的根源是同一概念在不同文档里叫法不同,或者 LLM 对同一概念给出了不同的标题。处理方法是建立别名表,把已知的同义词映射到标准名。别名表可以手工维护,也可以让 LLM 从已有条目里自动发现。
条目冲突是指两个条目对同一概念给出了矛盾的定义或属性。这种情况不要自动合并,应该标记出来人工裁决。我的做法是在 Wiki 里加一个conflicts字段,列出冲突的条目 ID,然后在渲染时高亮显示。
5.3 检索命中率低的优化
检索命中率低通常有三个原因。条目粒度不对,条目太大或太小都会影响检索。太大则一个条目包含多个概念,检索时定位不准;太小则信息不完整,需要跳很多跳。我的经验是每个条目对应一个明确的概念,正文控制在 200 到 800 字。嵌入模型不适合领域,通用嵌入模型在专业领域上表现会下降,可以考虑用领域数据微调,或者用 LLM 做重排序。关系链接缺失,如果条目之间没有建立关系,多跳检索就退化成单跳,上下文不够丰富。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 抽取条目数量异常少 | 提示词太严格或文档预处理过度 | 检查提示词和预处理输出 | 放宽提示词,减少预处理 |
| 抽取条目大量重复 | 别名未归一化 | 统计标题相似度分布 | 建立别名表,提高去重阈值 |
| JSON 解析频繁失败 | 模型输出不稳定 | 查看原始输出 | 降低温度,强制 JSON 格式,增加重试 |
| 检索结果不相关 | 条目粒度过大或嵌入模型不匹配 | 人工检查召回条目 | 拆分条目,换嵌入模型,加重排序 |
| 多跳检索引入噪声 | 跳数过多或关系类型太泛 | 检查跳转路径 | 减少跳数,收紧关系类型 |
| Wiki 渲染链接失效 | 条目名变更未同步 | 检查链接目标是否存在 | 用 ID 做链接,渲染时解析为名称 |
5.5 几个踩过的坑
第一个坑是过早追求全自动化。一开始我想让整个流程全自动跑,结果抽取质量差、合并错误多,最后还是要人工介入。后来改成半自动:抽取和合并自动跑,但低置信度条目和冲突条目进人工复核队列。这样整体效率反而更高。
第二个坑是关系类型定太多。一开始我定了 30 多种关系,结果 LLM 经常选错,而且关系太细导致链接稀疏。后来砍到 10 种核心关系,链接密度上来了,检索效果也好了。
第三个坑是忽略来源追溯。早期版本没存来源位置,后来发现答案有问题时根本查不到是哪个文档哪一段出的错。加上来源标注后,排查效率提升明显。
第四个坑是用通用嵌入模型做领域检索。在技术文档上还行,换到医疗、法律领域就明显不够用。后来改成先用通用模型粗筛,再用领域 LLM 做重排序,命中率提升了不少。
6. 知识编译的进阶玩法与个人体会
6.1 增量编译与版本管理
知识库不是一次建完就完事,文档会更新,新知识会进来。增量编译的核心是判断哪些条目受影响。新文档进来时,先抽取条目,然后和已有条目做匹配。匹配上的走合并流程,匹配不上的走新增流程。文档更新时,先找到该文档贡献的所有条目,重新抽取后对比差异,有变化就更新。
版本管理直接用 Git 就行。每个条目一个 Markdown 文件,每次编译提交一次,diff 清晰可见。回滚也方便,git revert就能恢复到任意版本。这比在数据库里做版本管理简单得多。
6.2 用 LLM 做知识质量巡检
知识库跑一段时间后,会有各种质量问题:过时信息、矛盾条目、孤立条目、断链。可以定期跑一个巡检脚本,让 LLM 检查这些问题。比如让 LLM 读一批条目,找出定义模糊、关系可疑、来源缺失的条目,输出待修复列表。这个巡检不需要很频繁,一个月跑一次就够。
6.3 个人使用体会
我用这套方法维护自己的技术知识库已经有一段时间了,最大的感受是前期投入值得。刚开始搭建时确实比直接上 RAG 麻烦,要设计条目结构、调抽取提示词、处理合并冲突。但一旦跑通,后续维护成本低很多。新文档进来,跑一遍编译流程,知识就自动归位了。检索时拿到的是有组织的知识,而不是散落的文本块,回答质量明显更稳。
另一个体会是不要追求完美。知识库永远有改进空间,条目可以更细、关系可以更准、来源可以更全。但如果一直纠结这些,就永远建不起来。我的策略是先跑通最小闭环,然后在使用中持续迭代。哪些条目经常被检索到但质量差,就优先修哪些。哪些关系经常被跳转但指向错误,就优先改哪些。用起来的知识库才是好知识库。
最后分享一个小技巧:给条目加一个“使用频率”字段,每次被检索到就加一。定期看高频条目,优先保证它们的质量。低频条目可以放一放,等有需要时再完善。这样能把有限的精力花在最有价值的地方。