← ブログに戻る

GraphRAGで本番バグ5件、4件はcall graph 1ホップ実測

自作 harness の運用で、6月から8月にかけて本番バグを5件踏みました。恥ずかしい。

全部を GraphRAG で調査したのですが、5件中4件、原因ファイルまでの最短経路は call graph の1ホップ先 でした。ベクトル検索は、そのあとに読ませる補足資料としての役目に落ちました。

先に一つ断っておきます。以前書いた 静的コードKGが61%見逃した動的呼び出しの話 はエッジの欠落そのものが主題でした。本記事は 描けているエッジをどう距離ログで使うか の話です。同じKGでも扱っている軸が違います。混同しないでください。

5件のバグ、それぞれのホップ距離

まず生ログから。日付・症状・原因ファイル・GraphRAGが原因ファイルに到達するまでの最短ホップ数だけ書きます。事業ドメインは伏せますが、コードは全部私の harness-ops リポジトリで起きたものです。

#症状原因ファイル種別ベクトル検索の順位call graph 最短hop
1Telegram通知が二重発火notify.sh の retry ラッパー4位1 hop
2strategist の judge JSON が空配列prompt template の変数展開ミス2位1 hop
3Zenn記事の予約時刻が UTC ずれcalendar_register.py の tz 引数7位1 hop
4図解生成が2枚目で落ちるPuppeteer viewport の再利用6位1 hop
5Evolver が同じ EVO 番号を返すcounter.txt の flock 抜け3位3 hop

5件中4件はエラーログの出力点から call graph を1歩たどるだけで原因に着きました。ベクトル検索は、その1ホップ先に触るときに「関連しそうな他のファイル」を並べて読ませるのに使っています。

ホップ数が短い理由は身も蓋もない。エラーが出た関数を呼んでいる、または呼ばれている関数のほとんどが原因 だったからです。設計は美しくない。でも実測はそう出ました。5件目の flock 抜けだけは3ホップ必要でした。counter.txt を触っている場所がリポジトリ全体で7箇所あって、その中で「同時実行時にだけ壊れる」場所を絞り込むのに、call graph を追加でたどる必要がありました。原因まで3歩。それでもgrepで7箇所を全部読むよりは短いです。

なぜ call graph が先に効いたのか

ベクトル検索が悪いわけではないです。実際、順位を見れば「7位以内」には全部入っています。ただ、上位の順位に「意味的に近いけど原因ではないファイル」が混じります。

たとえば #3 の tz ずれ。ベクトル検索の1位は calendar_query.py でした。読むとタイムゾーン周りの処理が丁寧に書いてあります。丁寧に書いてあるからバグらないんです。真犯人は calendar_register.py の方でした。tz 引数を 書き忘れた 側にバグは埋まる。書いてある量で類似度を計算すると、書き忘れている場所は見えません。

call graph の1ホップ先は、その「書いてある量」を見ないで、単純に エラーの発生点から関数呼び出しで直接繋がっている先 を返します。バグは呼び出しの継ぎ目で起きることが多い。だから刺さります。

実測方法 (すごく地味)

やっていることは以下だけです。

  1. kuzu にコードKGを載せる (tree-sitter で AST 抽出 → CALLS エッジを張る)
  2. 本番バグのエラーログから、Traceback の最下段の関数名を抜く
  3. その関数を起点に MATCH p=(f:Function {name: $err})-[:CALLS*1..3]-(g:Function) RETURN g, length(p) を叩く
  4. 結果を hop 距離順に並べる
  5. 別レーンで、同じエラーログをベクトル検索 (nomic-embed-text で埋め込んで faiss) に投げる
  6. 修正が入った commit の diff ファイルが、どちらのリストの何位に出たかを記録する

たったこれだけです。kuzu の RECURSIVE_REL は、8万行くらいの harness-ops なら1ホップ探索が10msくらいで返ります (0.7 系で recursive path finding が入ってから体感が変わりました)。速いです。

neo4j-driver を使わなかったのは、Neo4j サーバーを常駐させたくなかったからです。kuzu なら埋め込みで動くので、harness の cron に混ぜてもプロセスが増えません。ベンチマークが欲しい人は、kuzu 側の 公式ブログ をあたってください。数字は用途によって全然違うので、自分のスキーマで測るのが早いです。

tree-sitter query の落とし穴

一つだけハマった話を書いておきます。Python の getattr(obj, method_name)() みたいな動的呼び出しは tree-sitter の call query では拾えません。これは前述の 61%見逃し記事 で書いた通り、静的解析の限界です。

今回の5件中、#4 の Puppeteer viewport は動的呼び出しの塊で、tree-sitter だけだと call graph が繋がりません。ここは実行時のトレース (sys.settrace の出力を JSONL でため込んだもの) を CALLS_DYNAMIC エッジとして追加投入してあります。エッジタイプが違うので、GraphRAG のクエリ側で [:CALLS|:CALLS_DYNAMIC*1..3] と書き分ければ、静的と動的を混ぜて距離計算できます。

これをやっておかないと、動的呼び出しの多いコード (Puppeteer, DI, event handler) だけ call graph が沈黙するので、そこだけベクトル検索の順位に頼るという歪んだ運用になります。

GraphRAG-Bench の話

一つ大事な補足。2026年に入って GraphRAG-Bench (ICLR’26) が出て、GraphRAG がベクトルRAGを全クエリタイプで支配するわけではない、という結果が公開されました。単純な事実検索 (single-hop fact retrieval) ではテキストチャンクとほぼ互角で、多ホップ推論や要約系ではグラフが伸びる、というのがベンチマークの傾向です。

本記事の観察はそれと矛盾しません。「本番バグの原因ファイル特定」というタスクが、GraphRAG (特に call graph traversal) と相性が良い、という話です。バグは呼び出しの継ぎ目で起きるので、呼び出しをエッジで持っているグラフが刺さる。単一ドキュメントで完結する事実確認やコマンド一覧のような直接引用がほしいときは、ベクトル検索の方が使いやすいと思います。適材適所。

使い分けメモ

じゃあどう使い分けているか。私の中では、GraphRAG (call graph側) と ベクトル検索を以下で分けています。

  • 本番バグの原因特定: call graph 1-3 hop を先に見て、ベクトルは補助
  • 仕様理解 / どこに書いてあるか探す: ベクトル検索を先に、call graph は補助
  • リファクタの影響範囲: call graph 1-2 hop (これはベクトル検索より圧倒的に速い)
  • 新規機能の設計参考: ベクトル検索 (概念の類似性が効く)

同じ GraphRAG でも「エッジのたどり方」と「ノードの意味的類似度」は根本的にやっていることが違います。前者は「呼び出しの継ぎ目」を捉え、後者は「書いてある内容の似ている度合い」を捉える。今回の5件は前者にほぼ完全に寄っていました。

これが今回の実測から得た手触りです。

まとめ

  • 5件の本番バグ、4件は call graph 1ホップ先が原因ファイル
  • ベクトル検索は7位以内には入るが、「書いてある量」で似ているものを引くので、書き忘れ系バグに弱い
  • 動的呼び出しは CALLS_DYNAMIC エッジで別型にして、クエリで混ぜて距離計算する
  • GraphRAG は万能ではない。本番バグの原因特定にだけ強い、と割り切って使うと壊れない
  • kuzu の埋め込みモード + tree-sitter で harness に混ぜやすい

コードナレッジグラフを実務で使う話は、実践ナレッジグラフガイド にまとめてあります。GraphRAG のスキーマ設計、call graph の張り方、Neo4j / kuzu の使い分けまで、動くコード中心で書いています。

ナレッジグラフ活用大全 関連書籍 ナレッジグラフ活用大全 ナレッジグラフ 活用大全 | GraphRAG・Neo4j・RDF・Property Graph・Emotion AI の実践書 書籍ページを見る →