SereneDB

Postgres full-text search: the same @@ queries, ranked by BM25

SereneDB runs Postgres full-text queries on its own inverted index: the same @@ operator and *_tsquery parser names, ranked by BM25 instead of ts_rank, with typo matching in the same index. It speaks the PostgreSQL wire protocol, so psql and most Postgres drivers connect unchanged, and it can index tables your Postgres keeps.

psql -h localhost -p 7890 -U postgres
same query · two enginespostgresserenedb
An animation in three scenes. First, body @@ websearch_to_tsquery('password OR authenticator') over five help articles: PostgreSQL 17.11 with ts_rank_cd gives all four matches the same rank, 0.1, while SereneDB 26.11.2 ranks them by BM25: Reset two-factor sign-in 0.606, because authenticator is in only one body of five, then Rotate database credentials 0.275, Password rules 0.261 and Reset a forgotten password 0.225. Second, title @@ ts_levenshtein('pasword', 1): PostgreSQL finds 0 rows, because pasword is not a lexeme, while SereneDB matches password, one edit away, in the titles of articles 1 and 3. Third, SearchBench over 1 billion OpenTelemetry log rows and 92 queries with a 60-second cap: SereneDB 26.09.1 finished all 92 at a median of 31.0 ms, ParadeDB 0.25.0 finished 77 at 413.0 ms, Tiger Data's pg_textsearch 1.4.0 finished 33 and PostgreSQL 18.4 with GIN finished 12, so for both the median query timed out.
What carries over

tsvector and ts_rank, mapped to an inverted index and BM25

Postgres full-text search is a tsvector column, a GIN index, a *_tsquery parser and ts_rank or ts_rank_cd. PostgreSQL's own docs say the ranking functions “do not use any global information”, so a word in every row weighs as much as a word in one. Hover a mapping to see where it lands in each version of the same search.

migration map · password OR authenticator
postgres · tsvector + gin4 matches · all 0.1
ALTER TABLE articles ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
 
CREATE INDEX articles_tsv ON articles USING GIN (tsv);
 
SELECT id, title, ts_rank_cd(tsv, q) AS rank
FROM articles,
  websearch_to_tsquery('english', 'password OR authenticator') q
WHERE tsv @@ q
ORDER BY rank DESC, id LIMIT 10;
 
-- typos: CREATE EXTENSION pg_trgm;
serenedb · inverted indexarticle 4 first
CREATE TEXT SEARCH DICTIONARY english_dict AS
  split_text(case := 'lower') | stem_words('en')
  WITH (frequency, position, norm);
 
CREATE INDEX articles_idx ON articles
  USING inverted (id, title simple_dict, body english_dict);
 
SELECT id, title, BM25(articles_idx.tableoid) AS rank
FROM articles_idx
WHERE body @@ websearch_to_tsquery('password OR authenticator')
ORDER BY rank DESC, id LIMIT 10;
 
SELECT id, title FROM articles_idx
WHERE title @@ ts_levenshtein('pasword', 1);

Same query, same five articles. PostgreSQL 17.11 scores all four matches 0.1; SereneDB 26.11.2 ranks article 4 first with BM25, because authenticator is the rarer word.

A text search dictionary takes the place of the configuration. english_dict lower-cases and stems with the Snowball English stemmer and, unlike Postgres's english configuration, keeps stop words; simple_dict only lower-cases.

SereneDB has no tsvector columns or values: the inverted index analyzes the column itself, so there is no generated column or trigger to keep in sync.

The @@ operator is the same. The query names the index in FROM and the column on the left of @@; @@ against the base table is an error.

The column's dictionary analyzes the query text, so the parser functions take no configuration. websearch_to_tsquery never raises on malformed input, so search-box text can go in as a bound parameter.

SereneDB's to_tsquery parses Lucene-style query syntax: password AND reset, pass*, pasword~1. Parsing is strict, so a Postgres string such as 'password & reset' is a parse error; rewrite those.

ts_rank_cd gives all four matches 0.1. BM25 puts article 4 first: authenticator is in one body of five, password in three, and shorter bodies score higher.

Typo matching runs in the same inverted index: pasword is one edit from password and matches the titles of articles 1 and 3.

ts_highlight wraps matched terms in <b>…</b>. With the offset flag on the dictionary it reads their byte offsets from the index; without it, it re-analyzes the matched text.


Architecture

From your Postgres driver to a BM25-ranked inverted index

psql and the psycopg, node-postgres, Npgsql, libpqxx and tokio-postgres drivers have full support; JDBC, DBeaver, DataGrip and Grafana partial. A Postgres database you already run can feed the same kind of index through a view. Hover a stage, or a column inside the index.

01 · client
psycopg
full support
01 · client
node-postgres · psql
full support
01 · client
JDBC · DBeaver · Grafana
partial
02 · protocol
PostgreSQL wire protocol
port 7890
02 · your postgres
CREATE SERVER app
rows stay in Postgres
03 · inverted index
articles_idx · tickets_idx
body english_dictstemmed
title simple_dictwords
tickets_v (remote)snapshot
REINDEX or reindex_interval refreshes the view's snapshot
04 · parse
websearch_to_tsquery
search-box text
05 · rank
BM25()
k1 1.2 · b 0.75
06 · typos
ts_levenshtein
ts_ngram
07 · result
Rows to the driver
same result format
> client · psycopg

The driver you already use

Most Postgres drivers connect without code changes. Bind the user's text as a parameter; SereneDB parses it with websearch_to_tsquery on the server. Tested with psycopg 3.3.7.

python
import psycopg
conn = psycopg.connect("host=localhost port=7890 user=postgres")
rows = conn.execute(
"SELECT id, title FROM articles_idx"
" WHERE body @@ websearch_to_tsquery(%s)"
" ORDER BY BM25(articles_idx.tableoid) DESC, id LIMIT 10",
("password OR authenticator",),
).fetchall()
> client · psql

psql works as is

The clients page lists psql, psycopg, node-postgres, Npgsql, libpqxx and tokio-postgres with full support. The trust setting is for a throwaway local trial: it lets every connection that reaches the port in without a password.

shell
docker run -d --name serenedb -p 7890:7890 \
-e SERENEDB_HOST_AUTH_METHOD=trust serenedb/serenedb
psql -h localhost -p 7890 -U postgres
> client · partial

JDBC, DBeaver, DataGrip and Grafana

The clients page lists them with partial support. Grafana uses its built-in PostgreSQL data source with raw SQL; the query builder is not supported. This query returns articles 2, 3 and 1.

sql
SELECT id, title FROM articles_idx
WHERE body @@ websearch_to_tsquery('password')
ORDER BY BM25(articles_idx.tableoid) DESC
LIMIT 10;
> stage 02 · protocol

PostgreSQL's SQL and wire protocol, SereneDB's engine

SereneDB implements PostgreSQL's SQL dialect and speaks its wire protocol. Full-text search runs on its own inverted index, and the compatibility matrix lists which text-search functions carry over.

sql
SELECT version();
-- PostgreSQL 18.6 (SereneDB 26.11.2)
> stage 02 · your postgres

Index the Postgres you already run

CREATE SERVER stores the connection in the catalog, so it survives restarts, unlike ATTACH. The postings live in SereneDB and the rows stay in Postgres. The index is a snapshot: REINDEX INDEX refreshes it, and reindex_interval repeats that in the background.

sql
CREATE SERVER app FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'pg.internal', port '5432', database 'shop',
user 'reader', password 'secret');
CREATE VIEW tickets_v AS
SELECT id, subject, body, status FROM app.public.tickets;
CREATE INDEX tickets_idx ON tickets_v
USING inverted (subject english_dict, body english_dict);
-- after rows change in Postgres:
REINDEX INDEX tickets_idx;
> stage 03 · inverted index

Two dictionaries in place of simple and english

english_dict stems, simple_dict only lower-cases. frequency feeds BM25, position answers phrases, and norm lets BM25 normalize by length. New rows become searchable at the next refresh, every 1000 ms by default.

sql · hover a line
CREATE INDEX articles_idx ON articles
USING inverted (
body english_dict, -- stemmed
title simple_dict, -- words
id
);
CREATE INDEX tickets_idx ON tickets_v -- remote view
USING inverted (subject english_dict, body english_dict);
> stage 04 · parse

Search-box text, as typed

Quoted phrases, OR, and a leading minus to exclude. websearch_to_tsquery never raises on malformed input, so the box text goes straight in. This query returns article 1.

sql
SELECT id, title FROM articles_idx
WHERE body @@ websearch_to_tsquery('"reset link" -authenticator');
> stage 05 · rank

BM25 in ORDER BY

Article 4 comes first: authenticator is in one body of five, password in three, and BM25 weighs the rarer term higher. BM25(index.tableoid) defaults to k1 1.2 and b 0.75; b needs the norm flag on the dictionary.

sql
SELECT id, title, ROUND(BM25(articles_idx.tableoid)::NUMERIC, 3) AS score
FROM articles_idx
WHERE body @@ websearch_to_tsquery('password OR authenticator')
ORDER BY score DESC, id
LIMIT 10;
> stage 06 · typos

Typos without an extension

ts_levenshtein matches indexed words within an edit distance, and counts a swap of two adjacent letters as one edit by default; ts_ngram matches a column indexed with an n-gram dictionary.

sql
SELECT id, title FROM articles_idx
WHERE title @@ ts_levenshtein('pasword', 1)
ORDER BY id;
> stage 07 · result

Rows back to the same driver

Results come back over the same protocol, so the code that reads rows does not change. The psycopg query above prints articles 4, 2, 3 and 1.

python
for id, title in rows:
print(id, title)
A diagram in seven stages. Clients such as psycopg, node-postgres and psql, with full support, or JDBC, DBeaver and Grafana, with partial support, connect over the PostgreSQL wire protocol on port 7890. A Postgres database you already run can feed the index through CREATE SERVER and a view. The inverted index holds body with english_dict, title with simple_dict and the remote tickets view as a snapshot. websearch_to_tsquery parses search-box text, BM25 ranks the matches, ts_levenshtein matches typos, and the rows go back to the same driver.

SearchBench: a billion OpenTelemetry logs, the Postgres family

92 queries · 60 s cap · hot median
serenedb 26.09.1
31.0 ms

92 of 92 finished.

paradedb 0.25.0 · pg_search
413.0 ms

77 of 92 finished.

tiger data · pg_textsearch 1.4.0
median timed out

33 of 92 finished within the cap.

postgres 18.4 · tsvector + gin
median timed out

12 of 92 finished within the cap.

View the full benchmark →

Build it

Postgres full-text search with BM25, in five steps

One container, two dictionaries, one index, then a BM25-ranked query and a typo-tolerant one. Every statement below ran on SereneDB 26.11.2 on 2026-11-03.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 \
  -e SERENEDB_HOST_AUTH_METHOD=trust serenedb/serenedb
psql -h localhost -p 7890 -U postgres
SELECT version() answers PostgreSQL 18.6 (SereneDB 26.11.2).
step 02 / 05 · sql
CREATE TEXT SEARCH DICTIONARY simple_dict AS
  split_text(case := 'lower')
  WITH (frequency, position);

CREATE TEXT SEARCH DICTIONARY english_dict AS
  split_text(case := 'lower') | stem_words('en')
  WITH (frequency, position, norm);

CREATE TABLE articles (id INTEGER PRIMARY KEY, title VARCHAR, body VARCHAR);

CREATE INDEX articles_idx ON articles
  USING inverted (id, title simple_dict, body english_dict);
english_dict keeps stop words: 'Resetting the passwords on the account page' becomes {reset,the,password,on,the,account,page}. frequency feeds BM25, position phrase queries, norm length normalization.
step 03 / 05 · sql
INSERT INTO articles VALUES
  (1, 'Reset a forgotten password',  'Request a password reset link from the sign-in page.'),
  (2, 'Rotate database credentials', 'Rotate the database password without downtime.'),
  (3, 'Password rules',              'Passwords need twelve characters and one digit.'),
  (4, 'Reset two-factor sign-in',    'Resetting a lost authenticator starts on the account page.'),
  (5, 'Fix a dropped connection',    'Retry with backoff when the server resets the connection.');

VACUUM (REFRESH_TABLE) articles;
INSERT 0 5. Without VACUUM (REFRESH_TABLE), new rows are searchable after the next refresh, every 1000 ms by default.
step 04 / 05 · sql
SELECT id, title, ROUND(BM25(articles_idx.tableoid)::NUMERIC, 3) AS score
FROM articles_idx
WHERE body @@ websearch_to_tsquery('password OR authenticator')
ORDER BY score DESC, id
LIMIT 10;
4 Reset two-factor sign-in 0.606 · 2 Rotate database credentials 0.275 · 3 Password rules 0.261 · 1 Reset a forgotten password 0.225. PostgreSQL 17.11 gives all four 0.1 with ts_rank_cd.
step 05 / 05 · sql
SELECT id, title FROM articles_idx
WHERE title @@ ts_levenshtein('pasword', 1)
ORDER BY id;
pasword is one edit from password: articles 1 and 3, matched on the unstemmed title.

Use cases

What teams build on it

A search box for a Postgres app

The app keeps its driver and passes what the user typed to websearch_to_tsquery as a bound parameter: quoted phrases, OR and a leading minus, ranked by BM25.

drivers
psycopgnode-postgresNpgsql
Full-text search →
example · bound parameter
What does the box send for password -reset?
SELECT id, title FROM articles_idx
WHERE body @@ websearch_to_tsquery($1)
ORDER BY BM25(articles_idx.tableoid) DESC, id
LIMIT 10;
-- $1 = 'password -reset'
result
idtitle
2Rotate database credentials
3Password rules
Article 1 also holds password, but the minus drops it for reset. Malformed input never raises: '"reset -( OR' returns 0 rows, not an error.

Search over tables you keep in Postgres

SereneDB indexes a view over a table Postgres keeps as the system of record, through a remote PostgreSQL server. Matches come back with their Postgres columns, and they join with SereneDB tables in the same query.

statements
CREATE SERVERviewREINDEX
Zero-ETL search →
example · tickets in postgres
CREATE SERVER app FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (host 'pg.internal', port '5432', database 'shop',
           user 'reader', password 'secret');

CREATE VIEW tickets_v AS
  SELECT id, subject, body, status FROM app.public.tickets;

CREATE INDEX tickets_idx ON tickets_v
  USING inverted (subject english_dict, body english_dict);

SELECT id, subject, status,
       ROUND(BM25(tickets_idx.tableoid)::NUMERIC, 3) AS score
FROM tickets_idx
WHERE body @@ websearch_to_tsquery('refund -duplicate')
ORDER BY score DESC, id LIMIT 10;
result · 5 tickets in postgres
idsubjectstatusscore
103Refund policy questionclosed0.136
105Refund for a cancelled orderopen0.126
101Refund not receivedopen0.116
The postings live in SereneDB; the rows stay in Postgres. Ticket 102 also asks for a refund, but -duplicate drops it. A ticket added in Postgres shows up after REINDEX INDEX tickets_idx.

Keyword and vector search together

The same inverted index takes a FLOAT[N] embedding column, so a websearch_to_tsquery filter and vector distance meet in one statement.

indexed columns
body english_dictemb ivf
Hybrid search →
example · filtered ann
CREATE INDEX kb_idx ON kb USING inverted
  (id, title simple_dict, body english_dict,
   emb ivf (metric = 'cosine'));

SELECT id, title FROM kb_idx
WHERE body @@ websearch_to_tsquery('password')
ORDER BY emb <=> ai_embed('locked out of my account',
    'all-minilm', 'local_ai')::FLOAT[384]
LIMIT 5;
kb holds the articles with an emb FLOAT[384] column filled by ai_embed. local_ai is a secret of TYPE openai whose base_url points at an embedding server such as Ollama; all-minilm returns 384 dimensions. See hybrid search in the docs.

FAQ

Frequently asked questions

Go to docs →

Yes. PostgreSQL has tsvector and tsquery types, the @@ match operator, GIN and GiST indexes for them, and two ranking functions, ts_rank and ts_rank_cd. The ranking functions look only at the row being scored, not at the rest of the table, so a common word counts as much as a rare one.

Not in core. Extensions add BM25 inside Postgres, among them ParadeDB's pg_search and Tiger Data's pg_textsearch, which AlloyDB offers in preview. SereneDB ranks Postgres-syntax matches with BM25() in ORDER BY, k1 1.2 and b 0.75 by default, over the PostgreSQL wire protocol. On SearchBench's billion OpenTelemetry logs, SereneDB 26.09.1 finished all 92 queries at a median of 31.0 ms, ParadeDB 0.25.0 finished 77 at 413.0 ms, and pg_textsearch 1.4.0 finished 33; the write-up has the method. SereneDB vs Tiger Data compares SereneDB with TimescaleDB and pg_textsearch feature by feature.

In PostgreSQL it usually means the first argument is text or varchar: ts_rank takes a tsvector, so pass to_tsvector(body) or a tsvector column. SereneDB has no ts_rank or ts_rank_cd at all; it ranks matches with BM25(index.tableoid) or TFIDF() in ORDER BY.

ts_rank scores a row by how often the query's lexemes occur in it; ts_rank_cd also rewards matches that sit close together (cover density). Both take weights and an optional length normalization, and neither uses statistics about the rest of the table. BM25 adds how rare each term is across the rows, plus length normalization.

In PostgreSQL, through contrib modules: pg_trgm for trigram similarity, with GIN or GiST trigram indexes, and fuzzystrmatch for levenshtein(). SereneDB matches typos inside its full-text index: ts_levenshtein on a word column, ts_ngram on an n-gram column, or the ~N operator of to_tsquery.

For matching and filtering, tsvector, GIN and websearch_to_tsquery work inside the database. What the built-in text search does not have is corpus-aware ranking or typo tolerance, so teams add extensions such as pg_trgm and a BM25 extension, or run a separate search engine fed by a sync pipeline. SereneDB keeps the Postgres protocol and query syntax and adds BM25, phrase, prefix and fuzzy queries in one engine. SereneDB vs Elasticsearch compares it with Elasticsearch feature by feature.

No. SereneDB is a separate engine that implements PostgreSQL's SQL dialect and wire protocol: SELECT version() answers “PostgreSQL 18.6 (SereneDB 26.11.2)”, and CREATE EXTENSION does not parse. Full-text search runs on its own inverted index, and its TSQUERY is not PostgreSQL's tsquery type; tsvector columns and values are not supported.

Yes, as a snapshot. CREATE SERVER … FOREIGN DATA WRAPPER postgres_fdw stores the connection, a view names the table, and CREATE INDEX … USING inverted on the view builds the postings in SereneDB while the rows stay in Postgres. REINDEX INDEX picks up changes, and reindex_interval repeats it in the background. More in zero-ETL search.

> get started

Get started with SereneDB

Start one container, point psql or your Postgres driver at port 7890, and order your @@ matches by BM25.