pg_fts — BM25 full-text search for PostgreSQL


Table of Contents

pg_fts — BM25 full-text search
Data types
Operators
The fts index
Functions
Example
Index-only phrase and NEAR (WITH (positions = on))
Building indexes on large or high-vocabulary corpora
Limitations
Authors

pg_fts — BM25 full-text search

pg_fts provides full-text search with Okapi BM25 relevance ranking. It adds two data types — ftsdoc (an analyzed document) and ftsquery (a parsed query) — the @@@ match operator and the <=> relevance-distance operator, and a dedicated fts index access method that answers both.

Unlike the built-in tsvector/tsquery stack with a GIN index, pg_fts maintains the corpus statistics that BM25 ranking requires (document count, average document length, and per-term document frequency) inside the index, and its posting lists carry the term frequency and document length needed to score a match — so relevance ranking is computed from the index without re-reading the heap. This makes ranked top-k retrieval (ORDER BY doc <=> query LIMIT k) an index scan that stops early, rather than a scan-and-sort of every match.

Caution

This module is under active development. Its on-disk format and SQL interface may change between versions; an ALTER EXTENSION pg_fts UPDATE that changes the on-disk format requires a REINDEX of existing fts indexes.

Data types

ftsdoc

An analyzed document: a sorted list of terms, each with its term frequency and token positions, plus the document length. Produced from text with to_ftsdoc. A fts index stores the analyzed postings derived from an ftsdoc, not the original text.

ftsquery

A parsed query. Supports boolean & (AND), | (OR), ! (NOT); quoted phrases "a b c"; NEAR(a b, k) proximity; prefix term*; fuzzy term~k (edit distance k, default 2); and regular expressions /re/. Produced with to_ftsquery or the input syntax ('a & b'::ftsquery).

Operators

OperatorDescription
ftsdoc @@@ ftsquerybooleanDoes the document match the query?
ftsdoc <=> ftsqueryfloat8 Relevance distance 1/(1+score) (a smaller distance is a higher BM25 score). Used in ORDER BY to rank; the fts index answers this as an ordering scan.

The fts index

Create an index over an ftsdoc expression:

CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body));

The expression form is the recommended external content model: the text lives in the table, and the index derives the analyzed ftsdoc from it, so no document copy is stored in the index.

The index answers a boolean match as a bitmap scan:

SELECT count(*) FROM docs
  WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'postgres & index');

and a ranked top-k as an ordering index scan with no Sort node:

SELECT id FROM docs
  WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'postgres')
  ORDER BY to_ftsdoc('english', body) <=> to_ftsquery('english', 'postgres')
  LIMIT 10;

The index is a set of immutable segments plus a small pending write buffer. An INSERT appends to the pending buffer and is immediately searchable without a REINDEX. A flush — performed automatically by VACUUM, or on demand by fts_merge — folds pending documents into a segment; a size-tiered merge coalesces segments and physically drops tombstoned (deleted) documents. All page writes go through GenericXLog, so the index is crash-safe and replicated on a physical standby. Deletes are handled MVCC-correctly: VACUUM records a per-segment tombstone, and scans and counts exclude tombstoned documents.

Note

The fts index is not covering (it stores postings, not the source document), so it does not support index-only scans. A fast, visibility-map-aware count is available as fts_count.

Functions

to_ftsdoc([config regconfig, ] text text)ftsdoc

Analyze text into an ftsdoc. With a config, the text is parsed and normalized through that text search configuration (stemming, stop words); without one, a simple whitespace/fold analysis is used.

to_ftsquery([config regconfig, ] text text)ftsquery

Parse a query string. With a config, query terms are normalized through that configuration so they match the way documents were analyzed.

fts_count(index regclass, query ftsquery)bigint

Count the documents matching query using the given fts index, in bulk, without per-row executor overhead. Visible rows are counted via the visibility map; the heap is probed only for pages not marked all-visible.

fts_search(index regclass, query ftsquery, k int DEFAULT 10) → setof record

Return the top k visible documents by BM25 score as (ctid, score) rows, computed from the index.

fts_anomalous_docs(index regclass, k int DEFAULT 100, max_df int DEFAULT NULL) → setof record

Return the top k most lexically anomalous documents in the index — those containing globally rare terms — as (ctid, score, rarest_term, min_df) rows. A document's score is the maximum IDF over its terms (driven by its single rarest term). The scan walks only the low-document-frequency tail of the dictionary, skipping any term whose global document frequency exceeds max_df before decoding a posting; max_df defaults to a small fraction of the corpus (max(N/1000, 1)) when omitted. This is a lexical (not semantic) heuristic; the returned ctids are index-resident heap pointers (join back and filter for visibility if needed), and per-segment tombstones are honored.

fts_merge(index regclass)boolean

Flush the pending write buffer into a segment and merge every live segment into one now, instead of waiting for VACUUM. Returns whether any work was done. This compacts the segment directory but does not shrink the physical file; use fts_vacuum to reclaim disk space.

fts_vacuum(index regclass)boolean

Flush pending documents, compact to a single segment, and reclaim the physical space of superseded blocks by relocating live pages to the front of the index file and truncating the free tail back to the operating system — shrinking an index that has grown larger than its live contents (for example after a bulk build or heavy update churn), without a REINDEX. Returns whether any work was done. A single call reclaims most of the space; a second call converges to the fully compacted size. Runs automatically during VACUUM when the index is substantially bloated.

fts_bm25(doc ftsdoc, query ftsquery, n_docs float8, avgdl float8, dfs float8[] DEFAULT NULL)float8

The BM25 score of doc for query given the corpus size n_docs, average document length avgdl, and per-term document frequencies dfs. fts_bm25_opts exposes the tuning knobs and selectable variants (lucene, robertson, atire, bm25+, bm25l) matching the rank_bm25 reference implementations.

fts_bm25f(docs ftsdoc[], query ftsquery, ...)float8

The BM25F score across multiple fields (for example title and body) with per-field weights.

fts_index_stats(index regclass), fts_index_df(index regclass, query ftsquery), fts_index_nsegments(index regclass)

Introspect the index: corpus statistics (document count, average length, distinct terms); the per-term document frequencies used for IDF; and the current live segment count.

fts_highlight(doc text, query ftsquery, ...), fts_snippet(doc text, query ftsquery, ...)

Result presentation: wrap matched query terms in the source text, and return the best-matching window of the text.

tsquery_to_ftsquery(query tsquery)ftsquery

Convert a tsquery to an equivalent ftsquery (boolean operators and the <-> phrase operator are carried over). There is also an assignment cast, so an existing tsquery value can be used with @@@. This is a migration aid; queries, index DDL, and ranking calls must still be rewritten to the pg_fts API — it is not a transparent replacement for the tsvector stack.

Example

CREATE EXTENSION pg_fts;

CREATE TABLE docs (id serial PRIMARY KEY, body text);
INSERT INTO docs (body) VALUES
  ('the quick brown fox'),
  ('a quick red fox jumps'),
  ('lazy brown dogs sleep');

CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body));

-- boolean match
SELECT id FROM docs
  WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'quick & fox');

-- ranked top-2 by relevance
SELECT id FROM docs
  WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'fox')
  ORDER BY to_ftsdoc('english', body) <=> to_ftsquery('english', 'fox')
  LIMIT 2;

-- fast count
SELECT fts_count('docs_bm25', to_ftsquery('english', 'brown'));

Index-only phrase and NEAR (WITH (positions = on))

By default the bm25 index stores no token positions, so a phrase ("a b") or NEAR query generates the term-conjunction candidate set and then rechecks adjacency against the heap document, re-deriving the ftsdoc for each candidate. For a common two-word phrase whose conjunction set is large, that recheck dominates.

Building the index WITH (positions = on) stores per-token positions in the posting lists, so phrase and NEAR are answered directly from the index -- with no heap access and no recheck -- bringing phrase count and match to posting-scan speed:

CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body))
  WITH (positions = on);

The trade-off is index size: positions roughly double the posting bytes on high-term-frequency corpora (little effect when terms occur once per document). Positions are decoded lazily, so plain ranked / boolean / count queries are unaffected whether positions are on or off. Phrase and NEAR are always correct either way; positions = on only makes them fast (index-only). This is an on-disk format change (BM25 v2 → v3); an index built by an older release must be REINDEXed.

Building indexes on large or high-vocabulary corpora

Building an index over a large corpus of long, high-vocabulary documents (for example full email bodies or source code — many distinct, low-frequency terms per document) has three cost drivers: build memory, build time (dominated by per-document text analysis), and merge time.

Build time and throughput. The largest cost on a long-document corpus is usually the per-document text analysis itself (tokenizing and, for a language configuration such as english, stemming every token). This work is inherent to the text-search configuration, is proportional to the total token count, and for very long documents dominates everything the index does. It is also embarrassingly parallel: set max_parallel_maintenance_workers (and enough max_parallel_workers / max_worker_processes) so the analysis runs across cores — on a many-core host this is the single biggest reduction in wall-clock build time. Keep the memory formula above in mind when choosing the worker count (each participant holds its own budget). A serial build (max_parallel_maintenance_workers = 0) analyzes documents one at a time and, on a multi-gigabyte corpus, can legitimately run for a long time before the first segment is flushed — the buffer fills only after a whole budget's worth of (large) documents. During that window the segment count does not change and nothing is written yet; the build emits a LOG-level progress line as documents are analyzed and at each segment flush (set log_min_messages to log or lower to see them), so a long build can be distinguished from a stuck one.

Memory. Peak build memory is bounded by roughly shared_buffers + (max_parallel_maintenance_workers + 1) x 2 x maintenance_work_mem. Size maintenance_work_mem and max_parallel_maintenance_workers so that figure fits your host (and any cgroup MemoryMax). A larger maintenance_work_mem also flushes fewer, larger segments during the scan, which reduces the amount of merging afterwards — a good trade when you have RAM to spare.

Fewer segments on a very large build. A build flushes a new segment each time a participant's in-memory buffer reaches its budget; by default that budget grows only up to 2 x maintenance_work_mem, so a very large corpus can produce many segments. If you have spare RAM and want fewer, larger segments (less post-scan merging, and staying comfortably under the internal 128-segment limit), raise pg_fts.build_mem_ceiling_mb (the per-participant flush-budget ceiling, in MB; 0 keeps the default). Peak build memory is then about shared_buffers + (max_parallel_maintenance_workers + 1) x pg_fts.build_mem_ceiling_mb — size it against your host's free RAM.

Merge / finalization. After the scan the build compacts the many per-worker segments. Rather than always collapsing to a single segment (a single-backend pass over the whole index that can run for a long time on a very large corpus), a build whose total index size exceeds pg_fts.build_collapse_max_mb (default 4096 MB) stops at a bounded, size-tiered set of segments. The index is valid and fully queryable; ranked scans traverse a bounded handful of segments (a small, fixed cost). Run fts_merge(index) in a maintenance window to collapse to one segment when you want the smallest index and the lowest ranked-scan latency. Set pg_fts.build_collapse_max_mb to 0 to always collapse (regardless of size), or raise it if you have the time and memory to collapse a large index during the build itself.

Time, and planning a window. The scan phase parallelizes across max_parallel_maintenance_workers; the final single-segment collapse (whether at the end of a build under the cap, or an explicit fts_merge) is a single-backend pass that reads and rewrites the whole index once, so its cost is roughly linear in the index size. As an order-of-magnitude planning figure, that collapse writes on the order of tens of MB of index per second per core (measured ~40-60 MB/s on a commodity NVMe box; your hardware and vocabulary will vary), so a multi-GB index takes minutes, not hours — but budget for it in a maintenance window and watch the progress (below). If a build must be predictable on a modest box, a serial or low-parallelism build (max_parallel_maintenance_workers = 0 or a small value) trades throughput for a smaller, more predictable peak memory and segment count; combined with leaving the index tiered (the default above the cap) it completes in a bounded, observable time rather than one open-ended collapse.

Monitoring a build. The merge phase is not covered by pg_stat_progress_create_index (its blocks_done freezes once the scan finishes). Poll fts_index_nsegments(index) and fts_index_stats(index) (both work on an in-progress indisvalid = f index) to watch the segment count fall and the doc/term counts grow as merges complete, and set client_min_messages = debug1 (or log_min_messages = debug1) to see per-merge progress lines (merging N of M segments ... wrote merged segment (T terms, D docs) in S s).

Partitioning. For a very large corpus you can partition the table and build a per-partition fts index; each partition's build and merge are correspondingly smaller. A query with a partitionwise plan fans out across the per-partition indexes and each is scored against its own partition's corpus statistics. BM25 scores are therefore comparable within a partition; if you need a single global ranking across partitions, prefer one whole-corpus index and the tiered-build behavior above.

Limitations

  • No index-only scan (the index is not covering); use fts_count for a fast count.

  • Query execution (scan) is single-threaded (no parallel scan). The index build is parallel (amcanbuildparallel).

  • Ranked results cover flushed segments; documents still in the pending write buffer are found by @@@ and counted by fts_count, but are ranked by <=> only after the next flush (VACUUM or fts_merge).

  • fts_merge and the automatic merge compact the index logically (fewer segments, tombstones dropped) but do not shrink the physical file; use REINDEX to reclaim space.

Authors

Gregory Burd.