不用 LLM,也能构建自动链接的 Markdown Wiki:纯 Python 编译器实战
本教程整理自 Towards Data Science 文章 LLM Wikis Are Over-Engineered — I Replaced Mine With a Pure Python Compiler。核心方法来自原文;下面可运行的最小实现是为教学重新编写的实践补充,并非原仓库代码的逐字复制。
你将完成什么
我们要用 Python 标准库实现一个小型 Wiki 编译器:
- 从
raw_notes/读取 Markdown 源文件; - 提取标题、创建日期和正文;
- 按实体名称发现字面关联;
- 生成
[[Wiki Link]]、反向链接和结构化页面; - 重新编译时保留人工填写的
## Notes; - 检查断链和孤立页面;
- 用测试证明输出幂等、人工内容不会丢失。
整个项目不调用 LLM、不联网、不安装第三方依赖。
适用边界
这个方案适合内容已经存在于本地、格式大致可控、链接规则可以明确表达的知识库。它只做确定性编译,不会理解同义词或隐含语义。
如果一篇笔记写的是“梯度下降”,另一篇只写“优化步骤”,字面匹配不会自动关联它们。更稳妥的架构是:确定性编译器负责核心输出,语义检索作为可关闭的候选建议层,而不是直接改写 Wiki。
一、创建项目
项目结构如下:
wiki-compiler-demo/
├── raw_notes/
│ ├── attention_mechanism.md
│ ├── gradient_descent.md
│ └── learning_rate_schedule.md
├── compiled_wiki/
├── tests/
│ └── test_wiki_compiler.py
└── wiki_compiler.py创建目录:
mkdir -p wiki-compiler-demo/raw_notes wiki-compiler-demo/compiled_wiki wiki-compiler-demo/tests
cd wiki-compiler-demo准备三篇源笔记。
raw_notes/attention_mechanism.md:
# Attention Mechanism
created: 2026-07-01
A common mistake is tuning Attention Mechanism without first checking Learning Rate Schedule.raw_notes/gradient_descent.md:
# Gradient Descent
created: 2026-07-02
Gradient Descent experiments often mention Attention Mechanism as a downstream model component.raw_notes/learning_rate_schedule.md:
# Learning Rate Schedule
created: 2026-07-03
Learning Rate Schedule controls how the optimizer changes its step size.二、实现最小 Wiki 编译器
将下面代码保存为 wiki_compiler.py:
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
from pathlib import Path
TITLE_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
CREATED_RE = re.compile(r"^created:\s*(.+)$", re.MULTILINE | re.IGNORECASE)
LINK_RE = re.compile(r"\[\[([^]]+)]]")
@dataclass(frozen=True)
class Entity:
title: str
slug: str
created: str
body: str
source: Path
def slugify(title: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
if not slug:
raise ValueError(f"title cannot produce a slug: {title!r}")
return slug
def parse_entity(path: Path) -> Entity:
text = path.read_text(encoding="utf-8")
title_match = TITLE_RE.search(text)
title = title_match.group(1).strip() if title_match else path.stem.replace("_", " ").title()
created_match = CREATED_RE.search(text)
created = created_match.group(1).strip() if created_match else "unknown"
body_lines = [
line for line in text.splitlines()
if not line.startswith("# ") and not line.lower().startswith("created:")
]
body = "\n".join(body_lines).strip()
return Entity(title=title, slug=slugify(title), created=created, body=body, source=path)
def load_entities(source_dir: Path) -> dict[str, Entity]:
entities = [parse_entity(path) for path in sorted(source_dir.glob("*.md"))]
by_slug = {entity.slug: entity for entity in entities}
if len(by_slug) != len(entities):
raise ValueError("duplicate entity slug detected")
return by_slug
def build_graph(entities: dict[str, Entity]) -> dict[str, set[str]]:
graph = {slug: set() for slug in entities}
title_index = {entity.title.casefold(): slug for slug, entity in entities.items()}
for source_slug, entity in entities.items():
body = entity.body.casefold()
for title, target_slug in title_index.items():
if target_slug != source_slug and re.search(rf"\b{re.escape(title)}\b", body):
graph[source_slug].add(target_slug)
return graph
def extract_notes(path: Path) -> str:
if not path.exists():
return "_(add your own notes here -- preserved on recompile)_"
text = path.read_text(encoding="utf-8")
match = re.search(r"^## Notes\s*$\n(.*?)(?=^## |\Z)", text, re.MULTILINE | re.DOTALL)
return match.group(1).strip() if match else "_(add your own notes here -- preserved on recompile)_"
def render_page(
entity: Entity,
related: set[str],
referenced_by: set[str],
entities: dict[str, Entity],
notes: str,
) -> str:
related_lines = [f"- [[{entities[slug].title}]]" for slug in sorted(related)] or ["- none"]
back_lines = [f"- [[{entities[slug].title}]]" for slug in sorted(referenced_by)] or ["- none"]
return "\n".join([
f"# {entity.title}", "", "## Metadata",
f"- created: {entity.created}", f"- source: {entity.source.as_posix()}",
"", "## Related", *related_lines,
"", "## Referenced By", *back_lines,
"", "## Body", entity.body,
"", "## Notes", notes, "",
])
def compile_wiki(source_dir: Path, output_dir: Path) -> None:
entities = load_entities(source_dir)
graph = build_graph(entities)
output_dir.mkdir(parents=True, exist_ok=True)
for slug, entity in entities.items():
referenced_by = {source for source, targets in graph.items() if slug in targets}
output_path = output_dir / f"{slug}.md"
page = render_page(
entity=entity,
related=graph[slug],
referenced_by=referenced_by,
entities=entities,
notes=extract_notes(output_path),
)
output_path.write_text(page, encoding="utf-8")
def lint_wiki(output_dir: Path) -> list[str]:
pages = {path.stem: path for path in output_dir.glob("*.md")}
title_to_slug = {}
for slug, path in pages.items():
match = TITLE_RE.search(path.read_text(encoding="utf-8"))
if match:
title_to_slug[match.group(1).strip()] = slug
incoming = {slug: 0 for slug in pages}
errors: list[str] = []
for source_slug, path in pages.items():
text = path.read_text(encoding="utf-8")
related_match = re.search(r"^## Related\s*$\n(.*?)(?=^## |\Z)", text, re.MULTILINE | re.DOTALL)
related_text = related_match.group(1) if related_match else ""
for title in LINK_RE.findall(related_text):
target_slug = title_to_slug.get(title)
if target_slug is None:
errors.append(f"broken link: {source_slug} -> {title}")
else:
incoming[target_slug] += 1
errors.extend(f"orphan page: {slug}" for slug, count in incoming.items() if count == 0)
return sorted(errors)
def main() -> int:
parser = argparse.ArgumentParser(description="Compile and lint a deterministic Markdown wiki")
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
compile_wiki(args.source, args.output)
errors = lint_wiki(args.output)
for error in errors:
print(error)
print(f"compiled={len(list(args.output.glob('*.md')))} lint_findings={len(errors)}")
return 1 if any(item.startswith("broken link:") for item in errors) else 0
if __name__ == "__main__":
raise SystemExit(main())这里有两个关键设计:
Related是真实出边;Referenced By只是由图反推的展示信息。Lint 只扫描Related,否则会把反向链接误当成出边。Notes属于人工维护区;其他区段属于编译器。重新编译时只提取并写回Notes。
三、第一次编译
执行:
python3 wiki_compiler.py raw_notes compiled_wiki预期输出形态:
orphan page: gradient_descent
compiled=3 lint_findings=1这里的孤立页面不是程序失败。它表示没有其他页面通过 Related 指向 Gradient Descent,可作为人工复核清单。
打开 compiled_wiki/attention_mechanism.md,应看到:
## Related
- [[Learning Rate Schedule]]
## Referenced By
- [[Gradient Descent]]四、验证人工笔记不会被覆盖
把下面内容写入 compiled_wiki/attention_mechanism.md 的 ## Notes:
## Notes
这里是人工补充:后续加入多头注意力示例。再次运行编译命令。验收标准:
## Notes中的人工文字仍然存在;Metadata、Related、Referenced By和Body已按源文件重新生成;- 连续两次编译后,文件内容不再发生变化。
五、增加自动化测试
将下面代码保存为 tests/test_wiki_compiler.py:
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase, main
from wiki_compiler import compile_wiki, lint_wiki
class WikiCompilerTest(TestCase):
def setUp(self) -> None:
self.temp_dir = TemporaryDirectory()
root = Path(self.temp_dir.name)
self.source = root / "raw"
self.output = root / "compiled"
self.source.mkdir()
(self.source / "alpha.md").write_text(
"# Alpha\ncreated: 2026-07-01\n\nAlpha mentions Beta.\n",
encoding="utf-8",
)
(self.source / "beta.md").write_text(
"# Beta\ncreated: 2026-07-02\n\nBeta stands alone.\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def test_compile_is_idempotent(self) -> None:
compile_wiki(self.source, self.output)
first = {path.name: path.read_bytes() for path in self.output.glob("*.md")}
compile_wiki(self.source, self.output)
second = {path.name: path.read_bytes() for path in self.output.glob("*.md")}
self.assertEqual(first, second)
def test_human_notes_are_preserved(self) -> None:
compile_wiki(self.source, self.output)
alpha = self.output / "alpha.md"
alpha.write_text(
alpha.read_text(encoding="utf-8").replace(
"_(add your own notes here -- preserved on recompile)_",
"人工备注必须保留。",
),
encoding="utf-8",
)
compile_wiki(self.source, self.output)
self.assertIn("人工备注必须保留。", alpha.read_text(encoding="utf-8"))
def test_linter_counts_only_real_outgoing_links(self) -> None:
compile_wiki(self.source, self.output)
findings = lint_wiki(self.output)
self.assertIn("orphan page: alpha", findings)
self.assertNotIn("orphan page: beta", findings)
if __name__ == "__main__":
main()运行测试:
python3 -m unittest discover -s tests -v预期结果:3 个测试全部通过。
六、三个实战场景
场景 1:个人研究笔记
原始资料始终保存在 raw_notes/,编译结果可以随时删除并重建。人工判断只写入 Notes,避免与自动生成内容混在一起。
验收标准:同一批源文件连续编译两次,输出逐字节一致。
场景 2:团队文档的断链巡检
在 CI 中运行编译器,并把 broken link: 视为失败,把 orphan page: 作为警告。这样不会因为新出现一个孤立页面就阻断所有提交,但断链不能进入主分支。
失败处理:发现断链时,不自动猜测目标页面;输出来源页面和目标标题,交给作者修复。
场景 3:LLM 只生成候选建议
确定性编译完成后,可以让 LLM 读取孤立页面清单,生成“可能相关页面”候选,但不直接写入 Related。
建议流程:
确定性编译 → Lint 找孤立页 → LLM 生成候选 → 人工确认 → 修改源文件 → 重新编译安全边界:LLM 没有写入编译产物或源文件的权限;语义建议失败时,核心 Wiki 仍然可以正常重建。
七、常见失败与修复
1. 同名标题产生相同 slug
当前实现会抛出 duplicate entity slug detected。不要静默覆盖文件;应在源笔记中引入明确命名空间或稳定 ID。
2. 正则误匹配词语片段
实现使用单词边界降低误匹配,但对中文等无空格语言并不充分。中文知识库应改用显式别名表或经过测试的分词策略,不要假装现有规则支持所有语言。
3. 人工内容放错区段
除 Notes 外的区域都会被覆盖。可在团队规范中明确区段所有权,并在写入前保留备份或使用 Git 检查差异。
4. 文件数增加后变慢
先分别测量提取、建图、重写和 Lint,不要凭直觉优化。原文在 5,000 个文件时发现 Lint 占总耗时 56%,瓶颈主要是文件 I/O,而不是图构建。
八、上线前检查清单
- [ ] 源文件和编译产物分目录保存
- [ ] 人工区段与机器区段有明确所有权
- [ ] 重复编译输出保持一致
- [ ] 有人工内容保留测试
- [ ] Lint 只统计真实出边
- [ ] 断链会导致 CI 失败,孤立页面只告警
- [ ] 未经确认,LLM 不能写入源文件
- [ ] 扩展语义链接前,先保留可关闭和可回滚路径
结论
知识库中“解析已有文本、生成固定结构、计算链接和检查断链”这些工作,本质上更像编译,而不是推理。先把可确定的 90% 做成可测试、可重复、可回滚的流水线,再把 LLM 留给真正需要语义判断的 10%,通常比让 Agent 接管整个 Wiki 更便宜,也更容易维护。
来源与验证说明
- 原文:Towards Data Science
- 原作者项目:wiki-compiler
- 原文报告了 17 个测试及 100、1,000、5,000 文件的跨平台基准;本教程的教学实现独立编写。
- 本教程的代码验证等级将在发布记录中区分:静态文档检查、示例代码本地运行和真实外部后端验证。该项目不使用外部后端。