不用 LLM,也能构建自动链接的 Markdown Wiki:纯 Python 编译器实战

本教程整理自 Towards Data Science 文章 LLM Wikis Are Over-Engineered — I Replaced Mine With a Pure Python Compiler。核心方法来自原文;下面可运行的最小实现是为教学重新编写的实践补充,并非原仓库代码的逐字复制。

你将完成什么

我们要用 Python 标准库实现一个小型 Wiki 编译器:

  1. raw_notes/ 读取 Markdown 源文件;
  2. 提取标题、创建日期和正文;
  3. 按实体名称发现字面关联;
  4. 生成 [[Wiki Link]]、反向链接和结构化页面;
  5. 重新编译时保留人工填写的 ## Notes
  6. 检查断链和孤立页面;
  7. 用测试证明输出幂等、人工内容不会丢失。

整个项目不调用 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())

这里有两个关键设计:

三、第一次编译

执行:

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
这里是人工补充:后续加入多头注意力示例。

再次运行编译命令。验收标准:

五、增加自动化测试

将下面代码保存为 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,而不是图构建。

八、上线前检查清单

结论

知识库中“解析已有文本、生成固定结构、计算链接和检查断链”这些工作,本质上更像编译,而不是推理。先把可确定的 90% 做成可测试、可重复、可回滚的流水线,再把 LLM 留给真正需要语义判断的 10%,通常比让 Agent 接管整个 Wiki 更便宜,也更容易维护。

来源与验证说明