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 | shHow approximate nearest neighbor search skips most vectors
A vector search database stores embeddings and returns the rows whose vectors are closest to a query vector. Exact kNN computes one distance per stored vector. Approximate nearest neighbor (ANN) search lets an index skip most of them: IVF scans only the clusters nearest the query, HNSW walks a graph of neighbors. Drag the query, pick an index and a metric, and compare with the exact answer. The points are synthetic; the SQL ran on SereneDB 26.11.2.
CREATE INDEX points_idx ON points USING inverted (id, emb ivf (metric = 'l2')); SET sdb_ivf_search_nprobe = 3; SELECT id FROM points_idx ORDER BY emb <-> $1 LIMIT 10;
CREATE INDEX points_idx ON points USING inverted (id, emb ivf (metric = 'cosine')); SET sdb_ivf_search_nprobe = 3; SELECT id FROM points_idx ORDER BY emb <=> $1 LIMIT 10;
CREATE INDEX points_idx ON points USING inverted (id, emb ivf (metric = 'l1')); SET sdb_ivf_search_nprobe = 3; SELECT id FROM points_idx ORDER BY emb <+> $1 LIMIT 10;
CREATE INDEX points_idx ON points USING inverted (id, emb hnsw (metric = 'l2')); SET sdb_hnsw_ef_search = 12; SELECT id FROM points_idx ORDER BY emb <-> $1 LIMIT 10;
CREATE INDEX points_idx ON points USING inverted (id, emb hnsw (metric = 'cosine')); SET sdb_hnsw_ef_search = 12; SELECT id FROM points_idx ORDER BY emb <=> $1 LIMIT 10;
CREATE INDEX points_idx ON points USING inverted (id, emb hnsw (metric = 'l1')); SET sdb_hnsw_ef_search = 12; SELECT id FROM points_idx ORDER BY emb <+> $1 LIMIT 10;
IVF. SereneDB groups each segment's vectors into clusters of about sdb_ivf_posting_size vectors (1,024 by default) under a centroid tree, so these 384 would be one cluster; the demo uses clusters of 24 to show the idea. A query scans the sdb_ivf_search_nprobe clusters nearest it. The default, -1, picks that number per segment from the LIMIT and the cluster count, which keeps recall near 0.95.
HNSW. SereneDB links the vectors into a graph of neighbors (m = 32 and ef_construction = 200 by default), and each segment's search walks it with a beam of sdb_hnsw_ef_search candidates. The default, -1, uses ef_construction or the LIMIT, whichever is larger. A WHERE filter on an indexed column is applied inside the walk. The demo graph keeps up to 8 links per point.
Measured on SereneDB 26.11.2 against the exact answer (SET sdb_ann_force_exact = on): 5,000 hash-generated 8-dimensional vectors and the 10 nearest neighbors of one of them. IVF, built with sdb_ivf_posting_size = 64: nprobe 1 found 5 of the 10, 2 found 7, 4 found 8, 8 found 9 and the default all 10. HNSW found all 10 at sdb_hnsw_ef_search 10, 16 and 32 and at the default.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Four distance operators, one metric per index
ORDER BY emb <=> $q LIMIT kStraight-line distance. Quantized with sq8 unless quant says otherwise.
emb <-> $qCosine distance, from the angle between vectors. Most text-embedding models are tuned for it. sq8 by default.
emb <=> $qThe negative inner product, for normalized embeddings. sq8 by default; rabitq needs ip or l2.
emb <#> $qSum of absolute differences. Always stored unquantized.
emb <+> $qVector 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.
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'));
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;
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;
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;
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;
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.
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;
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.
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;
| product | articles | views |
|---|---|---|
| mobile | 2 | 1200 |
| web | 2 | 1650 |
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.
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)
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 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.