Table of Contents
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.
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.
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.
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).
| Operator | Description |
|---|---|
ftsdoc @@@ ftsquery → boolean | Does the document match the query? |
ftsdoc <=> ftsquery → float8 |
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.
|
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.
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.
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) → booleanFlush 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, ...) → float8The 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.
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'));
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 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( in a
maintenance window to collapse to one segment when you want the smallest
index and the lowest ranked-scan latency. Set
index)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(
and index)fts_index_stats(
(both work on an in-progress index)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.
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.