Hybridsuche in Postgres: pgvector und Volltextsuche ohne Elasticsearch kombinieren
Ein Startup in Amsterdam baute eine Support-Wissensdatenbank auf pgvector auf. Die semantische Suche war in Demos wirklich beeindruckend - Anfragen wie "Wie kuendige ich mein Abonnement" trafen Artikel mit dem Titel "Dein Abo beenden", obwohl kein einziges Wort uebereinstimmte. Dann wurde das System in der Produktion ausgerollt, und der Fehlerfall trat zu Tage: Nutzer, die nach einem bestimmten Fehlercode wie ERR_AUTH_EXPIRED suchten, erhielten allgemeine Artikel ueber Authentifizierung. Das Vektormodell hatte keine Ahnung, dass ein sechsstelliges Token der wichtigste Teil der Anfrage war. Sie fuegten Elasticsearch als zweites System hinzu. Sechs Monate spaeter zahlten sie zwei Satz Infrastrukturkosten, pflegten zwei Synchronisierungspipelines und debuggten die Luecke zwischen dem, was Postgres wusste, und dem, was Elasticsearch wusste. Es gibt einen besseren Weg.
Hybridsuche in Postgres - pgvector-Embeddings mit nativer tsvector-Volltextsuche kombinieren und die Ergebnisse mit Reciprocal Rank Fusion zusammenfuehren - bietet das meiste, was Elasticsearch an Suchqualitaet bringt, ohne den operativen Overhead. Fuer die meisten mittelgrossen SaaS-Teams, die bis zu einige Millionen Dokumente verwalten, ist das 2026 die richtige Standardwahl.
Warum keines der Systeme alleine ausreicht
Reine Vektorsuche glaenzt beim Intent-Matching. Sie beherrscht Synonyme, Paraphrasierungen und sprachuebergreifende Anfragen, die BM25 schlicht nicht kann. "Wie behebt man einen kaputten Login" trifft korrekt Artikel mit dem Titel "Authentifizierungsfehler beheben". Aber sie versagt bei Spezifitaet. Produktcodes, Fehlerkennzeichner, Versionsnummern, Eigennamen und technische Abkuerzungen werden in denselben Embedding-Raum wie alles andere komprimiert und verlieren dabei ihre Bedeutungsstaerke. Eine Anfrage nach symfony/http-kernel 6.4 sollte den genauen Changelog oder Migrationsleitfaden liefern, nicht eine Wolke semantisch benachbarter Artikel.
Reine Volltextsuche - ob Postgres tsvector, Elasticsearch BM25 oder Solr - ist das Gegenstueck. Sie ist bei exakten Tokens und Teiltreffern sehr praezise, beherrscht Zahlen und Codes korrekt und ist schnell. Aber sie hat kein Konzept von Bedeutung. "Zeig mir Artikel ueber das Beenden meines Abonnements" findet nichts, wenn das Wort "Kuendigung" nicht in der Anfrage auftaucht.
Hybridsuche nutzt beide Systeme und fuehrt die sortierten Listen zusammen. Die einzige Designentscheidung ist, wie zwei Bewertungsskalen kombiniert werden, die nicht auf derselben Achse liegen.
Reciprocal Rank Fusion: die einfachste Zusammenfuehrung, die funktioniert
Reciprocal Rank Fusion (RRF) loest das Problem der Bewertungskombination, indem es rohe Werte komplett ignoriert und nur mit Raengen arbeitet. Jedes Ergebnis erhaelt in jedem System eine Bewertung von 1 / (k + Rang), wobei k eine Konstante ist (typischerweise 60), die den Einfluss des obersten Ergebnisses daempft. Die Endpunktzahl ist die Summe ueber alle Systeme. Ein Ergebnis, das in der Vektorsuche auf Rang 1 und in der Volltextsuche auf Rang 5 liegt, schlaegt ein Ergebnis, das in einem System auf Rang 2 liegt und im anderen gar nicht vorkommt.
Die Formel ist einfach, robust und erfordert keine Gewichtungsanpassung zwischen den beiden Systemen. Teams, die gewichtete Summenansaetze versuchen, verbringen Wochen damit, Koeffizienten pro Anfragetyp zu kalibrieren. RRF degradiert kontrolliert: Wenn ein Ergebnis nur in einem Retrievalsystem auftaucht, traegt es trotzdem positiv bei, anstatt verworfen zu werden.
Das Schema
Ein sauberes Schema fuer die Hybridsuche haelt den Volltextindex in derselben Zeile wie den Vektor. Zwei separate Tabellen zu pflegen, die synchron bleiben muessen, ist dieselbe Problemklasse wie Elasticsearch neben Postgres zu betreiben - nur guenstiger.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunk (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
document_id UUID NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
search_vector TSVECTOR GENERATED ALWAYS AS (
to_tsvector('german', coalesce(content, ''))
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Vektorindex: HNSW fuer approximierte Naechste-Nachbarn-Suche bei Skalierung
CREATE INDEX chunk_embedding_hnsw_idx
ON document_chunk
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Volltextindex: GIN fuer schnelle tsvector-Suchen
CREATE INDEX chunk_fts_gin_idx
ON document_chunk
USING gin (search_vector);
-- B-Tree-Index fuer Tenant-Isolierung (fast jede Anfrage filtert nach Tenant)
CREATE INDEX chunk_tenant_idx ON document_chunk (tenant_id);
Die Spalte GENERATED ALWAYS AS ... STORED haelt den tsvector synchron mit content, ohne Anwendungscode. Du schreibst den Inhalt; Postgres pflegt den Index. Waehle dein Woerterbuch (english, german, simple usw.) basierend auf der Hauptsprache deiner Inhalte - oder pflege mehrere tsvector-Spalten fuer mehrsprachige Korpora.
Die Hybridabfrage
Die zentrale SQL-Abfrage fuehrt zwei CTEs zusammen und wendet RRF in einer einzigen Anfrage an. Postgres wertet beide Retrievalpfade in einem Round-Trip aus.
WITH vector_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS rank
FROM document_chunk
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 60
),
fts_results AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('german', $3)) DESC
) AS rank
FROM document_chunk
WHERE tenant_id = $2
AND search_vector @@ websearch_to_tsquery('german', $3)
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('german', $3)) DESC
LIMIT 60
),
rrf AS (
SELECT
COALESCE(v.id, f.id) AS id,
COALESCE(1.0 / (60 + v.rank), 0) +
COALESCE(1.0 / (60 + f.rank), 0) AS rrf_score
FROM vector_results v
FULL OUTER JOIN fts_results f ON v.id = f.id
)
SELECT
dc.id,
dc.content,
dc.document_id,
rrf.rrf_score
FROM rrf
JOIN document_chunk dc ON dc.id = rrf.id
ORDER BY rrf.rrf_score DESC
LIMIT 10;
$1 ist das Anfrage-Embedding (ein float[], das zu vector gecastet wird), $2 ist die Tenant-UUID und $3 ist der rohe Anfrage-String fuer websearch_to_tsquery. Der FULL OUTER JOIN stellt sicher, dass Ergebnisse, die nur in einem System auftauchen, trotzdem beruecksichtigt werden - sie tragen einen RRF-Term statt zwei bei, was sie natuerlich unter Ergebnisse, die in beiden Systemen vorkommen, einordnet.
Symfony- und Doctrine-Integration
Doctrines native Abfrageunterstuetzung macht es einfach, das in eine Repository-Methode zu verpacken. Der Schluessel ist, das Embedding als nativen Parameter zu uebergeben und DQL fuer die Hybridabfrage zu umgehen, die Postgres-spezifische Syntax nutzt.
// src/Repository/DocumentChunkRepository.php
class DocumentChunkRepository extends ServiceEntityRepository
{
public function __construct(
ManagerRegistry $registry,
private readonly EmbeddingClient $embeddings,
) {
parent::__construct($registry, DocumentChunk::class);
}
/**
* @return array<array{id: int, content: string, document_id: string, rrf_score: float}>
*/
public function hybridSearch(string $tenantId, string $query, int $limit = 10): array
{
$embedding = $this->embeddings->embed($query);
$embeddingLiteral = '[' . implode(',', $embedding) . ']';
$sql = <<<SQL
WITH vector_results AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY embedding <=> :embedding::vector) AS rank
FROM document_chunk
WHERE tenant_id = :tenant
ORDER BY embedding <=> :embedding::vector
LIMIT 60
),
fts_results AS (
SELECT id,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('german', :query)) DESC
) AS rank
FROM document_chunk
WHERE tenant_id = :tenant
AND search_vector @@ websearch_to_tsquery('german', :query)
ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('german', :query)) DESC
LIMIT 60
),
rrf AS (
SELECT COALESCE(v.id, f.id) AS id,
COALESCE(1.0 / (60 + v.rank), 0) +
COALESCE(1.0 / (60 + f.rank), 0) AS rrf_score
FROM vector_results v
FULL OUTER JOIN fts_results f ON v.id = f.id
)
SELECT dc.id, dc.content, dc.document_id, rrf.rrf_score
FROM rrf
JOIN document_chunk dc ON dc.id = rrf.id
ORDER BY rrf.rrf_score DESC
LIMIT :limit
SQL;
$conn = $this->getEntityManager()->getConnection();
$result = $conn->executeQuery($sql, [
'embedding' => $embeddingLiteral,
'tenant' => $tenantId,
'query' => $query,
'limit' => $limit,
]);
return $result->fetchAllAssociative();
}
}
Halte die Embedding-Generierung ausserhalb der Transaktionsgrenze. Ein langsamer Modell-API-Aufruf innerhalb einer Datenbanktransaktion ist ein zuverlaessiger Weg, den Verbindungspool unter Last zu erschoepfen. Zuerst embedden, dann suchen.
Index-Tuning fuer p95 unter 100 ms
Mit den richtigen Indizes bleibt eine Hybridsuche auf einigen Millionen Zeilen auf Standardhardware gut unter 100 ms p95. Die Variablen, die das verschieben, sind HNSW-Konstruktionsparameter, der Laufzeitparameter ef_search und das LIMIT, das an jeden CTE uebergeben wird.
HNSW m und ef_construction. Hoehere m-Werte (Anzahl der Verbindungen pro Knoten) verbessern den Recall auf Kosten von Speicher und langsameren Index-Builds. Fuer die meisten Dokumentkorpora ist m = 16 mit ef_construction = 64 der richtige Ausgangspunkt. ef_construction auf 128 zu erhoehen verbessert den Recall um einige Prozent; es auf 256 zu erhoehen rechtfertigt selten die Index-Build-Zeit.
ef_search zur Anfragezeit. Dieser Wert steuert, wie viele Kandidaten der HNSW-Graph pro Anfrage untersucht. Der Standard ist 40. Einen hoeheren Wert zu setzen verbessert den Recall, erhoeh aber die Latenz. Fuege ihn als Session-Level-Einstellung vor deinen Hybridabfragen hinzu, wenn du strenge Recall-Anforderungen und ein grosszuegiges Latenzbudget hast.
SET hnsw.ef_search = 80;
CTE LIMIT. Beide CTEs rufen 60 Kandidaten vor der Fusion ab. Diesen Wert zu erhoehen verbessert den Recall - du verpasst seltener ein relevantes Dokument, das knapp ausserhalb der Top 60 eines Systems liegt - auf Kosten von mehr Zeilen im RRF-Join. 60 ist der richtige Standard; 100 ist vernuenftig, wenn Recall kritisch ist und dein Korpus gross ist.
ANALYZE der Tenant-Spalte. Da die meisten Anfragen WHERE tenant_id = $2 enthalten, braucht Postgres genaue Statistiken zu dieser Spalte, um den richtigen Plan zu waehlen. ANALYZE document_chunk nach dem ersten grossen Datenladevorgang und nach grossen Bulk-Inserts ausfuehren haelt den Planner ehrlich und verhindert, dass er zum falschen Zeitpunkt auf sequentielle Scans zurueckgreift.
Suchqualitaet messen
Bevor du das System als produktionsreif erklaerst, instrumentiere es. Eine Hybridsuche, die du nicht messen kannst, ist eine Hybridsuche, die du nicht verbessern kannst.
Das minimale Evaluierungs-Harness besteht aus einer Reihe von goldenen Anfrage-Dokument-Paaren: Anfragen, bei denen du die richtigen Antworten kennst. Fuehre deine Hybridsuche gegen jedes davon durch, protokolliere den Rang des erwarteten Ergebnisses und berichte Mean Reciprocal Rank (MRR) und Recall@10. Verfolge diese in CI. Jede Aenderung am Embedding-Modell, am Tokenisierungswoerterbuch oder der RRF-Konstante k sollte gegen diesen Testsatz validiert werden, bevor sie in der Produktion landet.
A/B-teste Embedding-Modelle gegeneinander mit demselben Harness. Der Wechsel von text-embedding-3-small zu text-embedding-3-large verbessert MRR manchmal um 15%; manchmal macht es keinen Unterschied. Der Kostenunterschied ist real, und der Nutzen haengt vollstaendig von deinem Korpus ab. Beide messen, bevor du dich fuer das teurere Modell entscheidest.
Instrumentiere auch auf der Anwendungsebene: Protokolliere Anfrage-Strings, Ergebniszahlen aus jedem CTE-Zweig und finale RRF-Raenge. Anfragen, bei denen der Vektorzweig null Ergebnisse liefert (weil der Korpus keinen semantisch aehnlichen Inhalt hat) oder der FTS-Zweig null Ergebnisse liefert (weil die Anfrage konversationell ist und nichts trifft), sind die wichtigsten Faelle, die frueh erkannt werden muessen. In beiden Szenarien degradiert die Hybridabfrage zu einer Einzelsystem-Anfrage, was immer noch korrektes Verhalten ist - aber zu wissen, dass es passiert, zeigt dir, wo du in bessere Inhaltsabdeckung investieren solltest.
Wann der Wechsel zu einem dedizierten System sinnvoll ist
Hybridsuche in Postgres deckt die meisten B2B-SaaS-Workloads gut ab. Die Schwellenwerte, ab denen ein dediziertes System rational wird, sind enger als Anbieter-Marketing vermuten laesst.
Wechsle zu Elasticsearch, wenn du facettierte Suche mit komplexen Aggregationen ueber Millionen unterschiedlicher Werte benoetigst, Dokument-Updates in Echtzeit bei Tausenden von Schreibvorgaengen pro Sekunde benoetigt werden, oder sprachspezifische Analyser und Token-Filter weit ueber das hinausgehen, was Postgres-Woerterbucher bieten. Elasticsearch ist eine deutlich bessere Suchmaschine fuer inhaltslastige Produkte mit redaktionellen Workflows oder E-Commerce-Katalogen.
Wechsle zu einer dedizierten Vektordatenbank, wenn dein Korpus 50 bis 100 Millionen Chunks ueberschreitet, Multi-Vektor-Retrieval (ColBERT-style Late Interaction) benoetigt wird, oder Anfrageraten in den Bereich von Hunderten gleichzeitiger Vektorssuchen pro Sekunde auf einer einzelnen Primary gehen. Das sind echte Schwellenwerte - darueber werden der HNSW-Speicherbedarf und das Single-Writer-Modell von pgvector zu echten Engpaessen. Darunter, wo die meisten B2B-SaaS-Anwendungen die meiste Zeit ihrer Lebensdauer verbringen, ist die operative Einfachheit, alles in Postgres zu erledigen, mehr wert als jede Funktion eines dedizierten Systems.
Das Amsterdamer Team, das Elasticsearch nachtraeglich hinzufuegen musste, haette sich sechs Monate Doppelsystem-Komplexitaet mit diesem Muster gespart. Postgres war von Anfang an das richtige Werkzeug. Es fehlten nur beide Indizes von Beginn an.
Das in ein bestehendes Produkt integrieren
Hybridsuche zu einer laufenden Symfony-Anwendung hinzuzufuegen ist ein Ein-Sprint-Projekt, wenn das Schema sauber und die Embedding-Pipeline bereits vorhanden ist. Die groessere Investition ist das Evaluierungs-Harness: Goldene Anfrage-Sets definieren, sie in CI ausfuehren und die Faehigkeit aufbauen, Suchqualitaet als Metrik statt als Gefuehl zu behandeln.
Wenn dein Team an diesem Punkt ist - ueber dem Prototyp, auf dem Weg zur Skalierung, wo Suchqualitaet die Retention beeinflusst - und eine zweite Meinung zur Architektur moechtest, bevor du dich auf einen Ansatz festlegst, ist hello@wolf-tech.io der richtige Ausgangspunkt. Wir arbeiten mit SaaS-Teams, die Suche in bestehende Symfony- und Next.js-Produkte einbauen, und helfen dir, die Architektur richtig hinzubekommen, bevor der Korpus so gross wird, dass Aenderungen teuer werden. Mehr zu den Arten von Problemen, an denen wir arbeiten, unter wolf-tech.io.

