Demo untuk menjual ide meng-embed sebuah codebase selalu sama: kita bertanya "di mana kita mengunci pengguna?", grep tidak menemukan apa-apa karena kodenya menyebutnya cooldown, dan pencarian vektor langsung mendarat di fungsi yang benar. Demo yang bagus. Pertanyaannya, seberapa sering itu berhasil pada kode yang tidak dipilih untuk demo.
Berikut pengukurannya pada repositori yang pernah dibaca kebanyakan developer Python, psf/requests pada tag v2.32.3, dengan sepuluh pertanyaan yang jika di-grep dengan frasa yang paling jelas menghasilkan nol baris. Pipeline-nya meniru yang dipakai aplikasi desktop osFoundry untuk indeks folder lokalnya (aturan chunking dari folder_index.rs, peringkat hibrida dari folder_search.rs), dibangun ulang dalam kurang dari 200 baris Python dan dijalankan di Mac M1 8 GB, hanya CPU. Jawaban singkatnya: embedding saja menemukan chunk yang benar di 5 teratas pada 4 dari 10 pertanyaan. Peringkat kata kunci biasa untuk pertanyaan yang sama menemukan 6. Keduanya digabung menemukan 7 di 10 teratas, dan 9 setelah tes dan dokumentasi tidak lagi bersaing dengan kode sumber.
Embedding bekerja pada potongan, bukan file. Aplikasi memotong setiap file menjadi chunk sekitar 700 token dengan tumpang tindih 80 token, memperkirakan satu token sebagai 4 karakter, dan menyimpan baris pertama dan terakhir setiap chunk agar sebuah hasil bisa dilaporkan sebagai 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
Chunk dipotong pada batas baris, tidak pernah di tengah baris, dan pengaman terakhir itu penting: file hasil minify dengan satu baris 200 KB akan terus mundur ke awal yang sama tanpa henti.
Pemindaian sebelumnya melewati node_modules, dist, build, .git, .venv dan folder sejenis, lockfile berdasarkan nama lengkap (package-lock.json dan pnpm-lock.yaml tidak berakhiran .lock, sehingga filter yang hanya melihat ekstensi melewatkannya), file di atas 1 MB, dan apa pun yang punya byte null di 512 byte pertamanya. Pada requests tersisa 117 dari 129 file (10 biner, 1 di atas 1 MB, 1 SVG yang dikecualikan), 625.261 byte, dan 318 chunk.
Satu hal yang tersembunyi di balik aturan 4 karakter: kode ditokenisasi lebih buruk daripada prosa. Diukur dengan tokenizer bge-m3 sendiri, chunk median berukuran 771 token, bukan 700, dan yang terpanjang 2.050. Ini penting untuk langkah berikutnya.
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)
Skrip dijalankan dengan uv run, yang memasang sentence-transformers dan torch dari metadata inline di bagian atas setiap file. Sebagai pembanding, chunk yang sama juga diproses dengan bge-small-en-v1.5, model Inggris 33 juta parameter yang sekitar 17 kali lebih kecil (kueri-nya diberi awalan yang direkomendasikan modelnya, "Represent this sentence for searching relevant passages: ").
| bge-m3 | bge-small-en-v1.5 | |
|---|---|---|
| Ukuran vektor | 1.024 | 384 |
| Input maksimum | 8.192 token (dibatasi 2.048 di sini) | 512 token |
| Chunk yang terpotong | 1 dari 318 | 235 dari 318 |
| Waktu embedding 318 chunk (M1, CPU) | 631 s | 20,5 s |
| Memori puncak | 1,6 GB | tidak diukur |
| Vektor di disk (float32) | 1,3 MB | 0,49 MB |
| Embedding satu kueri | 682 ms | 21 ms |
Sepuluh menit untuk repositori 625 KB itu lambat, sekitar 2 detik per chunk di mesin ini, dan biayanya mengikuti jumlah kode, bukan jumlah file. Biaya ini hanya sekali: aplikasi menandai chunk dengan hash teksnya, jadi chunk yang tidak berubah mempertahankan vektornya, dan mengedit satu fungsi hanya meng-embed ulang beberapa chunk. Kecepatan model kecil punya konsekuensi yang terlihat jelas di tabel: pada 512 token, model itu hanya membaca bagian awal dari 235 dari 318 chunk.
Ada satu aturan yang ditandai dengan penanda merah di kode sumber aplikasi, dan mudah dilanggar di kode sendiri. Kueri harus di-embed oleh model yang sama dengan yang membangun indeks. Vektor chunk bge-m3 dan vektor kueri dari model lain berdimensi 1.024 punya bentuk yang sama, jadi tidak ada error, dan hasilnya sekadar salah.
grep diberi frasa yang akan kita ketik, tanpa membedakan huruf besar-kecil, pada 117 file yang sama. Sebagai pembanding kata kunci yang lebih adil, seluruh pertanyaan juga dijalankan lewat BM25 pada chunk, dengan pengenal dipecah pada _ dan camelCase sehingga should_strip_auth cocok dengan "strip". (Sisi kata kunci aplikasi adalah tantivy pada seluruh file, dipetakan ke chunk-chunk pertama tiap file; peringkat per chunk adalah penyederhanaan.)
Kedua peringkat digabung dengan Reciprocal Rank Fusion pada k = 60, konstanta yang sama dengan aplikasi, mengambil 30 teratas dari setiap daftar:
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 mengabaikan skor dan hanya memakai posisi, dan itulah intinya. Skor BM25 dan kemiripan kosinus tidak berada pada skala yang sama, dan menormalkan satu terhadap yang lain menciptakan hubungan yang tidak ada. Chunk di peringkat 3 pada satu daftar dan peringkat 5 pada daftar lain mengalahkan chunk di peringkat 1 pada satu daftar dan tidak muncul di daftar lainnya.
Jawaban untuk setiap pertanyaan adalah chunk yang berisi baris def fungsi target, dipilih dengan membaca kode sebelum menjalankan apa pun. Angka yang dilaporkan adalah peringkat chunk itu di antara semua 318.
| Pertanyaan | frasa grep | satu kata grep | BM25 | bge-m3 | Hibrida | bge-small |
|---|---|---|---|---|---|---|
jangan kirim password ke situs lain setelah redirect ( should_strip_auth ) |
"another site": 0 | password: 47 | 9 | 1 | 1 | 1 |
kenapa POST berubah jadi GET setelah redirect ( rebuild_method ) |
"turn into": 0 | GET: 625 | 65 | 10 | 19 | 6 |
berhenti mengikuti redirect yang berputar-putar ( resolve_redirects ) |
"redirect loop": 0 | loop: 6 | 5 | 28 | 5 | 14 |
kembalikan unggahan ke awal agar bisa dikirim lagi ( rewind_body ) |
"back to the start": 0 | start: 54 | 1 | 4 | 2 | 20 |
seberapa besar body yang akan kita kirim ( super_len ) |
"how big": 0 | big: 8 | 54 | 65 | tidak ada | 233 |
lewati proxy untuk mesin di jaringan lokal ( should_bypass_proxies ) |
"local network": 0 | local: 94 | 2 | 20 | 6 | 10 |
pastikan file tidak pernah tertinggal setengah tertulis jika proses mati ( atomic_open ) |
"half-written": 0 | half: 4 | 58 | 16 | 29 | 1 |
bedakan UTF-16 dan UTF-32 dari byte pertama JSON ( guess_json_utf ) |
"first bytes": 0 | bytes: 98 | 4 | 4 | 5 | 111 |
baca login tersimpan dari file di folder home ( get_netrc_auth ) |
"home folder": 0 | login: 4 | 1 | 112 | 7 | 17 |
jawab 401 dari server dengan password yang di-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 |
Peringkat dari 318 chunk; angka grep adalah jumlah baris yang cocok. "Hibrida" adalah BM25 yang digabung dengan bge-m3.
Ada tiga hal yang menonjol.
Frasa yang menghasilkan nol di grep tidak berarti kata kunci tidak berguna. Diurutkan berdasarkan kata-kata dalam pertanyaan, BM25 menempatkan jawaban di 5 teratas untuk 6 dari 10. "Redirect", "proxy", "401" dan "UTF-16" semuanya ada di kode; hanya frasa di sekitarnya yang tidak cocok. Grep satu kata menunjukkan masalah sebaliknya: 625 baris untuk GET, 98 untuk bytes. Jawabannya ada di suatu tempat dalam tumpukan yang lalu harus dibaca manual.
Kemenangan embedding itu nyata tetapi spesifik. bge-m3 menemukan should_strip_auth di peringkat pertama untuk pertanyaan yang tidak punya kata yang sama dengannya, dan memindahkan rebuild_method dari peringkat 65 di BM25 ke peringkat 10. Model kecil menempatkan atomic_open di peringkat pertama untuk "never left half-written if the process dies", sedangkan BM25 menempatkannya di peringkat 58; docstring fungsi itu berbunyi "atomic fashion", dan modelnya tahu arti atomik untuk sebuah file. Dan vektor gagal dengan cara yang tidak dialami kata kunci. Pada pengenal polos raise_for_status, bge-m3 menempatkan chunk definisinya di peringkat 70, dan bge-small di peringkat 244.
Penggabungan adalah pilihan bawaan yang lebih aman, bukan yang terbaik di setiap baris. Selain satu pertanyaan yang tidak ditemukan oleh siapa pun, hibrida tidak pernah menjadi kolom terburuk dan biasanya dekat dengan yang terbaik, yang memang diinginkan dari alat yang dipanggil tanpa tahu sebelumnya jenis pertanyaannya. "How big is the body" gagal di mana-mana: super_len tidak pernah menyebutkan apa yang diukurnya dengan kata-kata yang akan dipakai dalam pertanyaan.
| Hasil tepat di 5 / 10 teratas (10 pertanyaan) | BM25 | bge-m3 | Hibrida (bge-m3) | bge-small | Hibrida (bge-small) |
|---|---|---|---|---|---|
| Seluruh repo, 318 chunk | 6 / 7 | 4 / 5 | 5 / 7 | 3 / 5 | 7 / 7 |
Hanya src/ , 84 chunk |
7 / 7 | 5 / 7 | 7 / 9 | 5 / 7 | 8 / 8 |
Sebagian besar kegagalan bukan kebetulan. Untuk "why does a POST turn into a GET", hasil pertama bge-m3 adalah tests/test_requests.py, di sekitar test_http_303_changes_post_to_get. Untuk "read saved logins from a file in the home folder" hasilnya adalah entri changelog tentang .netrc di HISTORY.md, dan untuk "move the upload back to the start" maupun "how big is the body" sebuah bagian dari docs/user/advanced.rst. Tes dan dokumentasi menggambarkan perilaku dengan kata-kata biasa, persis seperti sebuah pertanyaan, sehingga sering lebih dekat dengannya daripada implementasinya.
Itu bukan jawaban yang salah, karena tes itu menyebut nama fungsinya. Tetapi peringkat berubah banyak setelah keduanya disaring. Mencari hanya di 84 chunk di bawah src/ dengan vektor yang sama, pencarian hibrida menemukan 9 dari 10 di 10 teratas. Perbaikan paling murah di repo ini bukan model yang lebih besar, melainkan filter path, atau setidaknya memberi label pada hasil tes dan dokumentasi agar bisa dilewati.
Model yang lebih kecil tetap mengimbangi meski hanya membaca 512 token pertama dari sebagian besar chunk, mungkin karena baris-baris awal sebuah chunk, dengan def dan docstring-nya, memuat sebagian besar isi chunk itu. Sepuluh pertanyaan pada satu repositori tidak cukup untuk menyatakan salah satu model lebih baik. Cukup untuk melihat bahwa pilihan model kurang berpengaruh dibanding menggabungkannya dengan kata kunci dan menyingkirkan tes.
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
Menjalankan embed.py pertama kali mengunduh bge-m3 dari Hugging Face, sekitar 2,3 GB. Tulis kueri sebelum melihat hasil, dan pilih jawabannya dengan membaca kodenya lebih dulu. Sangat mudah untuk terus menyesuaikan kata-kata sebuah pertanyaan sampai pencarian vektor menang.
Aplikasi desktop osFoundry menjalankan pipeline yang sama dari layar Indexes-nya, dengan bge-m3 di perangkat dan BM25 serta vektor digabung dalam alat agen folder_search. Entri chunking di glosariumnya membahas pertimbangan ukuran dan tumpang tindih chunk, dan aplikasinya gratis dari halaman unduhan osFoundry.