適用規模:檔案數十至數百個、單檔 500–2000 字(合計約 0.1–1 MB)。 主變數是檔案數,不是總 MB —— manifest 隨檔案數線性成長,單檔大小則由 §2.1 的字數上限管住。
架構決策:Agentic Retrieval,manifest 優先。不使用向量資料庫,不做執行時 chunking。 檔案系統即索引,front matter 即 metadata,模型自行決定讀什麼。
使用者提問
↓
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 轉成兩階段,那條路還很長。
判準不是字數,是這一句:
這個檔案能不能單獨回答一個真實使用者會問的問題?
- 「要配另一個檔一起看才行」→ 兩個應該合併
- 「這個檔回答了三個不相干的問題」→ 應該拆開
related欄位中某兩個檔永遠成對出現 → 合併訊號
字數僅供參考:500–2000 字是建議的甜蜜點;lint 的硬門檻是 200–2500 字(見 §9)。甜蜜點是建議,lint 門檻是硬線。
不要拆過頭。 拆到 100–200 字只是把 chunking 從執行時搬到檔案系統,代價是:
- 每檔的 front matter 開銷佔比暴增
- 單次查詢要拉 8+ 個檔,模型選檔錯誤率上升
related網絡爆炸,維護成本失控
每個檔案都必須有。沒有 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 |
檔案拆細之後,原本靠上下文提供的消歧資訊被拆掉了。
# Token 輪替
預設 90 天過期。呼叫 /refresh 取得新 token,舊 token 有 5 分鐘寬限期。單獨看完全合理。但這是哪個系統的 token? 如果知識庫裡有兩套 token 機制,模型會很有自信地拿錯一套來回答——而且聽起來完全正確。
scope 讓這個判斷在 manifest 階段就完成,不用讀進去才發現拿錯。
scope 的寫法:寫「這份文件在講哪個系統/產品/環境」,不是寫分類。
- ✅
內部 API Gateway、第三方整合、production 環境、PyPtt v1.x - ❌
認證、後端、技術文件
manifest 的檢索準確率幾乎完全取決於 summary 品質。
# ❌ 說明「這是關於什麼的」——無法用來判斷該不該讀
summary: 說明 token 輪替的相關機制
# ✅ 說明「這裡面有什麼」——可以直接判斷
summary: 90 天過期週期、/refresh 端點取得新 token、5 分鐘寬限期規則:列出這個檔能回答的具體事實,用頓號分隔,不要寫成句子。
中文沒有詞界,這對 grep 是雙面刃:
好處:登入 不會誤中 author/authentic 這種英文才有的子字串汙染,精確度天生較高。
壞處:同義詞完全沒有 fallback。文件寫「登錄」,使用者問「登入」,直接 miss。
已知的坑:\b word boundary 在 CJK 完全無效。ripgrep 用的 Rust regex 認定中文字元不是 \w,所以 \b登入\b 永遠不會 match。中文一律用純子字串,不要加 boundary。
keywords 就是為了補這個洞。每個檔案至少列出:
- 正式術語(登入)
- 口語變體(登錄、簽入)
- 英文原文(login、sign in)
- 縮寫(若有)
# Token 輪替
> 一句話結論放最前面。模型讀到這行就能回答大部分問題。
## 過期週期
...
## /refresh 端點
...
## 常見錯誤- H1 唯一,與
title一致 - H2 是檢索單位:
read_section以 H2 為邊界,所以每個 H2 底下要自成完整段落 - 不要用 H4 以下:階層太深代表該拆檔了
- 表格不跨節:一個表格必須完整落在一個 H2 底下
- 程式碼區塊標語言:便於 grep 過濾
- 第一段寫結論:模型常常只讀開頭就足夠回答
用領域分目錄,不要用文件類型分。
✅ knowledge/auth/、knowledge/deploy/、knowledge/api/
❌ knowledge/tutorials/、knowledge/reference/、knowledge/how-to/
原因:模型從 manifest 選檔時是靠「這問題屬於哪個領域」推理的,不是靠「使用者想要教學還是參考」。
深度最多兩層。第三層代表領域切太細,該用 scope 區分而非目錄。
=== 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_sectiondeprecated的檔案標[已淘汰]前綴,仍列出(使用者可能問到舊行為)draft不進 manifest
#!/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()manifest 放在 system prompt 最前面,開 prompt caching。
manifest 內容變更 → cache 失效 → 該次請求成本上升。 所以:
- 用 hash 判斷是否真的變了,沒變就不重建
- 不要在 manifest 裡放時間戳或任何每次都會變的東西
- 批次更新文件,不要一天推十次
- 檔案數越多,cache 失效越頻繁:每天有人改到某個檔的機率隨檔案數上升,而 manifest 是全有全無的。上百個檔以後把重建排成每日一次的批次,不要每次 commit 就重建
| 檔案數 | 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「何時該重新評估」。
檔案小而多,模型一次通常需要 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)模型 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])必須分兩段。 先 -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- 每個工具的 docstring 就是給模型的說明書,寫清楚使用時機
- 錯誤訊息要可行動:不要只說「找不到」,要說「可用章節有 A/B/C」
- 截斷必須明講:模型會學會調整,但你必須告訴它被截斷了,否則它會以為看到全部
- 路徑檢查:
is_relative_to防止../../etc/passwd
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")使用者問完「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)跑即可,成本可忽略。
你是一個文件助理,只根據提供的 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%。強制列出衝突,等於把這個錯誤轉成使用者可見的訊號——而且這是你發現該清理哪些文件的最佳來源。
這是整個系統長期品質的關鍵。 檢索架構寫好就不太會動,但知識匯入是每週都在發生的事,品質退化都是從這裡開始。既有知識庫的一次性遷移見 §8。
新知識進來時,第一步永遠是搜尋現有文件,不是開新檔。
新知識
│
├─ 先執行:rg -i "關鍵詞|同義詞1|同義詞2" knowledge/
│ 並檢查 manifest 是否有相近 summary
│
├─ 找到高度相關的既有檔案?
│ │
│ ├─ 是 → 內容衝突嗎?
│ │ ├─ 是 → 【路徑 A:更新取代】
│ │ └─ 否 → 是同一個問題的補充嗎?
│ │ ├─ 是 → 【路徑 B:就地擴充】
│ │ └─ 否 → 【路徑 C:新增 + 建立 related】
│ │
│ └─ 否 → 這個知識能單獨回答一個使用者問題嗎?
│ ├─ 是 → 【路徑 C:新增】
│ └─ 否 → 【路徑 D:暫存待累積】
│
└─ 既有檔案擴充後 > 2500 字? → 【路徑 E:拆分】
最容易出錯的路徑。 不要直接覆寫,因為使用者可能還在問舊行為。
1. 判斷舊資訊是「錯誤」還是「已淘汰」
- 錯誤(本來就寫錯)→ 直接改,updated 更新
- 已淘汰(曾經正確,現在變了)→ 見下
2. 已淘汰的處理:
a. 新行為寫進現有檔案(或新檔)
b. 舊行為若仍有人在用 → 移到 legacy/ 目錄,status: deprecated
c. 新檔的 related 加上舊檔,註解寫「舊版行為」
d. 舊檔開頭加:> ⚠️ 此為舊版行為,現行做法見 `新檔路徑.md`
3. 舊行為完全沒人在用 → 直接刪除,並清理所有指向它的 related判斷標準:如果有任何使用者可能還跑在舊版上,就保留 deprecated。文件庫留一個 deprecated 檔的成本,遠低於模型拿舊資訊自信回答的成本。
1. 加內容到適當的 H2 底下(沒有適當的就新增 H2)
2. 【必做】更新 front matter:
- summary 加上新增的事實
- keywords 加上新出現的術語
- updated 改成今天
3. 檢查總字數,超過 2500 字 → 走路徑 E
4. 重建 manifest最常被跳過的是第 2 步。 內容加了但 summary 沒更新,manifest 就看不到新知識,等於白加——這是 grep 使用率上升的頭號原因。
1. 決定路徑:knowledge/{領域}/{kebab-case-名稱}.md
2. 決定 scope:這是哪個系統/產品/環境的知識?
⚠️ 如果知識庫裡已有同概念但不同系統的檔案,
兩邊的 scope 都必須寫得能一眼區分
3. 寫 front matter(六個必填欄位)
4. 寫本文:H1 → 一句話結論 → H2 分節
5. 【雙向】建立 related:
- 新檔的 related 指向相關檔案
- 【必做】相關檔案的 related 也要加回新檔
6. 重建 manifest,跑 CI 檢查related 是雙向的。 單向連結會造成:使用者從 B 檔進來,永遠不知道 A 檔存在。這是拆細檔案之後最常見的知識孤島成因。
知識太零碎、無法單獨回答問題時(例如一條 30 字的注意事項):
knowledge/_inbox/YYYY-MM.md
同一個月的碎片累積在一起,月底檢視:
- 有 3 條以上屬於同一主題 → 合併成正式檔案
- 屬於既有檔案的補充 → 走路徑 B 併進去
- 三個月無人聞問 → 刪除
_inbox/ 目錄的 status 一律 draft,不進 manifest。
檔案超過 2500 字時:
1. 檢視 H2 結構,找出天然的切分點
(通常是「這兩節的讀者根本不同」的地方)
2. 拆成 2-3 個檔案,每個都要能單獨回答問題
3. 【必做】互相建立 related
4. 檢查是否有其他檔案的 related 指向原檔案 → 更新指向正確的新檔
5. 原路徑若已被外部引用 → 保留為索引檔,內容只有指向新檔的連結第 4 步最容易漏。 拆檔後有殘留的 related 指向已消失的內容,會讓模型讀到一個空殼檔案。
每次匯入完成前逐項確認:
□ 有先搜尋既有文件(rg + manifest),確認不是重複
□ front matter 六個必填欄位齊全
□ scope 能與知識庫中的相似檔案明確區分
□ summary 列出的是「具體事實」而非「主題描述」
□ keywords 涵蓋:正式術語 / 口語 / 英文 / 縮寫
□ related 是雙向的(對方檔案也加了回來)
□ updated 是今天
□ 單檔字數在 200-2500 之間(lint 硬門檻,建議落在 500-2000 甜蜜點)
□ 表格沒有跨 H2
□ 與既有文件無衝突(或衝突已按路徑 A 處理)
□ manifest 已重建
□ CI 檢查通過
□ 【建議】加一題進評測集
最後一項是長期品質的關鍵:每次新增知識就加一題評測,評測集會隨知識庫自然成長,不用另外投入時間。
如果你用 LLM 幫忙整理新知識進來,把這段給它:
你要把一份新知識整理進 markdown 知識庫。
【第一步,不可跳過】先用 grep 搜尋知識庫,確認這個知識是否已存在。
搜尋時一次給多個同義詞(中英都要)。回報搜尋結果後再繼續。
【第二步】根據搜尋結果決定:更新既有檔案 / 新增檔案 / 併入既有檔案。
說明你的判斷依據。
【第三步】執行。若新增檔案,front matter 必須包含:
title, scope, summary, keywords, related, updated, status
其中:
- scope 要能與知識庫中相似主題的檔案區分開
- summary 要列出「具體有哪些事實」,用頓號分隔,不要寫成句子
- keywords 要包含正式術語、口語說法、英文原文
【第四步】列出所有需要修改 related 的既有檔案(雙向連結)。
【禁止】
- 不要在沒搜尋的情況下直接新增檔案
- 不要為了「完整」而補充原始知識中沒有的內容
- 不確定 scope 時,停下來問,不要猜
適用於「已經有一整個舊知識庫,要一次全部搬進來」的情境,不是 §7 那種持續性的單筆匯入。 兩者的失敗模式不同:§7 錯一筆,頂多這一筆品質差;一次性遷移的核心風險是同一天要產生全庫的 front matter,卻沒有回饋迴路——manifest 品質直接決定整個系統的準確率,而你要等到全部搬完、上線之後才看得到結果。順序和紀律因此比 §7 更重要,不是可以邊做邊調的事。
盤點(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 要重寫一輪。批次回填是整段遷移裡最貴的工,只能做一次。
先量再動,不要憑印象判斷「這個舊庫大概還好」。要量四件事:檔案總數、字數分布(找出超過 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、被使用者查到過一次,之後想刪就要驗證沒人依賴,成本高一個量級。錯過這次窗口,這些檔案會留到下一次遷移。
在動任何 front matter 之前,先把「這個知識庫涵蓋哪幾個系統/產品/環境」列成一張定案清單,之後所有回填只能從這張清單選,不能邊搬邊發明。
為什麼不能讓 LLM 邊寫邊定: scope 的價值在於跨檔消歧(見 §2.3)——兩個檔案都在講「token」,靠 scope 讓模型分得清是哪一套機制。這個判斷需要看到全庫的分布才做得出來。單檔代寫的 LLM 只看得到眼前這一篇,看不到其他 50 篇裡還有幾套同名機制,於是必然退化成「認證」「後端」這種 §2.3 明文禁止的分類詞——因為分類詞不需要知道全局,事實消歧才需要。
做法:人工過一遍盤點清單,列出知識庫實際涵蓋的系統/產品/環境,例如:
內部 API Gateway
第三方整合
production 環境
staging 環境
PyPtt v1.x
這張清單定案後才進 8.6。中途發現漏了一個系統,回來加清單,不要讓執行回填的 LLM 自己補。
舊庫幾乎必然有多份文件在講同一件事——同一個功能被兩個人各寫過一次說明,或是舊版文件沒人清。刪除的部分已經在 8.2 產出清單;合併時的衝突處理方式與 §7.2 路徑 A 完全相同,不重複寫一遍。
遷移特有的一點:舊庫的內部連結(wiki link、相對路徑連結)是 related 最好的現成來源。 轉成 markdown 檔的過程中很容易只留純文字、把連結洗掉,轉檔時把這些連結先抄出來存著,8.7 補雙向 related 時直接就是現成清單,不用回頭重新盤點兩篇文件的關聯。
判準沿用 §2.1,不重複寫。遷移特有的地方:舊文件常見的形態是「一篇長文涵蓋一個大主題」(例如一份橫跨整個部署流程的說明),天然的切分點通常就在 H2 標題處——舊作者當初分節的地方,往往就是讀者關心的問題邊界。拆完之後逐檔檢查:每個檔案都要能單獨回答一個問題,答不出來就代表切錯位置或還要再拆一次。
整段遷移裡工作量最大的一步,適合交給 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.4 留下的雙向連結後跑 §9 的 lint 腳本。全庫一開始批次回填完,lint 幾乎必然大量 fail——這是正常的,遷移過程中用「錯誤數下降」當進度條,逐批修,不用每修一個就重跑全部。但上線閘門是全綠,不是「錯誤數降到可接受範圍」就算過;還有殘留錯誤代表還有沒修完的孤島連結或缺欄位,帶著已知的破洞上線,使用者會直接撞到。
§7.7 的「每次新增知識加一題」在一次全搬的情境下不成立——沒有「先搬 20% 上線看使用者反應」這個中間狀態,評測集不可能像 §7 那樣隨知識庫慢慢長出來。上線前必須一次寫完 30–50 題,這是一次全搬省掉分批驗證的必要代價,不是可以先上線再補的待辦。
題目來源:實際被問過的問題(工單、舊系統的聊天紀錄、FAQ)優先於憑空想像——舊庫既然服務過使用者,這些問題大概率留有紀錄。題型至少要包含:
- 幾題「同概念不同 scope」的消歧題,驗證 8.3 定的 scope 清單真的能讓模型分清楚
- 幾題「文件裡真的沒有」的拒答題,驗證模型不會用一般知識瞎補
只看一個數字:grep 使用率(呼應 §11 維運指標)。這個階段的失敗幾乎都出在 summary 寫得不夠準,而不是檢索策略或 system prompt 的問題——不要動 system prompt,先把觀察到的失敗案例對應回具體檔案的 summary / scope 去修(呼應文末「先修文件,再修 prompt」)。動 prompt 的代價是全庫每個問題的行為都跟著變,而多數失敗只是少數幾個檔案的 front matter 沒寫準。
□ 已跑過盤點,產出「不搬」與「要合併」兩份清單
□ scope 清單已定案,回填期間沒有再新增過 scope
□ 刪除與合併已完成,合併衝突按 §7.2 路徑 A 處理
□ 超過 2500 字的長文已全部拆分,每個新檔能單獨回答一個問題
□ front matter 以領域為批次回填,同批 summary / scope 用詞一致
□ 所有 [需人工確認 scope] 標記已清空
□ related 雙向補完(含舊庫連結轉出來的對照表)
□ lint 全綠(不是「錯誤數變少」,是零錯誤)
□ 30–50 題評測寫完,涵蓋消歧題與拒答題
□ manifest 已重建
□ 上線後第一週只看 grep 使用率,沒有動 system prompt
#!/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 雙向性檢查是這個腳本最有價值的部分。 它是唯一能自動抓到「知識孤島」的手段。
# 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 題起步。每次新增知識就加一題,讓評測集隨知識庫成長。
這是整個專案投報率最高的工作,也是幾乎所有人跳過的一步。
| 指標 | 量什麼 | 修法 |
|---|---|---|
| 檢索召回率 | expect_files 是否進了 context |
改 summary / keywords / scope |
| 回答正確率 | 撈到之後答得對不對 | 改 system prompt / 文件本文寫法 |
混在一起量就修不動。 你會發現大部分失敗是檢索沒撈到,而不是模型不會回答——而這兩種失敗的修法完全不同。
失敗時你有完整的 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 空間而丟掉。
每週看這四個數字:
| 指標 | 健康值 | 異常代表 |
|---|---|---|
| grep 使用率 | < 20% | 上升 = manifest summary 品質退化,通常是新增檔案時 front matter 沒好好寫 |
| 平均輪次 | 2–3 | 上升 = 模型找不到東西在試探;檢查是不是有領域的文件太散 |
| 達 MAX_TURNS 比例 | < 3% | 上升 = 有整類問題檢索不到,看 log 找共同點 |
| 「文件中沒有」回答率 | 穩定 | 突然上升 = 有文件被誤刪或 status 改錯;長期偏高 = 知識庫有缺口,看這些問題在問什麼 |
grep 使用率是最靈敏的健康指標。 它直接反映 manifest 的品質,而 manifest 品質直接決定整個系統的準確率。
最後一項的長期值也很有價值——那是你的知識庫該補什麼的最佳來源,比任何規劃會議都準。
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
- 定義 front matter schema,挑 5 個現有檔案手動補上 → 驗證 schema 好不好用
- 寫
build_manifest.py,看 manifest 長什麼樣 → 自己讀一遍,判斷得出來該讀哪個檔嗎? - 補完所有檔案的 front matter,跑
lint_docs.py - 實作三個工具 + agent loop
- 寫 30–50 題評測,跑一輪,看 grep 使用率
- 根據失敗案例修 summary / scope,不要先修 prompt
若是已有整個舊知識庫要一次搬進來(而非第 1、3 步這種漸進累積),第 1、3 步改走 §8 的流程。
第 6 步是重點:先修文件,再修 prompt。 大部分檢索失敗的根因在 front matter,不在 system prompt。