Search and Indexing
Inverted indexes, why a LIKE query is not search, and the ways a search index drifts out of step with the source of truth.
to_tsvector('english', body) @@ websearch_to_tsquery('english', $1)Search answers "which documents are about this", ranked. That is a different question from "which rows match this predicate", and it needs a different data structure: an inverted index, mapping each term to the list of documents containing it, with positions and frequencies.
Why a LIKE query is not search
A leading-wildcard pattern such as ILIKE '%shoe%' cannot use a B-tree index, so it scans. Worse, it is the wrong semantics: no stemming, so running misses run; no stop words; no tokenisation, so a hyphen or a comma defeats it; no relevance, so results come back in whatever order the scan produced. An inverted index is built by an analyser — lowercase, split, stem, drop stop words — and the same analysis is applied to the query, which is why both sides must use the same configuration.
Keeping the index in step
A search index is a derived store, never the source of truth, and it will drift. Dual writes drift whenever the second write fails. The reliable patterns are change data capture or a transactional outbox: the write commits with the database transaction, a stream carries it, and an indexer applies it. Make updates idempotent and carry a version, so a retry or an out-of-order delivery cannot resurrect an older document.
Where to put it
PostgreSQL full-text search is enough for a lot of products and keeps one source of truth. Reach for Elasticsearch or OpenSearch when you need per-field analysers, faceting, fuzziness, custom scoring or search traffic that would otherwise sit on your primary database.
Example
import { Pool } from "pg";
const pool = new Pool();
// The index is maintained by the database, so there is no second system to
// keep in step. Generated columns need PostgreSQL 12 or later:
//
// ALTER TABLE articles ADD COLUMN search tsvector
// GENERATED ALWAYS AS (
// setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
// setweight(to_tsvector('english', coalesce(body, '')), 'B')
// ) STORED;
// CREATE INDEX articles_search_idx ON articles USING GIN (search);
//
// setweight makes a title hit outrank a body hit. GIN is the index type that
// can answer a tsquery; a B-tree on the same column is useless here.
export async function search(q, limit = 20) {
const { rows } = await pool.query(
`SELECT a.id,
a.title,
ts_rank(a.search, query) AS rank,
ts_headline('english', a.body, query) AS snippet
FROM articles AS a,
-- websearch_to_tsquery accepts what users actually type:
-- quoted phrases, -negation, or. Added in PostgreSQL 11.
websearch_to_tsquery('english', $1) AS query
WHERE a.search @@ query -- @@ is "tsvector matches tsquery"
ORDER BY rank DESC, a.id -- id breaks ties, so paging is stable
LIMIT $2`,
[q, limit]
);
return rows;
}When to use it
- Replacing an ILIKE search endpoint that times out as the table grows.
- Deciding whether PostgreSQL full-text search is enough or an Elasticsearch cluster is justified.
- Working out why a document updated a second ago does not appear in search results.
- Designing the pipeline that keeps a search index in step with the database after a write.
More examples
The shorthand query parsers, and the tsquery each produces
Use websearch_to_tsquery for anything a human types, and build a tsquery by hand when the query comes from your own UI — a faceted filter or an autocomplete prefix, where you want :* rather than a parsed phrase. Printing the generated tsquery is also the answer to "why did this search return nothing": usually the analyser stemmed the term differently than expected.
-- Every *_to_tsquery function is shorthand for a tsquery you could write by
-- hand. Printing the result is the fastest way to see what a user's text did.
SELECT websearch_to_tsquery('english', 'cheap "red shoes" -suede');
-- 'cheap' & 'red' <-> 'shoe' & !'suede'
SELECT plainto_tsquery('english', 'red running shoes');
-- 'red' & 'run' & 'shoe' -- every word ANDed, punctuation dropped
SELECT phraseto_tsquery('english', 'red shoes');
-- 'red' <-> 'shoe' -- adjacency, not just co-occurrence
-- The longhand of that last one, written directly as a tsquery:
SELECT 'red' <-> 'shoe' :: tsquery;
-- And the operators the shorthands compile into: & and, | or, ! not,
-- <-> followed by, <2> two positions apart, :* prefix match. These live in
-- tsquery TEXT syntax, so the whole expression goes inside the literal; the
-- SQL-level tsquery operators are spelled &&, || and !! instead.
SELECT to_tsvector('english', 'Red running shoes, size 9')
@@ 'red & run:* & !sandal' :: tsquery; -- trueWhat LIKE costs, and what pg_trgm does and does not fix
Reach for pg_trgm when the requirement is genuinely substring or fuzzy matching — looking up a customer by a half-remembered name, deduplicating addresses. Reach for a tsvector and GIN when the requirement is relevance over prose. Confusing the two is how a product ends up with a search box that is fast and still feels broken.
-- Not indexable: the leading wildcard rules out the B-tree.
EXPLAIN ANALYZE SELECT id FROM articles WHERE body ILIKE '%shoe%';
-- Seq Scan on articles (rows=1200000) ... Execution Time: 4100 ms
-- A trigram index makes substring matching indexable:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_body_trgm ON articles USING GIN (body gin_trgm_ops);
EXPLAIN ANALYZE SELECT id FROM articles WHERE body ILIKE '%shoe%';
-- Bitmap Index Scan on articles_body_trgm ... Execution Time: 38 ms
-- But it is still not search. No stemming: this finds "shoe" and "shoes",
-- never "footwear"; it matches "shoehorn" and "horseshoe" just as happily;
-- and it returns no relevance, so there is nothing to ORDER BY.
-- Trigrams are the right tool for fuzzy name matching and typo tolerance:
SELECT name, similarity(name, 'jonh smith') AS s
FROM customers WHERE name % 'jonh smith' ORDER BY s DESC LIMIT 10;Keeping an external index in step with the database
This is the standard indexer shape. Three details earn their keep: refresh: false so bulk throughput is not destroyed by forcing a refresh per batch, external versioning so replay is safe, and treating 409 as success rather than failure — it means a newer version of the document is already indexed, which is exactly the outcome you wanted.
import { Client } from "@elastic/elasticsearch"; // 9.x
const es = new Client({ node: process.env.ES_URL });
// Driven by CDC or an outbox topic, never by a dual write from the request
// handler: the point is that the index update cannot be lost if the second
// write fails, because it is replayable from the stream.
export async function indexBatch(events) {
const operations = events.flatMap((e) => [
{
index: {
_index: "articles",
_id: String(e.id),
// External versioning makes the write idempotent AND order-proof:
// a replayed or late event with a lower version is rejected with 409
// instead of overwriting newer data. Use the row's updated_at as
// epoch millis, or a monotonic revision column.
version: e.version,
version_type: "external",
},
},
{ title: e.title, body: e.body, updated_at: e.updated_at },
]);
// Client 8 and later name this parameter `operations`; version 7 called the
// same array `body`. The newline-delimited payload it builds is identical.
const res = await es.bulk({ operations, refresh: false });
// bulk returns 200 even when individual items failed. Not checking this is
// the most common cause of an index that is quietly missing documents.
if (res.errors) {
const failed = res.items.filter((i) => i.index?.error &&
i.index.status !== 409);
if (failed.length) throw new Error(JSON.stringify(failed.slice(0, 3)));
}
}
Discussion