Das Versprechen der Vektorsuche lautet: Sie versteht Bedeutung, nicht nur Schlüsselwörter – frag nach „Abonnement kündigen" und sie findet die Passage über „Plan beenden", auch wenn die Wörter nicht übereinstimmen. Das stimmt, und es ist gleichzeitig der Grund, warum reine Vektorsuche bei den Abfragen, die in der Praxis am wichtigsten sind, still und leise versagt: bei solchen mit einem bestimmten Fehlercode, einer Produkt-SKU, einem Personennamen oder einem Konfigurationsschlüssel. Embeddings sind gut bei Synonymie und schlecht bei Präzision, denn der eigentliche Sinn eines Embeddings ist es, spezifische Token in eine unscharfe semantische Nachbarschaft zu komprimieren – genau die Eigenschaft, die ERR_429_RATE_LIMIT für einen Dense Retriever fast gleich aussehen lässt wie ERR_500_INTERNAL.
Die Lösung besteht nicht darin, die Vektorsuche aufzugeben – sondern damit aufzuhören, sie als einzigen Suchmechanismus zu behandeln. BM25, der jahrzehntealte Keyword-Ranking-Algorithmus hinter den meisten Volltextsuchmaschinen, ist exzellent in genau dem, worin Embeddings schwach sind: exakte Übereinstimmungen, seltene Token, Bezeichner, Akronyme. Beide parallel zu betreiben und die Ergebnisse zusammenzuführen – hybride Suche – deckt den blinden Fleck jeder Methode mit der Stärke der anderen ab. Und entscheidend: Du brauchst dafür weder Elasticsearch noch einen separaten Such-Cluster. Postgres hat seit Version 8.3 eine leistungsfähige Volltextsuchmaschine eingebaut, und mit pgvector für Embeddings können beide Retrieval-Pfade in derselben Tabelle, derselben Abfrage und derselben Transaktion leben.
Du wirst lernen:
- Warum reine Vektorsuche systematisch bei exakten Begriffen und Bezeichner-Abfragen versagt
- Wie Postgres’ eingebaute Volltextsuche (
tsvector,ts_rank, BM25-ähnliches Ranking) tatsächlich funktioniert - Wie man BM25- und Vektorscores mittels Reciprocal Rank Fusion (RRF) kombiniert – mit ausführbarem SQL
- Wie man beide Retrieval-Pfade in einer einzigen Postgres-Abfrage ohne separate Such-Infrastruktur ausführt
- Wie man die
k-Konstante von RRF und die Gewichte je Quelle für den eigenen Query-Mix abstimmt - Wo hybride Suche immer noch nicht ausreicht und was man darüber hinaus schichten kann
Inhaltsverzeichnis
- Warum reine Vektorsuche exakte Begriffe übersieht
- BM25 und Volltextsuche in Postgres
- Vektorsuche in derselben Datenbank mit pgvector
- Reciprocal Rank Fusion: Zwei Rankings zu einem zusammenführen
- RRF vs. Score-Normalisierung
- Eine einzige Hybrid-Abfrage in Postgres
- Ein konkretes Beispiel: Eine Abfrage, zwei Rankings
- RRF abstimmen: Die k-Konstante und Quellgewichte
- Wo hybride Suche immer noch Lücken hat
- Produktionshinweise
Warum reine Vektorsuche exakte Begriffe übersieht
Ein Embedding-Modell bildet Text in einen kontinuierlichen Vektorraum ab, in dem semantisch verwandte Konzepte nah beieinander liegen. Das ist eine bewusste, nützliche Komprimierung – aber Komprimierung bedeutet, dass Informationen weggeworfen werden. Und was zuerst weggeworfen wird, ist typischerweise die exakte Oberflächenform seltener Token. Drei reale Abfragetypen, die bei rein vektorbasiertem Retrieval scheitern:
- Bezeichner und Codes:
INVOICE-2026-004471,SKU-88213, der Klassenname einer Exception in einem Stack-Trace. Diese haben keine semantische Nachbarschaft – das Modell hat dieses exakte Token nie oft genug gesehen, um etwas Bedeutungsvolles darüber gelernt zu haben, sodass sein Embedding gegenüber ähnlich aussehenden Bezeichnern im Wesentlichen Rauschen ist. - Seltene Fachbegriffe: ein bestimmter API-Methodenname, ein Konfigurationsflag, eine chemische Verbindung. Wenn die Trainingsdaten des Embedding-Modells diesen Begriff kaum abgedeckt haben, ist seine Vektorposition unzuverlässig.
- Negation und exaktes Phrasen-Matching: „do NOT use
--force" und „always use--force" können fast identische Vektoren ergeben, weil die umgebenden Wörter die Repräsentation dominieren und kurze Negationstokens kaum zum Gesamtvektor beitragen.
BM25 hat diese Probleme nicht, weil es überhaupt nicht versucht, Bedeutung zu verstehen – es bewertet Dokumente nach Termhäufigkeit (wie oft ein Query-Term in einem Dokument vorkommt), gewichtet gegen die inverse Dokumenthäufigkeit (wie selten der Term im gesamten Korpus ist), mit Längennormalisierung, damit lange Dokumente nicht allein durch mehr Wörter gewinnen. Ein Dokument, das die wörtliche Zeichenkette INVOICE-2026-004471 enthält, wird für diese exakte Abfrage jedes Mal den höchsten Score erzielen – deterministisch, ohne Embedding-Modell im Ablauf. Dies ist genau die Klasse von Fehlern, die in warum RAG die falschen Chunks abruft ausführlicher behandelt wird – Query-Dokument-Asymmetrie und Embedding-Blindstellen sind eine der häufigsten Grundursachen einer Retrieval-Pipeline, die kaputt wirkt, aber eigentlich nur diesen zweiten Retrieval-Pfad vermisst.
BM25 und Volltextsuche in Postgres
Postgres’ native Volltextsuche ist nicht buchstäblich die BM25-Formel – sie verwendet ts_rank / ts_rank_cd, was BM25-ähnlich ist (termfrequenz- und abdeckungsbasiert), aber standardmäßig nicht BM25’s inversen Dokumentfrequenz-Term oder seine spezifische Längennormalisierungskurve enthält. Für echtes BM25-Scoring bietet die pg_search-Erweiterung (über ParadeDB) oder das vectorscale/pgvectorscale-Ökosystem das direkt an. Für viele Hybrid-Such-Anwendungsfälle kommt ts_rank_cd über einen gut konfigurierten tsvector nahe genug heran, dass der Unterschied keine Rolle spielt, es sei denn, du benchmarkst die Suchqualität direkt gegen eine dedizierte Suchmaschine. Dieser Beitrag verwendet Postgres’ eingebaute Volltextsuche, weil sie null zusätzliche Infrastruktur erfordert – das Ziel ist eine Hybrid-Pipeline, die vollständig in einer normalen Postgres-Instanz lebt.
Die Einrichtung beginnt mit einer generierten tsvector-Spalte und einem GIN-Index darüber:
ALTER TABLE documents ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(body, ''))) STORED;
CREATE INDEX documents_search_idx ON documents USING GIN (search_vector);
Abfrage mit Ranking:
SELECT id, body, ts_rank_cd(search_vector, query) AS bm25_score
FROM documents, plainto_tsquery('english', 'rate limit error') AS query
WHERE search_vector @@ query
ORDER BY bm25_score DESC
LIMIT 20;
to_tsvector übernimmt Stemming und Stoppwortentfernung automatisch (englisches „canceling", „cancels" und „canceled" werden alle auf dasselbe Lexem reduziert). Deshalb erholt sich die Volltextsuche allein noch von einem Teil der Synonymie, die ein naiver Substring-Match verpassen würde – aber bei weitem nicht so viel wie ein Embedding bei wirklich unterschiedlicher Formulierung.
Vektorsuche in derselben Datenbank mit pgvector
pgvector fügt Postgres direkt einen nativen Vektortyp und approximative Nearest-Neighbor-Indizierung (IVFFlat oder HNSW) hinzu:
ALTER TABLE documents ADD COLUMN embedding vector(1536);
CREATE INDEX documents_embedding_idx ON documents
USING hnsw (embedding vector_cosine_ops);
SELECT id, body, 1 - (embedding <=> :query_embedding) AS vector_score
FROM documents
ORDER BY embedding <=> :query_embedding
LIMIT 20;
Der <=>-Operator ist der Cosinus-Abstand; 1 - Abstand wandelt ihn zur Konsistenz mit ts_rank_cd in einen Ähnlichkeits-Score um, der bereits nach dem Motto „höher ist besser" funktioniert. Beide Indizes – GIN für Volltext, HNSW für Vektoren – koexistieren auf derselben Tabelle, was der gesamte Punkt ist: keine Datenverdopplung in ein zweites System, kein Synchron-halten zweier Datenbanken, kein zweiter Fehlerpunkt.
Reciprocal Rank Fusion: Zwei Rankings zu einem zusammenführen
Das Schwierige an der Hybrid-Suche war nie das Ausführen zweier Abfragen – es ist das Kombinieren zweier unvergleichbarer Scores zu einem einzigen Ranking. BM25-Scores und Cosinus-Ähnlichkeits-Scores leben auf völlig verschiedenen Skalen und Verteilungen; ein BM25-Score von 8,2 und eine Cosinus-Ähnlichkeit von 0,81 sind nicht direkt vergleichbar, und ein naives Mitteln lässt denjenigen Score dominieren, der zufällig einen größeren numerischen Bereich hat.
Reciprocal Rank Fusion umgeht das Skalenproblem vollständig, indem es die Rohwerte wegwirft und stattdessen die Rangposition jedes Ergebnisses verwendet:
RRF_score(doc) = Σ 1 / (k + rank_i(doc))
summiert über jede Suchmethode i, die doc zurückgegeben hat, wobei rank_i(doc) die 1-indizierte Position des Dokuments in den Ergebnissen von Methode i ist und k eine Glättungskonstante ist (60 ist der Standard-Standardwert aus dem ursprünglichen RRF-Paper), die die Kurve flacht, sodass der Unterschied zwischen Rang 1 und Rang 2 den Score nicht dominiert.
def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return dict(sorted(scores.items(), key=lambda kv: kv[1], reverse=True))
Ein Dokument, das in beiden – BM25 und Vektorsuche – gut abschneidet, akkumuliert einen höheren kombinierten Score als ein Dokument, das nur in einer Methode an erster Stelle liegt – genau das gewünschte Verhalten: starke Übereinstimmung zwischen zwei unabhängigen Signalen ist vertrauenswürdiger als ein einziges starkes Signal aus nur einer Methode.
RRF vs. Score-Normalisierung
RRF ist nicht der einzige Weg, zwei Rankings zusammenzuführen, und es lohnt sich zu wissen, worauf es im Vergleich zur Alternative verzichtet. Der andere gängige Ansatz ist Score-Normalisierung: Min-Max oder Z-Score normalisiert die Rohwerte jeder Methode auf einen gemeinsamen Bereich von 0–1 und nimmt dann direkt eine gewichtete Summe der normalisierten Scores, anstatt sie wegzuwerfen und die Rangposition zu verwenden.
def minmax_normalize(scores: dict[str, float]) -> dict[str, float]:
lo, hi = min(scores.values()), max(scores.values())
span = (hi - lo) or 1.0
return {doc_id: (s - lo) / span for doc_id, s in scores.items()}
Normalisierung behält mehr Informationen als RRF – sie bewahrt, wie viel besser das Top-Ergebnis war, nicht nur dass es das erste war –, was eine Rolle spielen kann, wenn das Top-Ergebnis einer Methode klar heraussticht und das nächste weit abgeschlagen ist. Der Nachteil ist Fragilität: BM25-Scores insbesondere haben eine Verteilung, die sich mit dem Korpus und der Termseltenheit der Abfrage verschiebt, sodass ein pro-Abfrage berechneter Normalisierungsbereich über Abfragen hinweg inkonsistent sein kann – auf Arten, die schwer zu erkennen sind ohne genaue Überwachung. RRFs nur-Rang-Ansatz ist weniger ausdrucksstark, aber weitaus robuster gegenüber genau dieser Art von Drift. Deshalb ist es in der Praxis der häufigere Standard für hybride Suche – greife zur Score-Normalisierung bewusst, wenn du verifiziert hast, dass deine Score-Verteilungen stabil genug sind, um es zu rechtfertigen, nicht als Standard.
Eine einzige Hybrid-Abfrage in Postgres
Beide Retrieval-Pfade und die Fusion lassen sich als eine einzige SQL-Abfrage mit CTEs ausdrücken, sodass ein einziger Round-Trip zu Postgres das finale hybride Rang-Ergebnisset zurückgibt:
WITH bm25_results AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_vector, query) DESC) AS rank
FROM documents, plainto_tsquery('english', :query_text) AS query
WHERE search_vector @@ query
LIMIT 50
),
vector_results AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :query_embedding) AS rank
FROM documents
ORDER BY embedding <=> :query_embedding
LIMIT 50
),
fused AS (
SELECT COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
)
SELECT d.id, d.body, f.rrf_score
FROM fused f
JOIN documents d ON d.id = f.id
ORDER BY f.rrf_score DESC
LIMIT 10;
Das FULL OUTER JOIN macht dies korrekt: Ein Dokument, das nur BM25 gefunden hat (weil es einen exakten Bezeichner enthält, den kein Embedding hoch ranken würde), erhält trotzdem einen Nicht-Null-RRF-Score und eine Chance auf der Oberfläche – und dasselbe gilt symmetrisch für ein Dokument, das nur die Vektorsuche gefunden hat. Es ist wichtig, jeden Kandidatensatz auf 50 zu erweitern, bevor man fusioniert (anstatt nur die Top-10 jeder Methode zu fusionieren) – ein Dokument, das auf Vektorähnlichkeit auf Rang 30 gewesen wäre, aber auf BM25 auf Rang 2, muss tatsächlich im Vektor-Kandidatenpool sein, damit der Fusionsschritt es überhaupt sieht.
Ein konkretes Beispiel: Eine Abfrage, zwei Rankings
Um die Zusammenführung greifbar zu machen, hier ein illustrativer Trace (manuell konstruiert, nicht aus einem Produktionssystem) für die Abfrage „rate limit error" gegen eine kleine Support-Docs-Tabelle:
| Dokument | BM25-Rang | Vektor-Rang | Grund |
|---|---|---|---|
doc_412 – „Handling ERR_429_RATE_LIMIT" | 1 | 9 | Enthält den wörtlichen fehlercode-nahen Begriff „rate limit", aber die Formulierung ist knapp und codelastig, sodass es sich weiter von einer konversationellen Abfrage entfernt einbettet |
doc_88 – „Throttling and backoff strategies" | 14 | 1 | Semantisch dasselbe Problem, aber verwendet die Wörter „rate" oder „limit" nie zusammen |
doc_203 – „API quotas overview" | 3 | 4 | Verwendet „limit" und ist thematisch bei beiden Signalen angrenzend |
Fusion bei k = 60: doc_412 erzielt 1/(60+1) + 1/(60+9) = 0,0164 + 0,0145 = 0,0309; doc_88 erzielt 1/(60+14) + 1/(60+1) = 0,0135 + 0,0164 = 0,0299; doc_203 erzielt 1/(60+3) + 1/(60+4) = 0,0159 + 0,0156 = 0,0315. doc_203 schlägt beide Einzel-Methoden-Führer knapp, weil es bei beiden Signalen respektabel abschnitt – genau das „Übereinstimmung schlägt ein einziges starkes Signal"-Verhalten, für das RRF entwickelt wurde. Beachte auch, dass weder doc_412 noch doc_88 bei der jeweils anderen Methode zuverlässig oben aufgetaucht wäre; eine reine Vektor-Pipeline vergräbt das exakt-treffende doc_412 auf Rang 9, und eine reine BM25-Pipeline vergräbt das semantisch relevante doc_88 auf Rang 14. Das ist die konkrete Form des Fehlers, den hybride Suche zu schließen existiert.
RRF abstimmen: Die k-Konstante und Quellgewichte
Der Standard-k = 60 ist ein vernünftiger Ausgangspunkt, keine universelle Konstante. Kleinere k-Werte schärfen die Kurve und geben Rang-1-Ergebnissen unverhältnismäßig großen Einfluss auf den fusionierten Score relativ zu Rang 10 – nützlich, wenn deine Top-BM25- und Vektor-Ergebnisse in der Regel wirklich besser sind als der Rest des Kandidatenpools. Höhere k-Werte glätten sie, nützlich wenn die Rankings innerhalb jeder Methode rauschbehaftet sind und du nicht willst, dass die Fusion auf die Top-Wahl einer einzigen Methode überreagiert.
RRF, wie oben geschrieben, behandelt BM25 und Vektorsuche als gleich vertrauenswürdig, was für jeden Query-Mix nicht zutrifft. Eine gewichtete Variante lässt dich das Gleichgewicht kippen:
COALESCE(1.5 / (60 + b.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score
Ein 1,5-faches Gewicht auf dem BM25-Term verzerrt das fusionierte Ranking in Richtung exakter Treffer – angemessen für einen Korpus mit vielen Bezeichnern und codelastigem Inhalt (API-Docs, Log-Suche, Ticketing-Systeme), wo Keyword-Präzision in der Regel wichtiger ist als semantische Breite. Ein Korpus aus langen, konversationellen Dokumenten (Support-Transkripte, Wissensdatenbank-Artikel) profitiert oft davon, das Gewicht in die andere Richtung zu kippen. Der einzig verlässliche Weg, diese Gewichte zu wählen, ist dieselbe Eval-Set-Methodik wie bei Chunking-Strategie-Vergleichen – bau ein Query-Set aus deinem eigenen Korpus auf, messe Recall@k unter einigen Gewichtskombinationen und lass die Daten entscheiden, anstatt einen Standard aus einem anderen System zu übernehmen.
Wo hybride Suche immer noch Lücken hat
Hybride Suche löst das spezifische Problem, dass Vektorsuche exakte Begriffe übersieht – es löst keine Probleme, die vor oder nach dem Retrieval liegen. Wenn Chunk-Grenzen einen Bezeichner von dem Satz trennen, der erklärt, was er bedeutet, kann BM25 immer noch den Chunk finden, der den Bezeichner enthält, aber das LLM, das eine Antwort aus diesem Chunk generiert, arbeitet so oder so mit demselben unvollständigen Kontext. Den Kandidatenpool auf 50 pro Methode zu erweitern, bevor man fusioniert, bedeutet auch mehr Kandidaten für einen Reranker, falls du einen verwendest – schau die Abwägungen in Reranking in RAG an, um zu entscheiden, wann dieser zusätzliche Schritt seine hinzugefügte Latenz über einem hybriden Such-Kandidatenset wert ist. Und hybride Suche sucht innerhalb der Dokumentenmenge, auf die die Abfrage beschränkt ist – sie sagt nichts darüber aus, ob diese Beschränkung selbst korrekt ist, was in jedem System, das mehr als einen Mandanten bedient, sehr wichtig ist.
Produktionshinweise
Ein paar Dinge, die wichtig werden, sobald dies über einen Prototyp hinausgeht:
- GIN-Index nebenläufig auf großen Tabellen neu erstellen.
CREATE INDEX CONCURRENTLYvermeidet das Sperren von Schreibvorgängen während der Index-Erstellung, was wichtig wird, sobalddocumentsmehr als ein paar hunderttausend Zeilen hat. - HNSW-Erstellungszeit und Speicher skalieren mit dem Datenvolumen. Setze
maintenance_work_memgroßzügig während der Index-Erstellung und plane echte Zeit dafür ein bei Tabellen mit Millionen von Zeilen – es ist nicht so unmittelbar wie ein GIN-Neuaufbau bei bescheidenem Textvolumen. - Kandidatenpool-Größen bewusst begrenzen. Das Fusionieren über die Top-50 jeder Methode (anstatt 10 oder 500) ist ein echter Abstimmungsknopf – zu klein und du übersiehst methodenübergreifende Meinungsverschiedenheiten, die die Fusion eigentlich abfangen soll; zu groß und du bezahlst für einen Vektor-Scan und Rang-Berechnung über Kandidaten, die niemals auftauchen werden.
plainto_tsqueryist der sichere Standard für Benutzereingaben. Es unterstützt keine booleschen Abfrageoperatoren von Postgres (&,|,!), was bedeutet, dass es auch nicht durch Benutzereingaben, die diese enthalten, gebrochen werden kann – greif zuwebsearch_to_tsquerynur wenn du speziell Unterstützung für Phrasen in Anführungszeichen und Ausschlüsse für Endbenutzer bereitstellen willst.- Das Eval-Set nach jeder Embedding-Modell- oder Tokenizer-Änderung erneut ausführen. Eine Hybrid-Pipeline hat zwei bewegliche Teile, die jeweils unabhängig voneinander regressieren können; einen zu ändern ohne beide Scores neu zu messen riskiert, das fein abgestimmte Fusionsgleichgewicht still zu verschieben.
Fazit
Vektorsuche und BM25 sind keine konkurrierenden Ansätze für dasselbe Problem – sie lösen zwei verschiedene Probleme, die zufällig beide „Suche" heißen. Eine versteht Bedeutung und übersieht exakte Token; die andere trifft exakte Token und versteht nichts über Bedeutung. Reciprocal Rank Fusion ist ein wirklich einfacher Weg, sie zu kombinieren, ohne zwei unvergleichbare Scoring-Skalen in Einklang bringen zu müssen, und Postgres – mit tsvector und pgvector auf derselben Tabelle – bedeutet, dass hybride Suche keinen zweiten Such-Cluster erfordert, um dorthin zu gelangen. Wenn deine RAG-Pipeline heute nur auf Vektoren setzt, ist der schnellste Korrektheitssgewinn kein größeres Embedding-Modell – es ist BM25 einen Platz am selben Tisch zu geben, buchstäblich.

