Module P-7·16 min read

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

Written by Jatin Jain Saraf · Architect of SupraScan

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:

SELECT * FROM articles WHERE body LIKE '%postgresql performance%';

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.

-- Convert text to tsvector
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- Result: 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
-- Note: "The", "over" removed (stop words), "jumps"→"jump", "lazy"→"lazi"
 
-- Convert a search query to tsquery
SELECT to_tsquery('english', 'jump & fox');
-- Result: 'jump' & 'fox'
 
-- Match a tsvector against a tsquery with the @@ operator
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog')
    @@ to_tsquery('english', 'jump & fox');
-- Result: true

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:

-- AND: both terms must be present
SELECT to_tsquery('english', 'postgres & performance');
 
-- OR: either term
SELECT to_tsquery('english', 'postgres | mysql');
 
-- NOT: exclude a term
SELECT to_tsquery('english', 'postgres & !mysql');
 
-- Phrase search (terms must be adjacent, in order)
SELECT to_tsquery('english', 'postgres <-> performance');
 
-- Proximity (within N words)
SELECT to_tsquery('english', 'postgres <2> performance');

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.

SELECT plainto_tsquery('english', 'postgresql performance tuning');
-- Result: 'postgresql' & 'perform' & 'tune'

websearch_to_tsquery

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

SELECT websearch_to_tsquery('english', 'postgresql "full text" -mysql');
-- 'postgresql' & 'full' <-> 'text' & !'mysql'
-- (the English Snowball stemmer doesn't truncate "postgresql" to "postgres" —
-- there's no matching suffix rule, so it stays as one lexeme)
 
-- Quoted phrases, minus sign for exclusion — familiar to users

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:

CREATE TABLE articles (
  id         BIGSERIAL PRIMARY KEY,
  title      TEXT NOT NULL,
  body       TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

A basic full-text search:

SELECT id, title
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
   @@ websearch_to_tsquery('english', 'postgresql performance');

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

CREATE INDEX idx_articles_fts
ON articles
USING GIN (to_tsvector('english', title || ' ' || body));

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

-- This uses the index:
SELECT id, title
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
   @@ websearch_to_tsquery('english', 'postgresql performance');

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.

ALTER TABLE articles
  ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
  ) STORED;
 
CREATE INDEX idx_articles_search_vector
ON articles
USING GIN (search_vector);

Now queries are clean and simple:

SELECT id, title
FROM articles
WHERE search_vector @@ websearch_to_tsquery('english', 'postgresql performance');

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.

-- Title gets weight A (highest), body gets weight B
ALTER TABLE articles
  ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
  ) STORED;

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

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