Volltextsuche in Postgres, ohne Elasticsearch
Postgres bringt eine leistungsstarke Volltextsuche mit tsvector, tsquery und GIN-Indizes mit — für die meisten Anwendungen reicht das völlig.

Die meisten Teams greifen zu Elasticsearch, sobald ein Product Manager das Wort "Suche" in den Mund nimmt. Das ist meistens die falsche Entscheidung. Elasticsearch ist im Betrieb teuer, erhöht die Komplexität beim Deployment und erfordert, die Daten ständig mit dem primären Datenspeicher synchron zu halten. Postgres verfügt seit über einem Jahrzehnt über eine leistungsfähige Volltextsuche — für die meisten Anwendungen ist das mehr als ausreichend.
Wie die Volltextsuche in Postgres funktioniert
Die zentralen Bausteine sind tsvector und tsquery. Ein tsvector ist eine sortierte Liste normalisierter Lexeme (auf ihren Wortstamm reduzierte Tokens), die aus einem Text extrahiert werden. Ein tsquery ist ein Suchausdruck — Begriffe mit booleschen Operatoren und optionalen Gewichtungen.
-- Convert text to a searchable vector
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
-- Build a query
SELECT to_tsquery('english', 'quick & fox');
-- 'quick' & 'fox'
-- Match them with the @@ operator
SELECT to_tsvector('english', 'The quick brown fox') @@ to_tsquery('english', 'quick & fox');
-- truePostgres reduziert Wörter automatisch anhand des konfigurierten Sprachwörterbuchs auf ihren Stamm — aus jumps wird jump, aus lazy wird lazi. Eine Suche nach "jumping" findet Dokumente, die "jumps" oder "jumped" enthalten, ganz ohne zusätzlichen Aufwand.
Vektoren mit generierten Spalten speichern
tsvector bei jeder Abfrage neu zu berechnen, macht den ganzen Vorteil zunichte — ein Ausdruck, der sich pro Zeile zur Abfragezeit ändert, lässt sich nicht indizieren, ohne ihn vorher zu materialisieren. Generierte Spalten übernehmen das automatisch und halten den Wert immer konsistent.
-- ❌ Inline computation — full table scan, no index benefit
SELECT title, body
FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', $1);
-- ✅ Persisted tsvector column — updated automatically, fully indexable
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;setweight weist verschiedenen Feldern Wichtigkeitsstufen zu (A bis D). Treffer im Titel werden höher gewichtet als Treffer im Fließtext — was fast immer gewünscht ist. Das coalesce schützt vor NULL-Spalten; to_tsvector liefert NULL zurück, wenn seine Eingabe NULL ist, was Treffer stillschweigend zunichtemacht.
Indizierung mit GIN
Ein GIN-Index (Generalized Inverted Index) auf der gespeicherten Vektorspalte verwandelt die Volltextsuche von einem sequenziellen Scan in eine indizierte Abfrage.
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);Mehr steckt nicht hinter der Indizierung. Mit einem GIN-Index löst Postgres eine tsquery gegen Millionen von Zeilen in Millisekunden auf. Für die meisten SaaS-Anwendungen mit weniger als ein paar Millionen Dokumenten bleibt die Abfragelatenz komfortabel unter 10 ms.
GIN-Indizes lassen sich schnell lesen, sind aber aufwendiger zu pflegen als
B-Tree-Indizes. Erwäge bei schreibintensiven Tabellen CREATE INDEX ... WITH (fastupdate = off), damit gepufferte, ausstehende Einfügungen unter
gleichzeitiger Leselast nicht zu veralteten Ergebnissen führen.
Ergebnisse bewerten und hervorheben
Die Funktion ts_rank bewertet jedes gefundene Dokument anhand der Termhäufigkeit und der Feldgewichtungen. ts_headline erzeugt einen hervorgehobenen Auszug — sie findet die relevanteste Textstelle und umschließt die Treffer mit den von dir gewählten Trennzeichen.
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);
interface SearchResult {
id: string;
title: string;
excerpt: string;
rank: number;
}
async function searchArticles(
query: string,
limit = 20,
offset = 0,
): Promise<SearchResult[]> {
// plainto_tsquery handles arbitrary user input without throwing on special chars.
// to_tsquery requires valid tsquery syntax — never pass raw user input to it.
return sql<SearchResult[]>`
SELECT
id,
title,
ts_headline(
'english',
body,
plainto_tsquery('english', ${query}),
'MaxWords=35, MinWords=15, StartSel=<mark>, StopSel=</mark>'
) AS excerpt,
ts_rank(search_vector, plainto_tsquery('english', ${query})) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', ${query})
ORDER BY rank DESC
LIMIT ${limit}
OFFSET ${offset}
`;
}Beachte, dass hier plainto_tsquery statt to_tsquery für nutzergesteuerte Eingaben verwendet wird. to_tsquery wirft einen Syntaxfehler, wenn die Eingabe unausgeglichene Operatoren oder nicht unterstützte Zeichen enthält — ein sicherer Weg zu 500er-Fehlern in Produktion. plainto_tsquery behandelt seine Eingabe als reinen Text und wandelt sie sicher in eine mit AND verknüpfte Abfrage um.
Autovervollständigung mit Präfixsuche und Trigrammen
Die Volltextsuche setzt vollständige Wörter voraus. Für die Autovervollständigung — also den Abgleich einer unvollständigen Eingabe wie "doc" → "docker" — verwendest du den Präfixoperator :* oder einen separaten Trigramm-Index aus pg_trgm.
-- Prefix matching with tsquery: 'dock:*' matches "docker", "docking", etc.
SELECT title FROM articles
WHERE search_vector @@ to_tsquery('english', 'dock:*')
LIMIT 10;
-- Fuzzy matching for typos (requires pg_trgm extension)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm_idx ON articles USING GIN (title gin_trgm_ops);
-- % is the similarity threshold operator (default threshold: 0.3)
SELECT title, similarity(title, 'documetnation') AS sim
FROM articles
WHERE title % 'documetnation'
ORDER BY sim DESC
LIMIT 10;Nutze die Trigramm-Ähnlichkeit von pg_trgm für tippfehlertolerante Suche und den Präfixoperator :* für Autovervollständigungs-Dropdowns. Beide Ansätze decken unterschiedliche Abfragearten ab und lassen sich gut kombinieren — Volltextsuche für die Suche im Fließtext, Trigramme für die Vervollständigung von Titeln.
Wann Postgres nicht ausreicht
Die Volltextsuche von Postgres hat reale Grenzen. Kenne sie, bevor du dich festlegst.
| Anforderung | Postgres FTS | Elasticsearch |
|---|---|---|
| Boolesche, Phrasen- und Proximity-Suche | ✓ | ✓ |
| Mehrsprachige Stammformreduktion | ✓ | ✓ |
| Facettierte Suche / Aggregationen | Eingeschränkt | ✓ |
| Feldspezifisches Relevanz-Tuning | Eingeschränkt | ✓ |
| Unter 100 ms bei 100M+ Dokumenten | Marginal | ✓ |
| Schreibintensive Echtzeit-Indizierung | ✓ | Komplex |
| Kein zusätzlicher Betriebsaufwand | ✓ | ✗ |
Wenn deine Suche Facetten braucht (gleichzeitiges Filtern nach Kategorie, Preisspanne und Tags), individuelle Relevanz-Scoring-Pipelines erfordert oder du wirklich im Bereich von Hunderten Millionen Dokumenten operierst, rechtfertigt Elasticsearch oder eine verwaltete Alternative ihre Kosten. Für einen Produktkatalog, eine Wissensdatenbank, eine Blog-Plattform oder einen internen Dokumentenspeicher erledigt Postgres das ohne zusätzliche Infrastruktur.
Der Punkt, ab dem sich der Aufwand lohnt, liegt höher, als die meisten Entwickler annehmen. Einen dedizierten Elasticsearch-Cluster zu betreiben bedeutet einen weiteren zu überwachenden Dienst, eine weitere Deployment-Pipeline, einen weiteren möglichen Fehlerfall und eine Datensynchronisationsschicht, die früher oder später auseinanderdriftet. Zahle diesen Preis erst, wenn Postgres nachweislich nicht mehr mithält.
Die wichtigsten Erkenntnisse
- Speichere
tsvectorals generierte Spalte — wird es bei jeder Abfrage neu berechnet, ist eine Indexnutzung von vornherein ausgeschlossen - Nutze
setweightfür feldübergreifende Relevanz — Treffer in Titel und Überschriften sollten Treffer im Fließtext übertreffen - GIN-Indizes sind der Schlüssel — ohne sie ist jede Suche ein vollständiger sequenzieller Scan
- Verwende
plainto_tsqueryfür Nutzereingaben, niemalsto_tsquery— es verarbeitet Rohtext sicher, ohne Syntaxfehler - Ergänze
pg_trgmfür unscharfe Treffer und Präfixsuche — Volltextsuche und Trigramme decken unterschiedliche Abfragearten ab und lassen sich sauber kombinieren - Greife nur dann zu Elasticsearch, wenn du Facetten in großem Maßstab brauchst — der Betriebsaufwand ist real; zahle ihn erst, wenn Postgres messbar nicht mehr mithält


