Skip to main content

Pavel Ivanov

Aug 11, 2026 · 15 minutes read

Build a search index on someone else's database

SereneDB indexes tables, files and lakehouses. Now it also indexes tables that live inside Postgres and ClickHouse, without moving the rows.

Ask a team where their text lives and you usually get two answers. The data of record sits in ClickHouse or Snowflake or a few terabytes of Parquet on S3. The searchable copy sits in Elasticsearch. Between them runs a pipeline that somebody wrote two years ago, somebody else maintains now and everyone has opinions about.

The duplication is the obvious cost. Every searchable row exists twice and the search copy tends to be the expensive one, because a document store keeps the original JSON alongside the index so it can hand it back to you.

The subtler cost is that the two halves can't talk to each other. Elasticsearch will rank your documents beautifully and then give you a list of ids. If your next question is "and what was the weekly revenue on those?", you write application code to stitch two result sets together. Usually you just don't ask.

SereneDB exists to collapse that arrangement into one engine: Elasticsearch-grade search and ClickHouse-grade analytics, both addressed over the Postgres wire protocol. This release extends it in a direction we've been working toward for a while. You can now build a full-text index over a table that belongs to a different database entirely.

What "search" means here

Lots of analytical databases have bolted on a text index and put "full-text search" on the feature list. You find out how deep it goes the first time a product manager asks for typo tolerance or ranking that goes beyond recency.

Ours comes from IResearch, a C++ information retrieval library our team has been building since 2016. It's what we benchmark against Lucene and Tantivy in Search Benchmark, The Game. SearchBench puts it against Elastic, OpenSearch, ParadeDB and ArangoDB. In SQL you get:

  • BM25 ranking with real term frequencies and document norms
  • phrase search and sloppy phrase search with a slop window
  • fuzzy terms, prefixes and wildcards
  • facets and aggregations computed over the matched set
  • geospatial predicates on points and geometries
  • vector search: IVF with product quantization, RaBitQ, and 4- and 8-bit scalar quantization
  • hybrid ranking that fuses text and vector results with RRF

Because one planner sees the whole query, a search predicate and an analytical rollup are the same statement:

SELECT date_trunc('day', ts) AS day,
count(*) AS hits,
avg(rating) AS avg_rating
FROM reviews_fts
WHERE body @@ ts_phrase('battery life') AND lang = 'en'
GROUP BY day
ORDER BY day;

In the two-system world that's a search request, a JSON response, a list of ids sent back to your app and a warehouse query with a 40,000-element IN clause. Assuming the id set is small enough to ship at all.

Where an index can point

Here's the design decision the rest of this post depends on. In SereneDB an inverted index does not belong to one table. It asks a data source for two things: a way to read the rows once at build time and a way to get a given row back later if a query needs its contents. Anything that can answer both can be indexed.

So CREATE INDEX ... USING inverted(...) currently accepts:

SourceIn practice
a local tablethe ordinary case, with real-time column-wise updates
a viewindex a projection, a join, a filtered subset, a JSON path
Parquet / CSV / NDJSONon local disk, S3 or Azure Blob, indexed where they sit
Icebergcatalog-managed lakehouse tables
a globone index spanning a whole prefix in object storage
an attached Postgres or ClickHouse tablenew and the subject of the rest of this post

The middle rows are what we've been calling zero-ETL remote search: BM25 and vector search straight over a data lake with no ingestion job in the middle. We wrote that up for source code in code search over a data lake.

A live database behaves differently from a Parquet file. It has its own primary key, its own query planner and its own preferred way of being asked for two thousand rows.

Connecting a database

SereneDB speaks the Postgres wire protocol, so it borrows Postgres's vocabulary for talking to other databases. Two foreign data wrappers ship today, postgres_fdw and clickhouse_fdw. Nothing above the connector layer is specific to either one, so adding an engine takes a connector and nothing else. Two generic wrappers are in progress right now, ODBC and ADBC. Between them they reach most of the database market. More on those at the end.

CREATE SERVER events FOREIGN DATA WRAPPER clickhouse_fdw
OPTIONS (host 'ch.internal', port '9000', database 'prod');

GRANT USAGE ON FOREIGN SERVER events TO analysts;

A CREATE SERVER is catalog DDL. It survives restarts, re-attaches on boot, shows up in pg_foreign_server and participates in dependency tracking like any other catalog object. An ATTACH lasts only as long as the session that made it. Credentials live on the server definition (one shared connection identity, the way ClickHouse itself does it) and access is a grant.

A foreign server is a catalog object inside SereneDB that routes queries to Postgres and ClickHouse while SereneDB holds the inverted indexesA foreign server is a catalog object inside SereneDB that routes queries to Postgres and ClickHouse while SereneDB holds the inverted indexes

A foreign server survives a restart. The server definitions and the inverted indexes live in SereneDB; the tables stay in the databases you already run, reachable over each engine's own protocol. Restart SereneDB and the servers re-attach on boot.

For ClickHouse the connector talks the native TCP protocol, columnar blocks and LZ4, over a pooled and health-checked connection layer, with projection, filter and ORDER BY / LIMIT pushdown, cardinality from system.tables and automatic rebinding when the remote schema shifts under it. After that the remote table is just a table:

SELECT count(*) FROM events.prod.hits;

Indexing it

Everything in this section runs as written. It points at ClickHouse's public playground, so there is nothing to set up on the ClickHouse side at all. Two commands and a paste. The only prerequisite is Docker.

# start SereneDB locally (brings up SereneUI too)
curl -fsSL https://install.serenedb.com | sh

# connect
psql -h localhost -p 7890 -U postgres

The installer prints the exact connect and teardown commands when it finishes, along with a browser URL for SereneUI — use those if it landed on a different port. If there is no psql on the machine it ships one too. Then paste this at the prompt:

CREATE SERVER play FOREIGN DATA WRAPPER clickhouse_fdw
OPTIONS (host 'play.clickhouse.com', port '9440', database 'default',
user 'play', secure 'true');

CREATE TEXT SEARCH DICTIONARY en (template = 'segmentation', case = 'lower');

CREATE VIEW hn AS
SELECT id, title, "by", score, url
FROM play.default.hackernews
WHERE type = 'story' AND score > 500
LIMIT 1024;

CREATE INDEX hn_fts ON hn USING inverted(id, title en)
INCLUDE ("by");

SELECT title, "by", score
FROM hn_fts
WHERE title @@ ts_phrase('rust')
ORDER BY score DESC
LIMIT 4;
                      title                       |      by       | score
--------------------------------------------------+---------------+-------
Async-await on stable Rust | pietroalbini | 1102
I have written a JVM in Rust | lukastyrychtr | 718
Tauri 1.0 – Electron Alternative Powered by Rust | Uninen | 715
Rust/WinRT Public Preview | steveklabnik | 628

The index build takes about two tenths of a second, the search about the same. Three columns end up in three places. title is tokenized into the index, "by" is included so it comes off local disk and score is neither — it is fetched back from ClickHouse by id, but only for the four rows that survived the LIMIT. ClickHouse has no idea any of this happened.

The LIMIT 1024 is there because this runs on somebody else's server. The playground is public and rate-limited, so the example takes a small slice and stays well inside the quota — enough to watch the thing work and no setup on your side at all. Point it at your own ClickHouse the moment you want more than a look. The slice is arbitrary, so your four titles will not be these four.

One more, because it costs people an afternoon: the dictionary above sets case = 'lower'. The default is none, which indexes terms exactly as written and then ts_phrase('rust') matches nothing at all while ts_phrase('Rust') matches everything.

How much of your data the index keeps

This is the knob people usually don't know they have.

By default the index holds postings, positions, norms and document lengths, plus whatever key it needs to find a row again. Your actual column values aren't in there. Ask for them and SereneDB goes and reads them from the source. INCLUDE changes that per column: named columns get written into the index's own columnstore and reading them afterwards costs one local read and no trip to the source at all.

Four requested columns resolve inside SereneDB and only the text column is fetched from ClickHouse for the ten matching rowsFour requested columns resolve inside SereneDB and only the text column is fetched from ClickHouse for the ten matching rows

Included columns stay local. id and the BM25 score come out of the index itself, author and posted_at out of the index's columnstore because they were included. Only text is fetched from ClickHouse, by primary key, for the ten rows that survived the LIMIT.

So you get a dial.

Store nothing. Smallest possible index. Good when the source is fast and local or when most queries only need counts, scores and ids anyway. A surprising number of "search" workloads are really count(*) with a WHERE clause and those never touch the source at all.

Store what you display. The common setting. Include the title, the url, the price, the timestamp: whatever a result card renders. Searches serve entirely from the index; a user who clicks through to a full record pays one keyed read.

Store everything. Now the index is a self-contained local mirror and the source is only consulted when you rebuild. This is roughly what Elasticsearch does with _source, except Elasticsearch decided it for you, which is part of why the mirror costs what it does.

Concretely. A shop's product table holds title, description and brand, which arrive with a supplier catalogue and change a few times a year, next to price_cents and stock, which change all day and change inside transactions, because you cannot sell stock you do not have. Index the text, include the brand and leave the two numbers where the transactions are:

CREATE INDEX products_fts ON products
USING inverted(sku, title en, description en)
INCLUDE (brand);

Search then runs entirely on local disk, matching and ranking and filtering by brand, while the two values a customer actually acts on are read from Postgres as the page renders. The price on the screen cannot be stale, because we do not have it. When the index falls behind, a product from yesterday's catalogue import is not findable yet. Nobody is ever shown a wrong price.

The reason to include a column is that you do not want the round trip. Everything you leave out is read from the source when a query asks for it, so put the columns your result page renders into the index and the search stays on local disk.

There is also a limit worth knowing about. The connector can only go back to a view that reads one source plainly. Put a join or a GROUP BY in the view body, say SELECT customer_id, string_agg(body, ' ') FROM tickets GROUP BY customer_id and reading real columns is refused outright with materialising real columns from this view-backed inverted index is not yet supported. That index still matches, counts and ranks. It just cannot hand you the rows.

What that buys you

No second copy of the corpus. You store an index and you choose per column how much more than that you want.

No pipeline. There's no CDC job, no queue, no mapping template drifting out of sync with a schema, no dashboard panel asking whether the sync is behind. The build is a DDL statement.

Adoption without a migration. This is the part platform teams actually care about. Your warehouse stays where it is, keeps its ingest, its retention rules and its access control while gaining a capability from the outside. If it doesn't work out, DROP SERVER puts you back exactly where you started. That's a much easier conversation than "we should move the data."

Results that are relations. The matched set is a table like any other, so you can aggregate it, window it, join it to a local dimension table, park it in a CTE or rank it by a blend of BM25 and vector distance. Over rows that are physically in ClickHouse.

Keep the database you have. Add search on top of it. Skip the copy in between.

How the lookup works

Four things happen between CREATE INDEX and a row coming back.

At CREATE INDEX time SereneDB finds a key and streams the remote table once; at query time it matches locally and fetches unstored columns in keyed batchesAt CREATE INDEX time SereneDB finds a key and streams the remote table once; at query time it matches locally and fetches unstored columns in keyed batches

The remote engine is touched in three different ways and only one of them repeats. Reading the primary key and streaming the table happen once, when the index is created (dashed). After that the only traffic is the keyed fetch for columns that are neither indexed nor included, one statement per batch of matches.

How a row is identified differs by engine and each default follows from how that engine works. Postgres keys on ctid, the physical row location: universal, no primary key required and the fetch pushes down as a TID scan. ClickHouse keys on the table's MergeTree primary key instead, read from system.columns, because part-and-offset ids do not survive a merge. Either default can be overridden with WITH (key_columns = ...).

The statements differ too, since each planner recognises a different shape: on Postgres the keys travel as a bound array parameter, on ClickHouse as a native columnar block sent alongside the query. Rows come back in whatever order the engine produced them and are put back in place by an ordinal that travelled out with the batch.

What else the connection is good for

A search index is one use of a foreign server. The connection underneath has a few others.

One index over several engines. An index can be built over a view and a view can be a union of tables from different servers. Fresh documents in Postgres, the archive in ClickHouse, cold data as Parquet in object storage and a single index across all of it. One query, one ranking and no stitching results back together in application code. A union has no single table to key on, so this is the keyless mode described above — postings key on a synthetic row id and you include the columns you want back.

Joins across engines. Facts in ClickHouse, dimensions in Postgres, one statement. Neither side is pre-joined or copied and the search index can sit on either of them.

ETL without the tool. Both connectors write as well as read, so INSERT INTO ... SELECT across servers is one statement: Postgres into ClickHouse or either of them into SereneDB's own storage once you decide the data should be local after all. The extract can be driven by a search query, which turns "everything matching this, into that table" into a one-liner — a document export, a training-set build, a subset migration.

All three pull their data through one process, so the scale they suit is a nightly job, a subset or a dimension table. Hundreds of terabytes need a real pipeline.

What comes next: ODBC and ADBC

Two wrappers cover two engines. The next two cover most of the rest.

ODBC is the lowest common denominator of database connectivity. If a database has been sold commercially in the last thirty years, it has an ODBC driver. A wrapper shaped like clickhouse_fdw but speaking ODBC points CREATE SERVER at anything with a DSN: MySQL, SQL Server, Oracle, Snowflake, Redshift, Databricks, anything with a DSN.

ADBC is the newer Arrow-native one and it is the better fit for what we do. Result sets arrive as Arrow columnar batches instead of one row at a time. That matters most during an index build, where we stream a whole table exactly once. Postgres, DuckDB, Snowflake, BigQuery and Flight SQL all have ADBC drivers already.

Neither one changes anything above the connector layer. The index, the INCLUDE columnstore, the key-based fetch and the SQL you write all stay as described above. And for sources where no primary key can be discovered from metadata, which becomes a good deal more likely once you are going through a generic driver, the keyless path already exists: postings key on a synthetic row id and the columns you included come back from the local columnstore.

You end up with one CREATE SERVER surface and one index mechanism. The list of engines you can reach becomes the list of engines that ship a driver.

Smaller items on the connector itself: fuller batch accumulation on the lookup path and parameterized IN {keys:Array} over the ClickHouse native protocol.

Everything here is on main, Apache 2.0.

curl https://install.serenedb.com | sh

Point a CREATE SERVER at a database you already run, build an index on a table that isn't ours and tell us what breaks.