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 ( // weightWordPrefix gilt für Wörter, die mit dem Suchwort oder seinem // Stamm beginnen: „deploy" → „deployment". weightWordPrefix = 0.5 // weightSubword gilt für Wörter, die das Suchwort irgendwo enthalten: // „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil // Teilwörter öfter zufällig passen. weightSubword = 0.3 // weightTypo gilt für Wörter, die sich um ein oder zwei Buchstaben // unterscheiden: „deploymnet" → „deployment". weightTypo = 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 ( minWordPrefix = 3 minSubword = 5 minStem = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt minTypo = 4 ) // variant ist eine Gruppe von Fundstellen, die über dieselbe Art von // Ähnlichkeit gefunden wurden, samt ihrem Gewicht. type variant struct { postings []posting weight float64 } // variantsFor sucht alle Fundstellen eines Suchworts: exakt, über den // Wortanfang, als Teilwort und — nur wenn typo 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) variantsFor(term string, typo bool) []variant { var result []variant if exact := s.invertedIndex[term]; len(exact) > 0 { result = append(result, variant{postings: exact, weight: 1}) } stem := trimSuffix(term) searchWordPrefix := len(term) >= minWordPrefix searchSubword := len(term) >= minSubword // 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. wordPrefix := make(map[string]int) subword := make(map[string]int) for word, postings := range s.invertedIndex { switch { case searchWordPrefix && strings.HasPrefix(word, term): keepHighest(wordPrefix, postings) case stem != "" && strings.HasPrefix(word, stem): keepHighest(wordPrefix, postings) case searchSubword && strings.Contains(word, term): keepHighest(subword, postings) } } if len(wordPrefix) > 0 { result = append(result, variant{postings: asPostings(wordPrefix), weight: weightWordPrefix}) } if len(subword) > 0 { result = append(result, variant{postings: asPostings(subword), weight: weightSubword}) } // Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste // Variante und kosten den meisten Rechenaufwand. if typo && len(result) == 0 { if hits := s.typoPostings(term); len(hits) > 0 { result = append(result, variant{postings: hits, weight: weightTypo}) } } return result } // typoPostings sammelt die Fundstellen aller Wörter, die sich vom // Suchwort nur um wenige Buchstaben unterscheiden. func (s *Index) typoPostings(term string) []posting { searchWord := []rune(term) allowed := allowedTypos(len(searchWord)) if allowed == 0 { return nil } best := make(map[string]int) for word, 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. length := utf8.RuneCountInString(word) if abs(length-len(searchWord)) > allowed { continue } if levenshtein(searchWord, []rune(word)) <= allowed { keepHighest(best, postings) } } return asPostings(best) } // allowedTypos 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 allowedTypos(length int) int { switch { case length < minTypo: return 0 case length < 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 { before := make([]int, len(b)+1) current := make([]int, len(b)+1) // Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen. for j := range before { before[j] = j } for i := 1; i <= len(a); i++ { current[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen for j := 1; j <= len(b); j++ { cost := 1 if a[i-1] == b[j-1] { cost = 0 } current[j] = min( before[j]+1, // Buchstabe aus a löschen current[j-1]+1, // Buchstabe aus b einfügen before[j-1]+cost, // Buchstabe ersetzen (oder gleich lassen) ) } before, current = current, before } return before[len(b)] } // suffixes sind die Wortendungen, die trimSuffix 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 suffixes = []string{"en", "es", "e", "n", "s"} // trimSuffix 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 trimSuffix(term string) string { for _, suffix := range suffixes { if strings.HasSuffix(term, suffix) && len(term)-len(suffix) >= minStem { return term[:len(term)-len(suffix)] } } return "" } // stopwords 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 withoutStopwords). // // # 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 stopwords = 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, } // withoutStopwords 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 withoutStopwords(tokens []string) []string { rest := make([]string, 0, len(tokens)) for _, t := range tokens { if !stopwords[t] { rest = append(rest, t) } } if len(rest) == 0 { return tokens } return rest } // keepHighest trägt für jedes Dokument die höchste Häufigkeit ein. func keepHighest(best map[string]int, postings []posting) { for _, p := range postings { if p.freq > best[p.docID] { best[p.docID] = p.freq } } } // asPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um. func asPostings(best map[string]int) []posting { result := make([]posting, 0, len(best)) for docID, freq := range best { result = append(result, posting{docID: docID, freq: freq}) } return result } // abs gibt den Betrag einer ganzen Zahl zurück. func abs(n int) int { if n < 0 { return -n } return n } // Coverage gibt an, welcher Anteil der Wörter aus query 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 Coverage(query, text string) float64 { share, _ := CoverageWithSearchTerms(query, text, "") return share } // CoverageWithSearchTerms 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 query, 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 CoverageWithSearchTerms(query, title, searchTerms string) (float64, []string) { words := withoutStopwords(tokenize(query)) if len(words) == 0 { return 0, nil } inTitle := tokenize(title) wordExact := make(map[string]bool) for _, w := range tokenize(searchTerms) { wordExact[w] = true } // Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht // zu zwei Dritteln „Server". seen := make(map[string]bool, len(words)) total := 0 var found []string for _, w := range words { if seen[w] { continue } seen[w] = true total++ if wordExact[w] { found = append(found, w) continue } for _, v := range inTitle { if matches(w, v) { found = append(found, w) break } } } return float64(len(found)) / float64(total), found } // matches prüft, ob zwei einzelne Wörter als dasselbe gelten. func matches(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) >= minStem && len(b) >= minStem && (strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) { return true } // Endung: „zertifikate" ↔ „zertifikat". if stem := trimSuffix(a); stem != "" && strings.HasPrefix(b, stem) { return true } if stem := trimSuffix(b); stem != "" && strings.HasPrefix(a, stem) { return true } // Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess". if len(a) >= minSubword && strings.Contains(b, a) { return true } return len(b) >= minSubword && strings.Contains(a, b) }