package search import ( "strings" "unicode/utf8" ) // # Wortvarianten // // BM25 vergleicht ganze Wörter. Ein Suchwort trifft also nur Einträge, in denen // genau dieses Wort steht. Im Alltag reicht das nicht: // // deploy soll Deployment finden (Wortanfang) // deployen soll Deployment finden (Endung) // prozess soll Releaseprozess finden (zusammengesetztes Wort) // deploymnet soll Deployment finden (Tippfehler, nur Weboberfläche) // // Deshalb sucht der Index zu jedem Suchwort mehrere Varianten. Jede Variante // hat ein Gewicht: Ein exakter Treffer zählt voll, ein Treffer über eine // Variante nur anteilig. So rutschen ungenaue Treffer nach hinten, statt die // genauen zu verdrängen. // // Trifft ein Eintrag über mehrere Varianten (etwa „ssh" exakt und „sshd" als // Wortanfang), zählt nur die beste. Sonst bekäme ein Eintrag mit vielen // ähnlichen Wörtern ein unverdient hohes Gewicht. // Gewichte der Varianten — ein exakter Treffer zählt 1. const ( // gewichtWortanfang gilt für Wörter, die mit dem Suchwort oder seinem // Stamm beginnen: „deploy" → „deployment". gewichtWortanfang = 0.5 // gewichtTeilwort gilt für Wörter, die das Suchwort irgendwo enthalten: // „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil // Teilwörter öfter zufällig passen. gewichtTeilwort = 0.3 // gewichtTippfehler gilt für Wörter, die sich um ein oder zwei Buchstaben // unterscheiden: „deploymnet" → „deployment". gewichtTippfehler = 0.3 ) // Mindestlängen der Suchwörter für die einzelnen Varianten. Kürzere Wörter // passen auf zu vieles: „de" ist der Anfang halber Wörterbücher, und „port" // steckt in „Reporting", „Export" und „Support". const ( minWortanfang = 3 minTeilwort = 5 minStamm = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt minTippfehler = 4 ) // variante ist eine Gruppe von Fundstellen, die über dieselbe Art von // Ähnlichkeit gefunden wurden, samt ihrem Gewicht. type variante struct { postings []posting gewicht float64 } // variantenFuer sucht alle Fundstellen eines Suchworts: exakt, über den // Wortanfang, als Teilwort und — nur wenn tippfehler gesetzt ist und sonst // gar nichts passt — mit Tippfehlern. // // Muss unter der Lesesperre des Index aufgerufen werden. // // # Laufzeit // // Der exakte Treffer ist ein einzelner Map-Zugriff. Für die übrigen Varianten // geht die Funktion einmal den ganzen Wortschatz des Projekts durch. Gemessen // (siehe bench_test.go) sind das bei 20.000 verschiedenen Wörtern einige // Zehntel Millisekunden je Anfrage — für Agenten wie Menschen nicht spürbar. func (s *Index) variantenFuer(term string, tippfehler bool) []variante { var ergebnis []variante if exakt := s.invertedIndex[term]; len(exakt) > 0 { ergebnis = append(ergebnis, variante{postings: exakt, gewicht: 1}) } stamm := kuerzeEndung(term) sucheWortanfang := len(term) >= minWortanfang sucheTeilwort := len(term) >= minTeilwort // Je Variante: Dokument → höchste Häufigkeit eines passenden Worts. // Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment" // und „deployt" im selben Eintrag sollen nicht dreifach zählen. wortanfang := make(map[string]int) teilwort := make(map[string]int) for wort, postings := range s.invertedIndex { switch { case sucheWortanfang && strings.HasPrefix(wort, term): merkeHoechste(wortanfang, postings) case stamm != "" && strings.HasPrefix(wort, stamm): merkeHoechste(wortanfang, postings) case sucheTeilwort && strings.Contains(wort, term): merkeHoechste(teilwort, postings) } } if len(wortanfang) > 0 { ergebnis = append(ergebnis, variante{postings: alsPostings(wortanfang), gewicht: gewichtWortanfang}) } if len(teilwort) > 0 { ergebnis = append(ergebnis, variante{postings: alsPostings(teilwort), gewicht: gewichtTeilwort}) } // Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste // Variante und kosten den meisten Rechenaufwand. if tippfehler && len(ergebnis) == 0 { if treffer := s.tippfehlerPostings(term); len(treffer) > 0 { ergebnis = append(ergebnis, variante{postings: treffer, gewicht: gewichtTippfehler}) } } return ergebnis } // tippfehlerPostings sammelt die Fundstellen aller Wörter, die sich vom // Suchwort nur um wenige Buchstaben unterscheiden. func (s *Index) tippfehlerPostings(term string) []posting { suchwort := []rune(term) erlaubt := erlaubteTippfehler(len(suchwort)) if erlaubt == 0 { return nil } beste := make(map[string]int) for wort, postings := range s.invertedIndex { // Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als // erlaubt, kann der Abstand nicht kleiner sein. Das erspart die // eigentliche Rechnung für fast alle Wörter. laenge := utf8.RuneCountInString(wort) if abs(laenge-len(suchwort)) > erlaubt { continue } if levenshtein(suchwort, []rune(wort)) <= erlaubt { merkeHoechste(beste, postings) } } return alsPostings(beste) } // erlaubteTippfehler legt fest, wie viele Buchstaben bei einem Wort dieser // Länge abweichen dürfen. Bei kurzen Wörtern wäre schon ein Fehler zu viel: // „host" und „post" sind verschiedene Dinge. func erlaubteTippfehler(laenge int) int { switch { case laenge < minTippfehler: return 0 case laenge < 8: return 1 default: return 2 } } // levenshtein berechnet den Editierabstand zweier Wörter: die kleinste Zahl // an Buchstaben, die man einfügen, löschen oder ersetzen muss, um aus a das // Wort b zu machen. // // deploymnet → deployment Abstand 2 (n→e, e→n) // sever → server Abstand 1 (r einfügen) // // Klassisch rechnet man das mit einer Tabelle von (len(a)+1) × (len(b)+1) // Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier // zwei Zeilen, die abwechselnd überschrieben werden. func levenshtein(a, b []rune) int { vorher := make([]int, len(b)+1) aktuell := make([]int, len(b)+1) // Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen. for j := range vorher { vorher[j] = j } for i := 1; i <= len(a); i++ { aktuell[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen for j := 1; j <= len(b); j++ { kosten := 1 if a[i-1] == b[j-1] { kosten = 0 } aktuell[j] = min( vorher[j]+1, // Buchstabe aus a löschen aktuell[j-1]+1, // Buchstabe aus b einfügen vorher[j-1]+kosten, // Buchstabe ersetzen (oder gleich lassen) ) } vorher, aktuell = aktuell, vorher } return vorher[len(b)] } // endungen sind die Wortendungen, die kuerzeEndung abschneidet — die // häufigsten deutschen Plural- und Beugungsendungen, dazu das englische -s. // Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht // zu „prozesse". // // Bewusst nicht dabei ist „-er": Im Deutschen bildet es meist ein eigenes Wort // („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch // „Service" fände. var endungen = []string{"en", "es", "e", "n", "s"} // kuerzeEndung schneidet eine Beugungsendung ab und gibt den Stamm zurück — // oder "", wenn es nichts zu kürzen gibt. // // deployen → deploy (findet dann „deployment") // zertifikate → zertifikat // datenbanken → datenbank // logs → "" (der Rest „log" wäre zu kurz) // // Das ist absichtlich kein vollständiger Wortstammbildner. Die kennen hunderte // Regeln und verfälschen dabei Fachbegriffe. Hier geht es nur darum, dass ein // Suchwort mit Endung auch das Wort ohne Endung findet. Da der Stamm nur als // Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate". func kuerzeEndung(term string) string { for _, endung := range endungen { if strings.HasSuffix(term, endung) && len(term)-len(endung) >= minStamm { return term[:len(term)-len(endung)] } } return "" } // stoppwoerter sind Wörter, die in fast jeder Frage stehen, aber nichts über // das Thema verraten. // // # Warum nur beim Suchen? // // Die Liste wird nur auf die Anfrage angewendet, nicht beim Indizieren. Der // Index bleibt also vollständig, und wer gezielt nach „was" sucht, findet es // weiterhin (siehe ohneStoppwoerter). // // # Warum überhaupt? // // Die instructions bitten Agenten, ganze Fragen zu stellen: „wie deployen wir // den Dienst". BM25 gewichtet häufige Wörter zwar herunter, aber nicht auf // null. Fehlt ein Wort wie „wir" im Projekt ganz, greift außerdem die Suche // nach dem Wortanfang und findet „wird" und „wirkung" — reines Rauschen. // // Die Wörter stehen in der Form, die tokenize erzeugt: klein und mit // gefalteten Umlauten („fur" statt „für"). Ein Test stellt das sicher. // // Nicht dabei sind Wörter, die in der IT etwas bedeuten können, etwa „it" // oder „go". var stoppwoerter = map[string]bool{ // Deutsch "der": true, "die": true, "das": true, "den": true, "dem": true, "des": true, "ein": true, "eine": true, "einen": true, "einem": true, "einer": true, "eines": true, "und": true, "oder": true, "aber": true, "auch": true, "nur": true, "noch": true, "ist": true, "sind": true, "war": true, "wird": true, "werden": true, "wurde": true, "hat": true, "haben": true, "habe": true, "gibt": true, "kann": true, "konnen": true, "muss": true, "soll": true, "sollte": true, "wie": true, "was": true, "wo": true, "wer": true, "wann": true, "warum": true, "welche": true, "welcher": true, "welches": true, "womit": true, "wozu": true, "ich": true, "du": true, "er": true, "sie": true, "es": true, "wir": true, "ihr": true, "man": true, "mein": true, "unser": true, "unsere": true, "unseren": true, "mit": true, "von": true, "zu": true, "zum": true, "zur": true, "im": true, "in": true, "am": true, "an": true, "auf": true, "aus": true, "bei": true, "fur": true, "uber": true, "nach": true, "vor": true, "um": true, "ob": true, "nicht": true, "kein": true, "keine": true, "so": true, "dass": true, "da": true, "hier": true, "dort": true, "dann": true, "denn": true, "sich": true, // Englisch "the": true, "and": true, "or": true, "of": true, "to": true, "on": true, "for": true, "with": true, "is": true, "are": true, "be": true, "how": true, "what": true, "where": true, "who": true, "why": true, "when": true, "which": true, "do": true, "does": true, "we": true, "you": true, "our": true, "can": true, "this": true, "that": true, } // ohneStoppwoerter entfernt die Stoppwörter aus einer Anfrage. // // Besteht die Anfrage nur aus Stoppwörtern, bleibt sie unverändert — dann // meint jemand offenbar genau diese Wörter, und eine leere Anfrage hülfe // niemandem. func ohneStoppwoerter(tokens []string) []string { rest := make([]string, 0, len(tokens)) for _, t := range tokens { if !stoppwoerter[t] { rest = append(rest, t) } } if len(rest) == 0 { return tokens } return rest } // merkeHoechste trägt für jedes Dokument die höchste Häufigkeit ein. func merkeHoechste(beste map[string]int, postings []posting) { for _, p := range postings { if p.freq > beste[p.docID] { beste[p.docID] = p.freq } } } // alsPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um. func alsPostings(beste map[string]int) []posting { ergebnis := make([]posting, 0, len(beste)) for docID, freq := range beste { ergebnis = append(ergebnis, posting{docID: docID, freq: freq}) } return ergebnis } // abs gibt den Betrag einer ganzen Zahl zurück. func abs(n int) int { if n < 0 { return -n } return n } // Ueberdeckung gibt an, welcher Anteil der Wörter aus anfrage in text // vorkommt — als Zahl von 0 (keines) bis 1 (alle). Stoppwörter zählen nicht. // // Gedacht für kurze Texte wie Titel, um Dubletten zu erkennen (siehe remember // in internal/mcpserver). Ein Wort gilt als vorhanden, wenn es dort exakt, // als Wortanfang, mit anderer Endung oder als Teil eines zusammengesetzten // Worts steht — dieselben Varianten wie bei der Suche, nur in beide // Richtungen: // // „Deployment-Ablauf" vs. „Deployment-Prozess" → 0,5 (deployment ✓, ablauf ✗) // „Releaseprozess" vs. „Release-Prozess" → 1 (beginnt mit „release") // „Deployments" vs. „Deployment" → 1 (Endung) // // # Warum nicht der BM25-Wert? // // BM25 hat keinen festen Wertebereich: Ob 4,2 viel oder wenig ist, hängt von // der Größe des Projekts und der Länge der Einträge ab. Eine Schwelle darauf // müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist // dagegen in jedem Projekt gleich zu lesen. func Ueberdeckung(anfrage, text string) float64 { anteil, _ := UeberdeckungMitSuchbegriffen(anfrage, text, "") return anteil } // UeberdeckungMitSuchbegriffen ist Ueberdeckung für einen vorhandenen // Eintrag, der aus zwei Teilen besteht: seinem Titel und seinen Suchbegriffen // (der Zeile „Auch gesucht als: …"). Zurück kommen der Anteil und die Wörter // aus anfrage, die gefunden wurden — in der Form, die tokenize erzeugt. // // # Warum zwei Teile? // // Im Titel gelten alle Varianten wie bei Ueberdeckung. Ein Suchbegriff zählt // dagegen nur, wenn er wörtlich passt. Suchbegriffe sind ausdrücklich // gewählte, ganze Wörter; mit allen Varianten erfassen sie zu viel. Im // Betrieb (2026-10-16) machte der Suchbegriff „postgres-migration" eines // Statuseintrags jeden neuen Eintrag mit „PostgreSQL" im Titel zur // vermeintlichen Dublette — „postgres" ist der Wortanfang von „postgresql". // // „Release-Ablauf" vs. Suchbegriff „release" → zählt (wörtlich) // „Betrieb mit PostgreSQL" vs. Suchbegriff „postgres" → zählt nicht func UeberdeckungMitSuchbegriffen(anfrage, titel, suchbegriffe string) (float64, []string) { woerter := ohneStoppwoerter(tokenize(anfrage)) if len(woerter) == 0 { return 0, nil } imTitel := tokenize(titel) wortgenau := make(map[string]bool) for _, w := range tokenize(suchbegriffe) { wortgenau[w] = true } // Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht // zu zwei Dritteln „Server". gesehen := make(map[string]bool, len(woerter)) gesamt := 0 var gefunden []string for _, w := range woerter { if gesehen[w] { continue } gesehen[w] = true gesamt++ if wortgenau[w] { gefunden = append(gefunden, w) continue } for _, v := range imTitel { if passtZu(w, v) { gefunden = append(gefunden, w) break } } } return float64(len(gefunden)) / float64(gesamt), gefunden } // passtZu prüft, ob zwei einzelne Wörter als dasselbe gelten. func passtZu(a, b string) bool { if a == b { return true } // Wortanfang, in beide Richtungen: „deploy" ↔ „deployment", // „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt // „de" zu allem. if len(a) >= minStamm && len(b) >= minStamm && (strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) { return true } // Endung: „zertifikate" ↔ „zertifikat". if stamm := kuerzeEndung(a); stamm != "" && strings.HasPrefix(b, stamm) { return true } if stamm := kuerzeEndung(b); stamm != "" && strings.HasPrefix(a, stamm) { return true } // Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess". if len(a) >= minTeilwort && strings.Contains(b, a) { return true } return len(b) >= minTeilwort && strings.Contains(a, b) }