why not just LIKE '%word%'

LIKE '%word%' can't use a standard index (the leading wildcard defeats it), doesn't understand word stems (searching "running" won't match "run"), has no concept of relevance ranking, and treats "the" and "cat" as equally significant. PostgreSQL's full-text search addresses all four.

tsvector and tsquery

SELECT to_tsvector('english', 'The quick brown foxes are running');
-- 'brown':3 'fox':4 'quick':2 'run':6
-- notice: stop words ('the', 'are') removed, words stemmed ('foxes' → 'fox', 'running' → 'run')

SELECT to_tsvector('english', 'The quick brown foxes are running')
       @@ to_tsquery('english', 'fox & run');
-- true — 'fox' and 'run' (stemmed) are both present
tsvector is a preprocessed, searchable representation of a document (stemmed, stop-words removed, with position info for ranking). tsquery is a preprocessed search query. @@ tests whether a vector matches a query.

querying and ranking

SELECT title, ts_rank(to_tsvector('english', body), query) AS rank
FROM articles, to_tsquery('english', 'postgresql & performance') query
WHERE to_tsvector('english', body) @@ query
ORDER BY rank DESC
LIMIT 10;
ts_rank scores how well a document matches, based on term frequency and (with ts_rank_cd) how close together the matching terms appear — use it to sort results by relevance rather than an arbitrary order.

indexing for search: a generated column + GIN

ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;

CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

SELECT title FROM articles
WHERE search_vector @@ to_tsquery('english', 'postgresql & indexing')
ORDER BY ts_rank(search_vector, to_tsquery('english', 'postgresql & indexing')) DESC;
Computing to_tsvector on every query works for small tables but re-does the same parsing work every time. A GENERATED ... STORED column keeps a precomputed tsvector in sync automatically on every write, and a GIN index on that column makes the @@ lookup itself fast.

when to reach for something else instead

Built-in full-text search is genuinely good for typical "search within our own content" use cases and avoids running a separate service. For fuzzy/typo-tolerant search, faceted search UIs, or search across a truly massive, high-QPS corpus, a dedicated engine (Elasticsearch, Meilisearch, Typesense) is usually still the better call — know the built-in option exists so reaching for a separate service is a deliberate choice, not a default.

where to go from here

JSONB — another PostgreSQL data type with its own GIN-indexed operators.
PostgreSQL Extensions — pg_trgm, for fuzzy/typo-tolerant matching this page's exact-stem search doesn't cover.