DEV Community

Cover image for La ricerca semantica nel codice batte davvero grep su un repository reale?

La ricerca semantica nel codice batte davvero grep su un repository reale?

La proposta per indicizzare un codice con gli embedding è sempre la stessa demo: si chiede "dove blocchiamo gli utenti?", grep non trova nulla perché il codice lo chiama cooldown, e la ricerca vettoriale cade sulla funzione giusta. È una bella demo. La domanda è quanto spesso regge su codice che nessuno ha scelto per la demo.

Ecco quindi una misura su un repository che la maggior parte degli sviluppatori Python ha letto almeno una volta, psf/requests al tag v2.32.3, con dieci domande per cui grep sulla frase più ovvia restituisce zero righe. La pipeline copia quella che l'app desktop osFoundry usa per il suo indice delle cartelle locali (regole di chunking da folder_index.rs, ranking ibrido da folder_search.rs), riscritta in meno di 200 righe di Python ed eseguita su un Mac M1 da 8 GB, solo CPU. La risposta breve: i soli embedding hanno trovato il chunk giusto nei primi 5 per 4 domande su 10. Un semplice ranking per parole chiave della stessa domanda ne ha trovate 6. I due fusi insieme ne hanno trovate 7 nei primi 10, e 9 quando test e documentazione hanno smesso di competere con il codice sorgente.

Chunking con numeri di riga

Gli embedding lavorano su pezzi, non su file. L'app taglia ogni file in chunk di circa 700 token con una sovrapposizione di 80 token, approssimando un token a 4 caratteri, e conserva la prima e l'ultima riga di ogni chunk in modo che un risultato possa essere indicato come 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
Enter fullscreen mode Exit fullscreen mode

I chunk sono tagliati sui confini di riga, mai a metà riga, e l'ultima protezione conta: un file minificato con un'unica riga da 200 KB altrimenti tornerebbe indietro per sempre allo stesso inizio.

La scansione che precede salta node_modules, dist, build, .git, .venv e cartelle simili, i lockfile per nome completo (package-lock.json e pnpm-lock.yaml non finiscono in .lock, ed è così che un filtro basato solo sull'estensione li lascia passare), i file sopra 1 MB e tutto ciò che ha un byte nullo nei primi 512 byte. Su requests sono rimasti 117 file su 129 (10 binari, 1 oltre 1 MB, 1 SVG escluso), 625.261 byte e 318 chunk.

Una cosa che la regola dei 4 caratteri nasconde: il codice si tokenizza peggio della prosa. Misurato con il tokenizer di bge-m3, il chunk mediano era di 771 token, non 700, e il più lungo di 2.050. Questo conta per il passo successivo.

Calcolare gli embedding in locale

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)
Enter fullscreen mode Exit fullscreen mode

Gli script girano con uv run, che installa sentence-transformers e torch dai metadati inline in cima a ogni file. Per confronto, gli stessi chunk sono passati anche per bge-small-en-v1.5, un modello inglese da 33 M di parametri circa 17 volte più piccolo (alle sue query viene anteposto il prefisso consigliato dal modello, "Represent this sentence for searching relevant passages: ").

bge-m3 bge-small-en-v1.5
Dimensione del vettore 1.024 384
Input massimo 8.192 token (limitato a 2.048 qui) 512 token
Chunk troncati 1 su 318 235 su 318
Tempo per calcolare 318 chunk (M1, CPU) 631 s 20,5 s
Memoria di picco 1,6 GB non misurata
Vettori su disco (float32) 1,3 MB 0,49 MB
Embedding di una query 682 ms 21 ms

Dieci minuti per un repository da 625 KB sono lenti, circa 2 secondi per chunk su questa macchina, e il costo cresce con il codice, non con il numero di file. È un costo una tantum: l'app identifica i chunk con un hash del loro testo, quindi un chunk invariato mantiene il suo vettore, e modificare una funzione ricalcola solo una manciata di chunk. La velocità del modello piccolo ha un rovescio che la tabella mostra chiaramente: a 512 token ha letto solo la prima parte di 235 dei 318 chunk.

C'è una regola che il sorgente dell'app segnala con un marcatore rosso ed è facile da violare nel proprio codice. La query va trasformata in embedding con lo stesso modello che ha costruito l'indice. Il vettore di un chunk bge-m3 e il vettore di una query di un altro modello a 1.024 dimensioni hanno la stessa forma, quindi non si genera nessun errore e i risultati sono semplicemente sbagliati.

Parole chiave, vettori e come fonderli

A grep è stata data la frase che si digiterebbe, senza distinguere maiuscole e minuscole, sugli stessi 117 file. Come riferimento per le parole chiave più equo, l'intera domanda è passata anche per BM25 sui chunk, con gli identificatori divisi su _ e camelCase in modo che should_strip_auth corrisponda a "strip". (Il lato parole chiave dell'app è tantivy su file interi, riportato sui primi chunk di ciascun file; il ranking per chunk è una semplificazione.)

I due ranking vengono uniti con Reciprocal Rank Fusion con k = 60, la stessa costante dell'app, prendendo i primi 30 di ciascuna lista:

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()]
Enter fullscreen mode Exit fullscreen mode

RRF ignora i punteggi e usa solo le posizioni, ed è questo il punto. Un punteggio BM25 e una similarità coseno non sono sulla stessa scala, e normalizzare l'uno rispetto all'altro inventa una relazione che non esiste. Un chunk al 3º posto in una lista e al 5º nell'altra batte uno al 1º in una lista e assente nell'altra.

La risposta per ogni domanda è il chunk che contiene la riga def della funzione bersaglio, scelta leggendo il codice prima di eseguire qualsiasi cosa. Il numero riportato è la posizione di quel chunk tra tutti i 318.

Risultati

Domanda frase grep una parola grep BM25 bge-m3 Ibrido bge-small
non inviare la password a un altro sito dopo un redirect (should_strip_auth) "another site": 0 password: 47 9 1 1 1
perché un POST diventa un GET dopo un redirect (rebuild_method) "turn into": 0 GET: 625 65 10 19 6
smettere di seguire i redirect che girano in cerchio (resolve_redirects) "redirect loop": 0 loop: 6 5 28 5 14
riportare l'upload all'inizio per poterlo inviare di nuovo (rewind_body) "back to the start": 0 start: 54 1 4 2 20
quanto è grande il corpo che stiamo per inviare (super_len) "how big": 0 big: 8 54 65 nessuno 233
saltare il proxy per le macchine della rete locale (should_bypass_proxies) "local network": 0 local: 94 2 20 6 10
assicurarsi che un file non resti mai scritto a metà se il processo muore (atomic_open) "half-written": 0 half: 4 58 16 29 1
distinguere UTF-16 da UTF-32 guardando i primi byte del JSON (guess_json_utf) "first bytes": 0 bytes: 98 4 4 5 111
leggere i login salvati da un file nella cartella home (get_netrc_auth) "home folder": 0 login: 4 1 112 7 17
rispondere al 401 del server con una password con 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

Le posizioni sono su 318 chunk; i numeri di grep sono righe corrispondenti. "Ibrido" è BM25 fuso con bge-m3.

Emergono tre cose.

Una frase per cui grep restituisce zero non significa che le parole chiave siano inutili. Ordinando per le parole della domanda, BM25 ha messo la risposta nei primi 5 per 6 delle 10. "Redirect", "proxy", "401" e "UTF-16" compaiono tutti nel codice; non corrisponde solo la formulazione intorno. Le ricerche grep di una sola parola mostrano il problema opposto: 625 righe per GET, 98 per bytes. Lo trovano da qualche parte in un mucchio che poi va letto a mano.

I successi degli embedding sono reali ma specifici. bge-m3 ha trovato should_strip_auth al primo posto per una domanda che non condivide parole con essa, e ha portato rebuild_method dal 65º posto di BM25 al 10º. Il modello piccolo ha messo atomic_open al primo posto per "never left half-written if the process dies", dove BM25 lo aveva al 58º; la docstring di quella funzione dice "atomic fashion", e il modello sa cosa significa atomico per un file. E i vettori sbagliano in modi in cui le parole chiave non sbagliano. Sul semplice identificatore raise_for_status, bge-m3 ha messo il chunk che lo definisce al 70º posto, e bge-small al 244º.

La fusione è il default più sicuro, non il migliore su ogni riga. A parte la domanda che nessuno ha trovato, l'ibrido non è mai stato la colonna peggiore ed è quasi sempre stato vicino alla migliore, che è ciò che si vuole da uno strumento che si richiama senza sapere in anticipo che tipo di domanda si ha. "How big is the body" è fallita ovunque: super_len non dice mai cosa misura con parole che userebbe una domanda.

Successi nei primi 5 / primi 10 (10 domande) BM25 bge-m3 Ibrido (bge-m3) bge-small Ibrido (bge-small)
Repository intero, 318 chunk 6 / 7 4 / 5 5 / 7 3 / 5 7 / 7
Solo src/, 84 chunk 7 / 7 5 / 7 7 / 9 5 / 7 8 / 8

Test e documentazione sono il principale concorrente

La maggior parte degli errori non era casuale. Per "why does a POST turn into a GET", il primo risultato di bge-m3 era tests/test_requests.py, intorno a test_http_303_changes_post_to_get. Per "read saved logins from a file in the home folder" era la voce del changelog su .netrc in HISTORY.md, e per "move the upload back to the start" e "how big is the body" una sezione di docs/user/advanced.rst. Test e documentazione descrivono il comportamento con parole semplici, che è esattamente ciò che è una domanda, quindi spesso le sono più vicini dell'implementazione.

Non è una risposta sbagliata, dato che il test nomina la funzione. Ma il ranking cambia molto una volta filtrati. Cercando solo nei 84 chunk sotto src/ con gli stessi vettori, la ricerca ibrida ne ha trovati 9 su 10 nei primi 10. Il miglioramento più economico su questo repository non è stato un modello più grande ma un filtro sul percorso, o almeno l'etichettatura dei risultati di test e documentazione per poterli saltare.

Il modello piccolo ha tenuto il passo pur leggendo solo i primi 512 token della maggior parte dei chunk, probabilmente perché le prime righe di un chunk, con il def e la sua docstring, portano la maggior parte di ciò di cui parla. Dieci domande su un solo repository non bastano per dire che uno dei due modelli sia migliore. Bastano per vedere che la scelta del modello contava meno della fusione con le parole chiave e del tenere i test fuori dai piedi.

Eseguirlo

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
Enter fullscreen mode Exit fullscreen mode

La prima esecuzione di embed.py scarica bge-m3 da Hugging Face, circa 2,3 GB. Conviene scrivere le query prima di guardare i risultati e scegliere le risposte leggendo prima il codice. È molto facile ritoccare la formulazione di una domanda finché la ricerca vettoriale non vince.

L'app desktop osFoundry esegue la stessa pipeline dalla schermata Indexes, con bge-m3 sul dispositivo e BM25 e vettori fusi nel suo strumento per agenti folder_search. La voce sul chunking nel glossario tratta i compromessi su dimensione e sovrapposizione dei chunk, e l'app è gratuita dalla pagina di download di osFoundry.

Top comments (0)