Module P-7·23 min read

tsvector, tsquery, GIN indexes, relevance ranking, and generated tsvector columns — search without Elasticsearch.

P-7 — Full-Text Search

The standard answer to "we need search" is Elasticsearch. Spin up a cluster, sync your data, write query DSL, manage index mappings. For large-scale dedicated search, that trade-off sometimes makes sense. For most application search — finding documents, filtering content, search-as-you-type boxes — you already have everything you need inside PostgreSQL.

This module covers PostgreSQL's full-text search system: how it represents and indexes text, how queries work, how to rank results by relevance, and how to set it up so searches stay fast at scale.


The Problem With LIKE

The first instinct for search is LIKE:

sql

This works. It also requires a full sequential scan of the table for every search — no index can help with a leading wildcard. At a thousand rows it is fast. At a million rows it is a problem.

ILIKE (case-insensitive) is slower still. And neither handles linguistic variations: a search for "running" will not find articles containing "runs" or "ran."

Full-text search solves both problems: it is indexable and it understands language.


The Two Core Types

PostgreSQL full-text search is built around two data types:

tsvector — a preprocessed representation of a document. It is a sorted list of lexemes (normalized word stems) with their positions in the original text. When you convert text to tsvector, PostgreSQL normalizes words (removing stop words like "the", stemming "running" → "run"), and records where each term appeared.

tsquery — a search query. It is a boolean expression of lexemes that gets matched against a tsvector.

sql

The @@ operator is the match operator. It returns true if the tsvector satisfies the tsquery.


Building Queries with tsquery

to_tsquery

The most explicit form. Uses boolean operators:

sql

to_tsquery requires valid tsquery syntax. If the user types bare text, it will error.

plainto_tsquery

Takes plain text and converts it to an AND query. Safe for direct user input.

sql

websearch_to_tsquery

Understands Google-style search syntax. The best choice for search boxes.

sql

websearch_to_tsquery is the right function for production search boxes. It handles messy user input gracefully and supports the syntax users expect.


Searching a Table

Given a table of articles:

sql

A basic full-text search:

sql

This works but is slow — it calls to_tsvector on every row for every query. No index is used.


GIN Indexes: Making Search Fast

The solution is to pre-compute the tsvector and index it. The index type for tsvector is GIN (Generalized Inverted Index). A GIN index maps each lexeme to the set of rows that contain it — exactly like the index at the back of a book.

Option 1: Index on an Expression

sql

The query must use the exact same expression for the index to be used:

sql

Expression indexes work but have a maintenance overhead: the expression is recomputed on every INSERT and UPDATE.

A better pattern in PostgreSQL 12+: store the tsvector as a generated column and index that.

sql

Now queries are clean and simple:

sql

The search_vector column is maintained by PostgreSQL automatically on INSERT and UPDATE. The GIN index is built on the stored column. Query performance is fast.


Weighting Multiple Columns

Different columns have different importance — a match in the title should rank higher than a match in the body. setweight assigns a weight label (A, B, C, D — in descending importance) to a tsvector.

sql

Weights are used by the ranking functions to score results. A query matching in the title scores higher than the same query matching only in the body.


Ranking Results

Matching tells you which rows contain the search terms. Ranking tells you which rows are most relevant. PostgreSQL has two ranking functions:

ts_rank

sql

ts_rank computes relevance based on how frequently the query terms appear in the document. Higher frequency = higher rank.

ts_rank_cd

sql

ts_rank_cd ("cover density") also considers how close together the matching terms appear in the document. Generally produces more intuitive rankings when terms appear in proximity to each other.

Combining Rank with Recency

Pure relevance ranking sometimes buries recent results. A common production pattern:

sql

This boosts newer results, decaying exponentially with age. Adjust the decay constant to taste.


Highlighted Snippets with ts_headline

Search results usually show a snippet of the original text with matching terms highlighted. ts_headline does this:

sql

ts_headline options:

  • MaxWords / MinWords — snippet length
  • StartSel / StopSel — HTML tags wrapping matched terms
  • MaxFragments — return multiple non-contiguous snippets
  • FragmentDelimiter — separator between fragments (default " ... ")

Important: do not run ts_headline on the tsvector column — run it on the original text column. ts_headline is slow (it re-processes the document to find context). Compute it only on the result rows, never in a WHERE clause.


For tables with many text fields, aggregate them all into one search_vector:

sql

Querying is the same regardless of how many columns feed the vector:

sql

Language Configuration

Every to_tsvector and to_tsquery call takes a language configuration name as the first argument. This controls which stop words are removed and which stemming rules apply.

sql

Use 'simple' when you need exact-word matching (product codes, usernames) or when working with languages not supported by built-in dictionaries.

To see available configurations:

sql

Combining Full-Text Search with Filters

Full-text search works naturally alongside SQL filters. The GIN index is used for the text filter; additional B-tree indexes can be used for other conditions.

sql

PostgreSQL's planner will use the GIN index for the text condition and may use a B-tree index on category or published_at depending on estimated selectivity.


Performance Considerations

GIN index build time: GIN indexes are expensive to build on large tables. For tables with millions of rows, build the index during a maintenance window and be aware that inserts and updates are slightly slower while the index is maintained.

ts_headline cost: It re-parses the document to find the best snippet context. Only call it on the final result set (after LIMIT), never in a subquery used for filtering.

ts_rank is computed per row: Sorting a million rows by rank is expensive. Always filter with WHERE search_vector @@ query first, then rank the surviving rows. The GIN index reduces the candidate set to a small fraction of the table before ranking.

websearch_to_tsquery is cheap: Parse the query once (ideally in application code) and pass it as a parameter. Don't reconstruct it in every subquery.

sql

When PostgreSQL FTS Is Not Enough

PostgreSQL full-text search handles the majority of application search requirements. The cases where Elasticsearch or a dedicated search engine makes more sense:

  • Autocomplete / prefix search: PostgreSQL FTS handles whole words. For character-by-character autocomplete, consider pg_trgm (trigram extension) or a dedicated autocomplete system.
  • Fuzzy matching / typo tolerance: pg_trgm provides edit-distance-based similarity search.
  • Real-time, sub-millisecond search across billions of rows: At extreme scale, dedicated search clusters with sharding and caching start to win.
  • Complex relevance tuning: Elasticsearch's BM25 and field boosting configuration is more flexible than PostgreSQL's ranking functions for search-intensive products.

For everything else — blog search, document search, product search, internal tooling — PostgreSQL is the right tool.


Practical Exercise

Build a searchable article store from scratch:

sql

Summary

PostgreSQL's full-text search system provides production-grade text search without external dependencies:

tsvector stores a normalized, stemmed representation of document text. tsquery expresses search queries with boolean operators. The @@ operator matches them. A GIN index on the tsvector column makes search fast at scale. Generated stored columns keep the vector automatically updated with no application code. ts_rank_cd provides relevance ranking. ts_headline generates highlighted snippets.

For the majority of application search use cases — content search, product search, document discovery — this is all you need. Elasticsearch solves a different problem at a different scale.

Next up: P-8 — Performance Tuning for Application Engineers — EXPLAIN ANALYZE in depth, connection pooling with PgBouncer, key configuration parameters, and eliminating the N+1 query problem.

Knowledge Check

A developer implements full-text search by creating a GIN index on a tsvector generated column. The application displays search results using ts_headline to show highlighted snippets. In testing, searching returns results instantly, but when querying the production database containing 5 million rows, the search page takes several seconds to load. What is the most likely architectural cause of this performance degradation?


You want to implement a robust user-facing search box for your application. Users often type phrases in quotes ("error handling"), exclude terms with a minus sign (-java), and occasionally make syntax errors like unbalanced quotes or trailing ampersands. Which PostgreSQL function is the safest and most effective choice for converting their raw input into a search query?

Test your knowledge with more question sets

Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.

Sign in & Register

Question 3: A team implements a full-text search feature using an expression index: CREATE INDEX idx_search ON documents USING GIN (to_tsvector('english', title || ' ' || body));. The application executes the following search query, but EXPLAIN ANALYZE shows a slow Sequential Scan instead of using the GIN index. Why?

sql
  • A) The @@ operator does not support GIN indexes; it requires a GiST index.
  • B) The websearch_to_tsquery function generates dynamic queries that cannot be evaluated against pre-computed indexes.
  • C) The expression in the WHERE clause (to_tsvector(...)) does not exactly match the expression used to create the index.
  • D) EXPLAIN ANALYZE always executes a sequential scan on local development databases because the tables are too small for the query planner to justify reading the index.
Reveal Answer

Correct Answer: C

Expression indexes in PostgreSQL are highly literal. For the query planner to use an expression index, the expression in the WHERE clause must match the indexed expression exactly character-for-character (conceptually). In this scenario, the index was created using title || ' ' || body, but the query uses coalesce(title, '') || ' ' || coalesce(body, ''). Because these expressions are logically different to the planner, it cannot guarantee the index contains the right data, so it falls back to a full sequential scan, recomputing the coalesce expression for every row. (This is a major reason why storing the tsvector in a generated column is heavily preferred over expression indexes—it eliminates this exact failure mode).

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.