Hybride Suche mit pgvector und BM25: Bessere Antworten ohne Elasticsearch

#Hybride Suche mit pgvector
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Wenn du ein RAG-Feature auf PostgreSQL ausgeliefert hast, bist du vermutlich gegen diese Wand gelaufen: Ein Nutzer sucht nach einer exakten Rechnungsnummer, einer Produkt-SKU oder einem Funktionsnamen, und deine semantisch brillante Vektorsuche liefert fünf lose verwandte Absätze zurück, die den Begriff überhaupt nicht enthalten. Hybride Suche mit pgvector und einem BM25-artigen Keyword-Ranker behebt genau diesen Fehlermodus, und du kannst sie in der Postgres-Instanz bauen, die du ohnehin betreibst. Kein Elasticsearch-Cluster, keine Sync-Pipeline, keine zweite Source of Truth.

Dieser Post erklärt, warum Retrieval mit nur einer Methode scheitert, was PostgreSQL auf der Keyword-Seite tatsächlich bietet und wie du beide Ergebnislisten mit Reciprocal Rank Fusion in reinem SQL zusammenführst.

Warum reine Vektorsuche offensichtliche Antworten verfehlt

Embedding-Modelle komprimieren Bedeutung in einen Vektor fester Größe. Das ist ihre Stärke und ihr blinder Fleck. Zwei Texte, die ungefähr dasselbe bedeuten, landen nah beieinander - genau das lässt semantisches Retrieval in Demos magisch wirken. Aber die Kompression verwirft Oberflächendetails, und Oberflächendetails sind genau das, woraus viele echte Queries bestehen:

  • Identifier: Ticketnummern, SKUs, Fehlercodes wie SQLSTATE[40P01], IBANs. Embeddings behandeln sie als Beinahe-Rauschen.
  • Seltene Eigennamen: ein Nischen-Library-Name oder ein kundenspezifischer Begriff, den das Embedding-Modell im Training kaum gesehen hat.
  • Negationen und exakte Phrasen: "Kündigung ohne Frist" und "Kündigung mit Frist" embedden fast identisch, mit gegensätzlicher rechtlicher Bedeutung.

Die Keyword-Suche hat die gespiegelte Schwäche. Sie kann nicht erkennen, dass "Wie kündige ich meinen Plan?" ein Dokument mit dem Titel "Dein Abo beenden" treffen sollte. Eine Methode matcht Wörter, die andere matcht Bedeutung, und Produktions-Queries brauchen beides. Die Forschung bestätigt das: Hybrides Retrieval schlägt auf Standard-Benchmarks wie BEIR konsistent jede Einzelmethode, weshalb jeder dedizierte Suchanbieter es inzwischen als Default ausliefert.

Wenn deine Pipeline bereits plausible-aber-falsche Antworten liefert, ist die Retrieval-Qualität der erste Ort zum Nachsehen. Die nachgelagerten Symptome haben wir in Why your RAG pipeline returns confident garbage behandelt; in diesem Post geht es darum, die Retrieval-Schicht selbst zu reparieren.

Was BM25 ist und was Postgres wirklich bietet

BM25 ist die Ranking-Funktion hinter Elasticsearch und den meisten klassischen Suchmaschinen. Sie bewertet ein Dokument nach Termfrequenz, gedämpft, sodass das zehnte Vorkommen eines Wortes weniger zählt als das zweite, normalisiert nach Dokumentlänge und gewichtet danach, wie selten der Begriff im Korpus ist (Inverse Document Frequency).

Ehrlichkeits-Checkpoint: Die native PostgreSQL-Volltextsuche ist kein BM25. ts_rank und ts_rank_cd ranken nach Termfrequenz und Nähe, nutzen aber keine korpusweiten IDF-Statistiken. In der Praxis hast du zwei Optionen:

  1. Native FTS mit ts_rank_cd. In jedem Postgres enthalten. Bei Korpora mit Zehn- oder Hunderttausenden von Dokumenten ist der Ranking-Unterschied zu echtem BM25 selten das, was deine Nutzer bemerken - vor allem, sobald Rank Fusion und ein Reranker obendrauf sitzen.
  2. Die pg_search-Extension (ParadeDB). Bringt echtes BM25-Scoring mit eigenem Indextyp. Eine Evaluierung wert, wenn Keyword-Relevanz zentral für dein Produkt ist, aber es ist eine weitere Extension, die installiert, aktualisiert und auf Managed-Plattformen freigegeben werden muss.

Unsere Empfehlung für die meisten Teams: Starte mit nativer FTS. Sie räumt das operative Argument für Elasticsearch in einem Schritt ab, und du kannst das Keyword-Bein später gegen pg_search tauschen, ohne die Fusionslogik anzufassen.

Hybride Suche mit pgvector bauen: Schema und Indexe

Nimm eine typische RAG-Chunk-Tabelle an. Die zwei Ergänzungen sind eine generierte tsvector-Spalte und das richtige Index-Paar:

CREATE TABLE chunks (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  document_id BIGINT NOT NULL REFERENCES documents(id),
  tenant_id BIGINT NOT NULL,
  content TEXT NOT NULL,
  embedding VECTOR(1536) NOT NULL,
  content_tsv TSVECTOR GENERATED ALWAYS AS (
    to_tsvector('english', content)
  ) STORED
);

CREATE INDEX chunks_embedding_idx ON chunks
  USING hnsw (embedding vector_cosine_ops);

CREATE INDEX chunks_tsv_idx ON chunks USING gin (content_tsv);

Drei Details, die in Produktion zählen:

  • Generierte Spalte statt Trigger. GENERATED ALWAYS AS ... STORED hält den tsvector ohne jeglichen Anwendungscode synchron und übersteht Bulk-Importe.
  • HNSW statt IVFFlat. Seit pgvector 0.5 bietet HNSW bessere Recall-Latenz-Trade-offs und braucht, anders als IVFFlat, kein Retraining, wenn die Daten wachsen.
  • Filterspalten leben in derselben Tabelle. Die tenant_id-Filterung passiert im selben Query-Plan. Das ist die stille Superkraft gegenüber einer separaten Suchmaschine, wo das Vorfiltern von Vektoren nach Tenant entweder teuer oder approximativ ist.

Die beiden Ergebnislisten fusionieren: Reciprocal Rank Fusion in SQL

Du hast jetzt zwei gerankte Listen pro Query: Nearest Neighbors nach Kosinus-Distanz und Top-Keyword-Treffer nach ts_rank_cd. Die Scores liegen auf inkompatiblen Skalen, versuche also nicht, sie zu mitteln. Reciprocal Rank Fusion (RRF) umgeht das Problem, indem sie Ränge statt Scores kombiniert: Jedes Dokument erhält 1 / (k + rank) aus jeder Liste, in der es auftaucht, mit k = 60 als Standard-Dämpfungskonstante.

WITH semantic AS (
  SELECT id, RANK() OVER (ORDER BY embedding <=> $1) AS rnk
  FROM chunks
  WHERE tenant_id = $3
  ORDER BY embedding <=> $1
  LIMIT 20
),
keyword AS (
  SELECT id, RANK() OVER (
    ORDER BY ts_rank_cd(content_tsv, websearch_to_tsquery('english', $2)) DESC
  ) AS rnk
  FROM chunks
  WHERE tenant_id = $3
    AND content_tsv @@ websearch_to_tsquery('english', $2)
  LIMIT 20
)
SELECT COALESCE(s.id, k.id) AS id,
       COALESCE(1.0 / (60 + s.rnk), 0) +
       COALESCE(1.0 / (60 + k.rnk), 0) AS rrf_score
FROM semantic s
FULL OUTER JOIN keyword k USING (id)
ORDER BY rrf_score DESC
LIMIT 8;

Das ist die gesamte Hybrid-Engine. Ein Roundtrip, eine Transaktion, Tenant-Filterung auf beiden Beinen vor der Fusion. websearch_to_tsquery akzeptiert rohe Nutzereingaben sicher, inklusive Phrasen in Anführungszeichen und Minus-Operatoren, du brauchst also keinen Query-Parser.

Zwei Stellschrauben sind wissenswert. Das LIMIT 20 pro Bein steuert, wie tief jede Methode greifen kann; erhöhe es auf 50 oder 100, wenn dein Reranker sich die Kandidaten leisten kann. Und wenn deine Nutzer stark zu Identifier-Lookups tendieren, gewichte das Keyword-Bein, indem du seinen RRF-Term vor dem Summieren mit 1,2 bis 1,5 multiplizierst. Widerstehe dem Drang, weiter zu tunen, bevor du ein Evaluationsset hast; Rank Fusion ist gerade deshalb robust, weil sie absolute Scores ignoriert.

Operative Notizen aus echten Deployments

Index-Wartung. Die HNSW-Build-Zeit wächst spürbar mit der Korpusgröße. Setze maintenance_work_mem großzügig (mehrere GB, wenn vorhanden), bevor du CREATE INDEX ausführst, und baue auf Live-Systemen concurrently.

Statement-Timeouts. Eine hybride Query besteht aus zwei Index-Scans plus einem kleinen Join. Bei einigen Millionen Chunks kannst du einstellige bis niedrige zweistellige Millisekunden für das Keyword-Bein erwarten und Ähnliches für HNSW bei ef_search = 40. Wenn du Hunderte von Millisekunden siehst, ist der übliche Übeltäter ein Filter, den der Planner nach dem Vektor-Scan statt davor anwendet; prüfe EXPLAIN ANALYZE auf Post-Filtering.

Connection-Druck. Embedding-Calls sind langsam (Dutzende bis Hunderte von ms), halte also niemals eine Datenbankverbindung, während du auf die Embedding-API wartest. Hole zuerst den Vektor, führe dann das SQL aus. Teams auf Symfony oder Next.js mit PgBouncer im Transaction-Mode bekommen das gratis, wenn der Embedding-Call außerhalb der Transaktion lebt.

Sprachbehandlung. to_tsvector('english', ...) stemmt aggressiv. Für gemischtsprachige Korpora speichere eine language-Spalte und nutze sie als regconfig-Argument, oder falle auf die simple-Konfiguration zurück und akzeptiere schwächeres Stemming statt falschem Stemming.

Wir sehen diese Probleme am häufigsten beim Auditieren von AI-Features, die aus einem Prototyp gewachsen sind. Wenn dir das bekannt vorkommt: Unsere Arbeit im Code Quality Consulting deckt regelmäßig genau diese Retrieval-Schicht ab, und für Teams, die das Feature von Grund auf bauen, übernehmen wir den kompletten Stack im Rahmen von Custom Software Development.

Wann Elasticsearch oder eine dedizierte Engine trotzdem gewinnt

Hybride Suche in Postgres ist der richtige Default für die meisten SaaS-Produkte, aber keine Universalantwort. Greife zu einer dedizierten Engine, wenn:

  • Suche das Produkt ist, mit Faceting, tippfehlertoleranter Autocomplete und Per-Field-Boosting als Kern-UX, nicht als unterstützendem Feature.
  • Die Korpusgröße die Zehnmillionen-Chunk-Marke überschreitet und du geshardetes horizontales Scale-out brauchst, das eine einzelne Postgres-Primary nicht bieten kann.
  • Du Cross-Cluster- oder Multi-Region-Such-Topologien brauchst, für die Postgres-Replikation nicht entworfen wurde.

Unterhalb dieser Schwellen spricht die operative Rechnung stark für Postgres: ein System für Backups, ein Zugriffskontrollmodell, transaktionale Konsistenz zwischen Anwendungsdaten und Suchindex und eine Pipeline weniger, die still driften kann. Den breiteren Vergleich haben wir in pgvector vs dedicated vector databases gezogen, und das Fazit gilt auch für die Keyword-Seite.

FAQ

Ersetzt hybride Suche einen Reranker? Nein, sie stapeln sich. RRF erzeugt eine bessere Kandidatenliste; ein Cross-Encoder-Reranker ordnet dann die Top 20 bis 50 Kandidaten mit deutlich höherer Präzision. Hybrides Retrieval plus Reranking ist die stärkste praktische Pipeline, bevor du Fine-Tuning anfasst.

Kann ich das mit einem ORM wie Doctrine oder Prisma machen? Führe die Fusions-Query als natives SQL aus. Sowohl Doctrine DBAL als auch Prisma Raw Queries verarbeiten sie sauber; versuche nicht, RRF durch den ORM-Query-Builder auszudrücken.

Woher weiß ich, dass es tatsächlich etwas verbessert hat? Baue zuerst ein kleines Evaluationsset: 50 bis 100 echte Queries mit bekannt-guten Chunks, gemessen mit Recall@k. Ohne das tunst du blind.

Ausliefern, dann messen

Hybride Suche mit pgvector und Postgres-Fulltext-Ranking ist für die meisten Codebasen eine Ein-Tages-Änderung: eine generierte Spalte, zwei Indexe, eine SQL-Query. Der Gewinn ist ein Retrieval, das sowohl "mein Abo kündigen" als auch "Rechnung INV-2024-0871" beherrscht, ohne ein zweites Suchsystem auf der Gehaltsliste.

Wenn du ein zweites Paar Augen auf deine Retrieval-Architektur willst, oder jemanden, der sie so baut, dass sie den Kontakt mit Produktionstraffic übersteht, schreib an hello@wolf-tech.io oder besuche wolf-tech.io. Wir schauen uns gern an, was du hast, bevor du dich auf neue Infrastruktur festlegst.