SereneDB

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.

SereneDB
13.0 ms
Tiger Data
474 ms

median hot query time · the 33 queries both finish

SearchBench/ 1B OpenTelemetry logs · 92 queries · SereneDB 26.09.1 vs TimescaleDB 2.29.2 + pg_textsearch 1.4.0
92 vs 33queries finished in 60 s vs TimescaleDB 2.29.236×faster median query67×faster ingest
View Full Benchmark →
SereneDBgeomean 1.11×load 5m 10s
Tiger Data hypertablegeomean 14.43×load 5h 43m
Tiger Data columnstoregeomean 2.24×load 6h 27m
fastest2×4×8×+timed out (60 s)no BM25 indexhover a cell to see the query
Why teams switch

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.

01

> 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."

02

> 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.

03

> 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.

04

> 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.

Side by side

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.

CapabilitySereneDBTiger Data
StorageYes: Search tables: inverted indexes in the table's own storage, other columns read with a columnstore scanYes: Hypertables: PostgreSQL tables split into time chunks; chunks can be converted to the columnstore
Full-text searchYes: Inverted index with per-column text analysis: tokenizers, stemming, stop words and synonymsYes: pg_textsearch BM25 index over a PostgreSQL text search configuration, and PostgreSQL's tsvector with GIN
Relevance rankingYes: BM25, TF-IDF and language-model scorers in SQL, with top-k pruningPartly: 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 proximityYes: ts_phrase with a slop: the words within N positionsPartly: 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 prefixYes: Edit distance, regular expressions, prefixes and wildcards from the indexPartly: 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 dataYes: BM25, phrase and fuzzy search run on the search table itself, from the inverted index in its own storageNo: 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 bucketingYes: time_bucket with offset, origin and time zone and the same default origins; gaps filled with generate_seriesYes: time_bucket; time_bucket_gapfill, locf and interpolate in the Community Edition
Vector and hybrid searchYes: HNSW and IVF in the same inverted index; a text match and a vector order in one queryYes: pgvector with pgvectorscale's StreamingDiskANN index; Tiger Data's hybrid search tutorial fuses BM25 and vector results with reciprocal rank fusion
Scheduled jobsYes: CREATE JOB … EVERY … AS any SQL statement; sdb_jobs() lists the runsYes: add_job runs a function or procedure on a schedule; policies refresh continuous aggregates, drop old chunks and convert chunks (Community Edition)
Updates and transactionsYes: UPDATE, DELETE and multi-statement ACID transactionsYes: PostgreSQL transactions; INSERT, UPDATE and DELETE work on columnstore chunks too, decompressing only the batches a statement touches
LicenseYes: Apache 2.0Partly: 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.


Same question, two queries

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

Tiger Data — TimescaleDB and pg_textsearch
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;
SereneDB — SQL
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

Tiger Data — TimescaleDB and pg_textsearch
-- <@> 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;
SereneDB — SQL
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

Tiger Data — TimescaleDB and pg_textsearch
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;
SereneDB — SQL
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

Tiger Data — TimescaleDB and pg_textsearch
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;
SereneDB — SQL
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

Tiger Data — the hypertable to copy
-- 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;
SereneDB — SQL
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.


Migration

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.

  1. 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/serenedb
  2. Copy the hypertables

    Attach the Tiger Data database read-only and copy each hypertable that timescaledb_information.hypertables lists 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"
  3. Port queries and jobs

    time_bucket calls run as they are. Rewrite time_bucket_gapfill as generate_series with a LEFT JOIN, first() and last() as arg_min() and arg_max(), <@> ranking as BM25() 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'"

FAQ

Questions from Tiger Data users

Go to docs →

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.