Full-Text Search is an extension to DuckDB that allows for search through strings, similar to SQLite's FTS5 extension.
The fts extension will be transparently autoloaded on first use from the official extension repository.
If you would like to install and load it manually, run:
INSTALL fts;
LOAD fts;
The extension adds two PRAGMA statements to DuckDB: one to create, and one to drop an index. Additionally, a scalar macro stem is added, which is used internally by the extension. Each index schema also contains search macros and an analyze_text table macro configured for that index.
PRAGMA create_fts_indexcreate_fts_index(input_table, input_id, *input_values, stemmer = 'porter',
tokenizer = 'regex',
stopwords = 'english',
ignore = "[0-9!@#$%^&*()_+={}\\[\\]:;<>,.?~\\\\/\\|''\"`-]+",
strip_accents = 1, lower = 1, overwrite = 0,
incremental = 0, cluster_terms = 0, layered_search = 0)
PRAGMA that creates a FTS index for the specified table.
| Name | Type | Description |
|---|---|---|
input_table | VARCHAR | Qualified name of specified table, e.g., 'table_name' or 'main.table_name' |
input_id | VARCHAR | Column name of document identifier, e.g., 'document_identifier' |
input_values… | VARCHAR | Column names of the text fields to be indexed (vararg), e.g., 'text_field_1', 'text_field_2', ..., 'text_field_N', or '\*' for all columns in input_table of type VARCHAR |
stemmer | VARCHAR | The type of stemmer to be used. One of 'arabic', 'armenian', 'basque', 'catalan', 'czech', 'danish', 'dutch', 'dutch_porter', 'english', 'esperanto', 'estonian', 'finnish', 'french', 'german', 'greek', 'hindi', 'hungarian', 'indonesian', 'irish', 'italian', 'lithuanian', 'nepali', 'norwegian', 'persian', 'polish', 'porter', 'portuguese', 'romanian', 'russian', 'serbian', 'sesotho', 'spanish', 'swedish', 'tamil', 'turkish', 'yiddish', or 'none' if no stemming is to be used. Defaults to 'porter' |
tokenizer | VARCHAR | Tokenizer to use. 'regex' keeps the legacy regex-split behavior. 'opensearch_standard' uses an OpenSearch/Lucene standard-tokenizer compatibility mode that splits Han, Hiragana, and Katakana into single-character tokens while preserving word runs for scripts such as Hebrew, Cyrillic, Arabic, Latin, and Hangul. Defaults to 'regex' |
stopwords | VARCHAR | Qualified name of table containing a single VARCHAR column containing the desired stopwords, or 'none' if no stopwords are to be used. Defaults to 'english' for a pre-defined list of 571 English stopwords |
ignore | VARCHAR | Regular expression of patterns to be ignored by the 'regex' tokenizer. Defaults to a punctuation and digit pattern |
strip_accents | BOOLEAN | Whether to remove accents (e.g., convert á to a). Defaults to 1 for 'regex' and 0 for 'opensearch_standard'. strip_accents=true is not supported with 'opensearch_standard' because OpenSearch's standard analyzer lowercases tokens but does not strip accents |
lower | BOOLEAN | Whether to convert all text to lowercase. Defaults to 1 |
overwrite | BOOLEAN | Whether to overwrite an existing index on a table. Defaults to 0 |
incremental | BOOLEAN | Whether to maintain the FTS index with triggers after inserts and deletes on the input table. Defaults to 0 |
cluster_terms | BOOLEAN | Whether to physically order the generated terms table by termid, fieldid, and docid. This can improve query-time pruning for direct reads from the FTS tables. Defaults to 0 |
layered_search | BOOLEAN | Whether to build the dictionary sidecars, positional postings, and layered search macros used by expanded, autocomplete, phrase, phrase-prefix, near, wildcard, and regex queries. This implies cluster_terms. Defaults to 0 |
This PRAGMA builds the index under a newly created schema. The schema will be named after the input table: if an index is created on table 'main.table_name', then the schema will be named 'fts_main_table_name'.
By default, indexes are static snapshots. If the input table changes after
index creation, rebuild the index with overwrite = true or use
incremental = true when creating the index. Incremental indexes are maintained
with triggers for INSERT and DELETE statements. They require trigger
support, a document id column declared NOT NULL or PRIMARY KEY, and unique
document id values. Persistent databases must use storage version v2.0.0 or
newer for incremental indexes.
cluster_terms = true changes only the physical ordering of the generated
terms table. For incremental indexes, the initial index build uses the
clustered layout, but trigger-appended rows are not globally reclustered.
PRAGMA drop_fts_indexdrop_fts_index(input_table)
Drops a FTS index for the specified table. This removes the generated FTS
schema and any triggers used for incremental maintenance. Recreating an index
with overwrite = true performs the same cleanup before building the new index.
| Name | Type | Description |
|---|---|---|
input_table | VARCHAR | Qualified name of input table, e.g., 'table_name' or 'main.table_name' |
Every generated FTS schema exposes the analyzer used by that index as a table macro:
SELECT *
FROM fts_main_documents.analyze_text('The running foxes')
ORDER BY position;
The result contains the normalized unstemmed raw_term, final indexed term,
one-based absolute position, position_increment, position_length,
start_offset, end_offset, and token_type. Empty tokenizer output is
removed before positions are assigned. Stopwords are removed afterward, so
leading and internal stopwords remain visible as position gaps.
Current analyzers use a position length of one and the token type word.
Offsets are reserved as nullable, half-open, zero-based UTF-8 byte offsets into
the original input. They are currently NULL because normalization prevents
the regex and OpenSearch-compatible tokenizers from mapping every token
reliably back to the original string.
analyze_text uses the same generated analyzer definition as bulk indexing,
incremental maintenance, and query analysis. The lower-level list-returning
tokenize macro remains available for compatibility, but it does not apply
stopword removal or stemming. Analyzer contract changes are versioned in
index_metadata and require dropping and recreating an existing index.
match_bm25 Functionmatch_bm25(input_id, query_string, fields := NULL, k := 1.2, b := 0.75,
conjunctive := 0, field_weights := NULL, field_b := NULL,
scoring_model := 'bm25f', tie_breaker := 0.0)
When an index is built, this retrieval macro is created that can be used to search the index.
| Name | Type | Description |
|---|---|---|
input_id | VARCHAR | Column name of document identifier, e.g., 'document_identifier' |
query_string | VARCHAR | The string to search the index for |
fields | VARCHAR | Comma-separated list of fields to search in, e.g., 'text_field_2, text_field_N'. Defaults to NULL to search all indexed fields |
k | DOUBLE | Parameter k1 in the Okapi BM25 retrieval model. Defaults to 1.2 |
b | DOUBLE | Parameter b in the Okapi BM25 retrieval model. Defaults to 0.75 |
conjunctive | BOOLEAN | Whether to make the query conjunctive, i.e., all query terms that remain after tokenization, stopword removal, and stemming must be present for a document to be retrieved |
field_weights | MAP(VARCHAR, DOUBLE) | Non-negative finite weights for indexed fields. Omitted fields have weight 1.0. Defaults to NULL |
field_b | MAP(VARCHAR, DOUBLE) | Per-field BM25 length-normalization parameters. Values must be between 0.0 and 1.0; omitted fields inherit b. Defaults to NULL |
scoring_model | VARCHAR | Field scoring model: bm25f or best_fields. Defaults to bm25f |
tie_breaker | DOUBLE | Contribution from non-best fields in best_fields mode. Must be finite and between 0.0 and 1.0. Defaults to 0.0 |
BM25F is the default for both single-field and multi-field indexes. It
normalizes term frequency independently for each selected field, combines those
frequencies using field_weights, and applies BM25 saturation once. A
single-field index reduces to ordinary BM25. Existing multi-field indexes may
produce different scores and ordering after rebuilding with this version.
search_layered_bm25(query_string, fields := NULL, top_k := 50, k := 1.2,
b := 0.75, term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true, expand_exact_terms := false,
query_mode := 'standard', field_weights := NULL,
field_b := NULL, scoring_model := 'bm25f',
tie_breaker := 0.0, near_distance := 10)
match_layered_bm25(input_id, query_string, fields := NULL, k := 1.2,
b := 0.75, term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true, expand_exact_terms := false,
query_mode := 'standard', field_weights := NULL,
field_b := NULL, scoring_model := 'bm25f',
tie_breaker := 0.0, near_distance := 10)
When layered_search is enabled, the extension builds dictionary sidecar
tables used to expand query terms before scoring over the standard FTS terms
table. These sidecar tables grow with the term dictionary rather than with the
document corpus.
search_layered_bm25 is a table macro returning docname, score, and
rank. match_layered_bm25 is the scalar form for use against an input table
row. Both macros use the same tokenization, stopword removal, stemming, field
filtering, and BM25 parameters as the base FTS index.
| Name | Type | Description |
|---|---|---|
input_id | VARCHAR | Document identifier to score. Only used by match_layered_bm25 |
query_string | VARCHAR | The string to search the index for |
fields | VARCHAR | Comma-separated list of indexed fields to search. Defaults to NULL to search all indexed fields |
top_k | BIGINT | Maximum number of rows returned by search_layered_bm25. Defaults to 50; use NULL to return all matches |
k | DOUBLE | Parameter k1 in the Okapi BM25 retrieval model. Defaults to 1.2 |
b | DOUBLE | Parameter b in the Okapi BM25 retrieval model. Defaults to 0.75 |
term_limit | BIGINT | Maximum number of expanded alternatives to include per query term. Defaults to 32 |
max_df_ratio | DOUBLE | Maximum document-frequency ratio for terms considered during expansion. Defaults to 0.15 |
max_df | BIGINT | Absolute document-frequency cap for terms considered during expansion. Defaults to 50000 |
enable_prefix | BOOLEAN | Whether to include dictionary terms that start with a query term. Defaults to true |
enable_substring | BOOLEAN | Whether to include dictionary terms that contain a query term. Defaults to true |
enable_fuzzy | BOOLEAN | Whether to include Damerau-Levenshtein fuzzy alternatives. Defaults to true |
enable_short_fuzzy | BOOLEAN | Whether to use a length-clustered path for short fuzzy alternatives. Defaults to true |
expand_exact_terms | BOOLEAN | Whether to also expand a query term that already has an exact dictionary match. Defaults to false |
query_mode | VARCHAR | Query execution mode. standard uses exact, prefix, substring, and fuzzy dictionary expansion; autocomplete keeps preceding tokens exact and matches the final token by raw-token prefix; phrase requires exact order and adjacency; phrase_prefix treats the final phrase token as a raw-token prefix; near requires every term in one field within near_distance tokens of each other, in any order; wildcard matches * and ? patterns; regex matches a conservative flat RE2 subset. Defaults to standard |
field_weights | MAP(VARCHAR, DOUBLE) | Non-negative finite weights for indexed fields. Omitted fields have weight 1.0. Defaults to NULL |
field_b | MAP(VARCHAR, DOUBLE) | Per-field BM25 length-normalization parameters. Values must be between 0.0 and 1.0; omitted fields inherit b. Defaults to NULL |
scoring_model | VARCHAR | Field scoring model: bm25f or best_fields. Defaults to bm25f |
tie_breaker | DOUBLE | Contribution from non-best fields in best_fields mode. Must be finite and between 0.0 and 1.0. Defaults to 0.0 |
near_distance | BIGINT | Tokens permitted between the first and the last query term in near mode, ignored otherwise. Must be a non-negative integer. Counted once across the whole match, and intervening query terms count toward it. 0 means the terms are consecutive. This is NEAR N from SQLite's FTS5, and shares its default of 10 |
Exact terms are always included in the candidate set. Prefix, substring, and fuzzy alternatives are optional and receive lower expansion weights before BM25 scoring. Numeric query terms are searched exactly and are excluded from trigram/fuzzy expansion. Stopwords are removed before both exact matching and expansion, so a query containing only stopwords returns no rows.
In bm25f mode, each field term frequency is divided by its field-specific
length normalization. The weighted normalized frequencies are summed into a
pseudo-frequency before BM25 saturation is applied once. In best_fields mode,
each field is saturated and scored separately, then combined as
max(field_score) + tie_breaker * (sum(field_score) - max(field_score)).
IDF remains corpus-wide for both models. Unknown fields and invalid models,
weights, normalization values, or tie breakers produce an error. A nonzero tie
breaker is only valid with best_fields.
Autocomplete mode requires a final searchable token of at least two characters. It routes that token through a compact two/three-character prefix table and then verifies the full raw-token prefix. The existing postings table stores a raw-token identifier alongside each stemmed term, so autocomplete remains correct when multiple raw forms share a stem. It does not use substring or fuzzy expansion for the final token.
Phrase mode uses positional postings to require zero-slop matches in one
indexed field. Positions are assigned after empty tokenizer output is removed
and before stopword removal, so removed stopwords retain their positional gap.
Phrase-prefix mode applies the same positional check but expands only the final
unfinished raw token through the prefix sidecar. term_limit bounds those
deterministically ordered completions; document-frequency filters and fuzzy or
substring expansion are not applied. A one-token phrase uses standard mode,
while a one-token phrase-prefix uses autocomplete mode.
Near mode uses the same positional postings to require that every query term
occurs in one indexed field, in any order, with at most near_distance tokens
between the first and the last of them. The distance is counted once across the
whole match rather than for each pair, and intervening query terms count toward
it, so two terms need near_distance = 0 to be adjacent while three consecutive
terms need 1. As in phrase mode, removed stopwords retain their positional gap, so a
stopword between two terms consumes distance. A term repeated in the query
counts once, and quoted phrases are not supported; the query is a set of
distinct terms. Near mode matches exact dictionary terms, so prefix, fuzzy, and
substring expansion are not applied, and a term that occurs in no document
disqualifies every document. near_distance is NEAR N from SQLite's FTS5
and shares its default of 10.
Wildcard and regex modes treat the complete query_string as one whole-token
pattern and match it verbatim against the normalized raw-term dictionary.
Wildcard syntax supports *, ?, and backslash escaping. Regex syntax is a
flat RE2 subset containing literals, escaped non-alphanumeric literals, .,
character classes, and *, +, ?, or bounded repetition quantifiers. Groups,
alternation, anchors, inline flags, and character-class escapes are rejected.
The bounded native pattern analyzer validates this grammar and extracts one
mandatory literal. Candidate generation, dictionary verification, field
deduplication, and scoring remain relational SQL operations.
An indexable pattern must contain either an unquantified leading literal prefix
of at least two characters or another mandatory literal run of at least three
characters. Leading prefixes use the existing prefix sidecar; internal literals
intersect the normalized-term trigram index plus a sparse, deduplicated sidecar
for raw forms not covered by it. Candidates are then verified with
regexp_full_match, so sidecar lookup cannot introduce false matches.
Pattern modes ignore term_limit and document-frequency expansion limits.
Pattern clauses use constant scoring: every matching document field contributes
once, regardless of token frequency or the number of matching raw terms. BM25F
sums matching field weights. best_fields applies its normal tie-breaker formula
to those field weights. BM25 length normalization and IDF do not apply to
pattern clauses. Because pattern text is not passed through the index's text
analyzer at query time, callers must use the normalized spelling; for example,
a lowercase index does not lowercase an uppercase wildcard pattern
automatically.
Structured Boolean queries evaluate all pattern leaves together through the same indexed paths, carrying each leaf's node ID through candidate generation and scoring. Ordinary leaves continue to use the non-pattern layered core, so an unused pattern branch does not add a correlated search plan per leaf.
Layered search can be static or incremental. With layered_search = true and
incremental = false, the sidecar tables are built once and later table
changes are not visible until the index is rebuilt. With both options enabled,
the sidecar tables are maintained together with the base FTS index for
INSERT and DELETE.
Indexes store a compact field-length list on each document row and corpus
average lengths in the statistics row. Layered indexes additionally store a
one-based position on every posting. The index_metadata view records the
physical format version, enabled features, indexed fields, and analyzer
fingerprint. The fingerprint includes a canonical digest of the effective
stopword set; the separate stopwords column retains its configured source
for provenance. Existing indexes created by an older extension version must
be dropped and rebuilt before field-aware or positional scoring can be used.
search_layered_bm25_query(query, top_k := 50, k := 1.2, b := 0.75,
term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true,
expand_exact_terms := false,
field_weights := NULL, field_b := NULL,
scoring_model := 'bm25f', tie_breaker := 0.0,
max_leaf_clauses := 1024,
max_boolean_depth := 64)
match_layered_bm25_query(input_id, query, k := 1.2, b := 0.75,
term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true,
expand_exact_terms := false,
field_weights := NULL, field_b := NULL,
scoring_model := 'bm25f', tie_breaker := 0.0,
max_leaf_clauses := 1024,
max_boolean_depth := 64)
These macros accept a JSON query tree. Load DuckDB's json extension before
creating a layered index to install the structured macros together with the
index. For a layered index that already exists, or one created before json
was loaded, load json and run
PRAGMA create_fts_boolean_query_macros('table_name'); this installs only the
macros and does not rebuild the index. A node is either a leaf with query or a
Boolean group containing must, should, and must_not arrays:
LOAD json;
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25_query(
'{
"must": [
{"query": "quack", "fields": ["text_content"]}
],
"should": [
{
"query": "han",
"fields": ["author"],
"query_mode": "autocomplete",
"boost": 2.0
}
],
"must_not": [
{"query": "archive"}
],
"minimum_should_match": 0
}'::JSON,
top_k := 10
);
Every must clause must match and any matching must_not clause excludes the
document. A should-only group requires one matching clause by default; a group
with at least one must clause defaults to zero. An explicit
minimum_should_match counts direct should children. Positive child scores
are added and then multiplied by the group's optional boost. Leaf boosts are
applied to the leaf BM25 score first.
Leaves can select indexed fields with a JSON string array and choose
standard, autocomplete, phrase, phrase_prefix, near, wildcard, or
regex query mode independently. A near leaf takes its own optional
near_distance, so one clause can require proximity while another does not.
Expansion controls and field scoring configuration remain macro-level settings
so all leaves share the same resource limits and field model.
scoring_model := 'bm25f' uses canonical BM25F with optional
field_weights and per-field length normalization through field_b;
best_fields selects the strongest field and optionally incorporates the
others through tie_breaker. Ordinary leaves reuse the non-pattern layered
core, while wildcard and regex leaves share one set-oriented indexed pattern
pipeline. The Boolean layer combines the resulting scores over the same
layered index.
By default, query trees are limited to 1,024 leaves and a maximum Boolean depth
of 64. Callers can lower or raise these resource limits with
max_leaf_clauses and max_boolean_depth; passing NULL disables the
corresponding extension-level limit. The Boolean tree is evaluated recursively,
so these are resource controls rather than fixed SQL-plan limits. Empty groups,
pure-negative groups, unknown keys, mixed leaf/group nodes, invalid boosts, and
out-of-range minimum_should_match values are rejected. Setting
minimum_should_match explicitly to zero on a should-only group matches all
indexed documents; documents that match no optional leaf receive score 0.
stem Functionstem(input_string, stemmer)
Reduces words to their base. Used internally by the extension.
| Name | Type | Description |
|---|---|---|
input_string | VARCHAR | The column or constant to be stemmed. |
stemmer | VARCHAR | The type of stemmer to be used. One of 'arabic', 'armenian', 'basque', 'catalan', 'czech', 'danish', 'dutch', 'dutch_porter', 'english', 'esperanto', 'estonian', 'finnish', 'french', 'german', 'greek', 'hindi', 'hungarian', 'indonesian', 'irish', 'italian', 'lithuanian', 'nepali', 'norwegian', 'persian', 'polish', 'porter', 'portuguese', 'romanian', 'russian', 'serbian', 'sesotho', 'spanish', 'swedish', 'tamil', 'turkish', 'yiddish', or 'none' if no stemming is to be used. |
Create a table and fill it with text data:
CREATE TABLE documents (
document_identifier VARCHAR,
text_content VARCHAR,
author VARCHAR,
doc_version INTEGER
);
INSERT INTO documents
VALUES ('doc1',
'The mallard is a dabbling duck that breeds throughout the temperate.',
'Hannes Mühleisen',
3),
('doc2',
'The cat is a domestic species of small carnivorous mammal.',
'Laurens Kuiper',
2
);
Build the index, and make both the text_content and author columns searchable.
PRAGMA create_fts_index(
'documents', 'document_identifier', 'text_content', 'author'
);
Search the author field index for documents that are authored by Muhleisen. This retrieves doc1:
SELECT document_identifier, text_content, score
FROM (
SELECT *, fts_main_documents.match_bm25(
document_identifier,
'Muhleisen',
fields := 'author'
) AS score
FROM documents
) sq
WHERE score IS NOT NULL
AND doc_version > 2
ORDER BY score DESC;
| document_identifier | text_content | score |
|---|---|---|
| doc1 | The mallard is a dabbling duck that breeds throughout the temperate. | 0.0 |
Search for documents about small cats. This retrieves doc2:
SELECT document_identifier, text_content, score
FROM (
SELECT *, fts_main_documents.match_bm25(
document_identifier,
'small cats'
) AS score
FROM documents
) sq
WHERE score IS NOT NULL
ORDER BY score DESC;
| document_identifier | text_content | score |
|---|---|---|
| doc2 | The cat is a domestic species of small carnivorous mammal. | 0.0 |
Build an incremental index when inserts and deletes should update the index automatically. The document identifier must be non-null and unique:
CREATE TABLE live_documents (
document_identifier VARCHAR NOT NULL,
text_content VARCHAR
);
INSERT INTO live_documents
VALUES ('doc1', 'quacking quacking'),
('doc2', 'barking barking');
PRAGMA create_fts_index(
'live_documents',
'document_identifier',
'text_content',
incremental = true
);
INSERT INTO live_documents VALUES ('doc3', 'meowing');
SELECT document_identifier
FROM (
SELECT *, fts_main_live_documents.match_bm25(
document_identifier,
'meowing'
) AS score
FROM live_documents
) sq
WHERE score IS NOT NULL;
Use layered search when query terms should match exact terms plus prefix, substring, or typo-tolerant alternatives:
CREATE TABLE animal_sounds (
document_identifier VARCHAR NOT NULL,
text_content VARCHAR,
author VARCHAR
);
INSERT INTO animal_sounds
VALUES ('doc1', 'quacking quacking', 'Hannes'),
('doc2', 'barking barking', 'Mark'),
('doc3', 'meowing meowing', 'Laurens');
PRAGMA create_fts_index(
'animal_sounds',
'document_identifier',
'text_content',
'author',
stemmer = 'none',
stopwords = 'none',
layered_search = true,
incremental = true
);
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'quack',
fields := 'text_content',
top_k := 10
);
Weight fields independently, or prefer the strongest matching field while retaining a fraction of the remaining field scores:
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'mark',
field_weights := MAP {
'author': 4.0,
'text_content': 1.0
},
field_b := MAP {'author': 0.3, 'text_content': 0.8},
scoring_model := 'best_fields',
tie_breaker := 0.1,
top_k := 10
);
The scalar layered helper can be used in the same row-filtering pattern as
match_bm25:
SELECT document_identifier, text_content, score
FROM (
SELECT *, fts_main_animal_sounds.match_layered_bm25(
document_identifier,
'mark',
fields := 'author',
enable_short_fuzzy := false
) AS score
FROM animal_sounds
) sq
WHERE score IS NOT NULL
ORDER BY score DESC;
By default, exact dictionary matches are not further expanded. Set
expand_exact_terms := true to include alternatives for exact query terms:
SELECT docname
FROM fts_main_animal_sounds.search_layered_bm25(
'mark',
fields := 'author',
expand_exact_terms := true
);
For low-latency term-prefix search, use autocomplete mode. Earlier tokens are matched exactly after the configured stemming, while only the final token is treated as a raw prefix:
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'han quac',
fields := 'text_content,author',
query_mode := 'autocomplete',
top_k := 10
);
Use phrase mode for exact adjacency and phrase-prefix mode when the final token is still being typed:
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'quacking quacking',
fields := 'text_content',
query_mode := 'phrase',
top_k := 10
);
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'quacking quac',
fields := 'text_content',
query_mode := 'phrase_prefix',
top_k := 10
);
Warning Without
incremental = true, the FTS index is a static snapshot and will not update automatically when the input table changes. Static layered indexes behave the same way: their sidecar tables are also rebuilt only when the index is rebuilt. Withincremental = true, the index and layered sidecar are maintained forINSERTandDELETEstatements on tables that support triggers.
The extension bundles the Snowball stemming
library (vendored under third_party/snowball/).
Starting with the upgrade to Snowball v3, the previously available
'german2' and 'kraaij_pohlmann' stemmers (legacy variants that were
undocumented but accepted by the underlying library) have been removed
upstream. If you relied on them, rebuild the FTS index with one of the
documented stemmers instead.
C++
89.6%
CMake
5.4%
Python
4.8%
Full-Text Search is an extension to DuckDB that allows for search through strings, similar to SQLite's FTS5 extension.
The fts extension will be transparently autoloaded on first use from the official extension repository.
If you would like to install and load it manually, run:
INSTALL fts;
LOAD fts;
The extension adds two PRAGMA statements to DuckDB: one to create, and one to drop an index. Additionally, a scalar macro stem is added, which is used internally by the extension. Each index schema also contains search macros and an analyze_text table macro configured for that index.
PRAGMA create_fts_indexcreate_fts_index(input_table, input_id, *input_values, stemmer = 'porter',
tokenizer = 'regex',
stopwords = 'english',
ignore = "[0-9!@#$%^&*()_+={}\\[\\]:;<>,.?~\\\\/\\|''\"`-]+",
strip_accents = 1, lower = 1, overwrite = 0,
incremental = 0, cluster_terms = 0, layered_search = 0)
PRAGMA that creates a FTS index for the specified table.
| Name | Type | Description |
|---|---|---|
input_table | VARCHAR | Qualified name of specified table, e.g., 'table_name' or 'main.table_name' |
input_id | VARCHAR | Column name of document identifier, e.g., 'document_identifier' |
input_values… | VARCHAR | Column names of the text fields to be indexed (vararg), e.g., 'text_field_1', 'text_field_2', ..., 'text_field_N', or '\*' for all columns in input_table of type VARCHAR |
stemmer | VARCHAR | The type of stemmer to be used. One of 'arabic', 'armenian', 'basque', 'catalan', 'czech', 'danish', 'dutch', 'dutch_porter', 'english', 'esperanto', 'estonian', 'finnish', 'french', 'german', 'greek', 'hindi', 'hungarian', 'indonesian', 'irish', 'italian', 'lithuanian', 'nepali', 'norwegian', 'persian', 'polish', 'porter', 'portuguese', 'romanian', 'russian', 'serbian', 'sesotho', 'spanish', 'swedish', 'tamil', 'turkish', 'yiddish', or 'none' if no stemming is to be used. Defaults to 'porter' |
tokenizer | VARCHAR | Tokenizer to use. 'regex' keeps the legacy regex-split behavior. 'opensearch_standard' uses an OpenSearch/Lucene standard-tokenizer compatibility mode that splits Han, Hiragana, and Katakana into single-character tokens while preserving word runs for scripts such as Hebrew, Cyrillic, Arabic, Latin, and Hangul. Defaults to 'regex' |
stopwords | VARCHAR | Qualified name of table containing a single VARCHAR column containing the desired stopwords, or 'none' if no stopwords are to be used. Defaults to 'english' for a pre-defined list of 571 English stopwords |
ignore | VARCHAR | Regular expression of patterns to be ignored by the 'regex' tokenizer. Defaults to a punctuation and digit pattern |
strip_accents | BOOLEAN | Whether to remove accents (e.g., convert á to a). Defaults to 1 for 'regex' and 0 for 'opensearch_standard'. strip_accents=true is not supported with 'opensearch_standard' because OpenSearch's standard analyzer lowercases tokens but does not strip accents |
lower | BOOLEAN | Whether to convert all text to lowercase. Defaults to 1 |
overwrite | BOOLEAN | Whether to overwrite an existing index on a table. Defaults to 0 |
incremental | BOOLEAN | Whether to maintain the FTS index with triggers after inserts and deletes on the input table. Defaults to 0 |
cluster_terms | BOOLEAN | Whether to physically order the generated terms table by termid, fieldid, and docid. This can improve query-time pruning for direct reads from the FTS tables. Defaults to 0 |
layered_search | BOOLEAN | Whether to build the dictionary sidecars, positional postings, and layered search macros used by expanded, autocomplete, phrase, phrase-prefix, near, wildcard, and regex queries. This implies cluster_terms. Defaults to 0 |
This PRAGMA builds the index under a newly created schema. The schema will be named after the input table: if an index is created on table 'main.table_name', then the schema will be named 'fts_main_table_name'.
By default, indexes are static snapshots. If the input table changes after
index creation, rebuild the index with overwrite = true or use
incremental = true when creating the index. Incremental indexes are maintained
with triggers for INSERT and DELETE statements. They require trigger
support, a document id column declared NOT NULL or PRIMARY KEY, and unique
document id values. Persistent databases must use storage version v2.0.0 or
newer for incremental indexes.
cluster_terms = true changes only the physical ordering of the generated
terms table. For incremental indexes, the initial index build uses the
clustered layout, but trigger-appended rows are not globally reclustered.
PRAGMA drop_fts_indexdrop_fts_index(input_table)
Drops a FTS index for the specified table. This removes the generated FTS
schema and any triggers used for incremental maintenance. Recreating an index
with overwrite = true performs the same cleanup before building the new index.
| Name | Type | Description |
|---|---|---|
input_table | VARCHAR | Qualified name of input table, e.g., 'table_name' or 'main.table_name' |
Every generated FTS schema exposes the analyzer used by that index as a table macro:
SELECT *
FROM fts_main_documents.analyze_text('The running foxes')
ORDER BY position;
The result contains the normalized unstemmed raw_term, final indexed term,
one-based absolute position, position_increment, position_length,
start_offset, end_offset, and token_type. Empty tokenizer output is
removed before positions are assigned. Stopwords are removed afterward, so
leading and internal stopwords remain visible as position gaps.
Current analyzers use a position length of one and the token type word.
Offsets are reserved as nullable, half-open, zero-based UTF-8 byte offsets into
the original input. They are currently NULL because normalization prevents
the regex and OpenSearch-compatible tokenizers from mapping every token
reliably back to the original string.
analyze_text uses the same generated analyzer definition as bulk indexing,
incremental maintenance, and query analysis. The lower-level list-returning
tokenize macro remains available for compatibility, but it does not apply
stopword removal or stemming. Analyzer contract changes are versioned in
index_metadata and require dropping and recreating an existing index.
match_bm25 Functionmatch_bm25(input_id, query_string, fields := NULL, k := 1.2, b := 0.75,
conjunctive := 0, field_weights := NULL, field_b := NULL,
scoring_model := 'bm25f', tie_breaker := 0.0)
When an index is built, this retrieval macro is created that can be used to search the index.
| Name | Type | Description |
|---|---|---|
input_id | VARCHAR | Column name of document identifier, e.g., 'document_identifier' |
query_string | VARCHAR | The string to search the index for |
fields | VARCHAR | Comma-separated list of fields to search in, e.g., 'text_field_2, text_field_N'. Defaults to NULL to search all indexed fields |
k | DOUBLE | Parameter k1 in the Okapi BM25 retrieval model. Defaults to 1.2 |
b | DOUBLE | Parameter b in the Okapi BM25 retrieval model. Defaults to 0.75 |
conjunctive | BOOLEAN | Whether to make the query conjunctive, i.e., all query terms that remain after tokenization, stopword removal, and stemming must be present for a document to be retrieved |
field_weights | MAP(VARCHAR, DOUBLE) | Non-negative finite weights for indexed fields. Omitted fields have weight 1.0. Defaults to NULL |
field_b | MAP(VARCHAR, DOUBLE) | Per-field BM25 length-normalization parameters. Values must be between 0.0 and 1.0; omitted fields inherit b. Defaults to NULL |
scoring_model | VARCHAR | Field scoring model: bm25f or best_fields. Defaults to bm25f |
tie_breaker | DOUBLE | Contribution from non-best fields in best_fields mode. Must be finite and between 0.0 and 1.0. Defaults to 0.0 |
BM25F is the default for both single-field and multi-field indexes. It
normalizes term frequency independently for each selected field, combines those
frequencies using field_weights, and applies BM25 saturation once. A
single-field index reduces to ordinary BM25. Existing multi-field indexes may
produce different scores and ordering after rebuilding with this version.
search_layered_bm25(query_string, fields := NULL, top_k := 50, k := 1.2,
b := 0.75, term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true, expand_exact_terms := false,
query_mode := 'standard', field_weights := NULL,
field_b := NULL, scoring_model := 'bm25f',
tie_breaker := 0.0, near_distance := 10)
match_layered_bm25(input_id, query_string, fields := NULL, k := 1.2,
b := 0.75, term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true, expand_exact_terms := false,
query_mode := 'standard', field_weights := NULL,
field_b := NULL, scoring_model := 'bm25f',
tie_breaker := 0.0, near_distance := 10)
When layered_search is enabled, the extension builds dictionary sidecar
tables used to expand query terms before scoring over the standard FTS terms
table. These sidecar tables grow with the term dictionary rather than with the
document corpus.
search_layered_bm25 is a table macro returning docname, score, and
rank. match_layered_bm25 is the scalar form for use against an input table
row. Both macros use the same tokenization, stopword removal, stemming, field
filtering, and BM25 parameters as the base FTS index.
| Name | Type | Description |
|---|---|---|
input_id | VARCHAR | Document identifier to score. Only used by match_layered_bm25 |
query_string | VARCHAR | The string to search the index for |
fields | VARCHAR | Comma-separated list of indexed fields to search. Defaults to NULL to search all indexed fields |
top_k | BIGINT | Maximum number of rows returned by search_layered_bm25. Defaults to 50; use NULL to return all matches |
k | DOUBLE | Parameter k1 in the Okapi BM25 retrieval model. Defaults to 1.2 |
b | DOUBLE | Parameter b in the Okapi BM25 retrieval model. Defaults to 0.75 |
term_limit | BIGINT | Maximum number of expanded alternatives to include per query term. Defaults to 32 |
max_df_ratio | DOUBLE | Maximum document-frequency ratio for terms considered during expansion. Defaults to 0.15 |
max_df | BIGINT | Absolute document-frequency cap for terms considered during expansion. Defaults to 50000 |
enable_prefix | BOOLEAN | Whether to include dictionary terms that start with a query term. Defaults to true |
enable_substring | BOOLEAN | Whether to include dictionary terms that contain a query term. Defaults to true |
enable_fuzzy | BOOLEAN | Whether to include Damerau-Levenshtein fuzzy alternatives. Defaults to true |
enable_short_fuzzy | BOOLEAN | Whether to use a length-clustered path for short fuzzy alternatives. Defaults to true |
expand_exact_terms | BOOLEAN | Whether to also expand a query term that already has an exact dictionary match. Defaults to false |
query_mode | VARCHAR | Query execution mode. standard uses exact, prefix, substring, and fuzzy dictionary expansion; autocomplete keeps preceding tokens exact and matches the final token by raw-token prefix; phrase requires exact order and adjacency; phrase_prefix treats the final phrase token as a raw-token prefix; near requires every term in one field within near_distance tokens of each other, in any order; wildcard matches * and ? patterns; regex matches a conservative flat RE2 subset. Defaults to standard |
field_weights | MAP(VARCHAR, DOUBLE) | Non-negative finite weights for indexed fields. Omitted fields have weight 1.0. Defaults to NULL |
field_b | MAP(VARCHAR, DOUBLE) | Per-field BM25 length-normalization parameters. Values must be between 0.0 and 1.0; omitted fields inherit b. Defaults to NULL |
scoring_model | VARCHAR | Field scoring model: bm25f or best_fields. Defaults to bm25f |
tie_breaker | DOUBLE | Contribution from non-best fields in best_fields mode. Must be finite and between 0.0 and 1.0. Defaults to 0.0 |
near_distance | BIGINT | Tokens permitted between the first and the last query term in near mode, ignored otherwise. Must be a non-negative integer. Counted once across the whole match, and intervening query terms count toward it. 0 means the terms are consecutive. This is NEAR N from SQLite's FTS5, and shares its default of 10 |
Exact terms are always included in the candidate set. Prefix, substring, and fuzzy alternatives are optional and receive lower expansion weights before BM25 scoring. Numeric query terms are searched exactly and are excluded from trigram/fuzzy expansion. Stopwords are removed before both exact matching and expansion, so a query containing only stopwords returns no rows.
In bm25f mode, each field term frequency is divided by its field-specific
length normalization. The weighted normalized frequencies are summed into a
pseudo-frequency before BM25 saturation is applied once. In best_fields mode,
each field is saturated and scored separately, then combined as
max(field_score) + tie_breaker * (sum(field_score) - max(field_score)).
IDF remains corpus-wide for both models. Unknown fields and invalid models,
weights, normalization values, or tie breakers produce an error. A nonzero tie
breaker is only valid with best_fields.
Autocomplete mode requires a final searchable token of at least two characters. It routes that token through a compact two/three-character prefix table and then verifies the full raw-token prefix. The existing postings table stores a raw-token identifier alongside each stemmed term, so autocomplete remains correct when multiple raw forms share a stem. It does not use substring or fuzzy expansion for the final token.
Phrase mode uses positional postings to require zero-slop matches in one
indexed field. Positions are assigned after empty tokenizer output is removed
and before stopword removal, so removed stopwords retain their positional gap.
Phrase-prefix mode applies the same positional check but expands only the final
unfinished raw token through the prefix sidecar. term_limit bounds those
deterministically ordered completions; document-frequency filters and fuzzy or
substring expansion are not applied. A one-token phrase uses standard mode,
while a one-token phrase-prefix uses autocomplete mode.
Near mode uses the same positional postings to require that every query term
occurs in one indexed field, in any order, with at most near_distance tokens
between the first and the last of them. The distance is counted once across the
whole match rather than for each pair, and intervening query terms count toward
it, so two terms need near_distance = 0 to be adjacent while three consecutive
terms need 1. As in phrase mode, removed stopwords retain their positional gap, so a
stopword between two terms consumes distance. A term repeated in the query
counts once, and quoted phrases are not supported; the query is a set of
distinct terms. Near mode matches exact dictionary terms, so prefix, fuzzy, and
substring expansion are not applied, and a term that occurs in no document
disqualifies every document. near_distance is NEAR N from SQLite's FTS5
and shares its default of 10.
Wildcard and regex modes treat the complete query_string as one whole-token
pattern and match it verbatim against the normalized raw-term dictionary.
Wildcard syntax supports *, ?, and backslash escaping. Regex syntax is a
flat RE2 subset containing literals, escaped non-alphanumeric literals, .,
character classes, and *, +, ?, or bounded repetition quantifiers. Groups,
alternation, anchors, inline flags, and character-class escapes are rejected.
The bounded native pattern analyzer validates this grammar and extracts one
mandatory literal. Candidate generation, dictionary verification, field
deduplication, and scoring remain relational SQL operations.
An indexable pattern must contain either an unquantified leading literal prefix
of at least two characters or another mandatory literal run of at least three
characters. Leading prefixes use the existing prefix sidecar; internal literals
intersect the normalized-term trigram index plus a sparse, deduplicated sidecar
for raw forms not covered by it. Candidates are then verified with
regexp_full_match, so sidecar lookup cannot introduce false matches.
Pattern modes ignore term_limit and document-frequency expansion limits.
Pattern clauses use constant scoring: every matching document field contributes
once, regardless of token frequency or the number of matching raw terms. BM25F
sums matching field weights. best_fields applies its normal tie-breaker formula
to those field weights. BM25 length normalization and IDF do not apply to
pattern clauses. Because pattern text is not passed through the index's text
analyzer at query time, callers must use the normalized spelling; for example,
a lowercase index does not lowercase an uppercase wildcard pattern
automatically.
Structured Boolean queries evaluate all pattern leaves together through the same indexed paths, carrying each leaf's node ID through candidate generation and scoring. Ordinary leaves continue to use the non-pattern layered core, so an unused pattern branch does not add a correlated search plan per leaf.
Layered search can be static or incremental. With layered_search = true and
incremental = false, the sidecar tables are built once and later table
changes are not visible until the index is rebuilt. With both options enabled,
the sidecar tables are maintained together with the base FTS index for
INSERT and DELETE.
Indexes store a compact field-length list on each document row and corpus
average lengths in the statistics row. Layered indexes additionally store a
one-based position on every posting. The index_metadata view records the
physical format version, enabled features, indexed fields, and analyzer
fingerprint. The fingerprint includes a canonical digest of the effective
stopword set; the separate stopwords column retains its configured source
for provenance. Existing indexes created by an older extension version must
be dropped and rebuilt before field-aware or positional scoring can be used.
search_layered_bm25_query(query, top_k := 50, k := 1.2, b := 0.75,
term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true,
expand_exact_terms := false,
field_weights := NULL, field_b := NULL,
scoring_model := 'bm25f', tie_breaker := 0.0,
max_leaf_clauses := 1024,
max_boolean_depth := 64)
match_layered_bm25_query(input_id, query, k := 1.2, b := 0.75,
term_limit := 32, max_df_ratio := 0.15,
max_df := 50000, enable_prefix := true,
enable_substring := true, enable_fuzzy := true,
enable_short_fuzzy := true,
expand_exact_terms := false,
field_weights := NULL, field_b := NULL,
scoring_model := 'bm25f', tie_breaker := 0.0,
max_leaf_clauses := 1024,
max_boolean_depth := 64)
These macros accept a JSON query tree. Load DuckDB's json extension before
creating a layered index to install the structured macros together with the
index. For a layered index that already exists, or one created before json
was loaded, load json and run
PRAGMA create_fts_boolean_query_macros('table_name'); this installs only the
macros and does not rebuild the index. A node is either a leaf with query or a
Boolean group containing must, should, and must_not arrays:
LOAD json;
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25_query(
'{
"must": [
{"query": "quack", "fields": ["text_content"]}
],
"should": [
{
"query": "han",
"fields": ["author"],
"query_mode": "autocomplete",
"boost": 2.0
}
],
"must_not": [
{"query": "archive"}
],
"minimum_should_match": 0
}'::JSON,
top_k := 10
);
Every must clause must match and any matching must_not clause excludes the
document. A should-only group requires one matching clause by default; a group
with at least one must clause defaults to zero. An explicit
minimum_should_match counts direct should children. Positive child scores
are added and then multiplied by the group's optional boost. Leaf boosts are
applied to the leaf BM25 score first.
Leaves can select indexed fields with a JSON string array and choose
standard, autocomplete, phrase, phrase_prefix, near, wildcard, or
regex query mode independently. A near leaf takes its own optional
near_distance, so one clause can require proximity while another does not.
Expansion controls and field scoring configuration remain macro-level settings
so all leaves share the same resource limits and field model.
scoring_model := 'bm25f' uses canonical BM25F with optional
field_weights and per-field length normalization through field_b;
best_fields selects the strongest field and optionally incorporates the
others through tie_breaker. Ordinary leaves reuse the non-pattern layered
core, while wildcard and regex leaves share one set-oriented indexed pattern
pipeline. The Boolean layer combines the resulting scores over the same
layered index.
By default, query trees are limited to 1,024 leaves and a maximum Boolean depth
of 64. Callers can lower or raise these resource limits with
max_leaf_clauses and max_boolean_depth; passing NULL disables the
corresponding extension-level limit. The Boolean tree is evaluated recursively,
so these are resource controls rather than fixed SQL-plan limits. Empty groups,
pure-negative groups, unknown keys, mixed leaf/group nodes, invalid boosts, and
out-of-range minimum_should_match values are rejected. Setting
minimum_should_match explicitly to zero on a should-only group matches all
indexed documents; documents that match no optional leaf receive score 0.
stem Functionstem(input_string, stemmer)
Reduces words to their base. Used internally by the extension.
| Name | Type | Description |
|---|---|---|
input_string | VARCHAR | The column or constant to be stemmed. |
stemmer | VARCHAR | The type of stemmer to be used. One of 'arabic', 'armenian', 'basque', 'catalan', 'czech', 'danish', 'dutch', 'dutch_porter', 'english', 'esperanto', 'estonian', 'finnish', 'french', 'german', 'greek', 'hindi', 'hungarian', 'indonesian', 'irish', 'italian', 'lithuanian', 'nepali', 'norwegian', 'persian', 'polish', 'porter', 'portuguese', 'romanian', 'russian', 'serbian', 'sesotho', 'spanish', 'swedish', 'tamil', 'turkish', 'yiddish', or 'none' if no stemming is to be used. |
Create a table and fill it with text data:
CREATE TABLE documents (
document_identifier VARCHAR,
text_content VARCHAR,
author VARCHAR,
doc_version INTEGER
);
INSERT INTO documents
VALUES ('doc1',
'The mallard is a dabbling duck that breeds throughout the temperate.',
'Hannes Mühleisen',
3),
('doc2',
'The cat is a domestic species of small carnivorous mammal.',
'Laurens Kuiper',
2
);
Build the index, and make both the text_content and author columns searchable.
PRAGMA create_fts_index(
'documents', 'document_identifier', 'text_content', 'author'
);
Search the author field index for documents that are authored by Muhleisen. This retrieves doc1:
SELECT document_identifier, text_content, score
FROM (
SELECT *, fts_main_documents.match_bm25(
document_identifier,
'Muhleisen',
fields := 'author'
) AS score
FROM documents
) sq
WHERE score IS NOT NULL
AND doc_version > 2
ORDER BY score DESC;
| document_identifier | text_content | score |
|---|---|---|
| doc1 | The mallard is a dabbling duck that breeds throughout the temperate. | 0.0 |
Search for documents about small cats. This retrieves doc2:
SELECT document_identifier, text_content, score
FROM (
SELECT *, fts_main_documents.match_bm25(
document_identifier,
'small cats'
) AS score
FROM documents
) sq
WHERE score IS NOT NULL
ORDER BY score DESC;
| document_identifier | text_content | score |
|---|---|---|
| doc2 | The cat is a domestic species of small carnivorous mammal. | 0.0 |
Build an incremental index when inserts and deletes should update the index automatically. The document identifier must be non-null and unique:
CREATE TABLE live_documents (
document_identifier VARCHAR NOT NULL,
text_content VARCHAR
);
INSERT INTO live_documents
VALUES ('doc1', 'quacking quacking'),
('doc2', 'barking barking');
PRAGMA create_fts_index(
'live_documents',
'document_identifier',
'text_content',
incremental = true
);
INSERT INTO live_documents VALUES ('doc3', 'meowing');
SELECT document_identifier
FROM (
SELECT *, fts_main_live_documents.match_bm25(
document_identifier,
'meowing'
) AS score
FROM live_documents
) sq
WHERE score IS NOT NULL;
Use layered search when query terms should match exact terms plus prefix, substring, or typo-tolerant alternatives:
CREATE TABLE animal_sounds (
document_identifier VARCHAR NOT NULL,
text_content VARCHAR,
author VARCHAR
);
INSERT INTO animal_sounds
VALUES ('doc1', 'quacking quacking', 'Hannes'),
('doc2', 'barking barking', 'Mark'),
('doc3', 'meowing meowing', 'Laurens');
PRAGMA create_fts_index(
'animal_sounds',
'document_identifier',
'text_content',
'author',
stemmer = 'none',
stopwords = 'none',
layered_search = true,
incremental = true
);
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'quack',
fields := 'text_content',
top_k := 10
);
Weight fields independently, or prefer the strongest matching field while retaining a fraction of the remaining field scores:
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'mark',
field_weights := MAP {
'author': 4.0,
'text_content': 1.0
},
field_b := MAP {'author': 0.3, 'text_content': 0.8},
scoring_model := 'best_fields',
tie_breaker := 0.1,
top_k := 10
);
The scalar layered helper can be used in the same row-filtering pattern as
match_bm25:
SELECT document_identifier, text_content, score
FROM (
SELECT *, fts_main_animal_sounds.match_layered_bm25(
document_identifier,
'mark',
fields := 'author',
enable_short_fuzzy := false
) AS score
FROM animal_sounds
) sq
WHERE score IS NOT NULL
ORDER BY score DESC;
By default, exact dictionary matches are not further expanded. Set
expand_exact_terms := true to include alternatives for exact query terms:
SELECT docname
FROM fts_main_animal_sounds.search_layered_bm25(
'mark',
fields := 'author',
expand_exact_terms := true
);
For low-latency term-prefix search, use autocomplete mode. Earlier tokens are matched exactly after the configured stemming, while only the final token is treated as a raw prefix:
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'han quac',
fields := 'text_content,author',
query_mode := 'autocomplete',
top_k := 10
);
Use phrase mode for exact adjacency and phrase-prefix mode when the final token is still being typed:
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'quacking quacking',
fields := 'text_content',
query_mode := 'phrase',
top_k := 10
);
SELECT docname, score, rank
FROM fts_main_animal_sounds.search_layered_bm25(
'quacking quac',
fields := 'text_content',
query_mode := 'phrase_prefix',
top_k := 10
);
Warning Without
incremental = true, the FTS index is a static snapshot and will not update automatically when the input table changes. Static layered indexes behave the same way: their sidecar tables are also rebuilt only when the index is rebuilt. Withincremental = true, the index and layered sidecar are maintained forINSERTandDELETEstatements on tables that support triggers.
The extension bundles the Snowball stemming
library (vendored under third_party/snowball/).
Starting with the upgrade to Snowball v3, the previously available
'german2' and 'kraaij_pohlmann' stemmers (legacy variants that were
undocumented but accepted by the underlying library) have been removed
upstream. If you relied on them, rebuild the FTS index with one of the
documented stemmers instead.
C++
89.6%
CMake
5.4%
Python
4.8%