Über michErfahrungBlogKontakt
LinkedInGitHubGitLabWhatsApp
Alle Beiträge

4 Min. Lesezeit

Private Codesuche: der Parameter, den niemand geschrieben hat

repowiselitellmollamaembeddingsself-hosted

Semantische Suche über Firmencode, bei der nichts das Firmennetz verlässt und die Antworten von einem 120B-Modell auf Firmenhardware geschrieben werden. Vier Werkzeuge, zwei Modelle, zwei Stolperfallen und ein Feld in jedem Request, das auf meiner Seite niemand geschrieben hatte.

Der Stack

Repowise indexiert das Repository: parst Dateien, baut den Symbolgraphen, schneidet Chunks und stellt das Ergebnis über MCP bereit, sodass jeder Agent search_codebase und get_answer aufrufen kann, statt das halbe Repository zu lesen. LanceDB, mitgeliefert, speichert die Vektoren auf der Platte, direkt im Repo. Ollama betreibt die offenen Gewichte, und zwar an zwei Orten. Auf dem Laptop macht nomic-embed-text aus jedem Chunk einen 768-dimensionalen Vektor, klein genug für ein Notebook. Auf dem Firmenserver schreibt gpt-oss:120b die Wiki-Seiten und formt aus gefundenen Chunks Antworten. LiteLLM steht als Routing-Proxy des Teams vor dem Server und spricht die OpenAI-kompatible API: ein Endpoint, ein Key, eine Modellliste, und das Backend hinter einem Modellnamen auszutauschen ist eine Konfigurationsänderung, die auf Client-Seite niemand bemerkt.

Das ist das Setup, bei dem ich gelandet bin. Es ist nicht das, mit dem ich angefangen habe.

Repository
ParsenRepowise auf dem Laptop: Dateien, Symbole, Abhängigkeitsgraph
Wiki-Seiten schreibengpt-oss:120b über LiteLLM auf dem Firmenserver
Einbettennomic-embed-text im lokalen Ollama: jede Seite und jeder Chunk wird ein 768-dimensionaler Vektor
SpeichernLanceDB auf der Platte, direkt im Repo
Ein Agent stellt eine Fragebis dahin läuft nichts
AbrufenRepowise auf dem Laptop, zwei Suchen zu einem Ranking verschmolzen
VolltextVektor
Antwortengpt-oss:120b über LiteLLM schreibt sie aus den gefundenen Chunks

Die ersten vier Schritte laufen einmal und danach inkrementell nach jedem Commit. Die letzten beiden laufen bei jeder Frage eines Agenten. Der Wiki-Durchlauf ging vom ersten Tag an über LiteLLM und hat nie Ärger gemacht. Nichts in diesem Ablauf verlässt das Firmennetz, und der Schritt, der am häufigsten läuft, das Einbetten, verlässt nicht einmal den Laptop.

Zuerst: die Tür hat keinen Namen

Bevor irgendetwas davon laufen konnte, ließ sich der Hostname des Proxys nicht auflösen. Der Laptop hing per VPN in einer Azure-Umgebung, nicht im Firmennetz, und dieses VPN fängt jede DNS-Anfrage ab und schickt sie an den Resolver von Azure, der von den internen Firmennamen noch nie gehört hat. Windows kann ein einzelnes Suffix an einen eigenen Resolver leiten: eine Regel in der Name Resolution Policy Table. Eine Zeile in einer Admin-PowerShell, kein Neustart, überlebt auch VPN-Neuverbindungen:

Add-DnsClientNrptRule -Namespace ".internal.example" -NameServers "10.0.0.2","10.0.0.3"
nslookup litellm.internal.example

Stolperfalle 1: der Parameter, den niemand geschrieben hat

Meine erste Version schickte auch die Embeddings über LiteLLM, an bge-m3 auf dem Server. Eine Tür für alles, ein Prozess weniger auf dem Laptop. Dateisuche und Symbolsuche funktionierten. Die erste Frage, die Bedeutung brauchte, lieferte das hier:

unsupported parameter
Indexed 0 items (N failed)

Ein anderer Client gegen denselben Endpoint und dasselbe Modell funktionierte. Ich habe die beiden Requests verglichen und genau einen Unterschied gefunden: encoding_format.

Ich hatte es nie gesetzt. Der OpenAI-Client setzt es für dich. Wenn du in embeddings.create() nicht sagst, wie du die Zahlen kodiert haben willst, fordert er base64 an und dekodiert die Bytes anschließend selbst zurück in Floats. Kleinere Antwort, vernünftiger Default gegen OpenAIs eigene Server.

# im openai-Paket, nicht in deinem Code
if not is_given(encoding_format):
    params["encoding_format"] = "base64"

LiteLLM übersetzt jedes Feld des Requests in die eigene API des Backends. Der Embed-Endpoint von Ollama kennt kein solches Feld, also ist für LiteLLM jeder Wert von encoding_format unbekannt, und unbekannt heißt standardmäßig abgelehnt. Ein explizites "float" ändert den Wert, nicht das Vorhandensein des Feldes, und geprüft wird das Vorhandensein. Einen Schalter für „nichts schicken“ gibt es nicht.

Der Client hat zwei Schichten: bequeme Methoden wie create(), die den Body bauen und Defaults einsetzen, und darunter ein schlichtes post(), das Basis-URL, Auth, Retries und Timeouts kennt, dem Body aber nichts hinzufügt. Das unerwünschte Feld lebt in der oberen Schicht, also habe ich den Embedder von Repowise so gepatcht, dass er diese Schicht überspringt.

# Repowise-Embedder, gepatcht: direkt die HTTP-Schicht des Clients aufrufen
response = client.post(
    "/embeddings",
    body={"model": model, "input": chunks},
    cast_to=CreateEmbeddingResponse,
)
vectors = [item.embedding for item in response.data]

cast_to parst die Antwort in dieselbe typisierte Struktur, die create() zurückgegeben hätte, Repowise merkt also nichts. Keine Kodierung angefordert, Ollama antwortet mit einfachen Floats. Das Modell war austauschbar. Die Form des Requests war es nicht.

Stolperfalle 2: die Breite

Die Requests gingen jetzt durch, und LanceDB lehnte jeden Vektor wegen falscher Form ab. Repowise kannte die Modelle von OpenAI und nahm für alles andere 1536 an, bge-m3 liefert aber 1024. REPOWISE_EMBEDDING_DIMS=1024 behebt das.

Ergebnis: 1024-dimensionale Vektoren, rund 1,6 Sekunden pro Eintrag über LiteLLM, null Fehler. Es funktionierte.

Der Fix, den ich nicht genommen habe, und der, den ich genommen habe

LiteLLM hat genau dafür eine Einstellung: drop_params: true verwirft jeden Parameter, den das Backend nicht versteht. Eine Zeile in der Proxy-Konfiguration, kein Patch, Problem weg. Ich habe nicht darum gebeten. Der Proxy wird von allen Teams geteilt, und ein Proxy, der unbekannte Felder schluckt, hätte meinen Fehler versteckt und die aller anderen Teams gleich mit: einen vertippten Parameter oder einen, den ein Backend wirklich braucht.

Der Patch ließ die Kosten des Fehlers dort, wo er gemacht wurde. Es blieb trotzdem ein Patch, an einem Paket, das das nächste pip install -U stillschweigend zurücksetzen würde, also wuchs daraus ein Skript mit einem --check-Modus, das beim Start der Shell läuft und sich meldet, bevor das nächste Indexed 0 items kommt.

Dann stellte ich die Frage, die ich zuerst hätte stellen sollen: Warum gehen Code-Chunks überhaupt übers Netz? Ein 768-dimensionaler Embedder läuft problemlos auf einem Laptop. 1433 Seiten lokal eingebettet, null Fehler, kein Proxy im Weg, kein Roundtrip pro Chunk, kein Parameter, über den man streiten müsste, kein Patch-Skript zu pflegen. Das Einzige, was den Server noch braucht, ist das 120B-Modell, das die Antworten schreibt, und genau dafür ist LiteLLM da.

Setup, die Form, bei der ich gelandet bin

pip install repowise
ollama pull nomic-embed-text

export OLLAMA_EMBEDDING_MODEL=nomic-embed-text
export LITELLM_BASE_URL=https://litellm.internal.example/v1
export LITELLM_API_KEY=$KEY
export REPOWISE_MODEL=gpt-oss:120b

cd repo && repowise init

Konfigurationsdateien, Secrets und andere sensible Pfade gehören in die Ignore-Datei, bevor der erste Chunk geschnitten wird.

Reicht das für eine ernsthafte Codebasis?

Größtenteils. nomic-embed-text ist ein allgemeiner Text-Embedder, auf identifikatorlastigem Code schwächer als bge-m3 oder ein auf Code abgestimmtes Modell. Repowise gleicht das aus, indem es Volltext- und Vektortreffer verschmilzt, exakte Namen werden also weiterhin gefunden, und der Embedder ist nur einen ollama pull vom Austausch entfernt. Bei einem großen Monorepo braucht der Laptop fürs erste Einbetten lange, danach geht es nur noch inkrementell. Die Teile, die sich nach Verstehen statt nach Suche anfühlen, get_overview, get_why, das Wiki, kommen aus dem Prosa-Durchlauf, und dort verdient sich das 120B-Modell seinen Platz.

Schick nur, was du meinst. Bitte den Server nicht, dir zu verzeihen, dass du es nicht getan hast. Der beste Fix für einen Parameter, den niemand geschrieben hat, war ein Request, den niemand schickt.