AI 支援開発

AI作業計画を、MarkdownフォルダではなくSQLiteにインデックス化する

AIコラボレーションによって、プランニングドキュメントの山に埋もれてしまいます。Markdownを信頼できる情報源(source of truth)とし、FTS5でSQLiteインデックスを追加して、全履歴を高速にクエリしましょう。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

AIアシスタントとの作業では、大量の文章が生成されます。簡単ではないタスクはどれも、計画、設計メモ、チェックリストから始まります。そして、それらのファイルがフォルダーに溜まっていき、数百個にもなると、必要なファイルを見つける方法がなくなってしまいます。この投稿では、私がその問題を解決した小さなパターンについて説明します。それは、markdownを信頼できる唯一の情報源としてそのままの場所に残し、その上にSQLiteインデックスを構築することで、全履歴をミリ秒単位で一覧表示、フィルタリング、全文検索できるようにするというものです。このインデックスは派生キャッシュです。いつでも破棄して、ファイルから再構築することができます。

Markdownの墓場問題

AIを使って作業を計画する場合、計画を書くのは安価なため、たくさん書くことになります。機能ごとに1ファイル、バグごとに1ファイル、リファクタリングごとに1ファイルです。それぞれにステータス、大まかな設計、そして何が起こったかの実行ログがあります。個々には役立ちますが、大量になると墓場と化します。

この失敗モードは具体的です。3ヶ月後、クレジット返金のエッジケースをどう扱うかについて決定を下したことは覚えていますが、どの計画ファイルに書いたか思い出せません。そこで grep に頼ることになります。grepは数百のファイルに対しては遅く、どのドキュメントが最近のものか、あるいはアクティブかを判断できず、構造のない生の行マッチを返すだけです。20個のファイルにわたって40件のヒットがあっても、結局はそれぞれを開いて、それがどの計画だったのか、そのステータスはどうだったのか、そしてその決定がまだ有効なのかを確認しなければなりません。

より深い問題は、AIもあなたと同じ問題を抱えているということです。新しいタスクを開始するとき、最も価値のあるコンテキストは、すでに行った関連する決定の集合です。そのコンテキストが、あなたもアシスタントもクエリできないフォルダに閉じ込められている場合、すべての新しいタスクは白紙の状態から始まり、数ヶ月前に解決したはずの決定を再び議論し直すことになります。

マークダウンを信頼できる唯一の情報源として維持し、その上にインデックスを追加する

解決策は、プランをデータベースに移行することではありません。マークダウンは適切なストレージ形式です。人間にもAIにも読みやすく、gitで差分を確認でき、その周りに構築するどんなツールよりも長持ちします。それを捨ててテーブルの行にしてしまうことは、間違ったトレードオフです。

代わりに、各プランを小さなYAML frontmatterブロックを持つマークダウンファイルとして保持し、それらのファイルを信頼できる唯一の情報源として扱います。次に、ファイルを読み込み、2つのものをテーブルにミラーリングするSQLiteインデックスを構築します。その2つとは、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 に含めます。コミットするのはデータベースではなく、マークダウンです。リポジトリをクローンした人は誰でも、1つのコマンドを実行してローカルでインデックスを再構築し、同期が取れなくなった場合は削除して再生成します。これがこのパターン全体を安全にする特性です。データベースは、構造上使い捨てであるため、保護しなければならない第二の信頼できる情報源になることは決してありません。

スキーマ: メタデータと全文

インデックスの中核は2つのテーブルです。1つはプランごとに1行を保持し、そのfrontmatterフィールドが列に昇格されています。もう1つはタグを保持し、タグごとに1行があるため、それらでフィルタリングや結合ができます。

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を列に昇格させることで、曖昧なフォルダ閲覧が正確なクエリに変わります。「Web APIのアクティブな課金プランを表示して」という要求は、手動のスキャンではなくなり、WHERE句になります。body列は完全なMarkdownを保持しているため、検索インデックスが読み取るものを持ち、またshowは2回目のファイル読み込みなしでプランを出力できます。

タスク中に何が起こったかの実行ログであるノートは、独自のテーブルを持ちます。これにより、プランの本文を書き換えることなく追記できます:

CREATE TABLE notes (
  plan_id     INTEGER NOT NULL REFERENCES plans(id),
  created_at  TEXT NOT NULL,
  text        TEXT NOT NULL
);

各ノートはタイムスタンプ付きの1行です。セッションを終了するとき、何が変更されたかについて一文を追記します。後で、ノートの履歴はその決定の変更履歴のように読むことができ、それはタスクを引き継ぐ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テーブルから読み取るため、すべてのプラン本文の2番目のコピーを保持することはありません。検索では、表示したい列のためにベーステーブルに再結合します:

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と共に返されるため、アクティブなプランは、同じ単語に言及している3つの放棄されたプランよりも上位に表示されます。snippet()を追加して一致箇所の周りのハイライトされた抜粋を取得したり、本文よりもタイトルに重み付けをしたい場合はbm25()でランク付けしたりできます。

FTS5は、grepが苦手とするクエリも処理します。複数単語のフレーズ、ブール演算のANDORrefund*によるプレフィックスマッチング、近接マッチングなどです。unicode61トークナイザは、大文字と小文字を区別せず、句読点で分割します。これは通常、技術的なテキストが混在している場合に望まれる動作です。

一貫性を保つタギングスキーム

自由形式のタグは腐敗します。放置しておくと、あるプランには billing、別のプランには payments、また別のプランには credits というタグが付けられ、単一のフィルターではこれら3つすべてを見つけることができなくなります。解決策は、少量の構造を追加することです。すべてのタグが、その軸 (axis) を名付ける名前空間プレフィックスを持つようにします。

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 は規模です。1つのプランは、軸ごとに1つか2つのタグを持ちます。プレフィックスには2つの便利な点があります。1つは、語彙を自己文書化することです。なぜなら、1つの軸のすべての値をリストアップすれば、管理された語彙全体を一目で確認できるからです。そして、フィルターを正確に保ちます。なぜなら、area:billing のクエリが、tech:billing-lib とタグ付けされたプランに誤って一致することがないからです。

タグをカンマ区切りの文字列ではなく (axis, value) の行として保存することで、クエリ可能になります。1つのタグによるフィルタリングは join です。2つでは2回の join です。ある軸の語彙をリストアップするには SELECT DISTINCT value FROM tags WHERE axis = 'area' を実行します。タグが単一のテキストフィールドに保存されている場合、これらのことは何もできません。

毎日実行するコマンド

これらを使用するためにSQLを書かなければならないのであれば、大した価値はありません。このインデックスは、一般的なクエリをラップする小さなコマンドラインツールの背後でその真価を発揮します。私が常に使用するのは、以下のコマンドです。

コマンド 機能
plan list アクティブなプランとバックログのプランを最新のものから順に表示
plan list --status active 進行中のもののみを表示
plan list --tag area:billing 1つの製品領域のすべてを表示
plan show 41 1つのプランのメタデータ、要約、最近のメモを表示
plan search "credit refund" すべての本文を対象にFTS5全文検索を実行
plan note 41 "switched to cursor pagination" タイムスタンプ付きのメモを追記
plan index --full Markdownファイルからインデックス全体を再構築

AIとの協業で最も重要なパターンは、あらゆるタスクにおける最初の行動です。コードを読んだりファイルを開いたりする前に、plan searchまたはplan list --tagを実行して関連する過去の作業を見つけ、次にplan showで決定事項を読み込みます。これにより、アシスタントはコールドスタートではなく、1つのステップで実際のコンテキストを得ることができます。これは、「プラン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はこの仕事にほとんど出来すぎなくらい適合します。それは単一のファイルなので、インデックスは削除して再生成できる1つのアーティファクトであり、その1つのパス以外にgitignoreするものは何もありません。実行するサーバーも、管理するポートも、プランをリストする前に起動している必要があるデーモンもありません。FTS5が組み込まれているので、検索にはCREATE VIRTUAL TABLEを実行する以外にコストはかかりません。そして、トランザクション対応なので、再インデックス化は完了するか、あるいは古いインデックスをそのまま残します。

より大きな要点は、このパターンが2つのフォーマットの長所を一度に得られるということです。Markdownは人間やAIアシスタントが読み書きしやすいものであり、gitが追跡するものです。リレーショナルインデックスはマシンがクエリしやすいものです。選択する必要はありません。あなたはMarkdownで読み書きし、SQLでクエリします。そして再構築ステップによって後者が前者と同期されます。より重いデータベースは、運用上の重荷を増やすだけで、この仕事が必要とするものを何も追加しません。なぜなら、データセットは小さく、ローカルで、単一ライターであり、使い捨て可能だからです。

トレードオフ、そしてフォルダで十分な場合

これは真のコストであり、ただで得られる勝利ではありません。インデックスは同期を保つ必要があります。Markdownが変更されるたびに、再インデックスするまでデータベースは古い状態になります。そのほとんどは、コミット時に再インデックスを行うファイルウォッチャーやGitフックの裏に隠すことができますが、結合は存在し、古い結果を静かに返す古いインデックスは、インデックスが全くないよりも悪いのです。再構築は安価で、理想的には自動である必要があります。

ビルドとメンテナンスのコストもあります。誰かがパーサー、スキーマ、コマンドラインラッパーを書き、frontmatterの進化に合わせてそれらが機能し続けるように維持しなければなりません。それは一度支払う固定コストであり、一定の量を超えて初めて元が取れます。

ですから、スケールについては正直になりましょう。もし計画が数十個程度なら、これは過剰設計です。Markdownのフォルダと、あいまいファイル検索やプレーンなgrepを備えたエディタがあれば、クエリを入力する時間で必要なものを何でも見つけることができ、その仕組み全体を省略できます。インデックスがその価値を発揮するのは、数が数百を超え、grepが答えの代わりにノイズを返し始め、過去の決定を見つけられないことのコストが、それを再度、異なる方法で行い、バグを生み出すことになったときです。その時点から、クエリ可能なインデックスはおもちゃであることをやめ、すべてのタスクで実時間を節約し始めるのです。

私が決めたルールは単純です。計画が100未満なら、フォルダを使います。それを超えたら、フォルダの上にSQLiteインデックスを置きます。どちらの場合もMarkdownを信頼できる唯一の情報源として維持することで、フォルダでは手狭になった日に、インデックスの追加がマイグレーションではなくビルドステップになるようにします。