検索コマンドの速さを測るつもりで、表を作っておりました。ripgrep、git grep、昔ながらの grep。同じ語を同じフォルダで7回ずつ走らせて、中央値を並べます。
表ができあがって、手が止まりました。時間の列は、どれも11〜35 msに収まっています。差を論じる意味がありません。目を引いたのは、隣の「出力バイト数」の列でした。同じ語の検索なのに、15,207 バイトから 771,629 バイトまで、50倍ほど開いています。
個人開発で運用している Web サイトの記事リポジトリ(MDX を日英で各893本)での測定です。検索ツールの比較で見るべきは速さではなく、返ってくる量と、その量の切り取られ方 でした。その記録をお伝えします。
測った条件
環境と手順を先に書いておきます。再現していただく際の手がかりになれば幸いです。
作業用の Linux 環境(4 コア)、ripgrep 13.0.0、GNU grep、git
対象は content/articles/ja(893ファイル)。語は hook(大文字小文字を区別しない)
各コマンドを7回走らせ、実行時間は中央値。出力は標準出力のバイト数と行数、最長の1行を記録
トークン数は測っておりません。ここでの「量」はバイトと行数です
測定に使った Python は、次のとおりです。そのまま貼って動きます。
import os
import statistics
import subprocess
import time
os.chdir(os.path.expanduser( "~/repos/claudelab.net" )) # 自分のリポジトリに置き換えてください
def run (cmd, n = 7 ):
times, out = [], b ""
for _ in range (n):
t = time.perf_counter()
# rg は 0 件だと exit 1 を返すので、check=True にはしません
out = subprocess.run(cmd, shell = True , capture_output = True ).stdout
times.append((time.perf_counter() - t) * 1000 )
lines = [l for l in out.split( b " \n " ) if l]
longest = max (( len (l) for l in lines), default = 0 )
return statistics.median(times), len (out), len (lines), longest
COMMANDS = [
( "rg -n" , "rg -n -i hook content/articles/ja" ),
( "rg -l" , "rg -l -i hook content/articles/ja" ),
( "rg -n -m1" , "rg -n -m1 -i hook content/articles/ja" ),
( "git grep -n" , "git grep -n -i hook -- content/articles/ja" ),
( "grep -rn" , "grep -rn -i hook content/articles/ja" ),
]
print ( "label \t ms \t bytes \t lines \t longest" )
for label, cmd in COMMANDS :
ms, size, count, longest = run(cmd)
print ( f " { label }\t{ ms :.0f }\t{ size }\t{ count }\t{ longest } " )
check=True を付けないのは、ripgrep が「一致なし」を終了コード1で返すためです。ここで例外にすると、0件の日に測定そのものが止まってしまいます。
結果1:速さは、選ぶ理由になりませんでした
実際の数字は次のとおりです。
3つのツールは、出力のバイト数が一致しました(どれも305,309 バイト)。速さの差は最大でも13 msです。Claude に検索を任せる場面で、この差が体感に出ることはまずありません。
意外だったのは、リポジトリ全体を対象にした一行目です。範囲を content/articles/ja に絞らないだけで、出力は771,629バイトまで膨らみました。英語版と src が混ざるためです。時間は10 ms増えただけで、量は2.5倍 になっています。
結果2:出力の量は、付け方ひとつで大きく動きます
同じ hook で、出力の形を変えて並べました。
私が一番効くと思っていたのは --max-columns でした。1行が長いから量が膨らむのだろう、と考えたからです。結果は2%減にとどまりました。
理由は、行の長さではなく行の数 と行頭の接頭辞 にありました。一致した行の本文は中央値58文字、90パーセンタイルで167文字、最長でも517文字です。日本語の記事は意外と1行が短いのです。一方で、各行の先頭に付く content/articles/ja/claude-code/…mdx:123: という接頭辞は、全体で約112,000文字。出力の約37%が、パスと行番号でした。
-o で前後40字に絞ると20%ほど減りますが、一致ごとに1行が立つため、行数は1,312から1,433に増えました。減ったのに増える、という少し不思議な形です。
結果3:先頭250行は、一致したファイルの4分の1にも届きません
ここからが本題です。私の環境で使っている Grep ツールの説明には、head_limit を指定しないと content モードで250行までに切る、と書かれています(既定の output_mode は files_with_matches なので、あふれるのは content モードを選んだときです)。お使いの環境の上限は、各ツールの説明でご確認ください。
では、1,312行のうち先頭の250行で、183ファイルのどれくらいに触れるのでしょうか。
先頭250行が触れるファイル数:42ファイル(23%)
その250行のバイト数:49,172(全件の16%)
上位3ファイルだけで217行(17%)。88行、76行、53行と、特定の記事が行を占有しています
一致が1件だけのファイルが61、5件以上のファイルが67
つまり、250行の大半は「何度も出てくる少数の記事」で埋まり、1回だけ触れた61ファイルのほとんどは視界に入りません。
さらに、見える顔ぶれは実行のたびに変わりました 。ripgrep は並列に走るため、並び順が固定されないからです。同じコマンドを6回走らせた先頭250行は、32〜51ファイルに触れ、カテゴリの内訳も揺れました。
import subprocess
from collections import Counter
def head_files (cmd, limit = 250 ):
out = subprocess.run(cmd, shell = True , capture_output = True ).stdout.decode()
top = out.splitlines()[:limit]
files = {l.split( ":" , 1 )[ 0 ] for l in top}
# content/articles/ja/<カテゴリ>/<slug>.mdx のカテゴリ部分を数える
return len (files), Counter(f.split( "/" )[ 3 ] for f in files)
for i in range ( 6 ):
n, cats = head_files( "rg -n -i hook content/articles/ja" )
print (i + 1 , n, dict ( sorted (cats.items())))
n, cats = head_files( "rg -n --sort path -i hook content/articles/ja" )
print ( "sorted" , n, dict ( sorted (cats.items())))
実際の出力から、4回分とパス順固定の結果を抜粋します。
直感に反したのは、並びを固定した最後の行です。claude-code の記事は1本も視界に入りませんでした。 hook について書いた記事が最も多いのは claude-code のカテゴリなのに、パス順で先頭250行を切ると、アルファベットで早い api-sdk と claude-ai だけが見えます。
「該当は主に api-sdk の記事のようです」という回答が返ってきたとしたら、それは検索が見落としたのではなく、見せる範囲の切り方が答えを決めていた ことになります。回答の文面には、切られたことが現れません。この点には、測ってみて初めて気づきました。
読み込む前に見積もる:scout_hits.py
そこで、全件を読む前に「どれくらい切られるか」を先に数えるスクリプトを書きました。何を解決するかといえば、一致数・ファイル数・先頭N行の到達率を、数十ミリ秒で出すことです。
#!/usr/bin/env python3
"""scout_hits.py — rg の全件出力を読む前に、件数・ファイル数・取りこぼしを見積もる。
使い方: python3 scout_hits.py PATTERN [PATH] [--limit 250]
"""
import subprocess
import sys
from collections import Counter
def rg (args):
# rg は 1 件も無いと exit 1 を返す。異常終了(2)だけを失敗として扱う
p = subprocess.run([ "rg" , * args], capture_output = True )
if p.returncode == 2 :
sys.exit(p.stderr.decode( errors = "replace" ))
return p.stdout.decode( errors = "replace" ).splitlines()
def main ():
argv = sys.argv[ 1 :]
limit = 250
if "--limit" in argv:
i = argv.index( "--limit" )
limit = int (argv[i + 1 ])
del argv[i:i + 2 ]
pattern = argv[ 0 ]
path = argv[ 1 ] if len (argv) > 1 else "."
lines = rg([ "-n" , "--sort" , "path" , "-i" , pattern, path])
if not lines:
print ( "0 件" )
return
per_file = Counter(l.split( ":" , 1 )[ 0 ] for l in lines)
total_bytes = sum ( len (l.encode()) + 1 for l in lines)
head = lines[:limit]
seen = {l.split( ":" , 1 )[ 0 ] for l in head}
first_m1 = rg([ "-n" , "-m1" , "-i" , pattern, path])
m1_bytes = sum ( len (l.encode()) + 1 for l in first_m1)
print ( f "一致 { len (lines) } 行 / { len (per_file) } ファイル / 全件 { total_bytes :, } bytes" )
print ( f "先頭 { limit } 行が触れるファイル: { len (seen) } / { len (per_file) } "
f "( { len (seen) / len (per_file) :.0% } )" )
top3 = per_file.most_common( 3 )
print ( "上位3ファイルの占有: {} 行 ( { :.0% } )" .format(
sum (n for _, n in top3), sum (n for _, n in top3) / len (lines)))
print ( f "-m1 なら { len (first_m1) } 行 / { m1_bytes :, } bytes で全ファイルに触れる" )
verdict = "-l か -m1 から始める" if len (lines) > limit else "そのまま読んでよい"
print ( "判定:" , verdict)
if __name__ == "__main__" :
main()
hook に当てた結果は、次のとおりです。
一致 1312 行 / 183 ファイル / 全件 305,309 bytes
先頭 250 行が触れるファイル: 42 / 183 (23%)
上位3ファイルの占有: 217 行 (17%)
-m1 なら 183 行 / 49,494 bytes で全ファイルに触れる
判定: -l か -m1 から始める
もっと絞った語(PreToolUse)では、結果が変わります。
一致 141 行 / 32 ファイル / 全件 38,516 bytes
先頭 250 行が触れるファイル: 32 / 32 (100%)
上位3ファイルの占有: 48 行 (34%)
-m1 なら 32 行 / 9,049 bytes で全ファイルに触れる
判定: そのまま読んでよい
hook での実行時間は64 msほどでした。数えるだけの前処理としては十分に軽く、「250行以内に収まるなら、全件をそのまま読んでよい」という判断を安く下せます。
なぜ --sort path で数えるのか。並び順を固定しないと、見積もるたびに数字が揺れるからです。本番の検索は揺れるままですが、見積もりは再現できる数字で持っておきたい、という私の好みです。
探索の順番:-l、-m1、そして絞り込み
測定を踏まえて、私が選んだ順番は次のとおりです。
ファイル名だけ(-l) :15 KB、183行で全体像をつかみます。ここで「どのカテゴリに散らばっているか」が見えます
ファイルあたり1件(-m1) :49 KB で全183ファイルに触れ、各ファイルが何の文脈で語を使っているかを短く確認します
対象を絞って全件 :2で気になったファイルやフォルダに範囲を絞ってから、-n で全件を読みます
3つ目は、手で絞るより「Claude に絞ってもらう」ほうが楽です。CLAUDE.md には、たとえば次のように書いています。
## 検索の進め方
- 一致が多そうな語は、いきなり内容を表示しない。先にファイル名だけ(files_with_matches)で範囲を把握する
- 内容を見るときは、ファイルあたり1件(-m1 相当)から始め、必要なファイルだけ全件を読む
- 先頭N行で打ち切られた検索は、結果が「一部」であることを回答に明記する
3つ目の項目は、取りこぼしを回答に残すためのものです。切られたことが回答の文面に現れないのは、先ほど見たとおりです。
落とし穴
実際に測って、引っかかった点を並べます。
--max-columns は量の削減に効きにくい :一致行が短い散文のリポジトリでは、2%しか減りませんでした。ログや minify されたファイルが多い環境なら話は別です
-o は行数が増える :1行に複数の一致があると、一致ごとに行が立ちます(1,312行が1,433行に)
接頭辞が量の約37% :パスが深いリポジトリほど、内容より場所の表示にバイトを使います。-l が軽いのはこのためです
git grep は未追跡のファイルを見ない :今回は追跡済みのファイルだけなので出力が一致しましたが、作業中の新しい記事は対象外になります。範囲の注意は、Cowork が読んでいない場所の棚卸し にも通じます
ripgrep は .gitignore を尊重する :「検索しても出てこない」ときは、逆向きの問題です。こちらはGlob・Grep が何も返さないときの切り分け にまとめております
測っていないこと
誠実にお伝えしておきたい限界があります。
トークン数は測っておりません。日本語は1文字あたりのトークンが英語と異なるため、バイトの比がそのままトークンの比にはなりません
Claude が実際にどのコマンドを選んで検索するかは、観察しておりません。上の表は、ripgrep を直接動かした結果です
対象は日本語の散文リポジトリ1つです。ソースコード中心のリポジトリでは、行の長さも一致の分布も違うはずです
もしお手元で数字が違っていたら、ぜひ教えていただければ幸いです。
次の一歩
文脈の使い方を全体で見直したい場合は、コンテキスト予算の最適化 も併せてご覧ください。
今日からできる最小の一歩は、次の2つです。
自分のリポジトリでよく探す語を1つ選び、scout_hits.py に当てて、判定が「-l か -m1 から始める」になるかを見る
判定が出た語について、-l → -m1 の順で一度やってみて、先頭250行の見え方とどう違うかを確かめる
検索の速さを競うより、検索の切り口をひとつ丁寧に選ぶほうが、見落としを減らしてくれるのだと思います 。今回の測定から、私はそう受け取りました。