Schlägt semantische Code-Suche grep wirklich in einem echten Repository?
Der Pitch fürs Einbetten einer Codebasis ist immer dieselbe Demo: Man fragt „Wo sperren wir Nutzer aus?“, grep findet nichts, weil der Code das Cooldown nennt, und die Vektorsuche landet in der richtigen Funktion. Eine gute Demo. Die Frage ist, wie oft das bei Code funktioniert, den niemand für die Demo ausgesucht hat. Hier also eine Messung an einem Repository, das die meisten Python-Entwickler irgendwann gelesen haben: psf/requests im Tag v2.32.3, mit zehn Fragen, bei denen grep nach der naheliegenden Formulierung null Zeilen liefert. Die Pipeline kopiert die, die die osFoundry-Desktop-App für ihren Index lokaler Ordner benutzt (Chunking-Regeln aus folder_index.rs, hybrides Ranking aus folder_search.rs), in unter 200 Zeilen Python nachgebaut und auf einem Mac M1 mit 8 GB ausgeführt, nur CPU. Die kurze Antwort: Embeddings allein fanden den richtigen Chunk bei 4 von 10 Fragen unter den Top 5. Ein schlichtes Keyword-Ranking derselben Frage fand 6. Beide zusammen fanden 7 in den Top 10, und 9, sobald Tests und Doku nicht mehr mit dem Quellcode konkurrierten. Chunking mit Zeilennummern Embeddings arbeiten mit Stücken, nicht mit Dateien. Die App schneidet jede Datei in Chunks von etwa 700 Tokens mit 80 Tokens Überlappung, wobei ein Token als 4 Zeichen angenähert wird, und behält die erste und letzte Zeile jedes Chunks, damit ein Treffer als path:start-end gemeldet werden kann. 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 werden an Zeilengrenzen geschnitten, nie mitten in einer Zeile, und die letzte Absicherung ist wichtig: Eine minifizierte Datei mit einer einzigen 200-KB-Zeile würde sonst endlos zum selben Start zurückspringen. Der Scan davor überspringt node_modules, dist, build, .git, .venv und ähnliche Ordner, Lockfiles anhand des vollen Namens (package-lock.json und pnpm-lock.yaml enden nicht auf .lock, weshalb ein reiner Endungsfilter sie übersieht), Dateien über 1 MB und alles mit einem Nullbyte in den ersten 512 Bytes. Bei requests blieben davon 117 von 129 Dateien übrig (10 binär, 1 über 1 MB, 1 ausgeschlossenes SVG), 625.261 Bytes und 318 Chunks. Eines verbirgt die 4-Zeichen-Regel: Code lässt sich schlechter tokenisieren als Fließtext. Mit dem eigenen Tokenizer von bge-m3 gemessen lag der mittlere Chunk bei 771 Tokens, nicht bei 700, der längste bei 2.050. Das spielt im nächsten Schritt eine Rolle. Lokal einbetten 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) Die Skripte laufen mit uv run, das sentence-transformers und torch aus den Inline-Metadaten am Anfang jeder Datei installiert. Zum Vergleich liefen dieselben Chunks auch durch bge-small-en-v1.5, ein englisches Modell mit 33 Mio. Parametern, etwa 17-mal kleiner (seine Anfragen bekommen das vom Modell empfohlene Präfix „Represent this sentence for searching relevant passages: “). bge-m3 bge-small-en-v1.5 Vektorgröße 1.024 384 Max. Eingabe 8.192 Tokens (hier auf 2.048 begrenzt) 512 Tokens Abgeschnittene Chunks 1 von 318 235 von 318 Zeit zum Einbetten von 318 Chunks (M1, CPU) 631 s 20,5 s Spitzenspeicher 1,6 GB nicht gemessen Vektoren auf der Platte (float32) 1,3 MB 0,49 MB Eine Anfrage einbetten 682 ms 21 ms Zehn Minuten für ein Repository mit 625 KB sind langsam, etwa 2 Sekunden pro Chunk auf diesem Rechner, und der Aufwand wächst mit dem Code, nicht mit der Dateizahl. Er fällt nur einmal an: Die App schlüsselt Chunks nach einem Hash ihres Textes, ein unveränderter Chunk behält also seinen Vektor, und das Ändern einer Funktion bettet nur eine Handvoll Chunks neu ein. Die Geschwindigkeit des kleinen Modells hat einen Haken, den die Tabelle deutlich zeigt: Bei 512 Tokens las es von 235 der 318 Chunks nur den Anfang. Eine Regel markiert der Quelltext der App mit einer roten Markierung, und sie lässt sich im eigenen Code leicht brechen. Die Anfrage muss mit demselben Modell eingebettet werden, das den Index gebaut hat. Ein bge-m3-Chunk-Vektor und ein Anfragevektor eines anderen Modells mit 1.024 Dimensionen haben dieselbe Form, also gibt es keinen Fehler, und die Ergebnisse sind einfach falsch. Keywords, Vektoren und ihre Verschmelzung grep bekam die Formulierung, die man eintippen würde, ohne Beachtung der Groß-/Kleinschreibung, über dieselben 117 Dateien. Als fairere Keyword-Baseline lief die ganze Frage außerdem durch BM25 über die Chunks, wobei Bezeichner an _ und camelCase getrennt wurden, damit should_strip_auth auf „strip“ passt. (Die Keyword-Seite der App ist tantivy über ganze Dateien, abgebildet auf die ersten Chunks jeder Datei; das Ranking pro Chunk ist eine Vereinfachung.) Die beiden Rankings werden mit Reciprocal Rank Fusion bei k = 60 verschmolzen, derselben Konstante wie in der App, mit den Top 30 jeder 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 ignoriert die Scores und nutzt nur die Positionen, und genau das ist der Punkt. Ein BM25-Score und eine Kosinus-Ähnlichkeit liegen nicht auf derselben Skala, und den einen gegen den anderen zu normalisieren erfindet einen Zusammenhang, den es nicht gibt. Ein Chunk, der in einer Liste auf Platz 3 und in der anderen auf Platz 5 steht, schlägt einen, der in einer Liste auf Platz 1 steht und in der anderen fehlt. Die Antwort für jede Frage ist der Chunk mit der def-Zeile der Zielfunktion, ausgewählt durch Lesen des Codes, bevor irgendetwas lief. Angegeben ist der Rang dieses Chunks unter allen 318. Ergebnisse Frage grep-Formulierung grep ein Wort BM25 bge-m3 Hybrid bge-small das Passwort nach einer Weiterleitung nicht an eine andere Seite schicken (should_strip_auth) "another site": 0 password: 47 9 1 1 1 warum wird aus einem POST nach einer Weiterleitung ein GET (rebuild_method) "turn into": 0 GET: 625 65 10 19 6 Weiterleitungen nicht mehr folgen, die im Kreis laufen (resolve_redirects) "redirect loop": 0 loop: 6 5 28 5 14 den Upload an den Anfang zurücksetzen, damit er erneut gesendet werden kann (rewind_body) "back to the start": 0 start: 54 1 4 2 20 wie groß ist der Body, den wir gleich senden (super_len) "how big": 0 big: 8 54 65 keiner 233 den Proxy für Rechner im lokalen Netzwerk überspringen (should_bypass_proxies) "local network": 0 local: 94 2 20 6 10 sicherstellen, dass eine Datei nie halb geschrieben bleibt, wenn der Prozess stirbt (atomic_open) "half-written": 0 half: 4 58 16 29 1 UTF-16 von UTF-32 an den ersten Bytes des JSON unterscheiden (guess_json_utf) "first bytes": 0 bytes: 98 4 4 5 111 gespeicherte Logins aus einer Datei im Home-Ordner lesen (get_netrc_auth) "home folder": 0 login: 4 1 112 7 17 das 401 des Servers mit einem gehashten Passwort beantworten (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 Die Ränge beziehen sich auf 318 Chunks; die grep-Zahlen sind passende Zeilen. „Hybrid“ ist BM25 verschmolzen mit bge-m3. Drei Dinge fallen auf. Dass grep bei einer Formulierung null liefert, heißt nicht, dass Keywords nutzlos sind. Über die Wörter der Frage gerankt, setzte BM25 die Antwort bei 6 der 10 in die Top 5. „Redirect“, „proxy“, „401“ und „UTF-16“ kommen alle im Code vor; nur die Formulierung drumherum passt nicht. Die Ein-Wort-Suchen zeigen das umgekehrte Problem: 625 Zeilen für GET, 98 für bytes. Sie finden die Stelle irgendwo in einem Haufen, den man dann von Hand liest. Die Erfolge der Embeddings sind echt, aber speziell. bge-m3 fand should_strip_auth auf Platz 1 bei einer Frage, die kein Wort mit der Funktion teilt, und holte rebuild_method von Platz 65 bei BM25 auf Platz 10. Das kleine Modell setzte atomic_open bei „never left half-written if the process dies“ auf Platz 1, wo BM25 es auf 58 hatte; der Docstring dieser Funktion sagt „atomic fashion“, und das Modell weiß, was atomar bei einer Datei bedeutet. Und Vektoren scheitern auf Arten, die Keywords nicht kennen. Beim reinen Bezeichner raise_for_status setzte bge-m3 den definierenden Chunk auf Platz 70, bge-small auf 244. Die Verschmelzung ist der sicherere Standard, nicht in jeder Zeile der beste. Bis auf die eine Frage, die keiner fand, war Hybrid nie die schlechteste Spalte und meist nah an der besten, und genau das will man von einem Werkzeug, das man aufruft, ohne vorher zu wissen, welche Art von Frage man hat. „How big is the body“ scheiterte überall: super_len sagt nie in Worten, die eine Frage benutzen würde, was es misst. Treffer in Top 5 / Top 10 (10 Fragen) BM25 bge-m3 Hybrid (bge-m3) bge-small Hybrid (bge-small) Ganzes Repo, 318 Chunks 6 / 7 4 / 5 5 / 7 3 / 5 7 / 7 Nur src/, 84 Chunks 7 / 7 5 / 7 7 / 9 5 / 7 8 / 8 Tests und Doku sind der größte Konkurrent Die meisten Fehlgriffe waren nicht zufällig. Bei „why does a POST turn into a GET“ war das erste Ergebnis von bge-m3 tests/test_requests.py, rund um test_http_303_changes_post_to_get. Bei „read saved logins from a file in the home folder“ war es der Changelog-Eintrag zu .netrc in HISTORY.md, und bei „move the upload back to the start“ und „how big is the body“ ein Abschnitt in docs/user/advanced.rst. Tests und Doku beschreiben Verhalten in einfachen Worten, genau wie eine Frage, und liegen ihr deshalb oft näher als die Implementierung. Das ist keine falsche Antwort, denn der Test nennt die Funktion. Aber das Ranking ändert sich stark, sobald man sie herausfiltert. Bei einer Suche nur in den 84 Chunks unter src/ mit denselben Vektoren fand die Hybridsuche 9 von 10 in den Top 10. Die billigste Verbesserung in diesem Repo war kein größeres Modell, sondern ein Pfadfilter, oder wenigstens eine Kennzeichnung von Test- und Doku-Treffern, damit man sie überspringen kann. Das kleinere Modell hielt mit, obwohl es bei den meisten Chunks nur die ersten 512 Tokens las, wahrscheinlich weil die ersten Zeilen eines Chunks, mit dem def und seinem Docstring, das meiste davon tragen, worum es geht. Zehn Fragen an einem Repository reichen nicht, um eines der beiden Modelle besser zu nennen. Sie reichen, um zu sehen, dass die Modellwahl weniger zählte als die Verschmelzung mit Keywords und das Fernhalten der Tests. Ausführen 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 Beim ersten Lauf von embed.py wird bge-m3 von Hugging Face geladen, etwa 2,3 GB. Die Anfragen sollten vor dem Blick auf die Ergebnisse geschrieben und die Antworten durch vorheriges Lesen des Codes festgelegt werden. Es ist sehr leicht, die Formulierung einer Frage so lange anzupassen, bis die Vektorsuche gewinnt. Die Desktop-App osFoundry führt dieselbe Pipeline über ihren Indexes-Bildschirm aus, mit bge-m3 auf dem Gerät und BM25 und Vektoren verschmolzen in ihrem Agent-Tool folder_search. Der Chunking-Eintrag im Glossar behandelt die Abwägungen bei Chunk-Größe und Überlappung, und die App ist auf der osFoundry-Download-Seite kostenlos erhältlich.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to