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:
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.
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:
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.
websearch_to_tsquery
Understands Google-style search syntax. The best choice for search boxes.
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:
A basic full-text search:
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
The query must use the exact same expression for the index to be used:
Expression indexes work but have a maintenance overhead: the expression is recomputed on every INSERT and UPDATE.
Option 2: Generated Stored Column (Recommended)
A better pattern in PostgreSQL 12+: store the tsvector as a generated column and index that.
Now queries are clean and simple:
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.
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
ts_rank computes relevance based on how frequently the query terms appear in the document. Higher frequency = higher rank.
ts_rank_cd
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:
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:
ts_headline options:
MaxWords/MinWords— snippet lengthStartSel/StopSel— HTML tags wrapping matched termsMaxFragments— return multiple non-contiguous snippetsFragmentDelimiter— 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.
Multi-Column Search
For tables with many text fields, aggregate them all into one search_vector:
Querying is the same regardless of how many columns feed the vector:
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.
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:
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.
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.
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_trgmprovides 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:
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.
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 & RegisterQuestion 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?
- A) The
@@operator does not support GIN indexes; it requires a GiST index. - B) The
websearch_to_tsqueryfunction generates dynamic queries that cannot be evaluated against pre-computed indexes. - C) The expression in the
WHEREclause (to_tsvector(...)) does not exactly match the expression used to create the index. - D)
EXPLAIN ANALYZEalways 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
0Join the discussion