コードベースを埋め込みで検索する話には、いつも同じデモが付いてきます。「ユーザーをロックしているのはどこ?」と聞くと、コードでは cooldown と呼ばれているのでgrepは0件、ベクトル検索なら正しい関数に一発でたどり着く、というものです。デモとしてはよくできています。気になるのは、デモ用に選ばれていないコードで、それがどのくらい通用するかです。
そこで、Python開発者なら一度は読んだことがありそうな psf/requests(タグ v2.32.3)で測ってみました。すぐ思いつくフレーズでgrepすると0行になる質問を10個用意し、デスクトップアプリ osFoundry がローカルのフォルダインデックスで使っているのと同じ流れ(チャンク分割は folder_index.rs、ハイブリッド検索は folder_search.rs)を、Python 200行弱で組み直しました。実行環境はメモリ8GBのM1 Mac、CPUのみです。先に結論を書くと、埋め込みだけで正解のチャンクが上位5件に入ったのは10問中4問でした。同じ質問をそのままキーワードで順位付けすると6問。2つを融合すると上位10件に7問、テストとドキュメントを検索対象から外すと9問になりました。
行番号つきでチャンクに切る
埋め込みはファイル単位ではなく、断片単位で作ります。アプリは各ファイルを約700トークンのチャンクに切り、隣と80トークン分重ねます。トークンは4文字で1つと見なし、各チャンクの最初と最後の行番号を保持するので、ヒットを path:start-end の形で返せます。
TARGET_TOKENS, OVERLAP_TOKENS = 700, 80
def chunk_with_lines(text):
lines = text.splitlines()
target, overlap = TARGET_TOKENS * 4, OVERLAP_TOKENS * 4 # ~4 chars per token
out, start = [], 0
while start < len(lines):
chars, end = 0, start
while end < len(lines) and chars < target:
chars += len(lines[end]) + 1
end += 1
body = "\n".join(lines[start:end])
if body.strip():
out.append((body, start + 1, end)) # 1-based, inclusive
if end >= len(lines):
break
back, idx = 0, end
while idx > start and back < overlap: # step back ~80 tokens
idx -= 1
back += len(lines[idx]) + 1
start = idx if idx > start else end # always move forward
return out
切るのは必ず行の境目で、行の途中では切りません。最後の1行のガードが地味に重要です。これがないと、1行が200KBあるminify済みファイルで、同じ開始位置に戻り続けて止まらなくなります。
その前のスキャンでは、node_modules、dist、build、.git、.venv などのフォルダ、ファイル名で指定したロックファイル、1MBを超えるファイル、先頭512バイトにNULバイトを含むファイルを外します。ロックファイルを拡張子ではなくファイル名で見るのは、package-lock.json も pnpm-lock.yaml も .lock で終わらないからで、拡張子だけで弾こうとするとここでハマります。requestsでは129ファイル中117ファイル(バイナリ10、1MB超1、除外したSVG 1)、625,261バイトが残り、チャンクは318個になりました。
「4文字で1トークン」という見積もりには落とし穴があります。コードは文章よりトークンが多めに出ます。bge-m3 自身のトークナイザーで数えると、チャンクの中央値は700ではなく771トークン、最大は2,050トークンでした。これが次の段階に効いてきます。
ローカルで埋め込む
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3", device="cpu")
model.max_seq_length = 2048
texts = [f"{c['path']}\n{c['text']}" for c in chunks] # the path carries meaning too
vecs = model.encode(texts, batch_size=2, normalize_embeddings=True)
スクリプトはどれも uv run で動きます。ファイル先頭のインラインメタデータから sentence-transformers と torch が入ります。比較用に、パラメータ数3,300万の英語モデル bge-small-en-v1.5(bge-m3 の約17分の1)でも同じチャンクを埋め込みました。こちらはモデル推奨の「Represent this sentence for searching relevant passages: 」を質問の前に付けています。
| bge-m3 | bge-small-en-v1.5 | |
|---|---|---|
| ベクトルの次元 | 1,024 | 384 |
| 入力の上限 | 8,192トークン(今回は2,048で打ち切り) | 512トークン |
| 途中で切られたチャンク | 318個中1個 | 318個中235個 |
| 318チャンクの埋め込み時間(M1、CPU) | 631秒 | 20.5秒 |
| ピークメモリ | 1.6GB | 未計測 |
| ベクトルのサイズ(float32) | 1.3MB | 0.49MB |
| 質問1つの埋め込み | 682ミリ秒 | 21ミリ秒 |
625KBのリポジトリに10分は遅く、このマシンでは1チャンク約2秒です。かかる時間はファイル数ではなくコードの量で決まります。ただし払うのは最初の1回だけです。アプリはチャンクを本文のハッシュで管理していて、中身が変わらないチャンクはベクトルを使い回すので、関数を1つ直しても埋め込み直すのは数チャンクです。小さいモデルの速さには、表のとおり代償があります。上限512トークンなので、318個中235個のチャンクは前半しか読んでいません。
アプリのソースに赤い印付きで書かれているルールが1つあり、自分で組むときに破りやすいので書いておきます。質問は、インデックスを作ったのと同じモデルで埋め込む必要があります。bge-m3 のチャンクのベクトルと、別の1,024次元モデルで作った質問のベクトルは形が同じなので、エラーは出ません。結果が黙っておかしくなるだけです。
キーワード、ベクトル、そして融合
grepには、人が打ちそうなフレーズを大文字小文字を区別せずに渡し、同じ117ファイルを対象にしました。もう少し公平なキーワード側の比較として、質問文をまるごとチャンク単位のBM25にもかけています。識別子は _ とキャメルケースで分割するので、should_strip_auth は「strip」にも当たります(アプリのキーワード検索はファイル単位の tantivy で、ヒットをそのファイルの先頭のチャンクに割り当てています。チャンク単位にしたのはここでの簡略化です)。
2つの順位は、アプリと同じ k = 60 の Reciprocal Rank Fusion でまとめます。それぞれのリストから上位30件を使います。
def rrf(lists, k=60, n=30):
s = Counter()
for lst in lists:
for r, i in enumerate(lst[:n]):
s[i] += 1 / (k + r + 1)
return [i for i, _ in s.most_common()]
RRFはスコアを無視して順位だけを使います。そこがポイントです。BM25のスコアとコサイン類似度は尺度がまったく違い、片方をもう片方に合わせて正規化すると、存在しない関係をでっち上げることになります。片方で3位、もう片方で5位のチャンクは、片方で1位でもう片方に出てこないチャンクより上に来ます。
正解は、目的の関数の def 行を含むチャンクです。何かを実行する前に、コードを読んで決めました。表の数字は、そのチャンクが318個の中で何位だったかです。
結果
| 質問(目的の関数) | grep(フレーズ) | grep(1語) | BM25 | bge-m3 | ハイブリッド | bge-small |
|---|---|---|---|---|---|---|
don't send the password to another site after a redirect(should_strip_auth) |
"another site":0 | password:47 | 9 | 1 | 1 | 1 |
why does a POST turn into a GET after a redirect(rebuild_method) |
"turn into":0 | GET:625 | 65 | 10 | 19 | 6 |
stop following redirects that go round in a loop(resolve_redirects) |
"redirect loop":0 | loop:6 | 5 | 28 | 5 | 14 |
move the upload back to the start so it can be sent again(rewind_body) |
"back to the start":0 | start:54 | 1 | 4 | 2 | 20 |
how big is the body we are about to send(super_len) |
"how big":0 | big:8 | 54 | 65 | 圏外 | 233 |
skip the proxy for machines on the local network(should_bypass_proxies) |
"local network":0 | local:94 | 2 | 20 | 6 | 10 |
make sure a file is never left half-written if the process dies(atomic_open) |
"half-written":0 | half:4 | 58 | 16 | 29 | 1 |
tell UTF-16 from UTF-32 by looking at the first bytes of the JSON(guess_json_utf) |
"first bytes":0 | bytes:98 | 4 | 4 | 5 | 111 |
read saved logins from a file in the home folder(get_netrc_auth) |
"home folder":0 | login:4 | 1 | 112 | 7 | 17 |
answer the server's 401 with a hashed password(handle_401) |
"hashed password":0 | hash:17 | 1 | 2 | 1 | 1 |
raise_for_status |
15 | 2 | 70 | 12 | 244 | |
UnrewindableBodyError |
9 | 1 | 2 | 2 | 6 |
順位は318チャンク中の位置、grepの数字は一致した行数です。「ハイブリッド」はBM25とbge-m3の融合です。質問はコードに合わせて英語のまま使っています。
目に付いたことが3つあります。
フレーズのgrepが0件でも、キーワードが役に立たないわけではありません。質問の単語で順位付けしたBM25は、10問中6問で正解を上位5件に入れました。redirect、proxy、401、UTF-16 といった単語はコードにも出てきます。合わないのは、その周りの言い回しだけです。逆に1語のgrepは別の問題を見せています。GETで625行、bytesで98行。どこかには入っていますが、その山を自分の目で読むことになります。
埋め込みが勝つ場面は確かにありますが、限られています。bge-m3 は、言葉がまったく重ならない質問で should_strip_auth を1位にし、rebuild_method をBM25の65位から10位に引き上げました。小さいモデルは「never left half-written if the process dies」で atomic_open を1位にしています。BM25では58位です。この関数のdocstringには「atomic fashion」とあり、ファイルにとって atomic が何を意味するかをモデルが知っているわけです。一方で、ベクトルはキーワードならありえない外し方もします。識別子そのままの raise_for_status で、定義のあるチャンクは bge-m3 で70位、bge-small では244位でした。
融合は、どの行でも1番というわけではなく、外しにくい選択肢です。どの方法でも見つからなかった1問を除けば、ハイブリッドがいちばん悪い列になったことはなく、たいてい最良の列の近くにいます。どんな種類の質問か事前にわからないまま呼ぶツールなら、それが欲しい性質です。「how big is the body」はどの方法でも失敗しました。super_len は、自分が何を測っているかを、質問に出てきそうな言葉ではどこにも書いていません。
| 上位5件 / 上位10件に入った数(10問) | BM25 | bge-m3 | ハイブリッド(bge-m3) | bge-small | ハイブリッド(bge-small) |
|---|---|---|---|---|---|
| リポジトリ全体(318チャンク) | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 |
src/ のみ(84チャンク) |
7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 |
いちばんの競争相手はテストとドキュメント
外れの多くは、でたらめな外れではありませんでした。「why does a POST turn into a GET」で bge-m3 の1位は tests/test_requests.py の test_http_303_changes_post_to_get のあたり。「read saved logins from a file in the home folder」では HISTORY.md の .netrc についての変更履歴、「move the upload back to the start」と「how big is the body」はどちらも docs/user/advanced.rst の一節でした。テストとドキュメントは動きをふつうの言葉で説明しています。質問も同じくふつうの言葉なので、実装より質問に近くなりがちです。
テスト名から関数にはたどれるので、間違いとまでは言えません。ただ、これらを外すと順位は大きく変わります。同じベクトルのまま src/ 以下の84チャンクだけを検索すると、ハイブリッドで10問中9問が上位10件に入りました。このリポジトリでいちばん安く効いた改善は、大きなモデルではなくパスでの絞り込みでした。せめてテストやドキュメントのヒットに印を付けて、飛ばせるようにしておくだけでも違います。
小さいモデルは、ほとんどのチャンクを512トークンまでしか読んでいないのに健闘しました。チャンクの冒頭、つまり def とdocstringに、そのチャンクが何についてのものかがほぼ書かれているからだと思われます。1つのリポジトリで10問では、どちらのモデルが上とは言えません。言えるのは、モデルの選び方よりも、キーワードとの融合と、テストを邪魔にならない場所に置くことのほうが効いた、ということです。
手元で動かす
git clone --depth 1 --branch v2.32.3 https://github.com/psf/requests.git
python3 chunk.py requests chunks.json
uv run embed.py chunks.json BAAI/bge-m3 vec-m3.npy
uv run search.py chunks.json vec-m3.npy BAAI/bge-m3 queries.json
SCOPE=src/ uv run search.py chunks.json vec-m3.npy BAAI/bge-m3 queries.json
初回の embed.py で、Hugging Face から bge-m3(約2.3GB)がダウンロードされます。質問は結果を見る前に書き、正解は先にコードを読んで決めておくのがおすすめです。ベクトル検索が勝つまで質問の言い回しをいじる、というのはとても簡単にできてしまいます。
デスクトップアプリ osFoundry では、同じ流れを「インデックス」画面から実行できます。bge-m3 は端末内で動き、エージェントの folder_search ツールがBM25とベクトルを融合して検索します。チャンクの大きさと重なりの考え方は用語集のチャンキングの項目にあり、アプリはosFoundryのダウンロードページから無料で入手できます。