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