Skip to main content

Alexander Malandin

May 25, 2026 · 9 minutes read

Why SQL Is the Right Language for Search-OLAP

BM25 scores and vector distances belong in the same query plan as your joins and aggregations

The retrieval/analytics divide

Search and analytics are usually split for good reasons. Search engines are built around relevance: matching, scoring, ranking and returning useful results fast. Analytical systems are built around scans, joins, grouping and historical reporting. In production, these become separate projections of the same business reality, kept in sync by pipelines and stitched together by application code.

The trouble starts when the product asks an analytical question about a relevance-defined set: “among the best-matching documents, count by category,” “among the documents nearest to a query embedding, count by category,” or “for users matching this query, show revenue by region.” Now relevance lives in one system and aggregation context lives in another. The query crosses the seam: ask search what matches, translate that into IDs or filters, query the analytical copy, then reconcile the answers. The result is added latency, freshness ambiguity, semantic drift and glue code no one wants to own.

Before Search-OLAP: split search and analytics with separate projectionsBefore Search-OLAP: split search and analytics with separate projections

We built SereneDB around a different premise: retrieval and analytics in the same engine, behind the same query language, over the same data. We're calling this search-OLAP — analytical SQL queries (joins, aggregations, ranking) over candidate sets selected by full-text or vector predicates. It's an emerging term and we hope it catches on, because we think the workload is becoming common enough to deserve one.

After Search-OLAP: SereneDB handles search and analytics in one SQL query planAfter Search-OLAP: SereneDB handles search and analytics in one SQL query plan

Why SQL is the right language for it

Search-OLAP queries need to do two things at once: select a candidate set with a search predicate and then run real analytical work over that set. Search DSLs excel at the first, SQL at the second but few languages do both.

Search engines excel at ranking denormalized documents. Where they get awkward is the relational half: arbitrary joins, window functions, QUALIFY, correlated subqueries, set operations, recursive queries. Those aren't search primitives. The engines that excel at relational work speak SQL.

That ecosystem advantage isn't incidental. SQL has the relational algebra surface, the optimizer literature, the tooling and the practitioner base. The modern lakehouse (Iceberg, Parquet, Delta) represents structured, columnar data that SQL's optimizer understands. Most analytical data lives in these formats and participates in the query plans DuckDB, ClickHouse, Trino and Snowflake produce. Any language for search-OLAP needs to be fluent in the data that's already there.

Once you accept that retrieval and analytics belong in one engine, the choice of query language is clear. SQL is the only option with both halves and it has a clean predicate slot for search to drop into, courtesy of Postgres's @@. We didn't have to invent a syntax. We had to make the right-hand side of @@ a first-class expression language and make the engine underneath capable of treating search as a peer of every other physical operator.

What this looks like in practice

Weekly volume and top relevance, by author. A content team wants to see how often articles about distributed systems are appearing, who's writing them and the strongest match per author each week:

SELECT
date_trunc('week', published_at) AS week,
author_id,
COUNT(*) AS matches,
MIN(distance) AS closest_match
FROM (
SELECT author_id,
published_at,
body_embedding <=> ai_embed(
'consensus algorithms and fault tolerance in databases',
'text-embedding-3-small',
'openai')::FLOAT[1536] AS distance
FROM articles_idx
WHERE body @@ (ts_phrase('distributed systems') && !!ts_phrase('blockchain'))
AND published_at >= DATE '2025-01-01'
) t
GROUP BY week, author_id
ORDER BY week, closest_match;

A lexical filter generates the candidate set; the vector distance to the query embedding becomes a first-class column. GROUP BY rolls it up by week and author and MIN(distance) ranks within each bucket.

Search joined with normalized data. Products matching a search, joined to inventory and warehouse, aggregated by region:

SELECT
region,
COUNT(*) AS matching_products,
SUM(units_available) AS total_stock,
quantile_cont(distance, 0.5) AS median_distance
FROM (
SELECT w.region,
i.units_available,
p.description_embedding <=> ai_embed(
'wireless noise-cancelling headphones with long battery life',
'text-embedding-3-small',
'openai')::FLOAT[1536] AS distance
FROM products_idx AS p
JOIN inventory AS i ON i.product_id = p.id
JOIN warehouses AS w ON w.id = i.warehouse_id
WHERE p.description @@ ts_phrase('headphones')
) t
GROUP BY region
ORDER BY matching_products DESC;

Three tables, a hybrid lexical + vector search predicate, two joins, a GROUP BY and a continuous-quantile aggregation over vector distances. One query plan. More than anything else, this single query plan — with the search predicate as one of its nodes — demonstrates the unique optimization of search-OLAP.

Search-OLAP over the data lake

The examples above run over local tables. Most analytical data lives in object storage — Iceberg, Delta, or raw Parquet — in data lakes, not warehouses. DuckDB and ClickHouse made it normal to query that data where it sits: no ingestion, no copy, no round-trip. SereneDB extends that model to search: index and query remote data with the same SQL you'd use against a local table.

CREATE VIEW events AS
SELECT * FROM iceberg_scan('s3://warehouse/events/', allow_moved_paths=true);

CREATE INDEX events_idx ON events
USING inverted(
id,
message en_dict,
embedding hnsw (metric = 'cosine', m = 32, ef_construction = 64)
) INCLUDE (event_time, service, severity);

SELECT
hour,
service,
COUNT(*) AS matching_errors,
quantile_cont(distance, 0.95) AS p95_distance
FROM (
SELECT date_trunc('hour', e.event_time) AS hour,
e.service,
e.embedding <=> ai_embed(
'upstream service unreachable, TCP connection refused',
'text-embedding-3-small',
'openai')::FLOAT[1536] AS distance
FROM events_idx AS e
WHERE e.message @@ ts_phrase('connection refused')
AND e.severity = 'error'
AND e.event_time >= NOW() - INTERVAL '7 days'
) t
GROUP BY hour, service
ORDER BY matching_errors DESC, hour;

A logs/events lake on S3, in Iceberg, with a hybrid lexical + vector search predicate, with structured filters on severity and time, with a quantile over vector distances grouped by hour and service.

No copy into a search engine. No ETL. No second system to keep in sync.

SQL is the analytical language. SQL is the language with a slot for search. SQL is what the lakehouse speaks. One engine handling all three means one query plan, no ETL seams (neither between search and analytics nor between local and remote data).

One engine, not two

The reason these queries run well isn't only the syntax. It's the engine.

SereneDB's core contribution is fusing a search engine with a vectorized analytical executor at the physical-operator level. A search predicate isn't a black box queried over a network. It's a real operator inside the query plan, with cost statistics, selectivity estimates and a row-id-plus-score output that the optimizer reorders against joins, scans and aggregations. That's why BM25 scores and vector distances both work inside window functions, why a remote Iceberg scan and a local indexed table participate in the same join and why the planner (not the application) decides which side of the search/structured boundary drives the query.

The engine builds on top of IResearch — our own search engine, benchmarked ahead of Lucene and Tantivy in the benchmark called Search Benchmark, The Game — plugged in as that physical operator. On the analytical side, we used the vectorized execution and an optimizer from DuckDB. This was a deliberate decision; reinventing analytical execution alongside our search work would have produced something worse. The fusion between the two (the cost model, the index integration, the SQL surface for search expressions) is what makes SereneDB and it's what makes one system instead of two systems sharing a process.

Search-in-SQL itself isn't new. Postgres has had @@ for nearly two decades and the operator is good enough that we kept it. ParadeDB and others continue to push modern relevance into the Postgres world; that work is real. What's different about SereneDB is the depth of the integration: a search predicate that's a first-class physical operator inside a vectorized analytical executor, reading both local and remote data natively. That's the point on the design surface SereneDB occupies.

A complete search engine, plus an analytical executor

A JSON search DSL has separate query types per mode (match, knn, geo_distance, fuzzy) and combining them means nesting clauses. SQL doesn't have separate query types. It has expressions. SereneDB exposes all of the search capabilities as composable expressions inside @@: full-text and phrase search, n-grams, Levenshtein-based fuzzy matching, vector search, geospatial search and hybrid lexical-vector ranking, freely combined and freely consumed by the analytical SQL around them.

Pair that with an analytical executor of equal weight — vectorized, cost-based, the full surface of analytical SQL — and you have one system carrying both sides at full strength.

One honest caveat though, at the time of writing this runs on a single node. Clustering is on the roadmap but a single SereneDB node is already powerful enough for most workloads (very large deployments are where we're heading next).

Closing

The next generation of search-aware applications will be built by teams who never had to learn the difference between their search system and their warehouse, who joined search results to relational data without writing glue code and who treated relevance (lexical or semantic) as just another column in analytical SQL. SereneDB is built for those teams! One complete search engine, one vectorized analytical executor, one query language for both.