कोडबेस को 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 डाउनलोड पेज से मुफ़्त है।
Top comments (0)