PostgreSQL Full-Text Search in 2026: tsvector, Ranking, Multi-Language, and Search-as-You-Type
Build production full-text search with PostgreSQL: tsvector and tsquery, relevance ranking with ts_rank, multi-language stemming, partial word search-as-you-type, and hybrid vector+FTS search.
PostgreSQL Full-Text Search in 2026: tsvector, Ranking, Multi-Language, and Search-as-You-Type
PostgreSQL's built-in full-text search covers most SaaS search use cases without adding Elasticsearch to your stack. For document counts under 10 million and query rates under a few hundred per second, PostgreSQL FTS is faster to implement, cheaper to operate, and simpler to maintain.
This post covers production-ready PostgreSQL FTS: the tsvector data model, GIN indexing, relevance ranking, multi-language stemming, and search-as-you-type via pg_trgm. For most teams, this replaces the need for a dedicated search service.
How PostgreSQL FTS Works
Input text: "The PostgreSQL database running on AWS is reliable"
โ to_tsvector('english', ...)
tsvector: 'aws':6 'databas':3 'postgreql':2 'reliabl':8 'run':5
(stems + positions; stop words removed: "the", "on", "is")
Query: "databases running"
โ to_tsquery('english', ...)
tsquery: 'databas' & 'run' (both stems must match)
Match: tsvector @@ tsquery โ TRUE (both stems present)
Schema: Indexed tsvector Column
-- Store tsvector in a generated column โ auto-updates when source changes
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL,
tags TEXT[],
author_name TEXT NOT NULL,
published_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Generated column: weighted combination of fields
-- A = highest weight, B = high, C = medium, D = low
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(author_name, '')), 'B') ||
setweight(to_tsvector('english', coalesce(
array_to_string(tags, ' '), ''
)), 'B') ||
setweight(to_tsvector('english', coalesce(body, '')), 'D')
) STORED
);
-- GIN index on the generated tsvector
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);
-- Also index for filtering (status, published_at)
CREATE INDEX articles_status_published_idx ON articles (status, published_at DESC);
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Basic Search Queries
-- Simple search with ranking
SELECT
id,
title,
author_name,
published_at,
ts_rank(search_vector, query) AS rank,
ts_headline('english', body, query,
'MaxWords=30, MinWords=15, ShortWord=3, HighlightAll=false, StartSel=<mark>, StopSel=</mark>'
) AS excerpt
FROM articles, to_tsquery('english', 'postgresql & database') query
WHERE status = 'published'
AND search_vector @@ query
ORDER BY rank DESC, published_at DESC
LIMIT 20;
-- Phrase search (words must appear adjacent)
SELECT title FROM articles
WHERE search_vector @@ phraseto_tsquery('english', 'machine learning');
-- "machine learning" must appear together, not just both words present
-- Web-style query (handles user input with OR, -, "")
-- plainto_tsquery: "postgresql database" โ 'postgresql' & 'database'
-- websearch_to_tsquery: "postgresql OR mysql" โ 'postgresql' | 'mysql'
-- "-oracle" โ !'oracle'
SELECT title FROM articles
WHERE search_vector @@ websearch_to_tsquery('english', $1);
-- $1 = user's raw search input โ safe, handles any input
TypeScript Search Service
// src/services/search.ts
import { db } from '@/lib/db';
interface SearchResult {
id: string;
title: string;
authorName: string;
publishedAt: string;
excerpt: string;
rank: number;
tags: string[];
}
interface SearchOptions {
query: string;
limit?: number;
offset?: number;
language?: string;
filters?: {
tags?: string[];
authorId?: string;
dateFrom?: Date;
dateTo?: Date;
};
}
export async function searchArticles(opts: SearchOptions): Promise<{
results: SearchResult[];
total: number;
}> {
const {
query,
limit = 20,
offset = 0,
language = 'english',
filters = {},
} = opts;
// Build filter conditions
const conditions: string[] = ["status = 'published'"];
const params: unknown[] = [query];
let paramIdx = 2;
if (filters.tags?.length) {
conditions.push(`tags && $${paramIdx}::text[]`);
params.push(filters.tags);
paramIdx++;
}
if (filters.dateFrom) {
conditions.push(`published_at >= $${paramIdx}`);
params.push(filters.dateFrom);
paramIdx++;
}
const whereClause = conditions.join(' AND ');
const { rows } = await db.query<SearchResult & { total: string }>(
`WITH results AS (
SELECT
id, title, author_name AS "authorName", published_at AS "publishedAt",
tags,
ts_rank_cd(search_vector, query, 4) AS rank, -- 4 = normalize by doc length
ts_headline(
'${language}', body, query,
'MaxWords=25, MinWords=10, StartSel=<b>, StopSel=</b>, HighlightAll=false'
) AS excerpt,
count(*) OVER() AS total
FROM articles, websearch_to_tsquery('${language}', $1) query
WHERE ${whereClause}
AND search_vector @@ query
ORDER BY rank DESC, published_at DESC
)
SELECT * FROM results
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
[...params, limit, offset],
);
return {
results: rows,
total: rows[0] ? Number(rows[0].total) : 0,
};
}
Multi-Language Search
-- Store the document language per row
ALTER TABLE articles ADD COLUMN language REGCONFIG NOT NULL DEFAULT 'english';
-- Dynamically use the document's language for indexing
-- (Cannot use GENERATED ALWAYS for dynamic language โ use trigger instead)
CREATE OR REPLACE FUNCTION articles_tsvector_update() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector(NEW.language, coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector(NEW.language, coalesce(NEW.body, '')), 'D');
RETURN NEW;
END;
$$;
CREATE TRIGGER articles_tsvector_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION articles_tsvector_update();
-- Supported languages (PostgreSQL built-in dictionaries):
-- english, german, french, spanish, italian, portuguese, dutch,
-- russian, turkish, danish, finnish, hungarian, norwegian, romanian, swedish
// Search in the document's own language
export async function searchMultiLanguage(
userQuery: string,
userLanguage: 'english' | 'german' | 'french' | 'spanish',
): Promise<SearchResult[]> {
// Query must match document language โ cross-language search requires translation
const { rows } = await db.query(
`SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles,
websearch_to_tsquery($1::regconfig, $2) query
WHERE language = $1
AND search_vector @@ query
ORDER BY rank DESC
LIMIT 20`,
[userLanguage, userQuery],
);
return rows;
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Search-as-You-Type (Partial Word Matching)
PostgreSQL FTS requires complete words โ it won't match "postg" for "postgresql". For autocomplete, combine with pg_trgm:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN index for trigram similarity (partial word matching)
CREATE INDEX articles_title_trgm_idx ON articles USING GIN (title gin_trgm_ops);
CREATE INDEX articles_author_trgm_idx ON articles USING GIN (author_name gin_trgm_ops);
-- Search-as-you-type: combine trigram prefix match + FTS
-- Use for queries < 3 words; full FTS for longer queries
SELECT
id,
title,
author_name,
similarity(title, $1) AS title_sim
FROM articles
WHERE status = 'published'
AND (
title % $1 -- Trigram similarity (handles partials)
OR author_name % $1
)
ORDER BY title_sim DESC
LIMIT 10;
-- For "type-ahead" prefix completion specifically:
SELECT DISTINCT unnest(string_to_array(title, ' ')) AS word
FROM articles
WHERE title ILIKE $1 || '%' -- Starts with prefix
AND status = 'published'
ORDER BY word
LIMIT 10;
Hybrid Search: FTS + Semantic (pgvector)
For best-in-class search, combine keyword FTS with vector semantic search:
-- Reciprocal Rank Fusion (RRF) combining FTS + vector similarity
WITH fts_results AS (
SELECT id, row_number() OVER (ORDER BY ts_rank_cd(search_vector, query) DESC) AS fts_rank
FROM articles, websearch_to_tsquery('english', $1) query
WHERE status = 'published'
AND search_vector @@ query
LIMIT 60
),
vector_results AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> $2::vector) AS vec_rank
FROM articles
WHERE status = 'published'
ORDER BY embedding <=> $2::vector
LIMIT 60
),
fused AS (
SELECT
COALESCE(f.id, v.id) AS id,
COALESCE(1.0/(60 + f.fts_rank), 0) + COALESCE(1.0/(60 + v.vec_rank), 0) AS score
FROM fts_results f
FULL OUTER JOIN vector_results v USING (id)
)
SELECT a.id, a.title, a.excerpt, f.score
FROM fused f
JOIN articles a ON a.id = f.id
ORDER BY f.score DESC
LIMIT 20;
-- $1 = text query, $2 = query embedding vector
FTS vs Elasticsearch: When to Switch
| Factor | PostgreSQL FTS | Elasticsearch |
|---|---|---|
| Documents | < 5M | > 5M |
| Query rate | < 200 req/s | > 200 req/s sustained |
| Languages | 24 built-in | 30+ with plugins |
| Setup complexity | None (existing DB) | Significant |
| Operational cost | $0 extra | $200โ$2,000+/month |
| Faceted search | Manual (GROUP BY) | Native |
| Fuzzy matching | Via pg_trgm | Native |
| Real-time indexing | Near-instant | Near-instant |
Start with PostgreSQL FTS. Migrate to Elasticsearch only when you hit these limits.
Working With Viprasol
We implement full-text search for SaaS applications โ from PostgreSQL FTS schema design through hybrid vector + keyword search and search-as-you-type autocomplete.
What we deliver:
- tsvector generated column setup with weighted fields
- GIN index configuration and query optimization
- Multilingual search with language detection
- Search-as-you-type via pg_trgm + prefix matching
- Hybrid FTS + pgvector semantic search for highest relevance
โ Discuss your search implementation โ Backend development services
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.