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.

Syntaxto_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

Example · javascript
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.

Example · sql
-- 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;   -- true

What 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.

Example · sql
-- 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.

Example · javascript
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

  • Be the first to comment on this lesson.