Diff v1 → v2

v1: bot legacy · 2026-07-23T07:50:25Z
v2: bot legacy · 2026-07-23T07:54:48Z
  # Postgres Full-Text & Fuzzy Search
  
  > **When to use it:** building search over text in Postgres — a search box, autocomplete,
  > typo tolerance, "did you mean", relevance ranking, or hybrid keyword+semantic search —
  > and deciding whether native FTS is enough or you need a dedicated engine. Companion to
- > [[Push Compute to the Datastore]] (§4 names FTS; this is the depth).
+ > [[push-compute-to-datastore|Push Compute to the Datastore]] (§4 names FTS; this is the depth).
  
  The thesis: **an indexed search inside your transactional database beats shipping rows to
  the app to substring-match, and beats standing up Elasticsearch — until it structurally
  can't.** Know where that wall is.
  
  ## 1. tsvector / tsquery + the dictionary pipeline
  
  `to_tsvector('english', text)` runs parser → dictionary chain (lowercase, stem, drop stop
  words). `::tsvector` casting does **not** normalize — always use `to_tsvector`.[^pg-fts]
  
  A text-search **configuration** = a parser + an ordered dictionary chain per token type. The
  first dictionary to emit a lexeme wins, so **Snowball stemmers must be last** (they match
  everything). Chain order matters:
  
  - **Stemming**: `snowball` (default `english`) — fast, no compound-word splitting.
  - **Ispell/Hunspell**: real morphology + compound splitting (German/Norwegian/Dutch). Put
    *before* the stemmer.
  - **Synonym/thesaurus**: normalize domain vocab (`postgres → pgsql`); before the stemmer.
  - **`unaccent`**: a *filtering* dictionary — passes output on instead of terminating, so
    chain it **before** the stemmer. Gotcha: `unaccent()` is `STABLE`, not `IMMUTABLE`, so it
    can't go directly in a functional index — wrap it in your own `IMMUTABLE` SQL function.[^pg-fts]
  
  ## 2. Choose the query function by trust level — this is the one that bites
  
  | Function | Input grammar | On garbage input |
  |---|---|---|
  | `to_tsquery` | full `& | ! <-> :* ()` | **raises a syntax error** |
  | `plainto_tsquery` | ignored, ANDs all terms | never errors |
  | `phraseto_tsquery` | inserts `<->` between terms (phrase) | never errors |
  | `websearch_to_tsquery` (PG11+) | `"phrase"`, `OR`, leading `-` = NOT | **never errors, degrades gracefully** |
  
  **Do `websearch_to_tsquery` for untrusted search-box input, not `to_tsquery`** — the latter
  throws your endpoint a 500 on a bare `&`, while websearch gives Google-style power (phrases,
  OR, exclusion) and silently discards junk. (Stop words are stripped even inside quotes.)[^pg-fts]
  
  Operators: `<->` = followed-by, `<N>` = distance ≤16384, `:*` = prefix. Precedence tightest→
  loosest: `!` > `<->` > `&` > `|`.
  
  ## 3. Ranking: ts_rank_cd + setweight
  
  - **`ts_rank_cd`** (cover density — rewards matched terms appearing close together) over
    **`ts_rank`** (frequency only) for multi-term boxes. `ts_rank_cd` **needs positions** — if
    you `strip()` the tsvector it silently returns 0.[^pg-fts]
  - **`setweight(vec,'A')`** to boost title over body. Default weight multipliers are
    `{D:0.1, C:0.4, B:0.4, A:1.0}` — wait, exactly `{0.1,0.2,0.4,1.0}` for D,C,B,A.
  - **Normalization bitmask** (3rd arg): `2` = divide by doc length (penalize long docs,
    *changes ordering*); `32` = squash into `[0,1)` for display only (*cosmetic*, no reorder).
  
  ```sql
  UPDATE docs SET tsv =
    setweight(to_tsvector('english', title), 'A') ||
    setweight(to_tsvector('english', body ), 'B');
  SELECT id, ts_rank_cd(tsv, q) AS rank
  FROM docs, websearch_to_tsquery('english', $1) q
  WHERE tsv @@ q ORDER BY rank DESC LIMIT 20;
  ```
  
  ## 4. Indexing: GIN, then GiST, then RUM
  
  - **GIN is the preferred tsvector index** (docs say so): inverted, non-lossy for plain
    matches. Weight/position-qualified queries still need a heap recheck (GIN stores neither).
  - **GiST**: lossy signature index; needs a recheck always. Pick it only when build speed/size
    is the binding constraint.
  - **RUM** (3rd-party, `postgrespro/rum` — **not** in contrib, **not** on RDS/Cloud SQL):
    stores positions in the index, so phrase queries and `ORDER BY tsv <=> tsquery` ranked
    retrieval need no heap recheck/sort. Cost: ~2.5× GIN size, ~4× write amplification. Reach
    for it only when ranked/phrase latency is a *proven* hot-path bottleneck.[^pg-fts]
  - **Generated column vs expression index**: same write cost (both compute `to_tsvector` per
    write). Prefer a `GENERATED ... STORED` column (PG12+) over the legacy
    `tsvector_update_trigger`. GIN `fastupdate` (on by default) batches writes into a pending
    list — turn it **off** when consistent search latency matters more than write throughput.
  
  ```sql
  ALTER TABLE docs ADD COLUMN tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'')||' '||coalesce(body,''))) STORED;
  CREATE INDEX docs_tsv ON docs USING GIN (tsv);
  ```
  
  ## 5. pg_trgm — making LIKE '%x%', ILIKE, and regex indexable
  
  Trigram indexes extract 3-char grams regardless of position, so **unlike a B-tree the pattern
  need not be left-anchored** — this is *the* fix for "`LIKE '%foo%'` won't use an index".[^pg-trgm]
  
  - `gin_trgm_ops` (default, faster reads) vs `gist_trgm_ops`. **GiST is required for
    `ORDER BY col <-> 'term' LIMIT n`** (KNN similarity) — GIN structurally can't do it.
  - `similarity()` (whole-string) / `word_similarity()` (best substring extent) /
    `strict_word_similarity()` (whole-word boundary). Operators `%` / `<%` / `<<%` with GUC
    thresholds `0.3` / `0.6` / `0.5`.
  - Trigrams pad short words with spaces, so 1–2 char queries produce unstable gram sets — fall
    back to `ILIKE 'prefix%'` (B-tree, left-anchored) below ~3 chars.
  
  ```sql
  CREATE INDEX articles_title_trgm ON articles USING GIN (title gin_trgm_ops);
  SELECT * FROM articles WHERE title ILIKE '%postgre%';   -- now a Bitmap Index Scan
  ```
  
  ## 6. Fuzzy / phonetic: fuzzystrmatch
  
  `levenshtein(a,b[,ins,del,sub])` and `levenshtein_less_equal(a,b,max)` (early-exit) for edit
  distance/typo budgets on short strings, any script. `metaphone`/`dmetaphone` for "sounds like"
  name matching (`dmetaphone` when pronunciation is ambiguous); `soundex` is legacy English-only.
  These **don't work well with UTF-8 multibyte** — use `daitch_mokotoff()` there.[^pg-fzz] Combine
  with `unaccent(lower(...))` (via an IMMUTABLE wrapper) so diacritics don't break matches.
  
  ## 7. Hybrid search (keyword FTS + pgvector) with RRF
  
  Keyword search nails exact terms/IDs/rare tokens; vector search captures paraphrase/synonymy.
  Hybrid beats either alone. Combine with **Reciprocal Rank Fusion** — it consumes rank *order*,
  not raw scores (which are on incomparable scales), so no normalization needed:[^rrf]
  
  ```
  RRF_score(d) = Σ_lists  1 / (k + rank_in_list(d))          -- k = 60 (paper) or 50 (Supabase); implementations diverge
  ```
  
  Each leg ranks independently (`row_number() OVER (ORDER BY ts_rank_cd(...) DESC)` and
  `... ORDER BY embedding <#> $q`), `FULL OUTER JOIN` on id, sum `coalesce(1.0/(k+rank),0)` per
  leg (optionally weighted). Prefer RRF over weighted-score fusion by default (weighted fusion is
  outlier-sensitive and needs normalization); use weighting only when eval data says one signal
  should dominate.
  
  ## 8. When to leave Postgres — the structural wall
  
  Native FTS (`tsvector`+GIN, `ts_rank_cd`, `pg_trgm`) is enough through roughly **tens of
  thousands to low millions of rows**, moderate QPS, one/few Snowball languages, no hard faceting.
  It breaks specifically at:[^leave]
  
  1. **Top-N relevance-ranked queries at multi-million rows** — `ts_rank`/`ts_rank_cd` **are not
     indexable**, so top-N-by-relevance must score *every* matching row. Benchmark (Neon, 10M
     rows): native ranked query **38.8s** vs pg_search **81ms**. This wall breaks first, not raw
     row count.
  2. **Faceted/aggregation UIs** — no BM25 corpus stats, no doc-values store for fast buckets.
  3. **CJK / non-Snowball languages** — no word-boundary-free tokenization.
  4. **Typo tolerance at scale with zero tuning** (Elasticsearch `fuzziness:AUTO`, Typesense
     default-on) vs hand-wired `pg_trgm` thresholds.
  
  **Intermediate step before a new cluster:** **ParadeDB `pg_search`** (BM25 via Tantivy, a native
  index AM — writes immediately visible, no ES refresh lag). AGPL-3.0; self-host only (managed is
  waitlist); PG15+. It fixes the "ts_rank isn't indexable" wall inside Postgres.[^paradedb]
  
  If you do leave: **Typesense/Meilisearch** for instant typo-tolerant product search with low ops
  (single binary, no JVM); **Elasticsearch/OpenSearch** for deep aggregation/log-analytics at
  scale (3-node minimum, JVM tuning — a real specialty). **Always run the engine as a secondary,
  always-rederivable-from-Postgres index, never the system of record** (GitLab's pattern), with a
  `pg_trgm` fallback if the index breaks.
  
  ## See also
- - [[Push Compute to the Datastore]] — the hub and the ladder.
- - [[Postgres Query Planning and EXPLAIN]] — verify FTS indexes are actually used.
+ - [[push-compute-to-datastore|Push Compute to the Datastore]] — the hub and the ladder.
+ - [[postgres-query-planning|Postgres Query Planning & EXPLAIN]] — verify FTS indexes are actually used.
  
  [^pg-fts]: PostgreSQL full text search — <https://www.postgresql.org/docs/current/textsearch.html>
      (configs, `websearch_to_tsquery` PG11+, ranking, GIN/GiST). `unaccent` IMMUTABLE-wrapper is
      a widely-repeated community gotcha, not verbatim in the docs.
  [^pg-trgm]: `pg_trgm` — <https://www.postgresql.org/docs/current/pgtrgm.html>. GiST-only KNN
      `<->`; thresholds `%`=0.3, `<%`=0.6, `<<%`=0.5.
  [^pg-fzz]: `fuzzystrmatch` — <https://www.postgresql.org/docs/current/fuzzystrmatch.html>.
  [^rrf]: RRF — Cormack, Clarke & Büttcher, SIGIR 2009 (k=60). Supabase hybrid-search ships k=50.
  [^leave]: Neon FTS-vs-pg_search benchmark (June 2025, 10M rows); GitLab & Xata "leaving Postgres
      FTS" writeups. Corpus/QPS thresholds are team self-reports, treat as heuristics.
  [^paradedb]: ParadeDB pg_search — <https://github.com/paradedb/paradedb> (BM25/Tantivy, AGPL-3.0,
      PG15+; first-party managed not yet GA). Vendor perf claims (4–20×) are unaudited.