Topic 42

Full-Text Search and Trigram Matching

Search

Cartwheel's product search is name ILIKE '%straw%', fired on every keystroke against 200,000 products. A leading wildcard cannot use a B-tree, so each keystroke is a sequential scan of the whole catalogue, and the search box is the single most expensive read path in the application by execution count.

Postgres has two proper answers and they are not interchangeable. Full-text search understands words — tokenizing, stemming, dropping stop words, ranking results. Trigram matching understands character sequences, which makes arbitrary substring and fuzzy matching indexable. "Strawberries" finding "strawberry" is the first one's job. "straw" finding "Strawberry Punnet 400g" mid-word, and "strwaberry" finding it anyway, is the second's.

tsvector and tsquery

A text search configuration, english in Cartwheel's case, parses a document into tokens, discards stop words, and reduces the rest to lexemes. The result is a tsvector: a sorted list of lexemes with the positions where each occurred. A tsquery is the parsed search expression, and @@ is the operator that asks whether one matches the other.

What the english configuration does to a product description
SELECT to_tsvector('english', 'A punnet of the ripest strawberries');
--  'punnet':2 'ripest':5 'strawberri':6

SELECT to_tsvector('english', 'ripe strawberry')
    @@ to_tsquery('english', 'strawberries');   -- true

Half the words vanished: a, of and the are stop words and carry no search value. strawberries became the lexeme strawberri, and so does strawberry, so a search for one finds the other without any synonym table. The numbers are token positions, and they are what makes phrase search possible later. Both sides of @@ go through the same configuration, so the match is between normalized forms rather than between the strings a human typed.

What the english configuration does before anything is compared
Document text“A punnet of the ripest strawberries”
Parsed into tokenssix words
Stop words discardeda, of and the carry no search value
Reduced to lexemesstored with the positions where each occurred
tsvector @@ tsquerythe operator that asks whether one matches the other

Indexing It Properly

The value has to be stored, not computed at query time. Computing to_tsvector inside the WHERE clause means parsing and stemming every product on every keystroke — the same sequential scan as ILIKE, with more CPU. A stored generated column computes it once per write and makes it an ordinary indexable column.

A stored tsvector column with an explicit configuration, and its GIN index
ALTER TABLE products
  ADD COLUMN search tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english',
      coalesce(name, '') || ' ' ||
      coalesce(attributes->>'description', ''))
  ) STORED;

CREATE INDEX products_search_idx ON products USING gin (search);

Naming the configuration is not optional decoration. Postgres only accepts text-search functions that specify a configuration name in an index expression, and the reason is stated in the manual: index contents must be unaffected by default_text_search_config, or different entries could hold vectors built under different rules with no way to tell which is which, and the index could not be dumped and restored correctly. The built-in default of that setting is pg_catalog.simple, which does no stemming and drops no stop words, and initdb overrides it based on the server's locale, so leaving it implicit means staging and production can disagree about what a word is.

The coalesce calls exist because concatenating a null makes the whole expression null, which would un-index every product without a description and raise no error doing it. Combining the name with the description in one vector is a deliberate simplification: Postgres can weight fields A through D so a match in the name outranks a match in the body, and that is worth adding when relevance complaints start arriving, not before.

Building Queries Humans Type

Users do not type tsquery syntax. Three functions translate what they do type. plainto_tsquery takes a bag of words and joins them with AND. phraseto_tsquery keeps word order and requires adjacency. websearch_to_tsquery accepts the syntax people already know from search engines: quoted text becomes a phrase, OR becomes alternation, and a leading minus sign becomes negation.

The search endpoint, with ranking and a limit
SELECT id, name, ts_rank(p.search, q) AS rank
  FROM products p,
       websearch_to_tsquery('english', $1) AS q
 WHERE p.search @@ q
 ORDER BY rank DESC
 LIMIT 20;

-- $1 = 'strawberry -frozen'  →  'strawberri' & !frozen
-- $1 = '"oat milk" or soy'   →  'oat' <-> 'milk' | 'soy'

The query is parsed once and joined against the indexed column, so the GIN index does the filtering and only surviving rows are ranked. That ordering matters: the manual warns that ranking is expensive because it has to consult the tsvector of every matching document, which is I/O bound and slow, and practical queries match a lot of documents. Narrow the match before you rank it, and treat ranking as a cost you have chosen rather than one you inherited. A broad query on a 200,000-row catalogue can match tens of thousands of rows, all of which get ranked before the LIMIT discards them.

Trigram Matching with pg_trgm

Full-text search deliberately cannot answer "contains these five characters anywhere". pg_trgm can. The extension breaks each string into overlapping three-character sequences, padding each word with two leading spaces and one trailing space so that cat yields four trigrams, and indexes those. Two strings sharing many trigrams are similar; a search pattern's trigrams can be looked up like any other index key.

Making ILIKE and fuzzy matching indexable on the SKU column
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX products_sku_trgm_idx
    ON products USING gin (sku gin_trgm_ops);

SELECT id, sku FROM products WHERE sku ILIKE '%88-4471%';
SELECT id, name FROM products WHERE name % 'strwaberry';

The first query is the one that could not be indexed at all before the extension existed, and now is. Trigram index searches have supported LIKE and ILIKE since 9.1 and regular-expression matches since 9.3, which covers essentially every "contains" search an application issues. The second uses the similarity operator %, true when two strings' similarity exceeds pg_trgm.similarity_threshold, which defaults to 0.3 — that is the typo tolerance, and the threshold is the dial you actually tune. There is also a distance operator, <->, and it comes with an index choice: GiST can answer ORDER BY name <-> $1 LIMIT 10 efficiently as a nearest-neighbour scan and GIN cannot, so a "closest matches" endpoint wants gist_trgm_ops while a plain contains-filter wants gin_trgm_ops.

How a leading wildcard becomes an index scan
Split into trigramsoverlapping three-character sequences
Those become the index keysgin_trgm_ops on the column
The pattern is split the same way'%88-4471%' has trigrams of its own to look up
The query is answered by the indexLIKE and ILIKE since 9.1, regular expressions since 9.3

Choosing Between Them

Full-text search is for natural language: descriptions, articles, reviews — content where stemming, stop words and ranking are the point. Trigrams are for short strings where the user's input is an approximation of the stored value: names, SKUs, autocomplete, anything that has to survive a typo. Applying either one to the other's job produces a search that is technically working and practically useless.

Cartwheel ends up with both. A tsvector over product name and description handles "gluten free oat milk". A trigram index on sku handles a warehouse operator typing four characters off a label. The two are queried separately and the results merged in the application, because they answer different questions and their scores are not comparable. Two honest result sets are easier to debug than one blended relevance number.

Where Postgres Stops

The boundary is real and worth naming before you hit it. There is no distributed index: search capacity is the capacity of one primary and its replicas. Relevance tuning is four weight labels and a ranking function, not a pluggable scoring pipeline. There is no learned ranking, no query understanding, no per-field analyzer chain, and no synonym or spell-correction machinery beyond what you assemble from dictionaries and trigrams yourself.

What that buys is one system instead of two. No search cluster to run, secure and upgrade, no synchronization pipeline, and no class of bug where the index and the database disagree about what exists. The decision to move to a dedicated engine should come from relevance requirements or scale, not from an assumption that databases cannot search, and the last chapter takes that decision up properly alongside the extensions worth adding before it. For a 200,000-row catalogue with a search box, Postgres is the right answer by a wide margin.

Full-text search vs pg_trgm vs a search engine

Full-text search — understands words: stemming, stop words, phrase queries, weights and ranking. Right for descriptions, articles and any content where "strawberries" and "strawberry" must be the same query.

pg_trgm — understands character sequences: substrings, typos, similarity scores and nearest-match ordering. Right for names, SKUs and autocomplete, where the user's input approximates the stored string.

Elasticsearch or OpenSearch — adds distributed indexing, analyzer chains and far richer relevance tuning. Right when search is the product, at the price of a second system to run, secure and keep in sync with the database.

Common Mistakes
  • Leaving ILIKE '%term%' in the search path and adding a B-tree to fix it — a leading wildcard cannot use a B-tree at all, so the sequential scan stays and the index is pure write overhead.
  • Indexing to_tsvector(name) without naming the configuration — the result then depends on default_text_search_config, which differs between environments, so the index and the query disagree and searches return nothing.
  • Computing the tsvector in the WHERE clause instead of storing it — every keystroke re-parses and re-stems all 200,000 products.
  • Concatenating columns into a generated tsvector without coalesce — one null field makes the whole vector null and removes that product from every search result, silently.
  • Using full-text search on SKUs — tokenization and stemming break an identifier into pieces that no longer match the fragment the operator typed.
  • Ranking a broad match before limiting it — ts_rank reads the vector of every matching row, so a query matching 30,000 products pays for 30,000 rankings to display twenty.
Best Practices
  • Store the tsvector in a stored generated column with the configuration named explicitly, and index it with GIN.
  • Use websearch_to_tsquery for user input so that quotes, OR and a leading minus behave the way people already expect them to.
  • Add pg_trgm for substring and fuzzy matching, and keep it on short identifier columns such as sku where the trigram count per row stays small.
  • Choose gist_trgm_ops when the endpoint orders by similarity with a LIMIT, and gin_trgm_ops when it filters.
  • Tune pg_trgm.similarity_threshold against real query logs rather than accepting 0.3 as a permanent answer.
  • Decide the boundary with a dedicated search engine on operational grounds, meaning relevance requirements, scale and who maintains it, rather than on whether Postgres is capable.
Comparable toolsElasticsearch distributed index, analyzer chains, relevance tuningMeilisearch typo-tolerant search as a small separate serviceMySQL FULLTEXT indexes with MATCH … AGAINSTpgvector embedding search as a complement, not a replacement

Knowledge Check

What does to_tsvector('english', …) produce from a sentence?

  • Stemmed lexemes with their positions, minus the stop words
  • Every word of the original text, lowercased and stored in order
  • All overlapping three-character sequences found in the sentence
  • The words paired with a precomputed relevance score for each

Why must the text search configuration be named explicitly in an indexed expression?

  • Without a name, to_tsvector returns null and the index would hold nothing
  • Otherwise the index contents would depend on a session-level setting
  • A GIN index can only store vectors built by a named configuration
  • Naming it lets Postgres skip re-parsing the document on every insert

Why can't a B-tree index serve name ILIKE '%straw%'?

  • A B-tree cannot index text columns without a special operator class
  • A leading wildcard gives the tree no prefix to navigate with
  • ILIKE is case-insensitive and B-trees only compare exact byte sequences
  • The search pattern exceeds the maximum size of a B-tree index entry

A warehouse operator types four characters from the middle of a SKU. Which approach fits?

  • A pg_trgm index on sku, serving the substring match directly
  • A tsvector over sku, since full-text search handles short strings well
  • A B-tree on sku with text_pattern_ops, serving the wildcard pattern
  • A hash index on sku, since the lookup is an equality on a short key

What is the cost of ordering search results by ts_rank?

  • The index doubles in size to hold a precomputed score per document
  • Every matching row's tsvector must be read before the limit applies
  • Writes to the table block while the ranking query is being executed
  • The scan loses the index-only optimization it would otherwise have had

You got correct