SereneDB

The Qdrant alternative that keeps vectors next to your rows

SereneDB is an Apache 2.0, PostgreSQL-compatible database that stores each embedding in the row it describes and indexes it with HNSW beside BM25 full-text search, so one SQL query filters, ranks and aggregates the results. Qdrant is an open-source vector search engine, written in Rust, that keeps vectors and their JSON payloads in its own collections and serves them through REST and gRPC APIs.

SereneDB
2,264 QPS
Qdrant
787 QPS

queries per second at 0.99 recall · k=10, no filter · 32 clients

VectorBench/ 50M Cohere Wikipedia vectors · 1024 dims · SereneDB 26.10.2 vs Qdrant 1.19.1
2.9×more queries/s than Qdrant 1.19.112 vs 9query groups at 0.95+ recall1.3×faster load
View Full Benchmark →
SereneDBgeomean 1.00×load 1h 18m
Qdrantgeomean 2.55×load 1h 44m
most queries/s2× fewer4× fewer8×+ fewerbelow the 0.95 recall barhover a cell to see the query
Why teams switch

Why teams look for a Qdrant alternative

Teams look past Qdrant when the vectors describe rows that already live in a SQL database, when keyword ranking has to stand next to vector distance, or when search results feed reports and analytics. SereneDB keeps the embeddings in the table they describe and answers all three in one SQL query.

01

> No second datastore to keep in sync

SereneDB keeps each embedding in a FLOAT[N] column of the row it describes, so the row and its vector change in one transaction, and the index publishes the change within about a second. Running Qdrant beside Postgres needs a sync path: Qdrant's own guide describes dual writes, an outbox worker and change data capture with Debezium. Rows can also reach SereneDB through a subscription to a Postgres publication, or stay in Parquet or Iceberg files that it indexes in place.

02

> Full-text search beyond BM25 term matches

SereneDB's inverted index ranks with BM25 and also matches phrases with a slop, fuzzy terms by edit distance, prefixes, wildcards and regular expressions, and highlights the matches, in the statement that orders by vector distance. Qdrant 1.20.0 added BM25 ranking on a full-text payload index, without fuzzy matching or highlighting.

03

> SQL filters inside the HNSW walk

A WHERE clause beside ORDER BY emb <=> … LIMIT k, from ranges and IN lists to ORs and full-text matches, is checked while the graph walk runs, and a filter that admits few rows is answered exactly over those rows. On VectorBench's three 1% filters, SereneDB 26.10.2 answered at recall 1.00 while the recall of Qdrant 1.19.1 stopped below 0.89.

04

> Search results feed analytics in the same query

GROUP BY, window functions and joins run over the nearest neighbours in the same SQL statement, through psql or a PostgreSQL driver such as psycopg, node-postgres or Npgsql. A Qdrant query returns points, so counting, grouping or joining them happens in application code; its facet API counts payload values over filtered points, not over search results.

Side by side

SereneDB vs Qdrant, feature by feature

Both index vectors with HNSW, quantize them and filter during the graph search. They differ in the query language, in what else one index holds, and in where the vectors live.

CapabilitySereneDBQdrant
Query interfaceYes: SQL over the PostgreSQL wire protocol, from psql or any PostgreSQL driverYes: REST and gRPC APIs with official clients for Python, JavaScript, Rust, Go, Java and .NET
Vector indexesYes: HNSW and IVF over FLOAT[N] columns, in one inverted index with text, keyword and number fieldsYes: HNSW for dense vectors, beside payload indexes for filters
Distance metricsYes: Cosine, L2, inner product and L1: <=>, <->, <#>, <+>Yes: Cosine, Dot, Euclid and Manhattan
QuantizationYes: sq8 by default, plus sq4, usq8, usq4, fp16, bf16, pq, rabitq, tq, bq and osq; candidates re-scored at full precision by defaultYes: TurboQuant, scalar, binary and product quantization, with rescore and oversampling set per query
Filtered vector searchYes: Any SQL predicate, applied inside the HNSW walk; a selective filter is scored exactlyYes: Payload filters applied during the HNSW search, with payload indexes and filter-aware graph links
Keyword + vector (hybrid)Yes: BM25 and vector distance in one SQL statement, fused with reciprocal rank fusionYes: Query API prefetch over a BM25 text query (since 1.20.0) and dense vectors, fused with RRF or DBSF
Full-text searchYes: BM25 or TF-IDF, phrases with a slop, fuzzy, prefix, wildcard and regex matches, highlightingPartly: BM25 since 1.20.0, with tokenizers, stemming and stop words; no fuzzy matching or highlighting
Joins and aggregationsYes: JOIN, GROUP BY and window functions over the search results, in the same statementPartly: with_lookup attaches points from another collection by id, and facet counts cover filtered points; no joins with other data
Vectors next to the source rowsYes: In the table they describe; rows can come from a subscription to a Postgres publication or stay in Parquet, CSV, JSON or Iceberg files indexed in placeNo: In Qdrant collections; Qdrant's guide to running beside Postgres covers dual writes, an outbox worker and change data capture
LicenseYes: Apache 2.0Yes: Apache 2.0

✓ supported · ~ supported with limits · ✕ not supported. Qdrant facts from Qdrant's documentation and API reference for 1.20 and requests run on 1.20.0, SereneDB facts from its documentation and runs on 26.10.7. Last reviewed October 31, 2026.


Same question, two queries

Qdrant Query API in, SQL out

Each tab asks the same question of the same eight products, as a Qdrant Query API request and as SereneDB SQL. The SQL ran on SereneDB 26.10.7 and the requests on Qdrant 1.20.0, both on October 31, 2026, and both sides returned the same points.

Filtered search

Qdrant — Query API (REST)
POST /collections/products/points/query
{
  "query": [0.9, 0.1, 0.0, 0.1],
  "filter": {
    "must": [
      { "key": "category", "match": { "value": "shoes" } },
      { "key": "price", "range": { "lt": 100 } }
    ]
  },
  "limit": 3,
  "with_payload": ["name", "price"]
}
SereneDB — SQL
SELECT id, name, price
FROM products_idx
WHERE category = 'shoes' AND price < 100
ORDER BY emb <=> [0.9, 0.1, 0.0, 0.1]::FLOAT[4]
LIMIT 3;

Qdrant reads the filter from payload fields, here with keyword and float payload indexes created one call per field; SereneDB reads it from columns of products_idx, the one inverted index that also holds the vectors and the BM25 text. Both return points 1, 7 and 4, and EXPLAIN shows SereneDB applying the filter inside its HNSW scan.

Hybrid search

Qdrant — Query API (REST)
POST /collections/products/points/query
{
  "prefetch": [
    { "query": { "text": "running shoes" },
      "using": "name", "limit": 20 },
    { "query": [0.9, 0.1, 0.0, 0.1], "limit": 20 }
  ],
  "query": { "rrf": { "k": 60 } },
  "limit": 5
}
SereneDB — SQL
WITH fused AS (
    SELECT id, RANK() OVER (ORDER BY score DESC) AS rank
    FROM (SELECT id, BM25(products_idx.tableoid) AS score
          FROM products_idx
          WHERE name @@ 'running shoes'
          ORDER BY score DESC
          LIMIT 20) AS lexical
    UNION ALL
    SELECT id, RANK() OVER (ORDER BY dist) AS rank
    FROM (SELECT id, emb <=> [0.9, 0.1, 0.0, 0.1]::FLOAT[4] AS dist
          FROM products_idx
          ORDER BY dist
          LIMIT 20) AS semantic
)
SELECT id, SUM(1.0 / (60 + rank)) AS rrf
FROM fused
GROUP BY id
ORDER BY rrf DESC
LIMIT 5;

Both rank the BM25 matches for running shoes next to the nearest vectors and fuse the two lists with reciprocal rank fusion, and both return 1, 2, 5, 3 and 6. Qdrant's text query needs a full-text payload index with "scoring": true, available since 1.20.0, and its RRF counts ranks from 0 with k = 2 by default, so the request sets k = 60 to match the SQL.

Quantization

Qdrant — collection and query
PUT /collections/products
{
  "vectors": { "size": 4, "distance": "Cosine" },
  "quantization_config": {
    "scalar": { "type": "int8" }
  }
}

POST /collections/products/points/query
{
  "query": [0.9, 0.1, 0.0, 0.1],
  "params": {
    "quantization": { "rescore": true, "oversampling": 2.0 }
  },
  "limit": 3,
  "with_payload": ["name"]
}
SereneDB — SQL
CREATE TEXT SEARCH DICTIONARY words
    AS split_text(case := 'lower') WITH (frequency, position, norm);
CREATE INDEX products_idx ON products USING inverted (
    id, name words, category, price,
    emb hnsw (metric = 'cosine', quant = 'sq8'));

SET sdb_ann_rescore_width = 2;

SELECT id, name
FROM products_idx
ORDER BY emb <=> [0.9, 0.1, 0.0, 0.1]::FLOAT[4]
LIMIT 3;

Both keep 8-bit codes for the graph walk and re-score candidates with the full-precision vectors. Qdrant re-scores scalar codes only when a query sets rescore, and reads oversampling × limit candidates from the codes. SereneDB re-scores by default: a candidate is re-read while its code score, plus sdb_ann_rescore_width times the code's estimated error, could still beat the k-th exact score, and 2 is the width the default picks for sq8. Both return 1, 5 and 2.

Search, then aggregate

Qdrant — Query API (REST)
# The five nearest points with their payloads;
# counting and averaging them per category
# happens in application code
POST /collections/products/points/query
{
  "query": [0.9, 0.1, 0.0, 0.1],
  "limit": 5,
  "with_payload": ["category", "price"]
}
SereneDB — SQL
SELECT category,
       count(*) AS products,
       round(avg(price), 2) AS avg_price
FROM (SELECT category, price
      FROM products_idx
      ORDER BY emb <=> [0.9, 0.1, 0.0, 0.1]::FLOAT[4]
      LIMIT 5) AS nearest
GROUP BY category
ORDER BY products DESC;

SereneDB groups the five nearest products in the same statement: the plan reads them from the HNSW index (Top: 5) and aggregates them. Qdrant returns the five points, and the count and average are computed in application code; its facet API counts payload values over all points that match a filter, not over the nearest k. Both give four shoes at 90.5 on average and one apparel item at 35.


Migration

Move a Qdrant collection to SereneDB in three steps

The vectors move as they are, with no re-embedding: export the points with Qdrant's Scroll API, read the file with read_json, then index the table and rewrite the queries as in the tabs above. These three commands ran end to end on 1,500 points of 1024 dimensions, from Qdrant 1.20.0 to SereneDB 26.10.7.

  1. Export the points with the Scroll API

    Page through the collection with payloads and vectors and write one JSON line per point, until next_page_offset comes back null. The loop needs curl and jq.

    $ off=null; while :; do r=$(curl -s -X POST localhost:6333/collections/docs/points/scroll -H 'Content-Type: application/json' -d "{\"limit\": 1000, \"with_payload\": true, \"with_vector\": true, \"offset\": $off}"); echo "$r" | jq -c '.result.points[]' >> points.jsonl; off=$(echo "$r" | jq '.result.next_page_offset'); [ "$off" = null ] && break; done
  2. Load them into a table

    Start SereneDB with the export folder mounted, for example docker run -d --name serenedb -p 7890:7890 -v serenedb-data:/var/lib/serenedb -v "$PWD":/data -e POSTGRES_PASSWORD=<password> serenedb/serenedb, then read the file with read_json. List your payload keys as columns and give the vector column your collection's dimension; for named vectors, read vector.<name>.

    $ psql -h localhost -p 7890 -U postgres -c "CREATE TABLE docs AS SELECT id, payload.title AS title, payload.lang AS lang, vector::FLOAT[1024] AS emb FROM read_json('/data/points.jsonl', format = 'newline_delimited')"
  3. Index the table and query it

    One inverted index covers the text, keyword and vector columns. Map each Qdrant distance to a metric and its operator: Cosine to cosine and <=>, Dot to ip and <#> (the negative inner product, so the nearest come first in ascending order), Euclid to l2 and <->, Manhattan to l1 and <+>. Then rewrite each request as in the tabs above.

    $ psql -h localhost -p 7890 -U postgres -c "CREATE TEXT SEARCH DICTIONARY words AS split_text(case := 'lower') WITH (frequency, position, norm); CREATE INDEX docs_idx ON docs USING inverted (id, title words, lang, emb hnsw (metric = 'cosine'))"

FAQ

Questions from Qdrant users

Go to docs →

Yes. The Qdrant server and its official clients for Python, JavaScript, Rust, Go, Java and .NET are open source under the Apache 2.0 license. SereneDB is licensed under Apache 2.0 too. What a separate vector store costs beyond the license is the second datastore and the pipeline that keeps it in step with the source rows; SereneDB keeps the vectors in the table they describe, so there is no second store to keep in sync.

Qdrant is a dedicated vector search engine, so the data around the vectors lives elsewhere. It has no SQL interface and no joins with data outside Qdrant, and its own guide to running it beside Postgres describes dual writes, an outbox worker or change data capture to keep the two in sync. Its full-text index has ranked results with BM25 since 1.20.0, without fuzzy matching or highlighting. SereneDB runs vector search, BM25 and SQL filters, joins and aggregations over one table.

On VectorBench, yes. Over 50M Cohere Wikipedia vectors of 1024 dimensions with 32 concurrent clients, SereneDB 26.10.2 served 2,264 queries per second at 0.99 recall for k = 10 without a filter, and Qdrant 1.19.1 served 787. SereneDB had more throughput on all nine query groups both engines answered at the recall bar, and on the three 1% filters the recall of Qdrant stopped at 0.877 to 0.886. Qdrant had the lower median latency on the correlated filter, and Qdrant 1.20.0 has not been measured yet.

Qdrant stores points, each an id, one or more vectors and a JSON payload, in collections. It indexes dense vectors with an HNSW graph and payload fields with payload indexes, which filters use during the graph search, and it answers queries through its Query API over REST or gRPC. A query can chain prefetch stages and fuse their results, as in the hybrid tab above.

Vector search ranks by the distance between embeddings, which finds results with a similar meaning but can miss exact terms such as product names, error codes or rare words. Hybrid search also ranks by keywords, usually with BM25, and fuses the two rankings, for example with reciprocal rank fusion. In SereneDB both rankings come from one inverted index in one SQL statement.

No. SereneDB speaks SQL over the PostgreSQL wire protocol, so applications connect through psql or a PostgreSQL driver such as psycopg, node-postgres or Npgsql, and each Qdrant call becomes a SQL query like the ones in the tabs above. LangChain applications switch to the langchain-serenedb vector store, which also runs hybrid BM25 and vector retrieval.

No: SQL is a query language. A SQL database works as a vector database when it stores embeddings and indexes them for nearest-neighbour search, as SereneDB does with HNSW and IVF indexes over FLOAT[N] columns, queried with ORDER BY emb <=> … LIMIT k. The vector search database page covers it in full.