GrooveSeek

Semantic search over a Markdown knowledge base, served over MCP.

View the Project on GitHub alphabet-h/grooveseek

12. コードは定義で切り、覆えなかった範囲は行で埋める

背景と問題

これまでの parser はどれも散文を割ってきた。Markdown は見出しで、バイナリ形式は その形式が既に持っている単位 — ページ / シート / スライド — で割る。ソースコードには 見出しが無く、読み手が返してほしい単位は定義である。問いに答えるのは、関数であり、 struct であり、method だからだ。

散文と同じ割り方をすると、関数の途中で始まって次の関数の途中で終わる chunk ができる。 これはまさに本 feature が消そうとしている検索の失敗そのものだ。かといって「定義だけ」で 割ると穴が空く — ファイルは定義だけでできていない。import、トップレベルの文、impl ブロックを囲む波括弧、そしていちばん効く場合として parser が解釈できなかった領域が、 どの定義の外にも残る。定義だけを出す chunker は、それらを黙って落とす。

本決定が答える問い: コード chunk の単位は何か。そして、その単位が覆わなかった バイトはどうなるのか。

決定を左右した事情

検討した選択肢

  1. きれいに parse できないファイルはプレーンテキスト parser へ fallback する。 いかにも安全網に見えて、実は逆。TxtParser はファイル全体を 1 chunk にする。 波括弧 1 つが余計な 2,000 行のファイルが 1 chunk になり、検索の役に立たないうえ、 壊れた箇所の周囲で問題なく parse できていた 90 個の定義まで隠してしまう。
  2. 構造を無視した固定長 / 行ウィンドウ。 1 バイトも落とさず grammar も要らないが、 関数を丸ごと返すこともない。コードが散文と違う理由を最初の一歩で捨てている。
  3. 定義だけ。 きれいで説明もしやすいが、import も、tags query が拾わない言語の module レベル定数も、parse できなかった領域も落ちる。しかも落ちたことが見えない — ファイルの 3 分の 1 が index されていないことは出力のどこにも現れない。
  4. 定義で切り、覆えなかったバイトを行で埋める (採用)。

決定

選択肢 4 を採る。単位は grammar 自身の tags.scm@definition.* capture。 どの定義も覆わないバイト範囲は、見出しを持たない行ベースの chunk になる。

そこから導かれること、そして各部がその形である理由:

定義の範囲は doc comment から始まる。 tags query が報告するのは定義ノードで、 その上に書かれた doc comment はノードの外にある。放っておくと comment は定義の chunk にもその上の gap chunkにも入る — 二重に index され、内容を合計するものは 二重に数える。そこで覆う範囲を comment ノードの連なりのぶん手前へ伸ばし、空行で止める。 空行で隔てられた comment は、その定義ではなくファイルへの注釈だからだ。

予算を超えた定義は入れ子の定義へ再帰し、入れ子が無ければ行で割る。 module や class には潜る先があるが、method には無い — つまり実際のコードで支配的な形では、 行で割るのが例外ではなく常道になる。割れた各片は元の定義の見出しと種別を保ち、 自分自身の行範囲を報告する。長い関数の後半に hit しても、それがどの関数かは分かる。

行番号は定義ではなく chunk を指す。 関数の上から取り込んだ doc comment は範囲の内側で、 3 つに割れた関数は各片が自分の範囲を持つ。「その行でファイルを開けば、この chunk の中身が そこにある」がコードファイルの全 chunk について真になる読み方は、これだけである。

種別は言語のキーワードではなく grammar の語。 Rust の tags query は struct も enum も union も class と呼ぶ。キーワードを復元するにはノード種別から表示名への対応表を 言語ごとに groove 側へ持つことになり、それこそこの設計が避けている表だ。だから symbol_kindclass を運び、値の集合は enum で固定せず、言語の追加とともに増える。

スコープは定義リストではなく木を歩いて得る。 Rust の tags query は impl ブロックを 参照としてしか capture せず、定義にはしない。よって impl Database は定義木の節にならない。 だが同じファイルにある 2 つの open method を区別するのは、まさにそれである。親を辿って name / type / trait のいずれかの field を持つ最初の祖先を採る — この 3 つの field 名は grammar をまたいで通用するので、言語中立のまま保てる。

短さの閾値に満たない gap 断片は捨てる。 ただし捨てるとファイルの chunk が 無くなる場合は残す。quality filter だけでは足りない: 閉じ波括弧 1 行は閾値を下回って filter されるが、閾値未満の 2 行断片は filter を上回ってしまい、中身の無い chunk として 生き残る。ここで再利用する閾値は quality filter が既に使っているものと同じで、 ずれ得る数を 2 つ持たないためである。

結果と代償

参考

chunker は grooveseek/src/parser/code/ にある。どの grammar を同梱し、残りをどう届けるか という対の決定は ADR-0013