SereneDB

Vector search: ORDER BY distance on HNSW or IVF

SereneDB keeps each embedding as a FLOAT[N] column on the row it describes and indexes it with HNSW or IVF in the same inverted index as your text and filter columns. The k nearest rows are one ORDER BY distance LIMIT k, over the PostgreSQL wire protocol.

curl https://install.serenedb.com | sh
vector index · articlesquerywalktop-k
An illustration of three queries over eight help-center articles, placed by their embeddings: account articles on the left, billing at the top right, connectivity at the bottom right. On the IVF index, ORDER BY emb <-> [1.0, 0.0, 0.0] LIMIT 3 returns Reset your password at distance 0.000, Forgot my password at 0.054 and Recover a locked account at 0.150. Adding WHERE product = 'mobile' returns Forgot my password at 0.054 and Set up two-factor authentication at 0.300. On the HNSW index, ORDER BY emb <=> [0.0, 1.0, 0.0] LIMIT 2 walks the graph to Change your payment method at cosine distance 0.001 and Download an invoice at 0.008.

Architecture

One vector index beside the columns you filter on

A FLOAT[N] column declared with hnsw (…) or ivf (…) sits in one inverted index with the columns you filter on, and every vector query is a SELECT from that index by name. Hover a stage, or a column inside the index, to find its line.

01 · table
articles
id · title · product views INTEGER emb FLOAT[3]
02 · embed
ai_embed()
or vectors you compute yourself
02 · as stored
verbatim
id · product
03 · inverted index
articles_idx
emb ivf (metric = 'l2')ann
productverbatim
idverbatim
IVF: clusters under a centroid tree · HNSW: a graph of neighbors
04 · knn
ORDER BY … LIMIT k
emb <-> $q
05 · range
WHERE dist < r
emb <-> $q < 0.2
06 · filtered
WHERE + ORDER BY
product = 'mobile'
07 · result
Nearest rows
joins · GROUP BY
> stage 01 · table

The vector is one more column

Every row and every query share the dimension N: a vector index needs a fixed-size FLOAT[N], and an unsized FLOAT[] is rejected. The embedding sits beside title, product and views, so ranking, filters and GROUP BY run over one table.

sql
CREATE TABLE articles (
id INTEGER,
title VARCHAR,
product VARCHAR,
views INTEGER,
emb FLOAT[3]
);
> stage 02 · embeddings

Embeddings from SQL, or your own

ai_embed(text, model, secret_name) calls an embedding model on an OpenAI-compatible endpoint: OpenAI, OpenRouter or Gemini, or a local Ollama, vLLM, LM Studio, LiteLLM or llama.cpp server. It returns a FLOAT[]; cast it to FLOAT[N]. Rows go out in batches of up to 100 texts per request (sdb_ai_embedding_max_batch_size), and NULL or empty text returns NULL without a request. Embed rows once at write time and only the question at search time.

sql
CREATE SECRET local_ai (
TYPE openai,
base_url 'http://localhost:11434'
);
SELECT id,
ai_embed(title, 'all-minilm', 'local_ai')::FLOAT[384] AS emb
FROM articles;
> stage 02 · verbatim

Filter columns in the same index

Columns listed without a dictionary or an operator class are indexed as stored, for exact-match filters next to the vector. A filter column belongs in the index (or its INCLUDE list): otherwise its rows are checked after the search.

sql
CREATE INDEX articles_idx ON articles
USING inverted (id, product, emb ivf (metric = 'l2'));
> stage 03 · vector index

Two index kinds, one syntax

IVF groups each segment's vectors into clusters of about 1,024 under a centroid tree trained on a 20% sample (sdb_ivf_posting_size and sdb_ivf_sample_factor), and a query scans the clusters nearest it. HNSW links the vectors into a graph of neighbors (m = 32, ef_construction = 200 by default) and walks it with a beam; the docs call it usually the better trade at high recall. Both take the same metrics, operators and queries.

sql · hover a line
CREATE INDEX articles_idx ON articles
USING inverted (
id, -- verbatim
product, -- verbatim
emb ivf (metric = 'l2') -- ann
);
CREATE INDEX articles_hnsw ON articles
USING inverted (id, product, emb hnsw (metric = 'cosine'));
> stage 04 · knn

The k nearest rows

ORDER BY the distance, LIMIT k, selecting from the index by name: EXPLAIN shows an IRESEARCH_SCAN with Score l2_distance and Top 3. The operator must match the index metric: <=> on this l2 index still returns correct distances, but from a full scan. The base table answers too, exactly, by sequential scan.

sql
SELECT id, title,
round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distance
FROM articles_idx
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
LIMIT 3;
-- 1 Reset your password 0.000
-- 2 Forgot my password 0.054
-- 3 Recover a locked account 0.150
> stage 05 · range

Everything within a radius

A distance in WHERE returns every row inside the radius; add ORDER BY … LIMIT k for the closest k inside it. sdb_ivf_search_nprobe and sdb_hnsw_ef_search do not narrow range queries: on the playground's 5,000 test vectors, nprobe 1, the default, HNSW and the base table all found the same 9 rows within 0.5.

sql
SELECT id, title,
round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distance
FROM articles_idx
WHERE emb <-> [1.0, 0.0, 0.0]::FLOAT[3] < 0.2
ORDER BY distance;
-- 1 Reset your password 0.000
-- 2 Forgot my password 0.054
-- 3 Recover a locked account 0.150
> stage 06 · filtered

Filter in WHERE, rank in ORDER BY

A kNN query returns k rows whenever at least k rows match its WHERE: when the clusters an IVF index probes hold fewer, the query is answered exactly from every matching row. HNSW applies the filter inside the graph walk. EXPLAIN shows product = mobile as the scan's Index Filter. When a keyword match and the vector should both rank, fuse them with hybrid search.

sql
SELECT id, title,
round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distance
FROM articles_idx
WHERE product = 'mobile'
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
LIMIT 2;
-- 2 Forgot my password 0.054
-- 4 Set up two-factor authentication 0.300
> stage 07 · result

Rows, not IDs to fetch elsewhere

The nearest rows are an ordinary row set: join them back to the table, group them, or hand them to an answer. There is no second store to fetch the payload from.

sql
SELECT a.product, count(*) AS articles, sum(a.views) AS views
FROM (
SELECT id FROM articles_idx
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
LIMIT 4
) AS nearest
JOIN articles AS a ON a.id = nearest.id
GROUP BY a.product
ORDER BY a.product;
-- mobile 2 1200
-- web 2 1650
A diagram in seven stages. The articles table holds id, title, product, views and a FLOAT[3] embedding column, filled by ai_embed() or by vectors you compute yourself. The inverted index articles_idx holds the embedding as an IVF field with the l2 metric and id and product as verbatim fields. Queries select from the index for the k nearest rows (ORDER BY emb <-> $q LIMIT k), for every row within a radius (WHERE emb <-> $q < 0.2) or for the nearest rows that pass a filter (WHERE product = 'mobile'), and return ordinary rows that join and group.

Four distance operators, one metric per index

ORDER BY emb <=> $q LIMIT k
metric = 'l2'
Euclidean

Straight-line distance. Quantized with sq8 unless quant says otherwise.

emb <-> $q
metric = 'cosine'
Cosine

Cosine distance, from the angle between vectors. Most text-embedding models are tuned for it. sq8 by default.

emb <=> $q
metric = 'ip'
Inner product

The negative inner product, for normalized embeddings. sq8 by default; rabitq needs ip or l2.

emb <#> $q
metric = 'l1'
Manhattan

Sum of absolute differences. Always stored unquantized.

emb <+> $q

Build it

Vector search in SQL, from an empty database

One table, one index, five steps. Every statement and result here ran on SereneDB 26.11.2 on 2026-11-03.

step 01 / 05 · sql
CREATE TABLE articles (
    id INTEGER,
    title VARCHAR,
    product VARCHAR,
    views INTEGER,
    emb FLOAT[3]
);

CREATE INDEX articles_idx ON articles
    USING inverted (id, product, emb ivf (metric = 'l2'));
metric is the one required option: ivf without it is rejected. l2, ip and cosine are quantized with sq8 by default.
step 02 / 05 · sql
INSERT INTO articles VALUES
    (1, 'Reset your password',              'web',    1200, [1.0,  0.0,  0.0 ]::FLOAT[3]),
    (2, 'Forgot my password',               'mobile',  900, [0.98, 0.05, 0.0 ]::FLOAT[3]),
    (3, 'Recover a locked account',         'web',     450, [0.9,  0.1,  0.05]::FLOAT[3]),
    (4, 'Set up two-factor authentication', 'mobile',  300, [0.8,  0.2,  0.1 ]::FLOAT[3]),
    (5, 'Change your payment method',       'web',     700, [0.05, 1.0,  0.0 ]::FLOAT[3]),
    (6, 'Download an invoice',              'web',     650, [0.1,  0.9,  0.05]::FLOAT[3]),
    (7, 'Fix a dropped connection',         'mobile',  820, [0.0,  0.05, 1.0 ]::FLOAT[3]),
    (8, 'Sync stalls on mobile data',       'mobile',  400, [0.05, 0.1,  0.9 ]::FLOAT[3]);

VACUUM (REFRESH_TABLE) articles;
INSERT 0 8. The index is eventually consistent: VACUUM (REFRESH_TABLE) publishes the writes now, and a background refresh does it every 1,000 ms (refresh_interval).
step 03 / 05 · sql
SELECT id, title,
    round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distance
FROM articles_idx
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
LIMIT 3;
1 Reset your password 0.000 · 2 Forgot my password 0.054 · 3 Recover a locked account 0.150. EXPLAIN shows an IRESEARCH_SCAN of articles_idx with Score l2_distance and Top 3.
step 04 / 05 · sql
SELECT id, title,
    round((emb <-> [1.0, 0.0, 0.0]::FLOAT[3])::numeric, 3) AS distance
FROM articles_idx
WHERE product = 'mobile'
ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
LIMIT 2;
2 Forgot my password 0.054 · 4 Set up two-factor authentication 0.300. EXPLAIN shows product = mobile as the scan's Index Filter.
step 05 / 05 · sql
SELECT a.product, count(*) AS articles, sum(a.views) AS views
FROM (
    SELECT id FROM articles_idx
    ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
    LIMIT 4
) AS nearest
JOIN articles AS a ON a.id = nearest.id
GROUP BY a.product
ORDER BY a.product;
mobile: 2 articles, 1,200 views · web: 2 articles, 1,650 views.

Use cases

What teams build on vector search

Semantic search over your own text

Embed rows once with ai_embed(), embed each question inside the query, and rank by cosine distance. A constant ai_embed() probe still drives the index: EXPLAIN shows the IRESEARCH_SCAN. When exact terms matter as well, such as SKUs or error codes, add BM25 and fuse the two rankings.

indexed columns
titleemb FLOAT[384]hnsw (metric = 'cosine')
Hybrid search →
example · ai_embed at write and query time
reset my login
CREATE TABLE help (id INTEGER, title VARCHAR, emb FLOAT[384]);
CREATE INDEX help_idx ON help
    USING inverted (id, emb hnsw (metric = 'cosine'));

INSERT INTO help
SELECT id, title,
    ai_embed(title, 'all-minilm', 'local_ai')::FLOAT[384]
FROM articles;
VACUUM (REFRESH_TABLE) help;

SELECT id, title FROM help_idx
ORDER BY emb <=>
    ai_embed('reset my login', 'all-minilm', 'local_ai')::FLOAT[384]
LIMIT 3;
The titles are embedded once, at write time; only the question is embedded when the query runs. Which three rows come back depends on the model you serve.

More like this, then count it

Rank the neighbors of an article, then turn them into counts and totals with an ordinary join and GROUP BY. Pass the probe vector as a constant, a literal or a bound $1 parameter: a scalar subquery as the probe is scored row by row instead of through the index.

indexed columns
idproductviewsemb
Analytical SQL →
example · neighbors by product
SELECT a.product, count(*) AS articles, sum(a.views) AS views
FROM (
    SELECT id FROM articles_idx
    ORDER BY emb <-> [1.0, 0.0, 0.0]::FLOAT[3]
    LIMIT 4
) AS nearest
JOIN articles AS a ON a.id = nearest.id
GROUP BY a.product
ORDER BY a.product;
result
productarticlesviews
mobile21200
web21650
A self-join with a.emb <-> b.emb < 0.06 flags near-duplicates: here article 2 is a copy of article 1 at 0.054.

Retrieval for LLM answers

Chunks, embeddings and metadata in one table. langchain-serenedb implements LangChain's VectorStore on it: init_vectorstore_table() creates the table and its IVF index, and the retriever reads the same rows your SQL does.

table columns
contentembeddinglangchain_metadata
RAG database →
example · langchain
engine = SereneDBEngine.from_connection_string(
    "host=127.0.0.1 port=7890 user=postgres dbname=postgres")
engine.init_vectorstore_table("chunks", 384, vector_index=IVFIndex())

store = SereneDBVectorStore.create_sync(engine, embeddings, "chunks")
store.add_texts(texts, metadatas=metadatas)
docs = store.similarity_search("how do I configure the index?", k=5)
Ran with langchain-serenedb 26.11.2 on SereneDB 26.11.2. The table gets USING inverted (embedding ivf (metric=cosine)); embeddings is any LangChain embeddings model.

FAQ

Frequently asked questions

Go to docs →

A vector search database stores embeddings and answers "which rows are closest to this vector?" with an index instead of comparing every row. In SereneDB the embedding is a FLOAT[N] column, the index is HNSW or IVF inside the inverted index, and the query is SQL: ORDER BY emb <-> $q LIMIT k.

No. SQL is a query language; a database does vector search when it can store, compare and index vectors. SereneDB does all three in SQL over the PostgreSQL wire protocol, as its own engine rather than an extension.

Keyword search matches the query's words and ranks them with BM25. Vector search compares embeddings, so it finds the same meaning in other words, but exact identifiers can slip. Hybrid search runs both and fuses the rankings; see also full-text search.

Approximate nearest neighbor (ANN) search finds the k closest vectors without computing the distance to every one. An index narrows the search, by partitioning the vectors (IVF) or by navigating a graph of them (HNSW), and trades a little recall for a large cut in work. In SereneDB the session settings sdb_ivf_search_nprobe and sdb_hnsw_ef_search set how much.

Both take the same metrics, quantization options and queries, so switching is one CREATE INDEX. HNSW links vectors into a graph; the docs call it usually the better trade at high recall, and it applies a WHERE filter inside the walk. IVF groups each segment's vectors into clusters of about 1,024 and scans the clusters nearest the query. On the 5,000 test vectors above, HNSW found all 10 exact neighbors even at sdb_hnsw_ef_search = 10.

Raise sdb_ivf_search_nprobe (IVF) or sdb_hnsw_ef_search (HNSW); both default to -1, which lets the engine choose. On a quantized index, sdb_ann_rescore_width sets how many candidates are re-scored at full precision. To measure, compare with the exact answer under SET sdb_ann_force_exact = on: on 5,000 test vectors, nprobe 1 found 5 of the 10 exact neighbors and the default found all 10.

Use the metric your embedding model was trained for; most text-embedding models are tuned for cosine. Build the index with that metric and query with its operator (<=> cosine, <-> L2, <#> inner product, <+> L1). With another operator the query still answers, from a full scan; see vector functions.

Yes. SereneDB speaks the PostgreSQL wire protocol, on port 7890 by default, so psql and PostgreSQL drivers connect as they are; langchain-serenedb uses psycopg 3. Vector search is plain SQL over that connection.

Help-center search: a question such as "I can't log in" is embedded, and an embedding model places it near the articles about the same problem, even ones that share no words with it. The walkthrough above runs the idea on eight articles with hand-made three-dimensional vectors: the probe [1.0, 0.0, 0.0] stands for an account question, and the nearest rows are Reset your password, Forgot my password and Recover a locked account.

SereneDB is an open-source database (Apache 2.0) with vector search built in: embeddings are one column type, indexed next to text and filters and queried with SQL. On VectorBench (commit 65b6ac1, run of 2026-10-20), SereneDB 26.10.2 with an HNSW index served 2,264 queries per second at 0.99 recall (k = 10, no filter, 32 clients) over 50 million Cohere Wikipedia embeddings of 1,024 dimensions. Its recall reached 0.99 or higher in all 12 query groups, and 1.00 on the 1% equality, range and conjunction filters. SereneDB vs Qdrant has the VectorBench run against Qdrant, and SereneDB vs Pinecone maps Pinecone's SDK calls to SQL.

> get started

Get started with SereneDB

Add a FLOAT[N] column, index it with hnsw (…) or ivf (…), and ORDER BY distance. Embeddings can come from ai_embed() in the same SQL.