← ブログに戻る

ナレッジグラフでコードレビュー範囲を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,00038,400−90.7%
平均レビュー時間3分12秒28秒−85.4%
有用な指摘/PR0.4件1.8件+350%
無用な指摘/PR2.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層で埋めるべき盲点です。

Blast Radius範囲の絞り込みと、レビュー対象ファイル数の変化

なぜベクトル検索ではないのか

「変更に類似するコードを検索して渡せばいいのでは」と聞かれることがあります。実際に一度試しました。結果、精度は上がりませんでした。ベクトル検索は「意味的に近いコード」を返しますが、コードレビューが必要なのは「呼び出し関係で近いコード」です。この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との統合まで踏み込んでいます。この記事の理論を「実際にどう組むか」まで持っていきたい方はどうぞ。

ナレッジグラフ実践ガイド