404 lines
14 KiB
Go
404 lines
14 KiB
Go
// Package search implementiert einen In-Memory-Volltextindex mit BM25-Ranking.
|
||
//
|
||
// # Was ist BM25?
|
||
//
|
||
// BM25 (Best Match 25) ist der Standardalgorithmus moderner Suchmaschinen —
|
||
// er steckt hinter Elasticsearch, Solr, Lucene und den meisten anderen Systemen.
|
||
// Er verbessert das ältere TF-IDF durch zwei wichtige Korrekturen:
|
||
//
|
||
// 1. Sättigungsfaktor (k1): Ein Begriff der 10x so oft vorkommt wie ein anderer
|
||
// ist nicht 10x so relevant. Ab einem gewissen Punkt "sättigt" der Nutzen —
|
||
// das 20. Vorkommen eines Worts bringt kaum mehr Relevanz als das 10.
|
||
//
|
||
// 2. Längennormalisierung (b): Kurze Dokumente sollen keinen unfairen Vorteil haben.
|
||
// "Deployment" in einem 50-Wort-Artikel ist relevanter als dasselbe Wort
|
||
// in einem 2000-Wort-Dokument, in dem es nur am Rand auftaucht.
|
||
//
|
||
// # Wie funktioniert ein invertierter Index?
|
||
//
|
||
// Statt für jeden Suchbegriff alle Dokumente zu lesen, dreht ein invertierter Index
|
||
// die Beziehung um:
|
||
//
|
||
// Normaler Index: Dokument → enthaltene Begriffe
|
||
// Invertierter Index: Begriff → Dokumente die ihn enthalten
|
||
//
|
||
// Beispiel:
|
||
//
|
||
// "deployment" → ["server-setup" (3x), "pipeline" (1x)]
|
||
// "nginx" → ["server-setup" (5x), "reverse-proxy" (2x)]
|
||
//
|
||
// Bei einer Suche wird nur noch in dieser Map nachgeschlagen — kein Dateisystem-Zugriff,
|
||
// keine Schleife über alle Dokumente. Das macht die Suche sehr schnell.
|
||
//
|
||
// # Implementierungsdetails
|
||
//
|
||
// Der Index ist komplett im Arbeitsspeicher (kein Dateisystem).
|
||
// Beim Serverstart wird er aus der Datenbank aufgebaut.
|
||
// Titel-Treffer werden mit Faktor 3 höher gewichtet als Body-Treffer.
|
||
// Tags werden mit Faktor 2 gewichtet.
|
||
//
|
||
// Der Index kennt keine Einträge, sondern nur Dokument-IDs — dadurch bleibt er
|
||
// unabhängig vom Datenmodell und ließe sich ohne Änderung auch für etwas anderes
|
||
// verwenden. Die Zuordnung ID → Eintrag macht der Aufrufer.
|
||
package search
|
||
|
||
import (
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"unicode"
|
||
)
|
||
|
||
// BM25-Tuning-Parameter — Standardwerte aus der Originalliteratur (Robertson et al. 1994).
|
||
const (
|
||
// bm25K1 steuert die Sättigungskurve der Termhäufigkeit.
|
||
// Typischer Bereich: 1.2–2.0. Höher = längere Sättigungskurve.
|
||
bm25K1 = 1.2
|
||
|
||
// bm25B steuert die Längennormalisierung.
|
||
// 0.0 = keine Normalisierung, 1.0 = vollständige Normalisierung.
|
||
// 0.75 ist der Standardwert.
|
||
bm25B = 0.75
|
||
|
||
// titleBoost: Treffer im Titel gelten als 3x relevanter als im Body.
|
||
// Technisch umgesetzt durch Vervielfältigung der Titel-Tokens beim Indexieren.
|
||
titleBoost = 3
|
||
|
||
// tagsBoost: Treffer in Tags gelten als 2x relevanter als im Body.
|
||
tagsBoost = 2
|
||
)
|
||
|
||
// posting speichert wie oft ein Term in einem konkreten Dokument vorkommt.
|
||
// Es ist der Grundbaustein des invertierten Index.
|
||
type posting struct {
|
||
docID string // Kennung des Dokuments — hier der Slug eines Eintrags
|
||
freq int // Häufigkeit des Terms in diesem Dokument
|
||
}
|
||
|
||
// docMeta speichert die Metadaten die BM25 pro Dokument benötigt.
|
||
type docMeta struct {
|
||
length int // Anzahl der Terme (nach Tokenisierung und Boost)
|
||
title string // Titel für die Ergebnisanzeige
|
||
boost float64 // Faktor auf den Endwert, 1 = neutral (siehe IndexWithBoost)
|
||
}
|
||
|
||
// Result ist ein einzelnes Suchergebnis mit BM25-Score.
|
||
type Result struct {
|
||
DocID string // Kennung des Dokuments — hier der Slug eines Eintrags
|
||
Score float64 // BM25-Relevanz-Score — nur für internes Ranking, kein fester Wertebereich
|
||
}
|
||
|
||
// Index kapselt den invertierten Index und alle dazugehörigen Daten.
|
||
// Er ist threadsicher durch eine RWMutex (mehrere Leser gleichzeitig erlaubt,
|
||
// aber nur ein Schreiber auf einmal).
|
||
type Index struct {
|
||
mu sync.RWMutex
|
||
|
||
// invertedIndex ist der Kern: Term → Liste aller Dokumente mit Häufigkeit.
|
||
// map[string][]posting: "nginx" → [{"server-setup", 5}, {"reverse-proxy", 2}]
|
||
invertedIndex map[string][]posting
|
||
|
||
// docMeta speichert Metadaten je Dokument — für BM25-Berechnungen nötig.
|
||
docs map[string]docMeta
|
||
|
||
// docTerms ist ein Rückwärts-Index: Dokument → Liste seiner Terme.
|
||
// Er macht das Aktualisieren/Löschen effizient:
|
||
// ohne ihn müsste man beim Löschen den gesamten invertedIndex durchsuchen.
|
||
docTerms map[string][]string
|
||
|
||
// totalTerms ist die Summe der Dokumentlängen — für die Durchschnittslänge.
|
||
totalTerms int
|
||
}
|
||
|
||
// NewIndex erstellt einen leeren, einsatzbereiten Index.
|
||
func NewIndex() *Index {
|
||
return &Index{
|
||
invertedIndex: make(map[string][]posting),
|
||
docs: make(map[string]docMeta),
|
||
docTerms: make(map[string][]string),
|
||
}
|
||
}
|
||
|
||
// Index fügt ein Dokument hinzu oder aktualisiert es.
|
||
// Falls docID bereits existiert, wird der alte Eintrag zuerst entfernt.
|
||
//
|
||
// title, body und tags werden getrennt übergeben um unterschiedliche
|
||
// Boost-Faktoren anwenden zu können.
|
||
func (s *Index) Index(docID, title, body string, tags []string) {
|
||
s.IndexWithBoost(docID, title, body, tags, 1)
|
||
}
|
||
|
||
// IndexWithBoost indiziert wie Index, gibt dem Dokument aber einen Faktor
|
||
// mit, mit dem sein Endwert bei jeder Suche malgenommen wird.
|
||
//
|
||
// Damit lassen sich Dokumente bei sonst gleicher Relevanz bevorzugen, ohne
|
||
// dass der Index wissen muss, warum. Die Registry nutzt das für kuratierte
|
||
// Einträge (siehe curatedBoost in registry.go). Ein Faktor von 1 ist neutral;
|
||
// Werte ≤ 0 werden als 1 behandelt, damit kein Dokument aus Versehen
|
||
// unauffindbar wird.
|
||
func (s *Index) IndexWithBoost(docID, title, body string, tags []string, boost float64) {
|
||
if boost <= 0 {
|
||
boost = 1
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
// Zuerst alten Eintrag entfernen falls vorhanden (Update-Logik).
|
||
s.removeUnsafe(docID)
|
||
|
||
// Tokens aus allen Quellen mit Boost-Gewichtung sammeln.
|
||
// Boost durch Wiederholung: "nginx" im Titel → 3x "nginx" in der Tokenliste.
|
||
// Das erhöht die Termhäufigkeit (TF) künstlich für Titel-Treffer.
|
||
var allTokens []string
|
||
allTokens = append(allTokens, repeatTokens(tokenize(title), titleBoost)...)
|
||
allTokens = append(allTokens, tokenize(body)...)
|
||
allTokens = append(allTokens, repeatTokens(tokenize(strings.Join(tags, " ")), tagsBoost)...)
|
||
|
||
if len(allTokens) == 0 {
|
||
return
|
||
}
|
||
|
||
// Termhäufigkeit pro Token zählen: "nginx" vorkommt 5x → {nginx: 5}
|
||
termFreq := make(map[string]int, len(allTokens))
|
||
for _, t := range allTokens {
|
||
termFreq[t]++
|
||
}
|
||
|
||
// Invertierter Index aufbauen: für jeden Term einen Posting-Eintrag hinzufügen.
|
||
termList := make([]string, 0, len(termFreq))
|
||
for term, freq := range termFreq {
|
||
s.invertedIndex[term] = append(s.invertedIndex[term], posting{
|
||
docID: docID,
|
||
freq: freq,
|
||
})
|
||
termList = append(termList, term)
|
||
}
|
||
|
||
// Metadaten und Rückwärts-Index speichern.
|
||
s.docs[docID] = docMeta{length: len(allTokens), title: title, boost: boost}
|
||
s.docTerms[docID] = termList
|
||
s.totalTerms += len(allTokens)
|
||
}
|
||
|
||
// Remove entfernt ein Dokument vollständig aus dem Index.
|
||
func (s *Index) Remove(docID string) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.removeUnsafe(docID)
|
||
}
|
||
|
||
// removeUnsafe entfernt ein Dokument ohne Lock — nur aus internen Methoden aufrufen
|
||
// die bereits einen Lock halten.
|
||
func (s *Index) removeUnsafe(docID string) {
|
||
meta, exists := s.docs[docID]
|
||
if !exists {
|
||
return
|
||
}
|
||
|
||
// Alle Postings für dieses Dokument aus dem invertierten Index entfernen.
|
||
// Wir nutzen docTerms um zu wissen welche Terme betroffen sind —
|
||
// viel schneller als den gesamten Index zu scannen.
|
||
for _, term := range s.docTerms[docID] {
|
||
postings := s.invertedIndex[term]
|
||
filtered := postings[:0] // wiederverwendet das bestehende Slice (kein Speicher-Allokierung)
|
||
for _, p := range postings {
|
||
if p.docID != docID {
|
||
filtered = append(filtered, p)
|
||
}
|
||
}
|
||
if len(filtered) == 0 {
|
||
delete(s.invertedIndex, term)
|
||
} else {
|
||
s.invertedIndex[term] = filtered
|
||
}
|
||
}
|
||
|
||
s.totalTerms -= meta.length
|
||
delete(s.docs, docID)
|
||
delete(s.docTerms, docID)
|
||
}
|
||
|
||
// Search führt eine BM25-Suche für den übergebenen Suchbegriff durch.
|
||
// Die Ergebnisse sind absteigend nach Relevanz sortiert (bester Treffer zuerst).
|
||
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
|
||
//
|
||
// Neben exakten Treffern findet Search auch Wortanfänge, Wörter mit anderer
|
||
// Endung und zusammengesetzte Wörter (siehe varianten.go). Tippfehler
|
||
// gleicht Search nicht aus — dafür gibt es SearchFuzzy.
|
||
func (s *Index) Search(queryStr string, limit int) []Result {
|
||
return s.suche(queryStr, limit, false)
|
||
}
|
||
|
||
// SearchFuzzy sucht wie Search, findet aber zusätzlich Wörter mit Tippfehlern
|
||
// — allerdings nur für Suchwörter, die sonst gar nichts treffen.
|
||
//
|
||
// # Warum nicht immer?
|
||
//
|
||
// Gedacht ist das für Menschen in der Weboberfläche. Agenten machen kaum
|
||
// Tippfehler; bei ihnen brächte die Toleranz vor allem zusätzliche, unsichere
|
||
// Treffer. Deshalb nutzt das MCP-Werkzeug recall weiterhin Search.
|
||
func (s *Index) SearchFuzzy(queryStr string, limit int) []Result {
|
||
return s.suche(queryStr, limit, true)
|
||
}
|
||
|
||
// suche ist die gemeinsame Umsetzung von Search und SearchFuzzy.
|
||
func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
queryTokens := ohneStoppwoerter(tokenize(queryStr))
|
||
if len(queryTokens) == 0 || len(s.docs) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Gesamtanzahl Dokumente und Durchschnittslänge für BM25 berechnen.
|
||
N := float64(len(s.docs))
|
||
avgDocLen := float64(s.totalTerms) / N
|
||
|
||
// scores sammelt den BM25-Score je Dokument über alle Query-Terme.
|
||
// map[docID]score
|
||
scores := make(map[string]float64)
|
||
|
||
for _, term := range queryTokens {
|
||
// Je Dokument zählt für dieses Suchwort nur die beste Variante —
|
||
// ein exakter Treffer wird also nicht durch denselben Treffer als
|
||
// Wortanfang noch einmal aufgewertet.
|
||
beste := make(map[string]float64)
|
||
|
||
for _, v := range s.variantenFuer(term, tippfehler) {
|
||
// IDF (Inverse Document Frequency):
|
||
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
|
||
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
|
||
// nq = Anzahl der Dokumente die diesen Term enthalten
|
||
nq := float64(len(v.postings))
|
||
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
|
||
|
||
for _, p := range v.postings {
|
||
meta := s.docs[p.docID]
|
||
docLen := float64(meta.length)
|
||
|
||
// BM25-Termgewicht:
|
||
// Zähler: tf * (k1 + 1) — gibt bei tf=0 den Wert 0
|
||
// Nenner: tf + k1 * (1 - b + b * |D| / avgdl) — beugt Übergewichtung vor
|
||
tf := float64(p.freq)
|
||
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
|
||
|
||
wert := v.gewicht * idf * tfNorm
|
||
if wert > beste[p.docID] {
|
||
beste[p.docID] = wert
|
||
}
|
||
}
|
||
}
|
||
|
||
for docID, wert := range beste {
|
||
scores[docID] += wert
|
||
}
|
||
}
|
||
|
||
if len(scores) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Ergebnisse in eine sortierbare Liste umwandeln — dabei den Faktor des
|
||
// Dokuments anwenden (siehe IndexWithBoost).
|
||
results := make([]Result, 0, len(scores))
|
||
for docID, score := range scores {
|
||
results = append(results, Result{DocID: docID, Score: score * s.docs[docID].boost})
|
||
}
|
||
|
||
// Absteigend nach Score sortieren (bester Treffer zuerst).
|
||
sort.Slice(results, func(i, j int) bool {
|
||
return results[i].Score > results[j].Score
|
||
})
|
||
|
||
// Auf limit begrenzen.
|
||
if limit > 0 && len(results) > limit {
|
||
results = results[:limit]
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// tokenize zerlegt einen Text in Terme für die Indizierung.
|
||
// Es werden nur Buchstaben und Ziffern behalten, alles andere gilt als Trennzeichen.
|
||
// Einzeichen-Tokens werden ignoriert (kaum aussagekräftig).
|
||
//
|
||
// Umlaute und ß werden dabei vereinheitlicht — siehe faltUmlaut.
|
||
func tokenize(text string) []string {
|
||
text = strings.ToLower(text)
|
||
var tokens []string
|
||
var current strings.Builder
|
||
|
||
for _, r := range text {
|
||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||
current.WriteString(faltUmlaut(r))
|
||
} else {
|
||
if current.Len() > 1 {
|
||
tokens = append(tokens, current.String())
|
||
}
|
||
current.Reset()
|
||
}
|
||
}
|
||
// Letztes Token nicht vergessen
|
||
if current.Len() > 1 {
|
||
tokens = append(tokens, current.String())
|
||
}
|
||
|
||
return tokens
|
||
}
|
||
|
||
// faltUmlaut bildet Umlaute und ß auf ihre Grundform ab.
|
||
//
|
||
// # Wozu?
|
||
//
|
||
// Ohne das findet „prufung" den Eintrag „Prüfung" nicht — es sind für den Index
|
||
// zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur
|
||
// sitzt oder es eilig hat. Bei einem Menschen ist das ärgerlich, bei einer
|
||
// KI-Anfrage ein stiller Fehltreffer: Sie bekommt kein Ergebnis und erfährt
|
||
// nicht, dass es eines gäbe.
|
||
//
|
||
// Die Faltung greift, weil tokenize sowohl beim Indizieren als auch beim Suchen
|
||
// verwendet wird — beide Seiten landen also auf derselben Form.
|
||
//
|
||
// # Was sie abdeckt und was nicht
|
||
//
|
||
// prüfung → prufung ✓ und „prufung" findet es
|
||
// größe → grosse ✓ und „grosse" findet es
|
||
// prüfung → prufung ✗ „pruefung" findet es NICHT
|
||
//
|
||
// Die Umschreibung mit e (ue, oe, ae) bleibt außen vor. Sie ließe sich nur
|
||
// abdecken, indem man auch „ue" zu „u" zusammenzieht — dann würde aber aus
|
||
// „neue" ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der
|
||
// Nutzen. Die Punkte wegzulassen ist die weit häufigere Schreibweise.
|
||
//
|
||
// Alle anderen Zeichen bleiben unverändert; für lateinische Buchstaben und
|
||
// Ziffern kostet die Funktion nur einen Vergleich.
|
||
func faltUmlaut(r rune) string {
|
||
switch r {
|
||
case 'ä':
|
||
return "a"
|
||
case 'ö':
|
||
return "o"
|
||
case 'ü':
|
||
return "u"
|
||
case 'ß':
|
||
return "ss"
|
||
default:
|
||
return string(r)
|
||
}
|
||
}
|
||
|
||
// repeatTokens wiederholt eine Tokenliste n-mal — damit wird Boost durch
|
||
// erhöhte Termhäufigkeit (TF) simuliert, ohne die BM25-Formel zu verändern.
|
||
func repeatTokens(tokens []string, n int) []string {
|
||
if n <= 1 {
|
||
return tokens
|
||
}
|
||
result := make([]string, 0, len(tokens)*n)
|
||
for i := 0; i < n; i++ {
|
||
result = append(result, tokens...)
|
||
}
|
||
return result
|
||
}
|