使用 SQLite 索引您的 AI 工作計畫,而非 Markdown 資料夾
AI 協作讓您深陷於規劃文件中。將 Markdown 作為單一事實來源,使用 FTS5 新增一個 SQLite 索引,並快速查詢整個歷史記錄。
與 AI 助理協作會產生大量文字。每個非尋常的任務都始於一份計畫、一份設計筆記、一份檢查清單,而這些檔案會堆積在一個資料夾中,直到你有數百個檔案,卻無法找到你需要的那一個。這篇文章描述了一個為我解決這個問題的小模式:將 markdown 檔案完全保留在原處作為事實的唯一來源 (source of truth),並在其上建立一個 SQLite 索引,這樣你就可以在毫秒內列出、篩選和全文搜尋整個歷史紀錄。這個索引是一個衍生快取。你可以隨時將它丟棄,並從檔案中重建。
Markdown 墳場問題
當你與 AI 一起規劃工作時,撰寫計畫的成本很低,所以你會寫下許多計畫。每個功能一個檔案,每個錯誤一個檔案,每次重構一個檔案。每個檔案都有狀態、粗略的設計,以及記錄事件的日誌。單獨來看,它們都很有用。但大量累積後,它們就變成了一座墳場。
這種失敗模式很具體。三個月後,你記得曾就如何處理信用卡退款的邊界案例做過決定,但你卻想不起來那個決定在哪個計畫裡。於是你求助於 grep。在數百個檔案中執行 Grep 很慢,它無法判斷哪個文件是最近的或活躍的,而且它回傳的是沒有任何結構的原始匹配行。你從二十個檔案中得到四十個匹配結果,但仍然需要逐一打開,才能找出是哪個計畫、它的狀態是什麼,以及那個決定是否仍然有效。
更深層的問題是,AI 和你面臨同樣的問題。當你開始一項新任務時,最有價值的上下文是你已經做出的相關決策集合。如果這些上下文被困在一個你和助理都無法查詢的資料夾中,那麼每項新任務都得從零開始,而你將會重新爭論幾個月前就已解決的決策。
保留 Markdown 作為事實的唯一來源,並在其上新增索引
解決方案並非將計畫移至資料庫。Markdown 是正確的儲存格式。它可供人類閱讀、可供 AI 閱讀、可在 git 中進行差異比較,並且能在您為其建構的任何工具中存續。為了表格中的資料列而拋棄它,將是錯誤的取捨。
取而代之的是,將每個計畫都保存為帶有小型 YAML frontmatter 區塊的 markdown 檔案,並將這些檔案視為唯一的事實來源。然後建立一個 SQLite 索引,讀取這些檔案並將兩樣東西鏡像到表格中:來自 frontmatter 的結構化元資料,以及用於搜尋的全文內容。索引負責回應查詢。檔案則保存著事實。
一個計畫檔案看起來像這樣:
---
id: 41
status: active
project: web-api
tags: [area:billing, tech:go, type:feature, scope:minor]
created: 2026-05-02
---
# Credit refund on subscription downgrade
## Decision
Refunds are prorated by remaining days, not by unused credits...
因為索引是衍生出來的,所以它會被放在 .gitignore 中。您提交的是 markdown,而不是資料庫。任何複製 (clone) 該 repo 的人,只需執行一個指令即可在本地重建索引,如果它發生不同步的情況,您就刪除它並重新生成。正是這個特性使得整個模式變得安全:資料庫永遠不會成為您必須保護的第二個事實來源,因為它在建構上就是可拋棄的。
綱要:元數據加上全文
索引的核心是兩個資料表。一個資料表為每個計畫保存一列,其 frontmatter 欄位被提升為資料行。另一個資料表保存標籤,每個標籤一列,以便您能對其進行篩選和聯結。
CREATE TABLE plans (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL, -- backlog, active, completed
project TEXT NOT NULL,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE tags (
plan_id INTEGER NOT NULL REFERENCES plans(id),
axis TEXT NOT NULL, -- area, tech, type, scope
value TEXT NOT NULL,
PRIMARY KEY (plan_id, axis, value)
);
將 frontmatter 提升為資料行,就能將模糊的資料夾瀏覽轉變為精確的查詢。「顯示網頁 API 的有效計費計畫」不再需要手動掃描,而是變成一個 WHERE 子句。body 資料行承載著完整的 markdown,這樣搜尋索引就有東西可以讀取,而 show 也能在不需第二次讀取檔案的情況下列印計畫。
備註,即任務期間所發生事件的運行日誌,有它們自己的資料表,這樣你就可以在不重寫其主體的情況下附加到計畫中:
CREATE TABLE notes (
plan_id INTEGER NOT NULL REFERENCES plans(id),
created_at TEXT NOT NULL,
text TEXT NOT NULL
);
每個備註都是一行帶有時間戳記的文字。當你完成一個工作階段時,你會附加一句關於變更內容的句子。之後,備註歷史讀起來就像是該決策的變更日誌,而且它是最值得先展示給接手任務的 AI 看的東西。
FTS5 讓搜尋瞬間完成
SQLite 內建 FTS5,這是一個全文搜尋擴充功能。您不需要安裝任何東西。您只需建立一個虛擬資料表來索引您想搜尋的欄位,將其指向真實的資料表,然後使用 MATCH 子句進行查詢。
CREATE VIRTUAL TABLE plans_fts USING fts5(
title,
body,
content='plans',
content_rowid='id',
tokenize='unicode61'
);
content='plans' 選項會將其設定為外部內容索引。FTS5 只儲存搜尋結構,並從 plans 資料表中讀取實際文字,因此您不需要保留每個計畫內容的第二份副本。搜尋時會與基礎資料表進行 join,以取得您想顯示的欄位:
SELECT p.id, p.title, p.status
FROM plans_fts f
JOIN plans p ON p.id = f.rowid
WHERE plans_fts MATCH 'credit AND refund'
ORDER BY rank
LIMIT 10;
與 grep 的不同之處不僅在於速度,儘管在數百份文件上,查詢在您手指離開 Enter 鍵之前就已返回結果。更重要的是,結果是結構化的。每個命中結果都會連同其 id、title 和 status 一起返回,並按相關性排序,因此使用中的計畫會排在三個提及相同詞語的已廢棄計畫之上。您可以加上 snippet() 來取得匹配處周圍的醒目標示摘錄,如果您想讓標題的權重高於內文,也可以使用 bm25() 進行排序。
FTS5 還能處理 grep 不擅長的查詢:多詞片語、布林 AND 和 OR、使用 refund* 的前綴匹配,以及鄰近匹配。unicode61 分詞器會摺疊大小寫並根據標點符號進行分割,這通常是處理混合技術文本時所需要的。
一個保持一致的標籤方案
自由形式的標籤會變得混亂。若放任不管,一個計畫標上 billing,另一個標上 payments,第三個標上 credits,結果就是沒有任何單一篩選器能找出全部三個。解決方法是加入少量結構:每個標籤都帶有一個命名空間前綴,用來命名其軸向。
area:billing area:auth area:worker
tech:go tech:sqlite tech:redis
type:feature type:bugfix type:refactor
scope:patch scope:minor scope:major
每個軸向回答一個不同的問題。area 是產品的部分,tech 是技術堆疊,type 是工作類型,scope 是規模大小。一個計畫在每個軸向上帶有一或兩個標籤。這個前綴有兩個有用的功能。它讓詞彙庫自我文件化,因為你可以列出一個軸向上的所有值,並一目了然地看到整個受控詞彙庫。而且它確保篩選器精確無誤,因為一個查詢 area:billing 的請求,不會意外地匹配到標有 tech:billing-lib 的計畫。
將標籤儲存為 (axis, value) 的資料列,而不是用逗號分隔的字串,是使其可被查詢的關鍵。用一個標籤進行篩選就是一次 join。用兩個標籤篩選就是兩次 join。列出一個軸向上的詞彙,就是執行 SELECT DISTINCT value FROM tags WHERE axis = 'area'。如果標籤是存在單一的文字欄位中,這些操作都不可能實現。
您每天執行的指令
如果您需要編寫 SQL 才能使用這一切,那它就沒什麼價值了。這個索引的價值,在於其背後一個包裝了常見查詢的小型命令列工具。以下是我經常使用的指令:
| 指令 | 功能 |
|---|---|
plan list |
作用中與待辦的計畫,最新的排在最前面 |
plan list --status active |
僅顯示進行中的計畫 |
plan list --tag area:billing |
某個產品領域中的所有內容 |
plan show 41 |
顯示單一計畫的元資料、摘要和近期筆記 |
plan search "credit refund" |
在所有內文中進行 FTS5 全文搜尋 |
plan note 41 "switched to cursor pagination" |
附加一則帶有時間戳記的筆記 |
plan index --full |
從 markdown 檔案重建整個索引 |
對於 AI 協作而言,最重要的模式是在執行任何任務時的第一步。在閱讀程式碼、開啟檔案之前,您會先執行 plan search 或 plan list --tag 來尋找相關的過往工作,然後執行 plan show 來載入決策。這一步就能為助理提供真實的上下文,而不是讓它冷啟動。這就是「我們在計畫 41 中決定了這件事,理由如下」和從頭重新推導相同答案之間的區別。
# Start a task by loading context, not by grepping
plan list --tag area:billing --status active
plan show 41
plan notes 41 --last 5
為何選擇 SQLite 而非更大型的資料庫
SQLite 對於這項工作來說,幾乎是再適合不過了。它是一個單一檔案,所以索引是一個你可以刪除並重新生成的產物,除了那個路徑之外,沒有什麼需要加入 gitignore。無需執行伺服器、無需管理連接埠,在你列出計畫之前,也無需確保有任何常駐程式正在運行。FTS5 是內建的,所以搜尋功能只需一個 CREATE VIRTUAL TABLE 指令,別無其他成本。而且它具備交易性,所以重新索引要嘛完成,要嘛會保持舊索引原封不動。
更重要的一點是,這種模式同時汲取了兩種格式的優點。Markdown 是人類和 AI 助理擅長讀寫的格式,也是 git 所追蹤的內容。關聯式索引則是機器擅長查詢的格式。你不必二選一。你用 Markdown 讀寫,用 SQL 查詢,而一個重建步驟讓後者與前者保持同步。一個更重量級的資料庫會增加操作上的負擔,卻沒有增加任何工作所需的功能,因為資料集是小型的、本地的、單一寫入者,且是可拋棄的。
權衡取捨,以及何時一個資料夾就已足夠
這是有實際成本的,並非免費的勝利。索引必須保持同步。每當 markdown 變更時,資料庫在您重新建立索引之前都是過時的。您可以將大部分工作隱藏在檔案監看器或在 commit 時重新建立索引的 git hook 後面,但耦合性依然存在,而一個默默回傳舊結果的過時索引比完全沒有索引更糟糕。您需要重建的成本低廉,且最好是自動化的。
此外還有建置和維護的成本。必須有人撰寫解析器、結構描述和命令列包裝器,並隨著 frontmatter 的演進而維護它們的運作。這是一次性的固定成本,只有在超過一定數量後才划算。
所以要誠實面對規模。如果您只有幾十個計畫,這就是過度設計。一個 markdown 資料夾加上一個具備模糊檔案搜尋或純 grep 功能的編輯器,就能在您輸入查詢的瞬間找到任何您需要的東西,而您可以省去整套機制。當數量跨越數百個時,當 grep 開始回傳雜訊而非答案時,以及當找不到過去決策的成本是您會用不同的方式再做一次決策並產生一個錯誤時,索引才開始顯現其價值。那時,一個可查詢的索引就不再是個玩具,而是開始為您的每項任務節省實際時間。
我所定下的規則很簡單。一百個計畫以下,使用資料夾。超過一百個,就在資料夾之上加上一個 SQLite 索引。無論如何,都將 markdown 作為單一事實來源,這樣當您有一天成長到超出資料夾所能負荷時,新增索引只是一個建置步驟,而不是一次遷移。