387 lines
14 KiB
Go
387 lines
14 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 (
|
||
// 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 {
|
||
woerter := ohneStoppwoerter(tokenize(anfrage))
|
||
if len(woerter) == 0 {
|
||
return 0
|
||
}
|
||
vorhanden := tokenize(text)
|
||
|
||
// Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht
|
||
// zu zwei Dritteln „Server".
|
||
gesehen := make(map[string]bool, len(woerter))
|
||
gesamt, gefunden := 0, 0
|
||
for _, w := range woerter {
|
||
if gesehen[w] {
|
||
continue
|
||
}
|
||
gesehen[w] = true
|
||
gesamt++
|
||
|
||
for _, v := range vorhanden {
|
||
if passtZu(w, v) {
|
||
gefunden++
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return float64(gefunden) / float64(gesamt)
|
||
}
|
||
|
||
// 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)
|
||
}
|