Repository navigation
feat(search): hybride Suche mit EmbeddingGemma 2 über llama-server, RRF mit Volltext, Rückfall (REQ-535) - #153
Merged
Merged
Conversation
…RF mit Volltext, Rückfall (REQ-535) Mit GHOSTTREE_EMBED_URL mischt die Wissenssuche (context_search, /api/search, Web-Suche) die Volltext-Rangfolge und eine Vektor-Rangfolge per Reziprok-Rang-Fusion (k = 60). Ohne die Einstellung läuft nichts und die Ausgabe bleibt die heutige. - internal/embed: Texte nach der Modellkarte mit Präfixversion, HTTP-Client für /v1/embeddings, int8-Quantisierung, Kosinus, Rangfusion. - Tabellen knowledge_embeddings (Modell, Präfixversion, Dimension, Texthash, Skalierung, int8-Vektor, Lesezeit), knowledge_embedding_failures und knowledge_embedding_state; Schreiben über den Runtime-Writer. - internal/embedjob: gedrosselter Hintergrundjob (Batch 8, Pause 2 s, ein Aufruf zugleich, exponentielles Zurückweichen, fortsetzbar). Ein Text, an dem der Dienst scheitert, wird einzeln ermittelt und geparkt; im Leerlauf werden nur seit dem Vektor geänderte Einträge gelesen. - Sichtbarkeit: Scope und Globalregel in der Abfrage, dazu ein Vorfilter nach Projektrolle, die Zeilenregel der Aufrufer vor der Fusion; verborgene Zeilen verschieben keine sichtbaren, und die Zeilenregel läuft höchstens 1000 Mal. - Keine Auffüller: Mindestähnlichkeit 0,55 und höchstens fünf rein semantische Treffer je Suche; als genutzt zählt, was die API ausliefert, einmal. - Rückfall auf die Volltextsuche bei Zeitüberschreitung, Ausfall oder Fehler, ein Log je Minute. - ctx embed status, ctx eval search, deploy/ghosttree-embed.service und der Abschnitt in deploy/README.md.
NurRobin
force-pushed
the
feat/hybrid-search-embeddings
branch
from
October 20, 2026 13:18
ddef815 to
30ee53e
Compare
6 of 9 tasks
NurRobin
added a commit
that referenced
this pull request
Oct 20, 2026
…nicht hinter dem Nachrechnen wartet (REQ-535) (#154) # Pull request ## What changed? **Eigener Embedding-Server für Suchanfragen (REQ-535, Folgeauftrag zu #153).** Solange der Hintergrund-Job Dokumente einbettet (Batch 8, auf zwei Kernen etwa 5 s je Dokument), wartet die Einbettung einer Suchanfrage im selben `llama-server` hinter dem Batch. Gemessen: jede Suche brauchte 1,59 s, lief damit in die Zeitgrenze von 1,5 s und fiel auf Volltext zurück. `-np 2` hilft nicht, weil beide Slots dieselben Rechenthreads teilen; `-ub`/`-b` lassen sich nicht verkleinern, weil eine Einbettung die ganze Sequenz in einem ubatch braucht. Beim Erstlauf über rund 2800 Einträge dauert das Stunden, danach kurz bei jedem neuen oder geänderten Eintrag. Anfragen und Hintergrund-Job dürfen deshalb getrennte Server nutzen. ### Option `GHOSTTREE_EMBED_QUERY_URL` / `--embed-query-url` (`ctx serve`, `ctx embed status`, `ctx eval search`): Basis-URL des Servers, der Suchanfragen einbettet. - Leer heißt: der Server von `--embed-url`. Das ist das heutige Verhalten, ein Client, keine Prüfung, dieselbe `embed_config`-Logzeile (getestet). Dieselbe Adresse zweimal (auch mit Schrägstrich oder `/v1/embeddings`) zählt als ein Server. - Der Job nimmt immer `--embed-url`. - Dieselben URL-Prüfungen wie bei `--embed-url` (`embed.ParseURL`: kein Zugang, keine Query, kein Fragment; Fehlermeldung nennt `--embed-query-url`), und `ctx serve` warnt bei einer Adresse, die nicht Loopback ist, weil dann jede Suchanfrage die Maschine verlässt. - Nur die Query-URL ohne `--embed-url` ist ein Konfigurationsfehler (`--embed-query-url needs --embed-url: …`): `ctx serve` startet nicht, `embed status` und `eval search` enden mit Exit 2. Ohne Vektoren gibt es nichts zu durchsuchen. ### Dasselbe Modell auf beiden Servern Ein Anfragevektor ist nur neben Vektoren desselben Modells etwas wert. `internal/embed/modelcheck.go`: `ctx serve` schickt im Hintergrund (blockiert nichts) einen festen kurzen Text im Anfrageformat an beide Server und vergleicht die Vektoren. Gleiche Dimension und Kosinus mindestens 0,999 heißt „gleich“ (dieselbe Datei auf anderen Threads oder in anderem Build weicht nur durch Rundung ab, weit darüber; anderes Modell oder andere Quantisierung liegt weit darunter). - Weichen sie ab: `embed_models_differ` als **Error** mit dem, was abweicht, und die Suche nutzt den Vektorteil nicht (`EmbedSearchConfig.Ready`, die Suche antwortet aus Volltext wie bei jedem anderen Ausfall, kein Fehler an den Aufrufer, die Anfrage wird nicht mehr gesendet), bis eine Prüfung Gleichheit findet (`embed_models_agree`). Der strenge Modus der Messung meldet es als Fehler. Der Job läuft weiter, die gespeicherten Vektoren sind die des Job-Servers. - Wie oft: alle **10 Minuten**, solange sie übereinstimmen. Ein Modell wird getauscht, indem jemand einen Server neu startet (selten); ein Anfragevektor eines anderen Modells macht das Ranking schlechter, nicht falsch, und eine Prüfung kostet zwei kurze Texte, einen davon im Slot des Servers, der gerade den Job rechnet. Bei Abweichung **jede Minute**, damit die Suche eine Prüfung nach der Korrektur zurück ist. - Antwortet ein Server nicht (aus, lädt das Modell noch, zu beschäftigt), wiederholt die Prüfung nach 5 s, 10 s, … bis 1 min (`embed_models_unverified`) und ändert nichts: das letzte Urteil gilt, und vor dem ersten läuft die Suche wie mit einem Server. Ein Job-Server, der aus oder ausgelastet ist, schaltet den Vektorteil also nicht ab. Je Zustand höchstens eine Logzeile alle 10 Minuten. ### Weitere Stellen - `ctx embed status`: bei verschiedenen Servern `job server`, `query server` und eine Zeile `same model` (ja mit Kosinus, NEIN mit Abweichung, oder „not checked“). Ohne zweiten Server unverändert. - `ctx eval search --embed-query-url`: die Anfragen gehen an den Anfrage-Server; vorher eine Prüfung auf dasselbe Modell, bei Abweichung Abbruch (die Zahlen wären wertlos), bei einem Server, der nicht antwortet, Hinweis auf stderr und Weiterlauf. - `deploy/ghosttree-embed-query.service` (neu, Beispiel): zweiter `llama-server` mit derselben GGUF-Datei, Port 8476, `-t 1 -np 1 -c 1024 -ub 1024 -b 1024`, Container mit `--cpus 1 --memory 768m --memory-swap 768m`, read-only, `--cap-drop ALL`, nur auf `127.0.0.1`. `deploy/ghosttree-embed.service` bleibt der Dienst des Hintergrund-Jobs; sein Kommentar zu `-np 2` ist korrigiert. - `deploy/README.md`: Abschnitt „A second server for queries“ (warum zwei Prozesse, mit der Messung, Konfiguration, Modellprüfung und Takt, ein Server allein funktioniert weiter, mit dem Nachteil, dass eine Suche während des Jobs aus Volltext antwortet). Die frühere Aussage, zwei Slots ließen eine Suche ihre Anfrage einbetten, während ein Batch läuft, ist durch die Messung ersetzt. - CHANGELOG (Unreleased). ## Verification - `gofmt -l .` leer, `go vet ./cmd/ctx ./internal/embed ./internal/embedjob ./internal/store ./internal/eval/...`, `go build ./...`: sauber. - `go test ./cmd/ctx ./internal/embed/... ./internal/embedjob ./internal/eval/...`: ok. `internal/embed` und die neuen Tests in `cmd/ctx` zusätzlich mit `-race` und mehrfach (`-count=8`/`-count=15`). - `go test ./internal/store -run 'Embed|Hybrid|Search'`: ok. - `go test ./internal/store` (ganze Suite): ok (522 s). - Neue Tests: zwei Fake-Server mit Zähler je Server (Anfrage-Text nur am Anfrage-Server, Dokumente nur am Job-Server, Probe an beiden); leere Query-URL = ein Server ohne Probe und ohne neue Logzeile; Abweichung der Modelle (andere Dimension, anderer Vektor) = Volltext-Antwort, kein Fehler, keine weitere Anfrage an den Anfrage-Server, der Job läuft weiter; Modellprüfung mit Wechsel auf abweichend und zurück, nicht antwortender Server hebt das Urteil nicht auf, Ende mit dem Kontext; `Ready` im Store (Fallback, strenger Modus, Rückkehr); Konfigurationsfehler und URL-Prüfungen für `serve`, `embed status`, `eval search`; Warnung bei Nicht-Loopback; `embed status` und `eval search` mit zwei Servern. - Nicht geprüft: der Betrieb mit echten `llama-server`-Prozessen (die neue Unit ist ein Beispiel und nicht gestartet worden). ## Release impact - [ ] `major` — incompatible after 1.0 - [x] `minor` — compatible feature, or incompatible before 1.0 - [ ] `patch` — compatible fix - [ ] `none` — no release impact ## Checklist - [x] This pull request targets `dev` (or is an approved release/hotfix promotion). - [x] Tests cover changed behavior. - [x] I did not add credentials, private infrastructure details, or internal planning documents. - [x] User-facing behavior and the changelog are updated where needed. - [x] I have read and agree to the [Contributor License Agreement](../CLA.md), and I am authorized to make this contribution.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull request
What changed?
Hybride Suche über Wissenseinträge mit EmbeddingGemma 2 (REQ-535). Mit einem Embedding-Server auf Loopback (
GHOSTTREE_EMBED_URL, llama.cppllama-server) mischt die Wissenssuche (context_search,GET /api/search, Web-Suche/ui/searchund die Suche auf/ui/knowledge) die Volltext-Rangfolge und eine Vektor-Rangfolge per Reziprok-Rang-Fusion (k = 60). Ein Treffer, den nur der Vektor fand, trägt"match": "semantic"(API) bzw.match:semanticim Label des MCP-Werkzeugs. Ohne die Einstellung läuft nichts, und die Ausgabe ist die heutige, Byte für Byte (getestet gegen die Volltextsuche). Der Server bleibt cgo-frei, es kommt keine neue Abhängigkeit hinzu: Inferenz ist ein eigener Prozess, ghosttree sprichtPOST /v1/embeddingsundGET /healthper Standardbibliothek.Aufbau
internal/embed(nur Standardbibliothek): Texte nach der Modellkarte (Anfragetask: search result | query: {q}, Dokumenttitle: {Titel} | text: {Body}, ohne Titeltitle: none) mit festerPrefixVersion; HTTP-Client; Normieren, int8-Quantisierung (je Vektor symmetrisch, vorher L2-normiert), Kosinus, Rangfusion.embed/embedtestist ein Fake-Server mit deterministischen Vektoren für die Tests. Dokumenttext höchstens 2000 Zeichen (ohne Tokenizer nur zählbar; auch ein Token je Zeichen bleibt unter dem Kontext von 2048 je Slot, für Prosa sind es 500 bis 700 Token).CREATE TABLE IF NOT EXISTS, keinupgrade-schema, Pitfall #432):knowledge_embeddings(knowledge_id PK, model, prefix_version, dim, text_hash, scale, vec BLOB, read_at, updated_at),knowledge_embedding_failures(geparkte Einträge) undknowledge_embedding_state(eine Zeile fürctx embed status). Schreiben über den begrenzten Runtime-Writer (UpsertKnowledgeEmbedding,TouchKnowledgeEmbeddings,RecordEmbeddingFailure,SetEmbedJobStatemitqueueWrite); alle neuen Store-Methoden stehen im Writer-Inventar.internal/embedjob: Hintergrundjob im Server, startet 10 s nach dem Server in einer eigenen Goroutine. Fällig sind aktive Einträge ohne Vektor, mit anderem Modell oder Präfix oder mit geändertemtext_hash(Neueste zuerst). Höchstens--embed-batch(8) Dokumente je Anfrage,--embed-pause(2 s) zwischen den Anfragen, nie mehr als ein Aufruf zugleich, bei Fehlern 5 s, 10 s, 20 s … bis--embed-max-backoff(5 min). Fortsetzbar, weil der Zustand die Tabellen sind.internal/store/search_hybrid.go): die gemeinsame Stelle istStore.searchKnowledgeWith, hinterSearchKnowledge,SearchKnowledgeForContext,SearchAllKnowledgeund dem neuenSearchKnowledgeWith. Anfrage einbetten (synchron,--embed-query-timeout1500 ms), Kosinus über die int8-Vektoren in Go (Brute Force), je Liste die ersten 100, RRF, dannlimit.ctx embed status --db <pfad>: aktive Einträge, eingebettet, fällig (davon geparkt), Zustand des Jobs, Probe des Servers (Health plus eine echte Einbettung mit Zeit).ctx eval search --db <pfad> …: Messwerkzeug (siehe unten).deploy/ghosttree-embed.serviceund der Abschnitt „Embeddings for hybrid search“ indeploy/README.md; CHANGELOG (Unreleased); eine Zeile in der README.Konfiguration (Standardwerte)
GHOSTTREE_EMBED_URL--embed-urlGHOSTTREE_EMBED_MODEL--embed-modelembeddinggemma-2-q8_0GHOSTTREE_EMBED_QUERY_TIMEOUT--embed-query-timeout1500msGHOSTTREE_EMBED_BATCH--embed-batchGHOSTTREE_EMBED_PAUSE--embed-pause2sGHOSTTREE_EMBED_IDLE--embed-idle30sGHOSTTREE_EMBED_MAX_BACKOFF--embed-max-backoff5mGHOSTTREE_EMBED_MIN_SIMILARITY--embed-min-similarity0.55(0= kein Minimum)Sichtbarkeit im Vektorteil
Der Vektorteil rechnet nur über Zeilen, die der Leser sehen darf, und dasselbe gilt für die Rangfolgen, bevor sie gemischt werden:
FilterWhere/UnionWhere),withGlobalScope(globale Einträge anderer Autoren fallen für erzwingende Betrachter heraus),status = 'active'und die Ausschlüsse der Sicht (Agentensicht: nicht quarantäniert; Betreibersicht: quarantänierte bleiben).ProjectAccess.KnowledgePrefilter): Einträge ohne Projekt, Einträge der Projekte, in denen das Konto eine Rolle hat, und Einträge unbeanspruchter Projekte (nur Autor oder Instanz-Admin). Er ist nie enger als die Zeilenregel (Test: gleiche Antwort mit und ohne Vorfilter für Gast, Mitglied, Owner, Fremden, unbeanspruchtes Projekt), aber Einträge fremder Projekte werden weder verglichen noch der Zeilenregel übergeben.CanSeeKnowledgeundCanDeliverKnowledgein/api/search;CanSeeKnowledgeplus Status in der Web-Suche) wird an die Suche übergeben (KnowledgeSearch.Keep) und auf beide Listen angewandt, bevor Ränge vergeben werden. Gäste sehen so nurtrusted/verified, Fremde nichts, ein anderes Projekt nichts. Eine verborgene Zeile kann weder erscheinen noch die Reihenfolge sichtbarer verändern (Pitfall #2447). Der Test baut das so, dass Fusion vor Filter eine andere Reihenfolge ergäbe (40 verborgene Zeilen vor den sichtbaren) und prüft Gleichheit mit der Welt ohne verborgene Zeilen.Rückfall
Zeitgrenze überschritten, Server aus, HTTP-Fehler, kaputte Antwort oder ein Fehler im Vektorteil selbst: Ergebnis der reinen Volltextsuche, kein Fehler an den Aufrufer, ein Log
embed_search_fallbackje Minute (ohne Anfragetext). Eine Anfrage nur aus Füllwörtern („was ist noch zu tun“) wird nie an den Server geschickt (Pitfall #732).Keine Auffüller, und was als genutzt zählt (Review-Befunde 1 bis 4)
limitauf, der „cut at …“-Hinweis stimmt wieder. Der Wert 0,55 stützt sich auf die Messung: der beste Nachbar jeder der 398 Zitat-Anfragen hatte bei EmbeddingGemma 2 einen Kosinus von mindestens 0,66, der mittlere etwa 0,78; Zitate sind länger als Fragen von Menschen und erreichen höhere Werte, daher ist 0,55 ein Startwert, der auf dem Server mitctx eval search --embed-min-similarity …nachzujustieren ist (Test mit dem Fake-Raum: Nähe etwa 0,71 drin, Ferne 0 draußen). Die reine Vektor-Rangfolge der Messung hat weder Deckel noch Auffüllung.GET /api/searchruft die Suche mitNoRecordund zählt danach (RecordKnowledgeSearchHits, best-effort über den Bookkeeper) nur die Treffer, die nach Zeilenregel undlimitwirklich in der Antwort stehen. Das gilt auch ohne Embeddings und ist dort eine Verhaltensänderung bei den Zählern, nicht bei der Ausgabe: bisher zähltesearch_hitsauch den Überhang (bei erzwungenem Zugriff das Vierfache vonlimit) und Einträge, die die Regel danach verwirft. Die Zähler ordnen den Bootstrap und speisenctx usage; sie bewegen sich jetzt nur noch durch tatsächlich Gelieferte. Test: zwei Wortmatches und dreißig Nachbarn (7 geliefert, 7 gezählt, je einmal), und Volltext allein mitlimit=1.knowledge_embedding_failuresgeparkt: 5 min, dann 10, 20 … bis 6 h, mit Fehlerzahl; ein dauerhaft abgelehnter Text (400/413/422) bis zur Änderung des Eintrags, des Modells oder des Formats. Beantwortet der Server keinen einzigen Text, ist es der Server: nichts wird geparkt, es bleibt beim Rückweichen. 404/401/5xx schreiben nie ab.read_atdes Vektors geändert wurden (jede Textänderung läuft überupdated_at, ich habe alleUPDATE knowledgegeprüft). Eine Änderung ohne Textänderung (neue Vertrauensstufe) kostet keine Anfrage an den Server, nur einen Schreibzugriff.read_atist die Zeit vor dem Lesen des Textes, damit eine Änderung während der Berechnung nicht verloren geht (Test). Ein vollständiger Blick auf alle Einträge geschieht einmal beim Start. Bekannte Grenze: ein Text, der ohneupdated_atgeändert wird (kein Pfad im Code tut das), fällt erst beim nächsten Start auf.-np 2im README und in der Unit ist jetzt vorsichtig formuliert („gedacht“, auf dem Server zu messen).Requests: bewusst nicht umgesetzt
Die Auftragssuche (
SearchRequests) ist keine Rangfolge, sondern ein Filter inid-Reihenfolge mitid-Cursor (ORDER BY r.id DESC,NextCursor) und eigenen Sichtbarkeitsfiltern (Restrict,Projects,Unclaimed*,HideHeld). Eine Fusion würde sie in eine Relevanzsuche mit neuem Cursor verwandeln, dazu kämerequest_embeddingssamt Job-Zweig und Status. Das bleibt mit denselben Bausteinen nicht klein. Der Bericht misst Requests zudem nur an 51 Anfragen. Die Bausteine (internal/embed, Fusion, Job) sind wiederverwendbar; ein eigener Auftrag kann sie nutzen. Sessions und Ghost-Dateien sind ausgenommen, Hook (relevance.go,/api/context/relevant) und Gärtner sind unverändert.Messwerkzeug
ctx eval searchctx eval search --db <pfad> [--sample 1500] [--seed 20261020] [--embed-url …] [--min-chars 30] [--embed-min-similarity 0.55]: Anfragen sind die Evidenzzitate (knowledge_evidence.quote, mindestens 30 Zeichen, nur aktive Einträge), das Ziel ist derenknowledge_id. Es ruftSearchKnowledgeWith(dieselbe Funktion wie der Server) einmal je Rangfolge: Volltext allein (= vorher), Vektor allein, hybrid (= nachher; ohne Rückfall, ein Ausfall des Servers bricht den Lauf ab), in der Betreibersicht (alle aktiven Einträge, quarantänierte eingeschlossen). Ausgabe nur Aggregate: n, R@1, R@3, R@10, MRR@10, für alle Ziele, für die nicht quarantänierten Ziele und für die Agentensicht ohne quarantänierte Einträge. Nie ein Zitat oder Text (getestet). Die Datenbank wird nur lesend geöffnet (OpenReadOnly, keine Migration, die Suche zählt keine Nutzung); gelesen werden die gespeicherten Vektoren, eingebettet werden nur die Anfragen: nacheinander,--pause(50 ms) auseinander, mit Fortschrittszeile alle 10 s auf stderr. Hinweis, wenn nicht alles eingebettet ist. Mit mehreren Werten für--embed-min-similaritylässt sich messen, was die Untergrenze kostet.Verification
Lokal, im Worktree auf
feat/hybrid-search-embeddings:gofmt -l .: leergo vet ./...: saubergo build ./...: saubergo test ./internal/store/... -timeout 30m -count=1: ok (876 s)go test ./internal/web/... -timeout 55m -count=1: ok (1245 s)go test ./internal/server/... ./internal/mcpserver/... ./internal/embed/... ./internal/embedjob/... ./internal/eval/... ./internal/client/... ./cmd/... -count=1: okgo test -raceauf den neuen Paketen (internal/embed,internal/embedjob,internal/eval/searchquality) und den neuen Tests ininternal/store,internal/server,cmd/ctx: okNeue Tests: Fake-Embedding-Server (httptest) mit deterministischen Vektoren.
limit(Mindestähnlichkeit, Deckel)./api/searchund über/ui/search; Reihenfolge mit und ohne verborgene Zeilen identisch; Gegenprobe, dass Fusion vor Filter anders ausginge; Prüfzahl der Zeilenregel bei großem fremdem Bestand.ctx eval searchundctx embed statusan kleinen Test-Datenbanken: Aggregate, kein Text in der Ausgabe, Strenge gegenüber dem Rückfall, Warnung bei unvollständiger Einbettung, Probe eines kaputten Servers, geparkte Einträge.TestRuntimeStoreWriterInventory) mit den neuen Methoden.Nicht geprüft: ein Lauf gegen einen echten
llama-server(der Dienst wird getrennt eingerichtet) und die Qualitätszahlen auf echten Daten (die liefertctx eval searchnach dem Nachrechnen), ebenso der endgültige Wert für--embed-min-similarity.Release impact
major— incompatible after 1.0minor— compatible feature, or incompatible before 1.0patch— compatible fixnone— no release impactChecklist
dev(or is an approved release/hotfix promotion).