Teil 2 · Hybrid Retrieval in der Praxis: FAISS + BM25, verschmolzen mit RRF

Apr. 25, 2026·
Ndimofor Aretas
Ndimofor Aretas
· 8 Min Lesezeit
blog AI Engineering

Teil einer Serie über die Entwicklung von Gemma CogniVault, einem vollständig lokalen KI-Lernbegleiter. Zuvor: Why I built a local-first RAG.

Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.

Die erste Version von CogniVault nutzte reines Dense Retrieval – die Suchanfrage mit embeddinggemma einbetten, in einem FAISS-Index suchen und die Top-7-Chunks an das Modell übergeben. Es funktionierte. Es funktionierte hervorragend – bis ein Nutzer ein PDF mit deutschen Gesetzestexten hochlud und nach “§3 Absatz 2” fragte.

Das Modell konnte es nicht finden.

Der Chunk war genau da. Das PDF war indiziert. Aber “§3 Absatz 2” lässt sich nicht in etwas Semantisch Sinnvolles einbetten – es ist ein Identifikator auf Token-Ebene, kein Konzept. Der dichte Vektor für die Suchanfrage landete nicht einmal in der Nähe des dichten Vektors für den Chunk, obwohl der Chunk exakt den String enthielt, nach dem der Nutzer gefragt hatte.

Dieser Bug hat reines Dense Retrieval für mich erledigt. In diesem Beitrag geht es darum, womit ich es ersetzt habe.

Zwei Arten von “ähnlich”

Du nutzt bereits jeden Tag beide Arten der Suche. Wenn Spotify ein “Song Radio” basierend auf einem Track erstellt, den du magst, vergleicht es das Gefühl – Tempo, Stimmung, Genre – und spielt dir gerne einen Song vor, dessen Titel kein einziges Wort mit dem Original gemeinsam hat. Aber wenn du Bohemian Rhapsody remastered 2011 in die Suchleiste tippst, willst du kein Gefühl. Du willst genau diesen String, und “ein ähnliches opernhaftes Rock-Epos” ist die falsche Antwort.

Suchsysteme formalisieren diese Unterscheidung in zwei Konzepte von Ähnlichkeit:

  • Lexikalische Ähnlichkeit – “Teilen diese Strings seltene Wörter?” Das ist es, was TF-IDF und BM25 modellieren. Sie glänzen bei Identifikatoren, Namen, Code, Fachbegriffen und direkten Zitaten.
  • Semantische Ähnlichkeit – “Sprechen diese Passagen über dieselbe Idee, auch wenn sie andere Wörter verwenden?” Das ist es, was Embeddings modellieren. Sie glänzen bei Paraphrasen, konzeptionellen Anfragen und natürlichsprachlichen Fragen.

Keines der beiden schließt das andere ein. Ein Nutzer, der fragt: “Wie ist die praktische Prüfung aufgebaut?”, braucht die semantische Suche – im Dokument steht nämlich nicht zwingend “Aufbau der praktischen Prüfung”. Ein Nutzer, der "§3 Absatz 2" fragt, braucht die lexikalische Suche – da gibt es kein Konzept zum Einbetten, nur einen wörtlichen String.

Production-RAG muss beides können. CogniVault macht beides und führt die Ergebnislisten dann mit Reciprocal Rank Fusion (RRF) zusammen.

Der Stack

Query
  ├── embed via embeddinggemma  ──►  FAISS IndexFlatIP  ──► top-K dense
  └── tokenize + lowercase      ──►  BM25Okapi          ──► top-K sparse
                                  Reciprocal Rank Fusion ◄──┘
                                      top-7 fused chunks

Beide Indizes liegen im Arbeitsspeicher, davor sitzt ein VectorDB-Singleton. FAISS führt eine Inner-Product-Suche über normalisierte Embeddings durch (das Skalarprodukt entspricht also dem Kosinus). BM25 ist BM25Okapi aus rank_bm25, gefüttert mit denselben Chunks, die durch einen einfachen Lowercase-und-Split-Tokenizer in Tokens zerlegt wurden.

Die Korpora werden synchron gehalten: Wenn man die Chunks einer Datei weich löscht, löst das einen BM25-Rebuild über die verbleibenden aktiven Chunks aus, und das Singleton lädt beide Indizes aus vector_store.faiss + vector_store.json (Chunk-Metadaten + Rohtext) nach jedem Ingestion-Lauf und beim App-Start neu.

Warum FAISS IndexFlatIP und nicht HNSW oder IVF?

IndexFlatIP ist eine exakte Brute-Force-Suche. Es scannt jeden Vektor für jede Anfrage. Bei zehntausenden Chunks ist das völlig in Ordnung – unter einer Millisekunde auf einem Laptop. CogniVault ist eine lokale Single-User-App; der Index wird nie Milliarden von Vektoren haben. Um Recall für Geschwindigkeit über HNSW oder IVF einzutauschen, würde hier nichts bringen und nur die “Exakt”-Garantie kosten. Langweilig, korrekt, schnell genug.

Wenn das Korpus so groß wird, dass Brute-Force zu zäh wird, ist der Wechsel nur eine Zeile Code. Bis dahin gewinnt der einfachste Index.

Reciprocal Rank Fusion

Der naive Weg, zwei geordnete Listen zu kombinieren, ist, sie zu scoren und zu addieren. Das klingt sinnvoll, bis du dich daran erinnerst, dass FAISS Inner-Product-Scores in einem begrenzten Bereich liefert und BM25 Scores in einem unbegrenzten – sie sind ohne Normalisierung nicht vergleichbar, und jede Normalisierung, die du wählst, ist irgendwie willkürlich.

RRF umgeht das Problem komplett. Es schaut sich nur Ränge an, keine Scores. Für jede Ergebnisliste trägt ein Item auf Rang r mit 1 / (k + r) zu seinem End-Score bei (mit k = 60 per Konvention – groß genug, um den Tail abzuflachen, klein genug, damit die Top-Items noch dominieren). Items, die in beiden Listen auftauchen, werden summiert.

# Simplified — the real implementation also de-duplicates chunks
# by (source, chunk_id, page) before scoring.
def reciprocal_rank_fusion(result_lists, k=60):
    scores = defaultdict(float)
    for results in result_lists:
        for rank, chunk_id in enumerate(results, start=1):
            scores[chunk_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)

Das ist schon der ganze Algorithmus. Kein Tuning, keine Kalibrierung, keine Gewichte pro Korpus. Ein Chunk, der bei BM25 auf Platz 1 und bei FAISS auf Platz 4 liegt, schlägt problemlos einen Chunk, der nur in einer der Listen auf Platz 2 ist. Ein Chunk, bei dem sich beide Indizes einig sind, steigt deterministisch an die Spitze.

Das Ergebnis für die “§3 Absatz 2”-Anfrage: BM25 findet den exakten Treffer und platziert ihn auf Rang 1. FAISS findet nichts Brauchbares (seine Top-Treffer handeln allgemein von Prüfungsordnungen). RRF bringt den BM25-Treffer an die Spitze der fusionierten Liste. Problem gelöst.

Scope-Filterung mit ContextVar-Isolierung

Ein Detail, das man leicht falsch macht: Der Retriever muss sich seines Scopes bewusst sein. In CogniVault können Nutzer eine Frage auf eine einzelne Kategorie oder bestimmte Dateien beschränken. Der Scope wird durch den Request gesetzt, aber die Suche wird tief im Inneren des Strands-Agent-Loops aufgerufen, der wiederum von einem streamenden FastAPI-Handler aufgerufen wird – möglicherweise mit mehreren parallelen Requests pro Worker.

Den Scope durch jeden Funktionsaufruf durchzureichen, wäre unschön. Eine globale Variable ist unsicher. Das richtige Mittel dafür ist Pythons contextvars.ContextVar, das dir einen task-lokalen, isolierten State gibt, den sowohl asyncio als auch Threads respektieren.

from contextvars import ContextVar

_doc_scope: ContextVar[DocScope | None] = ContextVar("doc_scope", default=None)

def set_doc_scope(scope: DocScope | None) -> None:
    _doc_scope.set(scope)

def current_doc_scope() -> DocScope | None:
    return _doc_scope.get()

Der /rag-Request-Handler setzt den Scope ganz am Anfang jeder Streaming-Antwort; das Such-Tool liest ihn; und weil der Wert task-lokal ist, stirbt er mit dem Request. Keine globalen Variablen, kein Durchbohren von Parametern, keine Race Conditions über gleichzeitige Nutzer hinweg.

Das ist eine dieser Designentscheidungen, die nach Over-Engineering aussehen, bis du zwei Browser-Tabs offen hast und merkst, dass ohne sie der Scope-Filter von Tab A in die Frage von Tab B leaken würde.

Chunking-Entscheidungen, die sich später auszahlen

Hybrid Retrieval ist nur so gut wie seine Chunks. CogniVault nutzt einen RecursiveCharacterTextSplitter mit 1.000 Zeichen und 100 Zeichen Overlap für unstrukturierten Text – klein genug, um das Retrieval präzise zu halten, groß genug, um Kontext für das Modell zu liefern.

Für strukturierte Formate ändert sich die Strategie:

  • MarkdownMarkdownHeaderTextSplitter liefert einen Chunk pro H1/H2/H3-Abschnitt, wobei die Überschriftenhierarchie als Brotkrümel vorangestellt wird (“Privacy > Vault Audit > Indicators”). BM25 liebt Brotkrümel – sie lassen Anfragen mit Überschriften-Keywords sauber matchen.
  • CSV → Kopfzeile + 20 Zeilen pro Batch als Chunk, sodass eine Suche nach einem Spaltennamen im richtigen Block landet.
  • PPTX → ein Chunk pro Folie, Titel und Body-Text zusammen.
  • XLSX → Kopfzeile + Zeilen-Batches pro Sheet, mit einem [Sheet: name] Präfix.

Winzige Fragmente werden gefiltert: Unstrukturierter Text braucht mindestens 100 Zeichen, um ein Chunk zu werden, während die strukturierten Formate die Messlatte auf 20 senken – ein zweizeiliger Markdown-Abschnitt oder ein Sheet, das nur aus Überschriften besteht, ist zwar kurz, aber immer noch aussagekräftig. Der rekursive Splitter ist altbekanntes Terrain, aber die formatabhängigen Strategien sind viel wichtiger, als man ihnen oft zugesteht.

Was ich anders machen würde

Ein paar Dinge, die ich noch einmal überdenken würde, wenn ich noch einmal von vorn anfangen würde:

  • Aufhören, für BM25 mit str.split() zu tokenisieren. Es ist okay, aber ein echter Tokenizer, der mit Satzzeichen und deutschen Komposita umgehen kann, würde den Recall bei den rechtlichen Dokumenten deutlich verbessern.
  • Einen kleinen Reranker hinzufügen. RRF findet das richtige Set, aber ein Cross-Encoder-Rerank auf den Top 20 würde die Reihenfolge aufpolieren. Natürlich lokal gehostet – da gibt es mittlerweile gute kleine Modelle.
  • Query Expansion für dünne Anfragen. Zwei-Wort-Fragen wie “§3 Prüfung” könnten vor dem Retrieval über einen schnellen gemma4-Aufruf erweitert werden. Kostet Latenz, bringt aber Recall.

Nichts davon ist bisher an Bord. RRF über FAISS + BM25 ist schon so viel besser als jedes für sich allein, dass ich noch nicht den Drang gespürt habe, weiter zu optimieren.

Fazit

Wenn dein Retrieval “embed + cosine + top-k” ist, wird es genau auf dieselbe Weise scheitern wie meins – bei Anfragen, die wortwörtliche Identifikatoren enthalten, für die dein Modell kein Embedding hat. Die Lösung ist kein besseres Embedding-Modell. Es ist ein zweiter Retriever, der nicht so tut, als wäre alles ein Konzept.

FAISS für Ideen. BM25 für Strings. RRF entscheidet, wer heute Recht hat.


Anhang: Abkürzungen in diesem Beitrag

AbkürzungVollformBedeutung
RAGRetrieval-Augmented GenerationRufe zuerst relevante Passagen aus deinen eigenen Dokumenten ab; lass das Modell dann basierend darauf antworten
FAISSFacebook AI Similarity SearchMetas Bibliothek zum Speichern von Vektoren und zum schnellen Finden der ähnlichsten
BM25Best Match 25Eine Keyword-Ranking-Formel – die 25. Ranking-Funktion, die im Informationsretrieval-System Okapi entwickelt wurde
RRFReciprocal Rank FusionFührt geordnete Listen nur anhand der Ränge zusammen: Jedes Item punktet mit Σ 1/(k + rank) über alle Listen hinweg
TF-IDFTerm Frequency–Inverse Document FrequencyDer Vorfahre von BM25: Bewertet Wörter danach, wie oft sie hier auftauchen vs. wie selten sie überall sonst sind
IP (in IndexFlatIP)Inner ProductDas Ähnlichkeitsmaß, das FAISS berechnet; bei normalisierten Vektoren entspricht es der Kosinus-Ähnlichkeit
HNSWHierarchical Navigable Small WorldEine beliebte Struktur für approximative Vektor-Indizes – hier bewusst nicht verwendet
IVFInverted File IndexEin weiterer approximativer FAISS-Indextyp – ebenfalls bewusst nicht verwendet
AEVOAusbildereignungsverordnungDas deutsche Gesetz, dessen Anfrage “§3 Absatz 2” das reine Dense Retrieval zum Scheitern brachte
CSV / PPTX / XLSXComma-Separated Values / PowerPoint / Excel (Office Open XML)Strukturierte Formate mit ihren eigenen Chunking-Strategien
H1/H2/H3Heading levels 1–3Die Markdown-Überschriftenebenen, die zum Aufteilen von Abschnitten verwendet werden

Als Nächstes: Two-phase streaming with Strands Agents — wie der /rag-Endpoint von CogniVault das Denken von Gemma 4 streamt, bevor Tool-Aufrufe starten.