Full-text search
DraftLIKE and ILIKE match characters: '%indexing%' never finds "Indexes". Full-text search matches words. It reduces text to lexemes, so "indexing", "indexes" and "index" are the same word, understands search-box syntax, ranks the results, and has an index. It is built into PostgreSQL: no extension needed.
Same eight documents as the vectors lesson, without the embeddings.
The documents
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL
);INSERT INTO documents (title, body)
VALUES
('SQL indexes', 'Indexes help PostgreSQL find rows without reading the whole table.'),
('JOIN patterns', 'Joins combine rows from related tables into one result.'),
('Query tuning', 'Slow queries get faster with the right index and a better plan.'),
('React components', 'Components split a user interface into small reusable pieces.'),
('CSS grid layouts', 'Grid and flexbox position elements on a web page.'),
('Neural networks', 'Neural networks learn patterns from training data.'),
('Chatbots', 'Language models answer questions written in natural language.'),
('Vector search', 'Embeddings let a database find documents with a similar meaning.');Text becomes lexemes
to_tsvector splits text into words, drops stop words like 'the' and 'without', and reduces each word to its stem: 'Indexes' becomes 'index', 'reading' becomes 'read'. The numbers are word positions.
SELECT title, to_tsvector('english', title || ' ' || body) AS lexemes
FROM documents
LIMIT 3;| title | lexemes |
|---|---|
| SQL indexes | 'find':6 'help':4 'index':2,3 'postgresql':5 'read':9 'row':7 'sql':1 'tabl':12 'whole':11 'without':8 |
| JOIN patterns | 'combin':4 'join':1,3 'one':10 'pattern':2 'relat':7 'result':11 'row':5 'tabl':8 |
| Query tuning | 'better':13 'faster':6 'get':5 'index':10 'plan':14 'queri':1,4 'right':9 'slow':3 'tune':2 |
Limit (cost=0.00..0.83 rows=3 width=64)
-> Seq Scan on documents (cost=0.00..235.25 rows=850 width=64)Match with @@
The query goes through the same stemming, so 'indexing' matches 'Indexes' and 'index'. ILIKE '%indexing%' would find nothing.
SELECT title
FROM documents
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'indexing');| title |
|---|
| SQL indexes |
| Query tuning |
Seq Scan on documents (cost=0.00..237.38 rows=4 width=32)
Filter: (to_tsvector('english'::regconfig, ((title || ' '::text) || body)) @@ '''index'''::tsquery)Search like a search box
websearch_to_tsquery understands what users type in search boxes: "quoted phrases", OR, and -word to exclude. Here: documents about finding, but not the PostgreSQL one.
SELECT title
FROM documents
WHERE to_tsvector('english', title || ' ' || body)
@@ websearch_to_tsquery('english', 'find -postgresql');| title |
|---|
| Vector search |
Seq Scan on documents (cost=0.00..237.38 rows=4 width=32)
Filter: (to_tsvector('english'::regconfig, ((title || ' '::text) || body)) @@ '''find'' & !''postgresql'''::tsquery)Rank the results
ts_rank scores how well each row matches. With title words weighted A and body words B, a match in the title counts more.
SELECT
title,
round(ts_rank(
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B'),
query
)::numeric, 3) AS rank
FROM documents, websearch_to_tsquery('english', 'search or find') AS query
WHERE (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) @@ query
ORDER BY rank DESC;| title | rank |
|---|---|
| Vector search | 0.426 |
| SQL indexes | 0.122 |
Sort (cost=456.24..456.26 rows=8 width=64)
Sort Key: (round((ts_rank((setweight(to_tsvector('english'::regconfig, documents.title), 'A'::"char") || setweight(to_tsvector('english'::regconfig, documents.body), 'B'::"char")), '''search'' | ''find'''::tsquery))::numeric, 3)) DESC
-> Seq Scan on documents (cost=0.00..456.12 rows=8 width=64)
Filter: ((setweight(to_tsvector('english'::regconfig, title), 'A'::"char") || setweight(to_tsvector('english'::regconfig, body), 'B'::"char")) @@ '''search'' | ''find'''::tsquery)Highlight the matches
ts_headline returns the text with matching words wrapped in <b> tags, ready for a results page.
SELECT title, ts_headline('english', body, query) AS snippet
FROM documents, websearch_to_tsquery('english', 'rows') AS query
WHERE to_tsvector('english', title || ' ' || body) @@ query;| title | snippet |
|---|---|
| SQL indexes | Indexes help PostgreSQL find <b>rows</b> without reading the whole table. |
| JOIN patterns | Joins combine <b>rows</b> from related tables into one result. |
Seq Scan on documents (cost=0.00..238.38 rows=4 width=64)
Filter: (to_tsvector('english'::regconfig, ((title || ' '::text) || body)) @@ '''row'''::tsquery)A search column and an index
The examples above call to_tsvector on every row, for every search. In a real table, store the tsvector in a generated column, and index it:
-- Computed once on INSERT/UPDATE instead of on every search.
-- setweight labels title words A and body words B, so ts_rank can
-- rank a title match above a body match.
ALTER TABLE documents ADD COLUMN search tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED;
-- GIN: an inverted index, lexeme -> rows that contain it.
-- Create it after a bulk load: building it once is faster than updating it per row.
CREATE INDEX documents_search_idx ON documents USING gin (search);Store it and index it
5,008 documents, a stored search column, and a GIN index. The plan is a Bitmap Index Scan: the index returns the 62 rows containing both lexemes, and only those are read.
SELECT title
FROM documents
WHERE search @@ websearch_to_tsquery('english', 'redis chatbots');| title |
|---|
| Redis chatbots note 63 |
| Redis chatbots note 143 |
| Redis chatbots note 223 |
| Redis chatbots note 303 |
| Redis chatbots note 383 |
| Redis chatbots note 463 |
| Redis chatbots note 543 |
| Redis chatbots note 623 |
| Redis chatbots note 703 |
| Redis chatbots note 783 |
| Redis chatbots note 863 |
| Redis chatbots note 943 |
| Redis chatbots note 1023 |
| Redis chatbots note 1103 |
| Redis chatbots note 1183 |
| Redis chatbots note 1263 |
| Redis chatbots note 1343 |
| Redis chatbots note 1423 |
| Redis chatbots note 1503 |
| Redis chatbots note 1583 |
| Redis chatbots note 1663 |
| Redis chatbots note 1743 |
| Redis chatbots note 1823 |
| Redis chatbots note 1903 |
| Redis chatbots note 1983 |
| Redis chatbots note 2063 |
| Redis chatbots note 2143 |
| Redis chatbots note 2223 |
| Redis chatbots note 2303 |
| Redis chatbots note 2383 |
| Redis chatbots note 2463 |
| Redis chatbots note 2543 |
| Redis chatbots note 2623 |
| Redis chatbots note 2703 |
| Redis chatbots note 2783 |
| Redis chatbots note 2863 |
| Redis chatbots note 2943 |
| Redis chatbots note 3023 |
| Redis chatbots note 3103 |
| Redis chatbots note 3183 |
| Redis chatbots note 3263 |
| Redis chatbots note 3343 |
| Redis chatbots note 3423 |
| Redis chatbots note 3503 |
| Redis chatbots note 3583 |
| Redis chatbots note 3663 |
| Redis chatbots note 3743 |
| Redis chatbots note 3823 |
| Redis chatbots note 3903 |
| Redis chatbots note 3983 |
| Redis chatbots note 4063 |
| Redis chatbots note 4143 |
| Redis chatbots note 4223 |
| Redis chatbots note 4303 |
| Redis chatbots note 4383 |
| Redis chatbots note 4463 |
| Redis chatbots note 4543 |
| Redis chatbots note 4623 |
| Redis chatbots note 4703 |
| Redis chatbots note 4783 |
| Redis chatbots note 4863 |
| Redis chatbots note 4943 |
Bitmap Heap Scan on documents (cost=21.84..128.86 rows=62 width=24)
Recheck Cond: (search @@ '''redi'' & ''chatbot'''::tsquery)
-> Bitmap Index Scan on documents_search_idx (cost=0.00..21.82 rows=62 width=0)
Index Cond: (search @@ '''redi'' & ''chatbot'''::tsquery)What it can't do
A typo, 'postgress', becomes the lexeme 'postgress', which no document contains. Full-text search matches words, not spellings or meanings.
SELECT title
FROM documents
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'postgress');No rows
Seq Scan on documents (cost=0.00..237.38 rows=4 width=32)
Filter: (to_tsvector('english'::regconfig, ((title || ' '::text) || body)) @@ '''postgress'''::tsquery)Limits
Full-text search knows words and their forms, nothing more. It doesn't forgive typos: fuzzy search does. It doesn't know that "speed up" and "faster" mean the same thing: vector search does. The language matters too: 'english' stems English words; Postgres ships configurations for about 30 languages, and 'simple' for no stemming at all.
Text search in Postgres compares all the approaches and combines full-text with vector search.
What We Learned
- tsvectora document as a sorted list of normalized words (lexemes).
- tsquerythe search, built with
plainto_tsqueryorwebsearch_to_tsquery, matched with@@. - ts_rankscores matches; setweight makes title words count more.
- ts_headlinehighlights the matching words in a snippet.
- GIN indexan inverted index from each lexeme to the rows that contain it.