ローカルに保存した技術資料が増えると、「以前読んだはずの内容」を探すだけで時間を使います。ファイル名やディレクトリを覚えていれば grep でも十分ですが、Markdown の本文を横断して検索したい場合は、検索用インデックスを持たせる方が扱いやすくなります。
この記事では、Python の標準ライブラリに含まれる SQLite と FTS5 を使い、ローカルの Markdown 資料を検索する小さな CLI ツールを作ります。手元で実際に動かして確認したところ、追加の検索サーバーを立てずに、タイトル・本文・検索結果の抜粋表示まで実装できました。
FTS5を選んだ理由と日本語での注意点
Markdown資料の取り込みから検索結果の表示までの流れを図にすると次の通りです。
SQLite には FTS5(Full-Text Search 5)という全文検索機能があります。通常のテーブルに対する LIKE '%キーワード%' と比べて、検索用の仮想テーブルを作れること、検索結果の順位付けやスニペット表示を利用できることが利点です。
今回の構成は次のようにします。
Markdownファイル
↓
documents テーブル
↓
documents_fts(FTS5の検索インデックス)
↓
MATCH検索
日本語では、英語のように単語が空白で区切られていません。SQLite FTS5 の標準的な unicode61 tokenizer だけでは、日本語の部分一致検索を期待どおりに扱えないケースがあります。
そこで今回は trigram tokenizer を使用します。文字列を3文字単位でインデックス化するため、日本語の技術用語やクラス名、設定キーなどを部分一致に近い形で検索できます。
利用する SQLite のバージョンを、最初に確認しておきます。
python -c "import sqlite3; print(sqlite3.sqlite_version)"
trigram tokenizer を使うため、SQLite 3.34.0 以降を目安にしてください。環境によっては Python がリンクしている SQLite のバージョンが古い場合があります。
FTS5のテーブルを作る
まず、通常の資料テーブルと全文検索用テーブルを作ります。検索インデックスには本文を重複して保存せず、content='documents' で通常テーブルを参照する構成にしました。
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
title,
body,
content='documents',
content_rowid='id',
tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS documents_ai
AFTER INSERT ON documents
BEGIN
INSERT INTO documents_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER IF NOT EXISTS documents_au
AFTER UPDATE ON documents
BEGIN
INSERT INTO documents_fts(documents_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
INSERT INTO documents_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER IF NOT EXISTS documents_ad
AFTER DELETE ON documents
BEGIN
INSERT INTO documents_fts(documents_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
END;
"""
documents に追加・更新・削除が発生したとき、トリガーで FTS5 側も同期します。外部コンテンツ方式では、この同期処理を忘れると検索結果と元データがずれるため、トリガーを最初から用意しておくのが安全です。
Markdown資料を取り込む
次に、指定ディレクトリ以下の Markdown ファイルを読み込みます。先頭の # 見出し をタイトルとして使い、見つからない場合はファイル名をタイトルにします。
from pathlib import Path
def read_markdown(path: Path) -> tuple[str, str]:
body = path.read_text(encoding="utf-8")
title = path.stem
for line in body.splitlines():
if line.startswith("# "):
title = line[2:].strip()
break
return title, body
def build_index(db_path: str, root_dir: str) -> None:
root = Path(root_dir)
files = sorted(root.rglob("*.md"))
with sqlite3.connect(db_path) as conn:
conn.executescript(SCHEMA)
# 今回は資料ディレクトリを正として、インデックスを作り直す
conn.execute("DELETE FROM documents")
rows = []
for path in files:
title, body = read_markdown(path)
rows.append((str(path), title, body))
conn.executemany(
"""
INSERT INTO documents(path, title, body)
VALUES (?, ?, ?)
""",
rows,
)
# 外部コンテンツのFTSインデックスを再構築する
conn.execute(
"INSERT INTO documents_fts(documents_fts) VALUES ('rebuild')"
)
print(f"{len(rows)} files indexed")
ここでは差分更新ではなく、実行のたびに対象ディレクトリの内容で作り直しています。数千ファイル程度の個人用資料なら実装が単純で、削除されたファイルが検索結果に残る問題も避けられます。
ファイル数が数万以上になり、毎回の再構築が遅くなった場合は、ファイルの更新日時やハッシュ値を保存して差分更新に切り替えるとよいでしょう。
検索結果を表示する
FTS5 の検索には MATCH を使います。snippet() で一致箇所の周辺テキストを取得し、bm25() で関連度の高い順に並べます。
def search(db_path: str, query: str, limit: int = 10) -> None:
sql = """
SELECT
d.path,
d.title,
snippet(documents_fts, 1, '【', '】', '…', 16) AS excerpt
FROM documents_fts
JOIN documents AS d
ON d.id = documents_fts.rowid
WHERE documents_fts MATCH ?
ORDER BY bm25(documents_fts)
LIMIT ?
"""
with sqlite3.connect(db_path) as conn:
rows = conn.execute(sql, (query, limit)).fetchall()
if not rows:
print("No results")
return
for index, (path, title, excerpt) in enumerate(rows, start=1):
print(f"[{index}] {title}")
print(f" {path}")
print(f" {excerpt}")
print()
SQL に検索文字列を直接埋め込まず、プレースホルダーに渡している点が重要です。検索語に記号が含まれる場合は FTS5 のクエリ構文として解釈されるため、単純なキーワード検索だけを提供するなら、入力をフレーズとしてエスケープする実装も検討できます。
例えば、検索コマンドを次のように呼び出します。
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
build_parser = subparsers.add_parser("build")
build_parser.add_argument("root")
build_parser.add_argument("--db", default="search.db")
search_parser = subparsers.add_parser("search")
search_parser.add_argument("query", nargs="+")
search_parser.add_argument("--db", default="search.db")
search_parser.add_argument("--limit", type=int, default=10)
args = parser.parse_args()
if args.command == "build":
build_index(args.db, args.root)
else:
search(args.db, " ".join(args.query), args.limit)
ファイル構成が次のようになっているとします。
notes/
├── sqlite.md
├── python.md
└── docker/
└── compose.md
インデックス作成と検索は以下のコマンドで実行できます。
python local_search.py build ./notes --db notes.db
python local_search.py search "SQLite transaction" --db notes.db
python local_search.py search "インデックス" --db notes.db --limit 5
私の環境では、検索結果にタイトル、ファイルパス、本文の一致箇所が表示され、目的の資料へすぐ移動できる形になりました。
実際に使って分かった改善ポイント
まず、検索対象の拡張子を増やしたくなりました。設計メモや SQL ファイルも検索したい場合は、rglob("*.md") を複数拡張子に対応させます。ただし、バイナリファイルを誤って読む可能性があるため、対象は明示的に限定するのが安全です。
次に、検索語が2文字以下の場合です。trigram は3文字単位のため、短い検索語では期待どおりにヒットしない場合があります。「API」や「DB」のような略語を頻繁に検索するなら、短い語だけ LIKE にフォールバックする処理を追加すると実用性が上がります。
また、Markdown のコードブロックだけを対象外にしたい場合もあります。今回の用途ではコード例も検索したかったため、そのままインデックス化しました。自然言語だけを探したい場合は、取り込み時に Markdown を解析してコードブロックを除去する方法があります。
SQLite のデータベースファイル自体をバックアップしやすい点も便利でした。資料ディレクトリと search.db を同じ作業フォルダに置けば、別のマシンでもインデックスを再生成できます。検索サービスを常駐させる必要がないので、個人用ツールとして管理しやすい構成です。
まとめ
SQLite FTS5 と Python を使うと、ローカルの技術資料を対象にした全文検索ツールを比較的少ないコードで作れます。
- 資料本体は通常の SQLite テーブルで管理する
- FTS5 の
trigramtokenizer で日本語や部分一致を扱う - トリガーで検索インデックスを同期する
-
MATCH、snippet()、bm25()で検索と結果表示を実装する - 小規模な資料なら、全件再構築でも十分運用できる
まずは Markdown の検索から始め、必要になった段階で差分更新、拡張子対応、短い検索語へのフォールバックを追加するのが進めやすいと思います。