Hybridsuche in Postgres: pgvector und Volltextsuche ohne Elasticsearch kombinieren

Sandor Farkas
Gründer & Lead Developer
Experte für Softwareentwicklung und Legacy-Code-Optimierung
LinkedInEin Startup in Amsterdam baute eine Support-Wissensdatenbank auf pgvector auf. Die semantische Suche war in Demos wirklich beeindruckend - Anfragen wie "Wie kündige ich mein Abonnement" trafen Artikel mit dem Titel "Dein Abo beenden", obwohl kein einziges Wort übereinstimmte. 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 über Authentifizierung. Das Vektormodell hatte keine Ahnung, dass ein sechsstelliges Token der wichtigste Teil der Anfrage war. Sie fügten Elasticsearch als zweites System hinzu. Sechs Monate später zahlten sie zwei Satz Infrastrukturkosten, pflegten zwei Synchronisierungspipelines und debuggten die Lücke 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 zusammenführen - bietet das meiste, was Elasticsearch an Suchqualität bringt, ohne den operativen Overhead. Für die meisten mittelgroßen SaaS-Teams, die bis zu einige Millionen Dokumente verwalten, ist das 2026 die richtige Standardwahl.
Warum keines der Systeme alleine ausreicht
Reine Vektorsuche glänzt beim Intent-Matching. Sie beherrscht Synonyme, Paraphrasierungen und sprachübergreifende Anfragen, die BM25 schlicht nicht kann. "Wie behebt man einen kaputten Login" trifft korrekt Artikel mit dem Titel "Authentifizierungsfehler beheben". Aber sie versagt bei Spezifität. Produktcodes, Fehlerkennzeichner, Versionsnummern, Eigennamen und technische Abkürzungen werden in denselben Embedding-Raum wie alles andere komprimiert und verlieren dabei ihre Bedeutungsstärke. 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 Gegenstück. Sie ist bei exakten Tokens und Teiltreffern sehr präzise, beherrscht Zahlen und Codes korrekt und ist schnell. Aber sie hat kein Konzept von Bedeutung. "Zeig mir Artikel über das Beenden meines Abonnements" findet nichts, wenn das Wort "Kündigung" nicht in der Anfrage auftaucht.
Hybridsuche nutzt beide Systeme und führt die sortierten Listen zusammen. Die einzige Designentscheidung ist, wie zwei Bewertungsskalen kombiniert werden, die nicht auf derselben Achse liegen.
Reciprocal Rank Fusion: die einfachste Zusammenführung, die funktioniert
Reciprocal Rank Fusion (RRF) löst das Problem der Bewertungskombination, indem es rohe Werte komplett ignoriert und nur mit Rängen arbeitet. Jedes Ergebnis erhält in jedem System eine Bewertung von 1 / (k + Rang), wobei k eine Konstante ist (typischerweise 60), die den Einfluss des obersten Ergebnisses dämpft. Die Endpunktzahl ist die Summe über alle Systeme. Ein Ergebnis, das in der Vektorsuche auf Rang 1 und in der Volltextsuche auf Rang 5 liegt, schlägt 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 Summenansätze versuchen, verbringen Wochen damit, Koeffizienten pro Anfragetyp zu kalibrieren. RRF degradiert kontrolliert: Wenn ein Ergebnis nur in einem Retrievalsystem auftaucht, trägt es trotzdem positiv bei, anstatt verworfen zu werden.
Das Schema
Ein sauberes Schema für die Hybridsuche hält den Volltextindex in derselben Zeile wie den Vektor. Zwei separate Tabellen zu pflegen, die synchron bleiben müssen, ist dieselbe Problemklasse wie Elasticsearch neben Postgres zu betreiben - nur günstiger.
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 hält den tsvector synchron mit content, ohne Anwendungscode. Du schreibst den Inhalt; Postgres pflegt den Index. Wähle dein Wörterbuch (english, german, simple usw.) basierend auf der Hauptsprache deiner Inhalte - oder pflege mehrere tsvector-Spalten für mehrsprachige Korpora.
Die Hybridabfrage
Die zentrale SQL-Abfrage führt 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 für websearch_to_tsquery. Der FULL OUTER JOIN stellt sicher, dass Ergebnisse, die nur in einem System auftauchen, trotzdem berücksichtigt werden - sie tragen einen RRF-Term statt zwei bei, was sie natürlich unter Ergebnisse, die in beiden Systemen vorkommen, einordnet.
Symfony- und Doctrine-Integration
Doctrines native Abfrageunterstützung macht es einfach, das in eine Repository-Methode zu verpacken. Der Schlüssel ist, das Embedding als nativen Parameter zu übergeben und DQL für 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 außerhalb der Transaktionsgrenze. Ein langsamer Modell-API-Aufruf innerhalb einer Datenbanktransaktion ist ein zuverlässiger Weg, den Verbindungspool unter Last zu erschöpfen. Zuerst embedden, dann suchen.
Index-Tuning für 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 übergeben wird.
HNSW m und ef_construction. Höhere m-Werte (Anzahl der Verbindungen pro Knoten) verbessern den Recall auf Kosten von Speicher und langsameren Index-Builds. Für die meisten Dokumentkorpora ist m = 16 mit ef_construction = 64 der richtige Ausgangspunkt. ef_construction auf 128 zu erhöhen verbessert den Recall um einige Prozent; es auf 256 zu erhöhen 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 höheren Wert zu setzen verbessert den Recall, erhöht aber die Latenz. Füge ihn als Session-Level-Einstellung vor deinen Hybridabfragen hinzu, wenn du strenge Recall-Anforderungen und ein großzügiges Latenzbudget hast.
SET hnsw.ef_search = 80;
CTE LIMIT. Beide CTEs rufen 60 Kandidaten vor der Fusion ab. Diesen Wert zu erhöhen verbessert den Recall - du verpasst seltener ein relevantes Dokument, das knapp außerhalb der Top 60 eines Systems liegt - auf Kosten von mehr Zeilen im RRF-Join. 60 ist der richtige Standard; 100 ist vernünftig, wenn Recall kritisch ist und dein Korpus groß 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 wählen. ANALYZE document_chunk nach dem ersten großen Datenladevorgang und nach großen Bulk-Inserts ausführen hält den Planner ehrlich und verhindert, dass er zum falschen Zeitpunkt auf sequentielle Scans zurückgreift.
Suchqualität messen
Bevor du das System als produktionsreif erklärst, 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. Führe 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 Änderung am Embedding-Modell, am Tokenisierungswörterbuch 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 hängt vollständig von deinem Korpus ab. Beide messen, bevor du dich für das teurere Modell entscheidest.
Instrumentiere auch auf der Anwendungsebene: Protokolliere Anfrage-Strings, Ergebniszahlen aus jedem CTE-Zweig und finale RRF-Ränge. Anfragen, bei denen der Vektorzweig null Ergebnisse liefert (weil der Korpus keinen semantisch ähnlichen Inhalt hat) oder der FTS-Zweig null Ergebnisse liefert (weil die Anfrage konversationell ist und nichts trifft), sind die wichtigsten Fälle, die früh erkannt werden müssen. 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 lässt.
Wechsle zu Elasticsearch, wenn du facettierte Suche mit komplexen Aggregationen über Millionen unterschiedlicher Werte benötigst, Dokument-Updates in Echtzeit bei Tausenden von Schreibvorgängen pro Sekunde benötigt werden, oder sprachspezifische Analyser und Token-Filter weit über das hinausgehen, was Postgres-Wörterbücher bieten. Elasticsearch ist eine deutlich bessere Suchmaschine für inhaltslastige Produkte mit redaktionellen Workflows oder E-Commerce-Katalogen.
Wechsle zu einer dedizierten Vektordatenbank, wenn dein Korpus 50 bis 100 Millionen Chunks überschreitet, Multi-Vektor-Retrieval (ColBERT-style Late Interaction) benötigt wird, oder Anfrageraten in den Bereich von Hunderten gleichzeitiger Vektorsuchen pro Sekunde auf einer einzelnen Primary gehen. Das sind echte Schwellenwerte - darüber werden der HNSW-Speicherbedarf und das Single-Writer-Modell von pgvector zu echten Engpässen. 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 nachträglich hinzufügen musste, hätte sich sechs Monate Doppelsystem-Komplexität 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 hinzuzufügen ist ein Ein-Sprint-Projekt, wenn das Schema sauber und die Embedding-Pipeline bereits vorhanden ist. Die größere Investition ist das Evaluierungs-Harness: Goldene Anfrage-Sets definieren, sie in CI ausführen und die Fähigkeit aufbauen, Suchqualität als Metrik statt als Gefühl zu behandeln.
Wenn dein Team an diesem Punkt ist - über dem Prototyp, auf dem Weg zur Skalierung, wo Suchqualität die Retention beeinflusst - und eine zweite Meinung zur Architektur möchtest, 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 groß wird, dass Änderungen teuer werden. Mehr zu den Arten von Problemen, an denen wir arbeiten, unter wolf-tech.io.
