go-wiki/handler/search.go
2026-07-14 20:46:19 +02:00

309 lines
9.9 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.

// Dieses File 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.md" (3x), "/pipeline.md" (1x)]
// "nginx" → ["/server-setup.md" (5x), "/reverse-proxy.md" (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 den .md-Dateien neu aufgebaut — konsistent mit dem PageCache.
// Titel-Treffer werden mit Faktor 3 höher gewichtet als Body-Treffer.
// Tags werden mit Faktor 2 gewichtet.
package handler
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.22.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 // URL-Pfad des Dokuments, z.B. "/projekte/notizen"
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
}
// SearchResult ist ein einzelnes Suchergebnis mit BM25-Score.
type SearchResult struct {
URLPath string // URL-Pfad der Seite, z.B. "/projekte/notizen"
Score float64 // BM25-Relevanz-Score — nur für internes Ranking, kein fester Wertebereich
}
// SearchIndex 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 SearchIndex struct {
mu sync.RWMutex
// invertedIndex ist der Kern: Term → Liste aller Dokumente mit Häufigkeit.
// map[string][]posting: "nginx" → [{"/server.md", 5}, {"/proxy.md", 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
}
// NewSearchIndex erstellt einen leeren, einsatzbereiten SearchIndex.
func NewSearchIndex() *SearchIndex {
return &SearchIndex{
invertedIndex: make(map[string][]posting),
docs: make(map[string]docMeta),
docTerms: make(map[string][]string),
}
}
// Index fügt ein Dokument zum Suchindex hinzu oder aktualisiert es.
// Falls urlPath 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 *SearchIndex) Index(urlPath, title, body string, tags []string) {
s.mu.Lock()
defer s.mu.Unlock()
// Zuerst alten Eintrag entfernen falls vorhanden (Update-Logik).
s.removeUnsafe(urlPath)
// 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: urlPath,
freq: freq,
})
termList = append(termList, term)
}
// Metadaten und Rückwärts-Index speichern.
s.docs[urlPath] = docMeta{length: len(allTokens), title: title}
s.docTerms[urlPath] = termList
s.totalTerms += len(allTokens)
}
// Remove entfernt ein Dokument vollständig aus dem Index.
func (s *SearchIndex) Remove(urlPath string) {
s.mu.Lock()
defer s.mu.Unlock()
s.removeUnsafe(urlPath)
}
// removeUnsafe entfernt ein Dokument ohne Lock — nur aus internen Methoden aufrufen
// die bereits einen Lock halten.
func (s *SearchIndex) removeUnsafe(urlPath string) {
meta, exists := s.docs[urlPath]
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[urlPath] {
postings := s.invertedIndex[term]
filtered := postings[:0] // wiederverwendet das bestehende Slice (kein Speicher-Allokierung)
for _, p := range postings {
if p.docID != urlPath {
filtered = append(filtered, p)
}
}
if len(filtered) == 0 {
delete(s.invertedIndex, term)
} else {
s.invertedIndex[term] = filtered
}
}
s.totalTerms -= meta.length
delete(s.docs, urlPath)
delete(s.docTerms, urlPath)
}
// 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.
func (s *SearchIndex) Search(queryStr string, limit int) []SearchResult {
s.mu.RLock()
defer s.mu.RUnlock()
queryTokens := 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 {
postings, found := s.invertedIndex[term]
if !found {
continue
}
// 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(postings))
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
for _, p := range 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))
scores[p.docID] += idf * tfNorm
}
}
if len(scores) == 0 {
return nil
}
// Ergebnisse in eine sortierbare Liste umwandeln.
results := make([]SearchResult, 0, len(scores))
for docID, score := range scores {
results = append(results, SearchResult{URLPath: docID, Score: score})
}
// 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).
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.WriteRune(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
}
// 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
}