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 postgrestsvector 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.
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;
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
SearchBench: a billion OpenTelemetry logs, the Postgres family
92 queries · 60 s cap · hot median92 of 92 finished.
77 of 92 finished.
33 of 92 finished within the cap.
12 of 92 finished within the cap.
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.
docker run -d --name serenedb -p 7890:7890 \ -e SERENEDB_HOST_AUTH_METHOD=trust serenedb/serenedb psql -h localhost -p 7890 -U postgres
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);
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;
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;
SELECT id, title FROM articles_idx WHERE title @@ ts_levenshtein('pasword', 1) ORDER BY id;
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.
Full-text search →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'
| id | title |
|---|---|
| 2 | Rotate database credentials |
| 3 | Password rules |
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.
Zero-ETL search →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;
| id | subject | status | score |
|---|---|---|---|
| 103 | Refund policy question | closed | 0.136 |
| 105 | Refund for a cancelled order | open | 0.126 |
| 101 | Refund not received | open | 0.116 |
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.
Hybrid search →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;
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 with SereneDB
Start one container, point psql or your Postgres driver at port 7890, and order your @@ matches by BM25.