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.
queries per second at 0.99 recall · k=10, no filter · 32 clients
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.
> 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.
> 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.
> 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.
> 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.
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.
| Capability | SereneDB | Qdrant |
|---|---|---|
| Query interface | Yes: SQL over the PostgreSQL wire protocol, from psql or any PostgreSQL driver | Yes: REST and gRPC APIs with official clients for Python, JavaScript, Rust, Go, Java and .NET |
| Vector indexes | Yes: HNSW and IVF over FLOAT[N] columns, in one inverted index with text, keyword and number fields | Yes: HNSW for dense vectors, beside payload indexes for filters |
| Distance metrics | Yes: Cosine, L2, inner product and L1: <=>, <->, <#>, <+> | Yes: Cosine, Dot, Euclid and Manhattan |
| Quantization | Yes: sq8 by default, plus sq4, usq8, usq4, fp16, bf16, pq, rabitq, tq, bq and osq; candidates re-scored at full precision by default | Yes: TurboQuant, scalar, binary and product quantization, with rescore and oversampling set per query |
| Filtered vector search | Yes: Any SQL predicate, applied inside the HNSW walk; a selective filter is scored exactly | Yes: 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 fusion | Yes: Query API prefetch over a BM25 text query (since 1.20.0) and dense vectors, fused with RRF or DBSF |
| Full-text search | Yes: BM25 or TF-IDF, phrases with a slop, fuzzy, prefix, wildcard and regex matches, highlighting | Partly: BM25 since 1.20.0, with tokenizers, stemming and stop words; no fuzzy matching or highlighting |
| Joins and aggregations | Yes: JOIN, GROUP BY and window functions over the search results, in the same statement | Partly: 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 rows | Yes: 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 place | No: In Qdrant collections; Qdrant's guide to running beside Postgres covers dual writes, an outbox worker and change data capture |
| License | Yes: Apache 2.0 | Yes: 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.
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
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"]
}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
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
}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
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"]
}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
# 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"]
}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.
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.
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_offsetcomes 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; doneLoad 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 withread_json. List your payload keys as columns and give the vector column your collection's dimension; for named vectors, readvector.<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')"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
cosineand<=>, Dot toipand<#>(the negative inner product, so the nearest come first in ascending order), Euclid tol2and<->, Manhattan tol1and<+>. 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'))"
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.