The Tiger Data alternative for BM25 log search and time-series SQL
Tiger Data, formerly Timescale, makes TimescaleDB, a PostgreSQL extension that splits tables into time-based chunks and can convert chunks to a columnstore, and pg_textsearch, a BM25 index for PostgreSQL. SereneDB is an Apache 2.0 database that ranks log lines with BM25 and groups them with time_bucket in the same PostgreSQL-compatible SQL query, over columnar storage that keeps its text index.
median hot query time · the 33 queries both finish
Why teams look for a Tiger Data alternative
Teams running TimescaleDB look further when log search has to rank results and aggregate them in the same query, when columnar chunks must stay searchable, or when loading and indexing a billion rows takes hours. SereneDB does all three in one PostgreSQL-compatible engine.
> BM25 search and time buckets in one query
SereneDB finds log lines with an @@ match, scores them with BM25() in the same index scan and groups them with time_bucket in one SELECT. pg_textsearch ranks with its <@> operator in ORDER BY, and its @@ filters, new in 1.5.0, use the same index, but its README says combining them with BM25 ranking "is not yet optimized as a single index scan."
> Phrase, fuzzy and regex search in the index
SereneDB's inverted index answers phrases with a slop, edit distance, regular expressions, prefixes and wildcards. pg_textsearch 1.5.1 has no positional index and no fuzzy or regex matching: its phrase operator is a Boolean filter rechecked against the table rows, and prefixes work only as a filter.
> Columnar storage that keeps its text index
A SereneDB search table keeps its inverted indexes in the table's own storage and reads other columns with a columnstore scan, so BM25 search runs on the columnar data. TimescaleDB's columnstore keeps no BM25 index: after the conversion, pg_textsearch 1.5.1 on TimescaleDB 2.31.0 indexes no rows of a converted chunk.
> One table, no chunk interval to choose
A SereneDB search table needs no partition column or chunk size, and its inverted index covers every row. A hypertable is split into chunks by a partition column and a chunk interval, 7 days by default, and pg_textsearch keeps BM25 statistics per partition, so scores from different chunks can use different IDF scales.
SereneDB vs Tiger Data, feature by feature
Both speak PostgreSQL's SQL and bucket time the same way. They differ in how search is indexed and ranked, whether columnar data stays searchable, and how a table is laid out.
| Capability | SereneDB | Tiger Data |
|---|---|---|
| Storage | Yes: Search tables: inverted indexes in the table's own storage, other columns read with a columnstore scan | Yes: Hypertables: PostgreSQL tables split into time chunks; chunks can be converted to the columnstore |
| Full-text search | Yes: Inverted index with per-column text analysis: tokenizers, stemming, stop words and synonyms | Yes: pg_textsearch BM25 index over a PostgreSQL text search configuration, and PostgreSQL's tsvector with GIN |
| Relevance ranking | Yes: BM25, TF-IDF and language-model scorers in SQL, with top-k pruning | Partly: BM25 through <@> in ORDER BY, with Block-Max WAND; @@ filters (since 1.5.0) are not yet one index scan with the ranking; statistics kept per partition |
| Phrase and proximity | Yes: ts_phrase with a slop: the words within N positions | Partly: No positional index in pg_textsearch; since 1.5.0 the phrase operator <-> works as a Boolean filter, rechecked against the table rows |
| Fuzzy, regex and prefix | Yes: Edit distance, regular expressions, prefixes and wildcards from the index | Partly: No fuzzy or regex matching in pg_textsearch, and prefixes (:*) only as a Boolean filter; pg_trgm indexes serve LIKE and regular expressions, and levenshtein() has no index operator class |
| Search on columnar data | Yes: BM25, phrase and fuzzy search run on the search table itself, from the inverted index in its own storage | No: Not supported by pg_textsearch: on TimescaleDB 2.31.0 with pg_textsearch 1.5.1, the BM25 index of a chunk converted to the columnstore holds no rows |
| Time bucketing | Yes: time_bucket with offset, origin and time zone and the same default origins; gaps filled with generate_series | Yes: time_bucket; time_bucket_gapfill, locf and interpolate in the Community Edition |
| Vector and hybrid search | Yes: HNSW and IVF in the same inverted index; a text match and a vector order in one query | Yes: pgvector with pgvectorscale's StreamingDiskANN index; Tiger Data's hybrid search tutorial fuses BM25 and vector results with reciprocal rank fusion |
| Scheduled jobs | Yes: CREATE JOB … EVERY … AS any SQL statement; sdb_jobs() lists the runs | Yes: add_job runs a function or procedure on a schedule; policies refresh continuous aggregates, drop old chunks and convert chunks (Community Edition) |
| Updates and transactions | Yes: UPDATE, DELETE and multi-statement ACID transactions | Yes: PostgreSQL transactions; INSERT, UPDATE and DELETE work on columnstore chunks too, decompressing only the batches a statement touches |
| License | Yes: Apache 2.0 | Partly: TimescaleDB: the Apache 2 Edition, and the Community Edition under the Tiger Data License, which may not be offered as a database service; pg_textsearch: PostgreSQL License |
✓ supported · ~ supported with limits · ✕ not supported. Tiger Data facts from Tiger Data's documentation and GitHub for TimescaleDB 2.31.0 and pg_textsearch 1.5.1 and runs on both, SereneDB facts from its documentation and runs on 26.10.7. Last reviewed October 31, 2026.
The same question in TimescaleDB SQL and SereneDB SQL
Each tab asks one question of the same six log lines in both engines and gets the same rows back. The TimescaleDB SQL ran on TimescaleDB 2.31.0 with pg_textsearch 1.5.1 and the SereneDB SQL on SereneDB 26.10.7, both on October 31, 2026.
Errors per 15 minutes
SELECT time_bucket('15 minutes', ts) AS bucket,
count(*) AS timeouts
FROM logs
WHERE to_tsvector('simple', body)
@@ to_tsquery('simple', 'timeout')
GROUP BY bucket
ORDER BY bucket;SELECT time_bucket('15 minutes', ts) AS bucket,
count(*) AS timeouts
FROM logs
WHERE body @@ 'timeout'
GROUP BY bucket
ORDER BY bucket;SereneDB takes TimescaleDB's time_bucket('15 minutes', ts) call as it is. TimescaleDB answers the match from a GIN index on to_tsvector('simple', body); SereneDB answers body @@ 'timeout' from the search table's inverted index. Both count two timeouts at 09:00 and one at 09:45.
Top matches by BM25
-- <@> scores are negative: the best match sorts first
SELECT ts, service, body,
body <@> to_bm25query('connection reset refused',
'logs_bm25') AS score
FROM logs
WHERE to_tsvector('simple', body)
@@ to_tsquery('simple', 'connection | reset | refused')
ORDER BY score, ts DESC
LIMIT 10;SELECT ts, service, body,
BM25(logs.tableoid) AS score
FROM logs
WHERE body @@ ts_any(['connection', 'reset', 'refused'])
ORDER BY score DESC, ts DESC
LIMIT 10;pg_textsearch's <@> returns BM25 as a negative number, so the best match sorts first, and a row without a match scores 0, so the WHERE clause keeps such rows out. Since 1.5.0 that filter can also run through the BM25 index as body @@ to_tsquery(…), once default_text_search_config matches the index's text_config. SereneDB's BM25() scores the rows the @@ match finds, highest first. Both return the same two lines.
Rank and bucket in one query
SELECT time_bucket('15 minutes', ts) AS bucket,
count(*) AS matches,
round(min(score)::numeric, 3) AS best_score,
(array_agg(body ORDER BY score))[1] AS most_relevant
FROM (SELECT ts, body,
body <@> to_bm25query('timeout refused reset',
'logs_bm25') AS score
FROM logs
WHERE to_tsvector('simple', body)
@@ to_tsquery('simple', 'timeout | refused | reset')
) AS hits
GROUP BY bucket
ORDER BY bucket;SELECT time_bucket('15 minutes', ts) AS bucket,
count(*) AS matches,
round(max(score)::NUMERIC, 3) AS best_score,
arg_max(body, score) AS most_relevant
FROM (SELECT ts, body, BM25(logs.tableoid) AS score
FROM logs
WHERE body @@ ts_any(['timeout', 'refused', 'reset'])
) AS hits
GROUP BY bucket
ORDER BY bucket;In SereneDB one inverted-index scan finds the matches and scores them with BM25, and arg_max keeps each bucket's best line. In TimescaleDB the GIN index finds the rows and <@> scores each of them against the BM25 index. Both pick the same best line in each of the three buckets.
Gap-filled buckets
SELECT time_bucket_gapfill('15 minutes', ts) AS bucket,
coalesce(count(*), 0) AS errors
FROM logs
WHERE ts >= '2026-10-31 09:00+00'
AND ts < '2026-10-31 10:15+00'
AND to_tsvector('simple', body)
@@ to_tsquery('simple', 'timeout | refused | reset')
GROUP BY bucket
ORDER BY bucket;SELECT g.bucket, coalesce(m.errors, 0) AS errors
FROM generate_series(TIMESTAMPTZ '2026-10-31 09:00+00',
TIMESTAMPTZ '2026-10-31 10:00+00',
INTERVAL '15 minutes') AS g(bucket)
LEFT JOIN (SELECT time_bucket('15 minutes', ts) AS bucket,
count(*) AS errors
FROM logs
WHERE body @@ ts_any(['timeout', 'refused', 'reset'])
AND ts >= '2026-10-31 09:00+00'
AND ts < '2026-10-31 10:15+00'
GROUP BY bucket) AS m USING (bucket)
ORDER BY g.bucket;time_bucket_gapfill, a Community Edition hyperfunction, adds the empty buckets in TimescaleDB, and coalesce turns their NULL counts into 0. In SereneDB, generate_series lists every bucket and a LEFT JOIN fills the gaps. Both return five buckets: 2, 1, 0, 1 and 0.
Copy a hypertable
-- The hypertable stays in Tiger Data as it is
CREATE TABLE logs (
ts TIMESTAMPTZ NOT NULL,
service TEXT,
severity TEXT,
body TEXT
) WITH (tsdb.hypertable, tsdb.partition_column = 'ts');
-- The hypertables to copy
SELECT hypertable_schema, hypertable_name
FROM timescaledb_information.hypertables;ATTACH 'host=tiger port=5432 dbname=postgres user=postgres'
AS tiger (TYPE postgres, READ_ONLY);
CREATE TABLE logs WITH (storage = 'search') AS
SELECT * FROM tiger.public.logs;
CREATE TEXT SEARCH DICTIONARY log_words
AS split_text(case := 'lower')
WITH (frequency, position, norm);
CREATE INDEX logs_idx ON logs
USING inverted (body log_words, service);ATTACH reads the Tiger Data database over the PostgreSQL protocol, and CREATE TABLE … AS SELECT copies the hypertable through its parent table, every chunk included, into a search table. The dictionary and the inverted index come after the copy, which builds a more compact index than loading into an indexed table.
Move from Tiger Data to SereneDB in three steps
Run SereneDB next to Tiger Data, copy the hypertables over the PostgreSQL protocol, then port the queries and jobs and move traffic once the results match. Attaching PostgreSQL covers the connection options.
Start SereneDB
Run it next to Tiger Data and connect with psql or any PostgreSQL driver on port 7890.
$ docker run -d --name serenedb -p 7890:7890 -e POSTGRES_PASSWORD=<password> serenedb/serenedbCopy the hypertables
Attach the Tiger Data database read-only and copy each hypertable that
timescaledb_information.hypertableslists into a search table, then add the dictionary and the inverted index, as in the last tab above.$ psql -h localhost -p 7890 -U postgres -c "ATTACH 'host=<tiger-host> port=5432 dbname=<db> user=<user> password=<password>' AS tiger (TYPE postgres, READ_ONLY)" -c "CREATE TABLE logs WITH (storage = 'search') AS SELECT * FROM tiger.public.logs"Port queries and jobs
time_bucketcalls run as they are. Rewritetime_bucket_gapfillasgenerate_serieswith a LEFT JOIN,first()andlast()asarg_min()andarg_max(),<@>ranking asBM25()and tsvector matches as@@. Continuous aggregates become rollup tables filled by CREATE JOB, retention policies a scheduled DELETE, and Grafana keeps its PostgreSQL data source with raw SQL queries.$ psql -h localhost -p 7890 -U postgres -c "CREATE JOB logs_retention EVERY 1 DAY AS DELETE FROM logs WHERE ts < now() - INTERVAL '30 days'"
Yes. Timescale took the name Tiger Data on June 17, 2025, and TimescaleDB, its PostgreSQL extension, kept its name. Tiger Data also builds pg_textsearch, a BM25 index for PostgreSQL, and pgvectorscale, a vector index for pgvector.
TimescaleDB has two editions. The Apache 2 Edition is open source under Apache 2.0. The Community Edition, under the Tiger Data License, adds the columnstore, continuous aggregates, retention policies, jobs and functions such as time_bucket_gapfill; it is free to run yourself, but the license does not allow offering it as a database service. pg_textsearch uses the PostgreSQL License, and SereneDB is licensed under Apache 2.0.
No. Tiger Data's pg_textsearch guide lists compressed (Hypercore) data as unsupported. On TimescaleDB 2.31.0 with pg_textsearch 1.5.1, the BM25 index of a chunk converted to the columnstore holds no rows, even when it is rebuilt after the conversion, and SearchBench's columnstore run on 2.29.2 could not run its 16 BM25 queries. SereneDB runs BM25 search on its columnar search tables.
On SearchBench's 92 log search and analytics queries over 1B OpenTelemetry logs, TimescaleDB 2.29.2 with pg_textsearch 1.4.0 finished 33 within 60 seconds and PostgreSQL 18.4 with tsvector and GIN finished 12. SereneDB 26.09.1 finished all 92, with a 36× lower median than TimescaleDB on the 33 both finish.
SearchBench ran all three on the same 92 queries over 1B OpenTelemetry logs. SereneDB 26.09.1 answered all 92, ClickHouse 26.8.2.7 can express 70, and TimescaleDB 2.29.2 with pg_textsearch 1.4.0 finished 33. SereneDB's median query time was 13× lower than ClickHouse's on the 70 both finish and 36× lower than TimescaleDB's on the 33. The ClickHouse comparison covers SereneDB against ClickHouse.
Mostly. SereneDB's time_bucket takes the same width, offset, origin and time-zone arguments, positional or named, with the same default origins, so those calls run unchanged: they returned the same buckets on SereneDB 26.10.7 and TimescaleDB 2.31.0. Rewrite time_bucket_gapfill, first() and last(), and integer-width buckets.
Keep the rollup in a table that a CREATE JOB fills on a schedule with INSERT … SELECT time_bucket(…) over the newest rows, and replace add_retention_policy with a job that runs a DELETE. Jobs run inside the server, and sdb_jobs() lists each job's runs and failures.
On SearchBench, yes. Over 1B OpenTelemetry logs, SereneDB 26.09.1 had a 36× lower median hot query time than TimescaleDB 2.29.2 with pg_textsearch 1.4.0 on the 33 queries both finish (13.0 ms against 474 ms), finished all 92 queries and loaded the data 67× faster. On cold runs SereneDB's lead is 4.6×. TimescaleDB 2.31 and pg_textsearch 1.5 have not been measured yet.