Full-Text Search
View as MarkdownFull-text search in Nucleus is an index on a table column, not a separate store. Rows
are matched with the @@ operator and ranked with BM25(), so search results are
ordinary rows: joinable, filterable, and covered by the same transactions and
row-level security policies as everything else.
Because keyword search and vector search both return rows from the same table, hybrid search is a plain SQL query — no fusion built-in, no second system to keep in sync.
Creating an Index
CREATE TABLE articles (
id INT PRIMARY KEY,
title TEXT,
body TEXT,
category TEXT,
embedding VECTOR(384)
);
CREATE INDEX articles_body_fts ON articles USING FTS (body);
USING BM25 is accepted as a synonym.
The index is maintained by INSERT, UPDATE, and DELETE like any other index, and
is rebuilt from committed rows if a transaction aborts. There is nothing to
re-synchronise by hand.
Requirement: the table needs an integer PRIMARY KEY. Documents are keyed on it so
that maintenance survives deletes, which shift physical row positions. Tables without
one can still use @@ — they just don't get an index.
Matching
column @@ 'query' is true when the column contains every term in the query, after
stemming and stopword removal.
SELECT id, title
FROM articles
WHERE body @@ 'machine learning'
AND category = 'tech'
ORDER BY published_at DESC
LIMIT 10;
@@ is defined on the row's own text, so it returns the same rows whether or not an
index exists. The index makes it faster; it never changes the answer.
Ranking
BM25(column, 'query') scores a row against the corpus statistics of that column's
index.
SELECT id, title, BM25(body, 'machine learning') AS score
FROM articles
WHERE body @@ 'machine learning'
ORDER BY score DESC
LIMIT 10;
Okapi BM25 with the standard parameters — k1 = 1.2 (term frequency saturation) and
b = 0.75 (document length normalisation). Shorter documents containing more
occurrences of rarer terms score higher.
BM25() requires an FTS index on the column, because inverse document frequency and
average document length are properties of the corpus, not of one row. Without an index
it reports that, rather than scoring against an empty corpus.
Hybrid Search
Reciprocal Rank Fusion combines a keyword ranking and a vector ranking by their positions rather than their scores, which sidesteps the problem that BM25 scores and cosine distances are not on a comparable scale.
WITH kw AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY BM25(body, 'machine learning') DESC) AS r
FROM articles
WHERE body @@ 'machine learning'
LIMIT 50
),
sem AS (
SELECT id, ROW_NUMBER() OVER (
ORDER BY VECTOR_DISTANCE(embedding, VECTOR('[0.1, 0.2, ...]'), 'cosine')
) AS r
FROM articles
LIMIT 50
)
SELECT COALESCE(kw.id, sem.id) AS id,
COALESCE(1.0 / (60 + kw.r), 0) + COALESCE(1.0 / (60 + sem.r), 0) AS score
FROM kw FULL OUTER JOIN sem ON kw.id = sem.id
ORDER BY score DESC
LIMIT 10;
Both halves read the same table in the same snapshot, through the same policies.
The constant 60 is the conventional RRF damping factor; raising it flattens the
contribution of top ranks. Because fusion is expressed in the query rather than hidden
in a built-in, you can weight the two halves differently, add a third ranking, or
replace the fusion entirely without waiting for a new function.
PostgreSQL Compatibility
The PostgreSQL spelling works and returns the same rows:
SELECT * FROM articles
WHERE TO_TSVECTOR(body) @@ PLAINTO_TSQUERY('machine learning');
-- Boolean match test
SELECT * FROM articles WHERE TS_MATCH(body, 'machine learning');
-- Highlight matching terms with <em> tags
SELECT TS_HEADLINE(body, 'rust') FROM articles;
-- → "<em>Rust</em> is a systems programming language"
-- Convert text to a stemmed query
SELECT PLAINTO_TSQUERY('machine learning');
-- → "machine & learn"
Two differences worth knowing:
TS_RANKis not BM25. Like PostgreSQL'sts_rank, it scores a single(document, query)pair with no corpus, so it has no inverse document frequency and no length normalisation. It is fine for a rough within-document signal, and it will order results differently fromBM25(). UseBM25()for relevance ranking.TO_TSVECTORreturns text, a normalised term string, not a distincttsvectortype with lexeme positions. It composes correctly with@@andPLAINTO_TSQUERY; it does not render like PostgreSQL's'learn':2 'machin':1.
Behaviour to Expect
- Corpus statistics are not snapshot-isolated.
N, average document length, and document frequencies come from the current index rather than from your transaction's snapshot, so a score can shift as other sessions write. This matches how Lucene, Elasticsearch, and PostgreSQL statistics behave; the set of rows you get back is snapshot-exact, only the ranking weights are shared. - Under row-level security,
@@andBM25()remain available and filter through policy like any other predicate. Index acceleration is skipped, so search falls back to a scan; results are unchanged. Corpus statistics are aggregate and are not partitioned by policy — a document frequency counts rows the querying role cannot read, the same way PostgreSQL's planner statistics do. - Inside an open transaction, index acceleration is likewise skipped so that
uncommitted rows cannot leak into another session's candidate set.
@@still evaluates correctly against your own uncommitted rows.
Stemming
Six built-in language stemmers normalise words to their root form:
| Language | Examples | |----------|----------| | English (default) | running → run, learning → learn | | German | Übungen → Übung | | French | étudiantes → étudiant | | Spanish | corriendo → corr | | Italian | velocemente → veloce | | Portuguese | correndo → corr |
Tokenization Pipeline
- Split on non-alphanumeric characters
- Lowercase
- Filter stopwords (48 common English words)
- Apply language-specific stemming
Both sides of a comparison run through the same pipeline, so a query term matches a document term whenever their stems agree.
Document Store Surface (Legacy)
Nucleus also exposes the inverted index directly, keyed by a document id you supply:
SELECT FTS_INDEX(1, 'Rust is a systems programming language');
SELECT FTS_SEARCH('systems programming', 10);
-- → [{"doc_id":1,"score":2.45}]
SELECT FTS_FUZZY_SEARCH('systms programing', 2, 10);
SELECT FTS_REMOVE(1);
This store is independent of your tables. Nothing ties doc_id 1 to any row, so
keeping it consistent with a table is the application's job — a missed FTS_REMOVE
leaves a deleted row searchable. It is also unavailable while row-level security is
active, because it has no policy-aware access path.
Prefer CREATE INDEX ... USING FTS for anything that indexes table data. The document
store remains for corpora that genuinely have no table behind them, and for fuzzy and
faceted search, which the table-attached index does not yet expose.
Use Cases
- Site search — full-text search across pages and posts
- Product search — find products by description, ranked by relevance
- Log analysis — search through structured log messages
- Knowledge base — search documentation and articles
- Retrieval for AI — hybrid keyword + vector retrieval in one query, one snapshot