PostgreSQL‑Indizes für SaaS‑Apps: Wählen Sie zwischen B‑tree, GIN und GiST anhand realer Abfrageformen wie Filter, Suche, JSONB und Arrays.

Ein Index verändert, wie PostgreSQL Zeilen findet. Ohne Index muss die Datenbank oft einen großen Teil der Tabelle lesen (ein Sequential Scan) und dann das meiste verwerfen. Mit dem richtigen Index kann sie direkt zu den passenden Zeilen springen (ein Index-Lookup) und nur das holen, was wirklich gebraucht wird.
Das merkt man früh in SaaS, weil Alltagsbildschirme oft viele Abfragen auslösen. Ein einziger Klick kann mehrere Reads triggern: die Listen-Seite, eine Gesamtanzahl, ein paar Dashboard‑Karten und ein Suchfeld. Wenn eine Tabelle von Tausenden auf Millionen Zeilen wächst, fängt dieselbe Abfrage, die früher instant war, an zu stocken.
Ein typisches Beispiel ist eine Orders‑Seite, gefiltert nach Status und Datum, sortiert nach neuesten zuerst, mit Paginierung. Wenn PostgreSQL die ganze orders‑Tabelle scannen muss, um bezahlte Bestellungen der letzten 30 Tage zu finden, macht jede Seitenladung unnötige Arbeit. Ein guter Index verwandelt das in einen schnellen Sprung zur richtigen Datenmenge.
Indizes sind nicht kostenlos. Jeder Index beschleunigt Lesevorgänge für bestimmte Abfragen, macht aber auch Writes langsamer (INSERT/UPDATE/DELETE müssen Indizes aktualisieren) und verbraucht mehr Speicher (und erhöht Cache‑Druck). Deshalb sollte man von echten Abfragemustern ausgehen, nicht von Index‑Typen.
Eine einfache Regel, die Zeit spart: Fügen Sie einen Index nur hinzu, wenn Sie auf eine konkrete, häufige Abfrage verweisen können, die er beschleunigt. Wenn Sie Bildschirme mit einem Chat-gesteuerten Builder wie Koder.ai bauen, hilft es, das hinter den Listen- und Dashboard‑Seiten stehende SQL zu erfassen und daraus Ihre Index‑Wunschliste zu machen.
Die meisten Verwirrungen um Indizes verschwinden, wenn Sie aufhören, in Features (JSON, Suche, Arrays) zu denken, und stattdessen in Abfrage‑Formen: Was macht die WHERE‑Klausel, und wie erwarten Sie die Ergebnisse sortiert?
Verwenden Sie B-tree, wenn Ihre Abfrage normale Vergleiche enthält und Ihnen die Sortierung wichtig ist. Es ist das Arbeitspferd für Gleichheit, Bereiche und Joins.
Beispiel‑Formen: Filtern nach tenant_id = ?, status = 'active', created_at \u003e= ?, Joinen users.id = orders.user_id, oder „neueste zuerst“ mit ORDER BY created_at DESC.
GIN (Generalized Inverted Index) passt gut, wenn eine Spalte viele Mitglieder enthält und Sie fragen „enthält sie X?“. Das ist typisch bei JSONB‑Schlüsseln, Array‑Elementen und Full‑Text‑Vektoren.
Beispiel‑Formen: metadata @\u003e {'plan':'pro'} auf JSONB, tags @\u003e ARRAY['urgent'] oder to_tsvector(body) @@ plainto_tsquery('reset password').
GiST (Generalized Search Tree) eignet sich für Fragen zu Abstand oder Überlappung, wo Werte wie Bereiche oder Formen behandelt werden. Es wird oft für Bereichstypen, geometrische Daten und manche „nächstgelegene Treffer“-Suchen eingesetzt.
Beispiel‑Formen: überlappende Zeitfenster mit Range‑Spalten, gewisse Similarity‑Stile (z. B. mit Trigramm‑Operatoren) oder räumliche Abfragen (wenn Sie PostGIS nutzen).
Eine praktische Auswahlregel:
Indizes beschleunigen Lesevorgänge, kosten aber Schreibzeit und Platz. In SaaS ist dieser Kompromiss besonders wichtig bei heißen Tabellen wie events, sessions und activity_logs.
Die meisten SaaS‑Listenbildschirme haben dieselbe Form: eine Tenant‑Grenze, ein paar Filter und eine vorhersehbare Sortierung. B‑tree‑Indizes sind hier die Default‑Wahl und meist am günstigsten zu pflegen.
Ein gängiges Muster ist WHERE tenant_id = ? plus Filter wie status = ?, user_id = ? und eine Zeitspanne wie created_at \u003e= ?. Bei zusammengesetzten B‑tree‑Indizes setzen Sie Gleichheitsfilter zuerst (Spalten mit =), dann die Spalte, nach der Sie sortieren.
Regeln, die in den meisten Apps gut funktionieren:
tenant_id, wenn jede Abfrage tenant‑scope hat.=‑Filter danach (oft status, user_id).ORDER BY‑Spalte kommt zuletzt (oft created_at oder id).INCLUDE, um Listenseiten abzudecken, ohne den Schlüssel breiter zu machen.Ein realistisches Beispiel: Eine Tickets‑Seite zeigt die neuesten Elemente zuerst, gefiltert nach Status.
-- Query
SELECT id, status, created_at, title
FROM tickets
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
-- Index
CREATE INDEX tickets_tenant_status_created_at_idx
ON tickets (tenant_id, status, created_at DESC)
INCLUDE (title);
Dieser Index unterstützt sowohl das Filter als auch die Sortierung, sodass Postgres eine große Ergebnismenge nicht sortieren muss. Das INCLUDE (title) hilft, dass die Listen‑Seite weniger Table‑Pages anfassen muss, während die Index‑Schlüssel auf Filter und Reihenfolge fokussiert bleiben.
Für Zeitintervalle gilt dasselbe Prinzip:
SELECT id, created_at
FROM events
WHERE tenant_id = $1
AND created_at \u003e= $2
AND created_at \u003c $3
ORDER BY created_at DESC
LIMIT 100;
CREATE INDEX events_tenant_created_at_idx
ON events (tenant_id, created_at DESC);
Paginierung ist ein häufiger Flaschenhals in SaaS‑Apps. Offset‑Paginierung (OFFSET 50000) zwingt die Datenbank, an vielen Zeilen vorbeizulaufen. Seek‑Paginierung bleibt schnell, indem sie den zuletzt gesehenen Sortierschlüssel nutzt:
SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
AND created_at \u003c $2
ORDER BY created_at DESC
LIMIT 50;
Mit dem richtigen B‑tree‑Index bleibt das auch bei wachsender Tabelle schnell.
Die meisten SaaS‑Apps sind multi‑tenant: jede Abfrage bleibt innerhalb eines Tenants. Wenn Ihre Indizes tenant_id nicht enthalten, kann Postgres zwar trotzdem Rows finden, scannt aber oft viel mehr Indexeinträge als nötig. Tenant‑aware Indizes gruppieren die Daten eines Tenants im Index, sodass gängige Bildschirme schnell und vorhersagbar bleiben.
Einfache Regel: Setzen Sie tenant_id an den Anfang des Index, wenn die Abfrage immer nach Tenant filtert. Fügen Sie dann die Spalte hinzu, nach der Sie am häufigsten filtern oder sortieren.
Hochwirksame, unspektakuläre Indizes sehen oft so aus:
(tenant_id, created_at) für Listen der neuesten Items und Cursor‑Paginierung(tenant_id, status) für Status‑Filter (Open, Paid, Failed)(tenant_id, user_id) für „Items dieses Nutzers“‑Screens(tenant_id, updated_at) für „zuletzt geändert“ Admin‑Ansichten(tenant_id, external_id) für Lookups aus Webhooks oder ImportsÜberindexierung passiert, wenn Sie für jede leicht abweichende Ansicht einen neuen Index anlegen. Bevor Sie einen weiteren erstellen, prüfen Sie, ob ein vorhandener zusammengesetzter Index bereits die linken Spalten abdeckt, die Sie brauchen. Zum Beispiel: Wenn Sie (tenant_id, created_at) haben, brauchen Sie normalerweise nicht zusätzlich (tenant_id, created_at, id), außer Sie filtern wirklich nach id nach diesen Spalten.
Partielle Indizes können Größe und Schreibkosten reduzieren, wenn die meisten Zeilen irrelevant sind. Sie funktionieren gut bei Soft‑Deletes und „nur aktive“ Daten, z. B. nur indexieren, wenn deleted_at IS NULL oder nur bei status = 'active'.
Jeder zusätzliche Index macht Writes teurer. Inserts müssen jeden Index aktualisieren, und Updates können mehrere Indizes betreffen, selbst wenn nur eine Spalte geändert wird. Wenn Ihre App viele Events ingestet (auch Apps, die schnell mit Koder.ai gebaut wurden), konzentrieren Sie Indizes auf die wenigen Abfrage‑Formen, die Nutzer täglich treffen.
JSONB ist praktisch, wenn Ihre App flexible Extra‑Felder braucht, etwa Feature‑Flags, Nutzerattribute oder tenant‑spezifische Einstellungen. Der Haken ist, dass verschiedene JSONB‑Operatoren unterschiedlich behandelt werden, also hängt der beste Index davon ab, wie Sie abfragen.
Zwei Formen sind am wichtigsten:
@\u003e.-\u003e / -\u003e\u003e (häufig verglichen mit =).Wenn Sie häufig mit @\u003e filtern, zahlt sich ein GIN‑Index auf der JSONB‑Spalte meist aus.
-- Query shape: containment
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata @\u003e '{\"region\":\"eu\",\"plan\":\"pro\"}';
-- Index
CREATE INDEX accounts_metadata_gin
ON accounts
USING GIN (metadata);
Wenn Ihre JSON‑Struktur vorhersehbar ist und Sie hauptsächlich @\u003e auf Top‑Level‑Keys verwenden, kann jsonb_path_ops kleiner und schneller sein, unterstützt aber weniger Operator‑Typen.
Wenn Ihre UI wiederholt auf ein Feld filtert (z. B. plan), ist es oft schneller und günstiger, dieses Feld zu extrahieren und zu indexieren, als einen breiten GIN‑Index aufzubauen.
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata-\u003e\u003e'plan' = 'pro';
CREATE INDEX accounts_plan_expr
ON accounts ((metadata-\u003e\u003e'plan'));
Praktische Regel: Behalten Sie JSONB für flexible, selten gefilterte Attribute, aber heben Sie stabile, stark genutzte Felder (plan, status, created_at) in echte Spalten. Wenn Sie schnell iterieren in einer generierten App, ist das oft ein einfacher Schema‑Tweak, sobald Sie sehen, welche Filter auf jeder Seite auftauchen.
Beispiel: Wenn Sie {\"tags\":[\"beta\",\"finance\"],\"region\":\"us\"} in JSONB speichern, verwenden Sie GIN, wenn Sie nach Bündeln von Attributen (@\u003e) filtern, und fügen Ausdrucksindizes für die wenigen Keys hinzu, die die meisten Listenansichten treiben (plan, region).
Arrays sind verlockend, weil sie einfach zu speichern und zu lesen sind. Eine users.roles text[]‑Spalte oder projects.labels text[] kann gut funktionieren, wenn die Hauptfrage lautet: Enthält diese Zeile einen Wert? Genau hier hilft ein GIN‑Index.
GIN ist die Standardwahl für Membership‑Abfragen auf Arrays. Es zerlegt das Array in einzelne Items und baut ein schnelles Lookup zu den Zeilen, die diese Items enthalten.
Array‑Abfrageformen, die oft profitieren:
@\u003e (array contains)\u0026\u0026 (Array teilt Items)= ANY(...), aber @\u003e ist oft vorhersehbarerEin typisches Beispiel zum Filtern von Nutzern nach Rolle:
-- Find users who have the "admin" role
SELECT id, email
FROM users
WHERE roles @\u003e ARRAY['admin'];
CREATE INDEX users_roles_gin ON users USING GIN (roles);
Und zum Filtern von Projekten nach einem Label‑Set (muss beide Labels enthalten):
SELECT id, name
FROM projects
WHERE labels @\u003e ARRAY['billing', 'urgent'];
CREATE INDEX projects_labels_gin ON projects USING GIN (labels);
Worüber sich Leute oft wundern: Manche Muster nutzen den Index nicht wie erwartet. Wenn Sie das Array in einen String konvertieren (array_to_string(labels, ',')) und dann LIKE verwenden, hilft der GIN‑Index nicht. Und wenn Sie „starts with“ oder fuzzy Matches innerhalb von Labels brauchen, gehört das in die Text‑Suche, nicht in Array‑Membership.
Arrays werden auch schwer wartbar, wenn sie zur Mini‑Datenbank werden: häufige Updates, Metadaten pro Item (wer hat das Label hinzugefügt, wann, warum) oder Analysen pro Label. Dann ist eine Join‑Tabelle wie project_labels(project_id, label) meist einfacher zu validieren, zu queryen und weiterzuentwickeln.
Für Suchfelder treten zwei Muster immer wieder auf: Volltextsuche (Datensätze zu einem Thema finden) und fuzzy Matching (Tippfehler, Teilnamen, ILIKE‑Muster). Der richtige Index ist der Unterschied zwischen „instant“ und „Timeouts bei 10k Nutzern“.
Verwenden Sie Volltextsuche, wenn Nutzer echte Wörter eingeben und Sie Ergebnisse nach Relevanz sortieren wollen, z. B. Tickets nach Betreff und Beschreibung. Üblich ist ein tsvector (oft als generierte Spalte) und ein GIN‑Index darauf. Gesucht wird mit @@ und einem tsquery.
-- Tickets: full-text search on subject + body
ALTER TABLE tickets
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(subject,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX tickets_search_vec_gin
ON tickets USING GIN (search_vec);
-- Query
SELECT id, subject
FROM tickets
WHERE search_vec @@ plainto_tsquery('simple', 'invoice failed');
-- Customers: fuzzy name search using trigrams
CREATE INDEX customers_name_trgm
ON customers USING GIN (name gin_trgm_ops);
SELECT id, name
FROM customers
WHERE name ILIKE '%jon smth%';
Was im Vector gespeichert werden sollte: nur die Felder, die Sie tatsächlich durchsuchen. Wenn Sie alles mitschleppen (Notes, interne Logs), zahlen Sie beim Index‑Size und beim Write‑Cost drauf.
Verwenden Sie Trigramm‑Similarity, wenn Nutzer Namen, E‑Mails oder kurze Phrasen suchen und Sie Teilmatches oder Tippfehler tolerieren wollen. Trigramme helfen bei ILIKE '%term%' und Similarity‑Operatoren. GIN ist meist schneller für „passt das?“ Lookups; GiST kann besser sein, wenn Sie zusätzlich nach Ähnlichkeit sortieren möchten.
Faustregeln:
tsvector für relevance‑basierte Textsuche.Fallstricke, die Sie beachten sollten:
ILIKE '%abc') erzwingen Scans.Wenn Sie Suchbildschirme schnell ausliefern, betrachten Sie den Index als Teil des Features: Such‑UX und Indexwahl müssen gemeinsam gestaltet werden.
Starten Sie mit der exakten Abfrage, die Ihre App ausführt, nicht mit einer Vermutung. Ein „langsamer Bildschirm“ ist meist eine SQL‑Anweisung mit konkreter WHERE und ORDER BY. Kopieren Sie sie aus Logs, Ihrem ORM‑Debug‑Output oder dem Query‑Capture, das Sie nutzen.
Ein Workflow, der in echten Apps hält:
EXPLAIN (ANALYZE, BUFFERS) auf derselben Abfrage aus.=, \u003e=, LIKE, @\u003e, @@), nicht nur auf Spaltennamen.EXPLAIN (ANALYZE, BUFFERS) mit realistischer Datenmenge erneut aus.Hier ein konkretes Beispiel. Eine Customers‑Seite filtert nach Tenant und Status, sortiert nach Neuesten und paginiert:
SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Wenn EXPLAIN einen Sequential Scan und ein Sort zeigt, behebt oft ein B‑tree‑Index, der Filter und Sort abdeckt, das Problem:
CREATE INDEX ON customers (tenant_id, status, created_at DESC);
Wenn der langsame Teil JSONB‑Filter wie metadata @\u003e '{"plan":"pro"}' ist, deutet das auf GIN. Wenn es Full‑Text ist wie to_tsvector(...) @@ plainto_tsquery(...), dann ist auch das ein GIN‑basiertes Suchindex‑Szenario. Wenn es um „nächster Treffer“ oder Overlap‑Operatoren geht, ist GiST oft die passende Wahl.
Nach dem Hinzufügen des Index messen Sie den Kompromiss. Prüfen Sie Index‑Größe, Insert‑ und Update‑Zeit und ob er die Top‑Slow‑Queries hilft oder nur einen Randfall. In schnelllebigen Projekten (auch solchen mit Koder.ai) hilft dieses Nachmessen, unnötige Indizes zu vermeiden.
Die meisten Index‑Probleme drehen sich nicht um die Wahl B‑tree vs GIN vs GiST. Sondern darum, einen Index zu bauen, der zwar richtig aussieht, aber nicht zur tatsächlichen Abfrage passt.
Fehler, die besonders stören:
tenant_id und created_at beginnt, der Index aber mit created_at, kann der Planner ihn überspringen.status, is_active oder einem Boolean hilft oft wenig, weil er zu viele Zeilen matched. Kombinieren Sie ihn mit einer selektiven Spalte (z. B. tenant_id oder created_at) oder lassen Sie ihn weg.ANALYZE lange nicht lief, kann der Planner trotz vorhandenem Index schlechte Pläne wählen.Ein konkretes Beispiel: Ihre Invoices‑Seite filtert nach tenant_id und status und sortiert dann nach created_at DESC. Ein Index nur auf status hilft kaum. Besser ist ein zusammengesetzter Index, der mit tenant_id beginnt, dann status und dann created_at (Filter zuerst, Sort zuletzt). Diese Änderung schlägt oft drei einzelne Indizes.
Behandeln Sie jeden Index als Kostenstelle. Er muss sich in echten Abfragen bezahlt machen, nicht nur in der Theorie.
Index‑Änderungen sind leicht auszurollen und ärgerlich rückgängig zu machen, wenn sie Schreibkosten erhöhen oder eine heiße Tabelle blockieren. Behandeln Sie sie wie ein kleines Release.
Beginnen Sie damit, zu entscheiden, was Sie optimieren. Holen Sie zwei kurze Rankings aus Logs oder Monitoring: die Abfragen, die am häufigsten laufen, und die mit der höchsten Latenz. Für jede notieren Sie die exakte Form: Filter‑Spalten, Sortierung, Joins und verwendete Operatoren (equals, range, IN, ILIKE, JSONB‑Operatoren, Array‑contains). Das verhindert Raten und hilft, den richtigen Index‑Typ zu wählen.
Pre‑Ship Checklist:
Nachdem Sie den Index hinzugefügt haben, prüfen Sie, ob er geholfen hat. Führen Sie EXPLAIN (ANALYZE, BUFFERS) auf derselben Abfrage aus und vergleichen Sie Vorher/Nachher. Beobachten Sie dann das Produktionsverhalten für einen Tag:
Wenn Sie mit Koder.ai bauen, lohnt es sich, das generierte SQL für ein oder zwei langsame Bildschirme neben der Änderung aufzubewahren, damit der Index genau zu dem passt, was die App tatsächlich ausführt.
Stellen Sie sich einen üblichen Admin‑Screen vor: eine Users‑Liste mit Tenant‑Scope, ein paar Filtern, Sortierung nach zuletzt aktiv und einer Suche. Hier hört Indextheorie auf und spart echte Zeit.
Drei Abfrage‑Formen, die Sie meist sehen:
-- 1) List page with tenant + status filter + sort
SELECT id, email, last_active_at
FROM users
WHERE tenant_id = $1 AND status = $2
ORDER BY last_active_at DESC
LIMIT 50;
-- 2) Search box (full-text)
SELECT id, email
FROM users
WHERE tenant_id = $1
AND to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'')) @@ plainto_tsquery($2)
ORDER BY last_active_at DESC
LIMIT 50;
-- 3) Filter on JSON metadata (plan, flags)
SELECT id
FROM users
WHERE tenant_id = $1
AND metadata @\u003e '{\"plan\":\"pro\"}'::jsonb;
Ein kleines, aber gezieltes Index‑Set für diesen Screen:
(tenant_id, status, last_active_at DESC).tsvector‑Spalte mit einem GIN‑Index.GIN (metadata), wenn Sie viel mit @\u003e arbeiten, oder ein Ausdrucks‑B‑Tree wie ((metadata-\u003e\u003e'plan')), wenn Sie hauptsächlich einen Key filtern.Gemischte Bedürfnisse sind normal. Wenn eine Seite Filter + Suche + JSON kombiniert, quetschen Sie nicht alles in einen Mega‑Index. Bewahren Sie B‑tree für Sortierung/Paginierung und fügen Sie einen spezialisierten Index (meist GIN) für den teuren Teil hinzu.
Nächste Schritte: Wählen Sie einen langsamen Screen, notieren Sie seine Top 2–3 Abfrage‑Formen und prüfen Sie jeden Index nach Zweck (Filter, Sort, Suche, JSON). Wenn ein Index nicht klar zu einer realen Abfrage passt, streichen Sie ihn. Wenn Sie schnell mit Koder.ai iterieren, hilft diese Kontrolle beim Vermeiden von Index‑Sprawl während Ihr Schema sich noch ändert.
Ein Index erlaubt es PostgreSQL, die passenden Zeilen zu finden, ohne den Großteil der Tabelle lesen zu müssen. Für typische SaaS-Bildschirme wie Listen, Dashboards und Suche kann der richtige Index einen langsamen Sequenzscan in einen schnellen Lookup verwandeln, der mit wachsender Tabelle besser skaliert.
Beginnen Sie mit B-tree für die meisten App-Abfragen — es ist ideal für =-Filter, Bereichsfilter, Joins und ORDER BY. Wenn Ihre Abfrage hauptsächlich Containment (JSONB, Arrays) oder Textsuche betrifft, ist GIN meist die passende Wahl; GiST eignet sich eher für Überlappungs‑ oder „nächstgelegene“-Anfragen.
Setzen Sie die Spalten, die Sie mit = filtern, an den Anfang, und die Spalte, nach der Sie sortieren, ans Ende. Diese Reihenfolge erlaubt dem Planner, den Index effizient zu durchlaufen und sowohl zu filtern als auch in der richtigen Reihenfolge zurückzugeben, ohne zusätzlich sortieren zu müssen.
Ja. Wenn jede Abfrage nach tenant_id scoped ist, gruppiert ein zuerst platzierter tenant_id die Zeilen des Tenants im Index. Das reduziert in der Regel die Menge an Index- und Tabellendaten, die PostgreSQL für alltägliche Listen anfasst.
INCLUDE erlaubt das Hinzufügen zusätzlicher Spalten, damit Index-only-Reads für Listenseiten möglich werden, ohne den Indexschlüssel breiter zu machen. Nützlich, wenn Sie filtern und sortieren nach wenigen Spalten, aber noch ein oder zwei zusätzliche Felder auf der Seite anzeigen.
Eine partielle Indexierung ist besser, wenn Sie nur an einer Teilmenge der Zeilen interessiert sind, z. B. „nicht gelöscht“ oder „nur aktiv“. Das macht den Index kleiner und günstiger in der Pflege — wichtig für stark belastete Tabellen mit vielen Inserts/Updates.
Verwenden Sie GIN auf der JSONB-Spalte, wenn Sie häufig mit Containment wie metadata @\u003e '{"plan":"pro"}' suchen. Wenn Sie jedoch hauptsächlich auf ein oder zwei spezifische JSON-Schlüssel filtern, ist ein Ausdrucks‑B-Tree‑Index auf (metadata-\u003e\u003e'plan') oft kleiner und schneller.
GIN ist passend, wenn die zentrale Frage lautet „enthält dieses Array X?“ mittels Operatoren wie @\u003e oder \u0026\u0026. Wenn Sie jedoch Metadaten pro Element, häufige Änderungen oder Auswertungen pro Label/Role brauchen, ist eine Normalisierung in eine Join-Tabelle meist besser handhabbar.
Für Full-Text-Search speichern Sie ein tsvector (z. B. als generierte Spalte) und indexieren es mit GIN, dann suchen Sie mit @@ für relevante Ergebnisse. Für fuzzy Matching wie ILIKE '%name%' und Tippfehler-Toleranz sind Trigramm-Indizes (oft GIN) die richtige Wahl.
Kopieren Sie das exakte SQL, das Ihre App ausführt, und führen Sie EXPLAIN (ANALYZE, BUFFERS) darauf aus, um zu sehen, wo Zeit verloren geht — Scan, Sort oder teure Filter. Fügen Sie den kleinsten Index hinzu, der zu den Operatoren und der Sortierung der Abfrage passt, und prüfen Sie mit EXPLAIN erneut, ob sich der Plan verbessert hat.