Skip to content

Instantly share code, notes, and snippets.

@podhmo
Created May 15, 2026 11:13
Show Gist options
  • Select an option

  • Save podhmo/1b0de3286d719324647eefe2b92c7556 to your computer and use it in GitHub Desktop.

Select an option

Save podhmo/1b0de3286d719324647eefe2b92c7556 to your computer and use it in GitHub Desktop.
`mdq`-ソースコードの理解と対応

対話履歴

ユーザー: 読んで


AI: ご提示いただいた mdq (Markdown cross-file query toolkit) のソースコード一式を読み込み、仕様と構造を理解しました。

LLM(Copilotやカスタムエージェント)に渡すコンテキストを最小限に抑えるために、Markdownファイルを見出し(Heading)ごとにチャンク化し、ローカル環境のみで高速に検索・抽出できるように設計された非常に洗練されたツールですね。

コードの主要な構成要素は以下の通り把握しています:

  1. store.py (SQLiteバックエンド)
    • files テーブルと chunks テーブルで構成されるシンプルなスキーマ。
    • バージョン3スキーマでは FTS5 (全文検索) の仮想テーブル chunks_fts とトリガーを利用し、FTSが利用できない環境では安全にフォールバックする仕組み。
  2. indexer.py (インデクサー・構文解析)
    • 依存関係を最小限に抑えつつ(yamlはオプショナル)、見出しレベルごとにチャンクを分割。
    • コードブロック(Fence)内の # を誤認識しない堅牢なパース処理。
    • max_chunk_chars による長文テキストの賢いサブ分割(段落→行ベースでの分割フォールバック)。
  3. search.py (検索処理)
    • rank_bm25 があればそれを使用し、なければ独自実装の _MiniBM25 にフォールバックする実装。さらに FTS5 エンジンへの切り替えもサポート。
    • 親見出しの包含(include_parent)や前後のチャンク拡張(expand_neighbors)、分割された同一見出しの結合(merge_parts)など、LLMに必要なコンテキストを柔軟に組み立てる機能。
  4. watcher.py (リアルタイム監視)
    • watchdog を使ったファイル監視。デバウンス処理(重複イベントの抑制)やバーストイベント発生時の全件再スキャンへのフォールバックなど、エッジケースに配慮された安全な並行処理設計。
  5. cli.py & __main__.py (CLIインターフェース)
    • エージェントからJSON/JSONL形式で呼び出しやすい search, get, list, index, watch などのコマンド体系。

こちらのコードベースに対して、どのような作業を行いましょうか? 以下のような対応が可能です。ご指示をお知らせください。

  • 機能追加(新しいオプションの追加、検索ロジックの調整など)
  • バグ修正やコードレビュー(潜在的なバグの指摘やパフォーマンス改善)
  • テストコードの実装pytest などを用いた特定モジュールのテスト作成)
  • 仕様の変更やリファクタリング
  • ドキュメントの作成

(返答終了理由: STOP)


ユーザー: watchしてる時に1つファイルが追加された時にどのようなことがおきますか?すべて再スキャンになりますか?


AI: いいえ、1つのファイルが追加されただけであればすべて再スキャン(全ルートの再走査)にはなりません

追加されたその1つのファイルだけを対象としたインクリメンタル(差分)更新が実行されます。

内部では以下のような流れで処理が行われます。

1ファイル追加時に起こる処理の流れ

  1. イベント検知 OSのファイルイベントを watchdog が検知し、watcher.pyon_created(または on_moved)が発火します。
  2. キューイングとデバウンス待機 すぐには処理せず、内部の保留キューに追加して デフォルトで 500ms(debounce_ms)待機します。 この待機中に同じファイルに何度も連続して書き込みが行われた場合でも、最後の1回の更新イベントにまとめられます(ファイルの保存連打などによる無駄な負荷を防ぐため)。
  3. 単一ファイルのインデックス更新 500ms経過後、バックグラウンドのワーカースレッドがキューから対象ファイルを取り出し、indexer.pyindex_one_file() を呼び出します。
    • 追加された1つのMarkdownファイルを読み込んで見出し単位のチャンクに分割します。
    • SQLiteデータベース(.mdq/index.sqlite)に対して、そのファイルのメタデータを追加(または上書き)し、そのファイルに属するチャンクデータのみをデータベースに書き込みます。

では、どのような時に「すべて再スキャン」になるのか?

コードには 「バースト検出機能」 が備わっています。

デフォルトの設定では、「1.0秒間(burst_window_s)に 100件(burst_threshold)以上の変更イベント」 が発生した場合のみ、システムへの過負荷や競合を防ぐための安全網として、キューをリセットして build_index による 全ルートの再走査(すべて再スキャン) へとフォールバックします。

これは主に git checkout によるブランチの切り替えや、大規模なディレクトリのコピー・削除などが行われた時を想定した処理です。人が通常手作業で1つファイルを作成・保存した程度であれば、このバースト判定に引っかかることはなく、対象ファイルのみの軽量な差分更新で完了します。

(返答終了理由: STOP)


ユーザー: うーんそれではBM25の計算はどうなってるのですか?


AI: 非常に鋭いご指摘です。「全体再スキャンをしないなら、コーパス全体の単語頻度(IDFなど)に依存するBM25のスコアはどうやって整合性を取っているのか?」という疑問ですね。

結論から言うと、mdq ではファイル追加時(インデクシング時)にはBM25の事前計算を一切行わず、「検索を実行した瞬間(クエリ時)にオンザフライで計算する」(または 「SQLiteのFTS5エンジンに丸投げする」)というアプローチをとっています。

具体的には、検索実行時(search.py)に設定されているエンジンによって以下の2パターンのどちらかで動きます。

1. インメモリBM25フォールバックの場合 (デフォルトの動作)

外部ライブラリの rank_bm25 や、自前の _MiniBM25 を使う場合です。

検索コマンドが呼ばれるたびに、以下の処理が行われます。

  1. DBからすべてのチャンクテキストをメモリに読み込む (rows = _store.all_chunks(conn))
  2. すべてのテキストをその場でトークナイズしてコーパスを作る (corpus = [tokenize(r["text"]) for r in rows])
  3. 最新の全データからIDFなどを計算し、スコアリングする (bm25 = BM25Okapi(corpus) など)

つまり、ファイルが1つ追加されてDB(SQLite)に新しいチャンクが保存されていれば、次の検索時にはその新しいチャンクを含めた全データをロードしてゼロからBM25の統計量を作るため、スコアは常に完全に正確なものになります。

2. SQLite FTS5エンジンを使用している場合

環境変数 MDQ_FTS5=1 などでSQLiteの組み込み全文検索(FTS5)を有効にしている場合です。

  1. watcher によって1つのファイルがDBの chunks テーブルに追加・更新されると、store.py で定義されている**SQLiteのトリガー(AFTER INSERT など)**が発火します。
  2. トリガーによって、自動的にFTS5の仮想テーブル(chunks_fts)にデータが同期されます。
  3. SQLiteのFTS5エンジンが内部で自動的に単語インデックスや統計情報を更新してくれます。
  4. 検索時はSQLで ORDER BY bm25(chunks_fts) と投げるだけで、SQLite側が高速に計算した結果を返してくれます。

なぜこの設計なのか?

store.py の冒頭のコメントにも以下のように書かれています。

Schema is intentionally small. BM25 ranking is computed at query time over the chunks loaded from this store (small/medium corpora). (スキーマは意図的に小さくしています。BM25ランキングは、DBからロードされたチャンクに対してクエリ時に計算されます(小〜中規模のコーパス向け)。)

小〜中規模(数千〜数万チャンク程度)のMarkdownファイル群であれば、Pythonで毎回DBから読み込んでインメモリでBM25を構築してもミリ秒単位で終わります。

ファイルを保存するたびに複雑なIDFの再計算やインデックスの更新ロジックを自前で実装するよりも、「データベースにはテキストだけを素直に保存しておき、計算は検索時に行う」という設計にすることで、システム全体をバグが少なくシンプルに保つ意図があります。

(返答終了理由: STOP)


ユーザー: extras_requireで全ての依存もインストールできるpyproject.tomlを作ってくれませんか?


AI: ソースコードの記述とコメント(PyYAMLwatchdogrank_bm25 の利用)から要件を読み取り、モダンな pyproject.toml (PEP 621準拠)を作成しました。

エージェントやユーザーがコマンド一発で全機能を有効化できるよう、all というオプションも定義しています。プロジェクトのルートディレクトリに pyproject.toml という名前で保存してください。

[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "mdq"
version = "0.1.0"
description = "Local-only Markdown cross-file query toolkit."
authors = [
    { name = "Author", email = "author@example.com" }
]
readme = "README.md"
requires-python = ">=3.8"

# 常にインストールされる基本依存パッケージ
# (indexer.py のコメントに "PyYAML is already a project dependency" とあるため)
dependencies = [
    "PyYAML>=6.0",
]

[project.optional-dependencies]
# 高度なBM25検索用(インストールされていれば優先的に使われ、無ければフォールバック)
search = [
    "rank_bm25>=0.2.2",
]

# リアルタイム監視機能用(watcher.py で使用)
watch = [
    "watchdog>=3.0.0",
]

# すべてのオプション機能(検索高速化+ファイル監視)を一括でインストール
all = [
    "rank_bm25>=0.2.2",
    "watchdog>=3.0.0",
]

[project.scripts]
# CLIコマンドとしての登録。これによりターミナルで `mdq` コマンドが使えるようになります。
mdq = "mdq.cli:main"

[tool.setuptools]
packages = ["mdq"]

インストール方法の使い方

この pyproject.toml を配置したディレクトリ(mdq/ フォルダがある階層)で、用途に合わせて以下のコマンドでインストールできます。

基本機能のみ(外部の全文検索・監視ライブラリなしで軽量に使いたい場合)

pip install -e .

監視機能(watch)も有効にしたい場合

pip install -e .[watch]

(※ソースコードの警告ログにあった mdq-watch は、分かりやすいよう watch にしています)

利用可能なすべての機能(高速検索と監視機能の両方)をインストールする場合

pip install -e .[all]

インストール後は python -m mdq ... だけでなく、単に mdq search --q "テスト" のような形でコマンドラインから直接実行できるようになります。

(返答終了理由: STOP)


ユーザー: 探索時のマルチバイト文字の扱いはどうなってるのですか?

またセクションを取り出す方法は正規表現だと思うのですが、コードブロック中のコメント行(たとえばPythonやRuby)に対しておかしなマッチはしませんか?


AI: 2点とも非常に重要なポイントですね。ソースコードを読むと、どちらも「外部依存(MeCabや複雑なパーサー)を使わずに、いかに標準ライブラリだけで頑健に動かすか」という工夫が見て取れます。

それぞれの実装がどうなっているか解説します。


1. 探索時のマルチバイト文字の扱い

検索時(クエリ時やインメモリBM25でのインデックス時)の文字列分割は、search.py の以下の正規表現で行われています。

_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+|[\u3040-\u30ff\u4e00-\u9fff]")

def tokenize(text: str) -> list[str]:
    return [t.lower() for t in _TOKEN_RE.findall(text)]

この正規表現には面白い特徴があります。

  • 英数字 ([A-Za-z0-9_]+): + が付いているので、「Markdown」や「Python」のようにひとまとまりの単語として扱われます。
  • 日本語 ([\u3040-\u30ff\u4e00-\u9fff]): ひらがな、カタカナ、漢字の範囲ですが、あえて + が付いていません

つまり、マルチバイトの日本語については「MeCab」などの形態素解析器を使わず、強制的に1文字ずつ(ユニグラム)に分割してBM25の検索にかけています。 「検索」という単語を入力した場合、内部的には ["検", "索"] という2つのトークンのAND検索のようなスコア計算になります。外部ライブラリ不要のローカル実行ツールとしては、非常に理にかなった軽量なアプローチです。

※注意点(SQLiteのFTS5エンジンについて) 環境変数 MDQ_FTS5=1 などでSQLite内蔵のFTS5(全文検索)に切り替えた場合、SQLiteのテーブル定義は tokenize='unicode61'(空白区切り)となっています。SQLiteのデフォルトでは日本語の文字単位分割をしてくれないため、FTS5モードを有効にすると日本語の検索がうまくヒットしなくなる可能性が高いです。日本語をメインで検索する場合は、デフォルトのまま(FTS5を使わず、Python側のBM25で動かす)にしておくのが安全です。


2. コードブロック中の #(コメント)の扱い

結論から言うと、おかしなマッチはしません(安全に設計されています)

「正規表現だけでファイル全体からガサッと見出しを抽出している」のであれば誤爆してしまいますが、indexer.py_split_chunks 関数は**ファイルを1行ずつ上から順に走査するステートマシン(状態遷移)**として実装されています。

以下の処理が行われています。

    in_fence = False  # コードブロックの中にいるかどうかのフラグ
    fence_marker = ""

    # ... 中略 ...

    for idx in range(body_start, len(lines)):
        line = lines[idx]
        
        # 1. まずコードブロックの境界( ``` や ~~~ )をチェック
        fm = FENCE_RE.match(line)
        if fm:
            # 状態を反転させる (外にいたら中へ、中にいたら外へ)
            # (省略)
                
        # 2. ★コードブロックの外側にいる時だけ見出しをチェック!
        if not in_fence:
            hm = HEADING_RE.match(line)
            if hm:
                # ここで見出しとして分割処理を実行

このように、コードブロックの開始記号(``` など)を見つけると in_fence = True という状態になり、その閉じ記号を見つけるまでは見出しチェック(HEADING_RE.match)の処理自体を完全にスキップします。

そのため、PythonやRubyのコードブロック内に # 重要なループ処理 のような行があっても、決してMarkdownの見出し(セクションの区切り)として誤認識されることはありません。さらに長文を分割する _subdivide 関数内でも、コードブロック内は絶対に途中で分割しない(indivisible)処理が書かれており、コードの構造を壊さないように配慮されています。

(返答終了理由: STOP)


ユーザー: コードブロックはネストできることを知ってますか?4個のバッククォートのコードブロックの中で3個のバッククォートのコードブロックを作れます


AI: おっしゃる通りです!非常に鋭いご指摘をありがとうございます。見事にこのコードの**潜在的なバグ(Markdownの仕様に対する考慮漏れ)**を突かれました。

現在の indexer.py の実装を確認すると、ご指摘の通り「ネストされたコードブロック」を入力された場合に**パースが壊れる(途中でコードブロックが終了したと誤認して見出しとして解釈してしまう)**バグが存在します。

バグの原因

indexer.py には2箇所、コードブロックの開閉を判定しているロジック(_segment_by_fence_split_chunks)がありますが、どちらも開始マーカーを保持する際に [:3] とハードコードして最初の3文字だけを記憶してしまっています。

# 現在の実装 (indexer.py の抜粋)
        fm = FENCE_RE.match(line)
        if fm:
            marker = fm.group(1)
            if not in_fence:
                in_fence = True
                fence_marker = marker[:3]  # 🚨 ここが原因!4つ以上で開いても "```" になってしまう
            elif line.startswith(fence_marker):
                in_fence = False

この実装のままだと、以下のようなMarkdownがあった場合:

````
```python
# 重要な設定
def setup(): pass
```
````
  1. 最初の ````(4つ)を検知し、in_fence = True になる。
  2. しかし記憶されるマーカーは marker[:3] により ```(3つ)になる。
  3. 次の行の ```python を読んだ時、startswith("```")True になってしまい、ここでコードブロックが閉じたと誤認してしまう。
  4. その結果、次の行の # 重要な設定見出しとして抽出してしまう(誤爆)

修正方法

Markdown(CommonMark)の正しい仕様では、「開始時と同じ文字(` または ~)で、かつ開始時と同じかそれ以上の長さの連続」 でなければコードブロックは閉じません。

このバグを修正するために、indexer.py の2つの関数を修正する必要があります。

1. _split_chunks の修正

ステートマシン側で「何の文字で」「何文字で」開いたかを正確にトラッキングするように直します。

# 修正前
    in_fence = False
    fence_marker = ""

    # ... 中略 ...
        fm = FENCE_RE.match(line)
        if fm:
            marker = fm.group(1)
            if not in_fence:
                in_fence = True
                fence_marker = marker[:3]
            elif line.startswith(fence_marker):
                in_fence = False

# ==========================================
# 修正後
    in_fence = False
    fence_char = ""
    fence_len = 0

    # ... 中略 ...
        fm = FENCE_RE.match(line)
        if not in_fence:
            if fm:
                in_fence = True
                fence_char = fm.group(1)[0]      # '`' か '~' かを記憶
                fence_len = len(fm.group(1))     # バッククォートの数を記憶
        else:
            # コードブロック内にいる場合は、閉じ条件を満たすかチェック
            if fm and fm.group(1)[0] == fence_char and len(fm.group(1)) >= fence_len:
                in_fence = False

2. _segment_by_fence の修正

チャンクが文字数上限(max_chunk_chars)を超えた場合の長文分割処理でも、同じバグがあります。

# 修正前
        m = FENCE_RE.match(lines[i])
        if m:
            marker = m.group(1)[:3]
            # ... 中略 ...
                if lines[j].startswith(marker):
                    closed = True

# ==========================================
# 修正後
        m = FENCE_RE.match(lines[i])
        if m:
            marker_char = m.group(1)[0]
            marker_len = len(m.group(1))
            j = i + 1
            closed = False
            while j < n:
                close_m = FENCE_RE.match(lines[j])
                if close_m and close_m.group(1)[0] == marker_char and len(close_m.group(1)) >= marker_len:
                    closed = True
                    break
                j += 1

コードの記述から意図を正確に読み取っていただき、本当にありがとうございます。これに気づけるのはMarkdownの仕様とパーサーの実装パターンの両方に詳しい証拠ですね。もしこの修正版の完全な indexer.py のコードが必要であればお出しします。

(返答終了理由: STOP)


ユーザー: このツールの限界を超えるタイミングを考えてください。そして列挙してください。


AI: 「外部依存を極力排除し、ローカルでLLMエージェント向けのチャンクを最速で取り出す」という mdq の洗練された設計思想は素晴らしいですが、その**トレードオフとして「あえて捨てている部分」**があります。

ツールが限界を迎え、より本格的な検索エンジン(Elasticsearch, ベクトルDB, LlamaIndex等の導入)への移行を検討すべきタイミングを、5つの観点で列挙します。

1. データ量(コーパスサイズ)の限界:数万チャンクの壁

現在の検索ロジック(search.py)は、検索のたびにSQLiteから全チャンクをメモリ上にロードし、オンザフライでBM25の統計量(IDFなど)を計算しています。

  • 限界のタイミング: ファイル数が数千、チャンク数が数万〜数十万レベルに達した時。毎回の検索で大量のメモリを消費し、検索レイテンシが「LLMエージェントが瞬時に返答するための許容時間(数百ミリ秒)」を超えて、数秒〜数十秒待たされるようになります。

2. 日本語検索の精度限界(ユニグラムの限界)

先ほど確認した通り、日本語は形態素解析器(MeCabなど)を使わず「1文字ずつ(ユニグラム)」に分割して検索しています。

  • 限界のタイミング:
    • ノイズの増加: 「東京都」と検索したのに「」「」「」がバラバラに含まれる全く別の文書(例:「阪神のにある市」)が高いスコアを出してLLMのコンテキスト枠を無駄遣いするようになった時。
    • 表記揺れと類義語: 「サーバー」「サーバ」、「スマホ」「スマートフォン」などの表記揺れが吸収できず、検索漏れ(False Negative)が頻発してLLMが「情報が見つかりません」と答えるようになった時。

3. 「見出し依存」のチャンク化戦略の限界

indexer.py は、Markdownの見出し(#)を文脈の境界としてチャンクを分割しています。これは技術文書や仕様書には最適です。

  • 限界のタイミング:
    • 見出しの無い長文: 会議の議事録ベタ書きや、小説のような「見出しはないが文脈が変わる文書」が増えた時。物理的な文字数(max_chunk_chars)で強制的にぶつ切りにされ、重要な文脈(代名詞の指す先など)が分断されてLLMが内容を誤認します。
    • 巨大なテーブルとリスト: Markdownの巨大な表(Table)や、深くネストされた箇条書きの中に重要なデータがある場合、表やリストの途中で文字数制限によって分割されると、LLMは「何の表の一部なのか」理解できなくなります。

4. リアルタイム監視(Watchdog)のOSリソースの限界

watcher.py はOSのファイルシステムイベント(inotifyやFSEvents)をフックして動作しています。

  • 限界のタイミング:
    • 監視対象ファイルが数万件を超え、**OSの監視ハンドル上限(inotify limit)**に抵触して監視ツールがクラッシュした時。
    • コードベースがネットワークドライブ(SMB/NFSなどクラウドストレージの同期フォルダ)に置かれた時。これらはファイル更新イベントを正しくOSに通知しないことが多く、インデックスのサイレントな欠落が発生します。

5. セマンティック検索(ベクトル検索)が求められる限界

mdq はキーワード一致(BM25・grep)のみをサポートしています。

  • 限界のタイミング: LLMやエージェントの利用者が、「先月のバグ修正でデータベースのスキーマを変えた時の議論」のような、自然言語・意味ベース(Semantic)の質問を投げるようになった時。 ユーザーの質問の中に「データベース」「スキーマ」「変更」という直接的なキーワードが含まれていない(実際の文書には「DBのテーブル定義をalterした件」と書かれている)場合、BM25では絶対にヒットしません。

総評 mdq は「個人の知識ベース」「1つのプロジェクトのドキュメント(数百〜数千ファイル)」をローカルで高速にエージェントに読ませるための最高の初期装備です。 しかし、上記の事象(特にパフォーマンス悪化と日本語のノイズ)が顕在化した時が、ベクトルデータベース(Qdrant, Chromaなど)への移行や、チャンク分割ロジックの抜本的な見直し(LlamaIndexの導入など)を行う明確なサインとなります。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-3.1-pro-preview",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false,
    "thinkingBudget": -1,
    "outputResolution": "1K",
    "thinkingLevel": "THINKING_HIGH",
    "enableImageSearch": false,
    "enableGoogleMaps": false,
    "enableAgentThinkingSummariesControl": false,
    "enableAgentVisualizationControl": false,
    "enableAgentCollaborativePlanningControl": false,
    "environmentMode": "new"
  },
  "systemInstruction": {}
}
@podhmo
Copy link
Copy Markdown
Author

podhmo commented May 15, 2026

uithub.com経由で全部渡してai studio上で聞くとかだとスマホ上でも何とかなる。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment