47. Full-text search: tsvector and tsquery
Why LIKE isn't "search"
Module 10 established that LIKE '%word%' can't use a normal index and
forces a full scan. But there's a deeper problem beyond performance:
LIKE has no concept of language — it can't match "running" when you
search "run," can't ignore word order, can't rank results by relevance,
can't treat "the"/"a"/"and" as noise words. Full-text search is
Postgres's built-in solution to all of this — a real, if basic, search
engine built into the database.
tsvector: text, preprocessed for searching
A tsvector is text broken into lexemes (normalized word stems,
with stop words like "the"/"a" removed) plus their positions:
SELECT to_tsvector('english', 'A dinosaur runs through the jungle');
-- 'dinosaur':2 'jungl':6 'run':3
Notice: "the"/"A" are gone (stop words), "runs" became run and
"jungle" became jungl (stemming — matches "running"/"run"/"runner"
alike, and "jungles"/"jungle" alike). This preprocessing is why
full-text search can match linguistic variants that LIKE never could.
pagila's film table already has this built in — a fulltext tsvector
column kept in sync by a trigger, visible in psql's \d film output:
SELECT title, fulltext FROM film WHERE film_id = 1;
That trigger (film_fulltext_trigger, using title and description)
automatically keeps fulltext in sync on every INSERT/UPDATE — a
real, practical use of Module 9's trigger lesson, not just a
demonstration.
tsquery: the search terms, similarly preprocessed
SELECT to_tsquery('english', 'dinosaur & jungle'); -- AND
SELECT to_tsquery('english', 'dinosaur | jungle'); -- OR
SELECT to_tsquery('english', 'dinosaur & !jungle'); -- AND NOT
&/|/! are the boolean operators inside a tsquery —
AND/OR/NOT, applied to the same stemmed-lexeme matching tsvector uses.
plainto_tsquery is the friendlier version for plain user input (no
operators, just AND's every word together):
SELECT plainto_tsquery('english', 'dinosaur jungle'); -- same as dinosaur & jungle
Matching: @@
SELECT title FROM film WHERE fulltext @@ to_tsquery('english', 'epic & drama');
SELECT title FROM film WHERE fulltext @@ plainto_tsquery('english', 'shark');
@@ is the match operator — does this tsvector satisfy this
tsquery. This is the core of a full-text search query, and (like JSONB
containment) can be accelerated with a GIN index — pagila's film
table already has one (film_fulltext_idx, using GiST in pagila's
specific case — both GIN and GiST support full-text search, GIN
generally reads faster, GiST updates faster; pagila's designers chose
GiST, a legitimate tradeoff for a table this size and update pattern).
Ranking results by relevance
Unlike a plain WHERE filter, search results usually need to be
ordered by relevance, not an arbitrary column:
SELECT title, ts_rank(fulltext, to_tsquery('english', 'epic & drama')) AS rank
FROM film
WHERE fulltext @@ to_tsquery('english', 'epic & drama')
ORDER BY rank DESC
LIMIT 10;
ts_rank scores how well a document matches a query (roughly: how many
matching terms, how close together, how rare the terms are overall) —
this is what turns "which rows match" into "which rows match best,"
the difference between a filter and an actual search feature.
Check yourself
- What does
to_tsvectordo to a piece of text that makes it fundamentally different from just storing the raw string? - Why can
LIKE '%running%'never match a row containing the word "run," while full-text search can? - What does
ts_rankadd on top of a plain@@match?