Skip to content

Instantly share code, notes, and snippets.

@PttCodingMan
Last active August 26, 2026 07:48
Show Gist options
  • Select an option

  • Save PttCodingMan/b28d52ce586d76e65971ada7d214da3a to your computer and use it in GitHub Desktop.

Select an option

Save PttCodingMan/b28d52ce586d76e65971ada7d214da3a to your computer and use it in GitHub Desktop.
Markdown 知識庫 Chat Bot 開發規範(Agentic Retrieval, manifest 優先)

Markdown 知識庫 Chat Bot 開發規範

適用規模:檔案數十至數百個、單檔 500–2000 字(合計約 0.1–1 MB)。 主變數是檔案數,不是總 MB —— manifest 隨檔案數線性成長,單檔大小則由 §2.1 的字數上限管住。

架構決策:Agentic Retrieval,manifest 優先。不使用向量資料庫,不做執行時 chunking。 檔案系統即索引,front matter 即 metadata,模型自行決定讀什麼。


目錄

  1. 架構總覽
  2. 文件庫規範
  3. Manifest 生成
  4. 工具層
  5. Agent Loop
  6. System Prompt
  7. 新知識匯入流程
  8. 既有知識庫遷移
  9. CI 檢查
  10. 評測
  11. 維運指標
  12. 目錄骨架

1. 架構總覽

使用者提問
    ↓
System Prompt(含完整 manifest,prompt cache 常駐)
    ↓
模型從 manifest 直接判斷需要哪些檔案
    ↓
read_files([3-5 個檔])          ← 主要路徑(目標 80%+)
    或
grep(多同義詞) → read_files()    ← fallback(目標 <20%)
    ↓
回答 + 引用 `path.md#章節`

為什麼不用向量檢索

向量檢索 Agentic Retrieval
結構資訊 壓扁成浮點向量,檔名/階層/scope 全部丟失 完整保留,模型看得到
失敗可觀測性 只看到爛答案,不知為何 完整 tool call log,看得到 grep 了什麼
中文同義詞 靠 embedding 語意(不穩定) 靠模型一次給多候選詞(可控)
重試 一次射歪就沒救 可多輪換策略
基礎設施 向量 DB + embedding pipeline + 重建流程 ripgrep + 檔案系統

數 MB 規模下,向量檢索的唯一優勢(大規模語意召回)用不到,但缺點全部承擔。

何時該重新評估

出現以下任一情況,再考慮加 semantic_search 工具(sqlite-vec 即可,架構不用重寫):

  • 檔案數超過 2000(兩階段 manifest 的目錄層也開始選錯目錄)
  • 評測顯示「使用者用完全不同詞彙描述同一概念」的 miss 率 > 15%

manifest 變大本身不是上向量的理由——先照 §3.4 轉成兩階段,那條路還很長。


2. 文件庫規範

2.1 檔案粒度

判準不是字數,是這一句:

這個檔案能不能單獨回答一個真實使用者會問的問題?

  • 「要配另一個檔一起看才行」→ 兩個應該合併
  • 「這個檔回答了三個不相干的問題」→ 應該拆開
  • related 欄位中某兩個檔永遠成對出現 → 合併訊號

字數僅供參考:500–2000 字是建議的甜蜜點;lint 的硬門檻是 200–2500 字(見 §9)。甜蜜點是建議,lint 門檻是硬線。

不要拆過頭。 拆到 100–200 字只是把 chunking 從執行時搬到檔案系統,代價是:

  • 每檔的 front matter 開銷佔比暴增
  • 單次查詢要拉 8+ 個檔,模型選檔錯誤率上升
  • related 網絡爆炸,維護成本失控

2.2 Front Matter Schema

每個檔案都必須有。沒有 front matter 的檔案不進 manifest,等於不存在。

---
title: Token 輪替
scope: 內部 API Gateway
summary: 90 天過期週期、/refresh 端點取得新 token、5 分鐘寬限期
keywords: [token, 權杖, 輪替, 換發, refresh, 過期]
related:
  - auth/oauth-flow.md
  - auth/api-key-rotation.md  # 不同機制,勿混用
updated: 2026-08-18
status: current
---
欄位 必填 說明
title ✅ 人類可讀標題,與 H1 一致
scope ✅ 消歧欄位,最重要的一個
summary ✅ 一句話,說明「這裡有什麼」而非「這是關於什麼的」
keywords ✅ 同義詞集合,含中英、口語、正式用語
related 相關檔案路徑(相對於 knowledge 根),含關係註解
updated ✅ ISO 日期
status ✅ current / deprecated / draft

2.3 scope 為何是最重要的欄位

檔案拆細之後,原本靠上下文提供的消歧資訊被拆掉了。

# Token 輪替

預設 90 天過期。呼叫 /refresh 取得新 token,舊 token 有 5 分鐘寬限期。

單獨看完全合理。但這是哪個系統的 token? 如果知識庫裡有兩套 token 機制,模型會很有自信地拿錯一套來回答——而且聽起來完全正確。

scope 讓這個判斷在 manifest 階段就完成,不用讀進去才發現拿錯。

scope 的寫法:寫「這份文件在講哪個系統/產品/環境」,不是寫分類。

  • ✅ 內部 API Gateway、第三方整合、production 環境、PyPtt v1.x
  • ❌ 認證、後端、技術文件

2.4 summary 的寫法

manifest 的檢索準確率幾乎完全取決於 summary 品質。

# ❌ 說明「這是關於什麼的」——無法用來判斷該不該讀
summary: 說明 token 輪替的相關機制

# ✅ 說明「這裡面有什麼」——可以直接判斷
summary: 90 天過期週期、/refresh 端點取得新 token、5 分鐘寬限期

規則:列出這個檔能回答的具體事實,用頓號分隔,不要寫成句子。

2.5 keywords 與中文檢索

中文沒有詞界,這對 grep 是雙面刃:

好處:登入 不會誤中 author/authentic 這種英文才有的子字串汙染,精確度天生較高。

壞處:同義詞完全沒有 fallback。文件寫「登錄」,使用者問「登入」,直接 miss。

已知的坑:\b word boundary 在 CJK 完全無效。ripgrep 用的 Rust regex 認定中文字元不是 \w,所以 \b登入\b 永遠不會 match。中文一律用純子字串,不要加 boundary。

keywords 就是為了補這個洞。每個檔案至少列出:

  • 正式術語(登入)
  • 口語變體(登錄、簽入)
  • 英文原文(login、sign in)
  • 縮寫(若有)

2.6 Markdown 本文規範

# Token 輪替

> 一句話結論放最前面。模型讀到這行就能回答大部分問題。

## 過期週期

...

## /refresh 端點

...

## 常見錯誤
  • H1 唯一,與 title 一致
  • H2 是檢索單位:read_section 以 H2 為邊界,所以每個 H2 底下要自成完整段落
  • 不要用 H4 以下:階層太深代表該拆檔了
  • 表格不跨節:一個表格必須完整落在一個 H2 底下
  • 程式碼區塊標語言:便於 grep 過濾
  • 第一段寫結論:模型常常只讀開頭就足夠回答

2.7 目錄結構

用領域分目錄,不要用文件類型分。

✅ knowledge/auth/、knowledge/deploy/、knowledge/api/
❌ knowledge/tutorials/、knowledge/reference/、knowledge/how-to/

原因:模型從 manifest 選檔時是靠「這問題屬於哪個領域」推理的,不是靠「使用者想要教學還是參考」。

深度最多兩層。第三層代表領域切太細,該用 scope 區分而非目錄。


3. Manifest 生成

3.1 產出格式

=== auth ===
auth/token-rotation.md [內部 API Gateway] — 90 天過期週期、/refresh 端點取得新 token、5 分鐘寬限期
  keywords: token, 權杖, 輪替, 換發, refresh, 過期
  §: 過期週期 | /refresh 端點 | 常見錯誤

auth/api-key-rotation.md [第三方整合] — API key 手動輪替流程、雙 key 並行期、撤銷步驟
  keywords: api key, 金鑰, 輪替, 撤銷
  §: 申請新 key | 並行期設定 | 撤銷舊 key
  • [scope] 緊跟檔名,讓消歧在掃描時就完成
  • § 列出所有 H2,讓模型能直接用 read_section
  • deprecated 的檔案標 [已淘汰] 前綴,仍列出(使用者可能問到舊行為)
  • draft 不進 manifest

3.2 生成腳本

#!/usr/bin/env python3
"""build_manifest.py — 從 front matter 生成 manifest,不需要 LLM。"""

from __future__ import annotations

import hashlib
import json
import re
import sys
from pathlib import Path

import yaml

KNOWLEDGE_ROOT = Path("knowledge")
MANIFEST_OUT = Path("build/manifest.txt")
HASH_OUT = Path("build/manifest.hash")

FM_RE = re.compile(r"\A---\n(.*?)\n---\n(.*)", re.S)
H2_RE = re.compile(r"^## (.+)$", re.M)

REQUIRED = ("title", "scope", "summary", "keywords", "updated", "status")


def strip_code_fences(text: str) -> str:
    return re.sub(r"```.*?```", "", text, flags=re.S)


def parse_doc(path: Path) -> dict | None:
    text = path.read_text(encoding="utf-8")
    m = FM_RE.match(text)
    if not m:
        print(f"警告:{path} 缺少 front matter,略過", file=sys.stderr)
        return None

    fm = yaml.safe_load(m.group(1)) or {}
    missing = [k for k in REQUIRED if k not in fm]
    if missing:
        print(f"警告:{path} front matter 缺少欄位 {missing},略過", file=sys.stderr)
        return None

    if fm["status"] == "draft":
        return None

    return {
        "path": str(path.relative_to(KNOWLEDGE_ROOT)),
        "dir": str(path.parent.relative_to(KNOWLEDGE_ROOT)) or ".",
        "scope": fm["scope"],
        "summary": fm["summary"],
        "keywords": fm["keywords"],
        "sections": H2_RE.findall(strip_code_fences(m.group(2))),
        "deprecated": fm["status"] == "deprecated",
    }


def render(docs: list[dict]) -> str:
    out: list[str] = []
    for group in sorted({d["dir"] for d in docs}):
        out.append(f"=== {group} ===")
        for d in sorted((x for x in docs if x["dir"] == group),
                        key=lambda x: x["path"]):
            prefix = "[已淘汰] " if d["deprecated"] else ""
            out.append(f'{prefix}{d["path"]} [{d["scope"]}] — {d["summary"]}')
            out.append(f'  keywords: {", ".join(d["keywords"])}')
            if d["sections"]:
                out.append(f'  §: {" | ".join(d["sections"])}')
            out.append("")
    return "\n".join(out)


def main() -> None:
    docs = [d for p in sorted(KNOWLEDGE_ROOT.rglob("*.md"))
            if (d := parse_doc(p)) is not None]

    manifest = render(docs)
    digest = hashlib.sha256(manifest.encode()).hexdigest()

    if HASH_OUT.exists() and HASH_OUT.read_text().strip() == digest:
        print(f"{len(docs)} 個檔案,未變更")
        return

    MANIFEST_OUT.parent.mkdir(parents=True, exist_ok=True)
    MANIFEST_OUT.write_text(manifest, encoding="utf-8")
    HASH_OUT.write_text(digest)

    print(f"{len(docs)} 個檔案,manifest 約 {len(manifest)} tokens")


if __name__ == "__main__":
    main()

3.3 快取策略

manifest 放在 system prompt 最前面,開 prompt caching。

manifest 內容變更 → cache 失效 → 該次請求成本上升。 所以:

  • 用 hash 判斷是否真的變了,沒變就不重建
  • 不要在 manifest 裡放時間戳或任何每次都會變的東西
  • 批次更新文件,不要一天推十次
  • 檔案數越多,cache 失效越頻繁:每天有人改到某個檔的機率隨檔案數上升,而 manifest 是全有全無的。上百個檔以後把重建排成每日一次的批次,不要每次 commit 就重建

3.4 容量預算

檔案數 manifest 大小 處置
< 100 < 10k tokens 正常,全量放入
100–250 10–25k tokens 上限區,精簡 § 與 keywords 還能再撐一段
> 250 > 25k tokens 改成兩階段:先給目錄層 manifest,模型選定目錄後再載入該目錄詳細 manifest

換算基礎:每個檔案在 manifest 裡約佔 80–100 tokens(§3.1 的範例實測約 66,真實文件的 keywords 與 H2 更多)。中文約 1 字 1 token。

要精算就直接對產出的 manifest.txt 跑一次 token 計數,不要用字數推。兩階段 manifest 大約可以撐到 2000 個檔(約 7 MB),再往上見 §1「何時該重新評估」。


4. 工具層

4.1 read_files(主力工具)

檔案小而多,模型一次通常需要 3–5 個檔。只提供單檔讀取會導致 N 次 round trip、N 次完整 context 重送,延遲與成本線性疊加。

def read_files(paths: list[str]) -> str:
    """一次讀取多個檔案的完整內容。

    檔案小的時候一次給 5-8 個沒問題。看完 manifest 後
    應該把需要的檔案一次列出來,不要一個一個試。
    """
    MAX_TOTAL = 8000  # tokens,約 8000 中文字
    parts, used = [], 0

    for p in paths:
        path = (KNOWLEDGE_ROOT / p).resolve()
        if not path.is_relative_to(KNOWLEDGE_ROOT.resolve()):
            parts.append(f"<file path='{p}'>路徑越界,拒絕讀取</file>")
            continue
        if not path.exists():
            parts.append(f"<file path='{p}'>檔案不存在</file>")
            continue

        body = path.read_text(encoding="utf-8")
        cost = len(body)
        if used + cost > MAX_TOTAL:
            parts.append(
                f"<file path='{p}'>已達總量上限,未讀取。"
                f"請分批或改用 read_section。</file>"
            )
            continue
        used += cost
        parts.append(f"<file path='{p}'>\n{body}\n</file>")

    return "\n\n".join(parts)

4.2 read_section

模型 grep 到第 340 行有東西之後,它真正想要的是「這行所屬的那一節」,不是「340 到 380 行」。少了這個工具,模型會反覆試探行號範圍,浪費輪次。

def read_section(path: str, heading: str) -> str:
    """讀取指定 H2 標題到下一個同級標題之間的內容。

    heading 可以是部分比對(不分大小寫)。
    """
    full = (KNOWLEDGE_ROOT / path).resolve()
    if not full.is_relative_to(KNOWLEDGE_ROOT.resolve()):
        return "路徑越界,拒絕讀取"
    if not full.exists():
        return "檔案不存在"

    text = full.read_text(encoding="utf-8")
    lines = text.split("\n")

    start = next(
        (i for i, ln in enumerate(lines)
         if ln.startswith("## ") and heading.lower() in ln.lower()),
        None,
    )
    if start is None:
        avail = [ln[3:] for ln in lines if ln.startswith("## ")]
        return f"找不到章節「{heading}」。可用章節:{' / '.join(avail)}"

    end = next(
        (i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")),
        len(lines),
    )
    return "\n".join(lines[start:end])

4.3 grep(fallback)

必須分兩段。 先 -l 拿檔名,模型判斷相關性,再針對性讀取。直接噴全文 match 是 token 黑洞——一個常見詞可以瞬間吃掉 50k。

import subprocess


def grep(pattern: str, files_only: bool = True, max_results: int = 40) -> str:
    """在文件庫中搜尋。

    pattern 支援 regex。中文請用 | 串接多個同義詞,
    例如 "登入|登錄|login|sign.?in"。
    注意:\\b word boundary 對中文無效,不要使用。
    """
    cmd = ["rg", "--no-heading", "-i", "-n", "--type", "md"]
    cmd += ["-l"] if files_only else ["-C", "2", "-m", "3"]
    cmd += ["-e", pattern, "--", str(KNOWLEDGE_ROOT)]

    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
    except subprocess.TimeoutExpired:
        return "搜尋逾時,請縮小範圍或改用更精確的關鍵字。"
    if r.returncode == 1:
        return "沒有結果。試試其他同義詞,或改用 manifest 判斷。"
    if r.returncode != 0:
        return f"搜尋錯誤(可能是 regex 語法問題):{r.stderr[:200]}"

    lines = r.stdout.strip().split("\n")
    if len(lines) > max_results:
        return (
            "\n".join(lines[:max_results])
            + f"\n\n(還有 {len(lines) - max_results} 筆,關鍵字過於廣泛,請縮小範圍)"
        )
    return r.stdout

4.4 工具設計原則

  • 每個工具的 docstring 就是給模型的說明書,寫清楚使用時機
  • 錯誤訊息要可行動:不要只說「找不到」,要說「可用章節有 A/B/C」
  • 截斷必須明講:模型會學會調整,但你必須告訴它被截斷了,否則它會以為看到全部
  • 路徑檢查:is_relative_to 防止 ../../etc/passwd

5. Agent Loop

MAX_TURNS = 8

必須設上限。 沒有上限的話,找不到東西時模型會無止境地換關鍵字重試,燒掉整個 context window 然後回一句「找不到」。

def run(user_query: str, history: list[dict]) -> str:
    query = rewrite_query(user_query, history)
    messages = history + [{"role": "user", "content": query}]

    for turn in range(MAX_TURNS):
        resp = client.messages.create(
            model=MODEL,
            system=[
                {"type": "text", "text": SYSTEM_PROMPT,
                 "cache_control": {"type": "ephemeral"}},
            ],
            messages=messages,
            tools=TOOLS,
            max_tokens=4096,
        )

        messages.append({"role": "assistant", "content": resp.content})

        tool_uses = [b for b in resp.content if b.type == "tool_use"]
        if not tool_uses:
            return "".join(b.text for b in resp.content if b.type == "text")

        results = [
            {"type": "tool_result", "tool_use_id": tu.id,
             "content": dispatch(tu.name, tu.input)}
            for tu in tool_uses
        ]
        messages.append({"role": "user", "content": results})

    # 用盡輪次:強制收斂。塞進最後一則 tool_result 訊息的 content 裡,
    # 不要另外 append 一則 user 訊息(role 必須交替)。
    messages[-1]["content"].append({
        "type": "text",
        "text": "已達檢索上限。請用目前已讀取的資訊回答,"
                "並明確說明哪些部分無法從文件確認。",
    })
    final = client.messages.create(
        model=MODEL,
        system=[
            {"type": "text", "text": SYSTEM_PROMPT,
             "cache_control": {"type": "ephemeral"}},
        ],
        messages=messages,
        max_tokens=4096,
    )
    return "".join(b.text for b in final.content if b.type == "text")

多輪對話的 query rewriting

使用者問完「PyPtt 怎麼登入」接著問「那如果失敗呢」——第二句丟去檢索是零訊號。

在檢索前先改寫成獨立問題:

REWRITE_PROMPT = """根據對話歷史,把使用者最新的問題改寫成一個獨立、
完整、不依賴上文的問題。只輸出改寫後的問題,不要任何其他內容。
如果問題本身已經獨立,原樣輸出。"""


def rewrite_query(user_query: str, history: list[dict]) -> str:
    if not history:
        return user_query
    resp = client.messages.create(
        model=HAIKU_MODEL,
        system=REWRITE_PROMPT,
        messages=history + [{"role": "user", "content": user_query}],
        max_tokens=200,
    )
    return "".join(b.text for b in resp.content if b.type == "text")

用小模型(Haiku)跑即可,成本可忽略。


6. System Prompt

你是一個文件助理,只根據提供的 markdown 知識庫回答問題。

## 知識庫索引

格式說明:
  路徑 [scope] — 這個檔案包含的內容
    keywords: 同義詞
    §: 章節列表

<manifest>
{MANIFEST}
</manifest>

## 檢索策略

1. **優先看 manifest 直接判斷**。這是主要路徑。注意 [scope] 標籤,
   不同 scope 的同名概念是不同的東西,不要混用。
2. **一次讀取所有需要的檔案**。看完 manifest 後把 3-5 個相關檔案
   一次列進 read_files,不要一個一個試。
3. **只在 manifest 不夠明確時才 grep**。grep 時一次給多個同義詞,
   用 | 串接,中英混搭:`登入|登錄|login|sign.?in`
   注意 \b 對中文無效,不要使用。
4. grep 先用 files_only=True,再針對性讀取。

## 回答規則

- 每個事實都要標註來源:`path/to/file.md#章節名`
- 文件中沒有的內容,明說「文件中沒有提到 X」。
  **不要用一般知識補充。** 這是最重要的規則。
- 文件互相矛盾時,兩邊都列出並指出衝突,附上各自的 updated 日期,
  不要自行選一個。
- 讀到標記 [已淘汰] 的檔案時,說明這是舊行為並指出現行文件。
- 不確定使用者問的是哪個 scope 時,直接問清楚,不要猜。

為什麼「矛盾時兩邊都列」很重要

數 MB 規模的文件庫幾乎必然存在過期沒刪的舊版說明。模型預設會挑一個講得很有自信,而挑錯的機率是 50%。強制列出衝突,等於把這個錯誤轉成使用者可見的訊號——而且這是你發現該清理哪些文件的最佳來源。


7. 新知識匯入流程

這是整個系統長期品質的關鍵。 檢索架構寫好就不太會動,但知識匯入是每週都在發生的事,品質退化都是從這裡開始。既有知識庫的一次性遷移見 §8。

7.1 決策樹

新知識進來時,第一步永遠是搜尋現有文件,不是開新檔。

新知識
  │
  ├─ 先執行:rg -i "關鍵詞|同義詞1|同義詞2" knowledge/
  │  並檢查 manifest 是否有相近 summary
  │
  ├─ 找到高度相關的既有檔案?
  │   │
  │   ├─ 是 → 內容衝突嗎?
  │   │        ├─ 是 → 【路徑 A:更新取代】
  │   │        └─ 否 → 是同一個問題的補充嗎?
  │   │                 ├─ 是 → 【路徑 B:就地擴充】
  │   │                 └─ 否 → 【路徑 C:新增 + 建立 related】
  │   │
  │   └─ 否 → 這個知識能單獨回答一個使用者問題嗎?
  │            ├─ 是 → 【路徑 C:新增】
  │            └─ 否 → 【路徑 D:暫存待累積】
  │
  └─ 既有檔案擴充後 > 2500 字? → 【路徑 E:拆分】

7.2 路徑 A:更新取代(有衝突)

最容易出錯的路徑。 不要直接覆寫,因為使用者可能還在問舊行為。

1. 判斷舊資訊是「錯誤」還是「已淘汰」
   - 錯誤(本來就寫錯)→ 直接改,updated 更新
   - 已淘汰(曾經正確,現在變了)→ 見下

2. 已淘汰的處理:
   a. 新行為寫進現有檔案(或新檔)
   b. 舊行為若仍有人在用 → 移到 legacy/ 目錄,status: deprecated
   c. 新檔的 related 加上舊檔,註解寫「舊版行為」
   d. 舊檔開頭加:> ⚠️ 此為舊版行為,現行做法見 `新檔路徑.md`

3. 舊行為完全沒人在用 → 直接刪除,並清理所有指向它的 related

判斷標準:如果有任何使用者可能還跑在舊版上,就保留 deprecated。文件庫留一個 deprecated 檔的成本,遠低於模型拿舊資訊自信回答的成本。

7.3 路徑 B:就地擴充

1. 加內容到適當的 H2 底下(沒有適當的就新增 H2)
2. 【必做】更新 front matter:
   - summary 加上新增的事實
   - keywords 加上新出現的術語
   - updated 改成今天
3. 檢查總字數,超過 2500 字 → 走路徑 E
4. 重建 manifest

最常被跳過的是第 2 步。 內容加了但 summary 沒更新,manifest 就看不到新知識,等於白加——這是 grep 使用率上升的頭號原因。

7.4 路徑 C:新增檔案

1. 決定路徑:knowledge/{領域}/{kebab-case-名稱}.md
2. 決定 scope:這是哪個系統/產品/環境的知識?
   ⚠️ 如果知識庫裡已有同概念但不同系統的檔案,
      兩邊的 scope 都必須寫得能一眼區分
3. 寫 front matter(六個必填欄位)
4. 寫本文:H1 → 一句話結論 → H2 分節
5. 【雙向】建立 related:
   - 新檔的 related 指向相關檔案
   - 【必做】相關檔案的 related 也要加回新檔
6. 重建 manifest,跑 CI 檢查

related 是雙向的。 單向連結會造成:使用者從 B 檔進來,永遠不知道 A 檔存在。這是拆細檔案之後最常見的知識孤島成因。

7.5 路徑 D:暫存待累積

知識太零碎、無法單獨回答問題時(例如一條 30 字的注意事項):

knowledge/_inbox/YYYY-MM.md

同一個月的碎片累積在一起,月底檢視:

  • 有 3 條以上屬於同一主題 → 合併成正式檔案
  • 屬於既有檔案的補充 → 走路徑 B 併進去
  • 三個月無人聞問 → 刪除

_inbox/ 目錄的 status 一律 draft,不進 manifest。

7.6 路徑 E:拆分

檔案超過 2500 字時:

1. 檢視 H2 結構,找出天然的切分點
   (通常是「這兩節的讀者根本不同」的地方)
2. 拆成 2-3 個檔案,每個都要能單獨回答問題
3. 【必做】互相建立 related
4. 檢查是否有其他檔案的 related 指向原檔案 → 更新指向正確的新檔
5. 原路徑若已被外部引用 → 保留為索引檔,內容只有指向新檔的連結

第 4 步最容易漏。 拆檔後有殘留的 related 指向已消失的內容,會讓模型讀到一個空殼檔案。

7.7 匯入 Checklist

每次匯入完成前逐項確認:

□ 有先搜尋既有文件(rg + manifest),確認不是重複
□ front matter 六個必填欄位齊全
□ scope 能與知識庫中的相似檔案明確區分
□ summary 列出的是「具體事實」而非「主題描述」
□ keywords 涵蓋:正式術語 / 口語 / 英文 / 縮寫
□ related 是雙向的(對方檔案也加了回來)
□ updated 是今天
□ 單檔字數在 200-2500 之間(lint 硬門檻,建議落在 500-2000 甜蜜點)
□ 表格沒有跨 H2
□ 與既有文件無衝突(或衝突已按路徑 A 處理)
□ manifest 已重建
□ CI 檢查通過
□ 【建議】加一題進評測集

最後一項是長期品質的關鍵:每次新增知識就加一題評測,評測集會隨知識庫自然成長,不用另外投入時間。

7.8 給 LLM 代寫時的指示

如果你用 LLM 幫忙整理新知識進來,把這段給它:

你要把一份新知識整理進 markdown 知識庫。

【第一步,不可跳過】先用 grep 搜尋知識庫,確認這個知識是否已存在。
搜尋時一次給多個同義詞(中英都要)。回報搜尋結果後再繼續。

【第二步】根據搜尋結果決定:更新既有檔案 / 新增檔案 / 併入既有檔案。
說明你的判斷依據。

【第三步】執行。若新增檔案,front matter 必須包含:
title, scope, summary, keywords, related, updated, status

其中:
- scope 要能與知識庫中相似主題的檔案區分開
- summary 要列出「具體有哪些事實」,用頓號分隔,不要寫成句子
- keywords 要包含正式術語、口語說法、英文原文

【第四步】列出所有需要修改 related 的既有檔案(雙向連結)。

【禁止】
- 不要在沒搜尋的情況下直接新增檔案
- 不要為了「完整」而補充原始知識中沒有的內容
- 不確定 scope 時,停下來問,不要猜

8. 既有知識庫遷移

適用於「已經有一整個舊知識庫,要一次全部搬進來」的情境,不是 §7 那種持續性的單筆匯入。 兩者的失敗模式不同:§7 錯一筆,頂多這一筆品質差;一次性遷移的核心風險是同一天要產生全庫的 front matter,卻沒有回饋迴路——manifest 品質直接決定整個系統的準確率,而你要等到全部搬完、上線之後才看得到結果。順序和紀律因此比 §7 更重要,不是可以邊做邊調的事。

8.1 順序不可換

盤點(8.2)
  ↓
定 scope 清單(8.3)
  ↓
刪除(8.4)
  ↓
合併(8.4)
  ↓
拆長文(8.5)
  ↓
批次回填 front matter(8.6)
  ↓
補 related 雙向(8.7)
  ↓
lint 全綠(8.7)
  ↓
寫 30–50 題評測(8.8)
  ↓
上線
  ↓
修 summary(8.9)

回填 front matter 必須排在刪除/合併/拆長文全部做完之後。 順序反了,代表你先幫一批檔案寫好 summary,才發現其中兩個要合併、一個要刪、一個要拆成三個——這些操作全部會讓 summary 失真,等於那一批 front matter 要重寫一輪。批次回填是整段遷移裡最貴的工,只能做一次。

8.2 盤點

先量再動,不要憑印象判斷「這個舊庫大概還好」。要量四件事:檔案總數、字數分布(找出超過 2500 字的長文)、疑似重複主題、明顯過期的檔案。這是一次性工作,直接用 shell 跑:

# 檔案總數
find knowledge -name '*.md' | wc -l

# 字數分布,抓出超過 2500 字的長文(門檻與 §2.1 一致)
find knowledge -name '*.md' -exec wc -m {} \; | awk '$1 > 2500 {print}' | sort -rn

# 疑似重複主題:同一組關鍵詞出現在多個檔案,換你自己的領域關鍵詞
rg -l -i '關鍵詞1|關鍵詞2|關鍵詞3' knowledge/

# 明顯過期:最後更新超過一年的檔案,人工複核而非直接刪
# ⚠️ 不要用 mtime:舊庫剛複製進 knowledge/ 時所有檔案的 mtime 都是新的,永遠回空
git -C knowledge ls-files '*.md' | while read -r f; do
  echo "$(git -C knowledge log -1 --format=%ad --date=short -- "$f") $f"
done | sort
# 舊庫沒進版控 → 只能翻文件內自己記載的日期,或問當初的作者

跑完產出兩份清單:不搬的、要合併的。

**什麼不要搬,這一步比想像中重要:**會議記錄、聊天紀錄、已經沒人在跑的舊版說明、只有一個人在乎的私人筆記。這些東西進了知識庫只會拉低 grep 精準度、佔 manifest 空間,卻沒有人會真的問。一次性遷移是刪東西成本最低的時機——散落在舊庫裡的時候刪一個沒人有感覺,等它被回填成 front matter、進了 manifest、被使用者查到過一次,之後想刪就要驗證沒人依賴,成本高一個量級。錯過這次窗口,這些檔案會留到下一次遷移。

8.3 先定 scope 清單,這步不能外包給 LLM

在動任何 front matter 之前,先把「這個知識庫涵蓋哪幾個系統/產品/環境」列成一張定案清單,之後所有回填只能從這張清單選,不能邊搬邊發明。

為什麼不能讓 LLM 邊寫邊定: scope 的價值在於跨檔消歧(見 §2.3)——兩個檔案都在講「token」,靠 scope 讓模型分得清是哪一套機制。這個判斷需要看到全庫的分布才做得出來。單檔代寫的 LLM 只看得到眼前這一篇,看不到其他 50 篇裡還有幾套同名機制,於是必然退化成「認證」「後端」這種 §2.3 明文禁止的分類詞——因為分類詞不需要知道全局,事實消歧才需要。

做法:人工過一遍盤點清單,列出知識庫實際涵蓋的系統/產品/環境,例如:

內部 API Gateway
第三方整合
production 環境
staging 環境
PyPtt v1.x

這張清單定案後才進 8.6。中途發現漏了一個系統,回來加清單,不要讓執行回填的 LLM 自己補。

8.4 刪除與合併

舊庫幾乎必然有多份文件在講同一件事——同一個功能被兩個人各寫過一次說明,或是舊版文件沒人清。刪除的部分已經在 8.2 產出清單;合併時的衝突處理方式與 §7.2 路徑 A 完全相同,不重複寫一遍。

遷移特有的一點:舊庫的內部連結(wiki link、相對路徑連結)是 related 最好的現成來源。 轉成 markdown 檔的過程中很容易只留純文字、把連結洗掉,轉檔時把這些連結先抄出來存著,8.7 補雙向 related 時直接就是現成清單,不用回頭重新盤點兩篇文件的關聯。

8.5 拆長文

判準沿用 §2.1,不重複寫。遷移特有的地方:舊文件常見的形態是「一篇長文涵蓋一個大主題」(例如一份橫跨整個部署流程的說明),天然的切分點通常就在 H2 標題處——舊作者當初分節的地方,往往就是讀者關心的問題邊界。拆完之後逐檔檢查:每個檔案都要能單獨回答一個問題,答不出來就代表切錯位置或還要再拆一次。

8.6 批次回填 front matter

整段遷移裡工作量最大的一步,適合交給 LLM 做,但兩個地方比 §7.8 的單筆匯入嚴格:

以同一領域為一批餵,不要逐檔單獨跑。 讓模型在同一個 context 裡看到同批鄰居檔案,才寫得出彼此不撞車的 summary、前後一致的 scope 用詞。逐檔單獨跑省不了多少 token,卻會讓同一領域的十個檔案各自寫出風格、用詞都不一樣的 summary,回頭還要再校一輪。

prompt 必須附上 8.3 定案的 scope 清單,並明文禁止模型自創 scope。 不確定屬於哪個 scope 就標記出來留給人判斷,不准用猜的塞一個進去——猜錯的 scope 比空著更糟,因為 lint 檢查不出「猜錯」,只檢查不出「聽起來很像但是錯的」。

你要幫一批既有的 markdown 文件批次回填 front matter,這批文件屬於同一個領域,
你可以看到彼此的內容,請利用這一點讓 summary 和 scope 前後一致。

【scope 清單,只能從這裡面選,不准自創】
{8.3 定案的 scope 清單}

【任務】為每個檔案產生 front matter:
title, scope, summary, keywords, related, updated, status

其中:
- scope 只能是清單裡的其中一個。不確定屬於哪個 → 標記「[需人工確認 scope]」,不要猜
- summary 列出「具體有哪些事實」,用頓號分隔,不要寫成句子
- keywords 包含正式術語、口語說法、英文原文
- related 先看我提供的舊連結對照表,同批文件間也要互相標註
- updated 填原文最後更新的日期,查不到才填遷移當天。不要一律填今天——
  這個欄位是之後判斷兩份說法衝突時誰比較新的唯一依據

【禁止】
- 不要改寫本文,也不要補充原始內容裡沒有的事實,front matter 只能從既有內容萃取
- 不要為了讓 summary 好看而誇大或延伸原文沒寫的結論
- scope 清單以外的詞一律不准用,不確定就標記,不要猜

回填完先過一遍所有 [需人工確認 scope] 標記再進下一步。

8.7 related 與 lint

補完 8.4 留下的雙向連結後跑 §9 的 lint 腳本。全庫一開始批次回填完,lint 幾乎必然大量 fail——這是正常的,遷移過程中用「錯誤數下降」當進度條,逐批修,不用每修一個就重跑全部。但上線閘門是全綠,不是「錯誤數降到可接受範圍」就算過;還有殘留錯誤代表還有沒修完的孤島連結或缺欄位,帶著已知的破洞上線,使用者會直接撞到。

8.8 上線前一次寫完 30–50 題評測(硬條件)

§7.7 的「每次新增知識加一題」在一次全搬的情境下不成立——沒有「先搬 20% 上線看使用者反應」這個中間狀態,評測集不可能像 §7 那樣隨知識庫慢慢長出來。上線前必須一次寫完 30–50 題,這是一次全搬省掉分批驗證的必要代價,不是可以先上線再補的待辦。

題目來源:實際被問過的問題(工單、舊系統的聊天紀錄、FAQ)優先於憑空想像——舊庫既然服務過使用者,這些問題大概率留有紀錄。題型至少要包含:

  • 幾題「同概念不同 scope」的消歧題,驗證 8.3 定的 scope 清單真的能讓模型分清楚
  • 幾題「文件裡真的沒有」的拒答題,驗證模型不會用一般知識瞎補

8.9 上線後第一週

只看一個數字:grep 使用率(呼應 §11 維運指標)。這個階段的失敗幾乎都出在 summary 寫得不夠準,而不是檢索策略或 system prompt 的問題——不要動 system prompt,先把觀察到的失敗案例對應回具體檔案的 summary / scope 去修(呼應文末「先修文件,再修 prompt」)。動 prompt 的代價是全庫每個問題的行為都跟著變,而多數失敗只是少數幾個檔案的 front matter 沒寫準。

8.10 遷移 Checklist

□ 已跑過盤點,產出「不搬」與「要合併」兩份清單
□ scope 清單已定案,回填期間沒有再新增過 scope
□ 刪除與合併已完成,合併衝突按 §7.2 路徑 A 處理
□ 超過 2500 字的長文已全部拆分,每個新檔能單獨回答一個問題
□ front matter 以領域為批次回填,同批 summary / scope 用詞一致
□ 所有 [需人工確認 scope] 標記已清空
□ related 雙向補完(含舊庫連結轉出來的對照表)
□ lint 全綠(不是「錯誤數變少」,是零錯誤)
□ 30–50 題評測寫完,涵蓋消歧題與拒答題
□ manifest 已重建
□ 上線後第一週只看 grep 使用率,沒有動 system prompt

9. CI 檢查

#!/usr/bin/env python3
"""lint_docs.py — 文件庫檢查,接進 pre-commit 或 CI。"""

import re
import sys
from pathlib import Path

import yaml

KNOWLEDGE_ROOT = Path("knowledge")
REQUIRED = {"title", "scope", "summary", "keywords", "updated", "status"}
VALID_STATUS = {"current", "deprecated", "draft"}


def strip_code_fences(text: str) -> str:
    return re.sub(r"```.*?```", "", text, flags=re.S)


def normalize_related(path: str) -> str:
    return path.split("#")[0].strip()


def lint() -> list[str]:
    errors: list[str] = []
    docs: dict[str, dict] = {}

    for path in KNOWLEDGE_ROOT.rglob("*.md"):
        rel = str(path.relative_to(KNOWLEDGE_ROOT))
        text = path.read_text(encoding="utf-8")

        if not text.startswith("---\n"):
            errors.append(f"{rel}: 缺少 front matter")
            continue

        raw, body = text[4:].split("\n---\n", 1)
        fm = yaml.safe_load(raw) or {}
        status = fm.get("status")

        if status not in VALID_STATUS:
            errors.append(f"{rel}: status 值不合法 '{status}'")
            continue

        # draft(如 _inbox/)是碎片累積,只檢查 front matter 存在與 status 合法
        if status == "draft":
            docs[rel] = fm
            continue

        if missing := REQUIRED - fm.keys():
            errors.append(f"{rel}: 缺少欄位 {sorted(missing)}")
            continue

        # summary 寫成主題描述而非事實列表
        if len(fm["summary"]) < 15:
            errors.append(f"{rel}: summary 過短,應列出具體事實")
        if any(w in fm["summary"] for w in ("相關", "的說明", "介紹")):
            errors.append(f"{rel}: summary 疑似主題描述,應改為事實列表")

        if len(fm["keywords"]) < 3:
            errors.append(f"{rel}: keywords 少於 3 個,中文檢索會 miss")

        # 字數(硬門檻 200-2500,建議落在 500-2000 甜蜜點)
        n = len(body)
        if n > 2500:
            errors.append(f"{rel}: {n} 字,超過 2500,考慮拆分")
        if n < 200 and fm["status"] != "deprecated":
            errors.append(f"{rel}: {n} 字,過短,考慮併入其他檔案")

        # 結構(先剝掉 fenced code block,避免程式碼裡的 # 誤判成標題)
        stripped = strip_code_fences(body)
        if stripped.count("\n# ") + stripped.startswith("# ") != 1:
            errors.append(f"{rel}: H1 必須恰好一個")
        if "\n#### " in stripped:
            errors.append(f"{rel}: 出現 H4,階層過深,應拆檔")

        docs[rel] = fm

    # related 雙向性與有效性
    for rel, fm in docs.items():
        for target in fm.get("related") or []:
            target = normalize_related(target)
            if target not in docs:
                errors.append(f"{rel}: related 指向不存在的檔案 '{target}'")
            elif rel not in {normalize_related(r) for r in (docs[target].get("related") or [])}:
                errors.append(f"{rel} → {target}: related 不是雙向的")

    # scope 撞名檢查(draft 前面已跳過大部分檢查,這裡也不比較)
    by_title: dict[str, list[str]] = {}
    for rel, fm in docs.items():
        if fm.get("status") == "draft":
            continue
        by_title.setdefault(fm["title"], []).append(rel)
    for title, paths in by_title.items():
        if len(paths) > 1:
            scopes = {docs[p]["scope"] for p in paths}
            if len(scopes) < len(paths):
                errors.append(f"標題 '{title}' 重複但 scope 未區分:{paths}")

    return errors


if __name__ == "__main__":
    if errs := lint():
        print("\n".join(f"❌ {e}" for e in errs))
        sys.exit(1)
    print("✅ 文件庫檢查通過")

related 雙向性檢查是這個腳本最有價值的部分。 它是唯一能自動抓到「知識孤島」的手段。


10. 評測

10.1 評測集格式

# eval/cases.yaml
- id: token-expiry-01
  question: token 多久會過期?
  expect_files: [auth/token-rotation.md]
  expect_sections: ["過期週期"]
  expect_contains: ["90 天"]
  must_not_contain: ["API key"]   # 防止 scope 混淆

- id: token-scope-disambiguation
  question: 金鑰多久要換一次?    # 故意模糊,不指定哪個系統
  expect_behavior: ask_clarification
  note: 有兩套機制,模型應該反問而非猜測

- id: not-in-docs-01
  question: 支援 SAML 登入嗎?
  expect_behavior: admit_unknown
  # must_not_contain 要挑不會出現在正確拒答裡的詞,這裡 admit_unknown 已足夠判斷

30–50 題起步。每次新增知識就加一題,讓評測集隨知識庫成長。

10.2 分開量兩件事

這是整個專案投報率最高的工作,也是幾乎所有人跳過的一步。

指標 量什麼 修法
檢索召回率 expect_files 是否進了 context 改 summary / keywords / scope
回答正確率 撈到之後答得對不對 改 system prompt / 文件本文寫法

混在一起量就修不動。 你會發現大部分失敗是檢索沒撈到,而不是模型不會回答——而這兩種失敗的修法完全不同。

10.3 Agentic Retrieval 的除錯優勢

失敗時你有完整的 tool call log:

Q: 金鑰多久要換一次?
  grep("金鑰|key|輪替")  → 2 files
  read_files([api-key-rotation.md])
  A: 90 天...   ❌ 拿錯系統

診斷:manifest 的 scope 沒讓模型意識到有兩套機制
修法:兩個檔的 scope 改成「內部 API Gateway」/「第三方整合」

向量檢索失敗時你只會看到一個爛答案,完全不知道為什麼。保留完整 tool call log 是這個架構的核心優勢,不要為了省 log 空間而丟掉。


11. 維運指標

每週看這四個數字:

指標 健康值 異常代表
grep 使用率 < 20% 上升 = manifest summary 品質退化,通常是新增檔案時 front matter 沒好好寫
平均輪次 2–3 上升 = 模型找不到東西在試探;檢查是不是有領域的文件太散
達 MAX_TURNS 比例 < 3% 上升 = 有整類問題檢索不到,看 log 找共同點
「文件中沒有」回答率 穩定 突然上升 = 有文件被誤刪或 status 改錯;長期偏高 = 知識庫有缺口,看這些問題在問什麼

grep 使用率是最靈敏的健康指標。 它直接反映 manifest 的品質,而 manifest 品質直接決定整個系統的準確率。

最後一項的長期值也很有價值——那是你的知識庫該補什麼的最佳來源,比任何規劃會議都準。


12. 目錄骨架

project/
├── knowledge/                # 知識庫本體
│   ├── auth/
│   │   ├── oauth-flow.md
│   │   ├── token-rotation.md
│   │   └── api-key-rotation.md
│   ├── deploy/
│   ├── api/
│   ├── legacy/              # status: deprecated
│   └── _inbox/              # status: draft,不進 manifest
│       └── 2026-08.md
│
├── build/
│   ├── manifest.txt         # 生成物,不進版控
│   └── manifest.hash
│
├── eval/
│   ├── cases.yaml
│   └── run_eval.py
│
├── scripts/
│   ├── build_manifest.py
│   └── lint_docs.py
│
├── bot/
│   ├── tools.py             # read_files / read_section / grep
│   ├── agent.py             # loop + query rewriting
│   └── prompts.py
│
├── AGENTS.md                # 給 LLM 的文件庫維護規則(第 7.8 節)
└── .pre-commit-config.yaml  # 掛 lint_docs.py

快速開始順序

  1. 定義 front matter schema,挑 5 個現有檔案手動補上 → 驗證 schema 好不好用
  2. 寫 build_manifest.py,看 manifest 長什麼樣 → 自己讀一遍,判斷得出來該讀哪個檔嗎?
  3. 補完所有檔案的 front matter,跑 lint_docs.py
  4. 實作三個工具 + agent loop
  5. 寫 30–50 題評測,跑一輪,看 grep 使用率
  6. 根據失敗案例修 summary / scope,不要先修 prompt

若是已有整個舊知識庫要一次搬進來(而非第 1、3 步這種漸進累積),第 1、3 步改走 §8 的流程。

第 6 步是重點:先修文件,再修 prompt。 大部分檢索失敗的根因在 front matter,不在 system prompt。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment