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.
Top comments (0)