309 lines
9.9 KiB
Go
309 lines
9.9 KiB
Go
// 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.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 // 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
|
||
}
|