Codexのようなコーディングエージェントは、コードを書く前にかなりの量の「探索」を行います。

対象のファイルが分からなければ、ディレクトリを確認し、rggrepで検索し、見つかったファイルを読み、そこから別の検索語を考えます。この探索は便利ですが、リポジトリが大きくなるほど、LLMが検索結果を読みながら次の行動を判断する回数も増えていきます。

2026年7月に公開された論文「BM25 Wins at Scale: A Scaling Study of Retrieval-Augmented Generation Paradigms」では、この問題に対して古典的な全文検索アルゴリズムであるBM25を使う方法が検証されています。

結論を先にまとめると、考え方は次の通りです。

リポジトリ全体

BM25で関連度の高い候補をランキング

上位のファイル・チャンクだけをCodexが読む

必要ならrgなどで追加探索

実装・修正

つまり、Codexを検索エンジンとして何度も働かせるのではなく、検索は安価なBM25へ任せ、Codexはコード理解や判断に集中させるという役割分担です。

この記事では、BM25とは何か、論文ではどの程度の差が出たのか、そしてDockerやDev Containerの中で実際に使うならどう実装できるのかを整理します。

論文では何が変わったのか

論文では、エージェントが生のファイルツリーに対してlist_dirgrepread_docを繰り返す方法をFile-System Agentとして評価しています。

これはコーディングエージェントでよく見る探索方法に近いものです。

質問・依頼

ディレクトリを見る

grepする

結果を読む

別の検索語を考える

別のファイルを読む

必要な情報を見つける

一方、Agent+BM25では、同じエージェントの前段にBM25によるランキング検索を置きます。最初の検索では質問そのものを使ってBM25のTop 5を取得し、その後はエージェントが必要に応じて検索語を変えられる構成です。

約51万文書のフルサイズ環境で、論文の比較は次の結果になりました。

方法モデル呼び出し回数1クエリあたりのトークンスコア文書Recall
File-System Agent36.12回895K36.936.8
Agent + BM255.79回101K69.472.4
Native BM251.00回5.8K54.865.6

File-System AgentからAgent+BM25へ変えると、1クエリあたりのトークンは895K → 101Kまで減っています。およそ9分の1です。それだけでなく、必要な文書を見つける割合と最終スコアも上がりました。

論文が示しているのは、「エージェントに考えさせない方がよい」という話ではありません。

むしろ、エージェントの推論は候補を見つけた後に使う方が効率的であり、巨大な検索空間から候補を見つける部分まで逐次的なLLM探索に任せる必要はないという結果です。論文ではこの考え方を「Retrieval Before Agency」と整理しています。

ただし「Codexのクレジットが9分の1になる」わけではない

ここは区別しておく必要があります。

このAgent+BM25の比較で使われているモデルはQwen3.6-27Bであり、CodexそのものをBM25付きで動かして895K → 101Kになった実験ではありません。

論文ではCodexを含むFile-System Agentのharness比較も行っていますが、それは別の実験です。そのため、上の数字をそのまま「Codexのクレジットが約9分の1になる」と読むのは正確ではありません。

一方、2026年8月時点のCodexは、多くのプランで入力トークン、キャッシュ済み入力トークン、出力トークンに応じたクレジット体系になっています。したがって、BM25によってCodexが読む検索結果や探索中のモデル呼び出しを減らせれば、Codexでもクレジット消費を抑えられる可能性がある、というのが実用上のつながりです。

効果はリポジトリの規模、モデル、キャッシュ、検索の仕方によって変わります。小さいリポジトリでは、BM25の索引を管理する手間の方が大きい場合もあります。

BM25とは何か

BM25は、検索語と文書の関連度を計算し、関係が深そうな文書を上位へ並べる全文検索のランキングアルゴリズムです。

AIやLLMではありません。Embedding APIも必要ありません。

検索結果を決めるときには、主に次のような情報を使います。

  • 検索語が文書内に出てくる回数
  • その検索語が全文書の中でどの程度珍しいか
  • 文書が長いだけでヒット数が増えていないか
  • 同じ語が大量に出た場合に、その回数をどこまで評価するか

たとえば、コードベースを次の語で検索するとします。

delete undo duration

constreturnのようにほぼすべてのコードに登場する語より、undodurationのように出現箇所が限られる語の方が、候補を絞るための情報として価値があります。

BM25はこうした情報を使って、次のようなランキングを作ります。

1. src/hooks/useDeleteUndo.ts
2. src/constants/duration.ts
3. src/components/DeleteToast.tsx
4. docs/delete.md
5. README.md

このTop 5だけをCodexへ読ませれば、最初からリポジトリ全体を探索させる必要がなくなります。

grepやrgとは役割が違う

BM25はgreprgの完全な代替ではありません。

rgは、探したい識別子や文字列が分かっていると非常に強力です。

rg "DELETE_UNDO_DURATION_MS"

これは「この文字列はどこにあるか」という検索です。

BM25が向いているのは、もう少し曖昧な段階です。

削除後のUndo表示時間に関係するコードはどこか

このような場合に、deleteundodurationなど複数の語から関連度を付けて候補を出します。

使い分けるなら次の形が分かりやすいです。

状況向いている検索
関数名・変数名が分かっているrg
完全一致の文字列を探したいrg / grep
どのファイルか分からないBM25
概念に関係する候補を先に絞りたいBM25
BM25で候補を出した後の詳細確認rg

BM25で候補を絞り、rgで正確に確認するのが実用的です。

Ubuntuにbm25という標準コマンドがあるわけではない

grepのように、Ubuntuへ最初からbm25コマンドが入っているわけではありません。

BM25はアルゴリズムの名前なので、実際にはBM25を実装したライブラリや検索エンジンを使います。Pythonならbm25srank_bm25などがありますし、ElasticsearchでもBM25が使われています。

ただ、Docker内でコード検索用の小さな仕組みを作るだけなら、さらに依存を減らせます。

SQLiteの全文検索機能FTS5には、組み込みのbm25()関数があります。Python標準ライブラリのsqlite3からFTS5を利用できる環境なら、追加のPythonパッケージなしでBM25検索を実装できます。

ここからは、この方法で最小構成を作ります。

Dockerコンテナ内でBM25コード検索を作る

想定する構成は次の通りです。

project/
├── src/
├── tools/
│   └── bm25_code_search.py
├── .bm25/
│   └── code.db
├── AGENTS.md
└── Dockerfile

.bm25/code.dbが検索インデックスです。

Codexも同じコンテナ内で動かすのであれば、BM25検索も同じコンテナから実行できる状態にしておくのが最も単純です。

1. DockerイメージにPythonとSQLiteを入れる

Node.jsベースのDev Containerなど、Pythonが入っていないイメージでは追加します。

FROM mcr.microsoft.com/devcontainers/typescript-node:24

USER root

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        python3 \
        sqlite3 \
    && rm -rf /var/lib/apt/lists/*

USER vscode

Pythonのsqlite3がFTS5対応かは、コンテナ内で次のコマンドで確認できます。

python3 - <<'PY'
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE VIRTUAL TABLE docs USING fts5(content)")
print("FTS5 is available")
PY

FTS5 is availableと表示されれば利用できます。

2. コードをチャンク化してSQLiteへ登録する

コードファイルを丸ごと1文書として登録すると、大きなファイルほど検索結果が粗くなります。

そこで、一定行数ごとに分割してBM25の検索単位にします。以下では80行を1チャンク、20行をオーバーラップさせています。

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import re
import sqlite3
from pathlib import Path

DEFAULT_EXTENSIONS = {
    ".astro", ".css", ".go", ".html", ".java", ".js", ".json", ".jsx",
    ".md", ".mdx", ".py", ".rs", ".sql", ".toml", ".ts", ".tsx",
    ".vue", ".yaml", ".yml",
}

EXCLUDED_DIRS = {
    ".git", ".astro", ".bm25", ".next", "coverage", "dist", "node_modules",
}


def ensure_fts5(conn: sqlite3.Connection) -> None:
    """SQLiteでFTS5を利用できることを確認する。"""
    try:
        conn.execute(
            "CREATE VIRTUAL TABLE IF NOT EXISTS __fts5_check USING fts5(content)"
        )
        conn.execute("DROP TABLE __fts5_check")
    except sqlite3.OperationalError as exc:
        raise SystemExit(
            "SQLite FTS5 が利用できません。FTS5対応のSQLiteを使用してください。"
        ) from exc


def iter_source_files(root: Path):
    """検索対象のソースファイルを列挙する。"""
    for path in root.rglob("*"):
        if not path.is_file() or path.suffix.lower() not in DEFAULT_EXTENSIONS:
            continue

        relative = path.relative_to(root)
        if any(part in EXCLUDED_DIRS for part in relative.parts):
            continue

        yield path


def chunk_lines(text: str, chunk_size: int, overlap: int):
    """テキストを行単位でオーバーラップ付きのチャンクへ分割する。"""
    lines = text.splitlines()
    if not lines:
        return

    step = chunk_size - overlap

    for start in range(0, len(lines), step):
        end = min(start + chunk_size, len(lines))
        yield start + 1, end, "\n".join(lines[start:end])

        if end == len(lines):
            break


def build_index(
    root: Path,
    db_path: Path,
    chunk_size: int,
    overlap: int,
) -> None:
    """リポジトリを走査し、FTS5インデックスを作成する。"""
    if chunk_size <= 0:
        raise SystemExit("--chunk-lines は1以上にしてください。")

    if overlap < 0 or overlap >= chunk_size:
        raise SystemExit(
            "--overlap は0以上、--chunk-lines未満にしてください。"
        )

    db_path.parent.mkdir(parents=True, exist_ok=True)

    if db_path.exists():
        db_path.unlink()

    conn = sqlite3.connect(db_path)
    ensure_fts5(conn)
    conn.execute(
        """
        CREATE VIRTUAL TABLE code USING fts5(
            path UNINDEXED,
            start_line UNINDEXED,
            end_line UNINDEXED,
            content,
            tokenize='unicode61'
        )
        """
    )

    count = 0

    with conn:
        for path in iter_source_files(root):
            relative = path.relative_to(root).as_posix()
            text = path.read_text(encoding="utf-8", errors="ignore")

            for start_line, end_line, chunk in chunk_lines(
                text,
                chunk_size,
                overlap,
            ):
                # ファイル名も検索対象になるよう、本文の先頭へパスを加える。
                searchable = f"{relative}\n{chunk}"
                conn.execute(
                    """
                    INSERT INTO code(path, start_line, end_line, content)
                    VALUES (?, ?, ?, ?)
                    """,
                    (relative, start_line, end_line, searchable),
                )
                count += 1

    conn.close()
    print(f"indexed {count} chunks -> {db_path}")


def build_fts_query(raw_query: str) -> str:
    """入力文字列を安全なFTS5 OR検索へ変換する。"""
    terms = re.findall(
        r"[A-Za-z0-9_]+|[^\W\d_]+",
        raw_query,
        flags=re.UNICODE,
    )
    terms = list(dict.fromkeys(term for term in terms if term))

    if not terms:
        raise SystemExit("検索語を指定してください。")

    escaped = [term.replace('"', '""') for term in terms]
    return " OR ".join(f'"{term}"' for term in escaped)


def search_index(db_path: Path, query: str, limit: int) -> None:
    """BM25スコア順に上位チャンクを表示する。"""
    if not db_path.exists():
        raise SystemExit(f"index not found: {db_path}")

    conn = sqlite3.connect(db_path)
    ensure_fts5(conn)
    fts_query = build_fts_query(query)

    rows = conn.execute(
        """
        SELECT
            path,
            start_line,
            end_line,
            bm25(code) AS score,
            snippet(code, 3, '[', ']', ' ... ', 20) AS excerpt
        FROM code
        WHERE code MATCH ?
        ORDER BY score
        LIMIT ?
        """,
        (fts_query, limit),
    ).fetchall()

    conn.close()

    for index, (path, start_line, end_line, score, excerpt) in enumerate(rows, 1):
        print(
            f"{index}. {path}:{start_line}-{end_line} "
            f"score={score:.4f}"
        )
        print(f"   {excerpt.replace(chr(10), ' ')}")


def parse_args() -> argparse.Namespace:
    """CLI引数を定義する。"""
    parser = argparse.ArgumentParser(
        description="SQLite FTS5/BM25によるコード検索"
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    index_parser = subparsers.add_parser("index", help="検索インデックスを作成")
    index_parser.add_argument("--root", default=".")
    index_parser.add_argument("--db", default=".bm25/code.db")
    index_parser.add_argument("--chunk-lines", type=int, default=80)
    index_parser.add_argument("--overlap", type=int, default=20)

    search_parser = subparsers.add_parser("search", help="BM25検索")
    search_parser.add_argument("query")
    search_parser.add_argument("--db", default=".bm25/code.db")
    search_parser.add_argument("--limit", type=int, default=5)

    return parser.parse_args()


def main() -> None:
    """サブコマンドに応じて索引作成または検索を実行する。"""
    args = parse_args()

    if args.command == "index":
        build_index(
            Path(args.root).resolve(),
            Path(args.db),
            args.chunk_lines,
            args.overlap,
        )
        return

    search_index(Path(args.db), args.query, args.limit)


if __name__ == "__main__":
    main()

インデックスを作ります。

python3 tools/bm25_code_search.py index --root .

検索は次のように実行します。

python3 tools/bm25_code_search.py search "delete undo duration"

結果は、Codexが次に読むべき候補として使える形にします。

1. src/hooks/useDeleteUndo.ts:1-80
2. src/constants/duration.ts:1-42
3. src/components/DeleteToast.tsx:61-140

このサンプルはPython 3.13.5 / SQLite 3.46.1で、インデックス作成と検索まで動作確認しています。

3. インデックスはGit管理しない

検索インデックスはソースコードから再生成できるため、通常はGitへ入れる必要がありません。

.bm25/

Dev Containerでワークスペース自体をbind mountしている場合、.bm25/もホスト側へ残るので、コンテナを作り直すたびにインデックスが消えることも避けられます。

別のDocker Volumeへ分けても構いません。

4. CodexへBM25を先に使うルールを伝える

ツールを置いただけでは、Codexが必ず使うとは限りません。

AGENTS.mdなどに、探索時の使い分けを明示します。

## Code search

- 対象ファイルが分からない広範囲の探索では、最初にBM25検索を使う。

```bash
python3 tools/bm25_code_search.py search "<query>"
```

- BM25の上位候補を確認してから、必要なファイルを読む。
- 関数名、変数名、エラーメッセージなど完全一致の文字列が分かっている場合は`rg`を優先してよい。
- BM25の結果だけで不足する場合に、追加のBM25検索または`rg`を行う。

このルールで重要なのは、rgを禁止しないことです。

対象が曖昧

BM25

候補を数件に絞る

rg・ファイル読み込み

修正

という順番にするだけです。

インデックスはいつ更新するのか

BM25には検索インデックスがあるため、コードを変更すると古くなります。

最初からファイル監視や差分更新まで作る必要はありません。小規模から中規模のリポジトリなら、Codexの作業開始時や大きな変更後に再生成するだけでも十分です。

python3 tools/bm25_code_search.py index --root .

運用してみて再生成時間が問題になってから、git diff --name-onlyなどを使った差分更新を検討できます。

最初からinotify、rename検知、削除検知、部分更新まで実装すると、検索よりインデックス管理の方が複雑になりやすいためです。

コード検索ではチャンクとトークナイズを調整する余地がある

上の実装は、仕組みを試すための最小構成です。

特にコード検索では、次の部分を調整する余地があります。

チャンクサイズ

80行で固定すると、関数の途中でチャンクが切れることがあります。

本格的に使うなら、ASTを使って関数・クラス単位に分割する方法も考えられます。ただし、最初の検証では行単位の方が単純です。

識別子の分割

DELETE_UNDO_DURATION_MSunicode61で区切りやすい一方、useDeleteUndoのようなcamelCaseは一つのトークンとして扱われる場合があります。

検索精度が不足するなら、インデックス作成時にcamelCaseやsnake_caseを分割した文字列を追加したり、FTS5のtrigram tokenizerを検討したりできます。

日本語検索

日本語の自然文をそのまま投げる場合も、tokenizerの影響を受けます。

コード探索では、まずdelete undo durationLinkCard markdownのように、実装に現れそうな英語の識別子・概念語へ寄せた検索を使う方が安定しやすいです。

BM25が特に効きやすい場面

BM25の導入効果が大きくなりやすいのは、Codexが「どこを見ればよいか」を探す時間が長いリポジトリです。

たとえば、次のような状況です。

  • ファイル数が多い
  • モノレポで複数アプリやパッケージがある
  • ドキュメントやIssue由来の文章も検索対象にしたい
  • 同じ概念が複数ファイルへ分散している
  • Codexが毎回似たrg検索を何度も繰り返している

逆に、数十ファイル程度の小さいプロジェクトや、変更対象のファイルが最初から明らかな作業では、BM25を挟む意味はあまりありません。

rg一回で答えが出るなら、その方が単純です。

BM25の価値は「AIを使わない部分」を増やせること

BM25自体は新しい技術ではありません。

それでもコーディングエージェントとの組み合わせで面白いのは、LLMを使わなくても解ける仕事を、LLMから外へ出せることです。

検索候補を探す        → BM25
完全一致を確認する    → rg
コードを理解する      → Codex
変更方針を判断する    → Codex
実装する              → Codex
テスト・型チェック    → 既存ツール

Codexにすべてを任せるのではなく、安価で決定的なツールを前後に置くことで、モデルを使う場所を絞れます。

今回の論文で重要なのも、BM25単体が常にエージェントより優秀だったという点ではありません。小さい検索空間ではFile-System Agentが強い場面もありました。

規模が大きくなるほど、まず全体をランキングして候補を見つけ、その後にエージェントが考える構成が有利になったことがポイントです。

Codexのクレジットを節約するという目的でも、「安いモデルへ変える」以外に、そもそもCodexへ読ませるものと探索回数を減らすという選択肢があります。

BM25は、そのために試しやすい方法の一つです。