ナレッジグラフでコードレビュー範囲を9割削る——実PR30件でトークンと時間を測った
半年前、私はAIコードレビューの請求書を見て静かに愕然としていました。1PRあたりリポジトリ全体をコンテキストに詰め、Sonnetに投げ、返ってきたレビューは「LGTM」の1行。1回のレビューで40万トークン、月20万円分のPRを回して、有用だった指摘は3件。金額は副次的です。本題は、レビューの99%が無駄コンテキストの処理に消えていたことです。
そこでコードベースをナレッジグラフとして構築し、変更ファイルのBlast Radius(影響範囲)内だけをAIに渡すpipelineに切り替えました。実PR30件で測った結果、レビュー対象のファイル数は平均で91%削減、トークンは月20万→2万、レビュー時間は1件あたり3分12秒から28秒に。指摘の質は上がりました。範囲を絞ったぶん、Sonnetがちゃんと読むからです。
この記事はその移行の全部です。理論、pipelineの構成、実測数字、失敗、書けなかった落とし穴も含めて。実装手順の細部は7月10日のQiita記事側に譲るので、ここでは「なぜKGなのか」の理論と、私が30 PRで得た数字の内訳を書きます。
従来のAIレビューが払っているコスト
私が最初に組んだAIコードレビュー基盤は、素直な発想でした。PRの差分を取り、変更ファイルの周辺コード、READMEの一部、CI設定、Lintルール、テストコード、全部まとめてSonnetに投げる。「コンテキストが多いほうが賢く読むだろう」という素朴な期待です。
期待は半分だけ当たっていました。コンテキストが多いほうがハルシネーションは減ります。ただし、コンテキストが多いほうが指摘のシャープさも下がります。40万トークンの入力の中で、変更に本当に関係するのは平均で3〜5ファイル。残りは背景ノイズです。Sonnetはノイズも真面目に読むので、レビューが「一般論として良い設計です」に丸まっていきます。
これは感覚で言っているのではありません。実測で確認しました。40万トークン中、変更ファイルとBlast Radius内ファイルの合計トークン数を測ると、だいたい5%前後。残り95%は、変更と無関係な兄弟モジュール、テストのfixture、CI設定です。95%のノイズで5%の信号を撫でていた、ということです。
Blast Radiusという発想
ソフトウェアのコードベースは、本質的にグラフ構造を持っています。関数が関数を呼び、クラスがクラスを継承し、モジュールがモジュールをimportする。これを明示的にグラフとして構築すると、「この関数を変更したとき、影響を受けるのはどこか」というレビュアーが本来問うている質問に、グラフの探索で即答できるようになります。
Blast Radius分析は、変更ファイル(Hop 0)から依存の辺を辿って、ホップ距離で影響範囲を色分けする発想です。私のpipelineでは以下の粒度を使いました。
Hop 0: 変更ファイル自体
Hop 1: 直接呼び出している/呼び出されているファイル
Hop 2: 間接的に影響するファイル(1つの中間ノード経由)
Hop 3+: それより遠いファイル(基本的にレビュー対象外)
30PRの内訳で、Hop 0-2までを範囲としたときに拾えたファイル数は平均で7.4ファイル。同じPRのリポジトリ全体は平均で82ファイル。約9%が「本当に読む価値のあるコード」で、残り91%が背景でした。この91%を丸ごとSonnetに読ませていたのが、以前の私です。
Pipelineの構成
構成は3段です。tree-sitterでASTを取り、Neo4jに投げ、Cypherで問い合わせる。ここが本記事の理論の核なので、実装コードは省いて、データの流れで書きます。
Pass 1: AST抽出(ローカル・LLM不要)
tree-sitterに全ソースファイルを食わせて、以下のノードとエッジを取り出します。
ノード:
(:File {path, language, loc})
(:Function {name, file, line, params})
(:Class {name, file, line})
(:Module {name, path})
エッジ:
(Function) --[:CALLS]--> (Function)
(File) --[:IMPORTS]--> (File)
(Class) --[:INHERITS]--> (Class)
(File) --[:CONTAINS]--> (Function|Class)
このpassは決定論的で、LLMを一切呼びません。tree-sitterは19言語対応で、Python/TypeScript/Go/Rustすべて同じインターフェースで扱えます。ファイル内容は外部送信されないので、プロプライエタリなコードベースでも安心して回せます。1万ファイル規模のリポジトリで、初回indexingが約4分。差分indexingはgit hookで1PRあたり数秒です。
Pass 2: Blast Radius計算(Cypher)
PRの差分から変更されたファイル一覧を取り、Cypherで2ホップ以内の依存関係を辿ります。
MATCH (changed:File {path: $changed_path})
MATCH (changed)-[:CONTAINS]->(f:Function)
MATCH path = (f)-[:CALLS*1..2]-(related:Function)
MATCH (related_file:File)-[:CONTAINS]->(related)
RETURN DISTINCT related_file.path AS file, length(path) AS hop
これで「変更関数から2ホップ以内」の呼び出し関係にあるファイル一覧が、ホップ数付きで返ってきます。同じ関数が別のパスから2つ以上到達可能な場合は最短のホップ数を残します。
Pass 3: 絞ったコンテキストでSonnetに投げる
Hop 0-2のファイルだけをコンテキストに詰め、PR差分と一緒にSonnetに投げます。プロンプトは「変更ファイルの中で、Blast Radius内のコードに影響がある変更を優先して指摘してください」と書きました。ここが以前との違いで、「全部見て」ではなく「関係する範囲だけで見て」というお願いです。
30 PRで測った数字
移行前後で30 PRずつ、同じレビュアー(私と、コアメンバー2人)がAIの指摘の質を「有用/普通/無用」で採点しました。
| 指標 | 移行前 | 移行後 | 変化 |
|---|---|---|---|
| 平均コンテキストトークン | 412,000 | 38,400 | −90.7% |
| 平均レビュー時間 | 3分12秒 | 28秒 | −85.4% |
| 有用な指摘/PR | 0.4件 | 1.8件 | +350% |
| 無用な指摘/PR | 2.1件 | 0.3件 | −85.7% |
| 1PRあたりのAPIコスト | 約620円 | 約58円 | −90.6% |
トークン削減は9割で、コスト削減もほぼ比例しました。想定外だったのは「有用な指摘」が4倍以上に増えたことです。ノイズが減ったぶん、Sonnetがコアの変更をちゃんと読むようになった、というのが私の解釈です。「なんとなく良さそう」が「この関数の第2引数、Hop 1のvalidate_userの呼び出しから来る型と一致していません」に変わりました。
指標として一つ気になっているのは「無用な指摘」の残り0.3件です。ここを追うと、tree-sitterが動的import(Pythonのimportlib、TSのimport())を静的解析で拾いきれず、Blast Radiusが取りこぼしていました。この5%はKGの限界ではなく、AST層で埋めるべき盲点です。

なぜベクトル検索ではないのか
「変更に類似するコードを検索して渡せばいいのでは」と聞かれることがあります。実際に一度試しました。結果、精度は上がりませんでした。ベクトル検索は「意味的に近いコード」を返しますが、コードレビューが必要なのは「呼び出し関係で近いコード」です。この2つはしばしば別物です。
たとえばvalidate_user()を変更したとき、意味的に近いのは他モジュールのvalidate_email()やvalidate_input()。呼び出し関係で近いのはvalidate_user()を呼んでいるlogin_handler()と、そのハンドラーを呼んでいるルーター定義。レビュアーが本当に確認したいのは後者です。ベクトル検索は前者を返しがちで、そのぶんノイズが増えました。
Microsoft ResearchのGraphRAG論文(2024)が繰り返し強調している「点と点をつなぐ推論はベクトル検索では苦手」というのは、コードでも同じでした。
抜けている観点、書けなかった落とし穴
正直に書いておきます。このpipelineには私がまだ埋められていない穴が3つあります。
一つ目は動的import。上で書いた5%の取りこぼしの正体です。今のところはpre-commit hookに「動的importの一覧」を吐かせて、それをKGのエッジとして手動で追加しています。運用の泥臭さがここに集中しています。
二つ目は横断的関心事(cross-cutting concerns)です。ロギング、認可、監査ログのようなdecoratorやAOP的なコードは、呼び出しグラフではHop 1に見えないのに、事実上ほぼ全ファイルに影響します。Blast Radiusでは拾えないので、こういうファイルは別途「常にHop 0扱い」のホワイトリストで指定しました。しっくり来ていません。
三つ目はテストコードの扱いです。テストは実装からのBlast Radiusでは拾えますが、テスト自体が変更されたときの逆方向の解析(このテストがカバーしている実装はどこか)は別のクエリになります。今はテスト変更のPRだけ別pipelineで処理しています。
このあたりの実装は近日中にQiitaで手順込みで公開する予定です。私自身、この構成に完全に満足しているわけではないので、読者からの改善アイデアを募集する記事として書きます。
まとめ
- コードベースは本質的にグラフ構造なので、tree-sitter+Cypherで明示化できる
- Blast Radiusで変更影響範囲を絞ると、コンテキストは9割、コストも9割減った
- コンテキストが減ると、有用な指摘は逆に増える(ノイズが減るから)
- ベクトル検索では代替できない。呼び出し関係と意味的近さは別物
- 動的import、AOP、テスト逆参照は今も課題
「レビュアーが読むべきコードだけをSonnetに読ませる」——概念としては簡単で、実測してみると想像より大きなレバレッジがありました。1PRあたり620円→58円は、地味ですが年間で見るとサブスク1本ぶんです。
コードKGとGraphRAGを実装レベルで解説した本を書いています。tree-sitterのquery設計、Blast Radiusクエリのvariant、GraphRAGとの統合まで踏み込んでいます。この記事の理論を「実際にどう組むか」まで持っていきたい方はどうぞ。
この記事は役に立ちましたか?