munin/internal/search/varianten.go

417 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}