PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 14: Search

Full-text search and trigram similarity

Level 3: Advanced SQL25 min

PostgreSQL ships a real full-text search engine: documents become tsvectors of normalised lexemes, queries become tsqueries, and GIN indexes make matching fast. For typos and fuzzy names, the pg_trgm extension adds similarity search.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Search with to_tsvector, websearch_to_tsquery and @@, and rank results
  • Index search with a generated tsvector column and GIN
  • Use pg_trgm for fuzzy matching and index-backed ILIKE

conceptThe concept

to_tsvector('english', text) splits text into lexemes, removes stop words and stems words (batteries becomes batteri). websearch_to_tsquery('english', 'battery -refund "sound quality"') parses search-box syntax into a tsquery. vector @@ query tests a match, ts_rank scores it and ts_headline highlights matches.

Store the vector in a generated column (GENERATED ALWAYS AS (to_tsvector(...)) STORED) and index it with GIN; setweight lets title matches outrank body matches.

pg_trgm breaks strings into three-character chunks. similarity(a, b) returns 0 to 1, a % b is true above a threshold, and <-> is distance for nearest-neighbour ordering. A GIN or GiST index with gin_trgm_ops also accelerates LIKE and ILIKE with leading wildcards, which a B-tree cannot.

whyWhy it matters

An external search cluster is another system to run, secure and keep in sync. For many applications, built-in search plus trigram matching is good enough and transactionally consistent with the data.

Use caseReal-world scenario

Customers search reviews for "battery" but not "refund", and support agents look up customers by misspelled names. Full-text search handles the first, trigrams the second.

Try itExamples

Run and edit these in the interactive lesson.

What the parser sees
SELECT to_tsvector('english', 'The batteries died within days, asked for a refund.') AS vector,
       websearch_to_tsquery('english', 'battery -refund "sound quality"') AS query;
Search and rank reviews
SELECT r.id, r.rating, r.title,
       round(ts_rank(to_tsvector('english', r.title || ' ' || r.body), q)::numeric, 3) AS rank,
       ts_headline('english', r.body, q, 'StartSel=[, StopSel=]') AS snippet
FROM reviews r,
     websearch_to_tsquery('english', 'battery -refund') q
WHERE to_tsvector('english', r.title || ' ' || r.body) @@ q
ORDER BY rank DESC, r.id
LIMIT 5;
Indexed search with a generated column
BEGIN;
ALTER TABLE reviews ADD COLUMN search tsvector
  GENERATED ALWAYS AS (setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', body), 'B')) STORED;
CREATE INDEX reviews_search_gin ON reviews USING gin (search);
ANALYZE reviews;
EXPLAIN SELECT id, title FROM reviews WHERE search @@ websearch_to_tsquery('english', 'refund');
ROLLBACK;
Fuzzy matching with pg_trgm
SELECT name, round(similarity(name, 'Prya Sharmaa')::numeric, 2) AS sim
FROM customers
WHERE name % 'Prya Sharmaa'
ORDER BY sim DESC, name
LIMIT 5;
Index-backed ILIKE with a leading wildcard
BEGIN;
CREATE INDEX customers_email_trgm ON customers USING gin (email gin_trgm_ops);
EXPLAIN SELECT id, email FROM customers WHERE email ILIKE '%kenji.fis%';
SET LOCAL enable_seqscan = off;
EXPLAIN SELECT id, email FROM customers WHERE email ILIKE '%kenji.fis%';
ROLLBACK;
The first plan is still a Seq Scan: the whole table is about 50 pages, so reading it is cheaper than the index. With seq scans disabled the planner shows that the trigram index can serve the leading-wildcard ILIKE, which is what you get on a large table.

internalsUnder the hood

A GIN index stores, for each lexeme or trigram, a compressed list of row locations. New entries go to a pending list first (fastupdate) and are merged later, which speeds up writes but can slow searches until VACUUM or gin_pending_list_limit flushes them.

Avoid / preferDo this, not that

Avoid
SELECT id FROM reviews WHERE to_tsvector(body) @@ to_tsquery('battery');
Prefer
SELECT id FROM reviews WHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', 'battery');

Without an explicit configuration the result depends on default_text_search_config, and an expression index built with a configuration will not match a query without one.

Avoid
to_tsquery('english', $user_input)
Prefer
websearch_to_tsquery('english', $user_input)

to_tsquery raises a syntax error on ordinary input such as "battery & ". websearch_to_tsquery accepts anything a user types.

PitfallsCommon mistakes

  • Calling to_tsvector(body) without a configuration in queries but with one in the index. The expressions must match exactly for the index to be used.
  • Using LIKE '%term%' on large tables with only a B-tree index. Add a trigram index or use full-text search.
  • Expecting full-text search to handle typos. It matches stems, not misspellings; combine it with pg_trgm.

LimitsWhen not to use it

For substring and typo-tolerant matching on short fields such as names and SKUs, pg_trgm is a better fit. For complex relevance tuning, facets and analytics over very large corpora, a dedicated search engine may be justified.

SecuritySecurity notes

ts_headline output contains the original document text plus highlight tags. Escape it before rendering in HTML, or user-written review text becomes a cross-site scripting vector.

ChallengeTry it yourself

Find the five best-ranked reviews mentioning battery but not charger, with a highlighted snippet.

Show one solution
One solution
SELECT id, rating, ts_headline('english', body, q) AS snippet
FROM reviews, websearch_to_tsquery('english', 'battery -charger') q
WHERE to_tsvector('english', body) @@ q
ORDER BY ts_rank(to_tsvector('english', body), q) DESC, id
LIMIT 5;

InterviewQuestions you should be able to answer

How does PostgreSQL full-text search work?
A text search configuration (such as english) parses documents into tokens, drops stop words and normalises words to lexemes via dictionaries, producing a tsvector with positions. Queries become tsqueries with AND, OR, NOT and phrase operators. @@ tests a match; ts_rank or ts_rank_cd scores it. A GIN index on the tsvector (often a stored generated column) makes matching fast.
How can you make <code>WHERE name ILIKE '%smith%'</code> fast?
A B-tree cannot help with a leading wildcard. Create the pg_trgm extension and a GIN (or GiST) index with gin_trgm_ops on the column; the planner then uses a Bitmap Index Scan for LIKE, ILIKE and regex matches with at least three literal characters. pg_trgm also provides similarity() and the % operator for typo-tolerant search.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation