背景:Markdown を「丸ごと読ませる」ことに、そろそろ限界を感じていた
最近、コーディングエージェント(GitHub Copilot、Claude Code、Gemini CLI など)に「このリポジトリの仕様に従って実装して」とお願いする機会が増えました。
便利です。便利なのですが、現場で使い込んでいると、ある違和感がだんだん無視できなくなってきます。
仕様書・設計書・ナレッジが Markdown でリポジトリに積まれているとき、エージェントはそれらを 丸ごと read_file で読み込もうとする ことが多いのです。数万トークンが一瞬で溶けていく。関係ない章まで読まれる。回答に noise が混ざる。
正直、「全文投入で力業」のアプローチは、そろそろ筋が良くないのではと思い始めました。
そこで、ローカル完結で Markdown を横断検索し、該当するチャンクだけをエージェントに返す 仕組み(markdown-query という Skill)を実際に作って運用してみています。本記事はその設計と、運用してみてわかったことの整理です。
このリポジトリーは、私が個人で管理しているもので、私の所属している企業や団体とは一切関係がありません。
注意点 / 前提
- 本記事は 私個人の検証と見解 です。所属組織の見解ではありません。
- 紹介する数値(トークン削減率など)は 特定の環境・特定のリポジトリ・特定のクエリ集合 での測定結果です。Markdown の量や構造、クエリの語彙、トークナイザ、マシン性能で結果は大きく変わります。ご自身のリポジトリで必ず再測定してください。
- ローカル完結(外部 API を呼ばない)構成を前提にしています。クラウド埋め込みベースの RAG とは設計思想がそもそも違います。
- プロダクション利用や一般化を保証するものではありません。あくまで「実務で使ってみた一例」として読んでいただければと思います。
ここで一度、整理してみます
「Context Window を節約する」と一口に言っても、論点を切り分けないと話が噛み合いません。私の中では次のように整理しています。
- 何が問題か:エージェントが Markdown を全文読み込むと、Context Window が短時間で枯渇し、コスト・速度・回答品質のすべてが劣化する。
- 何が問題ではないか:Markdown の編集・生成や、ソースコードの検索は、本記事のスコープではありません。そこは別の Skill / ツールの役目です。
- どこを狙うのか:「読み取り専用で、関連する見出しチャンクだけを抽出して渡す」という、地味だが効く一手に絞ります。
万能ではありません。とはいえ、絞った分だけ確実に効く範囲を作る、というのが今回のスタンスです。
どんな Skill なのか
markdown-query の実体は、mdq という Python 製の CLI です。.mdq 配下に SQLite で索引を持ち、外部 API は一切呼びません。エージェントからは python -m mdq ... のサブプロセスとして起動します。
できることは絞ってあります。
- BM25 検索:自然言語クエリで関連度順に上位 N 件
- grep 検索:完全一致したいキーワード用
-
タグ / パス絞り込み:frontmatter の
tagsや glob で範囲限定 -
見出し階層の俯瞰:
mdq listでファイル横断の見出し一覧 -
本文取得:
mdq get --chunk-id <ID>で必要な箇所だけ完全な本文
ポイントは、エージェントに返すのは 見出し単位の小さな snippet だけ という割り切りです。全文ではなく、引用元 (path:lines) を添えたチャンクを返す。これだけで Context の使い方が大きく変わります。
アーキテクチャ(簡略図)
索引 DB は (言語, Chunking Strategy) の組み合わせごとに別ファイル として作成されます。複数 Strategy を並行運用しても DB が壊れないよう、物理的に分離してあるのが地味ながら効いている設計です。
なぜチャンク分割を複数戦略にしたのか
最初は素朴な「見出しごとに 1 chunk」だけで始めました。実際に運用してみると、これだけでは扱いきれないクエリがあることに気づきます。
- 概念を聞きたいクエリ(「〜とは」「概要」)→ 見出しチャンクでは粒度が荒い
- 物語的な質問(「なぜ」「どのように」)→ 段落単位で意味境界を見たい
- コード片の検索 → 見出し構造を無視したい
- ID 風の文字列や固有名詞 → 完全一致で十分
そこで Strategy を増やしました。
| 戦略 | 境界 | 任意依存 |
|---|---|---|
heading(既定) |
Markdown 見出しごと | なし |
heading_recursive |
見出し chunk が大きい場合に段落で再分割 | なし |
fixed_window |
固定窓スライド | なし |
semantic_paragraph |
文 embedding 類似度で意味境界決定 | fastembed 等 |
pageindex |
見出しベースのツリー索引+ノードサマリ | なし |
auto(search 既定) |
クエリ内容から自動選択 | — |
実務では auto をそのまま使うことが多いです。クエリを query_router.py が 7 ルールで分類し、適切な Strategy の DB を選びます。該当 DB が無ければ fallback chain(pageindex → semantic_paragraph → heading_recursive → heading → fixed_window)に沿って切り替わります。
なぜ自動選択にしたか
正直、エージェントに毎回 --strategy を指定させるのは現実的ではありません。「クエリの性質を見て、それなりに妥当な戦略を選ぶ」くらいの自動化がないと、実務では使われなくなります。完璧なルーティングは目指していませんが、まずはここまでで十分だと感じています。
使い方(最小手順)
事前にインデックスを作る必要があります。これは外せません。
mdq index
その後、検索。
mdq search --q "クエリ" --top-k 5 --max-tokens 800
出力は JSONL(1 行 = 1 ヒット)。エージェントから呼ぶ場合は、
このリポジトリ配下の Markdown から "context window" を含む見出しを探して。
のように依頼すれば、Skill が起動してチャンクだけが返ってきます。
ファイル変更を逐次反映したい場合は mdq watch(要 watchdog)も用意していますが、必須ではありません。
ベンチマーク:本当に Context は減るのか
「効きそう」で終わらせず、実測しています。同梱の benchmark.py で、次の 3 シナリオを比較します。
-
baseline_full:全 Markdown 本文を投入 -
mdq_bm25:BM25 検索のヒットのみ -
mdq_grep:grep 検索のヒットのみ
実行例(本リポジトリの sample 配下、4 ファイル)
測定環境(例):tiktoken/cl100k_base / Python 3.12 / Windows 11 / top_k=5, max_tokens=800, repeat=3
baseline_full:4 files / 83,230 chars / 68,440 tokens
| シナリオ | avg tokens | savings vs baseline | latency mean (ms) |
|---|---|---|---|
mdq_bm25 |
1,794.6 | 97.38% | 13.35 |
mdq_grep |
700.6 | 98.98% | 1.45 |
全文投入比で 約 1〜3% までプロンプトトークンが圧縮されました。
…と、ここだけ取り出すと派手な数字ですが、実務では話が変わることが多いです。
数値の読み方に関する注意
-
削減率が高い = 良い、ではない。絞り込みすぎて期待箇所が抜けたら本末転倒です。「期待パスがヒットに含まれた割合(
coverage_proxy)」と必ずセットで見てください。 -
mdq_grepは表記揺れに弱い。生成AI パーソナライズのようなクエリで 0 hit になることがあります(このときsavings % = 100は「ヒットなし」を意味します)。 - latency は絶対値で比較しない。同一マシン・同一コミット内での A/B 比較に使う指標です。
- このベンチマークは LLM API を呼ばない。あくまで「Context 投入量と検索速度の代理指標」です。回答品質の評価は別途必要です。
運用で見えてきた、地味だが大事な工夫
ここから先は、READMEには大きく書かなかったけれど、実運用で「これがないと採用されない」と痛感した部分です。
1. Windows の文字コードで死なせない
cli.py が Windows cp932 環境で UnicodeEncodeError を吐くと exit 1 になり、エージェントは「壊れている」と判断してこのツールを 二度と呼ばなくなります。これは本当に致命的でした。
対策はシンプルで、main() 冒頭で sys.stdout.reconfigure(encoding='utf-8', errors='replace') を呼ぶか、PYTHONIOENCODING=utf-8 を環境変数で立てておく。地味ですが、ここを落とすと他の工夫が全部無に帰します。
2. 上位ルールに優先順位を明記する
リポジトリ最上位の .github/copilot-instructions.md / CLAUDE.md / AGENTS.md に、
Markdown ファイル群の検索は、まず
markdown-querySkill を試す。0 ヒットや目的不一致の場合に限りgrep_search/read_fileにフォールバックする。
と書いておかないと、エージェントは結局 grep_search を選びます。Skill を作るだけでは呼ばれません。呼ばせるための上位指示が要る、というのは運用してみないと実感しにくいポイントです。
3. 初回索引の自動化
索引が無い状態で mdq search を呼ぶと 0 件返却になり、エージェントは「使えないツール」と判断して諦めます。CI、pre-commit、devcontainer 起動スクリプト、あるいは onboarding ステップで mdq stats → 0 件なら自動 index、のような仕掛けを入れておくと採用率が変わります。
4. 利用ログで「実際に呼ばれているか」を確かめる
mdq の全 CLI 呼び出しは .mdq/usage.jsonl に append-only で記録されます。generate_usage_report.py を週次で回し、search の呼び出し回数や Context 削減率を眺めていると、「思ったより呼ばれていない」「auto で何が選ばれているか偏っている」など、Skill 設計のフィードバックが得られます。
なお .mdq/usage.jsonl には検索クエリがそのまま記録されます。機微情報の取り扱いはご注意ください。
ここまでの整理
一度、箇条書きでまとめておきます。
良い点
- ローカル完結で外部 API 不要。コスト・プライバシー面で安心
- 全文投入比で 1〜3% 程度までトークンを圧縮できる(あくまで一例)
- BM25 / grep / セマンティック / pageindex を用途で使い分けられる
- 索引 DB が
(lang, strategy)ごとに分離されているので壊れにくい
注意点
- 索引が前提。初回
mdq indexを忘れると 0 件返却で詰む - grep モードは表記揺れに弱い。日本語ではとくに
- Windows の文字コード問題を踏むと、エージェントが Skill を見限る
- 上位の指示文書に優先順位を書かないと、結局呼ばれない
限界
- Markdown の編集・生成はスコープ外
- ソースコード検索もスコープ外(本来
grep_search等の役割) - セマンティック検索の精度はモデル依存。万能ではない
- クラウド規模のベクトル検索が必要な要件には向かない
まとめ:「全文を読ませない」という設計判断
エージェントに何でも丸投げできる時代になりつつあるからこそ、何を読ませないか を設計する側で決めるのは、わりと重要な判断だと感じています。
markdown-query は派手な仕組みではありません。BM25 と SQLite と、いくつかの Chunking Strategy。ローカルで完結する、地味な Skill です。
それでも、
- Context Window を節約したい
- 引用元 (
path:lines) を明示したい - 外部 API に依存したくない
- まずは手元のリポジトリで試したい
という条件が揃うときには、十分実用に耐えると思います。
逆に、将来クラウド埋め込みベースの RAG が標準化されてきたら、本 Skill は素直に退役させる、というスタンスです。極論で「これさえあれば良い」と言うつもりはありません。
まずは自分のリポジトリで mdq index → benchmark.py を一度回してみて、自分の文脈での削減率と coverage を確認するところから始めてみてください。数字を見てから、続けるか退役させるかを決めれば良いと思います。
最後まで読んでいただきありがとうございました。