定期実行ジョブ + SQLite スキーマ リファレンス

Markdown 索引ジョブ

ローカルの Markdown ノート群を、15分おきに SQLite の1テーブルへ同期する常駐しないジョブ。埋め込みモデルは1回も呼ばず、純粋なテキスト処理だけで全文検索を成立させる。

この文書の読み方 — 特定の個人リポジトリの運用記録ではなく、「Markdown を原本にしたまま検索を足す」構成の設計ノートとして書いている。パス・ジョブ名・ディレクトリ名はすべて汎用の例に置き換えてあり、数値だけが実在するリポジトリ(367ファイル・約11MB の Markdown)での実測値。
用語 — 原本=人が読み書きする Markdown 本体(source of truth)。索引=そこから機械的に作る、いつでも捨てて作り直せる SQLite ファイル。チャンク=検索の単位に切り分けた本文の断片。
実行間隔
900秒
フルビルド
1.68秒
差分ビルド
0–1秒
チャンク数
10,237
対象ファイル
367
索引サイズ
28.9MB
検索応答
21ms
01 — なぜ定期実行なのか

索引は原本に追従する側であって、原本ではない

原本は Markdown ファイルのまま。索引はそこから機械的に作る一方向の生成物で、失っても rm して作り直せる。ただし作り直さないと実体からズレるので、定期的に追従させる必要がある。このジョブの仕事はそれだけである。

原本 — SOURCE OF TRUTH Markdown ファイル群 ~/notes-repo/**.md 367 ファイル ・ 約 11 MB 定期ジョブ — 常駐しない 索引ジョブ 900 秒おきに起きる 終わったらプロセスは消える 生成物 — 捨てて作り直せる SQLite 索引 index.sqlite (単一ファイル) 10,237 チャンク ・ 28.9 MB 読む 書く 索引から原本へは絶対に書き戻さない 検索コマンド 部分一致 + 絞り込みだけ AI アシスタント 返された住所で原本を直接読む path:line で開く
索引は答えを返す装置ではなく、原本のどこを読めばいいかを返す装置。検索が返すのは「ファイルパス+見出し+行番号」で、AI はそれを使って原本を直接読みに行く。だから索引が多少古くても致命傷にならず、逆に索引を原本の代わりにしようとすると設計が破綻する。
設計上の要点

常駐プロセスを持たない。ジョブは900秒おきに起きて、1〜2秒で終わってプロセスごと消える。監視デーモンもサーバも無い。「動いているはずのプロセスが実は死んでいた」という状態が存在しないので、監視すべきものが「ログに1行増えたか」だけになる。

02 — 1回の実行

7つの段階と、途中で降りる3つの出口

スケジューラが起動するのは薄いラッパースクリプトで、その中から索引本体を呼ぶ。ラッパーが存在する理由は、索引本体が「成功したが何もしなかった」と「死んだ」を区別できないから。

123 4567 解決したパスをログに記録 多重起動を防ぐロックを取る 前提を検査する 索引本体を実行する 鮮度を独立にもう一度測る 古いと判定されたら名指しで警告 結果を必ず1行書いて終わる 環境変数で差し替えられるため、実際に使った値を毎回出す ディレクトリ作成で取る(アトミック)。取れなければ即終了 対象ディレクトリが読めるか/索引本体が実行可能か 出力はファイルに逃がす。終了コードを見る行にパイプを挟まない 索引側の最終更新時刻 と 実ファイルの最新更新時刻 を比較 更新直後に古いなら異常。検索側の警告が消えない状態 更新0件でも書く 出口A — 既に実行中 出口B — 前提が崩れている 出口C — 索引本体が失敗 「実行中のため飛ばした」と書いて正常終了 読めない理由をログに残して異常終了 終了コードで原因が分かれる: 2 = 対象が保護領域内 / 存在しない 3 = 索引が別のリポジトリに束縛されている 4 = 除外判定に使うコマンドが動かない 失敗時は原因を名指しする1行目だけをログに載せ、 全文は固定名のファイルに退避してその場所を書く * 以前は末尾3行を載せていたが、切り落とされたのが   原因を名指しする側だった
どの出口を通っても、ログには必ず1行残る。これが「無音で死んだジョブ」と「更新が0件だった日」を区別する唯一の手段になっている。

実際のログ出力

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行」に固定し、代わりに鮮度の判定だけを独立に測って出す形にした。代償 何が更新されたかの内訳はログから消える(必要なときは手で叩き直す)。

03 — 走査と除外

索引に入れないものを、4段階で落とす

対象ディレクトリを再帰的に歩き、拡張子・ディレクトリ名・サブモジュール・バージョン管理の除外設定の4段階でふるいにかける。安い判定から順に並べてある。

1. 拡張子でふるう 2. ディレクトリ名でふるう(安いので先に) 3. サブモジュール配下を落とす 4. バージョン管理の除外設定に従う .md 以外は対象外(実測 132 本) .git / node_modules / .venv / __pycache__ / dist / build … 別リポジトリなので入れない(実測 4 ディレクトリ) 除外設定に載っているもの(実測 1 件)/シンボリックリンク(0 件) 索引対象 367 ファイル → 10,237 チャンク 平均 876 字
除外設定に従うのは最後。外部コマンドを呼ぶ判定なので最も高価であり、その手前でディレクトリ名による安い判定を済ませておく。落とし穴 この除外判定コマンドが動かないときは、索引を作らずに異常終了する——「除外できないまま索引に入れる」より「作らない」ほうが安全なため。
落とし穴 — 読めなかったファイルがあるときは掃除をしない

索引は「今回見つからなかったファイルの行」を削除する掃除処理を持つ。ここに条件が1つ付いている: 1ファイルでも読み取りに失敗していたら、この掃除を実行しない。

読めなかったファイルは「見つからなかった」と同じ扱いになるため、掃除をすると生きているファイルの行を消してしまう。しかも消えたことは件数が減るだけなので、エラーにならない。

索引の中身の偏り

実データではチャンクの77.6%が上位2ディレクトリに集中していた。この偏りは検索精度を直接壊すので、既定の検索範囲からその2つを外し、必要なときだけ明示的に戻す設計にしている。

作業ログ 定型の大量データ プロジェクト文書 その他 11 ディレクトリ合計 6,202 1,777 1,293 965 この2つで 77.6% — 既定の検索範囲から外す * 除外は削除ではない。過去の問いの答えが除外側の巨大ファイルの中にあった実例があるため、   明示的に指定すればいつでも検索範囲に戻せる形にしてある。
既定除外の値は索引の中(メタ情報テーブル)に1つだけ持つ。検索コマンド側と別の場所に同じ値をハードコードすると、「片方では出るのにもう片方では出ない」状態が無警告で生まれる。
04 — チャンク分割

見出しで割り、短すぎれば併合し、長すぎれば重ねて分ける

1ファイル=1レコードにはしない。検索が返すのは「どのファイルか」ではなく「そのファイルのどこか」でなければ意味がないため、見出し構造を使って本文を切り分ける。

元の Markdown # 見出し1 本文… ## 見出し2 短い本文 ### 見出し3 とても長い本文… …(1500字超) ``` # これは見出しでは ないと判定する ``` 分割 生成されるチャンク 住所: 見出し1 L1–L8 ・ 386 字 住所: 見出し1 > 見出し2 300字未満 → 隣に併合 住所: 見出し1 > 見出し2 > 見出し3 L20–L45 ・ 1,500 字 住所: (同じ)— 続き 先頭 200 字は前のチャンクと重複 重複させる理由: 境界をまたぐ語句が どちらにも当たらなくなるのを防ぐ パラメータ 分割する見出し = H1〜H3 併合の下限 = 300 字 分割の上限 = 1500 字 重複 = 200 字 単位は「文字」であって「バイト」ではない バイト長で実装すると日本語で規則が 実質3倍厳しくなり、チャンクが3倍細かくなる * シェルの文字数取得はロケール設定で値が   変わる(同じ文字列が 12 と 36)。この要求が   実装言語の選定を決めている
コードブロックの中の # は見出しとして扱わない。技術文書ではシェルのコメント行やコード例が大量に出てくるので、ここを素通りさせると住所が滅茶苦茶になる。実測 生成されたチャンクは最短8字・平均875字・最長1,499字。「300字未満は併合」は全チャンクを300字以上にする保証ではない(併合先が無いケースが残る)。
05 — 差分更新の判定

2つの関門で、書き戻す量を絞る

毎回すべてを作り直すと1.68秒かかる。実運用ではほとんどのファイルが変わっていないので、2段階の関門で「触らなくていいもの」を落としていく。結果として通常の実行は0〜1秒で終わる。

ファイル1件 第1関門 — ファイル更新時刻 前回記録した値と一致するか 一致 読まずにスキップ ここで大半が落ちる * 更新時刻を巻き戻して内容だけ変えると素通りする   (全再構築オプションが逃げ道) 不一致 → 読んで再チャンク 第2関門 — 本文のハッシュ チャンク単位で前回と比較 INSERT 新しいチャンク 前回に無かった識別子 UPDATE(本文は触らない) ハッシュが同じ 行番号・住所・日付だけ更新 UPDATE(本文ごと) ハッシュが違う 本文を差し替え+派生値を無効化 DELETE — 今回作られなかった識別子(ファイルが縮んだ・見出しが消えた)
「本文は同じだが行番号がずれた」を独立した分岐にしているのが要点。上の行に文章が挿入されると本文が同一でも位置が変わる。落とし穴 ここで行番号の更新を省くと、返された行番号で原本を開いたときに別の場所が開く。エラーは出ない。
実測 — 初回と定常時

初回(全再構築): 追加 10,237 / 更新 0 / 削除 0 ・ 1.68秒
定常時(15分間隔): 大半のファイルが第1関門で落ち、実測 0〜1秒。ログ上は chunks の数が数件ずつ増えていく。

将来のための分岐 — 本文が変わったら派生値を無効化する

本文が変わった分岐では、そのチャンクに紐づく派生値(後述の埋め込み用カラム)を明示的に NULL に戻している。戻さないと、変わった本文に古い派生値が貼りついたまま検索に出る。件数は返る。順位も出る。エラーは出ない。——現時点では派生値は未使用なので実害はないが、使い始めた瞬間に効く分岐である。

06 — スキーマ定義

テーブルは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 (…)日付の出所として想定外の値が入る。後から「この日付はどこから来たのか」を検証できなくなる
07 — カラム別リファレンス

各カラムが、誰のどの機能を支えているか

「何を入れるか」だけでなく「これが無いと何ができなくなるか」を併記する。使われていないカラムは、そう書く。

chunk_idTEXT / PK
パス+そのファイル内の序数+分割規則の版番号から作る決定的なハッシュ(先頭32文字)。 同じファイルを何度チャンクし直しても同じ ID になるので、差分更新が INSERT/UPDATE/DELETE に正しく分岐できる。ランダムな ID にすると毎回全削除・全挿入になる。 使う場面: 差分更新の分岐 / 縮んだファイルの掃除
pathTEXT / NOT NULL
対象ルートからの相対パス(区切りは / に正規化)。 絶対パスにしない。ルートの位置は meta 側に1つだけ持ち、索引を別の場所のリポジトリに繋ごうとしたら異常終了する仕組みにしてある——気づかずに2つのリポジトリの内容が混ざるのを防ぐため。 使う場面: ディレクトリ絞り込み / 検索結果の表示 / 差分更新の単位
heading_pathTEXT / NOT NULL
そのチャンクが属する見出しの連なり。見出し1 > 見出し2 > 見出し3 の形。 これが検索の中核。大きなファイルから候補を絞り込むとき、実際に効いていたのは本文の類似度ではなく「見出しを見て当たりを付ける」作業だった。だから「見出しの無いチャンクを作らない」が文書側の規約になっている。 使う場面: 検索結果の表示 / AI が読む範囲の判断
start_line
end_lineINTEGER / NOT NULL
原本での行番号。1始まり・両端を含む。 検索が返した結果から、AI が ファイル:行 の形で原本を直接開くため。索引に入っている本文のコピーを読ませるのではなく、必ず原本を読ませる設計になっている——索引が古い可能性が常にあるため。 使う場面: 検索結果の表示 / 原本の該当箇所を開く
textTEXT / NOT NULL
チャンクの本文そのもの。実測で最短8字・平均875字・最長1,499字。 部分一致検索が実際に走査する対象。同時に、これが原本の平文の複製そのものなので、索引ファイルの置き場所とバックアップ方針を決める要因にもなる(機微な内容がそのまま入る)。 使う場面: 全文の部分一致 / 本文つき出力
chunk_shaTEXT / NOT NULL
本文のハッシュ。差分更新の第2関門。 ファイル単位ではなくチャンク単位で持つ。ファイル単位にすると、巨大なファイルへの1行の追記で全チャンクを作り直すことになる(実際に数百KBのファイルが存在する)。 使う場面: 本文が変わったかの判定
domainTEXT / NOT NULL
パスの第1階層をそのまま入れる。ルート直下のファイルは空文字(NULL にしない)。 タグではなくパスから自動で決まるのが要点。人が付けるタグは付け忘れが起きて、そのチャンクは検索から静かに消える。パス由来ならこの問題が原理的に発生しない。 使う場面: ディレクトリ絞り込み / 既定除外 / 結果の分布表示
doc_dateTEXT / NULL 可
その文書が「いつのことを書いたものか」。パス中の日付から取る。無ければ NULL。 バージョン管理のコミット日は使わない——一括整形のコミットを1回打つと、全ファイルの日付が同じ日に飛ぶため。NULL は「日付が不明」ではなく「日付を持たないことが正しい文書」を意味する(現在の状態を書いた文書は、上書きし続けるので日付を持たない)。 使う場面: 期間での絞り込み / 並び順の第1キー
doc_date_sourceTEXT / NULL 可
日付をどこから拾ったかの記録。日単位のパス/月単位のパス/見出し/取得できず、の4通り。 検索結果に「32件 [日:21 月:6 見出し:3 不明:2]」の1行を必ず出すために使う。手当ての本体は NULL を埋めることではなく、NULL が何件あるかを常に見せることである——見えていれば、期間絞り込みで取りこぼしている可能性に気づける。 使う場面: 結果の内訳表示 / 日付付与の品質監視
mtimeINTEGER / NOT NULL
ファイルシステム上の更新時刻(整数ナノ秒)。 doc_date と絶対に混ぜない。片方は「いつのことか」、もう片方は「いつ触ったか」で、意味がまったく違う。整数で持つのは比較を確実にするため(表示するときだけ日時に変換する)。 使う場面: 差分更新の第1関門 / 並び順の第2キー / 鮮度の判定
embedding
embedding_space
stored_dim
stored_dtypeBLOB / TEXT / INT / TEXT
現在は未使用(実測: 非NULL 0件)。意味検索を足すときのベクトル本体と、その次元数・数値精度・どのモデルの空間かを記録する場所。 列だけ先に置いてあるのは、後からのスキーマ変更と全件移行を避けるため。差分更新のコードには「本文が変わったらこの4列を NULL に戻す」分岐が既に入っている——本文と派生値がズレたまま検索に出るのを防ぐため。 使う場面: なし(将来用)
08 — インデックスと meta

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);
本文の部分一致(全件走査) path LIKE 'dir/%' path >= 'dir/' AND path < 'dir0' domain = 'dir' 21 ms 5.6 ms 0.38 ms 0.35 ms インデックスが使われない(全件走査になる) 範囲比較 — 下位ディレクトリに効く 等値比較 — 第1階層に効く 前方一致のワイルドカードでは索引が効かない。両方の書き方を実装側に持つ必要がある。
全件走査でも21msなので、絞り込みは「速くするため」より「AI に渡す量を減らすため」に効く。索引が自動化しているのは意味の理解ではなく、数MBの候補を数十KBまで削る作業である。

meta テーブル — 8つのキー

キー役割
corpus_rootこの索引がどのディレクトリに束縛されているか。不一致なら索引を書き換えずに異常終了する(別リポジトリの内容が混ざるのを防ぐ)
corpus_headビルド時のバージョン管理のリビジョン
corpus_head_dirty未コミットの変更がある状態でビルドしたか
meta_search_default既定の検索除外リスト。検索コマンド側にハードコードせず、ここ1箇所だけに持つ
chunker_version分割規則の版。上げると識別子とハッシュの意味が変わるので、規則を1文字でも変えたら必ず上げる
schema_versionスキーマの版
built_at最終ビルド時刻
build_modefull(全再構築)か incremental(差分)か
09 — 無音で壊れる箇所

このジョブの失敗は、ほとんどがエラーを出さない

索引は「検索結果が少し減る」という形でしか壊れないので、気づくための仕組みを設計に埋め込んでおかないと、壊れたことが永遠に分からない。把握している箇所を列挙する。

壊れ方症状手当て
ジョブが死んでいる 索引が更新されなくなるが、検索自体は成功し続ける。古い結果が返る 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日以降」で絞ると月次の文書が落ちる