PostgreSQL full-text search voldoet voor veel apps. Gebruik een eenvoudige beslisregel, een starterquery en een index-checklist om te bepalen wanneer je een zoekengine nodig hebt.

De meeste mensen vragen niet om “full-text search”. Ze willen een zoekvak dat snel aanvoelt en op de eerste pagina vindt wat ze bedoelden. Als resultaten traag, rommelig of vreemd gesorteerd zijn, maakt het gebruikers niet uit of je PostgreSQL full-text search of een aparte engine gebruikte. Ze verliezen gewoon vertrouwen in zoeken.
Dit is één beslissing: hou je zoeken in Postgres of voeg je een dedicated zoekengine toe. Het doel is niet perfecte relevantie. Het is een solide basis die snel te leveren is, makkelijk te draaien en goed genoeg voor hoe je app echt gebruikt wordt.
Voor veel apps is PostgreSQL full-text search lange tijd voldoende. Als je een paar tekstvelden hebt (titel, beschrijving, notities), basis ranking en één of twee filters (status, categorie, tenant), kan Postgres het aan zonder extra infrastructuur. Je krijgt minder bewegende delen, eenvoudigere backups en minder “waarom is zoeken down maar de app up?”-incidenten.
“Voldoende” betekent meestal dat je drie doelen tegelijk kunt halen:
Een concreet voorbeeld: een SaaS-dashboard waar gebruikers projecten zoeken op naam en notities. Als een query zoals “onboarding checklist” het juiste project in de top 5 teruggeeft, binnen een seconde, en je niet constant analyzers aan het tunen bent of jobs aan het re-indexen, dan is dat “voldoende”. Als je die doelen niet haalt zonder veel complexiteit toe te voegen, wordt “ingebouwde zoekfunctie vs zoekengine” een echte keuze.
Teams beschrijven search vaak in features, niet in uitkomsten. De nuttige stap is elke feature vertalen naar wat het kost om te bouwen, af te stemmen en betrouwbaar te houden.
Vroege verzoeken klinken meestal als: fouttolerantie (typos), facets en filters, highlights, “slimme” ranking en autocomplete. Voor een eerste versie scheid je must-haves van nice-to-haves. Een basis zoekvak hoeft meestal alleen relevante items te vinden, gangbare woordvormen te behandelen (meervoud, tijd), eenvoudige filters te respecteren en snel te blijven naarmate je tabel groeit. Dat is precies waar PostgreSQL full-text search vaak goed past.
Postgres blinkt uit wanneer je content in normale tekstvelden staat en je zoeken dicht bij je data wilt houden: helpartikelen, blogposts, supporttickets, interne docs, producttitels en -beschrijvingen of notities bij klantrecords. Dit zijn meestal “vind het juiste record”-problemen, niet “bouw een zoekproduct”-problemen.
Nice-to-haves zijn waar complexiteit insluipt. Typo-tolerantie en rijke autocomplete duwen je vaak naar extra tooling. Facets zijn mogelijk in Postgres, maar als je veel facets, diepe analytics en directe tellingen over enorme datasets wilt, wordt een dedicated engine aantrekkelijker.
De verborgen kosten zijn zelden de licentiekosten. Het is het tweede systeem. Zodra je een zoekengine toevoegt, komen er ook data-syncs en backfills (en de bugs die ze veroorzaken), monitoring en upgrades, “waarom toont zoeken oude data?” supportwerk en twee sets relevantieknoppen.
Als je twijfelt, begin met Postgres, lever iets eenvoudigs op en voeg pas een andere engine toe als een duidelijke vereiste niet bereikt kan worden.
Gebruik een drie-check regel. Als je alle drie haalt, blijf bij PostgreSQL full-text search. Als je één slecht faalt, overweeg een dedicated zoekengine.
Een veilige start is eenvoudig: lever een basis in Postgres, log trage queries en “geen resultaat”-zoekopdrachten en beslis daarna. Veel apps groeien er nooit uit en je vermijdt het vroeg draaien en synchroniseren van een tweede systeem.
Rode vlaggen die meestal naar een dedicated engine wijzen:
Groene vlaggen om in Postgres te blijven:
PostgreSQL full-text search is een ingebouwde manier om tekst om te zetten in iets dat de database snel kan doorzoeken, zonder elke rij te scannen. Het werkt het beste wanneer je content al in Postgres staat en je snelle, degelijke zoekfuncties met voorspelbare operatie wilt.
Er zijn drie onderdelen die het waard zijn om te kennen:
ts_rank (of ts_rank_cd) gebruiken om relevantere rijen bovenaan te zetten.Taalconfiguratie is belangrijk omdat het verandert hoe Postgres met woorden omgaat. Met de juiste config kunnen “running” en “run” matchen (stemming) en veelvoorkomende stopwoorden genegeerd worden. Met de verkeerde config kan zoeken gebroken aanvoelen omdat normale gebruikersformuleringen niet matchen met wat er geïndexeerd is.
Prefix matching is wat mensen willen voor “typeahead-achtige” gedrag, zoals het matchen van “dev” met “developer”. In Postgres FTS wordt dat meestal gedaan met een prefix-operator (bijv. term:*). Het kan de waargenomen kwaliteit verbeteren, maar verhoogt vaak de hoeveelheid werk per query, dus behandel het als optionele upgrade, niet als default.
Wat Postgres niet probeert te zijn: een compleet zoekplatform met elke feature. Als je fuzzy spellingcorrectie, geavanceerde autocomplete, learning-to-rank, complexe analyzers per veld of gedistribueerde indexering over veel nodes nodig hebt, zit je buiten het comfortgebied van de ingebouwde functies. Voor veel apps geeft PostgreSQL full-text search echter het meeste van wat gebruikers verwachten met veel minder bewegende delen.
Hier is een klein, realistisch voorbeeld voor content die je wilt doorzoeken:
-- Minimal example table
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
Een goed baselinepatroon voor PostgreSQL full-text search is: bouw een query van wat de gebruiker typt, filter rijen eerst (waar mogelijk) en rank daarna de overgebleven matches.
-- $1 = user search text, $2 = limit, $3 = offset
WITH q AS (
SELECT websearch_to_tsquery('english', $1) AS query
)
SELECT
a.id,
a.title,
a.updated_at,
ts_rank_cd(
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B'),
q.query
) AS rank
FROM articles a
CROSS JOIN q
WHERE
a.updated_at >= now() - interval '2 years' -- example safe filter
AND (
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B')
) @@ q.query
ORDER BY rank DESC, a.updated_at DESC, a.id DESC
LIMIT $2 OFFSET $3;
Een paar details die later tijd besparen:
WHERE voordat je gaat ranken (status, tenant_id, datumbereiken). Je rankt dan minder rijen, dus het blijft snel.ORDER BY (zoals updated_at, daarna id). Dit houdt paginering stabiel wanneer veel resultaten dezelfde rank hebben.websearch_to_tsquery voor gebruikersinvoer. Het verwerkt aanhalingstekens en eenvoudige operators op een manier die mensen verwachten.Zodra deze baseline werkt, verplaats je de to_tsvector(...)-expressie naar een opgeslagen kolom. Dat voorkomt dat het bij elke query opnieuw berekend wordt en maakt indexeren rechttoe rechtaan.
De meeste verhalen “PostgreSQL full-text search is traag” komen neer op één ding: de database bouwt het zoekdocument bij elke query op. Los dat eerst op door een vooraf gebouwde tsvector op te slaan en te indexeren.
tsvector op: generated column of trigger?Een generated column is de eenvoudigste optie wanneer je zoekdocument opgebouwd wordt uit kolommen in dezelfde rij. Het blijft automatisch correct en is moeilijk te vergeten bij updates.
Gebruik een trigger-gehouden tsvector wanneer het document afhankelijk is van gerelateerde tabellen (bijvoorbeeld het combineren van een productrij met de naam van zijn categorie), of wanneer je custom logica wilt die niet makkelijk als één gegenereerde expressie uit te drukken is. Triggers voegen bewegende delen toe, dus houd ze klein en test ze.
Maak een GIN-index op de tsvector-kolom. Dat is de basis die PostgreSQL full-text search instant laat aanvoelen voor typische app-zoekopdrachten.
Een setup die voor veel apps werkt:
tsvector in dezelfde tabel als de rijen die je het meest doorzoekt.tsvector.@@ gebruikt tegen de opgeslagen tsvector, niet to_tsvector(...) die on-the-fly berekend wordt.VACUUM (ANALYZE) na grote backfills zodat de planner de nieuwe index begrijpt.Het vector in dezelfde tabel houden is meestal sneller en eenvoudiger. Een aparte zoektafel kan logisch zijn als de basistabel zeer write-heavy is, of als je een gecombineerd document indexeert dat over veel tabellen heen loopt en je het op je eigen schema wilt bijwerken.
Partiële indexen kunnen helpen als je maar een subset rijen doorzoekt, zoals status = 'active', een enkele tenant in een multi-tenant app, of een specifieke taal. Ze verkleinen de index en kunnen zoekopdrachten versnellen, maar alleen als je queries altijd hetzelfde filter bevatten.
Je kunt verrassend goede resultaten krijgen met PostgreSQL full-text search als je relevantieregels simpel en voorspelbaar houdt.
De makkelijkste winst is gewichten per veld: matches in een titel moeten zwaarder meetellen dan matches diep in de body. Bouw een gecombineerd tsvector waarbij de titel hoger gewogen wordt dan de beschrijving en rank met ts_rank of ts_rank_cd.
Als je wilt dat “verse” of “populaire” items omhoog drijven, doe dat voorzichtig. Een kleine boost is prima, maar laat het de tekstrelevantie niet overrulen. Een praktisch patroon is: rank eerst op tekst, breek ties met recency, of voeg een capped bonus toe zodat een irrelevant nieuw item geen oudere perfecte match verslaat.
Synoniemen en phrase matching zijn plekken waar verwachtingen vaak afwijken. Synoniemen komen niet automatisch; je krijgt ze alleen als je een thesaurus of custom dictionary toevoegt, of als je de querytermen zelf uitbreidt (bijv. “auth” als “authentication”). Phrase matching is ook niet standaard: gewone queries matchen woorden waar dan ook, niet “exact deze zin.” Als gebruikers aanhalingstekens of lange vragen typen, overweeg phraseto_tsquery of websearch_to_tsquery om beter te matchen hoe mensen zoeken.
Gemengde-taal content vereist een keus. Als je de taal per document kent, sla die op en genereer de tsvector met de juiste configuratie (English, Russian, etc.). Als je dat niet weet, is een veilige fallback indexeren met de simple configuratie (geen stemming), of twee vectors bewaren: één taal-specifiek waar bekend en één simple voor alles.
Om relevantie te valideren, houd het klein en concreet:
Dit is meestal genoeg voor PostgreSQL full-text search in app-zoekvakken zoals “templates”, “docs” of “projects”.
De meeste verhalen “PostgreSQL full-text search is traag of irrelevant” komen door een paar te vermijden fouten. Ze oplossen is meestal eenvoudiger dan een nieuw zoeksystem toevoegen.
Een veelvoorkomende valkuil is tsvector behandelen alsof het een berekende waarde is die vanzelf correct blijft. Als je tsvector in een kolom opslaat maar niet bij elke insert en update bijwerkt, zullen resultaten willekeurig lijken omdat de index niet meer overeenkomt met de tekst. Als je to_tsvector(...) on-the-fly in de query berekent, kunnen resultaten wel correct zijn maar trager, en mis je het voordeel van een index.
Nog een eenvoudige manier om prestaties te schaden is ranken voordat je de kandidaten set verkleint. ts_rank is nuttig, maar het zou meestal moeten draaien nadat Postgres de index gebruikte om matchende rijen te vinden. Als je rank berekent voor een groot deel van de tabel (of eerst naar andere tabellen joint), kun je van een snelle zoekopdracht een table scan maken.
Mensen verwachten ook dat “contains”-zoeken werken als LIKE '%term%'. Leading wildcards passen niet goed bij full-text search omdat FTS op woorden (lexemen) is gebaseerd, niet op willekeurige substrings. Als je substring-zoek nodig hebt voor productcodes of gedeeltelijke IDs, gebruik dan een ander hulpmiddel voor die casus (bijv. trigram-indexering) in plaats van FTS de schuld te geven.
Prestatieproblemen komen vaak van result-handling, niet van matching. Twee patronen om op te letten:
OFFSET-paginering, waardoor Postgres steeds meer rijen moet overslaan naarmate je verder bladert.Operationele dingen doen er ook toe. Indexbloat kan zich opbouwen na veel updates en reindexen kan duur zijn als je wacht totdat het pijnlijk is. Meet echte querytijden (en bekijk EXPLAIN ANALYZE) voor en na wijzigingen. Zonder cijfers is het makkelijk om PostgreSQL full-text search “op te lossen” door het op een andere manier slechter te maken.
Voordat je PostgreSQL full-text search de schuld geeft, voer deze checks uit. De meeste problemen komen van ontbrekende basics, niet van de feature zelf.
Bouw een echte tsvector: sla het op in een generated of onderhouden kolom (niet berekend bij elke query), gebruik de juiste taalconfig (english, simple, etc.) en pas gewichten toe als je velden mixt (titel > subtitle > body).
Normaliseer wat je indexeert: houd lawaaierige velden (IDs, boilerplate, navigatietekst) uit de tsvector en knip enorme blobs af als gebruikers ze nooit doorzoeken.
Maak de juiste index: voeg een GIN-index toe op de tsvector-kolom en bevestig dat deze in EXPLAIN wordt gebruikt. Als slechts een subset doorzoekbaar is (bijv. status = 'published'), kan een partiële index grootte en leestijd verminderen.
Houd tabellen gezond: dead tuples kunnen indexscans vertragen. Regelmatig vacuumen is belangrijk, vooral op vaak bijgewerkte content.
Heb een reindex-plan: grote migraties of opgeblazen indexen hebben soms een gecontroleerd reindex-venster nodig.
Als data en index er goed uitzien, concentreer je op query-vorm. PostgreSQL full-text search is snel wanneer het vroeg de kandidaatset kan beperken.
Filter eerst, dan pas rank: pas strikte filters toe (tenant, language, published, category) vóór het ranken. Ranking op duizenden rijen die je later weggooit is verspilde moeite.
Gebruik stabiele ordening: order op rank en daarna een tie-breaker zoals updated_at of id zodat resultaten niet springen tussen verversingen.
Vermijd “de query doet alles”: als je fuzzy matching of typo-tolerantie nodig hebt, doe dat doelbewust (en meet). Forceer geen sequential scans per ongeluk.
Test echte queries: verzamel de top 20 zoekopdrachten, controleer handmatig de relevantie en houd een kleine lijst met verwachte resultaten om regressies te vangen.
Houd trage paden in de gaten: log trage queries, bekijk EXPLAIN (ANALYZE, BUFFERS) en monitor indexgrootte en cache hit rate zodat je ziet wanneer groei gedrag verandert.
Een SaaS helpcenter is een goed startpunt omdat het doel simpel is: help mensen het ene artikel te vinden dat hun vraag beantwoordt. Je hebt een paar duizend artikelen, ieder met een titel, korte samenvatting en bodytekst. De meeste bezoekers typen 2–5 woorden zoals “reset password” of “billing invoice”.
Met PostgreSQL full-text search kan dit verrassend snel als ‘klaar’ voelen. Je slaat een tsvector op voor de gecombineerde velden, voegt een GIN-index toe en rankt op relevantie. Succes ziet er zo uit: resultaten komen binnen onder de 100 ms, de top 3 is meestal correct en je hoeft het systeem niet constant te bewaken.
Dan groeit het product. Support wil filteren op productgebied, platform (web, iOS, Android) en plan (free, pro, business). Docs-schrijvers willen synoniemen, “bedoelde je” en betere typo-afhandeling. Marketing wil analytics zoals “top zoekopdrachten zonder resultaten”. Verkeer stijgt en zoeken wordt één van de drukste endpoints.
Dat zijn signalen dat een dedicated zoekengine het waard kan zijn:
Een praktische migratieaanpak is Postgres als source-of-truth te houden, zelfs nadat je een zoekengine toevoegt. Begin met het loggen van zoekopdrachten en no-result gevallen, draai vervolgens een async sync-job die alleen doorzoekbare velden naar de nieuwe index kopieert. Draai beide parallel voor een tijdje en schakel geleidelijk, in plaats van alles in één keer op een dag te wagen.
Als je zoeken vooral “vind documenten die deze woorden bevatten” is en je dataset niet massief, is PostgreSQL full-text search meestal voldoende. Begin daar, zet het werkend en voeg pas een dedicated engine toe wanneer je het ontbrekende feature of schaalprobleem kunt benoemen.
Een samenvatting om bij de hand te houden:
tsvector kunt opslaan, een GIN-index kunt toevoegen en je rankingbehoeften basis zijn.Een praktische volgende stap: implementeer de starter-query en index uit eerdere secties en log een paar simpele metrics gedurende een week. Volg p95-querytijd, trage queries en een ruwe succesmetric zoals “search -> click -> geen directe bounce” (zelfs een eenvoudige eventteller helpt). Je ziet snel of je betere ranking nodig hebt of alleen een betere UX (filters, highlighting, betere snippets).
Start met plannen voor een dedicated zoekengine als één van de volgende echte vereisten wordt (geen nice-to-have): sterke autocomplete of instant search op elke toetsaanslag op schaal, sterke typo-tolerantie en spell-correctie, facets en aggregaties over veel velden met snelle tellingen, geavanceerde relevantietooling (synoniemsets, learning-to-rank, per-query boosts), of aanhoudende hoge load en grote indexen die moeilijk snel te houden zijn.
Als je snel wilt itereren aan de app-kant, kan Koder.ai (koder.ai) handig zijn om de zoek-UI en API te prototypen via chat, en dan veilig te itereren met snapshots en rollback terwijl je meet wat gebruikers echt doen.
PostgreSQL full-text search is “voldoende” wanneer je drie dingen tegelijk kunt halen:
Als je dit kunt bereiken met een opgeslagen tsvector + een GIN-index, zit je meestal goed.
Begin standaard met PostgreSQL full-text search. Het levert sneller, houdt data en zoekfunctie op één plek en voorkomt dat je een aparte indexeer-pijplijn moet bouwen en onderhouden.
Schakel pas over naar een dedicated engine als je een duidelijk vereiste hebt die Postgres niet goed kan vervullen (hoogwaardige typo-tolerantie, uitgebreide autocomplete, zware faceting, of zoekverkeer dat concurreert met kerndatabasewerk).
Een eenvoudige regel is: blijf in Postgres als je deze drie checks haalt:
Als je er één flink faalt (vooral features zoals typos/autocomplete of hoog zoekverkeer), overweeg dan een dedicated engine.
Gebruik Postgres FTS wanneer je zoekopdracht vooral ‘vind het juiste record’ is over een paar velden zoals titel/body/notes, met eenvoudige filters (tenant, status, categorie).
Het past goed bij helpcenters, interne docs, tickets, blog/artikelzoek, en SaaS-dashboards waar gebruikers zoeken op projectnamen en notities.
Een goed basisquery-ontwerp doet meestal het volgende:
websearch_to_tsquery.Sla een voorgebouwde tsvector op en voeg een GIN-index toe. Dat voorkomt dat je to_tsvector(...) bij elk verzoek opnieuw berekent.
Praktische setup:
Gebruik een generated column wanneer het zoekdocument is opgebouwd uit kolommen in dezelfde rij (eenvoudig en moeilijk kapot te maken).
Gebruik een trigger-maintained kolom als je zoektekst afhankelijk is van gerelateerde tabellen of van custom logica.
Standaardkeuze: eerst generated column; triggers alleen wanneer je echt koppelt over tabellen heen.
Begin met voorspelbare relevantie:
Valideer daarna met een klein lijstje echte gebruikersqueries en verwachte topresultaten.
Postgres FTS werkt woordgebaseerd, niet substring-gebaseerd. Daarom gedraagt het zich niet als LIKE '%term%' voor willekeurige gedeeltelijke strings.
Als je substring-zoek nodig hebt (IDs, codes, fragmenten), los dat dan apart op (vaak met trigram-indexering) in plaats van FTS te dwingen een taak te doen waarvoor het niet gemaakt is.
Signalen dat je Postgres FTS ontgroeid bent:
Een praktische aanpak: houd Postgres als source-of-truth en voeg asynchrone indexering toe wanneer de vereiste duidelijk is.
@@ tegen een opgeslagen tsvector.ts_rank/ts_rank_cd plus een stabiele tie-breaker zoals updated_at, id.Dit houdt resultaten relevant, snel en stabiel voor paginering.
tsvector op dezelfde tabel die je queryt.tsvector_column @@ tsquery gebruikt.Dit is de meest voorkomende oplossing wanneer zoeken traag aanvoelt.