Table of Contents
fts indexWITH (positions = on))WITH (trigrams = on))
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"; ordered proximity
NEAR(a b, k) or a p/k b;
proximity in either word order a w/k b, including
(a | b) w/k c; 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).
w/k accepts terms, phrases, OR alternatives and nested
proximity groups. It joins nonoverlapping matches whose nearest edges
are 1..k token positions apart, in either order. Chaining is left
associative; use parentheses to change grouping. AND/NOT operands have
no positional span and are rejected inside text proximity.
Exact <-> and <N>
measure the gap from the left match's end to the right match's start.
<0> permits the same position. Quoted phrases
preserve removed stopword gaps: "breach of contract"
under English becomes 'breach' <2> 'contract'.
A leading - is also accepted as NOT
(a -b excludes b), but
only in prefix position. A -,
. or / that sits between two word
characters is part of the term, so pkg-config,
foo/bar and python3.14 each search for
the word they look like rather than being split into an expression. This
matches how the document side tokenizes them, and PostgreSQL's own parser,
which classifies those as asciihword,
file and file respectively. A trailing
separator is dropped, as it is by to_tsvector:
c++ searches for c.
| 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. The BM25
order, with the index's corpus statistics, is produced only by that
ordering index scan. In any other plan (a Seq Scan
or a Bitmap Heap Scan plus Sort),
and in a select list, <=> falls back to a
corpus-free score (every term weighted as if rare, the document's own
length as the average), and the order can differ. If the order
matters, check the plan with EXPLAIN, or run the
query with enable_seqscan and
enable_bitmapscan off.
|
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 WHERE query and the ORDER BY query
need not be the same. WHERE filters and
ORDER BY only ranks: the scan returns every row the
WHERE clause admits (every @@@
condition on the index applies), ordered by the ORDER BY
query's BM25 score; a row holding none of the ranking terms scores 0
(distance 1) and follows every scored row:
SELECT id FROM docs
WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', '"query planner"')
ORDER BY to_ftsdoc('english', body) <=> to_ftsquery('english', 'index | scan')
LIMIT 10;
The ranking query's boolean operators do not filter here: its terms are
scored, as the <=> operator scores them.
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 (case-folding, stemming,
and stopword removal) so they match the way documents
were analyzed by to_ftsdoc. A term that the
configuration treats as a stopword is dropped from the query — for example
to_ftsquery('english', 'the & postgres') reduces to
just postgres, and a query of only stopwords becomes
empty (matching nothing) — exactly as to_tsquery
behaves, so a stopword can never silently zero out a boolean query.
Prefix (term*), fuzzy (term~k), and
regex (/re/) terms are matched literally and are never
stopword-normalized. Reserved words such as and and
or can be searched as literal terms when quoted.
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, one lookup per
run of matches on the same heap page; the heap is probed only for pages not
marked all-visible.
A single plain term is answered from the dictionary's document frequency alone — no posting decode and no heap access at all — when the index carries no unmerged pending documents, no segment has tombstones, and the whole heap is marked all-visible (typically: after a VACUUM, on a corpus that is not being written). The fast path refuses and falls back to the exact count if any of those conditions fails, or if the query is a prefix, fuzzy, regex, weighted, or multi-term expression. Both paths return the same number; the regression suite asserts that for every gate individually against a heap-only ground truth.
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. Requires ownership of the index and errors on a
read replica (it writes WAL, so it cannot run during recovery).
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. Takes
AccessExclusiveLock on the index (like
REINDEX), requires ownership of the index, and errors
on a read replica (it writes WAL, so it cannot run during recovery).
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: one entry per term occurrence in the
query, in query order (a repeated term takes one entry each time and is
scored each time), as fts_index_df returns them. A
NULL dfs, a NULL element, or a term past the
end of a shorter array is scored with a document frequency of 1.
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. ndocs,
avgdl and fts_index_df count
pending (not yet merged) documents, as the ordering scan does, so
fts_bm25(doc, q, s.ndocs, s.avgdl, fts_index_df(idx, q))
gives the ordering scan's score; fts_index_df returns
one element per term occurrence in the query, and 1 for a term no document
holds. nterms sums the segment dictionaries only: a term
held only by pending documents is not counted until the next merge or
flush, and a term in several segments is counted once per segment.
fts_shared_doclen_stats()
One row per document-length array currently published in shared memory
(see pg_fts.shared_doclen): the database and index
relfilenode it belongs to, the segment's first sidecar block and document
count, state (ready or
building), refcnt (scans currently
reading it), retired (its segment has been merged away;
freed once no scan reads it) and its size in bytes. For monitoring.
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. Any combination of plain terms, phrases,
OR groups, nested proximity, AND and NOT is then answered directly from
those positions, without heap rechecks:
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. Modifier and weighted terms use exact heap rechecks, as does a document whose positions the index could not store (a posting block too large for one page) or that exceed the evaluator's bound -- that document alone. Results agree with either position setting. The earlier introduction of stored positions changed the on-disk format (BM25 v2 → v3) and required REINDEX. The proximity syntax described here does not change that format.
The size of that difference is easy to underestimate. Measured on 2,188,038
Wikipedia articles (16 vCPU, 128 GB, warm cache), with the phrase
"united states" matching 361,465 documents:
| query | default (positions = off) | positions = on |
|---|---|---|
| ranked top-10 | 8,385 ms | 229 ms (36×) |
exact count(*) | 7,170 ms | 132 ms (54×) |
| index size | 1,421 MB | 2,626 MB (1.85×) |
In other words the default is measured in seconds at this
scale, because the adjacency recheck performs a heap probe per candidate. If
an application issues phrase or NEAR queries against a large table, enable
positions = on when the index is created. See
bench/NOTE_PHRASE_PROFILE_2026-09-06.md.
Note that phrase syntax uses double quotes:
to_ftsquery('english', '"united states"') parses to
('unit' <-> 'state'). Single quotes yield a plain
conjunction ('unit' & 'state'), which is a different
(much larger) match set.
Regex (/re/) and long fuzzy (term~k)
queries match many dictionary terms. The index can build a per-segment
trigram tier that narrows the candidate terms for these queries; by default
it is off, and such queries fall back to a full
dictionary scan (always correct, just slower on regex / long fuzzy). Build
WITH (trigrams = on) for a regex- or long-fuzzy-heavy
workload:
CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body))
WITH (trigrams = on);
The trade-off is index size (the trigram tier was ~18 percent of the index in one 2.19M-document measurement). Results are identical whether trigrams are on or off; the option only affects the speed of regex and long fuzzy queries. This is not an on-disk format change — both settings read on any release — so it needs no REINDEX to change (rebuild the index to actually add or drop the tier).
Like tsvector's A/B/C/D weight labels, an ftsdoc can carry per-field provenance so a query term restricts itself to a field (zone). Tag a sub-document with a weight and concatenate the labelled parts:
CREATE INDEX msg_fts ON messages USING fts ((
to_ftsdoc('english', subject, 'A') ||
to_ftsdoc('english', from_addr, 'B') ||
to_ftsdoc('english', body, 'C')
)) WITH (positions = on);
SELECT * FROM messages
WHERE (to_ftsdoc('english', subject, 'A') || to_ftsdoc('english', from_addr, 'B')
|| to_ftsdoc('english', body, 'C'))
@@@ to_ftsquery('english', 'vacuum:A & tgl:B'); -- vacuum in subject, tgl in from
A query term followed by : and one or more of the labels
A, B, C, D (e.g. vacuum:A or x:AB)
matches only occurrences carrying one of those labels — exactly as
to_tsquery('english', 'vacuum:A') does. A term with no
label matches any zone (unchanged behavior). BM25 scoring stays document-
level: a zone filter changes which documents match, not how a matching one
scores.
API.
to_ftsdoc(
tags every token with A/B/C/D;
config, text, weight "char")setftsweight(
relabels an existing document (like doc ftsdoc, weight "char")setweight);
ftsdoc || ftsdoc concatenates labelled sub-documents,
re-basing token positions and preserving each side's labels.
to_ftsdoc( also
carries the tsvector's own weights.
tsvector)
Field restriction needs the document to carry token positions (the labels
ride on positions), so build the index WITH (positions = on)
for positional index queries. Weight labels apply to plain terms and
prefixes (vacuum*:A or vacuum:*A).
Fuzzy+weight combinations and regex modifiers raise a syntax error.
This is not an on-disk index format change: labels live
only in the stored ftsdoc value, and a field-restricted query is
answered via the heap recheck. An index built before weight support (or from
an unlabelled to_ftsdoc) keeps working with no
REINDEX — every position reads as label D, so
term:D matches it and term:A does not,
and unlabelled queries are unchanged. To add field provenance, rebuild that
table's ftsdoc from labelled
to_ftsdoc(...,weight) || ... documents — opt-in per
table, never a forced global reindex.
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.
How much it matters is easy to underestimate, so here it is measured on
2,188,038 Wikipedia articles (16 vCPU, 128 GB;
bench/RESULTS_GATING_2026-09-09.md). “Merge”
is the fts_merge that consolidates whatever the build left:
maintenance_work_mem | build | segments after build | follow-up merge | final index |
|---|---|---|---|---|
| 64 MB (the PostgreSQL default) | 475 s | 8 | 216 s | 8,613 MB |
| 256 MB | 367 s | 6 | 223 s | 5,793 MB |
| 1 GB | 523 s | 1 | none needed | 4,605 MB |
| 2 GB | 527 s | 1 | none needed | 4,323 MB |
At 1 GB and above this corpus builds straight to a
single segment, so the post-build merge disappears entirely — saving both
the ~220 s merge and about 4 GB of transient index size. At the 64 MB
default the same corpus needs 8 segments plus a 216 s merge and lands
twice as large. If a build is followed by a long merge,
raising maintenance_work_mem is the first thing to try.
Parallel builds: faster, same final size.
max_parallel_maintenance_workers speeds a build up
(464 s versus 523 s serial at
maintenance_work_mem = 1GB on 2.19M articles) and, once
fts_vacuum has run, produces an
identically sized index — 1,420 MB either way,
with identical match counts. What a parallel build leaves behind is more
reclaimable residue, not a larger index: immediately after
the build the file measures 5,365 MB versus 4,605 MB serial, and
fts_vacuum collapses both to the same floor. (An earlier
revision of this documentation reported a durable ~17% size penalty; that figure
was measured before vacuuming and is withdrawn.)
The residue is intentional. Merge output is allocated by extending the file so
that a committed merge's freed input pages can never be handed out as the next
merge's output while in-flight read chains still point through them; the
alternative is a wrong read or a crash. Truncation then reclaims only a
contiguous free tail, so pages freed underneath the final
output survive until fts_vacuum's compaction pass relocates
live data toward the front of the file. Live pages are ~98.8% full, so this is
not a packing inefficiency. Since 1.11.0 a plain CREATE INDEX
or REINDEX, which holds the index exclusively, runs that
compaction itself at the end of the build, so the new index is already at its
packed size. After CREATE INDEX
CONCURRENTLY, run fts_vacuum once, and do not judge
the index's size before you do: a concurrent build cannot reuse pages
it freed while other sessions may be reading.
Unattended autovacuum holds the index bounded and reclaims
after deletes; no scheduled maintenance is required. Measured at one
million documents with autovacuum enabled and no manual maintenance whatsoever: five
consecutive cleanups with nothing changed stayed flat at 511 MB, six rounds of
insert-plus-delete churn stayed flat at 875 MB, and after deleting half the table
cleanup brought the index from 875 MB to 106 MB (8.3×) with queries
served throughout and results exact. Truncation is safe while the index is online
under ShareUpdateExclusiveLock, because scans treat an out-of-range
block as end-of-chain.
Earlier releases grew on every vacuum pass. Two fixes on 2026-09-12 (a merge
truncates the free tail it creates; cleanup truncates unconditionally before deciding
whether a fuller repack is worthwhile) cut that by roughly 6×, and a further fix
on 2026-09-13 removed the remainder at small scale: a compaction pass is now skipped
when its free space is not yet reusable. Without that check, a pass running straight
after a merge relocates the live data upward and reclaims nothing, because the pages
the merge just freed are still visible to the pass's own transaction and so fail the
recyclability test. See
bench/RESULTS_P1_SCALE_AB_2026-09-13.md and
bench/RESULTS_SELF_LIMITING_2026-09-12.md.
fts_vacuum remains useful for a one-off
tighter reclaim: it always performs the full vacate-and-pack rather than waiting for
the bloat threshold, reaching roughly 3× smaller than the automatic steady state.
VACUUM's own compaction only packs live data into older free pages
and does not run the extending vacate rewrite (the pack can still extend the file a
little when its room estimate falls short, as segment merges can). Truncating the
tail it frees needs a moment when no other session holds a lock on the index, and a
parallel
VACUUM does not truncate at all. Until then the space stays
reusable, and the next VACUUM that gets the lock returns it.
(From 1.11.0 to 1.14.0, a VACUUM that could not truncate
extended the file instead; fixed in 1.14.1.)
Run it once after a bulk load or a mass delete if you want the index at its floor. It
is not needed to keep the index from growing.
Incremental inserts of LARGE documents can grow the index file
far beyond its settled size. A pending document is stored verbatim, and
one that does not fit in an 8 kB page is indexed immediately as its own
one-document segment. Measured on a Wikipedia corpus, where 33% of documents exceed
that threshold: inserting 200,000 rows into a settled 792 MB / 1,000,000-document
index grew the file to 32 GB before any merge ran.
fts_merge then added only about 7% more, and
fts_vacuum returned the file to 971 MB. Reproduced at a
second scale (250,000 docs + 50,000 inserts: 272 MB → 7.7 GB →
319 MB).
This is corpus-dependent, not a general property: the effect
scales with the fraction of documents larger than a page, so a corpus of short
documents will not show it. If you bulk-insert large documents, run
fts_merge and fts_vacuum periodically rather
than accumulating pending data, and provision disk headroom accordingly. See
bench/RESULTS_C2_INGEST_2026-09-11.md.
Do not raise
max_parallel_maintenance_workers to speed up a merge.
fts_merge can take a parallel path, and it is
slower than the serial one. Measured on 2,188,038
Wikipedia articles, merging an 8-segment 7,185 MB index
(bench/RESULTS_PARALLEL_MERGE_2026-09-08.md):
serial 230.6 s producing 8,606 MB, versus parallel
333.5 s producing 10,229 MB — 1.45x slower
and 19% larger. One worker costs the same as three, so this is a fixed penalty
for taking the parallel path rather than a scaling curve; a merge is
sequential-I/O bound, and per-worker output streams pack pages independently.
Results are unaffected (every run converged to one segment with identical match
counts).
There is also a trap worth knowing: at
max_parallel_maintenance_workers = 8 on the test host the
workers registered, started, and exited within about 2 ms, so the merge
silently ran serially — which is why that setting
looked fast. Do not infer from a fast merge that parallelism helped; check for
parallel workers in pg_stat_activity if it matters.
The safe configuration for merges is the default (leave the setting alone, or
0).
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.
pg_fts is designed to run with little hands-on maintenance and to be safe on a
managed PostgreSQL service (all page changes go through
GenericXLog, so the index is crash-safe and replicates on a
physical standby with no extra configuration). This section is the operator
summary.
Automatic maintenance. Two things happen without operator action. Auto-merge: incremental inserts land in a small pending buffer and are folded into the main segments as they accumulate; a leveled, bounded-fan-in merge runs on the write path so the segment count stays bounded under continuous ingestion (it does not grow without limit). Auto-vacuum: ordinary VACUUM (autovacuum included) removes deleted documents from the index via the access method's bulk-delete + cleanup callbacks, and when the index is substantially bloated its cleanup gently compacts and reclaims space across passes under a lock that does not block reads. For most workloads you never need to call anything by hand.
fts_merge() vs fts_vacuum().
Call fts_merge( to
optimize now: it folds the pending buffer and merges
live segments into one, e.g. after heavy churn, or after a build too large
for index)pg_fts.build_collapse_max_mb (which leaves the
workers' segments unmerged). It compacts the
segment directory but does not shrink the physical file.
Call fts_vacuum( to
reclaim disk: it additionally relocates live pages to
the front of the index file and truncates the free tail back to the operating
system, shrinking an index that has grown larger than its live contents
(after CREATE INDEX CONCURRENTLY, a bulk load, or heavy
update churn) without a REINDEX. A plain
CREATE INDEX or REINDEX already
compacts at the end of the build (1.11.0). A single index)fts_vacuum() call
reclaims most of the space; a second converges to the floor.
Transient space during compaction.
fts_vacuum() and REINDEX rewrite the
live data into fresh pages before freeing the old copy, like a table rewrite:
provision enough free disk for both the old and the new copy transiently.
fts_vacuum() takes AccessExclusiveLock
on the index (like REINDEX); use
REINDEX INDEX CONCURRENTLY if you need the rebuild to stay
online.
Upgrading an index written by 1.10.0 or earlier.
ALTER EXTENSION pg_fts UPDATE does not repair an index that
took writes under 1.10.0 or earlier. If, before the upgrade, an insert was
rolled back or failed part-way, or a row was deleted while its entry was still
pending, that index can return rows that do not match, as exact matches. If a
pending flush ran while rows were being inserted, it can also miss rows.
VACUUM, fts_merge() and fts_vacuum()
repair neither. Rebuild the index with REINDEX INDEX (or
REINDEX INDEX CONCURRENTLY to keep it online) whatever a check
says: the check below finds false rows only for the terms it probes. It must
run as a plain index scan, with sequential and bitmap scans
off: a bitmap of a common term goes lossy and its recheck hides the false rows.
Use plain terms (a prefix, fuzzy, regex or weighted term is rechecked by design
and reads 0), and EXPLAIN it once to confirm an
Index Scan on the fts index. Any non-zero count means it needs
the rebuild (CHANGELOG 1.14.2 has the check for missing rows too):
SET enable_seqscan = off;
SET enable_bitmapscan = off;
SELECT count(*) FROM (
SELECT 1 FROM t
WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'vacuum')
AND NOT fts_match(to_ftsdoc('english', body), to_ftsquery('english', 'vacuum'))
OFFSET 0) s;
Behavior on a read replica.
Reads work normally on a hot standby (the index is fully replicated). The
maintenance functions fts_merge() and
fts_vacuum() write WAL, so they cannot run during
recovery: called on a replica they raise an error
(cannot run during recovery) rather than doing damage.
Run them on the primary; the effect replicates. Both also require the caller
to own the target index.
Privileges.
The value-level API (to_ftsdoc,
to_ftsquery, fts_bm25, the operators
and I/O functions) is available to PUBLIC. Two functions
that emit indexed content by index OID — fts_search
and fts_anomalous_docs — are revoked from
PUBLIC; the index owner and superusers keep access, and an
owner can grant them explicitly (for example GRANT EXECUTE ON
FUNCTION fts_search(regclass, ftsquery, int) TO ).
role
Behavior under continuous ingestion. Inserts are cheap (pending buffer) and periodically merged; the write amplification is that of a tiered/leveled merge (each document's postings are rewritten a bounded number of times as small segments merge into larger ones), and the live segment count stays bounded. Deletes are marked and reclaimed by VACUUM; an old snapshot held open (for example continuous replica feedback) defers that reclaim and defers physical shrink, which is expected — results and corpus statistics stay correct, the index simply holds the deferred space until the horizon advances. Corpus statistics used for scoring count only live documents, so ranking is not biased by not-yet-reclaimed deleted rows.
These settings change only how fast a ranked query runs, never which rows it returns or in what order.
pg_fts.doclen_cache_mb (integer, default 64)
Per-backend memory, in megabytes, for a resident copy of each index's
document lengths (about 2.6 bytes per document), built on the first
ranked query after the index's segments change. With it a document's
length is read with two array lookups instead of a page decode. A
segment that does not fit keeps the page-based lookup. 0
disables it. The arrays are shared by all backends (see
pg_fts.shared_doclen), so this budget is per index
segment, not per backend.
Since 1.12.0 the same budget also holds a per-backend copy of each segment's dictionary index (one entry per dictionary page, about 1 MB on a 2.2-million-document index), so a ranked query finds a term's dictionary page without reading the index's page chain. A segment that does not fit keeps the page-chain lookup; results are identical either way. Changing the setting in a session takes effect on that session's next ranked query.
pg_fts.shared_doclen (boolean, default on)
Keep one copy per server of each index segment's document-length array, in
dynamic shared memory, instead of one per backend. The first ranked query
to need a segment's array builds and publishes it; every other backend
maps the same pages. The copy depends only on the segment, which is never
modified once written, so backends with different snapshots share it
safely; a query reading a segment that a concurrent merge has since
dropped keeps that segment's copy until it finishes. Before this setting
existed every backend held its own copy, and with many concurrent backends
those copies competed for CPU cache: rare-term ranked throughput fell
by about a third between 16 and 64 clients. If shared memory is
unavailable (for example /dev/shm is full) a query
silently uses a private copy instead. Only superusers can change it.
It is a trade, not a strict improvement. With as many clients as cores, private copies can be faster: by 8-21% on cloud hosts where memory access with 4 KiB pages is slow, by about 1% where it is fast. With more clients than cores the shared copy is faster everywhere (2-17% at four times as many clients as cores). The default favours the oversubscribed case; measure both on your hardware if you run at about one client per core.
pg_fts.pending_flush_threshold (integer, default 20000)
When the number of documents in an index's unflushed pending list reaches
this many, the backend doing the insert folds the pending list into a
sorted segment, so a later ranked query does not have to scan it. A
ranked query reads the whole pending list, so without an insert-time
flush its latency grows with the number of inserts since the last
VACUUM or fts_merge() — at a
million pending documents a rare-term top-10 took 205 ms, against
0.22 ms with none pending. The flush takes the index's maintenance
lock only if it is free: if another backend is already flushing or
merging, the inserting backend returns at once and never waits, so inserts
do not serialize. Set to 0 to disable insert-time
flushing (the pre-1.14 behaviour); the pending list is then flushed only
by VACUUM and fts_merge(). The
flush costs about 7% of insert throughput at the default; raise the
threshold to spend less on flushing and more on reads lagging behind
writes, lower it to keep ranked reads fast under a heavy write load.
pg_fts.bestfirst (boolean, default on)
A ranked query on a single term, an AND of terms, or a
phrase reads the score bound of each 128-document block first and visits
blocks best first, stopping as soon as no remaining block can beat the
k-th result. A top-10 for a term in 735,000 documents visits about 30
blocks. For AND and phrases the blocks of the rarest
term are visited and the other terms are looked up. Results are
identical either way; off uses document-order
traversal (block-max WAND, or the exhaustive scoring below).
pg_fts.dense_score_min_df (integer, default 32768)
With pg_fts.bestfirst off, a ranked query on a single
plain term whose postings live in one segment and number at least this
many is scored exhaustively instead of with block-max WAND, which prunes
almost nothing for very common terms. 0 disables it.
pg_fts.lazy_phrase (boolean, default on)
A ranked phrase or NEAR query on an index built
WITH (positions = on) checks each candidate's word
positions as it is ranked, instead of first collecting every document
that contains the phrase. For a top-10 over a phrase with hundreds of
thousands of matches this is several times faster. If a stored block has
no positions (a very long document), the query quietly reruns the
collect-first way. off always collects first.
Before 1.15.0 the configured analyzer inherited PostgreSQL's 16,383-position
cap: every token after the 16,383rd shared one position, so a phrase there
did not match to_ftsdoc('simple', text). 1.15.0 keeps
the true positions by parsing long text in chunks; since 1.15.1 a chunk is
bounded by positions and ends at a verified token boundary of the configured
parser, so markup is not split (1.15.0 split a tag, comment or script body
that straddled a chunk boundary into words).
A thesaurus phrase straddling a chunk boundary is not matched across it.
Values computed by an earlier release keep their positions until they are
recomputed, or the expression index rebuilt.
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 include documents still in the pending write buffer,
scored exactly as they will be after the next flush. A prefix, fuzzy or
regex query is ranked through an exact heap fallback, so documents that
match only an expansion are returned too; ranking uses literal query-term
BM25 scores, so such a match can score zero. Equal scores are ordered
by tuple identifier to keep pagination stable. When the
WHERE query differs from the ORDER BY
query, the rows satisfying every WHERE key are ranked by
the ORDER BY query's terms; a row holding none of those
terms is returned at distance 1, after the ranked rows.
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.