コードを意味で検索するとgrepより見つかるのか、requestsのリポジトリで測ってみた
コードベースを埋め込みで検索する話には、いつも同じデモが付いてきます。「ユーザーをロックしているのはどこ?」と聞くと、コードでは 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のダウンロードページから無料で入手できます。
1 like · 1 comment
क्या सिमैंटिक कोड सर्च असली रिपॉज़िटरी पर सच में grep से बेहतर है?
कोडबेस को embed करने का पिच हमेशा एक ही डेमो होता है: आप पूछते हैं "यूज़र्स को लॉक आउट कहाँ करते हैं?", grep को कुछ नहीं मिलता क्योंकि कोड उसे cooldown कहता है, और वेक्टर सर्च सीधे सही फ़ंक्शन पर पहुँच जाता है। डेमो अच्छा है। सवाल यह है कि ऐसे कोड पर यह कितनी बार टिकता है जिसे किसी ने डेमो के लिए चुना ही नहीं।
इसलिए यहाँ एक ऐसे रिपॉज़िटरी पर माप है जिसे ज़्यादातर Python डेवलपर्स ने कभी न कभी पढ़ा है: psf/requests, टैग v2.32.3 पर, दस सवालों के साथ जिनके लिए साफ़ दिखने वाले वाक्यांश पर grep शून्य लाइनें लौटाता है। पाइपलाइन वही है जो osFoundry डेस्कटॉप ऐप अपने लोकल फ़ोल्डर इंडेक्स के लिए इस्तेमाल करता है (folder_index.rs के chunking नियम, folder_search.rs की हाइब्रिड रैंकिंग), जिसे 200 से कम लाइनों के Python में दोबारा बनाया गया और 8 GB के M1 Mac पर, सिर्फ़ CPU से चलाया गया। संक्षिप्त जवाब: अकेले embeddings ने 10 में से 4 सवालों में सही chunk टॉप 5 में खोजा। उसी सवाल की सादा कीवर्ड रैंकिंग ने 6 खोजे। दोनों को जोड़ने पर टॉप 10 में 7 मिले, और जब टेस्ट और डॉक्स सोर्स कोड से होड़ करना बंद कर चुके तब 9।
लाइन नंबर के साथ chunking
Embeddings फ़ाइलों पर नहीं, टुकड़ों पर काम करते हैं। ऐप हर फ़ाइल को लगभग 700 टोकन के chunks में काटता है, 80 टोकन के ओवरलैप के साथ, एक टोकन को 4 अक्षर मानकर, और हर chunk की पहली और आख़िरी लाइन रखता है ताकि हिट को 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
Chunks लाइन की सीमाओं पर कटते हैं, लाइन के बीच में कभी नहीं, और आख़िरी गार्ड मायने रखता है: 200 KB की एक ही लाइन वाली minified फ़ाइल वरना उसी शुरुआत पर हमेशा लौटती रहती।
इससे पहले का स्कैन node_modules, dist, build, .git, .venv और ऐसे फ़ोल्डर छोड़ देता है, lockfiles को पूरे नाम से (package-lock.json और pnpm-lock.yaml .lock पर ख़त्म नहीं होतीं, इसी वजह से सिर्फ़ एक्सटेंशन वाला फ़िल्टर उन्हें चूक जाता है), 1 MB से बड़ी फ़ाइलें, और कोई भी फ़ाइल जिसके पहले 512 बाइट में null बाइट हो। requests पर इसके बाद 129 में से 117 फ़ाइलें बचीं (10 बाइनरी, 1 जो 1 MB से बड़ी, 1 बाहर रखी गई SVG), 625,261 बाइट, और 318 chunks।
4 अक्षर वाला नियम एक बात छिपाता है: कोड का tokenization गद्य से ख़राब होता है। bge-m3 के अपने tokenizer से मापने पर, मध्यक chunk 700 नहीं बल्कि 771 टोकन का था, और सबसे लंबा 2,050 का। अगले चरण में यह मायने रखता है।
Embeddings लोकल में बनाना
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 से चलती हैं, जो हर फ़ाइल के ऊपर दिए inline मेटाडेटा से sentence-transformers और torch इंस्टॉल करता है। तुलना के लिए वही chunks bge-small-en-v1.5 से भी गुज़रे, जो 33M पैरामीटर वाला अंग्रेज़ी मॉडल है और लगभग 17 गुना छोटा है (इसकी क्वेरीज़ में मॉडल का सुझाया प्रीफ़िक्स "Represent this sentence for searching relevant passages: " लगाया गया)।
| | bge-m3 | bge-small-en-v1.5 |
|---|---|---|
| वेक्टर का आकार | 1,024 | 384 |
| अधिकतम इनपुट | 8,192 टोकन (यहाँ 2,048 पर सीमित) | 512 टोकन |
| कटे हुए chunks | 318 में से 1 | 318 में से 235 |
| 318 chunks को embed करने का समय (M1, CPU) | 631 s | 20.5 s |
| पीक मेमोरी | 1.6 GB | मापा नहीं |
| डिस्क पर वेक्टर (float32) | 1.3 MB | 0.49 MB |
| एक क्वेरी को embed करना | 682 ms | 21 ms |
625 KB के रिपॉज़िटरी के लिए दस मिनट धीमा है, इस मशीन पर लगभग 2 सेकंड प्रति chunk, और लागत कोड की मात्रा के साथ बढ़ती है, फ़ाइलों की गिनती के साथ नहीं। यह एक बार की लागत है: ऐप chunks को उनके टेक्स्ट के hash से कुंजीबद्ध करता है, इसलिए बिना बदला chunk अपना वेक्टर रखता है, और एक फ़ंक्शन बदलने पर मुट्ठी भर chunks ही दोबारा embed होते हैं। छोटे मॉडल की रफ़्तार के साथ एक पेच है जो टेबल साफ़ दिखाती है: 512 टोकन पर उसने 318 में से 235 chunks का सिर्फ़ शुरुआती हिस्सा पढ़ा।
एक नियम है जिसे ऐप का सोर्स लाल मार्कर से चिह्नित करता है, और अपने कोड में इसे तोड़ देना आसान है। क्वेरी को उसी मॉडल से embed करना होगा जिसने इंडेक्स बनाया। bge-m3 chunk का वेक्टर और किसी दूसरे 1,024-डायमेंशन मॉडल की क्वेरी का वेक्टर एक ही आकार के होते हैं, इसलिए कोई एरर नहीं आता, और नतीजे बस ग़लत होते हैं।
कीवर्ड, वेक्टर, और उन्हें जोड़ना
grep को वही वाक्यांश दिया गया जो आप टाइप करते, केस-इनसेंसिटिव, उन्हीं 117 फ़ाइलों पर। ज़्यादा निष्पक्ष कीवर्ड बेसलाइन के लिए, पूरा सवाल chunks पर BM25 से भी चलाया गया, जिसमें आइडेंटिफ़ायर _ और camelCase पर तोड़े गए ताकि should_strip_auth "strip" से मेल खाए। (ऐप का कीवर्ड हिस्सा पूरी फ़ाइलों पर tantivy है, जिसे हर फ़ाइल के पहले chunks पर मैप किया जाता है; प्रति-chunk रैंकिंग एक सरलीकरण है।)
दोनों रैंकिंग को Reciprocal Rank Fusion से k = 60 पर मिलाया गया, वही स्थिरांक जो ऐप इस्तेमाल करता है, हर सूची से टॉप 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 स्कोर और कोसाइन समानता एक ही पैमाने पर नहीं हैं, और एक को दूसरे के हिसाब से नॉर्मलाइज़ करना ऐसा रिश्ता गढ़ना है जो है ही नहीं। एक सूची में तीसरे और दूसरी में पाँचवें नंबर का chunk उस chunk से आगे रहता है जो एक सूची में पहले नंबर पर है और दूसरी में है ही नहीं।
हर सवाल का जवाब वह chunk है जिसमें लक्ष्य फ़ंक्शन की def लाइन है, जिसे कुछ भी चलाने से पहले कोड पढ़कर चुना गया। बताया गया नंबर सभी 318 chunks में उस chunk की रैंक है।
नतीजे
| सवाल | grep वाक्यांश | grep एक शब्द | BM25 | bge-m3 | हाइब्रिड | bge-small |
|---|---|---|---|---|---|---|
| रीडायरेक्ट के बाद पासवर्ड दूसरी साइट को न भेजना (should_strip_auth) | "another site": 0 | password: 47 | 9 | 1 | 1 | 1 |
| रीडायरेक्ट के बाद POST GET क्यों बन जाता है (rebuild_method) | "turn into": 0 | GET: 625 | 65 | 10 | 19 | 6 |
| चक्कर काटते रीडायरेक्ट का पीछा करना बंद करना (resolve_redirects) | "redirect loop": 0 | loop: 6 | 5 | 28 | 5 | 14 |
| अपलोड को शुरुआत पर लौटाना ताकि दोबारा भेजा जा सके (rewind_body) | "back to the start": 0 | start: 54 | 1 | 4 | 2 | 20 |
| जो बॉडी हम भेजने वाले हैं वह कितनी बड़ी है (super_len) | "how big": 0 | big: 8 | 54 | 65 | कोई नहीं | 233 |
| लोकल नेटवर्क की मशीनों के लिए प्रॉक्सी छोड़ना (should_bypass_proxies) | "local network": 0 | local: 94 | 2 | 20 | 6 | 10 |
| प्रोसेस मर जाए तो भी फ़ाइल कभी आधी लिखी न रहे (atomic_open) | "half-written": 0 | half: 4 | 58 | 16 | 29 | 1 |
| JSON के पहले बाइट देखकर UTF-16 और UTF-32 में फ़र्क़ करना (guess_json_utf) | "first bytes": 0 | bytes: 98 | 4 | 4 | 5 | 111 |
| होम फ़ोल्डर की फ़ाइल से सेव किए लॉगिन पढ़ना (get_netrc_auth) | "home folder": 0 | login: 4 | 1 | 112 | 7 | 17 |
| सर्वर के 401 का जवाब hash किए पासवर्ड से देना (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 chunks में से हैं; grep की गिनती मेल खाती लाइनों की है। "हाइब्रिड" यानी BM25 को bge-m3 के साथ मिलाया गया।
तीन बातें उभरकर आती हैं।
grep का किसी वाक्यांश पर शून्य लौटाना यह नहीं बताता कि कीवर्ड बेकार हैं। सवाल के शब्दों पर रैंक करने पर BM25 ने 10 में से 6 में जवाब टॉप 5 में रखा। "Redirect", "proxy", "401" और "UTF-16" सब कोड में मौजूद हैं; बस उनके आसपास की भाषा नहीं मिलती। एक-शब्द वाली grep उलटी समस्या दिखाती हैं: GET के लिए 625 लाइनें, bytes के लिए 98। वे उसे किसी ढेर में कहीं खोज देती हैं जिसे फिर हाथ से पढ़ना पड़ता है।
Embeddings की जीत असली है, पर ख़ास तरह की है। bge-m3 ने ऐसे सवाल पर should_strip_auth को पहले नंबर पर पाया जिसके शब्द उससे मेल नहीं खाते, और rebuild_method को BM25 के 65वें नंबर से 10वें पर ले आया। छोटे मॉडल ने "never left half-written if the process dies" पर atomic_open को पहले नंबर पर रखा, जबकि BM25 में वह 58वें पर था; उस फ़ंक्शन की docstring में "atomic fashion" लिखा है, और मॉडल जानता है कि फ़ाइल के लिए atomic का मतलब क्या है। और वेक्टर ऐसे तरीकों से फ़ेल होते हैं जैसे कीवर्ड नहीं होते। सिर्फ़ आइडेंटिफ़ायर raise_for_status पर bge-m3 ने इसे परिभाषित करने वाला chunk 70वें नंबर पर रखा, और bge-small ने 244वें पर।
फ़्यूज़न ज़्यादा सुरक्षित डिफ़ॉल्ट है, हर पंक्ति में सबसे अच्छा नहीं। उस एक सवाल को छोड़कर जिसे कोई नहीं खोज पाया, हाइब्रिड कभी सबसे ख़राब कॉलम नहीं रहा और अक्सर सबसे अच्छे के क़रीब रहा, जो उस टूल से चाहिए जिसे आप बिना पहले जाने बुलाते हैं कि सवाल किस तरह का है। "How big is the body" हर जगह फ़ेल हुआ: super_len कभी उन शब्दों में नहीं बताता कि वह क्या मापता है जो कोई सवाल इस्तेमाल करेगा।
| टॉप 5 / टॉप 10 में हिट (10 सवाल) | BM25 | bge-m3 | हाइब्रिड (bge-m3) | bge-small | हाइब्रिड (bge-small) |
|---|---|---|---|---|---|
| पूरा रिपॉज़िटरी, 318 chunks | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 |
| सिर्फ़ src/, 84 chunks | 7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 |
टेस्ट और डॉक्स सबसे बड़े प्रतिस्पर्धी हैं
ज़्यादातर चूक बेतरतीब नहीं थीं। "why does a POST turn into a GET" पर bge-m3 का पहला नतीजा tests/test_requests.py था, test_http_303_changes_post_to_get के आसपास। "read saved logins from a file in the home folder" पर वह HISTORY.md में .netrc के बारे में changelog एंट्री थी, और "move the upload back to the start" और "how big is the body" दोनों पर docs/user/advanced.rst का एक सेक्शन। टेस्ट और डॉक्स व्यवहार को सादे शब्दों में बताते हैं, और सवाल भी ठीक वही होता है, इसलिए वे अक्सर इम्प्लीमेंटेशन से ज़्यादा सवाल के क़रीब होते हैं।
यह ग़लत जवाब नहीं है, क्योंकि टेस्ट फ़ंक्शन का नाम लेता है। पर उन्हें छानने पर रैंकिंग काफ़ी बदल जाती है। उन्हीं वेक्टरों के साथ सिर्फ़ src/ के 84 chunks में खोजने पर हाइब्रिड सर्च ने टॉप 10 में 10 में से 9 खोजे। इस रिपॉज़िटरी पर सबसे सस्ता सुधार बड़ा मॉडल नहीं था, बल्कि पाथ फ़िल्टर था, या कम से कम टेस्ट और डॉक्स की हिट पर लेबल लगाना ताकि उन्हें छोड़ा जा सके।
ज़्यादातर chunks के सिर्फ़ पहले 512 टोकन पढ़ने के बावजूद छोटा मॉडल टिका रहा, शायद इसलिए कि chunk की शुरुआती लाइनें, def और उसकी docstring के साथ, यह बताने वाली ज़्यादातर बात रखती हैं कि वह किस बारे में है। एक रिपॉज़िटरी पर दस सवाल किसी भी मॉडल को बेहतर कहने के लिए काफ़ी नहीं हैं। इतना देखने के लिए काफ़ी हैं कि मॉडल का चुनाव कीवर्ड के साथ फ़्यूज़ करने और टेस्ट को रास्ते से हटाने से कम मायने रखता था।
इसे चलाना
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.3 GB। अपनी क्वेरीज़ नतीजे देखने से पहले लिखें, और जवाब पहले कोड पढ़कर चुनें। किसी सवाल के शब्द तब तक बदलते रहना बहुत आसान है जब तक वेक्टर सर्च जीत न जाए।
osFoundry डेस्कटॉप ऐप यही पाइपलाइन अपनी Indexes स्क्रीन से चलाता है, bge-m3 डिवाइस पर और BM25 व वेक्टर अपने folder_search एजेंट टूल में फ़्यूज़ करके। उसकी ग्लॉसरी में chunking की एंट्री chunk के आकार और ओवरलैप के समझौतों की बात करती है, और ऐप osFoundry डाउनलोड पेज से मुफ़्त है।
La recherche sémantique dans le code bat-elle vraiment grep sur un dépôt réel ?
L'argument pour indexer un code avec des embeddings est toujours la même démo : on demande « où bloque-t-on les utilisateurs ? », grep ne trouve rien parce que le code parle de cooldown, et la recherche vectorielle tombe sur la bonne fonction. C'est une bonne démo. La question est de savoir à quelle fréquence elle tient sur du code que personne n'a choisi pour la démo.
Voici donc une mesure sur un dépôt que la plupart des développeurs Python ont lu un jour, psf/requests au tag v2.32.3, avec dix questions pour lesquelles grep sur l'expression évidente ne renvoie aucune ligne. Le pipeline reprend celui que l'application de bureau osFoundry utilise pour son index de dossiers locaux (règles de découpage de folder_index.rs, classement hybride de folder_search.rs), reconstruit en moins de 200 lignes de Python et exécuté sur un Mac M1 de 8 Go, CPU uniquement. La réponse courte : les embeddings seuls ont trouvé le bon morceau dans le top 5 pour 4 questions sur 10. Un classement par mots-clés simple de la même question en a trouvé 6. Les deux fusionnés en ont trouvé 7 dans le top 10, et 9 une fois que les tests et la documentation ont cessé de concurrencer le code source.
Découpage avec numéros de ligne
Les embeddings travaillent sur des morceaux, pas sur des fichiers. L'application découpe chaque fichier en chunks d'environ 700 tokens avec un chevauchement de 80 tokens, en approximant un token à 4 caractères, et conserve la première et la dernière ligne de chaque chunk pour qu'un résultat puisse être indiqué sous la forme 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
Les chunks sont coupés aux limites de ligne, jamais au milieu d'une ligne, et la dernière garde compte : un fichier minifié avec une seule ligne de 200 Ko reviendrait sinon indéfiniment au même début.
L'analyse préalable ignore node_modules, dist, build, .git, .venv et les dossiers similaires, les lockfiles par leur nom complet (package-lock.json et pnpm-lock.yaml ne se terminent pas par .lock, ce qui explique qu'un filtre sur la seule extension les rate), les fichiers de plus de 1 Mo et tout ce qui contient un octet nul dans ses 512 premiers octets. Sur requests, il restait 117 fichiers sur 129 (10 binaires, 1 de plus de 1 Mo, 1 SVG exclu), 625 261 octets et 318 chunks.
La règle des 4 caractères cache une chose : le code se tokenise moins bien que la prose. Mesuré avec le tokenizer propre à bge-m3, le chunk médian faisait 771 tokens, pas 700, et le plus long 2 050. Cela compte pour l'étape suivante.
Calculer les embeddings en local
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)
Les scripts s'exécutent avec uv run, qui installe sentence-transformers et torch à partir des métadonnées en ligne en tête de chaque fichier. Pour comparer, les mêmes chunks sont aussi passés dans bge-small-en-v1.5, un modèle anglais de 33 M de paramètres environ 17 fois plus petit (ses requêtes reçoivent le préfixe recommandé par le modèle, « Represent this sentence for searching relevant passages: »).
| | bge-m3 | bge-small-en-v1.5 |
|---|---|---|
| Taille du vecteur | 1 024 | 384 |
| Entrée maximale | 8 192 tokens (plafonnée à 2 048 ici) | 512 tokens |
| Chunks tronqués | 1 sur 318 | 235 sur 318 |
| Temps pour calculer 318 chunks (M1, CPU) | 631 s | 20,5 s |
| Mémoire maximale | 1,6 Go | non mesurée |
| Vecteurs sur disque (float32) | 1,3 Mo | 0,49 Mo |
| Embedding d'une requête | 682 ms | 21 ms |
Dix minutes pour un dépôt de 625 Ko, c'est lent, environ 2 secondes par chunk sur cette machine, et le coût suit la quantité de code, pas le nombre de fichiers. C'est un coût unique : l'application identifie les chunks par un hash de leur texte, donc un chunk inchangé garde son vecteur, et modifier une fonction ne recalcule qu'une poignée de chunks. La vitesse du petit modèle a une contrepartie que le tableau montre clairement : à 512 tokens, il n'a lu que le début de 235 des 318 chunks.
Une règle est signalée par un marqueur rouge dans le code source de l'application, et elle est facile à enfreindre dans son propre code. La requête doit être transformée en embedding par le même modèle que celui qui a construit l'index. Un vecteur de chunk bge-m3 et un vecteur de requête d'un autre modèle à 1 024 dimensions ont la même forme, donc aucune erreur n'est levée, et les résultats sont tout simplement faux.
Mots-clés, vecteurs, et leur fusion
grep a reçu l'expression qu'on taperait, sans tenir compte de la casse, sur les mêmes 117 fichiers. Pour une référence par mots-clés plus équitable, la question entière est aussi passée dans BM25 sur les chunks, avec les identifiants découpés sur _ et le camelCase pour que should_strip_auth corresponde à « strip ». (Le volet mots-clés de l'application est tantivy sur des fichiers entiers, reporté sur les premiers chunks de chaque fichier ; le classement par chunk est une simplification.)
Les deux classements sont fusionnés avec Reciprocal Rank Fusion à k = 60, la même constante que dans l'application, en prenant les 30 premiers de chaque liste :
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 ignore les scores et n'utilise que les positions, et c'est tout l'intérêt. Un score BM25 et une similarité cosinus ne sont pas sur la même échelle, et normaliser l'un par rapport à l'autre invente une relation qui n'existe pas. Un chunk classé 3e dans une liste et 5e dans l'autre bat un chunk classé 1er dans l'une et absent de l'autre.
La réponse pour chaque question est le chunk qui contient la ligne def de la fonction visée, choisie en lisant le code avant de lancer quoi que ce soit. Le nombre indiqué est le rang de ce chunk parmi les 318.
Résultats
| Question | expression grep | un mot grep | BM25 | bge-m3 | Hybride | bge-small |
|---|---|---|---|---|---|---|
| ne pas envoyer le mot de passe à un autre site après une redirection (should_strip_auth) | "another site": 0 | password: 47 | 9 | 1 | 1 | 1 |
| pourquoi un POST devient un GET après une redirection (rebuild_method) | "turn into": 0 | GET: 625 | 65 | 10 | 19 | 6 |
| arrêter de suivre les redirections qui tournent en boucle (resolve_redirects) | "redirect loop": 0 | loop: 6 | 5 | 28 | 5 | 14 |
| remettre l'envoi au début pour pouvoir le renvoyer (rewind_body) | "back to the start": 0 | start: 54 | 1 | 4 | 2 | 20 |
| quelle taille fait le corps que l'on va envoyer (super_len) | "how big": 0 | big: 8 | 54 | 65 | aucun | 233 |
| contourner le proxy pour les machines du réseau local (should_bypass_proxies) | "local network": 0 | local: 94 | 2 | 20 | 6 | 10 |
| s'assurer qu'un fichier n'est jamais à moitié écrit si le processus meurt (atomic_open) | "half-written": 0 | half: 4 | 58 | 16 | 29 | 1 |
| distinguer UTF-16 d'UTF-32 d'après les premiers octets du JSON (guess_json_utf) | "first bytes": 0 | bytes: 98 | 4 | 4 | 5 | 111 |
| lire des identifiants enregistrés dans un fichier du dossier personnel (get_netrc_auth) | "home folder": 0 | login: 4 | 1 | 112 | 7 | 17 |
| répondre au 401 du serveur avec un mot de passe haché (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 |
Les rangs sont sur 318 chunks ; les nombres de grep sont des lignes correspondantes. « Hybride » désigne BM25 fusionné avec bge-m3.
Trois points ressortent.
Une expression qui renvoie zéro avec grep ne veut pas dire que les mots-clés sont inutiles. Classé sur les mots de la question, BM25 a placé la réponse dans le top 5 pour 6 des 10. « Redirect », « proxy », « 401 » et « UTF-16 » figurent tous dans le code ; seule la formulation autour ne correspond pas. Les recherches grep sur un seul mot montrent le problème inverse : 625 lignes pour GET, 98 pour bytes. Elles le trouvent quelque part dans un tas qu'il faut ensuite lire à la main.
Les gains des embeddings sont réels mais ciblés. bge-m3 a trouvé should_strip_auth en premier pour une question qui ne partage aucun mot avec lui, et a fait passer rebuild_method de la 65e place avec BM25 à la 10e. Le petit modèle a mis atomic_open en premier pour « never left half-written if the process dies », là où BM25 le plaçait 58e ; la docstring de cette fonction dit « atomic fashion », et le modèle sait ce que signifie atomique pour un fichier. Et les vecteurs échouent d'une façon que les mots-clés ne connaissent pas. Sur l'identifiant brut raise_for_status, bge-m3 a classé le chunk qui le définit 70e, et bge-small 244e.
La fusion est le choix par défaut le plus sûr, pas le meilleur sur chaque ligne. À part la question que rien n'a trouvée, l'hybride n'a jamais été la pire colonne et était généralement proche de la meilleure, ce qu'on attend d'un outil qu'on appelle sans savoir à l'avance de quel type de question il s'agit. « How big is the body » a échoué partout : super_len ne dit jamais ce qu'elle mesure avec des mots qu'une question emploierait.
| Réussites top 5 / top 10 (10 questions) | BM25 | bge-m3 | Hybride (bge-m3) | bge-small | Hybride (bge-small) |
|---|---|---|---|---|---|
| Dépôt entier, 318 chunks | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 |
| src/ seulement, 84 chunks | 7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 |
Les tests et la documentation sont le principal concurrent
La plupart des échecs n'étaient pas aléatoires. Pour « why does a POST turn into a GET », le premier résultat de bge-m3 était tests/test_requests.py, autour de test_http_303_changes_post_to_get. Pour « read saved logins from a file in the home folder », c'était l'entrée du changelog sur .netrc dans HISTORY.md, et pour « move the upload back to the start » comme pour « how big is the body », une section de docs/user/advanced.rst. Les tests et la documentation décrivent le comportement avec des mots simples, ce qui est exactement ce qu'est une question, et sont donc souvent plus proches d'elle que l'implémentation.
Ce n'est pas une mauvaise réponse, puisque le test nomme la fonction. Mais le classement change beaucoup quand on les filtre. En cherchant uniquement dans les 84 chunks de src/ avec les mêmes vecteurs, la recherche hybride en a trouvé 9 sur 10 dans le top 10. L'amélioration la moins chère sur ce dépôt n'a pas été un modèle plus gros mais un filtre sur le chemin, ou au moins un marquage des résultats de tests et de documentation pour pouvoir les écarter.
Le petit modèle a tenu le rythme alors qu'il ne lisait que les 512 premiers tokens de la plupart des chunks, probablement parce que les premières lignes d'un chunk, avec le def et sa docstring, portent l'essentiel de son sujet. Dix questions sur un seul dépôt ne suffisent pas pour déclarer l'un des modèles meilleur. Elles suffisent pour voir que le choix du modèle comptait moins que la fusion avec les mots-clés et l'éloignement des tests.
L'exécuter
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
La première exécution de embed.py télécharge bge-m3 depuis Hugging Face, environ 2,3 Go. Il vaut mieux écrire ses requêtes avant de regarder les résultats et choisir les réponses en lisant d'abord le code. Il est très facile d'ajuster la formulation d'une question jusqu'à ce que la recherche vectorielle gagne.
L'application de bureau osFoundry exécute le même pipeline depuis son écran Indexes, avec bge-m3 sur l'appareil et BM25 et vecteurs fusionnés dans son outil d'agent folder_search. L'entrée sur le chunking de son glossaire traite des compromis sur la taille et le chevauchement des chunks, et l'application est gratuite sur la page de téléchargement d'osFoundry.
Does semantic code search actually beat grep on a real repo?
The pitch for embedding a codebase is always the same demo: you ask "where do we lock users out?", grep finds nothing because the code calls it a cooldown, and vector search lands on the right function. It is a good demo. The question is how often it holds up on code nobody picked for the demo.
So here is a measurement on a repository most Python developers have read at some point, psf/requests at tag v2.32.3, with ten questions where grep for the obvious phrase returns zero lines. The pipeline copies the one the osFoundry desktop app uses for its local folder index (chunking rules from folder_index.rs, hybrid ranking from folder_search.rs), rebuilt in under 200 lines of Python and run on an 8 GB M1 Mac, CPU only. The short answer: embeddings alone found the right chunk in the top 5 for 4 of 10 questions. A plain keyword ranking of the same question found 6. The two fused together found 7 in the top 10, and 9 once tests and docs stopped competing with the source.
Chunking with line numbers
Embeddings work on pieces, not files. The app cuts each file into chunks of about 700 tokens with an 80-token overlap, approximating a token as 4 characters, and keeps the first and last line of every chunk so a hit can be reported as 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
Chunks are cut on line boundaries, never mid-line, and the last guard matters: a minified file with one 200 KB line would otherwise step back to the same start forever.
The scan before it skips node_modules, dist, build, .git, .venv and similar folders, lockfiles by full name (package-lock.json and pnpm-lock.yaml don't end in .lock, which is how an extension-only filter misses them), files over 1 MB, and anything with a null byte in its first 512 bytes. On requests that left 117 of 129 files (10 binary, 1 over 1 MB, 1 excluded SVG), 625,261 bytes, and 318 chunks.
One thing the 4-characters rule hides: code tokenizes worse than prose. Measured with bge-m3's own tokenizer, the median chunk was 771 tokens, not 700, and the longest was 2,050. That matters for the next step.
Embedding locally
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)
The scripts run with uv run, which installs sentence-transformers and torch from the inline metadata at the top of each file. For comparison, the same chunks also went through bge-small-en-v1.5, a 33M-parameter English model about 17 times smaller (its queries get the model's recommended "Represent this sentence for searching relevant passages: " prefix).
| | bge-m3 | bge-small-en-v1.5 |
|---|---|---|
| Vector size | 1,024 | 384 |
| Max input | 8,192 tokens (capped at 2,048 here) | 512 tokens |
| Chunks cut short | 1 of 318 | 235 of 318 |
| Time to embed 318 chunks (M1, CPU) | 631 s | 20.5 s |
| Peak memory | 1.6 GB | not measured |
| Vectors on disk (float32) | 1.3 MB | 0.49 MB |
| Embedding one query | 682 ms | 21 ms |
Ten minutes for a 625 KB repository is slow, about 2 seconds per chunk on this machine, and the cost scales with the code, not the number of files. It is a one-time cost: the app keys chunks by a hash of their text, so an unchanged chunk keeps its vector, and editing one function re-embeds a handful of chunks. The small model's speed comes with a catch the table shows plainly: at 512 tokens it read only the first part of 235 of the 318 chunks.
There is one rule the app's source flags with a red marker, and it is easy to break in your own code. The query has to be embedded by the same model that built the index. A bge-m3 chunk vector and a query vector from another 1,024-dimensional model have the same shape, so nothing errors, and the results are simply wrong.
Keywords, vectors, and fusing them
grep was given the phrase you would type, case-insensitive, over the same 117 files. As a fairer keyword baseline, the whole question also went through BM25 over the chunks, with identifiers split on _ and camelCase so that should_strip_auth matches "strip". (The app's keyword side is tantivy over whole files, mapped onto each file's first chunks; ranking per chunk is a simplification.)
The two rankings are merged with Reciprocal Rank Fusion at k = 60, the same constant the app uses, taking the top 30 from each list:
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 ignores the scores and only uses positions, which is the point. A BM25 score and a cosine similarity are not on the same scale, and normalising one against the other invents a relationship that isn't there. A chunk ranked 3rd by one list and 5th by the other beats a chunk ranked 1st by one and absent from the other.
The answer for each question is the chunk containing the target function's def line, chosen by reading the code before running anything. The number reported is that chunk's rank among all 318.
Results
| Question | grep phrase | grep one word | BM25 | bge-m3 | Hybrid | 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 | none | 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 |
Ranks are out of 318 chunks; grep counts are matching lines. "Hybrid" is BM25 fused with bge-m3.
Three things stand out.
A phrase grep returning zero does not mean keywords are useless. Ranked over the question's words, BM25 put the answer in the top 5 for 6 of the 10. "Redirect", "proxy", "401" and "UTF-16" all appear in the code; only the phrasing around them doesn't. The single-word greps show the opposite problem: 625 lines for GET, 98 for bytes. They find it somewhere in a pile you then read by hand.
The embedding wins are real but specific. bge-m3 found should_strip_auth first for a question that shares no wording with it, and moved rebuild_method from 65th under BM25 to 10th. The small model put atomic_open first for "never left half-written if the process dies", where BM25 had it 58th; that function's docstring says "atomic fashion", and the model knows what atomic means for a file. And vectors fail in ways keywords don't. On the bare identifier raise_for_status, bge-m3 ranked the defining chunk 70th, and bge-small 244th.
Fusion is the safer default, not the best on every row. Apart from the one question nothing found, hybrid was never the worst column and usually close to the best one, which is what you want from a tool you call without knowing in advance which kind of question you have. "How big is the body" failed everywhere: super_len never says what it measures in words a question would use.
| Top-5 / top-10 hits (10 questions) | BM25 | bge-m3 | Hybrid (bge-m3) | bge-small | Hybrid (bge-small) |
|---|---|---|---|---|---|
| Whole repo, 318 chunks | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 |
| src/ only, 84 chunks | 7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 |
Tests and docs are the biggest competitor
Most of the misses were not random. For "why does a POST turn into a GET", bge-m3's first result was tests/test_requests.py, around test_http_303_changes_post_to_get. For "read saved logins from a file in the home folder" it was the changelog entry about .netrc in HISTORY.md, and for both "move the upload back to the start" and "how big is the body" a section of docs/user/advanced.rst. Tests and docs describe behaviour in plain words, which is exactly what a question is, so they are often closer to it than the implementation.
That is not a wrong answer, since the test names the function. But the ranking changes a lot once they are filtered out. Searching only the 84 chunks under src/ with the same vectors, hybrid search found 9 of 10 in the top 10. The cheapest improvement on this repo was not a bigger model but a path filter, or at least labelling test and doc hits so they can be skipped.
The smaller model kept up despite reading only the first 512 tokens of most chunks, probably because a chunk's opening lines, with the def and its docstring, carry most of what it is about. Ten questions on one repository is not enough to call either model better. It is enough to see that the choice of model mattered less than fusing with keywords and keeping tests out of the way.
Running it
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
The first embed.py run downloads bge-m3 from Hugging Face, about 2.3 GB. Write your queries before looking at the results, and pick the answers by reading the code first. It is very easy to tune the wording of a question until the vector search wins.
The osFoundry desktop app does the same pipeline from its Indexes screen, with bge-m3 on the device and BM25 and vectors fused in its folder_search agent tool. The chunking entry in its glossary covers the trade-offs of chunk size and overlap, and the app is free from the osFoundry download page.
Open Source Didn't Ban AI Code. 83% of Its AI Policies Allow It, With One Catch
- Book: Prompt Engineering Pocket Guide
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
July was the month open source "banned AI." On July 22, Codeberg members voted 358 to 144 to bar projects built mostly from AI-generated code (It's FOSS). About a week later, the GCC Steering Committee said it won't take a legally significant contribution if any part of it traces back to an LLM (It's FOSS).
Read the GCC rule to the end, though. Contributions under the legal-significance threshold (roughly 15 lines) can still come in if they are clearly marked, test cases are exempt, and AI-assisted work carries an Assisted-by: tag in the commit message. Even the "ban" is mostly a set of conditions.
Then look past the two headlines at the whole field. In September, Andre Hora, Romain Robbes and Stefano Zacchiroli published ["We Permit the Use of AI, but [...]"](arxiv.org), a study of 281 AI policies from the 2,000 most-starred GitHub repositories plus 36 well-known projects and organizations such as the Linux kernel, LLVM and the Apache Software Foundation. The title is the finding.
The numbers say "yes, but"
From the paper's results:
- 83.3% of policies permit or encourage AI in code contributions (76.2% permit, 7.1% encourage).
- 14.9% forbid AI in code. Only 5.3% forbid it for every kind of contribution.
- 67.3% require a high level of human involvement.
- 43.4% make the contributor accountable for the result. None of the 281 say accountability is optional.
- 48.8% require you to disclose AI use. Of the disclosure rules that say where, 64.7% want it in the PR description and 26.7% in the commit message.
So a ban is the minority position. What the majority asks for is a human who was there: someone who directed the work, read it, and will answer for it. The paper quotes the policy of psf/requests on this: "you must remove any LLM co-author tags and take full ownership of every line."
One thing the study leaves open, and the authors list it as future work: whether anyone actually follows these policies.
What maintainers actually do with slop
The paper counts 188 countermeasures against low-quality AI contributions across the policies. The breakdown:
| Countermeasure | Share |
|---|---|
| Close the PR | 48.4% |
| Ban or block the user | 20.8% |
| Disallow autonomous agents | 13.8% |
| Restrict new or external users | 4.8% |
| Everything else (six measures) | 12.2% |
Nearly half of all measures are the same move: close it. The rqlite policy says slop PRs "may be closed without comment," per the paper. rust-analyzer goes further and says it does not allow autonomous agents to open pull requests or issues at all.
Some projects now address the agent directly. The paper finds policies in AGENTS.md and CLAUDE.md as well as CONTRIBUTING.md and AI_POLICY.md. The contributing guide of llama.cpp tells fully autonomous agents not to contribute and to exclude the repo from their configuration.
My take: they banned the unread diff, not the model
Put the two findings side by side. Most projects accept AI code. The most common response to bad AI code is closing the PR. The deciding factor between those two outcomes is whether a human read the change before a maintainer had to.
A closed PR is a maintainer telling you the review didn't happen on your side, so it isn't going to happen on theirs. Reviewing a large agent diff costs real time, and maintainers are often volunteers. The policies push that cost back to the sender, which is fair. The diff can be cheap to generate. Someone still has to understand it, and the 67.3% requiring high human involvement is the field agreeing on who that someone is.
That moves the review upstream. If the reading has to happen, it should happen on your machine, before the PR exists. Once you open it, every unread line is a maintainer's problem.
Three steps before you open the PR
1. Read the project's policy first. It might live in a file you don't expect:
ls AI_POLICY.md LLM_POLICY.md AGENTS.md CLAUDE.md \
CONTRIBUTING.md 2>/dev/null
grep -inwE "ai|llm|agent|copilot" CONTRIBUTING.md \
2>/dev/null
Then follow its format. There isn't one standard. The paper notes RustPython asks for Assisted-by and not Co-authored-by, Nextcloud requires an Assisted-by trailer, and psf/requests wants LLM co-author tags gone entirely.
2. Read the change step by step. One big diff hides the step where the agent rewrote a file you didn't ask about. Start with the summary, then walk the branch commit by commit, oldest first:
git diff main...HEAD --stat
git log -p --reverse main..HEAD
The --stat line shows the file you didn't ask about. The log shows each step in order. If you can't explain a hunk, ask the agent to undo it or redo it smaller. "Full ownership of every line" starts here.
3. Disclose where the project asks. Git has a flag for trailers (git docs):
git commit -m "Fix off-by-one in pagination" \
--trailer "Assisted-by: Claude Code"
Then say it in the PR description too, since that's where most disclosure rules point: which agent, what it did, what you checked by hand.
Doing the reading in Hermes IDE
I build Hermes IDE, so weigh this section with that in mind. Everything above works with plain git. Hermes is built around that order of operations: the agent works and you read before you land.
Every agent turn is saved, so you can diff or restore any single turn, with undo. The Review Desk (⌘G, or Ctrl+Shift+G on Windows and Linux) shows the changes by turn or by file, with risk flags, and a line comment goes back to the agent that wrote that line. That's the "read it turn by turn" step without scrolling one long diff (README).
!The Hermes Review Desk in By turn view, listing two turns of a Claude Code task with turn 1's diff of src/db.ts and a drafted commit message
The Review Desk (⌘G) in By turn view: two turns of a Claude Code task, turn 1's diff of src/db.ts, the changed files, and a commit message drafted from the turns.
Only after that does Land come in. It commits, opens a pull request or merges locally, into the branch the task started from, and it can be undone. Check that the commit message carries the trailer your project asks for before you land. Each task also runs on its own hermes/ branch and worktree, so the PR holds one task's work and nothing from the agent next door.
You can download Hermes. It is source-available under BSL 1.1 and runs on macOS, Windows and Linux. It runs Claude Code, Codex and other agents in their own terminals, with your own sign-ins. It isn't affiliated with Nous Research or its Hermes Agent. It can't make an agent's change good, but it gives you a place to check the change first.
Where do you draw the line?
If you maintain a project, does your policy say what "human involvement" means in practice? If you contribute, do you disclose the agent by default, or only when a project asks? Tell me in the comments.
---
If this was useful
Projects now write their AI rules into AGENTS.md and CLAUDE.md, which means a contribution policy is also a prompt. The Prompt Engineering Pocket Guide covers how to write instructions an agent actually follows, whether you're the maintainer writing them or the contributor whose agent reads them. If you'd rather review agent work turn by turn, Hermes IDE is the tool I build for exactly that.

You guys always ask "What's the lore?" whenever you're looking to buy a coin 👀
Now, there’s a coin with legendary lore, one that’s already in use and helping millions of dev.
Just imagine what happens when CT discovers $requests 🤯
psf/requests t.co
6 likes · 1 repost · 208 views
Ruff vs Black: is the Black Python formatter finished?
Is Black, the Python formatter, finished now that Ruff exists?
Ruff's formatter matches Black on over 99.9% of lines in big Black-formatted projects and ships far more often, but Black still releases. Chad and Neil check both repos and Ruff's formatter docs.
Sources:
- GitHub API: astral-sh/ruff and psf/black repositories, releases and commits (queried Oct 19, 2026) — astral-sh/ruff
- Ruff README: README.md (main, Oct 2026) — github.com/astral-sh/ruff/blob/main/README.md
- Black README: README.md (main, Oct 2026) — github.com/psf/black/blob/main/README.md
- Ruff docs: The Ruff Formatter — docs.astral.sh
Chad and Neil are animated characters. Voices are synthetic (text-to-speech).
Neil's voice: generated with Kyutai TTS 1.6B by Kyutai (CC BY 4.0, creativecommons.org), using speaker p345 from the CSTR VCTK Corpus v0.92 by Junichi Yamagishi, Christophe Veaux and Kirsten MacDonald, The Centre for Speech Technology Research, University of Edinburgh (CC BY 4.0, doi.org). The generated audio was edited (levelled, equalised and mixed).
Chad's voice: Kokoro-82M by hexgrad (Apache-2.0).
Sound effects: original.
Music: "Washed Up" by HoliznaCC0 (Free Music Archive), CC0 1.0 (public domain).
#shorts #python #ruff
ep:C04
34 views
PostHog; Black: Uncompromising Python code formatter | GitHub Curated Picks | 2026-10-25
Chapters:
00:00 2026-10-25 | GitHub Curated Picks
00:05 Pick #1 PostHog
00:19 Pick #2 Black
GitHub Curated Picks reviews 2 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption.
Published: 2026-10-25
Review window: daily
Curated repository picks:
- Pick #1 PostHog | Latest commit | feat(mcp): return condition_id from feature flag read tools (reference | PostHog/posthog
- Pick #2 Black | Latest commit | Recent commit verified | psf/black
Chapters: 00:00 2026-10-25 | GitHub Curated Picks 00:05 Pick #1 PostHog 00:19 Pick #2 Black 00:40 Watch Next 00:47 Educational Use Only
Top comment prompt: Which repository would you verify first, and what release or commit evidence matters most?
Source ledger:
Direct source URLs:
- github.com/trending
- api.github.com
- PostHog/posthog
- github.com/PostHog/posthog/releases/tag/hobby-lat…
- github.com/PostHog/posthog/commit/8c626c87f0c85cb…
- api.github.com
- psf/black
- github.com/psf/black/releases/tag/26.10.1
- github.com/psf/black/commit/80db630ac532f0e2f3ba3…
Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance.
Subscriber reason:
Subscribe for the daily AI, GitHub, and market board.
Tomorrow watchlist: Tomorrow, check the next tagged release, recent commit, and issue-health signals for the selected projects.
1 view
Black: Uncompromising Python code formatter; Coolify; OpenRefine | GitHub Curated Picks | 2026-10-24
Chapters:
00:00 2026-10-24 | GitHub Curated Picks
00:05 Pick #1 Black
00:25 Pick #2 Coolify
00:47 Pick #3 OpenRefine
GitHub Curated Picks reviews 3 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption.
Published: 2026-10-24
Review window: daily
Curated repository picks:
- Pick #1 Black | Latest commit | Fix crash on a fmt: skip header containing an inner skipped | psf/black
- Pick #2 Coolify | Latest commit | fix(applications): report a failed stop instead of failing silently | coollabsio/coolify
- Pick #3 OpenRefine | Latest commit | Fix absolute command URLs for path-prefixed installs (reference 7986) | OpenRefine/OpenRefine
Chapters: 00:00 2026-10-24 | GitHub Curated Picks 00:05 Pick #1 Black 00:25 Pick #2 Coolify 00:47 Pick #3 OpenRefine 01:02 Watch Next 01:09 Educational Use Only
Top comment prompt: Which repository would you verify first, and what release or commit evidence matters most?
Source ledger:
Direct source URLs:
- github.com/trending
- api.github.com
- github.com/psf/black/blob/main/README.md
- github.com/psf/black/releases/tag/26.10.1
- github.com/psf/black/commit/55996d1fb8342725c8a19…
- psf/black
- api.github.com
- coollabsio/coolify
- github.com/coollabsio/coolify/releases/tag/v4.5.0
- github.com/coollabsio/coolify/commit/e49e540ec0d6…
- api.github.com
- OpenRefine/OpenRefine
- github.com/OpenRefine/OpenRefine/releases/tag/3.1…
- github.com/OpenRefine/OpenRefine/commit/d66a10ee5…
Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance.
Subscriber reason:
Subscribe for the daily AI, GitHub, and market board.
Tomorrow watchlist: Tomorrow, check the next tagged release, recent commit, and issue-health signals for the selected projects.
11 views
Black: an interrupted check is not a completed check: source review
Black: an interrupted check is not a completed check — Official Python code formatter repository and README, live and active.
Do your checks distinguish interruption from success? Review the source before trusting an interrupted run.
Sources:
psf/black
black.readthedocs.io
black#5541
#python #black #opensource
47 views
Mentions
コードを意味で検索するとgrepより見つかるのか、requestsのリポジトリで測ってみた コードベースを埋め込みで検索する話には、いつも同じデモが付いてきます。「ユーザーをロックしているのはどこ?」と聞くと、コードでは 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のダウンロードページから無料で入手できます。
क्या सिमैंटिक कोड सर्च असली रिपॉज़िटरी पर सच में grep से बेहतर है? कोडबेस को embed करने का पिच हमेशा एक ही डेमो होता है: आप पूछते हैं "यूज़र्स को लॉक आउट कहाँ करते हैं?", grep को कुछ नहीं मिलता क्योंकि कोड उसे cooldown कहता है, और वेक्टर सर्च सीधे सही फ़ंक्शन पर पहुँच जाता है। डेमो अच्छा है। सवाल यह है कि ऐसे कोड पर यह कितनी बार टिकता है जिसे किसी ने डेमो के लिए चुना ही नहीं। इसलिए यहाँ एक ऐसे रिपॉज़िटरी पर माप है जिसे ज़्यादातर Python डेवलपर्स ने कभी न कभी पढ़ा है: psf/requests, टैग v2.32.3 पर, दस सवालों के साथ जिनके लिए साफ़ दिखने वाले वाक्यांश पर grep शून्य लाइनें लौटाता है। पाइपलाइन वही है जो osFoundry डेस्कटॉप ऐप अपने लोकल फ़ोल्डर इंडेक्स के लिए इस्तेमाल करता है (folder_index.rs के chunking नियम, folder_search.rs की हाइब्रिड रैंकिंग), जिसे 200 से कम लाइनों के Python में दोबारा बनाया गया और 8 GB के M1 Mac पर, सिर्फ़ CPU से चलाया गया। संक्षिप्त जवाब: अकेले embeddings ने 10 में से 4 सवालों में सही chunk टॉप 5 में खोजा। उसी सवाल की सादा कीवर्ड रैंकिंग ने 6 खोजे। दोनों को जोड़ने पर टॉप 10 में 7 मिले, और जब टेस्ट और डॉक्स सोर्स कोड से होड़ करना बंद कर चुके तब 9। लाइन नंबर के साथ chunking Embeddings फ़ाइलों पर नहीं, टुकड़ों पर काम करते हैं। ऐप हर फ़ाइल को लगभग 700 टोकन के chunks में काटता है, 80 टोकन के ओवरलैप के साथ, एक टोकन को 4 अक्षर मानकर, और हर chunk की पहली और आख़िरी लाइन रखता है ताकि हिट को 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 Chunks लाइन की सीमाओं पर कटते हैं, लाइन के बीच में कभी नहीं, और आख़िरी गार्ड मायने रखता है: 200 KB की एक ही लाइन वाली minified फ़ाइल वरना उसी शुरुआत पर हमेशा लौटती रहती। इससे पहले का स्कैन node_modules, dist, build, .git, .venv और ऐसे फ़ोल्डर छोड़ देता है, lockfiles को पूरे नाम से (package-lock.json और pnpm-lock.yaml .lock पर ख़त्म नहीं होतीं, इसी वजह से सिर्फ़ एक्सटेंशन वाला फ़िल्टर उन्हें चूक जाता है), 1 MB से बड़ी फ़ाइलें, और कोई भी फ़ाइल जिसके पहले 512 बाइट में null बाइट हो। requests पर इसके बाद 129 में से 117 फ़ाइलें बचीं (10 बाइनरी, 1 जो 1 MB से बड़ी, 1 बाहर रखी गई SVG), 625,261 बाइट, और 318 chunks। 4 अक्षर वाला नियम एक बात छिपाता है: कोड का tokenization गद्य से ख़राब होता है। bge-m3 के अपने tokenizer से मापने पर, मध्यक chunk 700 नहीं बल्कि 771 टोकन का था, और सबसे लंबा 2,050 का। अगले चरण में यह मायने रखता है। Embeddings लोकल में बनाना 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 से चलती हैं, जो हर फ़ाइल के ऊपर दिए inline मेटाडेटा से sentence-transformers और torch इंस्टॉल करता है। तुलना के लिए वही chunks bge-small-en-v1.5 से भी गुज़रे, जो 33M पैरामीटर वाला अंग्रेज़ी मॉडल है और लगभग 17 गुना छोटा है (इसकी क्वेरीज़ में मॉडल का सुझाया प्रीफ़िक्स "Represent this sentence for searching relevant passages: " लगाया गया)। | | bge-m3 | bge-small-en-v1.5 | |---|---|---| | वेक्टर का आकार | 1,024 | 384 | | अधिकतम इनपुट | 8,192 टोकन (यहाँ 2,048 पर सीमित) | 512 टोकन | | कटे हुए chunks | 318 में से 1 | 318 में से 235 | | 318 chunks को embed करने का समय (M1, CPU) | 631 s | 20.5 s | | पीक मेमोरी | 1.6 GB | मापा नहीं | | डिस्क पर वेक्टर (float32) | 1.3 MB | 0.49 MB | | एक क्वेरी को embed करना | 682 ms | 21 ms | 625 KB के रिपॉज़िटरी के लिए दस मिनट धीमा है, इस मशीन पर लगभग 2 सेकंड प्रति chunk, और लागत कोड की मात्रा के साथ बढ़ती है, फ़ाइलों की गिनती के साथ नहीं। यह एक बार की लागत है: ऐप chunks को उनके टेक्स्ट के hash से कुंजीबद्ध करता है, इसलिए बिना बदला chunk अपना वेक्टर रखता है, और एक फ़ंक्शन बदलने पर मुट्ठी भर chunks ही दोबारा embed होते हैं। छोटे मॉडल की रफ़्तार के साथ एक पेच है जो टेबल साफ़ दिखाती है: 512 टोकन पर उसने 318 में से 235 chunks का सिर्फ़ शुरुआती हिस्सा पढ़ा। एक नियम है जिसे ऐप का सोर्स लाल मार्कर से चिह्नित करता है, और अपने कोड में इसे तोड़ देना आसान है। क्वेरी को उसी मॉडल से embed करना होगा जिसने इंडेक्स बनाया। bge-m3 chunk का वेक्टर और किसी दूसरे 1,024-डायमेंशन मॉडल की क्वेरी का वेक्टर एक ही आकार के होते हैं, इसलिए कोई एरर नहीं आता, और नतीजे बस ग़लत होते हैं। कीवर्ड, वेक्टर, और उन्हें जोड़ना grep को वही वाक्यांश दिया गया जो आप टाइप करते, केस-इनसेंसिटिव, उन्हीं 117 फ़ाइलों पर। ज़्यादा निष्पक्ष कीवर्ड बेसलाइन के लिए, पूरा सवाल chunks पर BM25 से भी चलाया गया, जिसमें आइडेंटिफ़ायर _ और camelCase पर तोड़े गए ताकि should_strip_auth "strip" से मेल खाए। (ऐप का कीवर्ड हिस्सा पूरी फ़ाइलों पर tantivy है, जिसे हर फ़ाइल के पहले chunks पर मैप किया जाता है; प्रति-chunk रैंकिंग एक सरलीकरण है।) दोनों रैंकिंग को Reciprocal Rank Fusion से k = 60 पर मिलाया गया, वही स्थिरांक जो ऐप इस्तेमाल करता है, हर सूची से टॉप 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 स्कोर और कोसाइन समानता एक ही पैमाने पर नहीं हैं, और एक को दूसरे के हिसाब से नॉर्मलाइज़ करना ऐसा रिश्ता गढ़ना है जो है ही नहीं। एक सूची में तीसरे और दूसरी में पाँचवें नंबर का chunk उस chunk से आगे रहता है जो एक सूची में पहले नंबर पर है और दूसरी में है ही नहीं। हर सवाल का जवाब वह chunk है जिसमें लक्ष्य फ़ंक्शन की def लाइन है, जिसे कुछ भी चलाने से पहले कोड पढ़कर चुना गया। बताया गया नंबर सभी 318 chunks में उस chunk की रैंक है। नतीजे | सवाल | grep वाक्यांश | grep एक शब्द | BM25 | bge-m3 | हाइब्रिड | bge-small | |---|---|---|---|---|---|---| | रीडायरेक्ट के बाद पासवर्ड दूसरी साइट को न भेजना (should_strip_auth) | "another site": 0 | password: 47 | 9 | 1 | 1 | 1 | | रीडायरेक्ट के बाद POST GET क्यों बन जाता है (rebuild_method) | "turn into": 0 | GET: 625 | 65 | 10 | 19 | 6 | | चक्कर काटते रीडायरेक्ट का पीछा करना बंद करना (resolve_redirects) | "redirect loop": 0 | loop: 6 | 5 | 28 | 5 | 14 | | अपलोड को शुरुआत पर लौटाना ताकि दोबारा भेजा जा सके (rewind_body) | "back to the start": 0 | start: 54 | 1 | 4 | 2 | 20 | | जो बॉडी हम भेजने वाले हैं वह कितनी बड़ी है (super_len) | "how big": 0 | big: 8 | 54 | 65 | कोई नहीं | 233 | | लोकल नेटवर्क की मशीनों के लिए प्रॉक्सी छोड़ना (should_bypass_proxies) | "local network": 0 | local: 94 | 2 | 20 | 6 | 10 | | प्रोसेस मर जाए तो भी फ़ाइल कभी आधी लिखी न रहे (atomic_open) | "half-written": 0 | half: 4 | 58 | 16 | 29 | 1 | | JSON के पहले बाइट देखकर UTF-16 और UTF-32 में फ़र्क़ करना (guess_json_utf) | "first bytes": 0 | bytes: 98 | 4 | 4 | 5 | 111 | | होम फ़ोल्डर की फ़ाइल से सेव किए लॉगिन पढ़ना (get_netrc_auth) | "home folder": 0 | login: 4 | 1 | 112 | 7 | 17 | | सर्वर के 401 का जवाब hash किए पासवर्ड से देना (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 chunks में से हैं; grep की गिनती मेल खाती लाइनों की है। "हाइब्रिड" यानी BM25 को bge-m3 के साथ मिलाया गया। तीन बातें उभरकर आती हैं। grep का किसी वाक्यांश पर शून्य लौटाना यह नहीं बताता कि कीवर्ड बेकार हैं। सवाल के शब्दों पर रैंक करने पर BM25 ने 10 में से 6 में जवाब टॉप 5 में रखा। "Redirect", "proxy", "401" और "UTF-16" सब कोड में मौजूद हैं; बस उनके आसपास की भाषा नहीं मिलती। एक-शब्द वाली grep उलटी समस्या दिखाती हैं: GET के लिए 625 लाइनें, bytes के लिए 98। वे उसे किसी ढेर में कहीं खोज देती हैं जिसे फिर हाथ से पढ़ना पड़ता है। Embeddings की जीत असली है, पर ख़ास तरह की है। bge-m3 ने ऐसे सवाल पर should_strip_auth को पहले नंबर पर पाया जिसके शब्द उससे मेल नहीं खाते, और rebuild_method को BM25 के 65वें नंबर से 10वें पर ले आया। छोटे मॉडल ने "never left half-written if the process dies" पर atomic_open को पहले नंबर पर रखा, जबकि BM25 में वह 58वें पर था; उस फ़ंक्शन की docstring में "atomic fashion" लिखा है, और मॉडल जानता है कि फ़ाइल के लिए atomic का मतलब क्या है। और वेक्टर ऐसे तरीकों से फ़ेल होते हैं जैसे कीवर्ड नहीं होते। सिर्फ़ आइडेंटिफ़ायर raise_for_status पर bge-m3 ने इसे परिभाषित करने वाला chunk 70वें नंबर पर रखा, और bge-small ने 244वें पर। फ़्यूज़न ज़्यादा सुरक्षित डिफ़ॉल्ट है, हर पंक्ति में सबसे अच्छा नहीं। उस एक सवाल को छोड़कर जिसे कोई नहीं खोज पाया, हाइब्रिड कभी सबसे ख़राब कॉलम नहीं रहा और अक्सर सबसे अच्छे के क़रीब रहा, जो उस टूल से चाहिए जिसे आप बिना पहले जाने बुलाते हैं कि सवाल किस तरह का है। "How big is the body" हर जगह फ़ेल हुआ: super_len कभी उन शब्दों में नहीं बताता कि वह क्या मापता है जो कोई सवाल इस्तेमाल करेगा। | टॉप 5 / टॉप 10 में हिट (10 सवाल) | BM25 | bge-m3 | हाइब्रिड (bge-m3) | bge-small | हाइब्रिड (bge-small) | |---|---|---|---|---|---| | पूरा रिपॉज़िटरी, 318 chunks | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 | | सिर्फ़ src/, 84 chunks | 7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 | टेस्ट और डॉक्स सबसे बड़े प्रतिस्पर्धी हैं ज़्यादातर चूक बेतरतीब नहीं थीं। "why does a POST turn into a GET" पर bge-m3 का पहला नतीजा tests/test_requests.py था, test_http_303_changes_post_to_get के आसपास। "read saved logins from a file in the home folder" पर वह HISTORY.md में .netrc के बारे में changelog एंट्री थी, और "move the upload back to the start" और "how big is the body" दोनों पर docs/user/advanced.rst का एक सेक्शन। टेस्ट और डॉक्स व्यवहार को सादे शब्दों में बताते हैं, और सवाल भी ठीक वही होता है, इसलिए वे अक्सर इम्प्लीमेंटेशन से ज़्यादा सवाल के क़रीब होते हैं। यह ग़लत जवाब नहीं है, क्योंकि टेस्ट फ़ंक्शन का नाम लेता है। पर उन्हें छानने पर रैंकिंग काफ़ी बदल जाती है। उन्हीं वेक्टरों के साथ सिर्फ़ src/ के 84 chunks में खोजने पर हाइब्रिड सर्च ने टॉप 10 में 10 में से 9 खोजे। इस रिपॉज़िटरी पर सबसे सस्ता सुधार बड़ा मॉडल नहीं था, बल्कि पाथ फ़िल्टर था, या कम से कम टेस्ट और डॉक्स की हिट पर लेबल लगाना ताकि उन्हें छोड़ा जा सके। ज़्यादातर chunks के सिर्फ़ पहले 512 टोकन पढ़ने के बावजूद छोटा मॉडल टिका रहा, शायद इसलिए कि chunk की शुरुआती लाइनें, def और उसकी docstring के साथ, यह बताने वाली ज़्यादातर बात रखती हैं कि वह किस बारे में है। एक रिपॉज़िटरी पर दस सवाल किसी भी मॉडल को बेहतर कहने के लिए काफ़ी नहीं हैं। इतना देखने के लिए काफ़ी हैं कि मॉडल का चुनाव कीवर्ड के साथ फ़्यूज़ करने और टेस्ट को रास्ते से हटाने से कम मायने रखता था। इसे चलाना 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.3 GB। अपनी क्वेरीज़ नतीजे देखने से पहले लिखें, और जवाब पहले कोड पढ़कर चुनें। किसी सवाल के शब्द तब तक बदलते रहना बहुत आसान है जब तक वेक्टर सर्च जीत न जाए। osFoundry डेस्कटॉप ऐप यही पाइपलाइन अपनी Indexes स्क्रीन से चलाता है, bge-m3 डिवाइस पर और BM25 व वेक्टर अपने folder_search एजेंट टूल में फ़्यूज़ करके। उसकी ग्लॉसरी में chunking की एंट्री chunk के आकार और ओवरलैप के समझौतों की बात करती है, और ऐप osFoundry डाउनलोड पेज से मुफ़्त है।
La recherche sémantique dans le code bat-elle vraiment grep sur un dépôt réel ? L'argument pour indexer un code avec des embeddings est toujours la même démo : on demande « où bloque-t-on les utilisateurs ? », grep ne trouve rien parce que le code parle de cooldown, et la recherche vectorielle tombe sur la bonne fonction. C'est une bonne démo. La question est de savoir à quelle fréquence elle tient sur du code que personne n'a choisi pour la démo. Voici donc une mesure sur un dépôt que la plupart des développeurs Python ont lu un jour, psf/requests au tag v2.32.3, avec dix questions pour lesquelles grep sur l'expression évidente ne renvoie aucune ligne. Le pipeline reprend celui que l'application de bureau osFoundry utilise pour son index de dossiers locaux (règles de découpage de folder_index.rs, classement hybride de folder_search.rs), reconstruit en moins de 200 lignes de Python et exécuté sur un Mac M1 de 8 Go, CPU uniquement. La réponse courte : les embeddings seuls ont trouvé le bon morceau dans le top 5 pour 4 questions sur 10. Un classement par mots-clés simple de la même question en a trouvé 6. Les deux fusionnés en ont trouvé 7 dans le top 10, et 9 une fois que les tests et la documentation ont cessé de concurrencer le code source. Découpage avec numéros de ligne Les embeddings travaillent sur des morceaux, pas sur des fichiers. L'application découpe chaque fichier en chunks d'environ 700 tokens avec un chevauchement de 80 tokens, en approximant un token à 4 caractères, et conserve la première et la dernière ligne de chaque chunk pour qu'un résultat puisse être indiqué sous la forme 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 Les chunks sont coupés aux limites de ligne, jamais au milieu d'une ligne, et la dernière garde compte : un fichier minifié avec une seule ligne de 200 Ko reviendrait sinon indéfiniment au même début. L'analyse préalable ignore node_modules, dist, build, .git, .venv et les dossiers similaires, les lockfiles par leur nom complet (package-lock.json et pnpm-lock.yaml ne se terminent pas par .lock, ce qui explique qu'un filtre sur la seule extension les rate), les fichiers de plus de 1 Mo et tout ce qui contient un octet nul dans ses 512 premiers octets. Sur requests, il restait 117 fichiers sur 129 (10 binaires, 1 de plus de 1 Mo, 1 SVG exclu), 625 261 octets et 318 chunks. La règle des 4 caractères cache une chose : le code se tokenise moins bien que la prose. Mesuré avec le tokenizer propre à bge-m3, le chunk médian faisait 771 tokens, pas 700, et le plus long 2 050. Cela compte pour l'étape suivante. Calculer les embeddings en local 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) Les scripts s'exécutent avec uv run, qui installe sentence-transformers et torch à partir des métadonnées en ligne en tête de chaque fichier. Pour comparer, les mêmes chunks sont aussi passés dans bge-small-en-v1.5, un modèle anglais de 33 M de paramètres environ 17 fois plus petit (ses requêtes reçoivent le préfixe recommandé par le modèle, « Represent this sentence for searching relevant passages: »). | | bge-m3 | bge-small-en-v1.5 | |---|---|---| | Taille du vecteur | 1 024 | 384 | | Entrée maximale | 8 192 tokens (plafonnée à 2 048 ici) | 512 tokens | | Chunks tronqués | 1 sur 318 | 235 sur 318 | | Temps pour calculer 318 chunks (M1, CPU) | 631 s | 20,5 s | | Mémoire maximale | 1,6 Go | non mesurée | | Vecteurs sur disque (float32) | 1,3 Mo | 0,49 Mo | | Embedding d'une requête | 682 ms | 21 ms | Dix minutes pour un dépôt de 625 Ko, c'est lent, environ 2 secondes par chunk sur cette machine, et le coût suit la quantité de code, pas le nombre de fichiers. C'est un coût unique : l'application identifie les chunks par un hash de leur texte, donc un chunk inchangé garde son vecteur, et modifier une fonction ne recalcule qu'une poignée de chunks. La vitesse du petit modèle a une contrepartie que le tableau montre clairement : à 512 tokens, il n'a lu que le début de 235 des 318 chunks. Une règle est signalée par un marqueur rouge dans le code source de l'application, et elle est facile à enfreindre dans son propre code. La requête doit être transformée en embedding par le même modèle que celui qui a construit l'index. Un vecteur de chunk bge-m3 et un vecteur de requête d'un autre modèle à 1 024 dimensions ont la même forme, donc aucune erreur n'est levée, et les résultats sont tout simplement faux. Mots-clés, vecteurs, et leur fusion grep a reçu l'expression qu'on taperait, sans tenir compte de la casse, sur les mêmes 117 fichiers. Pour une référence par mots-clés plus équitable, la question entière est aussi passée dans BM25 sur les chunks, avec les identifiants découpés sur _ et le camelCase pour que should_strip_auth corresponde à « strip ». (Le volet mots-clés de l'application est tantivy sur des fichiers entiers, reporté sur les premiers chunks de chaque fichier ; le classement par chunk est une simplification.) Les deux classements sont fusionnés avec Reciprocal Rank Fusion à k = 60, la même constante que dans l'application, en prenant les 30 premiers de chaque liste : 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 ignore les scores et n'utilise que les positions, et c'est tout l'intérêt. Un score BM25 et une similarité cosinus ne sont pas sur la même échelle, et normaliser l'un par rapport à l'autre invente une relation qui n'existe pas. Un chunk classé 3e dans une liste et 5e dans l'autre bat un chunk classé 1er dans l'une et absent de l'autre. La réponse pour chaque question est le chunk qui contient la ligne def de la fonction visée, choisie en lisant le code avant de lancer quoi que ce soit. Le nombre indiqué est le rang de ce chunk parmi les 318. Résultats | Question | expression grep | un mot grep | BM25 | bge-m3 | Hybride | bge-small | |---|---|---|---|---|---|---| | ne pas envoyer le mot de passe à un autre site après une redirection (should_strip_auth) | "another site": 0 | password: 47 | 9 | 1 | 1 | 1 | | pourquoi un POST devient un GET après une redirection (rebuild_method) | "turn into": 0 | GET: 625 | 65 | 10 | 19 | 6 | | arrêter de suivre les redirections qui tournent en boucle (resolve_redirects) | "redirect loop": 0 | loop: 6 | 5 | 28 | 5 | 14 | | remettre l'envoi au début pour pouvoir le renvoyer (rewind_body) | "back to the start": 0 | start: 54 | 1 | 4 | 2 | 20 | | quelle taille fait le corps que l'on va envoyer (super_len) | "how big": 0 | big: 8 | 54 | 65 | aucun | 233 | | contourner le proxy pour les machines du réseau local (should_bypass_proxies) | "local network": 0 | local: 94 | 2 | 20 | 6 | 10 | | s'assurer qu'un fichier n'est jamais à moitié écrit si le processus meurt (atomic_open) | "half-written": 0 | half: 4 | 58 | 16 | 29 | 1 | | distinguer UTF-16 d'UTF-32 d'après les premiers octets du JSON (guess_json_utf) | "first bytes": 0 | bytes: 98 | 4 | 4 | 5 | 111 | | lire des identifiants enregistrés dans un fichier du dossier personnel (get_netrc_auth) | "home folder": 0 | login: 4 | 1 | 112 | 7 | 17 | | répondre au 401 du serveur avec un mot de passe haché (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 | Les rangs sont sur 318 chunks ; les nombres de grep sont des lignes correspondantes. « Hybride » désigne BM25 fusionné avec bge-m3. Trois points ressortent. Une expression qui renvoie zéro avec grep ne veut pas dire que les mots-clés sont inutiles. Classé sur les mots de la question, BM25 a placé la réponse dans le top 5 pour 6 des 10. « Redirect », « proxy », « 401 » et « UTF-16 » figurent tous dans le code ; seule la formulation autour ne correspond pas. Les recherches grep sur un seul mot montrent le problème inverse : 625 lignes pour GET, 98 pour bytes. Elles le trouvent quelque part dans un tas qu'il faut ensuite lire à la main. Les gains des embeddings sont réels mais ciblés. bge-m3 a trouvé should_strip_auth en premier pour une question qui ne partage aucun mot avec lui, et a fait passer rebuild_method de la 65e place avec BM25 à la 10e. Le petit modèle a mis atomic_open en premier pour « never left half-written if the process dies », là où BM25 le plaçait 58e ; la docstring de cette fonction dit « atomic fashion », et le modèle sait ce que signifie atomique pour un fichier. Et les vecteurs échouent d'une façon que les mots-clés ne connaissent pas. Sur l'identifiant brut raise_for_status, bge-m3 a classé le chunk qui le définit 70e, et bge-small 244e. La fusion est le choix par défaut le plus sûr, pas le meilleur sur chaque ligne. À part la question que rien n'a trouvée, l'hybride n'a jamais été la pire colonne et était généralement proche de la meilleure, ce qu'on attend d'un outil qu'on appelle sans savoir à l'avance de quel type de question il s'agit. « How big is the body » a échoué partout : super_len ne dit jamais ce qu'elle mesure avec des mots qu'une question emploierait. | Réussites top 5 / top 10 (10 questions) | BM25 | bge-m3 | Hybride (bge-m3) | bge-small | Hybride (bge-small) | |---|---|---|---|---|---| | Dépôt entier, 318 chunks | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 | | src/ seulement, 84 chunks | 7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 | Les tests et la documentation sont le principal concurrent La plupart des échecs n'étaient pas aléatoires. Pour « why does a POST turn into a GET », le premier résultat de bge-m3 était tests/test_requests.py, autour de test_http_303_changes_post_to_get. Pour « read saved logins from a file in the home folder », c'était l'entrée du changelog sur .netrc dans HISTORY.md, et pour « move the upload back to the start » comme pour « how big is the body », une section de docs/user/advanced.rst. Les tests et la documentation décrivent le comportement avec des mots simples, ce qui est exactement ce qu'est une question, et sont donc souvent plus proches d'elle que l'implémentation. Ce n'est pas une mauvaise réponse, puisque le test nomme la fonction. Mais le classement change beaucoup quand on les filtre. En cherchant uniquement dans les 84 chunks de src/ avec les mêmes vecteurs, la recherche hybride en a trouvé 9 sur 10 dans le top 10. L'amélioration la moins chère sur ce dépôt n'a pas été un modèle plus gros mais un filtre sur le chemin, ou au moins un marquage des résultats de tests et de documentation pour pouvoir les écarter. Le petit modèle a tenu le rythme alors qu'il ne lisait que les 512 premiers tokens de la plupart des chunks, probablement parce que les premières lignes d'un chunk, avec le def et sa docstring, portent l'essentiel de son sujet. Dix questions sur un seul dépôt ne suffisent pas pour déclarer l'un des modèles meilleur. Elles suffisent pour voir que le choix du modèle comptait moins que la fusion avec les mots-clés et l'éloignement des tests. L'exécuter 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 La première exécution de embed.py télécharge bge-m3 depuis Hugging Face, environ 2,3 Go. Il vaut mieux écrire ses requêtes avant de regarder les résultats et choisir les réponses en lisant d'abord le code. Il est très facile d'ajuster la formulation d'une question jusqu'à ce que la recherche vectorielle gagne. L'application de bureau osFoundry exécute le même pipeline depuis son écran Indexes, avec bge-m3 sur l'appareil et BM25 et vecteurs fusionnés dans son outil d'agent folder_search. L'entrée sur le chunking de son glossaire traite des compromis sur la taille et le chevauchement des chunks, et l'application est gratuite sur la page de téléchargement d'osFoundry.
Does semantic code search actually beat grep on a real repo? The pitch for embedding a codebase is always the same demo: you ask "where do we lock users out?", grep finds nothing because the code calls it a cooldown, and vector search lands on the right function. It is a good demo. The question is how often it holds up on code nobody picked for the demo. So here is a measurement on a repository most Python developers have read at some point, psf/requests at tag v2.32.3, with ten questions where grep for the obvious phrase returns zero lines. The pipeline copies the one the osFoundry desktop app uses for its local folder index (chunking rules from folder_index.rs, hybrid ranking from folder_search.rs), rebuilt in under 200 lines of Python and run on an 8 GB M1 Mac, CPU only. The short answer: embeddings alone found the right chunk in the top 5 for 4 of 10 questions. A plain keyword ranking of the same question found 6. The two fused together found 7 in the top 10, and 9 once tests and docs stopped competing with the source. Chunking with line numbers Embeddings work on pieces, not files. The app cuts each file into chunks of about 700 tokens with an 80-token overlap, approximating a token as 4 characters, and keeps the first and last line of every chunk so a hit can be reported as 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 Chunks are cut on line boundaries, never mid-line, and the last guard matters: a minified file with one 200 KB line would otherwise step back to the same start forever. The scan before it skips node_modules, dist, build, .git, .venv and similar folders, lockfiles by full name (package-lock.json and pnpm-lock.yaml don't end in .lock, which is how an extension-only filter misses them), files over 1 MB, and anything with a null byte in its first 512 bytes. On requests that left 117 of 129 files (10 binary, 1 over 1 MB, 1 excluded SVG), 625,261 bytes, and 318 chunks. One thing the 4-characters rule hides: code tokenizes worse than prose. Measured with bge-m3's own tokenizer, the median chunk was 771 tokens, not 700, and the longest was 2,050. That matters for the next step. Embedding locally 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) The scripts run with uv run, which installs sentence-transformers and torch from the inline metadata at the top of each file. For comparison, the same chunks also went through bge-small-en-v1.5, a 33M-parameter English model about 17 times smaller (its queries get the model's recommended "Represent this sentence for searching relevant passages: " prefix). | | bge-m3 | bge-small-en-v1.5 | |---|---|---| | Vector size | 1,024 | 384 | | Max input | 8,192 tokens (capped at 2,048 here) | 512 tokens | | Chunks cut short | 1 of 318 | 235 of 318 | | Time to embed 318 chunks (M1, CPU) | 631 s | 20.5 s | | Peak memory | 1.6 GB | not measured | | Vectors on disk (float32) | 1.3 MB | 0.49 MB | | Embedding one query | 682 ms | 21 ms | Ten minutes for a 625 KB repository is slow, about 2 seconds per chunk on this machine, and the cost scales with the code, not the number of files. It is a one-time cost: the app keys chunks by a hash of their text, so an unchanged chunk keeps its vector, and editing one function re-embeds a handful of chunks. The small model's speed comes with a catch the table shows plainly: at 512 tokens it read only the first part of 235 of the 318 chunks. There is one rule the app's source flags with a red marker, and it is easy to break in your own code. The query has to be embedded by the same model that built the index. A bge-m3 chunk vector and a query vector from another 1,024-dimensional model have the same shape, so nothing errors, and the results are simply wrong. Keywords, vectors, and fusing them grep was given the phrase you would type, case-insensitive, over the same 117 files. As a fairer keyword baseline, the whole question also went through BM25 over the chunks, with identifiers split on _ and camelCase so that should_strip_auth matches "strip". (The app's keyword side is tantivy over whole files, mapped onto each file's first chunks; ranking per chunk is a simplification.) The two rankings are merged with Reciprocal Rank Fusion at k = 60, the same constant the app uses, taking the top 30 from each list: 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 ignores the scores and only uses positions, which is the point. A BM25 score and a cosine similarity are not on the same scale, and normalising one against the other invents a relationship that isn't there. A chunk ranked 3rd by one list and 5th by the other beats a chunk ranked 1st by one and absent from the other. The answer for each question is the chunk containing the target function's def line, chosen by reading the code before running anything. The number reported is that chunk's rank among all 318. Results | Question | grep phrase | grep one word | BM25 | bge-m3 | Hybrid | 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 | none | 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 | Ranks are out of 318 chunks; grep counts are matching lines. "Hybrid" is BM25 fused with bge-m3. Three things stand out. A phrase grep returning zero does not mean keywords are useless. Ranked over the question's words, BM25 put the answer in the top 5 for 6 of the 10. "Redirect", "proxy", "401" and "UTF-16" all appear in the code; only the phrasing around them doesn't. The single-word greps show the opposite problem: 625 lines for GET, 98 for bytes. They find it somewhere in a pile you then read by hand. The embedding wins are real but specific. bge-m3 found should_strip_auth first for a question that shares no wording with it, and moved rebuild_method from 65th under BM25 to 10th. The small model put atomic_open first for "never left half-written if the process dies", where BM25 had it 58th; that function's docstring says "atomic fashion", and the model knows what atomic means for a file. And vectors fail in ways keywords don't. On the bare identifier raise_for_status, bge-m3 ranked the defining chunk 70th, and bge-small 244th. Fusion is the safer default, not the best on every row. Apart from the one question nothing found, hybrid was never the worst column and usually close to the best one, which is what you want from a tool you call without knowing in advance which kind of question you have. "How big is the body" failed everywhere: super_len never says what it measures in words a question would use. | Top-5 / top-10 hits (10 questions) | BM25 | bge-m3 | Hybrid (bge-m3) | bge-small | Hybrid (bge-small) | |---|---|---|---|---|---| | Whole repo, 318 chunks | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 | | src/ only, 84 chunks | 7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 | Tests and docs are the biggest competitor Most of the misses were not random. For "why does a POST turn into a GET", bge-m3's first result was tests/test_requests.py, around test_http_303_changes_post_to_get. For "read saved logins from a file in the home folder" it was the changelog entry about .netrc in HISTORY.md, and for both "move the upload back to the start" and "how big is the body" a section of docs/user/advanced.rst. Tests and docs describe behaviour in plain words, which is exactly what a question is, so they are often closer to it than the implementation. That is not a wrong answer, since the test names the function. But the ranking changes a lot once they are filtered out. Searching only the 84 chunks under src/ with the same vectors, hybrid search found 9 of 10 in the top 10. The cheapest improvement on this repo was not a bigger model but a path filter, or at least labelling test and doc hits so they can be skipped. The smaller model kept up despite reading only the first 512 tokens of most chunks, probably because a chunk's opening lines, with the def and its docstring, carry most of what it is about. Ten questions on one repository is not enough to call either model better. It is enough to see that the choice of model mattered less than fusing with keywords and keeping tests out of the way. Running it 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 The first embed.py run downloads bge-m3 from Hugging Face, about 2.3 GB. Write your queries before looking at the results, and pick the answers by reading the code first. It is very easy to tune the wording of a question until the vector search wins. The osFoundry desktop app does the same pipeline from its Indexes screen, with bge-m3 on the device and BM25 and vectors fused in its folder_search agent tool. The chunking entry in its glossary covers the trade-offs of chunk size and overlap, and the app is free from the osFoundry download page.
Open Source Didn't Ban AI Code. 83% of Its AI Policies Allow It, With One Catch - Book: Prompt Engineering Pocket Guide - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- July was the month open source "banned AI." On July 22, Codeberg members voted 358 to 144 to bar projects built mostly from AI-generated code (It's FOSS). About a week later, the GCC Steering Committee said it won't take a legally significant contribution if any part of it traces back to an LLM (It's FOSS). Read the GCC rule to the end, though. Contributions under the legal-significance threshold (roughly 15 lines) can still come in if they are clearly marked, test cases are exempt, and AI-assisted work carries an Assisted-by: tag in the commit message. Even the "ban" is mostly a set of conditions. Then look past the two headlines at the whole field. In September, Andre Hora, Romain Robbes and Stefano Zacchiroli published ["We Permit the Use of AI, but [...]"](arxiv.org), a study of 281 AI policies from the 2,000 most-starred GitHub repositories plus 36 well-known projects and organizations such as the Linux kernel, LLVM and the Apache Software Foundation. The title is the finding. The numbers say "yes, but" From the paper's results: - 83.3% of policies permit or encourage AI in code contributions (76.2% permit, 7.1% encourage). - 14.9% forbid AI in code. Only 5.3% forbid it for every kind of contribution. - 67.3% require a high level of human involvement. - 43.4% make the contributor accountable for the result. None of the 281 say accountability is optional. - 48.8% require you to disclose AI use. Of the disclosure rules that say where, 64.7% want it in the PR description and 26.7% in the commit message. So a ban is the minority position. What the majority asks for is a human who was there: someone who directed the work, read it, and will answer for it. The paper quotes the policy of psf/requests on this: "you must remove any LLM co-author tags and take full ownership of every line." One thing the study leaves open, and the authors list it as future work: whether anyone actually follows these policies. What maintainers actually do with slop The paper counts 188 countermeasures against low-quality AI contributions across the policies. The breakdown: | Countermeasure | Share | |---|---| | Close the PR | 48.4% | | Ban or block the user | 20.8% | | Disallow autonomous agents | 13.8% | | Restrict new or external users | 4.8% | | Everything else (six measures) | 12.2% | Nearly half of all measures are the same move: close it. The rqlite policy says slop PRs "may be closed without comment," per the paper. rust-analyzer goes further and says it does not allow autonomous agents to open pull requests or issues at all. Some projects now address the agent directly. The paper finds policies in AGENTS.md and CLAUDE.md as well as CONTRIBUTING.md and AI_POLICY.md. The contributing guide of llama.cpp tells fully autonomous agents not to contribute and to exclude the repo from their configuration. My take: they banned the unread diff, not the model Put the two findings side by side. Most projects accept AI code. The most common response to bad AI code is closing the PR. The deciding factor between those two outcomes is whether a human read the change before a maintainer had to. A closed PR is a maintainer telling you the review didn't happen on your side, so it isn't going to happen on theirs. Reviewing a large agent diff costs real time, and maintainers are often volunteers. The policies push that cost back to the sender, which is fair. The diff can be cheap to generate. Someone still has to understand it, and the 67.3% requiring high human involvement is the field agreeing on who that someone is. That moves the review upstream. If the reading has to happen, it should happen on your machine, before the PR exists. Once you open it, every unread line is a maintainer's problem. Three steps before you open the PR 1. Read the project's policy first. It might live in a file you don't expect: ls AI_POLICY.md LLM_POLICY.md AGENTS.md CLAUDE.md \ CONTRIBUTING.md 2>/dev/null grep -inwE "ai|llm|agent|copilot" CONTRIBUTING.md \ 2>/dev/null Then follow its format. There isn't one standard. The paper notes RustPython asks for Assisted-by and not Co-authored-by, Nextcloud requires an Assisted-by trailer, and psf/requests wants LLM co-author tags gone entirely. 2. Read the change step by step. One big diff hides the step where the agent rewrote a file you didn't ask about. Start with the summary, then walk the branch commit by commit, oldest first: git diff main...HEAD --stat git log -p --reverse main..HEAD The --stat line shows the file you didn't ask about. The log shows each step in order. If you can't explain a hunk, ask the agent to undo it or redo it smaller. "Full ownership of every line" starts here. 3. Disclose where the project asks. Git has a flag for trailers (git docs): git commit -m "Fix off-by-one in pagination" \ --trailer "Assisted-by: Claude Code" Then say it in the PR description too, since that's where most disclosure rules point: which agent, what it did, what you checked by hand. Doing the reading in Hermes IDE I build Hermes IDE, so weigh this section with that in mind. Everything above works with plain git. Hermes is built around that order of operations: the agent works and you read before you land. Every agent turn is saved, so you can diff or restore any single turn, with undo. The Review Desk (⌘G, or Ctrl+Shift+G on Windows and Linux) shows the changes by turn or by file, with risk flags, and a line comment goes back to the agent that wrote that line. That's the "read it turn by turn" step without scrolling one long diff (README). !The Hermes Review Desk in By turn view, listing two turns of a Claude Code task with turn 1's diff of src/db.ts and a drafted commit message The Review Desk (⌘G) in By turn view: two turns of a Claude Code task, turn 1's diff of src/db.ts, the changed files, and a commit message drafted from the turns. Only after that does Land come in. It commits, opens a pull request or merges locally, into the branch the task started from, and it can be undone. Check that the commit message carries the trailer your project asks for before you land. Each task also runs on its own hermes/ branch and worktree, so the PR holds one task's work and nothing from the agent next door. You can download Hermes. It is source-available under BSL 1.1 and runs on macOS, Windows and Linux. It runs Claude Code, Codex and other agents in their own terminals, with your own sign-ins. It isn't affiliated with Nous Research or its Hermes Agent. It can't make an agent's change good, but it gives you a place to check the change first. Where do you draw the line? If you maintain a project, does your policy say what "human involvement" means in practice? If you contribute, do you disclose the agent by default, or only when a project asks? Tell me in the comments. --- If this was useful Projects now write their AI rules into AGENTS.md and CLAUDE.md, which means a contribution policy is also a prompt. The Prompt Engineering Pocket Guide covers how to write instructions an agent actually follows, whether you're the maintainer writing them or the contributor whose agent reads them. If you'd rather review agent work turn by turn, Hermes IDE is the tool I build for exactly that. 
You guys always ask "What's the lore?" whenever you're looking to buy a coin 👀 Now, there’s a coin with legendary lore, one that’s already in use and helping millions of dev. Just imagine what happens when CT discovers $requests 🤯 psf/requests t.co
Ruff vs Black: is the Black Python formatter finished? Is Black, the Python formatter, finished now that Ruff exists? Ruff's formatter matches Black on over 99.9% of lines in big Black-formatted projects and ships far more often, but Black still releases. Chad and Neil check both repos and Ruff's formatter docs. Sources: - GitHub API: astral-sh/ruff and psf/black repositories, releases and commits (queried Oct 19, 2026) — astral-sh/ruff - Ruff README: README.md (main, Oct 2026) — github.com/astral-sh/ruff/blob/main/README.md - Black README: README.md (main, Oct 2026) — github.com/psf/black/blob/main/README.md - Ruff docs: The Ruff Formatter — docs.astral.sh Chad and Neil are animated characters. Voices are synthetic (text-to-speech). Neil's voice: generated with Kyutai TTS 1.6B by Kyutai (CC BY 4.0, creativecommons.org), using speaker p345 from the CSTR VCTK Corpus v0.92 by Junichi Yamagishi, Christophe Veaux and Kirsten MacDonald, The Centre for Speech Technology Research, University of Edinburgh (CC BY 4.0, doi.org). The generated audio was edited (levelled, equalised and mixed). Chad's voice: Kokoro-82M by hexgrad (Apache-2.0). Sound effects: original. Music: "Washed Up" by HoliznaCC0 (Free Music Archive), CC0 1.0 (public domain). #shorts #python #ruff ep:C04
PostHog; Black: Uncompromising Python code formatter | GitHub Curated Picks | 2026-10-25 Chapters: 00:00 2026-10-25 | GitHub Curated Picks 00:05 Pick #1 PostHog 00:19 Pick #2 Black GitHub Curated Picks reviews 2 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption. Published: 2026-10-25 Review window: daily Curated repository picks: - Pick #1 PostHog | Latest commit | feat(mcp): return condition_id from feature flag read tools (reference | PostHog/posthog - Pick #2 Black | Latest commit | Recent commit verified | psf/black Chapters: 00:00 2026-10-25 | GitHub Curated Picks 00:05 Pick #1 PostHog 00:19 Pick #2 Black 00:40 Watch Next 00:47 Educational Use Only Top comment prompt: Which repository would you verify first, and what release or commit evidence matters most? Source ledger: Direct source URLs: - github.com/trending - api.github.com - PostHog/posthog - github.com/PostHog/posthog/releases/tag/hobby-lat… - github.com/PostHog/posthog/commit/8c626c87f0c85cb… - api.github.com - psf/black - github.com/psf/black/releases/tag/26.10.1 - github.com/psf/black/commit/80db630ac532f0e2f3ba3… Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance. Subscriber reason: Subscribe for the daily AI, GitHub, and market board. Tomorrow watchlist: Tomorrow, check the next tagged release, recent commit, and issue-health signals for the selected projects.
Black: Uncompromising Python code formatter; Coolify; OpenRefine | GitHub Curated Picks | 2026-10-24 Chapters: 00:00 2026-10-24 | GitHub Curated Picks 00:05 Pick #1 Black 00:25 Pick #2 Coolify 00:47 Pick #3 OpenRefine GitHub Curated Picks reviews 3 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption. Published: 2026-10-24 Review window: daily Curated repository picks: - Pick #1 Black | Latest commit | Fix crash on a fmt: skip header containing an inner skipped | psf/black - Pick #2 Coolify | Latest commit | fix(applications): report a failed stop instead of failing silently | coollabsio/coolify - Pick #3 OpenRefine | Latest commit | Fix absolute command URLs for path-prefixed installs (reference 7986) | OpenRefine/OpenRefine Chapters: 00:00 2026-10-24 | GitHub Curated Picks 00:05 Pick #1 Black 00:25 Pick #2 Coolify 00:47 Pick #3 OpenRefine 01:02 Watch Next 01:09 Educational Use Only Top comment prompt: Which repository would you verify first, and what release or commit evidence matters most? Source ledger: Direct source URLs: - github.com/trending - api.github.com - github.com/psf/black/blob/main/README.md - github.com/psf/black/releases/tag/26.10.1 - github.com/psf/black/commit/55996d1fb8342725c8a19… - psf/black - api.github.com - coollabsio/coolify - github.com/coollabsio/coolify/releases/tag/v4.5.0 - github.com/coollabsio/coolify/commit/e49e540ec0d6… - api.github.com - OpenRefine/OpenRefine - github.com/OpenRefine/OpenRefine/releases/tag/3.1… - github.com/OpenRefine/OpenRefine/commit/d66a10ee5… Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance. Subscriber reason: Subscribe for the daily AI, GitHub, and market board. Tomorrow watchlist: Tomorrow, check the next tagged release, recent commit, and issue-health signals for the selected projects.
Black: an interrupted check is not a completed check: source review Black: an interrupted check is not a completed check — Official Python code formatter repository and README, live and active. Do your checks distinguish interruption from success? Review the source before trusting an interrupted run. Sources: psf/black black.readthedocs.io black#5541 #python #black #opensource