Full-text search has a reputation for being complicated, but in 2026 you have a clear decision tree: Postgres built-in FTS covers most apps, Typesense or Meilisearch covers search-first products, and Elasticsearch is overkill until you're at serious scale. Here is how to implement each correctly.
What changed in 2026
- Postgres 16/17 FTS improved phrase search and
ts_rank performance; it handles tens of millions of rows well with a GIN index.
- Typesense 27 supports multi-tenant, geosearch, and vector hybrid search (combining BM25 with embeddings) — a compelling single search layer for modern apps.
- Meilisearch 1.x is production-stable with excellent developer experience and a cloud offering.
- pgvector + pg_trgm combo is a popular pattern for apps that need both semantic (embedding) and lexical (keyword) search in one place.
Option comparison
| Option |
Typo tolerance |
Facets |
Vector search |
Ops overhead |
Best for |
| Postgres FTS |
No |
No |
Via pgvector |
None |
Existing Postgres apps, basic search |
| Typesense |
Yes |
Yes |
Yes (hybrid) |
Low |
Search-first products |
| Meilisearch |
Yes |
Yes |
Yes |
Low |
Fast setup, great DX |
| Elasticsearch |
Yes |
Yes |
Yes |
High |
Massive scale, log analytics |
| Algolia |
Yes |
Yes |
Yes |
None (SaaS) |
Speed, global CDN, high cost |
Postgres FTS: the right setup
Step 1: add the tsvector column and update it with a trigger
ALTER TABLE articles ADD COLUMN search_vector tsvector;
-- Trigger to update on insert or update
CREATE OR REPLACE FUNCTION articles_search_vector_update() RETURNS trigger AS $
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.body, '')), 'B');
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER articles_search_vector_trigger
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION articles_search_vector_update();
-- Backfill existing rows
UPDATE articles SET search_vector =
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B');
Step 2: GIN index
CREATE INDEX CONCURRENTLY idx_articles_search_vector
ON articles USING GIN(search_vector);
Step 3: query with ranking
SELECT id, title,
ts_rank_cd(search_vector, query) AS rank
FROM articles,
websearch_to_tsquery('english', $1) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
Use websearch_to_tsquery (Postgres 11+) — it parses natural-language input including AND, OR, and quoted phrases, which to_tsquery does not.
Typesense: quick setup
docker run -p 8108:8108 \
-v /tmp/typesense-data:/data \
typesense/typesense:27 \
--data-dir /data --api-key=xyz
import Typesense from 'typesense';
const client = new Typesense.Client({
nodes: [{ host: 'localhost', port: 8108, protocol: 'http' }],
apiKey: 'xyz',
connectionTimeoutSeconds: 2,
});
// Create collection (schema)
await client.collections().create({
name: 'articles',
fields: [
{ name: 'id', type: 'string' },
{ name: 'title', type: 'string' },
{ name: 'body', type: 'string' },
{ name: 'published_at', type: 'int64' },
],
default_sorting_field: 'published_at',
});
// Index a document
await client.collections('articles').documents().upsert({
id: '1', title: 'How search works', body: 'Full text ...', published_at: Date.now(),
});
// Search
const results = await client.collections('articles').documents().search({
q: 'full text',
query_by: 'title,body',
sort_by: '_text_match:desc',
});
For production, sync Postgres → Typesense via a CDC (change data capture) pipeline or after-write hooks.
Keeping search in sync with Postgres
| Pattern |
How |
Tradeoff |
| Synchronous write |
Write to Typesense in the same request handler |
Simple; search is always fresh; adds ~10ms latency per write |
| Background job |
Enqueue a sync task after DB write |
Slightly stale search; resilient to Typesense downtime |
| CDC (Debezium) |
Stream Postgres WAL changes to Typesense |
Most reliable; most infrastructure |
For most apps, a background job (BullMQ, ARQ, Celery) is the right balance.
How to pick
- Postgres FTS — you're already on Postgres, search is a secondary feature, results don't need typo tolerance, volume is under ~5M documents.
- Typesense or Meilisearch — search is a core feature, users expect instant-as-you-type with typo tolerance, or you need faceted filtering.
- Elasticsearch / OpenSearch — you're at tens of millions of documents, need log search, or are already on the Elastic stack.
- Algolia — you want zero ops and can afford the pricing (~$1–5 per 1000 search operations).
Common mistakes
Calling to_tsvector() in the WHERE clause. That recomputes the vector for every row on every query — slow at any scale. Store and index the vector.
Not using websearch_to_tsquery. User input like "react hooks" breaks to_tsquery; websearch_to_tsquery handles it gracefully.
Ignoring language configuration. to_tsvector('english', ...) applies stemming and stop words. Use the language that matches your content; multilingual apps need per-row language tracking.
Syncing search outside a transaction. If the DB write succeeds but the search index update fails, your index drifts. Use a retry queue.
No result ranking. Returning results in arbitrary order is a bad user experience. Always rank by ts_rank_cd or Typesense's _text_match.
What to skip
- Elasticsearch for a new project with standard search needs — the ops overhead and JVM memory requirements rarely pay off under 10M documents.
- LIKE '%query%' for search — no index, full table scan, no ranking. Replace it with FTS.
- Postgres trigram (
pg_trgm) as a primary search strategy — it's useful for fuzzy matching on short strings (usernames, codes) but not for paragraph search.
FAQ
Can Postgres FTS handle millions of rows?
Yes. With a GIN index on a tsvector column, Postgres handles 10–50M rows with sub-100ms search queries on modest hardware.
How do I add multilingual support?
Store a search_lang column per row, use it in to_tsvector(search_lang::regconfig, ...), and build separate indexes per language, or use the simple configuration as a universal fallback.
Does Typesense support vector/semantic search?
Yes, as of Typesense 0.25+ with hybrid search (BM25 + embeddings). You embed the query and documents, store the embedding in a float[] field, and combine keyword and vector scores.
How do I test search quality?
Build a small set of query/expected-result pairs and measure recall@k (how often the correct result appears in the top k). Run this in CI after any schema or ranking change.
Where to go next
See How to set up Postgres locally in 2026, How to write a database migration in 2026, and How to add search to a site in 2026.