417 lines
15 KiB
Go
417 lines
15 KiB
Go
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)
|
||
}
|