「コードをベクトル検索する」という甘い罠

自律型AIエージェントに巨大なリポジトリの改修をさせようとしたとき、誰もが一度は思いつく定番の構成があります。「コードファイルを一定の行数でチャンク分割し、Embeddingモデルでベクトル化してVector DBに保存。タスクに関連するコードをコサイン類似度で上位数件引き出し、エージェントのプロンプトに注入する(Code RAG)」というアーキテクチャです。

社内FAQやドキュメント検索(QAボット)でRAGを成功させたエンジニアほど、この構成に強い確信を持って飛びつきます。「AIにリポジトリ全体のコンテキストを効率よく与える決定打だ」と。

しかし、本番プロジェクトでこの「コードRAG」を運用した瞬間、開発者は冷酷な現実に直面します。

「自然言語では神ツールだったRAGが、なぜソースコードを対象にした途端、これほどポンコツなガラクタに変貌するのか?」

これは使用しているLLMやEmbeddingモデルの性能不足ではありません。「ソースコードという厳密な有向グラフ(Directed Graph)」に対して、「自然言語向けの意味的空間(Semantic Space)」を適用したことによる、数学的・情報構造的な必然の破綻なのです。

なぜコードに対するEmbedding RAGは破綻するのか?3つの致命傷

コードに対するベクトル検索が現場で壊滅する理由は、主に以下の3つの構造的致命傷に集約されます。

致命傷1:チャンク分割による「構文木(AST)の断頭台」

自然言語のテキストであれば、「200文字〜500文字」や「段落単位」で分割しても、前後の文脈が致命的に破壊されることは稀です。「人間は呼吸をする」という文は、どの段落にあっても主語と述語の関係を維持します。

しかし、ソースコードは平坦なテキストではありません。コンパイラやインタープリタが解釈する抽象構文木(AST: Abstract Syntax Tree)という厳密な階層構造です。行数や文字数で機械的に切断されたコード片は、構文的な手足を切り落とされた「不完全な肉片」に成り果てます。

// ❌ チャンク分割によってスコープが切断されたコード片の例
// チャンク境界によって import文 や クラス宣言 が消滅している
    async executePayment(ctx: PaymentContext): Promise {
      // どこから来たのか分からない logger や this.gateway
      const auth = await this.gateway.authorize(ctx.token);
      if (!auth.isValid) {
        this.logger.error("Auth failed", { code: auth.errorCode });
        throw new PaymentFailedException(auth.reason);
      }
      return this.repository.save(auth.txId);
    }
// ここでチャンクが終了。PaymentContext や TransactionResult の定義は別チャンクへ

このチャンク単体をエージェントに渡された場合を想像してください。エージェントは PaymentContext にどんなプロパティがあるのか、this.gateway はどんなインターフェースを実装しているのか、PaymentFailedException のコンストラクタ引数は何を受け取るのかを一切知ることができません。

結果として、エージェントは「たぶんこういう構造だろう」と勘に頼ってコードを生成し、型チェックで即死するか、実行時エラーを引き起こすコードを平然と出力します。チャンク分割は、コードから「文脈」を奪う最も暴力的な処理なのです。

致命傷2:コサイン類似度は「呼び出し関係(Call Graph)」を1ミリも理解しない

Embedding検索の根幹にあるのは「ベクトルの内積(コサイン類似度)」です。これは「単語の共起や意味的な概念が近いもの」を上位に抽出します。

しかし、エンジニアがコードを変更するときに知りたいのは「意味が似ているコード」でしょうか? 違います。知りたいのは「この関数を誰が呼んでいるのか(Callers)」、「この関数は何を呼んでいるのか(Callees)」、「このインターフェースを実装している実体はどれか(Implementations)」という、トポロジカルな依存グラフです。

【自然言語検索(RAG)の視点】
クエリ: "ユーザーの課金ステータスを更新する処理"
↓ ヒットする上位チャンク
1. /test/mocks/MockBillingService.ts (類似度: 0.89) ← テスト用のダミー
2. /legacy/v1/UserBillingHandler.ts (類似度: 0.86)   ← 使われていない旧API
3. /views/UserBillingView.tsx (類似度: 0.84)        ← フロントのUI表示ロジック

【真にエージェントが必要としている情報(Call Graph)】
PaymentWebhookHandler.ts 
  └─► BillingService.ts::updateSubscriptionStatus() [ここを修正したい]
        ├─► UserRepository.ts::findById()
        └─► StripeClient.ts::retrieveInvoice()

コサイン類似度で検索すると、同じ業務ドメインの語彙(billing, user, subscription)を大量に含むテストコードや非推奨コードが上位を占領します。本当に必要な「呼び出し元・呼び出し先の依存エッジ」は、語彙が異なる(例: StripeClient には UserBilling という単語が含まれない)ため、検索ランキングのはるか彼方に沈んでしまいます。

致命傷3:識別子・型情報の完全な希釈(Lexical Dilution)

自然言語では「スマホ」と「スマートフォン」、「購入する」と「買う」を同一視してくれるのがEmbeddingの最大の強みです。

しかし、プログラミングにおいて userId と orderId、あるいは findUserById と findUserByEmail は、1文字たりとも妥協できない全く別の存在です。高次元空間に埋め込む(Embedding)過程で、シンボルの完全一致性(Exact Match)は不可逆的に平滑化(スムージング)され、エージェントは同名の別スコープ関数や、微妙にシグネチャの異なるメソッドを取り違えます。

1トークンの違いでプロダクションが停止するコードの世界において、「なんとなく意味が近い」というベクトル空間の性質は、恩恵どころか純度100%のノイズなのです。

現場の解決策:決定論的「LSP / AST シンボルグラフ探索」

では、現代のAI駆動開発・エージェント開発において、リポジトリ全体を正確に理解させる最適解は何でしょうか?

答えは、20年以上かけてIDE(統合開発環境)とコンパイラが進化させてきた「LSP(Language Server Protocol)」と「抽象構文木(AST)に基づくシンボルグラフ探索」をエージェントに直接装備させることです。

エージェントに持たせるべき4つの決定論的ツール

エージェントに必要なのは曖昧なベクトル検索ではありません。普段プログラマがIDEで行っている操作を、決定論的(Deterministic)なTool Callingとして与えることです。

{
  "tools": [
    {
      "name": "lsp_find_definition",
      "description": "指定したシンボルの定義元ジャンプ(100%正確なファイルパスと行番号を返す)",
      "parameters": { "file": "src/service.ts", "symbol": "BillingClient" }
    },
    {
      "name": "lsp_find_references",
      "description": "指定した関数や型を参照している全箇所を特定する",
      "parameters": { "file": "src/models/user.ts", "symbol": "UserStatus" }
    },
    {
      "name": "lsp_get_call_hierarchy",
      "description": "関数の呼び出し元(Incoming)および呼び出し先(Outgoing)のグラフを取得する",
      "parameters": { "file": "src/api/pay.ts", "function": "handlePayment" }
    },
    {
      "name": "ast_get_outline",
      "description": "Tree-sitter等を用いて、ファイル全体の関数・クラス・シグネチャのみの骨格(中身省略)を取得する",
      "parameters": { "file": "src/repository/user.ts" }
    }
  ]
}

これらのツールはベクトルDBを介さず、コンパイラ(TypeScript Compiler API、gopls、rust-analyzer、Tree-sitterなど)が直接構文解析した結果を返します。類似度スコアなど存在せず、正解率は100%です。

段階的開示(Progressive Disclosure):コンテキスト窓を汚さない技術

LSPとASTを活用したエージェントの探索フローは、「段階的開示(Progressive Disclosure)」の原則に従います。

  1. Outline取得: 疑わしいファイル全体の ast_get_outline を実行し、メソッド名と引数・戻り値の型シグネチャだけを見る(実装本体は折りたたまれており、トークン消費は10分の1以下)。
  2. 定義ジャンプ: 必要な関数だけを選定し、lsp_find_definition でその実装ピンポイントに飛ぶ。
  3. 呼び出し元確認: 修正による影響範囲を調べるため、lsp_find_references で参照箇所を列挙する。

このアプローチであれば、エージェントは「切断された孤児チャンク」を見ることは一切ありません。常に完璧なスコープと型情報を保持したまま、最短の手数でピンポイントにコードを修正できます。

コード探索の3層ハイブリッドパイプライン

現場の実装として最も堅牢にワークする、エージェント向けコード探索パイプラインを整理します。

┌─────────────────────────────────────────────────────────────┐
│ 第1層: 語彙一致(Exact Match)- ripgrep / BM25               │
│  - シンボル名、エラーメッセージ、エンドポイントパスの厳密一致検索 │
└──────────────────────────────┬──────────────────────────────┘
                               ▼ 該当シンボル・起点の特定
┌─────────────────────────────────────────────────────────────┐
│ 第2層: 構造的グラフ探索(Graph Traversal)- LSP / SCIP       │
│  - 定義ジャンプ(Definition)                                 │
│  - 参照検索(References)                                     │
│  - 呼び出し階層(Call Hierarchy)                              │
└──────────────────────────────┬──────────────────────────────┘
                               ▼ 必要なスコープの確定
┌─────────────────────────────────────────────────────────────┐
│ 第3層: 構文木展開(Syntax Folding)- Tree-sitter             │
│  - 関数シグネチャの外枠提示と、対象ブロックのみの局所展開       │
└─────────────────────────────────────────────────────────────┘

このパイプラインを見ればわかる通り、コードの探索において「ベクトル検索(Embedding RAG)」が入る余地は原則としてありません。

唯一、Embeddingが許容される例外は、「メール送信が失敗した時の再試行処理はどこにある?」といった、シンボル名が皆目見当もつかない初動の1手目で、対象ファイルを1〜2個推測する際の補助(一次フィルタ)だけです。起点が1つ見つかった瞬間にベクトル検索は役目を終え、以降の探索は100%静的解析グラフに委ねるべきです。

開発者が直面する3つの疑問と現場のトレードオフ

Q1: 「CodeBERTなどのコード特化型Embeddingなら解決するのでは?」

結論から言うと、解決しません。モデルの精度向上の問題ではなく、「チャンク分割による構文木の切断」と「類似度計算がグラフのエッジ(呼び出し関係)を表現できない」というトポロジーの欠陥だからです。どれほど賢いEmbeddingモデルを使っても、50行で切断された関数の外側にある親クラスの型情報を魔法のように復元することはできません。

Q2: 「巨大リポジトリで毎回LSPを起動するとメモリが重すぎないか?」

開発環境ごとに巨大なLSPデーモンを常駐させる負荷が問題になる場合は、SCIP(Source Code Intelligence Protocol)や LSIF(Language Server Index Format)の活用が現場のデファクトです。CIパイプライン等で事前にコードの定義・参照関係を静的なインデックスファイル(SQLite等)としてダンプしておけば、エージェントは重いコンパイラを起動することなく、ミリ秒単位で正確なシンボルグラフを走査できます。

Q3: 「仕様書(Markdown)とコードが混在しているリポジトリはどうすべきか?」

これが現場で最もよくある罠です。「全部まとめて1つのVector DBに入れる」のは最悪のアンチパターンです。自然言語ドキュメント(docs/、README.md)は通常のチャンク分割とEmbedding RAGで検索させ、ソースコード(src/)はLSP/ASTツールで探索させるという、データ構造に応じた完全なパイプライン分離を徹底してください。

まとめ:コードは「意味の海」ではなく「厳密な有向グラフ」である

自然言語のテキスト検索における成功体験を、安易にプログラミングコードに持ち込んではいけません。自然言語が「意味のグラデーション」で成立しているのに対し、コードは「厳密な構文木と型システムの有向グラフ」によって成立しています。

エージェントに「なんとなく似た行」を引いてくるだけのベクトルDBを与えても、生まれるのはハルシネーションと構文崩壊の山です。

AIエージェントを本物のエンジニアとして働かせたいなら、自然言語のEmbedding検索を捨て、コンパイラとLSPが紡ぎ出す決定論的で堅牢なシンボルグラフを持たせてください。40年間のソフトウェア工学が磨き上げてきた静的解析の技術こそが、AIエージェントの知能を現場で覚醒させる真の武器なのです。