02 · Full-Text Search¶
For a large class of applications — a help centre, product search for a few hundred thousand items, searching notes or tickets — PostgreSQL's built-in full-text search is enough, and it has a huge advantage over a separate search engine: the index is transactionally consistent with your data, and there is nothing else to deploy, sync or monitor.
This lesson builds search over a small knowledge base and then measures it at scale. Outputs are from PostgreSQL 18.6.
Documents become lexemes¶
LIKE '%run%' matches characters. Full-text search matches words, normalised:
SELECT to_tsvector('english', 'The runners were running quickly through the parks');
'park':8 'quick':5 'run':4 'runner':2
The english configuration split the text into tokens, dropped stop words ("the", "were",
"through"), and stemmed the rest: "running" → run, "parks" → park, "quickly" → quick. The
numbers are word positions, used for phrase search and ranking. Compare the simple configuration,
which only lower-cases:
SELECT to_tsvector('simple', 'The runners were running quickly through the parks');
'parks':8 'quickly':5 'runners':2 'running':4 'the':1,7 'through':6 'were':3
ts_debug shows what happens to each token and which dictionary handled it:
SELECT * FROM ts_debug('english', 'PostgreSQL 18 runs faster');
alias | description | token | dictionaries | dictionary | lexemes
-----------+------------------+------------+----------------+--------------+--------------
asciiword | Word, all ASCII | PostgreSQL | {english_stem} | english_stem | {postgresql}
uint | Unsigned integer | 18 | {simple} | simple | {18}
asciiword | Word, all ASCII | runs | {english_stem} | english_stem | {run}
asciiword | Word, all ASCII | faster | {english_stem} | english_stem | {faster}
(Blank-space rows omitted.) Stemming is algorithmic (the Snowball stemmer), so it is not a
dictionary of real words: "freeze" becomes freez, and "faster" stays faster.
Queries¶
A tsquery is a boolean expression of lexemes, and @@ tests a vector against it:
SELECT to_tsvector('english', 'Parks are great for running') @@ to_tsquery('english', 'run & park');
t
Four ways to build queries:
SELECT to_tsquery('english', 'running & park') AS q1,
plainto_tsquery('english', 'running in parks') AS q2,
websearch_to_tsquery('english', '"vacuum freeze" -wraparound or autovacuum') AS q3;
q1 | q2 | q3
----------------+----------------+-----------------------------------------------------
'run' & 'park' | 'run' & 'park' | 'vacuum' <-> 'freez' & !'wraparound' | 'autovacuum'
to_tsquery— you write the operators (&,|,!,<->for "followed by",:*for prefix). It raises a syntax error on malformed input, so never pass raw user input to it.plainto_tsquery— ANDs all the words.phraseto_tsquery— words must be adjacent:'tune' <-> 'autovacuum'.websearch_to_tsquery— accepts search-box syntax: quotes for phrases,-for NOT,or. It never raises a syntax error, which makes it the right choice for user input.
A searchable table¶
Store the vector in a generated column, so it can never drift from the text, and weight the title above the body:
CREATE TABLE kb (
id int PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')) STORED
);
INSERT INTO kb (id, title, body) VALUES
(1, 'Tuning autovacuum for busy tables', 'Lower the scale factor and raise the cost limit so autovacuum keeps up with dead tuples on large, frequently updated tables.'),
(2, 'Why VACUUM FREEZE matters', 'Freezing old rows prevents transaction ID wraparound. Monitor the age of datfrozenxid and let autovacuum do its work.'),
(3, 'Choosing an index type', 'B-tree indexes handle equality and ranges. GIN indexes help with arrays, JSONB and full-text search vectors.'),
(4, 'Replication slots and disk usage', 'An inactive replication slot retains WAL and can fill the disk. Drop slots that are no longer consumed.'),
(5, 'Connection pooling basics', 'Each connection is a process. A pooler such as PgBouncer lets thousands of clients share a few server connections.');
CREATE INDEX kb_search ON kb USING gin (search);
The configuration must be named explicitly ('english') in the generated column, because the
one-argument form depends on the default_text_search_config setting and is therefore not immutable.
Ranking¶
SELECT id, title, ts_rank(search, q) AS rank, ts_rank_cd(search, q) AS rank_cd
FROM kb, websearch_to_tsquery('english', 'autovacuum') q
WHERE search @@ q
ORDER BY rank DESC;
id | title | rank | rank_cd
----+-----------------------------------+------------+---------
1 | Tuning autovacuum for busy tables | 0.66871977 | 1.4
2 | Why VACUUM FREEZE matters | 0.24317084 | 0.4
Both mention autovacuum, but article 1 has it in the title (weight A) and twice overall, so it ranks
higher. ts_rank weighs term frequency; ts_rank_cd ("cover density") also rewards matched terms
appearing close together. Both accept a normalisation flag to penalise long documents. Ranking needs
the vector of every matching row, so it is computed after the index has found matches — rank a few
hundred candidates, not a million.
Note how AND semantics narrows results:
... websearch_to_tsquery('english', 'autovacuum tables') ...
id | title | rank
----+-----------------------------------+------------
1 | Tuning autovacuum for busy tables | 0.98200154
Article 2 mentions autovacuum but not tables, so it no longer matches.
Snippets and prefix search¶
SELECT id, ts_headline('english', body, websearch_to_tsquery('english', 'wraparound'),
'StartSel=[, StopSel=], MaxWords=12, MinWords=5')
FROM kb WHERE search @@ websearch_to_tsquery('english', 'wraparound');
id | ts_headline
----+-----------------------------------------------
2 | [wraparound]. Monitor the age of datfrozenxid
ts_headline re-parses the original text, so it is relatively expensive — apply it only to the page
of results you display. For type-ahead, prefix matching with :*:
SELECT id, title FROM kb WHERE search @@ to_tsquery('english', 'index:*');
3 | Choosing an index type
Accents and custom configurations¶
A user typing "cafe" will not find "café". The unaccent extension provides a dictionary you can
chain in front of the stemmer in a custom configuration:
CREATE EXTENSION unaccent;
CREATE TEXT SEARCH CONFIGURATION english_unaccent (COPY = english);
ALTER TEXT SEARCH CONFIGURATION english_unaccent
ALTER MAPPING FOR hword, hword_part, word WITH unaccent, english_stem;
SELECT to_tsvector('english_unaccent', 'Café résumé naïve')
@@ websearch_to_tsquery('english_unaccent', 'cafe resume'); -- t
Use the same configuration for the stored vector and for queries. Other configurations ship for many
languages (\dF lists them); for multilingual content, store a language column and build the vector
with to_tsvector(lang::regconfig, body).
Performance at scale¶
A 200,000-row table with a stored search column:
-- stored vector, no index
Parallel Seq Scan on kb_big (actual time=0.719..12.115 rows=13.33 loops=3)
Filter: (search @@ '''wraparound'''::tsquery)
Execution Time: 17.033 ms
-- computing the vector on the fly instead
Parallel Seq Scan on kb_big (actual time=80.319..1053.283 rows=13.33 loops=3)
Filter: (to_tsvector('english'::regconfig, body) @@ '''wraparound'''::tsquery)
Execution Time: 1057.228 ms
-- stored vector with a GIN index (4656 kB)
Bitmap Heap Scan on kb_big (actual time=0.027..0.127 rows=40.00 loops=1)
-> Bitmap Index Scan on kb_big_search
Execution Time: 0.157 ms
Parsing and stemming text is expensive (1 second for 200,000 short documents); storing the vector
saves it, and the GIN index skips the scan entirely. An alternative to a stored column is an
expression index on to_tsvector('english', body), which saves disk space but requires the query to
repeat the exact expression and recomputes vectors when rechecking or ranking.
Where PostgreSQL search stops¶
Built-in full-text search does not do typo tolerance (combine it with pg_trgm similarity for
"did you mean"), synonyms beyond a synonym dictionary you maintain, language detection, faceting at
scale, or learning-to-rank. If search is the core of your product, a dedicated engine may be worth
its operational cost. For "search box on our app", it usually is not.
How It Actually Works¶
A text search configuration maps each token type produced by the parser (words, numbers,
URLs, emails, …) to a chain of dictionaries. Each token is offered to the dictionaries in order;
the first that recognises it returns lexemes (or nothing, for a stop word). english_stem is a
Snowball stemmer that accepts everything, so it must be last in the chain — which is why unaccent
goes in front of it.
A tsvector is a sorted array of distinct lexemes, each with up to 256 positions and a weight label
(A–D) per position. A tsquery is a tree of operators. Evaluation checks lexeme presence, and for
phrase operators (<->, <N>) compares positions.
The GIN index stores each lexeme once with a posting list of rows containing it. A query extracts its lexemes, fetches their posting lists, and combines them according to the boolean tree; phrase and weight conditions are verified on the heap tuple during recheck, because positions are not stored in the index.
Common mistakes¶
- Passing user input to
to_tsqueryand getting syntax errors on input likeC++or". - Mixing configurations: vectors built with
english, queries withsimple. - Calling
to_tsvectorin theWHEREclause without a matching index or stored column. - Running
ts_headlineon every match instead of the displayed page. - Expecting stemming to produce real words or handle typos.
Exercise¶
- Load 1,000 rows of real text (for example, this course's own Markdown files, one row per lesson)
into a table with a weighted, generated
tsvectorand a GIN index. - Build a search query that takes raw user input, ranks with
ts_rank_cd, returns the top 10 with highlighted snippets, and never errors on odd input. - Create an accent-insensitive configuration and prove a search for "resume" finds "résumé".
- Add
pg_trgmand implement "did you mean?" suggestions for queries that return no results, using similarity against a table of distinct words (ts_statcan produce one).