索引は原本に追従する側であって、原本ではない
原本は Markdown ファイルのまま。索引はそこから機械的に作る一方向の生成物で、失っても rm して作り直せる。ただし作り直さないと実体からズレるので、定期的に追従させる必要がある。このジョブの仕事はそれだけである。
常駐プロセスを持たない。ジョブは900秒おきに起きて、1〜2秒で終わってプロセスごと消える。監視デーモンもサーバも無い。「動いているはずのプロセスが実は死んでいた」という状態が存在しないので、監視すべきものが「ログに1行増えたか」だけになる。
7つの段階と、途中で降りる3つの出口
スケジューラが起動するのは薄いラッパースクリプトで、その中から索引本体を呼ぶ。ラッパーが存在する理由は、索引本体が「成功したが何もしなかった」と「死んだ」を区別できないから。
実際のログ出力
15:24:49 PATHS corpus=~/notes-repo db=~/.local/share/notes-index/index.sqlite build=…
15:24:50 INFO run done rc=0 elapsed=1s chunks=10237 files=367 stale=no
idx=2026-09-04T15:05:22 fs=2026-09-04T15:05:22 newest=notes/2026-09-04.md
chunks/files— 索引に載っている総数。増減がそのまま見えるstale— 索引本体の自己申告ではなく、別経路で測り直した鮮度。yesなら索引が実ファイルに追いついていないidx/fs— 索引側の最終更新時刻と、実ファイル側の最新更新時刻。この2つの比較がstaleの中身newest— いま一番新しいファイル。stale=yesのとき犯人を特定するために出している
索引本体には --quiet がある。実測すると標準出力・標準エラーともに0バイトで正常終了を返す。これをそのままスケジューラに登録すると、ジョブが死んでいる状態と、更新が0件だった状態が、ログ上でまったく同じ見え方になる。ラッパーを1枚噛ませているのはこの1点のためである。
索引本体の通常出力は15行ある。900秒間隔なら 96回/日 × 15行 = 1,440行/日。そこで「1回1〜2行」に固定し、代わりに鮮度の判定だけを独立に測って出す形にした。代償 何が更新されたかの内訳はログから消える(必要なときは手で叩き直す)。
索引に入れないものを、4段階で落とす
対象ディレクトリを再帰的に歩き、拡張子・ディレクトリ名・サブモジュール・バージョン管理の除外設定の4段階でふるいにかける。安い判定から順に並べてある。
索引は「今回見つからなかったファイルの行」を削除する掃除処理を持つ。ここに条件が1つ付いている: 1ファイルでも読み取りに失敗していたら、この掃除を実行しない。
読めなかったファイルは「見つからなかった」と同じ扱いになるため、掃除をすると生きているファイルの行を消してしまう。しかも消えたことは件数が減るだけなので、エラーにならない。
索引の中身の偏り
実データではチャンクの77.6%が上位2ディレクトリに集中していた。この偏りは検索精度を直接壊すので、既定の検索範囲からその2つを外し、必要なときだけ明示的に戻す設計にしている。
見出しで割り、短すぎれば併合し、長すぎれば重ねて分ける
1ファイル=1レコードにはしない。検索が返すのは「どのファイルか」ではなく「そのファイルのどこか」でなければ意味がないため、見出し構造を使って本文を切り分ける。
# は見出しとして扱わない。技術文書ではシェルのコメント行やコード例が大量に出てくるので、ここを素通りさせると住所が滅茶苦茶になる。実測 生成されたチャンクは最短8字・平均875字・最長1,499字。「300字未満は併合」は全チャンクを300字以上にする保証ではない(併合先が無いケースが残る)。2つの関門で、書き戻す量を絞る
毎回すべてを作り直すと1.68秒かかる。実運用ではほとんどのファイルが変わっていないので、2段階の関門で「触らなくていいもの」を落としていく。結果として通常の実行は0〜1秒で終わる。
初回(全再構築): 追加 10,237 / 更新 0 / 削除 0 ・ 1.68秒
定常時(15分間隔): 大半のファイルが第1関門で落ち、実測 0〜1秒。ログ上は chunks の数が数件ずつ増えていく。
本文が変わった分岐では、そのチャンクに紐づく派生値(後述の埋め込み用カラム)を明示的に NULL に戻している。戻さないと、変わった本文に古い派生値が貼りついたまま検索に出る。件数は返る。順位も出る。エラーは出ない。——現時点では派生値は未使用なので実害はないが、使い始めた瞬間に効く分岐である。
テーブルは2つだけ
本文テーブルが1つと、設定を持つキー・バリューが1つ。全文検索用の仮想テーブルもベクトル用のテーブルも無い。検索は素の部分一致で行う。
スキーマ全文CREATE TABLE chunks (
chunk_id TEXT PRIMARY KEY, -- 決定的なハッシュ(パス+序数+分割規則の版)
path TEXT NOT NULL, -- 対象ルートからの相対パス
heading_path TEXT NOT NULL DEFAULT '',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
text TEXT NOT NULL,
chunk_sha TEXT NOT NULL,
domain TEXT NOT NULL, -- パス第1階層
doc_date TEXT, -- 'YYYY-MM-DD' または NULL
doc_date_source TEXT,
mtime INTEGER NOT NULL, -- 更新時刻(整数ナノ秒)
-- 将来の意味検索用に、列だけ空けてある(現在は全件 NULL)
embedding BLOB,
embedding_space TEXT,
stored_dim INTEGER,
stored_dtype TEXT,
CHECK (start_line >= 1 AND end_line >= start_line),
CHECK (doc_date IS NULL OR length(doc_date) = 10),
CHECK (doc_date_source IS NULL OR doc_date_source IN ('path_day','path_month','heading'))
);
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT
);
意味検索(埋め込みベクトル)は現時点では実装していない。それでも列は最初から定義してある。「作らない」のではなく「順番が後」だと決めているので、後からスキーマを変更して既存データを移行する作業を発生させないためである。実測: 非NULLは0件。
3つの制約が守っていること
| 制約 | 何を防ぐか |
|---|---|
| start_line >= 1 AND end_line >= start_line | 行番号が0始まりで混入する/終端が始端より前になる。どちらも「原本を開いたら違う場所」に直結する |
| doc_date IS NULL OR length(doc_date) = 10 | 2026-3-1 のような桁の揃わない日付。文字列比較で範囲検索するので、桁が揃っていないと静かに取りこぼす |
| doc_date_source IN (…) | 日付の出所として想定外の値が入る。後から「この日付はどこから来たのか」を検証できなくなる |
各カラムが、誰のどの機能を支えているか
「何を入れるか」だけでなく「これが無いと何ができなくなるか」を併記する。使われていないカラムは、そう書く。
/ に正規化)。
絶対パスにしない。ルートの位置は meta 側に1つだけ持ち、索引を別の場所のリポジトリに繋ごうとしたら異常終了する仕組みにしてある——気づかずに2つのリポジトリの内容が混ざるのを防ぐため。
使う場面: ディレクトリ絞り込み / 検索結果の表示 / 差分更新の単位
見出し1 > 見出し2 > 見出し3 の形。
これが検索の中核。大きなファイルから候補を絞り込むとき、実際に効いていたのは本文の類似度ではなく「見出しを見て当たりを付ける」作業だった。だから「見出しの無いチャンクを作らない」が文書側の規約になっている。
使う場面: 検索結果の表示 / AI が読む範囲の判断
end_lineINTEGER / NOT NULL
ファイル:行 の形で原本を直接開くため。索引に入っている本文のコピーを読ませるのではなく、必ず原本を読ませる設計になっている——索引が古い可能性が常にあるため。
使う場面: 検索結果の表示 / 原本の該当箇所を開く
doc_date と絶対に混ぜない。片方は「いつのことか」、もう片方は「いつ触ったか」で、意味がまったく違う。整数で持つのは比較を確実にするため(表示するときだけ日時に変換する)。
使う場面: 差分更新の第1関門 / 並び順の第2キー / 鮮度の判定
embedding_space
stored_dim
stored_dtypeBLOB / TEXT / INT / TEXT
3本のインデックスが、21ms を 0.35ms にする
CREATE INDEX idx_chunks_path ON chunks(path); CREATE INDEX idx_chunks_domain ON chunks(domain); CREATE INDEX idx_chunks_doc_date ON chunks(doc_date);
meta テーブル — 8つのキー
| キー | 役割 |
|---|---|
| corpus_root | この索引がどのディレクトリに束縛されているか。不一致なら索引を書き換えずに異常終了する(別リポジトリの内容が混ざるのを防ぐ) |
| corpus_head | ビルド時のバージョン管理のリビジョン |
| corpus_head_dirty | 未コミットの変更がある状態でビルドしたか |
| meta_search_default | 既定の検索除外リスト。検索コマンド側にハードコードせず、ここ1箇所だけに持つ |
| chunker_version | 分割規則の版。上げると識別子とハッシュの意味が変わるので、規則を1文字でも変えたら必ず上げる |
| schema_version | スキーマの版 |
| built_at | 最終ビルド時刻 |
| build_mode | full(全再構築)か incremental(差分)か |
このジョブの失敗は、ほとんどがエラーを出さない
索引は「検索結果が少し減る」という形でしか壊れないので、気づくための仕組みを設計に埋め込んでおかないと、壊れたことが永遠に分からない。把握している箇所を列挙する。
| 壊れ方 | 症状 | 手当て |
|---|---|---|
| ジョブが死んでいる | 索引が更新されなくなるが、検索自体は成功し続ける。古い結果が返る | 0件でも必ず1行ログを書く。加えて索引側と実ファイル側の最新時刻を毎回比較する |
| 読めなかったファイルを「消えた」と誤認 | 生きているファイルの行が索引から削除される。件数が減るだけ | 1件でも読み取り失敗があれば掃除処理を実行しない |
| 行番号だけがズレる | 検索結果の行番号で原本を開くと、別の箇所が開く | 本文が同一でも行番号・見出し・日付は必ず更新する分岐を独立して持つ |
| 除外設定が効かなくなる | 特定のディレクトリが索引の大半を占め、検索結果がそれで埋まる | 既定除外を索引の中に1箇所だけ持ち、結果に必ずディレクトリ別分布を出す |
| 日付が取れていない | 期間で絞ると、日付が無いチャンクが黙って全部落ちる | 結果の1行に「日付の出所別の内訳」を必ず出す。日付なしを含めるオプションを用意する |
| 全角と半角が別物として扱われる | 部分一致なので ABC と ABC が一致しない。0件が返る |
本文を正規形に揃える規約を持ち、ビルド時に表記ゆれの文字数を毎回カウントして出す |
| 実行環境と対話環境で処理系が違う | 手元では動くのにスケジューラ経由だと構文エラー。書いた本人は気づけない | 処理系を絶対パスで固定し、実行時の PATH も明示的に設定する |
①「エラーが出ないこと」を正常の判定に使わない。0件でも1行出す設計にして、無音であること自体を異常の合図にする。
②処理系の自己申告を信じない。索引が「更新した」と言っても、鮮度は別経路(ファイルシステムを直接歩く)でもう一度測る。2つの経路が一致することを検査の主にする。
③切り詰めは通す側に壊れる。ログの行数を抑えるために出力を切ったら、切れたのが原因を名指しする行のほうだった、という事故が実際に起きた。長さを削るときは「何が残るか」ではなく「何が消えるか」で判断する。
現時点での既知の不整合
文書化された規約と実装が一致していない箇所が1つある。整合していないこと自体は無害だが、規約を根拠に判断すると誤る。
| 規約 | 実装・データの現状 |
|---|---|
日付は日単位だけを採用し、月単位のパス(2026-03.md のような形)と見出しからの日付抽出は廃止する |
どちらも現役。実測の内訳は 日単位 7,496 / 月単位 95 / 見出し 4 / 日付なし 2,642。 月単位のパスは 2026-03.md → 2026-03-01 に変換されるため、「3月15日以降」で絞ると月次の文書が落ちる |