<!-- doc/pg_fts.sgml -->

<sect1 id="pgfts" xreflabel="pg_fts">
 <title>pg_fts &mdash; BM25 full-text search</title>

 <indexterm zone="pgfts">
  <primary>pg_fts</primary>
 </indexterm>

 <para>
  <filename>pg_fts</filename> provides full-text search with
  <ulink url="https://en.wikipedia.org/wiki/Okapi_BM25">Okapi BM25</ulink>
  relevance ranking.  It adds two data types &mdash; <type>ftsdoc</type> (an
  analyzed document) and <type>ftsquery</type> (a parsed query) &mdash; the
  <literal>@@@</literal> match operator and the <literal>&lt;=&gt;</literal>
  relevance-distance operator, and a dedicated <literal>fts</literal> index
  access method that answers both.
 </para>

 <para>
  Unlike the built-in <type>tsvector</type>/<type>tsquery</type> stack with a
  GIN index, <filename>pg_fts</filename> 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 &mdash; so relevance
  ranking is computed from the index without re-reading the heap.  This makes
  ranked top-<replaceable>k</replaceable> retrieval
  (<literal>ORDER BY doc &lt;=&gt; query LIMIT k</literal>) an index scan that
  stops early, rather than a scan-and-sort of every match.
 </para>

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

 <sect2 id="pgfts-types">
  <title>Data types</title>

  <variablelist>
   <varlistentry>
    <term><type>ftsdoc</type></term>
    <listitem>
     <para>
      An analyzed document: a sorted list of terms, each with its term
      frequency and token positions, plus the document length.  Produced from
      text with <function>to_ftsdoc</function>.  A <literal>fts</literal>
      index stores the analyzed postings derived from an <type>ftsdoc</type>,
      not the original text.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><type>ftsquery</type></term>
    <listitem>
     <para>
      A parsed query.  Supports boolean <literal>&amp;</literal>
      (AND), <literal>|</literal> (OR), <literal>!</literal> (NOT); quoted
      phrases <literal>"a b c"</literal>; <literal>NEAR(a b, k)</literal>
      proximity; prefix <literal>term*</literal>; fuzzy
      <literal>term~k</literal> (edit distance <replaceable>k</replaceable>,
      default&#160;2); and regular expressions <literal>/re/</literal>.
      Produced with <function>to_ftsquery</function> or the input syntax
      (<literal>'a &amp; b'::ftsquery</literal>).
     </para>
     <para>
      A leading <literal>-</literal> is also accepted as NOT
      (<literal>a -b</literal> excludes <literal>b</literal>), but
      <emphasis>only in prefix position</emphasis>.  A <literal>-</literal>,
      <literal>.</literal> or <literal>/</literal> that sits between two word
      characters is part of the term, so <literal>pkg-config</literal>,
      <literal>foo/bar</literal> and <literal>python3.14</literal> 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 <literal>asciihword</literal>,
      <literal>file</literal> and <literal>file</literal> respectively.  A trailing
      separator is dropped, as it is by <function>to_tsvector</function>:
      <literal>c++</literal> searches for <literal>c</literal>.
     </para>
    </listitem>
   </varlistentry>
  </variablelist>
 </sect2>

 <sect2 id="pgfts-operators">
  <title>Operators</title>

  <informaltable>
   <tgroup cols="2">
    <thead>
     <row><entry>Operator</entry><entry>Description</entry></row>
    </thead>
    <tbody>
     <row>
      <entry><literal>ftsdoc @@@ ftsquery</literal> &rarr; <type>boolean</type></entry>
      <entry>Does the document match the query?</entry>
     </row>
     <row>
      <entry><literal>ftsdoc &lt;=&gt; ftsquery</literal> &rarr; <type>float8</type></entry>
      <entry>
       Relevance distance <literal>1/(1+score)</literal> (a smaller distance is
       a higher BM25 score).  Used in <literal>ORDER BY</literal> to rank; the
       <literal>fts</literal> index answers this as an ordering scan.
      </entry>
     </row>
    </tbody>
   </tgroup>
  </informaltable>
 </sect2>

 <sect2 id="pgfts-index">
  <title>The <literal>fts</literal> index</title>

  <para>
   Create an index over an <type>ftsdoc</type> expression:
<programlisting>
CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body));
</programlisting>
   The expression form is the recommended <quote>external content</quote>
   model: the text lives in the table, and the index derives the analyzed
   <type>ftsdoc</type> from it, so no document copy is stored in the index.
  </para>

  <para>
   The index answers a boolean match as a bitmap scan:
<programlisting>
SELECT count(*) FROM docs
  WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'postgres &amp; index');
</programlisting>
   and a ranked top-<replaceable>k</replaceable> as an ordering index scan with
   no <literal>Sort</literal> node:
<programlisting>
SELECT id FROM docs
  WHERE to_ftsdoc('english', body) @@@ to_ftsquery('english', 'postgres')
  ORDER BY to_ftsdoc('english', body) &lt;=&gt; to_ftsquery('english', 'postgres')
  LIMIT 10;
</programlisting>
  </para>

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

  <note>
   <para>
    The <literal>fts</literal> 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 <function>fts_count</function>.
   </para>
  </note>
 </sect2>

 <sect2 id="pgfts-functions">
  <title>Functions</title>

  <variablelist>
   <varlistentry>
    <term><function>to_ftsdoc(<optional><replaceable>config</replaceable> regconfig, </optional> <replaceable>text</replaceable> text)</function> &rarr; <type>ftsdoc</type></term>
    <listitem>
     <para>
      Analyze text into an <type>ftsdoc</type>.  With a
      <replaceable>config</replaceable>, the text is parsed and normalized
      through that text search configuration (stemming, stop words); without
      one, a simple whitespace/fold analysis is used.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>to_ftsquery(<optional><replaceable>config</replaceable> regconfig, </optional> <replaceable>text</replaceable> text)</function> &rarr; <type>ftsquery</type></term>
    <listitem>
     <para>
      Parse a query string.  With a <replaceable>config</replaceable>, query
      terms are normalized through that configuration (case-folding, stemming,
      and <emphasis>stopword removal</emphasis>) so they match the way documents
      were analyzed by <function>to_ftsdoc</function>.  A term that the
      configuration treats as a stopword is dropped from the query — for example
      <literal>to_ftsquery('english', 'the &amp; postgres')</literal> reduces to
      just <literal>postgres</literal>, and a query of only stopwords becomes
      empty (matching nothing) — exactly as <function>to_tsquery</function>
      behaves, so a stopword can never silently zero out a boolean query.
      Prefix (<literal>term*</literal>), fuzzy (<literal>term~k</literal>), and
      regex (<literal>/re/</literal>) terms are matched literally and are never
      stopword-normalized.  Note that <literal>and</literal>,
      <literal>or</literal>, <literal>not</literal>, and <literal>near</literal>
      are reserved query operators and cannot currently be searched for as
      literal words (even quoted); this is a rare limitation for natural-language
      corpora, where they are stopwords anyway.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_count(<replaceable>index</replaceable> regclass, <replaceable>query</replaceable> ftsquery)</function> &rarr; <type>bigint</type></term>
    <listitem>
     <para>
      Count the documents matching <replaceable>query</replaceable> using the
      given <literal>fts</literal> 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.
     </para>
     <para>
      A <emphasis>single plain term</emphasis> is answered from the dictionary's
      document frequency alone &mdash; no posting decode and no heap access at all
      &mdash; when the index carries no unmerged pending documents, no segment has
      tombstones, and the whole heap is marked all-visible (typically: after a
      <command>VACUUM</command>, 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.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_search(<replaceable>index</replaceable> regclass, <replaceable>query</replaceable> ftsquery, <replaceable>k</replaceable> int DEFAULT 10)</function> &rarr; setof record</term>
    <listitem>
     <para>
      Return the top <replaceable>k</replaceable> visible documents by BM25
      score as (<literal>ctid</literal>, <literal>score</literal>) rows,
      computed from the index.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_anomalous_docs(<replaceable>index</replaceable> regclass, <replaceable>k</replaceable> int DEFAULT 100, <replaceable>max_df</replaceable> int DEFAULT NULL)</function> &rarr; setof record</term>
    <listitem>
     <para>
      Return the top <replaceable>k</replaceable> most lexically anomalous
      documents in the index &mdash; those containing globally rare terms &mdash;
      as (<literal>ctid</literal>, <literal>score</literal>,
      <literal>rarest_term</literal>, <literal>min_df</literal>) 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
      <replaceable>max_df</replaceable> before decoding a posting;
      <replaceable>max_df</replaceable> defaults to a small fraction of the
      corpus (<literal>max(N/1000, 1)</literal>) when omitted.  This is a lexical
      (not semantic) heuristic; the returned <literal>ctid</literal>s are
      index-resident heap pointers (join back and filter for visibility if
      needed), and per-segment tombstones are honored.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_merge(<replaceable>index</replaceable> regclass)</function> &rarr; <type>boolean</type></term>
    <listitem>
     <para>
      Flush the pending write buffer into a segment and merge every live
      segment into one now, instead of waiting for <command>VACUUM</command>.
      Returns whether any work was done.  This compacts the segment directory
      but does not shrink the physical file; use <function>fts_vacuum</function>
      to reclaim disk space.  Requires ownership of the index and errors on a
      read replica (it writes WAL, so it cannot run during recovery).
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_vacuum(<replaceable>index</replaceable> regclass)</function> &rarr; <type>boolean</type></term>
    <listitem>
     <para>
      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 &mdash; shrinking an index that has grown larger than its live
      contents (for example after a bulk build or heavy update churn), without
      a <command>REINDEX</command>.  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 <command>VACUUM</command>
      when the index is substantially bloated.  Takes
      <literal>AccessExclusiveLock</literal> on the index (like
      <command>REINDEX</command>), requires ownership of the index, and errors
      on a read replica (it writes WAL, so it cannot run during recovery).
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_bm25(<replaceable>doc</replaceable> ftsdoc, <replaceable>query</replaceable> ftsquery, <replaceable>n_docs</replaceable> float8, <replaceable>avgdl</replaceable> float8, <replaceable>dfs</replaceable> float8[] DEFAULT NULL)</function> &rarr; <type>float8</type></term>
    <listitem>
     <para>
      The BM25 score of <replaceable>doc</replaceable> for
      <replaceable>query</replaceable> given the corpus size
      <replaceable>n_docs</replaceable>, average document length
      <replaceable>avgdl</replaceable>, and per-term document frequencies
      <replaceable>dfs</replaceable>.  <function>fts_bm25_opts</function>
      exposes the tuning knobs and selectable variants (<literal>lucene</literal>,
      <literal>robertson</literal>, <literal>atire</literal>,
      <literal>bm25+</literal>, <literal>bm25l</literal>) matching the
      <literal>rank_bm25</literal> reference implementations.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_bm25f(<replaceable>docs</replaceable> ftsdoc[], <replaceable>query</replaceable> ftsquery, ...)</function> &rarr; <type>float8</type></term>
    <listitem>
     <para>
      The BM25F score across multiple fields (for example title and body) with
      per-field weights.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_index_stats(<replaceable>index</replaceable> regclass)</function>, <function>fts_index_df(<replaceable>index</replaceable> regclass, <replaceable>query</replaceable> ftsquery)</function>, <function>fts_index_nsegments(<replaceable>index</replaceable> regclass)</function></term>
    <listitem>
     <para>
      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.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>fts_highlight(<replaceable>doc</replaceable> text, <replaceable>query</replaceable> ftsquery, ...)</function>, <function>fts_snippet(<replaceable>doc</replaceable> text, <replaceable>query</replaceable> ftsquery, ...)</function></term>
    <listitem>
     <para>
      Result presentation: wrap matched query terms in the source text, and
      return the best-matching window of the text.
     </para>
    </listitem>
   </varlistentry>

   <varlistentry>
    <term><function>tsquery_to_ftsquery(<replaceable>query</replaceable> tsquery)</function> &rarr; <type>ftsquery</type></term>
    <listitem>
     <para>
      Convert a <type>tsquery</type> to an equivalent <type>ftsquery</type>
      (boolean operators and the <literal>&lt;-&gt;</literal> phrase operator
      are carried over).  There is also an assignment cast, so an existing
      <type>tsquery</type> value can be used with <literal>@@@</literal>.  This
      is a migration aid; queries, index DDL, and ranking calls must still be
      rewritten to the <filename>pg_fts</filename> API &mdash; it is not a
      transparent replacement for the <type>tsvector</type> stack.
     </para>
    </listitem>
   </varlistentry>
  </variablelist>
 </sect2>

 <sect2 id="pgfts-example">
  <title>Example</title>

<programlisting>
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 &amp; 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) &lt;=&gt; to_ftsquery('english', 'fox')
  LIMIT 2;

-- fast count
SELECT fts_count('docs_bm25', to_ftsquery('english', 'brown'));
</programlisting>
 </sect2>

 <sect2 id="pgfts-positions">
  <title>Index-only phrase and NEAR (<literal>WITH (positions = on)</literal>)</title>

  <para>
   By default the bm25 index stores no token positions, so a phrase
   (<literal>"a b"</literal>) or NEAR query generates the term-conjunction
   candidate set and then rechecks adjacency against the heap document,
   re-deriving the <type>ftsdoc</type> for each candidate.  For a common
   two-word phrase whose conjunction set is large, that recheck dominates.
  </para>
  <para>
   Building the index <literal>WITH (positions = on)</literal> 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:
<programlisting>
CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body))
  WITH (positions = on);
</programlisting>
   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 <emphasis>correct</emphasis> either way; <literal>positions = on</literal>
   only makes them <emphasis>fast</emphasis> (index-only).  This is an on-disk
   format change (BM25 v2 &rarr; v3); an index built by an older release must be
   <command>REINDEX</command>ed.
  </para>
  <para>
   The size of that difference is easy to underestimate.  Measured on 2,188,038
   Wikipedia articles (16 vCPU, 128&#160;GB, warm cache), with the phrase
   <literal>"united states"</literal> matching 361,465 documents:
<informaltable>
 <tgroup cols="3">
  <thead>
   <row><entry>query</entry><entry>default (<literal>positions = off</literal>)</entry><entry><literal>positions = on</literal></entry></row>
  </thead>
  <tbody>
   <row><entry>ranked top-10</entry><entry>8,385 ms</entry><entry><emphasis>229 ms</emphasis> (36&#215;)</entry></row>
   <row><entry>exact <function>count(*)</function></entry><entry>7,170 ms</entry><entry><emphasis>132 ms</emphasis> (54&#215;)</entry></row>
   <row><entry>index size</entry><entry>1,421 MB</entry><entry>2,626 MB (1.85&#215;)</entry></row>
  </tbody>
 </tgroup>
</informaltable>
   In other words the default is measured in <emphasis>seconds</emphasis> 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
   <literal>positions = on</literal> when the index is created.  See
   <filename>bench/NOTE_PHRASE_PROFILE_2026-09-06.md</filename>.
  </para>
  <para>
   Note that phrase syntax uses <emphasis>double</emphasis> quotes:
   <literal>to_ftsquery('english', '"united states"')</literal> parses to
   <literal>('unit' &lt;-&gt; 'state')</literal>.  Single quotes yield a plain
   conjunction <literal>('unit' &amp; 'state')</literal>, which is a different
   (much larger) match set.
  </para>
 </sect2>

 <sect2 id="pgfts-trigrams">
  <title>Regex and long-fuzzy acceleration (<literal>WITH (trigrams = on)</literal>)</title>

  <para>
   Regex (<literal>/re/</literal>) and long fuzzy (<literal>term~k</literal>)
   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 <emphasis>off</emphasis>, and such queries fall back to a full
   dictionary scan (always correct, just slower on regex / long fuzzy).  Build
   <literal>WITH (trigrams = on)</literal> for a regex- or long-fuzzy-heavy
   workload:
<programlisting>
CREATE INDEX docs_bm25 ON docs USING fts (to_ftsdoc('english', body))
  WITH (trigrams = on);
</programlisting>
   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 &mdash; both settings read on any
   release &mdash; so it needs no <command>REINDEX</command> to change (rebuild
   the index to actually add or drop the tier).
  </para>
 </sect2>

 <sect2 id="pgfts-field-zones">
  <title>Field-targeted search (weight zones)</title>

  <para>
   Like <type>tsvector</type>'s A/B/C/D weight labels, an
   <type>ftsdoc</type> 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:
<programlisting>
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 &amp; tgl:B');   -- vacuum in subject, tgl in from
</programlisting>
   A query term followed by <literal>:</literal> and one or more of the labels
   A, B, C, D (e.g. <literal>vacuum:A</literal> or <literal>x:AB</literal>)
   matches only occurrences carrying one of those labels &mdash; exactly as
   <function>to_tsquery('english', 'vacuum:A')</function> 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.
  </para>
  <formalpara>
   <title>API</title>
   <para>
    <function>to_ftsdoc(<replaceable>config</replaceable>, <replaceable>text</replaceable>, <replaceable>weight</replaceable> "char")</function>
    tags every token with A/B/C/D;
    <function>setftsweight(<replaceable>doc</replaceable> ftsdoc, <replaceable>weight</replaceable> "char")</function>
    relabels an existing document (like <function>setweight</function>);
    <literal>ftsdoc || ftsdoc</literal> concatenates labelled sub-documents,
    re-basing token positions and preserving each side's labels.
    <function>to_ftsdoc(<replaceable>tsvector</replaceable>)</function> also
    carries the <type>tsvector</type>'s own weights.
   </para>
  </formalpara>
  <para>
   Field restriction needs the document to carry token positions (the labels
   ride on positions), so build the index <literal>WITH (positions = on)</literal>
   for field-restricted ranked or count queries.  Weight labels apply to plain
   terms; combining a label with a prefix, fuzzy, or regex term
   (<literal>vacuum:A*</literal>) is a syntax error.
  </para>
  <para>
   This is <emphasis>not</emphasis> an on-disk index format change: labels live
   only in the stored <type>ftsdoc</type> value, and a field-restricted query is
   answered via the heap recheck.  An index built before weight support (or from
   an unlabelled <function>to_ftsdoc</function>) keeps working with no
   <command>REINDEX</command> &mdash; every position reads as label D, so
   <literal>term:D</literal> matches it and <literal>term:A</literal> does not,
   and unlabelled queries are unchanged.  To add field provenance, rebuild that
   table's <type>ftsdoc</type> from labelled
   <literal>to_ftsdoc(...,weight) || ...</literal> documents &mdash; opt-in per
   table, never a forced global reindex.
  </para>
 </sect2>

 <sect2 id="pgfts-large-builds">
  <title>Building indexes on large or high-vocabulary corpora</title>

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

  <para>
   <emphasis role="bold">Build time and throughput.</emphasis> The largest cost
   on a long-document corpus is usually the per-document text analysis itself
   (tokenizing and, for a language configuration such as
   <literal>english</literal>, 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
   <emphasis>embarrassingly parallel</emphasis>: set
   <varname>max_parallel_maintenance_workers</varname> (and enough
   <varname>max_parallel_workers</varname> /
   <varname>max_worker_processes</varname>) so the analysis runs across cores
   &mdash; 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
   (<literal>max_parallel_maintenance_workers = 0</literal>) 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 &mdash; 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
   <literal>LOG</literal>-level progress line as documents are analyzed and at
   each segment flush (set <varname>log_min_messages</varname> to
   <literal>log</literal> or lower to see them), so a long build can be
   distinguished from a stuck one.
  </para>

  <para>
   <emphasis role="bold">Memory.</emphasis> Peak build memory is bounded by
   roughly <literal>shared_buffers + (max_parallel_maintenance_workers + 1) x 2 x maintenance_work_mem</literal>.
   Size <varname>maintenance_work_mem</varname> and
   <varname>max_parallel_maintenance_workers</varname> so that figure fits your
   host (and any cgroup <literal>MemoryMax</literal>).  A larger
   <varname>maintenance_work_mem</varname> also flushes fewer, larger segments
   during the scan, which reduces the amount of merging afterwards &mdash; a
   good trade when you have RAM to spare.
  </para>

  <para>
   How much it matters is easy to underestimate, so here it is measured on
   2,188,038 Wikipedia articles (16 vCPU, 128&nbsp;GB;
   <filename>bench/RESULTS_GATING_2026-09-09.md</filename>).  <quote>Merge</quote>
   is the <function>fts_merge</function> that consolidates whatever the build left:
<informaltable>
 <tgroup cols="5">
  <thead>
   <row>
    <entry><varname>maintenance_work_mem</varname></entry>
    <entry>build</entry>
    <entry>segments after build</entry>
    <entry>follow-up merge</entry>
    <entry>final index</entry>
   </row>
  </thead>
  <tbody>
   <row><entry>64&nbsp;MB (the PostgreSQL default)</entry><entry>475&nbsp;s</entry><entry>8</entry><entry>216&nbsp;s</entry><entry>8,613&nbsp;MB</entry></row>
   <row><entry>256&nbsp;MB</entry><entry>367&nbsp;s</entry><entry>6</entry><entry>223&nbsp;s</entry><entry>5,793&nbsp;MB</entry></row>
   <row><entry><emphasis>1&nbsp;GB</emphasis></entry><entry>523&nbsp;s</entry><entry><emphasis>1</emphasis></entry><entry><emphasis>none needed</emphasis></entry><entry>4,605&nbsp;MB</entry></row>
   <row><entry><emphasis>2&nbsp;GB</emphasis></entry><entry>527&nbsp;s</entry><entry><emphasis>1</emphasis></entry><entry><emphasis>none needed</emphasis></entry><entry>4,323&nbsp;MB</entry></row>
  </tbody>
 </tgroup>
</informaltable>
   At <literal>1&nbsp;GB</literal> and above this corpus builds straight to a
   single segment, so the post-build merge disappears entirely &mdash; saving both
   the ~220&nbsp;s merge and about 4&nbsp;GB of transient index size.  At the 64&nbsp;MB
   default the same corpus needs 8 segments plus a 216&nbsp;s merge and lands
   <emphasis>twice as large</emphasis>.  If a build is followed by a long merge,
   raising <varname>maintenance_work_mem</varname> is the first thing to try.
  </para>

  <para>
   <emphasis role="bold">Parallel builds: faster, same final size.</emphasis>
   <varname>max_parallel_maintenance_workers</varname> speeds a build up
   (464&nbsp;s versus 523&nbsp;s serial at
   <literal>maintenance_work_mem = 1GB</literal> on 2.19M articles) and, once
   <function>fts_vacuum</function> has run, produces an
   <emphasis>identically sized</emphasis> index &mdash; 1,420&nbsp;MB either way,
   with identical match counts.  What a parallel build leaves behind is more
   <emphasis>reclaimable residue</emphasis>, not a larger index: immediately after
   the build the file measures 5,365&nbsp;MB versus 4,605&nbsp;MB serial, and
   <function>fts_vacuum</function> 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.)
  </para>

  <para>
   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
   <emphasis>contiguous</emphasis> free tail, so pages freed underneath the final
   output survive until <function>fts_vacuum</function>'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.  <emphasis role="bold">Run
   <function>fts_vacuum</function> once after a large build, and do not judge the
   index's size before you do.</emphasis>
  </para>

  <para>
   <emphasis role="bold">Unattended autovacuum holds the index bounded and reclaims
   after deletes; no scheduled maintenance is required.</emphasis>  Measured at one
   million documents with autovacuum enabled and no manual maintenance whatsoever: five
   consecutive cleanups with nothing changed stayed flat at 511&nbsp;MB, six rounds of
   insert-plus-delete churn stayed flat at 875&nbsp;MB, and after deleting half the table
   cleanup brought the index from 875&nbsp;MB to 106&nbsp;MB (8.3&#215;) with queries
   served throughout and results exact.  Truncation is safe while the index is online
   under <literal>ShareUpdateExclusiveLock</literal>, because scans treat an out-of-range
   block as end-of-chain.
  </para>

  <para>
   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&#215;, 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
   <filename>bench/RESULTS_P1_SCALE_AB_2026-09-13.md</filename> and
   <filename>bench/RESULTS_SELF_LIMITING_2026-09-12.md</filename>.
  </para>

  <para>
   <function>fts_vacuum</function> remains useful for a <emphasis>one-off</emphasis>
   tighter reclaim: it always performs the full vacate-and-pack rather than waiting for
   the bloat threshold, reaching roughly 3&#215; smaller than the automatic steady state.
   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.
  </para>

  <para>
   <emphasis role="bold">Incremental inserts of LARGE documents can grow the index file
   far beyond its settled size.</emphasis>  A pending document is stored verbatim, and
   one that does not fit in an 8&nbsp;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&nbsp;MB / 1,000,000-document
   index grew the file to <emphasis>32&nbsp;GB before any merge ran</emphasis>.
   <function>fts_merge</function> then added only about 7% more, and
   <function>fts_vacuum</function> returned the file to 971&nbsp;MB.  Reproduced at a
   second scale (250,000 docs + 50,000 inserts: 272&nbsp;MB &rarr; 7.7&nbsp;GB &rarr;
   319&nbsp;MB).
  </para>
  <para>
   This is <emphasis>corpus-dependent</emphasis>, 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
   <function>fts_merge</function> and <function>fts_vacuum</function> periodically rather
   than accumulating pending data, and provision disk headroom accordingly.  See
   <filename>bench/RESULTS_C2_INGEST_2026-09-11.md</filename>.
  </para>

  <para id="pgfts-parallel-merge">
   <emphasis role="bold">Do not raise
   <varname>max_parallel_maintenance_workers</varname> to speed up a merge.</emphasis>
   <function>fts_merge</function> can take a parallel path, and it is
   <emphasis>slower</emphasis> than the serial one.  Measured on 2,188,038
   Wikipedia articles, merging an 8-segment 7,185&nbsp;MB index
   (<filename>bench/RESULTS_PARALLEL_MERGE_2026-09-08.md</filename>):
   serial <emphasis>230.6&nbsp;s</emphasis> producing 8,606&nbsp;MB, versus parallel
   <emphasis>333.5&nbsp;s</emphasis> producing 10,229&nbsp;MB &mdash; 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).
  </para>

  <para>
   There is also a trap worth knowing: at
   <literal>max_parallel_maintenance_workers = 8</literal> on the test host the
   workers registered, started, and exited within about 2&nbsp;ms, so the merge
   silently ran <emphasis>serially</emphasis> &mdash; which is why that setting
   looked fast.  Do not infer from a fast merge that parallelism helped; check for
   parallel workers in <structname>pg_stat_activity</structname> if it matters.
   The safe configuration for merges is the default (leave the setting alone, or
   <literal>0</literal>).
  </para>

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

  <para>
   <emphasis role="bold">Partitioning.</emphasis> For a very large corpus you
   can partition the table and build a per-partition <literal>fts</literal>
   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 <emphasis>within</emphasis> a partition; if you need a
   single global ranking across partitions, prefer one whole-corpus index and
   the tiered-build behavior above.
  </para>
 </sect2>

 <sect2 id="pgfts-operating">
  <title>Operating pg_fts (maintenance, replicas, and space)</title>

  <para>
   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
   <literal>GenericXLog</literal>, so the index is crash-safe and replicates on a
   physical standby with no extra configuration).  This section is the operator
   summary.
  </para>

  <formalpara>
   <title>Automatic maintenance</title>
   <para>
    Two things happen without operator action.  <emphasis>Auto-merge</emphasis>:
    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).  <emphasis>Auto-vacuum</emphasis>: ordinary
    <command>VACUUM</command> (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.
   </para>
  </formalpara>

  <formalpara>
   <title><function>fts_merge()</function> vs <function>fts_vacuum()</function></title>
   <para>
    Call <function>fts_merge(<replaceable>index</replaceable>)</function> to
    <emphasis>optimize now</emphasis>: it folds the pending buffer and merges
    live segments into one, e.g. right after a parallel build (which leaves the
    workers' segments unmerged for speed) or after heavy churn.  It compacts the
    segment directory but does <emphasis>not</emphasis> shrink the physical file.
    Call <function>fts_vacuum(<replaceable>index</replaceable>)</function> to
    <emphasis>reclaim disk</emphasis>: 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 a bulk build or heavy update churn) without a
    <command>REINDEX</command>.  A single <function>fts_vacuum()</function> call
    reclaims most of the space; a second converges to the floor.
   </para>
  </formalpara>

  <formalpara>
   <title>Transient space during compaction</title>
   <para>
    <function>fts_vacuum()</function> and <command>REINDEX</command> 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.
    <function>fts_vacuum()</function> takes <literal>AccessExclusiveLock</literal>
    on the index (like <command>REINDEX</command>); use
    <command>REINDEX INDEX CONCURRENTLY</command> if you need the rebuild to stay
    online.
   </para>
  </formalpara>

  <formalpara>
   <title>Behavior on a read replica</title>
   <para>
    Reads work normally on a hot standby (the index is fully replicated).  The
    maintenance functions <function>fts_merge()</function> and
    <function>fts_vacuum()</function> write WAL, so they cannot run during
    recovery: called on a replica they raise an error
    (<literal>cannot run during recovery</literal>) rather than doing damage.
    Run them on the primary; the effect replicates.  Both also require the caller
    to own the target index.
   </para>
  </formalpara>

  <formalpara>
   <title>Privileges</title>
   <para>
    The value-level API (<function>to_ftsdoc</function>,
    <function>to_ftsquery</function>, <function>fts_bm25</function>, the operators
    and I/O functions) is available to <literal>PUBLIC</literal>.  Two functions
    that emit indexed content by index OID &mdash; <function>fts_search</function>
    and <function>fts_anomalous_docs</function> &mdash; are revoked from
    <literal>PUBLIC</literal>; the index owner and superusers keep access, and an
    owner can grant them explicitly (for example <literal>GRANT EXECUTE ON
    FUNCTION fts_search(regclass, ftsquery, int) TO <replaceable>role</replaceable></literal>).
   </para>
  </formalpara>

  <formalpara>
   <title>Behavior under continuous ingestion</title>
   <para>
    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 <command>VACUUM</command>; an old snapshot held open (for
    example continuous replica feedback) defers that reclaim and defers physical
    shrink, which is expected &mdash; 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.
   </para>
  </formalpara>
 </sect2>

 <sect2 id="pgfts-limitations">
  <title>Limitations</title>

  <itemizedlist>
   <listitem>
    <para>
     No index-only scan (the index is not covering); use
     <function>fts_count</function> for a fast count.
    </para>
   </listitem>
   <listitem>
    <para>
     Query execution (scan) is single-threaded (no parallel scan).  The index
     build is parallel (<literal>amcanbuildparallel</literal>).
    </para>
   </listitem>
   <listitem>
    <para>
     Ranked results cover flushed segments; documents still in the pending
     write buffer are found by <literal>@@@</literal> and counted by
     <function>fts_count</function>, but are ranked by <literal>&lt;=&gt;</literal>
     only after the next flush (<command>VACUUM</command> or
     <function>fts_merge</function>).
    </para>
   </listitem>
   <listitem>
    <para>
     <function>fts_merge</function> and the automatic merge compact the index
     logically (fewer segments, tombstones dropped) but do not shrink the
     physical file; use <command>REINDEX</command> to reclaim space.
    </para>
   </listitem>
  </itemizedlist>
 </sect2>

 <sect2 id="pgfts-authors">
  <title>Authors</title>

  <para>
   Gregory Burd.
  </para>
 </sect2>

</sect1>
