1. はじめに

こんにちは。and factory バックエンドエンジニアの木梨です。

Claude Codeを大規模コードベースで使っていると、「この機能はどこで実装されているか」のような広い問いで GrepExplore が何度も走り、待ち時間が長くなりがちです。私が触っているGo / PHP / JSが混在する大規模モノレポでも、広い問いで数分待たされるのが日常的に発生していました。

改善策を探していた背景には2つの体験があります。1つは、社内にDevinが導入されたときにDeepWikiでコードベースに広い問いを投げた際の回答速度の速さです。一次情報は見つけられませんでしたが、応答の仕方からして内部でRAGを使っているのではと推測しています。もう1つは、Cursorのブログで報告されているセマンティック検索の導入効果です。どちらも「セマンティック検索を前段に置けば広い問いが軽くなる」という方向を示しています。同じ発想で最小構成のRAGをClaude Codeの前段に置いてみたのが本記事で紹介する構成です。

本記事では、tree-sitter・Qwen Embedding・ChromaDB で組んだ最小構成の RAG CLI を Claude Code から呼ぶまでを手順として共有します。約220行のPythonで動きます。既存のRAGライブラリを使う選択肢もありました。それでも今回自作の形にしたのは、Claude Code用にCLIとして統一したかった点と、中身が見える最小構成のほうが細かい調整をしやすい点の2つが理由です。

対象読者は Claude Code を日常的に使っており、RAG の基本概念(埋め込みベクトル、近傍検索)は既知の方を想定しています。

TL;DR

  • 構成: tree-sitter + Qwen3 Embedding + ChromaDB + Python CLI。約220行
  • 使い方: Claude Codeから検索CLI(例: myrag search)を呼び、候補を Read / Grep で裏取り
  • 効果: 広い問いで待ち時間が体感で明確に短縮された。参考計測では中央値でRAG前段75秒 / Explore very thorough 132秒 / Explore medium 172秒。実行時間の安定性でもRAGが優位。詳細は6章「実際の効果」参照
  • 注意: チャンク化したコードを外部APIへ送る構成。業務利用前に法務・セキュリティ確認が必要。詳細は事前に確認したいことを参照

事前に確認したいこと

チャンク化したコードはAlibaba Cloud(DashScope)に送信されます。業務リポジトリで適用する場合は、先に社内の法務・セキュリティ窓口でコード外部送信ポリシーを確認してください。以下は最低限のチェックポイントです。

  • 検証時は匿名化済みコードのみを使う
  • APIキー、秘密鍵、認証トークン、個人情報、契約情報を含むファイルは投入しない
  • .env や秘密鍵は EXCLUDE_PATTERNS(後述)に含まれているので、社内固有の機密ファイルがあれば同じ要領でパターンを足す

機密性が高くそもそも外部送信を避けたい場合は、embedder.pysentence-transformers 等のローカル埋め込みに差し替えれば同じ構成で動きます。

導入判断の目安

手を動かす前に、自分の環境が向いているかをざっくり判定するための表です。

条件向いているか
大規模モノレポ(数千ファイル以上)で「どこに実装?」系の広い問いが多い向いている
関数名・型名・定数名が分かっている検索が中心Grep で十分なことが多い
外部APIへのコード送信が禁止本記事のまま動かすのは不可。embedder.py をローカル埋め込みに差し替えて検証を推奨
小規模リポジトリ(目安: 1,000ファイル以下)ExploreGrep の方が軽量で速いため、導入コストに見合わないことが多い
Go以外の言語がメインで、関数単位チャンクの精度を上げたいtree-sitter grammarの追加実装が前提になる(4章「実装」参照)

「広い問いで数分待つ頻度が週数回以上」あたりが、約220行のPythonを書く費用対効果の目安感です。

2. 全体像

構成は単純です。

① インデックス構築(一度だけ実行)

flowchart LR
    A["source files"] -->|tree-sitter でパース| B["関数・メソッド単位のチャンク"]
    B -->|Qwen Embedding でベクトル化| C[("ChromaDB")]

② 検索(Claude Code から呼ぶ)

flowchart LR
    Q["query"] -->|Qwen Embedding でベクトル化| R["クエリベクトル"]
    R -->|ChromaDB で近傍検索| S["候補チャンク JSON"]
    S -->|Read / Grep で裏取り| T["Claude Code"]

使うもの:

用途使うものひとことで
コードのパースtree-sitter + 言語別パッケージ(tree-sitter-go など)多言語対応の構文解析ライブラリ。ASTを取り出せる
埋め込みモデルQwen3 Embedding(text-embedding-v4 / DashScope 経由)Alibaba Cloudの多言語対応埋め込みモデル。DashScopeはそのAPI窓口
ベクトル DBChromaDB(ローカルファイル永続化)オープンソースの軽量ベクトルDB。ローカルファイルに永続化できるので最小構成向き

CLI部分はPython標準ライブラリの argparse だけで作ります(外部依存を増やさない)。

設計方針: 「候補返し」に徹する

このRAGは LLM による回答合成を自前では行いません。候補チャンクのJSONを返すところで止めて、合成や補足検索はClaude Code側に任せます。Claude Codeは Read / Grep / Explore を自律的に呼び出すので、RAG側でも合成を挟むと往復が二重になって逆に遅くなるからです。

記事全体の実装判断(chunker.pysymbol / start_line を付ける、search.py が候補リストのみ返す等)は、この「候補返しに徹する」方針から来ています。

3. セットアップ

ここでは、手元で動かすための前提を揃えます。以降の実装章を読み進める前に、ここで依存をインストールしておきます。

必要なもの

  • uv: Pythonとパッケージ管理を兼ねる高速ツール。brew install uv で導入
  • Python 3.11〜3.13: 言語別tree-sitterパーサの3.14用ホイール未整備のため。uvが自動取得する
  • DashScope API キー: Alibaba Cloudで発行(2026年4月時点で無料枠あり。最新は公式の課金情報ページを参照)。本記事は国際版エンドポイント(dashscope-intl.aliyuncs.com)前提

手順

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
mkdir myrag && cd myrag

# pyproject.toml を作る(依存関係を宣言)
cat > pyproject.toml <<'EOF'
[project]
name = "myrag"
version = "0.1.0"
requires-python = ">=3.11,<3.14"
dependencies = [
    "tree-sitter>=0.25",
    "tree-sitter-go>=0.25",
    "chromadb>=1.5",
    "dashscope>=1.25",
    "pathspec>=1.1",
]
EOF

# 仮想環境の作成と依存インストールを一括で
uv sync

# API キーは環境変数で渡す
export DASHSCOPE_API_KEY="sk-..."

本記事で動作確認したバージョン

パッケージバージョン
uv0.9.x
Python3.13.x
tree-sitter0.25.x
tree-sitter-go0.25.x
chromadb1.5.x
dashscope1.25.x
pathspec1.1.x

4. 実装

ここからの実装は chunker.py / embedder.py / index.py / search.py / cli.py の5ファイルに収まります。チャンク分割 → 埋め込み → ChromaDBへの格納と検索 → CLIの順で作っていきます。

4.1 チャンク分割

コードをRAGに載せる最初のステップは、チャンク分割です。文書RAGなら段落単位で切りますが、コードRAGでは関数・メソッド単位で切るのが相性の良い方法です。関数は論理的にまとまった単位なので、埋め込みの意味がぶれにくくなります。

ここでは tree-sittertree-sitter-go を使い、GoファイルをASTから関数・メソッド単位に分割します。tree-sitter は多言語対応の構文解析ライブラリで、言語別grammarパッケージ(tree-sitter-go など)を差し替えれば他の言語にも広げられます。

関数・メソッド単位でチャンクを切る最小コード

イメージとしては次のような流れです。Goのソースをtree-sitterでパースしてASTを得て、関数・メソッドのノードを拾ってメタデータ付きのチャンクに変換します。

flowchart LR
    A["auth.go(Goコード)"] -->|tree-sitter でパース| B["AST"]
    B -->|関数・メソッドノードを走査して切り出し| C["関数・メソッド単位チャンク<br/>{ path, symbol, start_line,<br/>end_line, language, code }"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# chunker.py
from tree_sitter import Language, Parser
import tree_sitter_go as tsgo

# Go 用のパーサ(他言語を足すときは tree_sitter_python などを import してパーサを増やす)
GO_LANGUAGE = Language(tsgo.language())
GO_PARSER = Parser(GO_LANGUAGE)

def chunk_go_file(path: str) -> list[dict]:
    """Go ファイルを関数・メソッド単位のチャンクに切る"""
    with open(path, "rb") as f:
        source = f.read()
    tree = GO_PARSER.parse(source)

    chunks = []

    def visit(node):
        # AST を深さ優先で走査し、関数/メソッドノードに当たったらチャンク化する
        # Go では関数(func Foo)とメソッド(func (r *R) Foo)で AST ノード名が違う
        if node.type in ("function_declaration", "method_declaration"):
            # 関数名を取得(匿名関数が混ざっても落ちないよう "anonymous" にフォールバック)
            name_node = node.child_by_field_name("name")
            name = name_node.text.decode() if name_node else "anonymous"
            chunks.append({
                "path": path,
                "symbol": name,
                # tree-sitter の行番号は 0 始まりなので +1 して人間が読みやすい形に
                "start_line": node.start_point[0] + 1,
                "end_line": node.end_point[0] + 1,
                "language": "go",
                # ノードのバイト範囲からソース該当部分を切り出してチャンク本文に格納
                "code": source[node.start_byte:node.end_byte].decode("utf-8", errors="replace"),
            })
            # 関数リテラル(クロージャ)は外側と内容が重複するため再帰しない
            return
        # 関数/メソッドでなければ子ノードを辿って関数宣言を探し続ける
        for child in node.children:
            visit(child)

    visit(tree.root_node)
    return chunks

Goでは関数とメソッドでASTノード名が異なるため、その両方を拾ってチャンク化します。メタデータとして path / symbol / start_line / end_line を付けておくと、後でClaude Codeが Read ツールで該当箇所を読むときに役立ちます。

1点注意として、関数単位で切る設計では数百行の巨大関数もそのまま1チャンクに収まりますtext-embedding-v4 のトークン上限(目安: 8,192)を超えるとAPIエラーが返ります。数百行超の関数を含むリポジトリでは「一定行数超で行ベースに切り替える」fallbackを足すのが安全です。

他の言語に対応する場合は、pip install tree-sitter-php のようにgrammarパッケージを追加し、対応するParserを作ってASTノード名を増やしていきます。例えばPHPなら function_definition / method_declaration などです。

他言語向けの行ベース fallback

実リポジトリには他の言語も混在するので、本記事では次の仕様でチャンク分割します。

  • .go はtree-sitterで関数・メソッド単位に切る
  • それ以外はN行ずつの固定長チャンクに切る(Markdownや設定ファイルも含め拾う)
  • バイナリや依存ディレクトリ、秘匿ファイルなどはインデックスから除外する

除外は .gitignore 記法のワイルドカード(vendor/, *.min.js, .env.* など)で定義し、pathspec でマッチングします。ディレクトリ単位・拡張子単位・ファイル名パターンを同じ書式で扱えるので、機密ファイルや巨大な依存ディレクトリを漏れなく弾けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# chunker.py(続き)
from pathlib import Path
import pathspec

# 行ベースチャンクのサイズ(関数・メソッド単位より粗いが、全体を拾える)
LINES_PER_CHUNK = 40

# インデックスから除外するパターン(.gitignore 記法)
EXCLUDE_PATTERNS = [
    # 依存・生成物
    "vendor/", "node_modules/", "bower_components/",
    ".git/", "__pycache__/", ".venv/", "dist/", "build/",
    "*.min.js", "*.min.css", "*.map", "*.generated.*", "*.pyc",
    # バイナリ・メディア
    "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.ico", "*.svg",
    "*.pdf", "*.zip", "*.tar", "*.gz", "*.bz2",
    "*.mp3", "*.mp4", "*.mov", "*.avi",
    "*.so", "*.dylib", "*.dll", "*.exe",
    # シークレット系
    ".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx",
    "credentials.json", "serviceAccountKey.json", "*.secret", ".secrets/",
]


def chunk_by_lines(path: str) -> list[dict]:
    """ファイルを LINES_PER_CHUNK 行ずつの固定長チャンクに切る"""
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        lines = f.readlines()
    p = Path(path)
    # 拡張子がない場合(Makefile / Dockerfile 等)はファイル名を小文字で使う
    language = p.suffix.lstrip(".") or p.name.lower()
    chunks = []
    for i in range(0, len(lines), LINES_PER_CHUNK):
        block = lines[i:i + LINES_PER_CHUNK]
        chunks.append({
            "path": path,
            "symbol": "",  # 行ベースでは関数名は分からない
            "start_line": i + 1,
            "end_line": i + len(block),
            "language": language,
            "code": "".join(block),
        })
    return chunks


def chunk_repo(root: str) -> list[dict]:
    """リポジトリ配下を再帰的に走査してチャンクのリストを返す"""
    root_path = Path(root)
    # EXCLUDE_PATTERNS に加えて、リポ直下の .gitignore も読んで除外に反映
    patterns = list(EXCLUDE_PATTERNS)
    gitignore = root_path / ".gitignore"
    if gitignore.exists():
        patterns.extend(gitignore.read_text(errors="replace").splitlines())
    spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)

    chunks = []
    for path in root_path.rglob("*"):
        if not path.is_file():
            continue
        rel = str(path.relative_to(root_path))
        if spec.match_file(rel):
            continue
        if path.suffix == ".go":
            # Go は関数・メソッド単位で切る(精度が高い)
            chunks.extend(chunk_go_file(str(path)))
        else:
            # それ以外は行ベースに fallback(全体を拾う)
            chunks.extend(chunk_by_lines(str(path)))
    return chunks

これで chunk_repo("./my-repo") を呼ぶと、Goは関数・メソッド単位、それ以外のテキストファイルは40行単位のチャンクとして取得できます。リポジトリ直下に .gitignore があれば自動的に除外対象に加わります。

なお本実装はリポ直下の .gitignore のみを参照する簡易仕様です。vendor/node_modules/ 配下に独自の .gitignore を置く運用では、除外漏れが起きる恐れもあります。回避するには EXCLUDE_PATTERNS に明示パターンを足すか、git check-ignore をサブプロセス経由で使う形に切り替えてください。

精度を上げたい言語は、後から chunk_go_file と同様の関数を増やして上の分岐に足していけばOKです。

4.2 埋め込み

本記事では、Alibaba CloudのDashScopeから提供される埋め込みモデル text-embedding-v4 を使用します。これはDashScopeが提供するQwen3 Embeddingファミリの埋め込みAPIで、以降は便宜上「Qwen Embedding」と表記します。弊社はAlibaba Cloud と業務提携しており、社内インフラとしても採用済みのため第一候補として選んでいます。DashScopeが使えない環境では、embedder.py をOpenAI SDK用に差し替えれば text-embedding-3-small などでも動きます(他のファイルは変更不要)。

DashScopeのPython SDK経由なら、薄いラッパーで書けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# embedder.py
import os
import dashscope
from dashscope import TextEmbedding

# 国際版(DashScope Intl.)を使う場合は明示。中国本土版ならこの行は不要
dashscope.base_http_api_url = "https://dashscope-intl.aliyuncs.com/api/v1"

# text-embedding-v4 は 1 リクエストあたり最大 10 件の制限がある
_BATCH_SIZE = 10


def embed(texts: list[str]) -> list[list[float]]:
    """テキストのリストを埋め込みベクトルのリストに変換する"""
    results = []
    # API 側の上限(1 リクエスト 10 件)に合わせて分割して送る
    for i in range(0, len(texts), _BATCH_SIZE):
        batch = texts[i:i + _BATCH_SIZE]
        response = TextEmbedding.call(
            model="text-embedding-v4",
            input=batch,
            api_key=os.environ["DASHSCOPE_API_KEY"],
        )
        # output が None の場合はレート制限や認証エラー等。メッセージを付けて止める
        if response.output is None:
            raise RuntimeError(f"DashScope error: {response.code} {response.message}")
        # レスポンスから embedding ベクトルだけ取り出して結果に積む
        results.extend(item["embedding"] for item in response.output["embeddings"])
    return results

4.3 ChromaDB への格納と近傍検索

ChromaDBはオープンソースのベクトルDBで、ローカルファイルに永続化できます。埋め込みベクトルを貯めておき、クエリとの近傍検索(ベクトル空間で距離が近いもの順に取り出す操作)を行うのがここの仕事です。最小構成ではこれで十分です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# index.py
from pathlib import Path
import chromadb
from chunker import chunk_repo
from embedder import embed

DEFAULT_DB_PATH = str(Path(__file__).parent / "chroma_db")

def build_index(repo_path: str, db_path: str = DEFAULT_DB_PATH):
    """リポジトリをチャンク化し、埋め込みベクトルと一緒に ChromaDB に格納する"""
    # ローカルファイルに永続化するクライアント
    client = chromadb.PersistentClient(path=db_path)
    # リネーム・削除で旧 ID が残らないように毎回コレクションを作り直す
    if any(c.name == "code_chunks" for c in client.list_collections()):
        client.delete_collection(name="code_chunks")
    # 類似度比較しやすいよう cosine 距離を指定(距離は 0〜2 の範囲で返ってくる)
    collection = client.create_collection(
        name="code_chunks",
        metadata={"hnsw:space": "cosine"},
    )

    # ① リポを走査してチャンク化 → ② コード本文だけ取り出し → ③ 埋め込みに変換
    chunks = chunk_repo(repo_path)
    codes = [c["code"] for c in chunks]
    vectors = embed(codes)

    # ベクトル・メタデータ・元コードをまとめて 1 件ずつコレクションに格納
    # ID は path + 開始行で一意にする(同じファイルの別チャンクを区別)
    collection.add(
        ids=[f"{c['path']}:{c['start_line']}" for c in chunks],
        embeddings=vectors,
        metadatas=[{
            "path": c["path"],
            "symbol": c["symbol"],
            "start_line": c["start_line"],
            "end_line": c["end_line"],
            "language": c["language"],
        } for c in chunks],
        documents=codes,
    )
    print(f"Indexed {len(chunks)} chunks")

build は毎回コレクションを作り直す(全置き換え)仕様にしています。upsert だとファイル名変更や関数削除時に旧IDのエントリが残り続け、ノイズとして検索結果に混ざる事故が起きるためです。差分更新にしたい場合は7章まとめ末の「次のアクション」を参照してください。

近傍検索

検索側も同じくらいシンプルです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# search.py
from pathlib import Path
import chromadb
from embedder import embed

DEFAULT_DB_PATH = str(Path(__file__).parent / "chroma_db")

def search(query: str, top_k: int = 5, db_path: str = DEFAULT_DB_PATH) -> list[dict]:
    """クエリ文字列に近いチャンクを top_k 件返す"""
    # インデックス構築時に作ったコレクションを読み込む
    client = chromadb.PersistentClient(path=db_path)
    collection = client.get_collection(name="code_chunks")

    # クエリも埋め込みに変換してから近傍検索にかける
    query_vector = embed([query])[0]
    result = collection.query(
        query_embeddings=[query_vector],
        n_results=top_k,
    )

    # ChromaDB の返却は複数クエリ対応の 2 次元配列。今回は単一クエリなので [0] で展開
    # メタデータと距離を取り出し、Claude Code が扱いやすい辞書形式に整形する
    candidates = []
    for i in range(len(result["ids"][0])):
        meta = result["metadatas"][0][i]
        candidates.append({
            "path": meta["path"],
            "symbol": meta["symbol"],
            "start_line": meta["start_line"],
            "end_line": meta["end_line"],
            # ChromaDB の cosine 距離は `1 - cos(θ)`(範囲は 0〜2)。
            # 「0 が完全一致、2 が完全反対」を「1 が完全一致、0 が完全反対」に
            # 線形変換したいので、2 で割って 1 から引き 0〜1 のスコアにする。
            # 厳密なコサイン類似度ではなく、扱いやすい順位付けスコアとしての定義。
            "score": 1.0 - result["distances"][0][i] / 2.0,
        })
    return candidates

これで、クエリ文字列から類似チャンクのメタデータが返ってきます。score は0〜1の範囲で、1 に近いほど意味が近いという扱いです。

ChromaDBのcosine距離は 1 - cos(θ) で0〜2の範囲を取るため、2で割ってから1から引き、0〜1のスコアに線形変換しています。厳密なコサイン類似度そのものではなく、順位付けに使うスコアとしての定義です。

4.4 CLI

コア機能をCLIにまとめます。インデックス構築と検索の2サブコマンドを argparse で実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# cli.py
import argparse
import json
import sys
from index import build_index
from search import search

def main():
    parser = argparse.ArgumentParser(prog="myrag")
    sub = parser.add_subparsers(dest="command", required=True)

    # インデックス構築サブコマンド: build <repo_path>
    p_build = sub.add_parser("build", help="インデックスを構築")
    p_build.add_argument("repo_path")

    # 検索サブコマンド: search <query> [--top-k N]
    p_search = sub.add_parser("search", help="類似チャンクを検索")
    p_search.add_argument("query")
    p_search.add_argument("--top-k", type=int, default=5)

    args = parser.parse_args()

    if args.command == "build":
        build_index(args.repo_path)
    elif args.command == "search":
        results = search(args.query, top_k=args.top_k)
        # Claude Code が Bash ツール経由で受け取れるよう JSON を標準出力に書く
        json.dump({"candidates": results}, sys.stdout, ensure_ascii=False, indent=2)

if __name__ == "__main__":
    main()

使い方は次のとおりです。

1
2
3
4
5
# インデックス構築(最初の 1 回)
uv run cli.py build ./my-repo

# 検索
uv run cli.py search "ユーザー認証の実装はどこ" --top-k 5

シェルから頻繁に叩くなら、以下のようなエイリアスを貼っておくと myrag search ... の形で呼べます。本文中では以降、このエイリアスを貼った前提で myrag と表記します(Claude Codeから呼ぶときは5章「Claude Codeから使う」のフルコマンド例を貼り付けてください)。

1
alias myrag='uv run --project /absolute/path/to/myrag /absolute/path/to/myrag/cli.py'

出力はJSONで返ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "candidates": [
    {
      "path": "internal/auth/handler.go",
      "symbol": "LoginHandler",
      "start_line": 42,
      "end_line": 78,
      "score": 0.87
    }
  ]
}

ここまでで、myrag buildmyrag search の2コマンドが揃い、シェルから叩けば候補のJSONが返ってくる最小CLIが完成しました。次章でClaude Codeから呼び出す指示をCLAUDE.mdに書き込みます。

5. Claude Code から使う

Claude Codeからは Bash ツール経由でCLIを呼び、返ってきたJSONを手掛かりに Read / Grep で裏取りしてもらいます。実際には、CLAUDE.mdに方針を書いておくと運用しやすくなります。要点は次の4つです。

  • 広い問いではまず myrag search を呼ぶ。識別子が分かっている問いは Grep に直行する
  • 日本語クエリと英語クエリを 2〜3 パターン並列で投げる(Qwen Embeddingは多言語対応なのでヒットするファイルが変わる)
  • 候補上位から 3〜5 ファイルを Read で裏取りしてから回答する(問いの広さに応じて調整)
  • 候補が的外れなら Grep / Explore にフォールバック(RAGに固執しない)

これで広い問いではRAGを前段に使い、識別子問いでは直接 Grep に行くという使い分けを自然に行ってくれます。実際にCLAUDE.mdに入れた全文は折りたたみで掲載します。

CLAUDE.md に書いた指示(クリックで展開)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
## コード検索のヒント

**概念的なコード調査**(「この機能はどこ?」「〜はどんな条件で動く?」など)のときは、まず以下で候補を絞ってから `Read` で裏取りしてください。

```bash
uv run --project /path/to/myrag /path/to/myrag/cli.py search "<要約したキーワード>" --top-k 5
```

### ワークフロー

1. **多角的クエリで検索**: 日本語コメントにヒットするクエリと、英語の変数名・関数名にヒットするクエリの **2〜3 パターンを並列** で投げる。出力は JSON で `path` + `start_line` を含む
2. **結果の充足判定**: 結果が 0 件、または全件スコアが低ければクエリを言い換えて 1 回だけ再実行
3. **ソースコードで裏取り(必須)**: 候補上位から **3〜5 ファイル**`Read` で確認する(問いの広さに応じて調整)。**条件分岐・ガード節・状態変更・外部呼び出し** を含むコードを優先
4. **回答は 3 観点で整理**: 「**誰が対象**(処理対象の条件)」「**何がトリガー**(発火タイミング)」「**どの条件で**(分岐・ガード節)」を冒頭で明示する
5. **コード引用**: ファイルパスと行番号を必ず含める(例: `auth.go:L185`
### このコマンドを使わない場面

- 識別子が既知(関数名・型名が分かっている)→ 直接 `Grep` が速い
- 未コミット変更の確認 → インデックス外なので `Grep` / `Read` で直接
- 候補のどれも的外れに見える → RAG に固執せず `Grep``Explore` にフォールバック

6. 実際の効果

結論から言うと、広い問いではAgentic Search単独よりもRAGを前段に挟んだほうが速く、実行時間も安定しやすいという結果でした。業務で触っているGo/PHP/JSが混在する大規模モノレポ(生成物や依存ディレクトリを除いた実装ファイルが1万件規模)で計測した例です。弊社はAlibaba Cloud と業務提携しており社内インフラとしても採用済みのため、計測時の業務リポジトリへのDashScope適用は社内ポリシー上クリアしています。読者の皆さんが業務リポで試す場合は、本記事冒頭の事前に確認したいことを参照のうえ、社内の法務・セキュリティ窓口へ確認をお願いします。

先に断っておくと、Claude CodeのサブエージェントはLLM判断で試行ごとに挙動が変わるため、以下の秒数は参考値で、絶対値として扱える水準ではありません。数字そのものより、アプローチ間の相対差外れ値の出やすさに着目して読んでください。

計測条件

  • 対象: 上記モノレポのdevelopブランチ
  • 問い: カテゴリの異なる「広い問い」を3問用意
    • 横断列挙系(ある概念のエントリポイントを網羅的に列挙)
    • 業務フロー縦断系(管理画面起点で、バックエンド処理〜配信反映まで追う)
    • 状態遷移系(加入〜退会のように、状態機械の遷移を辿る)
  • アプローチ: 3種を比較
    • RAG 前段: セマンティック検索でコード候補を返し、Claude Code側で上位候補を Read して裏取りして回答(本記事の最小構成と同じ設計思想)
    • Explore medium: Claude Codeの Explore subagentを thoroughness=medium で起動
    • Explore very thorough: Claude Codeの Explore subagentを thoroughness=very thorough で起動
  • 試行: 3問 × 3アプローチ × 3試行 = 27 runs を正順で、さらに順序キャッシュバイアス検証用に 逆順 9 runs を追加、合計 36 runs を手動計測
  • 出力要件: 3アプローチで網羅度を揃えるため、同じ字数制約とファイル:行番号の根拠付与を指示

結果(中央値)

9セル(3問 × 3アプローチ)の各中央値を、さらにアプローチ単位で集計した値です。

アプローチセル中央値の中央値セル中央値の範囲
RAG 前段 + Read 裏取り75 秒69〜84
Explore very thorough132 秒108〜187
Explore medium172 秒106〜182

速度だけでなく分散も見ておきます。各アプローチの9試行(3問 × 3試行)の分布を並べると、RAGは幅が狭く、Exploreは広く外れ値も多いのがはっきりします。

集計の取り方が表ごとに違う点だけ補足しておきます。上表は「セル中央値の中央値」で、下表は「9試行そのものの中央値」です。同じデータでも前者のほうが外れ値の影響を二段階で薄めるため、RAGの中央値は前表75秒・後表78秒と少しずれて見えます。

アプローチ最小中央値最大最大/最小比
RAG 前段6778891.3倍
Explore very thorough1001573193.2倍
Explore medium931727077.6倍

RAGは9試行すべてが67〜89秒に収まり、Exploreより分散が小さい結果でした。Exploreは外れ値が大きく、広い問いでは試行ごとの探索方針の揺れが実行時間に出やすいように見えます。

副産物: Explore の mediumvery thorough より遅い(2026 年 4 月時点)

直感に反しますが、今回の3問ではいずれもmediumのほうがvery thoroughより時間がかかりました。順序を逆転した計測でも同じ傾向で、サンプル範囲では順序バイアスだけでは説明しきれない差分です。なおClaude Codeのサブエージェントの挙動はバージョン更新で変わることが多いため、以下の分析は2026年4月時点での観察として読んでください。

サブエージェントの内部ログを追ったところ、以下の違いが観察されました。

指標(正順 9 試行の中央値)mediumvery thorough
LLM ターン数4137
内部ツール呼び出し総数2923
grep129
find57
Read99

読むファイル数は同じですが、medium は grep の反復が多く、very thorough は find で構造を把握してから絞った grep で一撃という挙動になっていました。

個別ターンを追うと、mediumはenum値を見つけたとき「独立した実装ファイルがあるはず」と深読みしがちでした。存在しないファイルをfindgreplsと角度を変えて探し続ける、確信度が上がらないまま同じ情報を取りに行くパターンが頻出します。

一方でvery thoroughは「switch 文を1発grep」「関連ファイルを並列Read」といった面で情報を取る戦略を自然に選ぶ傾向がありました。

一般化するにはサンプル不足です。ただし今回の範囲では広い問いでExploreを使うならvery thoroughを一撃で投げたほうが、mediumで様子見するよりも中央値で速く安定しやすいという観察でした。

計測上の注意

  • 試行数は各セル3回と少なく、外れ値1つで中央値が変わる水準。数字は2倍程度の誤差を見込んで読むのが安全
  • OS の FS キャッシュClaude 側のプロンプトキャッシュの状態でExplore側は特に揺れる(新規セッションで投げ直すと2〜3割遅くなることがある)
  • 「3アプローチで同じ網羅度か」は、提出された回答の挙げたパスの数と主要ファイルの一致度を目視確認して揃えました。ただしLLMの出力なので完全一致は担保できません

使い分けの原則

体感を踏まえると、私は「広い問いはまずRAGを挟み、的外れっぽい候補しか返らなかったらExploreに切り替える」という運用に自然と寄っていきました。

ただしすべての問いで速くなるわけではありません。識別子が分かっている問いは Grep が速いですし、候補が的外れに見えるときは固執せず Explore に戻したほうが結果的に速いです。正味の効果は「毎回速い」ではなく「広い問いでは速く安定、狭い問いでは従来どおり」に近く、使い分ける前提で組むのが実用的です。

7. まとめ

ここまでで、最小構成のコードRAG CLIをClaude Codeから使える状態になりました。構成要素は4つだけです。

  • tree-sitter で関数・メソッド単位のチャンク分割
  • Qwen Embeddingでベクトル化
  • ChromaDBで近傍検索
  • Bash ツール経由でClaude Codeから呼ぶ

実装は chunker.py / embedder.py / index.py / search.py / cli.py の5ファイル・合計約220行に収まります。全コードは下の折り畳みに置いておきます。

コピペ用の全コードを展開する
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
# chunker.py
from pathlib import Path
import pathspec
from tree_sitter import Language, Parser
import tree_sitter_go as tsgo

# Go 用のパーサ(他言語を足すときは tree_sitter_python などを import してパーサを増やす)
GO_LANGUAGE = Language(tsgo.language())
GO_PARSER = Parser(GO_LANGUAGE)


def chunk_go_file(path: str) -> list[dict]:
    """Go ファイルを関数・メソッド単位のチャンクに切る"""
    with open(path, "rb") as f:
        source = f.read()
    tree = GO_PARSER.parse(source)

    chunks = []

    def visit(node):
        # AST を深さ優先で走査し、関数/メソッドノードに当たったらチャンク化する
        # Go では関数(func Foo)とメソッド(func (r *R) Foo)で AST ノード名が違う
        if node.type in ("function_declaration", "method_declaration"):
            # 関数名を取得(匿名関数が混ざっても落ちないよう "anonymous" にフォールバック)
            name_node = node.child_by_field_name("name")
            name = name_node.text.decode() if name_node else "anonymous"
            chunks.append({
                "path": path,
                "symbol": name,
                # tree-sitter の行番号は 0 始まりなので +1 して人間が読みやすい形に
                "start_line": node.start_point[0] + 1,
                "end_line": node.end_point[0] + 1,
                "language": "go",
                # ノードのバイト範囲からソース該当部分を切り出してチャンク本文に格納
                "code": source[node.start_byte:node.end_byte].decode("utf-8", errors="replace"),
            })
            # 関数リテラル(クロージャ)は外側と内容が重複するため再帰しない
            return
        # 関数/メソッドでなければ子ノードを辿って関数宣言を探し続ける
        for child in node.children:
            visit(child)

    visit(tree.root_node)
    return chunks


# 行ベースチャンクのサイズ(関数・メソッド単位より粗いが、全体を拾える)
LINES_PER_CHUNK = 40

# インデックスから除外するパターン(.gitignore 記法)
EXCLUDE_PATTERNS = [
    # 依存・生成物
    "vendor/", "node_modules/", "bower_components/",
    ".git/", "__pycache__/", ".venv/", "dist/", "build/",
    "*.min.js", "*.min.css", "*.map", "*.generated.*", "*.pyc",
    # バイナリ・メディア
    "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.ico", "*.svg",
    "*.pdf", "*.zip", "*.tar", "*.gz", "*.bz2",
    "*.mp3", "*.mp4", "*.mov", "*.avi",
    "*.so", "*.dylib", "*.dll", "*.exe",
    # シークレット系
    ".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx",
    "credentials.json", "serviceAccountKey.json", "*.secret", ".secrets/",
]


def chunk_by_lines(path: str) -> list[dict]:
    """ファイルを LINES_PER_CHUNK 行ずつの固定長チャンクに切る"""
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        lines = f.readlines()
    p = Path(path)
    # 拡張子がない場合(Makefile / Dockerfile 等)はファイル名を小文字で使う
    language = p.suffix.lstrip(".") or p.name.lower()
    chunks = []
    for i in range(0, len(lines), LINES_PER_CHUNK):
        block = lines[i:i + LINES_PER_CHUNK]
        chunks.append({
            "path": path,
            "symbol": "",  # 行ベースでは関数名は分からない
            "start_line": i + 1,
            "end_line": i + len(block),
            "language": language,
            "code": "".join(block),
        })
    return chunks


def chunk_repo(root: str) -> list[dict]:
    """リポジトリ配下を再帰的に走査してチャンクのリストを返す"""
    root_path = Path(root)
    # EXCLUDE_PATTERNS に加えて、リポ直下の .gitignore も読んで除外に反映
    patterns = list(EXCLUDE_PATTERNS)
    gitignore = root_path / ".gitignore"
    if gitignore.exists():
        patterns.extend(gitignore.read_text(errors="replace").splitlines())
    spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)

    chunks = []
    for path in root_path.rglob("*"):
        if not path.is_file():
            continue
        rel = str(path.relative_to(root_path))
        if spec.match_file(rel):
            continue
        if path.suffix == ".go":
            # Go は関数・メソッド単位で切る(精度が高い)
            chunks.extend(chunk_go_file(str(path)))
        else:
            # それ以外は行ベースに fallback(全体を拾う)
            chunks.extend(chunk_by_lines(str(path)))
    return chunks
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# embedder.py
import os
import dashscope
from dashscope import TextEmbedding

# 国際版(DashScope Intl.)を使う場合は明示。中国本土版ならこの行は不要
dashscope.base_http_api_url = "https://dashscope-intl.aliyuncs.com/api/v1"

# text-embedding-v4 は 1 リクエストあたり最大 10 件の制限がある
_BATCH_SIZE = 10


def embed(texts: list[str]) -> list[list[float]]:
    """テキストのリストを埋め込みベクトルのリストに変換する"""
    results = []
    # API 側の上限(1 リクエスト 10 件)に合わせて分割して送る
    for i in range(0, len(texts), _BATCH_SIZE):
        batch = texts[i:i + _BATCH_SIZE]
        response = TextEmbedding.call(
            model="text-embedding-v4",
            input=batch,
            api_key=os.environ["DASHSCOPE_API_KEY"],
        )
        # output が None の場合はレート制限や認証エラー等。メッセージを付けて止める
        if response.output is None:
            raise RuntimeError(f"DashScope error: {response.code} {response.message}")
        # レスポンスから embedding ベクトルだけ取り出して結果に積む
        results.extend(item["embedding"] for item in response.output["embeddings"])
    return results
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# index.py
from pathlib import Path
import chromadb
from chunker import chunk_repo
from embedder import embed

DEFAULT_DB_PATH = str(Path(__file__).parent / "chroma_db")


def build_index(repo_path: str, db_path: str = DEFAULT_DB_PATH):
    """リポジトリをチャンク化し、埋め込みベクトルと一緒に ChromaDB に格納する"""
    # ローカルファイルに永続化するクライアント
    client = chromadb.PersistentClient(path=db_path)
    # リネーム・削除で旧 ID が残らないように毎回コレクションを作り直す
    if any(c.name == "code_chunks" for c in client.list_collections()):
        client.delete_collection(name="code_chunks")
    # 類似度比較しやすいよう cosine 距離を指定(距離は 0〜2 の範囲で返ってくる)
    collection = client.create_collection(
        name="code_chunks",
        metadata={"hnsw:space": "cosine"},
    )

    # ① リポを走査してチャンク化 → ② コード本文だけ取り出し → ③ 埋め込みに変換
    chunks = chunk_repo(repo_path)
    codes = [c["code"] for c in chunks]
    vectors = embed(codes)

    # ベクトル・メタデータ・元コードをまとめて 1 件ずつコレクションに格納
    # ID は path + 開始行で一意にする(同じファイルの別チャンクを区別)
    collection.add(
        ids=[f"{c['path']}:{c['start_line']}" for c in chunks],
        embeddings=vectors,
        metadatas=[{
            "path": c["path"],
            "symbol": c["symbol"],
            "start_line": c["start_line"],
            "end_line": c["end_line"],
            "language": c["language"],
        } for c in chunks],
        documents=codes,
    )
    print(f"Indexed {len(chunks)} chunks")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# search.py
from pathlib import Path
import chromadb
from embedder import embed

DEFAULT_DB_PATH = str(Path(__file__).parent / "chroma_db")


def search(query: str, top_k: int = 5, db_path: str = DEFAULT_DB_PATH) -> list[dict]:
    """クエリ文字列に近いチャンクを top_k 件返す"""
    # インデックス構築時に作ったコレクションを読み込む
    client = chromadb.PersistentClient(path=db_path)
    collection = client.get_collection(name="code_chunks")

    # クエリも埋め込みに変換してから近傍検索にかける
    query_vector = embed([query])[0]
    result = collection.query(
        query_embeddings=[query_vector],
        n_results=top_k,
    )

    # ChromaDB の返却は複数クエリ対応の 2 次元配列。今回は単一クエリなので [0] で展開
    # メタデータと距離を取り出し、Claude Code が扱いやすい辞書形式に整形する
    candidates = []
    for i in range(len(result["ids"][0])):
        meta = result["metadatas"][0][i]
        candidates.append({
            "path": meta["path"],
            "symbol": meta["symbol"],
            "start_line": meta["start_line"],
            "end_line": meta["end_line"],
            # ChromaDB の cosine 距離は `1 - cos(θ)`(範囲は 0〜2)。
            # 0〜2 のまま扱いにくいので、2 で割って 1 から引き 0〜1 のスコアに線形変換する。
            "score": 1.0 - result["distances"][0][i] / 2.0,
        })
    return candidates
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# cli.py
import argparse
import json
import sys
from index import build_index
from search import search


def main():
    parser = argparse.ArgumentParser(prog="myrag")
    sub = parser.add_subparsers(dest="command", required=True)

    # インデックス構築サブコマンド: build <repo_path>
    p_build = sub.add_parser("build", help="インデックスを構築")
    p_build.add_argument("repo_path")

    # 検索サブコマンド: search <query> [--top-k N]
    p_search = sub.add_parser("search", help="類似チャンクを検索")
    p_search.add_argument("query")
    p_search.add_argument("--top-k", type=int, default=5)

    args = parser.parse_args()

    if args.command == "build":
        build_index(args.repo_path)
    elif args.command == "search":
        results = search(args.query, top_k=args.top_k)
        # Claude Code が Bash ツール経由で受け取れるよう JSON を標準出力に書く
        json.dump({"candidates": results}, sys.stdout, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    main()

次のアクション

ここから先は本番運用や大規模リポジトリで効いてくる改善です。個人検証や小さめのリポジトリなら、まずは本文の構成だけでも十分試せます。以下はカテゴリ別の改善候補で、順番に足していけばよく、最初から全部揃える必要はありません。

信頼性

  • リトライ・バックオフ: embedder.py はレート制限(HTTP 429)やネットワーク瞬断に対する再試行をしない。数万チャンクのbuildが途中で落ちると初回コストが痛いので、指数バックオフ+タイムアウトを入れる
  • バッチ化された collection.add: 数万チャンクを一度に add するとメモリとChromaDB側のトランザクションで詰まる可能性がある。512〜2,048件程度に分割してループで投入する

パフォーマンス

  • 差分更新: 毎回フル再インデックスは重い。ファイルハッシュで変更検出し、変更・追加・削除のみ反映する(collection.delete(ids=...)add の組み合わせ)
  • 並列化: 直列10件バッチだと時間がかかる。ThreadPoolExecutor で5〜10並列にするとインデックス構築は体感半分以下になる(DashScopeのレート制限とバランスを取る)

検索精度・カバレッジ

  • チャンク設計の改善: 本記事の chunk_by_lines は40行固定・オーバーラップなし。関数途中で切れた箇所は意味が分断されるので、チャンク間のオーバーラップ(5〜10行)や、巨大関数を一定行数で分割するfallbackを入れる
  • 多言語対応: Go以外(PHP、TypeScriptなど)に tree-sitter-* パーサを足し、言語別のノード名で関数・メソッドを切る
  • グラフ拡張: ベクトル近傍に加えて、関数呼び出しやimport関係を辿って関連チャンクを補強する。意味的に近いが構造的に遠い問題に効く
  • プロンプトチューニング: 検索クエリの組み立て、CLAUDE.mdの指示文、問い分類ロジックなど、Claude Code側のプロンプトを詰めると検索精度とツール使い分けの挙動が変わる

運用

  • チャンクキャッシュの置き場所: chroma_db/ をリポジトリ直下に置くとうっかりcommitされる事故がある。.gitignore に追加するか、~/.cache/myrag/ などユーザーキャッシュに逃がす

まずは動く形を手元に置き、日常の調査で困ったポイントから順に機能を足していくのが現実的です。本記事がその最初の約220行を用意する助けになれば幸いです。

付録: 用語集

記事で使っている技術用語を軽く整理します。

用語意味
Embedding(埋め込み)テキストを高次元ベクトルに変換したもの。意味が近いテキストは近いベクトルになる
近傍検索ベクトル間の距離を比較して「近い」ものを取り出す検索
cosine 距離1 − cosine類似度 で定義される指標。類似度が −1〜1 の範囲を取るため距離は 0〜2 で、0 に近いほど意味が似ている
チャンク検索対象のテキストを一定サイズに切り分けた断片
AST(抽象構文木)ソースコードを文法構造として木で表現したもの。tree-sitter が生成する
Agentic SearchLLM がその場で Grep / Read などのツールを呼んでコードを辿る検索スタイル。Claude Code 標準

関連リンク