The Virtuoso vector index is built on HNSW (Hierarchical Navigable Small World) graphs, which perform approximate nearest neighbor (ANN) search over your embeddings. ANN trades a small amount of recall for orders-of-magnitude faster search than a brute-force scan. The parameters you choose at build time and at query time control exactly where you sit on that speed/recall/memory curve.
This reference covers:
- Two categories of parameters: build-time (set at
CREATE VECTOR INDEX) and query-time (set in the query) - Build-time parameters:
hnsw_m,ef_construction,dimensions,distance_metric, andbuild - Query-time parameters: the
kargument toX_SIMILARand thelimitandef_searchSPARQL options - Tuning methodology: a four-step process to move from defaults to production-grade accuracy
- Storage estimation: how much space the HNSW graph takes
For an architectural introduction, see Vector index overview.
Overview
The HNSW algorithm builds a multi-layer graph where each node is an embedding vector. Nodes in upper layers are sparse (long-range links) and nodes in lower layers are dense (short-range links). A query starts at the top layer and greedily descends, refining the candidate set at each layer. The number of neighbors per node, how deeply the graph was explored during construction, and how wide the search beam is at query time together determine recall and latency.
Virtuoso exposes these as two distinct parameter categories:
| Category | When set | How set | Can change without rebuild? |
|---|---|---|---|
| Build-time | At CREATE VECTOR INDEX | WITH (...) clause | No — requires drop and recreate |
| Query-time | At each query | k argument, or limit and ef_search options | Yes — per query, no rebuild |
Build-time parameters
Build-time parameters are set in the WITH (...) clause of CREATE VECTOR INDEX. They control the structure and quality of the HNSW graph and cannot be changed without dropping and recreating the index.
CREATE VECTOR INDEX DOCS_VEC
ON DB.DBA.DOCS (content)
USING 'granite-384-local'
WITH (
dimensions = 384,
distance_metric = 'cosine',
hnsw_m = 16,
ef_construction = 200
);hnsw_m (default 16)
hnsw_m controls the maximum number of neighbors per node per layer in the HNSW graph — 2 × hnsw_m at the bottom layer, hnsw_m above it. It is the single most influential parameter on the graph's connectivity and, consequently, on recall.
- Higher values produce a denser graph with more connections per node. This improves recall because the search has more paths to explore, but each node consumes more space.
- Lower values produce a sparser graph that is cheaper to build and store, but may miss true neighbors during search.
Storage impact: in the packed page format a node stores only the neighbor links it has; in the legacy format each node is a page of its own that reserves room for every layer's links. hnsw_m also sets how wide a vector can be stored inside its node: inline_dim_limit in __VEC_INDEX_PAGE_STATS is 1,742 floats at hnsw_m = 16 and 1,470 at hnsw_m = 32. A wider vector goes to an overflow page of its own, so at 1,536 dimensions hnsw_m = 32 costs a second page per node where 16 does not — see Storage estimation.
Recommended values:
| Value | When to use |
|---|---|
| 16 (default) | Small datasets (< 10K rows) or low-dimensional vectors (384-dim, like the bundled granite model). Good starting point for development. |
| 32 | Recommended for production with medium datasets, or 1536-dim OpenAI text-embedding-3-small embeddings. |
| 48–64 | High-accuracy use cases with large datasets (> 100K rows) where recall is more important than storage. |
ef_construction (default 200)
ef_construction controls the search depth during index construction — that is, how many candidate nodes are examined when inserting each new node into the graph. A higher value means the builder explores more of the existing graph to find the best neighbors for each insertion, producing a higher-quality graph.
- Higher values produce a better-connected graph with more accurate neighbor links, improving recall. The trade-off is slower build time.
- Lower values build faster but produce a lower-quality graph, which caps the recall achievable even with aggressive query-time tuning.
Recommended values:
| Value | When to use |
|---|---|
| 200 (default) | Good for development and rapid iteration. |
| 400 | Recommended for production accuracy — the sweet spot for most workloads. |
| 600+ | High-accuracy use cases. Note diminishing returns above 1000. |
Build-time scaling: the graph-building part of a build grows roughly linearly with ef_construction. With a local model, embedding the text usually takes longer than building the graph, so the difference shows mainly on a rebuild, which does not embed. Because the index is built once, the trade-off is usually acceptable for production.
dimensions
dimensions specifies the dimensionality of the embedding vectors stored in the index. It is optional for a model-backed index: omitted, it is taken from the model's output_shape in DB.DBA.ONNX_MODELS.
-- dimensions comes from the model (384 for the bundled granite model)
CREATE VECTOR INDEX "DOCS_VIDX" ON "DB"."DBA"."DOCS" ("content")
USING 'granite-384-local';It stays required in two cases:
reduce = 'truncate'— the stored width is a free choice rather than a property of the model, so there is nothing to infer.source_kind = 'vector'— precomputed vectors have no model to ask.
When you do give it:
- It must match the model's output dimension exactly (unless reducing). Mismatched dimensions cause
CREATE VECTOR INDEXto fail withVEC19(SQL) orVEC75(SPARQL). - It cannot be changed after index creation. An index is bound to its model for life: to switch, for example, from the bundled granite model to an OpenAI model, drop the index and create it again with the new model.
- Higher dimensions increase the space per node (the vector itself is
dim * 4 bytes) and slow down distance computation, because each distance calculation is O(dim).
Common model dimensions:
| Model | Dimensions |
|---|---|
granite-384-local (granite-embedding-30m-english, bundled) | 384 |
OpenAI text-embedding-3-small | 1536 |
OpenAI text-embedding-3-large | 3072 |
Ollama mxbai-embed-large | 1024 |
distance_metric
distance_metric (distance in SPARQL) selects the function used to measure similarity between vectors. It is set at index creation and cannot be changed without recreating the index.
| Metric | Default | Range | Best for |
|---|---|---|---|
cosine | Yes | [0, 2] where 0 = identical | Text embeddings (recommended) |
l2 | No | [0, inf) Euclidean distance | When vector magnitude is meaningful |
l1 | No | [0, inf) Manhattan distance | Less sensitive to outliers than L2 |
cosine(default): measures the angle between vectors, ignoring magnitude. Best for text embeddings, which are typically normalized or near-normalized. A distance of 0 means identical direction; 2 means opposite direction.l2: Euclidean (straight-line) distance. Sensitive to vector magnitude — two vectors pointing the same direction but with different magnitudes will have a non-zero L2 distance. Use only when magnitude carries signal.l1: Manhattan (taxicab) distance. Less sensitive to outliers than L2 because it sums absolute differences rather than squaring them.
For a detailed explanation of each metric, how scores are derived from distances, and guidance on choosing the right one, see Similarity metrics.
build (default 'auto', which means incremental)
build chooses how the initial backfill constructs the graph: the one a SPARQL index's first refresh does, and the one a SQL CREATE VECTOR INDEX does over the rows already in its table.
| value | behaviour |
|---|---|
'incremental' | link each embedded batch into the graph as it is produced |
'auto' (default) | same as 'incremental' |
'bulk' | embed everything first, then derive the whole graph in one parallel pass |
SPARQL
CREATE VECTOR INDEX <urn:my:idx>
ON GRAPH <urn:my:graph>
for predicate <http://schema.org/text>
USING MODEL 'granite-384-local'
WITH (dimensions = 384, distance = 'cosine', hnsw_m = 16,
ef_construction = 200, build = 'bulk')
;Measured on 2026-09-05: 57,600 FiQA documents embedded with all-MiniLM-L6-v2 (384-d), hnsw_m=16, ef_construction=200, embedding concurrency 8, on a 10-core arm64 machine. Both rows build from the same vectors, so the comparison is between build methods, not models:
| build | wall clock | graph pages | page format |
|---|---|---|---|
'incremental' | 13 min 09 s | 57,600 | legacy |
'bulk' | 5 min 51 s | 14,064 | packed |
That is 2.2x faster and 4.1x smaller, because incremental linking gets more expensive as the graph grows while a bulk build is a single parallel pass.
On the SQL surface the same option goes in the WITH clause:
CREATE VECTOR INDEX DOCS_VEC ON DB.DBA.DOCS (content) USING 'granite-384-local'
WITH (dimensions = 384, distance_metric = 'cosine', build = 'bulk');Measured, CREATE VECTOR INDEX over tables of 10,000–40,000 rows with a synthetic 384-dimension test model (no embedding cost, so this is the graph build alone), the stock virtuoso.ini (NumberOfBuffers = 10000), 10-core arm64, 2026-10-10:
| rows | 'incremental' | 'bulk' (packed) |
|---|---|---|
| 10,000 | 10.4 s | 0.81 s |
| 20,000 | 32.0 s | 1.73 s |
| 40,000 | not finished after 12 min — every link was a disk read | 4.29 s |
An incremental build writes one 8 KB page per node, so 40,000 nodes needed four times the stock buffer pool.
Later refreshes of a bulk index
A refresh embeds only what is new or changed. What it then does with the graph depends on how much changed:
- A small change is linked in. When the changed rows are at most
__vec_bulk_delta_pctpercent of the graph's live nodes (default 10), the refresh links them into the existing graph one at a time, exactly as an incremental index would, and leaves every other node where it was. Measured on a 1,000,000-row, 64-dimension index (10-core arm64, 2026-09), a 58-row change went from 25.2 s (a whole-graph rebuild) to 3.5 s. - A large change rebuilds the graph from all the stored embeddings, as the first build did.
- A graph that is empty or unhealthy is always rebuilt, whatever changed — a refresh is how an operator brings an index back up to date, so it will not link rows into a graph that cannot serve searches.
registry_set ('__vec_bulk_delta_pct', '25'); -- link changes of up to 25% of the graph
registry_set ('__vec_bulk_delta_pct', '0'); -- never link: every change rebuilds the graph
registry_set ('__vec_bulk_delta_pct', ''); -- back to the default of 10Linked rows are searchable at once and are found as reliably as rows in an incremental index. Two things accumulate, though. Each new node takes one page of its own in the legacy format, and each backlink it adds to a node the fast builder packed replaces that node's farthest neighbour, because a packed record has no spare slot. Neither matters for a few percent of the graph; after many small refreshes, a __VEC_INDEX_REBUILD folds everything back into one packed graph.
While any refresh runs — incremental or bulk — the index is in state refreshing, and bif:vector_search on it fails closed with VEC50 rather than returning partial results. For a bulk index that covers the whole first build.
Matryoshka dimension reduction
What it is
Matryoshka Representation Learning (MRL) is a technique where an embedding model is trained so that the first N dimensions of its output vector form a valid, lower-dimensional embedding on their own. The name comes from the Russian nesting doll — each prefix of the vector is a "smaller doll" that still captures semantic meaning, just at a coarser resolution.
Models that support MRL include:
| Model | Native dimensions | Common reduced dimensions |
|---|---|---|
OpenAI text-embedding-3-small | 1536 | 512, 256 |
OpenAI text-embedding-3-large | 3072 | 1536, 1024, 256 |
Nomic nomic-embed-text-v1.5 | 768 | 512, 256, 128, 64 |
The reduce parameter
The reduce option controls whether vectors are truncated to a lower dimension before they go into the HNSW graph.
| Value | Description |
|---|---|
'none' (default) | No reduction — vectors are stored at the model's native dimension |
'truncate' | MRL truncation — the first dimensions elements are kept, the rest are discarded. The truncated vector is then L2-renormalized. |
When reduce = 'truncate':
- The model produces vectors at its native dimension (e.g. 1536 for OpenAI text-embedding-3-small)
- Only the first
dimensionselements are kept (e.g. 256) - The truncated vector is L2-renormalized (divided by its magnitude) so that cosine similarity remains meaningful
- The reduced vector goes into the HNSW graph — less space, faster search. The index's embedding table keeps the model's full-width vector.
When to use it
Use Matryoshka reduction when you want to:
- Reduce graph size: a 256-dim vector is a sixth of a 1536-dim one, and at that width several nodes share a page — see the measurements below
- Speed up search: distance computation is proportional to dimensionality
- Trade quality for cost: lower dimensions = slightly lower recall, but significantly faster search and less space
Example: OpenAI text-embedding-3-small reduced to 256 dimensions
-- Register the OpenAI model (native 1536 dimensions):
DB.DBA.ONNX_API_KEY_SET('ONNX_API_KEY_OPENAI', '<your-key>');
DB.DBA.ONNX_REGISTER_HTTP_EMBED_MODEL(
'openai-small', 'openai',
'text-embedding-3-small', 1536,
NULL, 'ONNX_API_KEY_OPENAI', 30);
-- Create an index with Matryoshka reduction to 256 dimensions:
CREATE VECTOR INDEX DOCS_VEC ON DB.DBA.DOCS (content)
USING 'openai-small'
WITH (
dimensions = 256, -- reduced dimension (not 1536)
reduce = 'truncate',
distance_metric = 'cosine',
hnsw_m = 32,
ef_construction = 400
);This stores 256-element vectors in the graph instead of 1536-element ones — a sixth of the payload — while retaining most of the search quality. The server embeds each text at 1536 dimensions using OpenAI, then truncates to 256 and renormalizes before it goes into the HNSW graph. Query text follows the same pipeline.
Validation rules
The following rules are enforced at CREATE VECTOR INDEX time. The SQL and SPARQL surfaces share the same native-layer checks (VEC13, VEC19, VEC64); the SPARQL surface adds its own catalog-level checks (VEC72–VEC75) that fire before the native DDL is issued.
| Condition | SQL error | SPARQL error |
|---|---|---|
reduce = 'truncate' without dimensions | VEC13: dimensions required | VEC73: reduce = truncate requires WITH (dimensions = \<n\>) |
reduce = 'truncate' with dimensions above the model's native dim (SPARQL: at or above) | VEC19: model output dim is smaller than requested reduced dim | VEC74: dimensions must be less than the model native output dimension |
reduce = 'none' with dimensions ≠ model native dim | VEC19: model output dim does not match requested dim | VEC75: dimensions must equal the model native output dimension when reduce is not truncate |
reduce value other than 'none' or 'truncate' | VEC64: must be 'none' or 'truncate' | VEC72: Unsupported reduce mode |
Memory comparison
| Configuration | Bytes per vector | Relative to granite |
|---|---|---|
| Bundled granite (384-dim, no reduce) | 1,536 | 1x |
| OpenAI (1536-dim, no reduce) | 6,144 | 4x |
| OpenAI (1536→512, truncate) | 2,048 | 1.3x |
| OpenAI (1536→256, truncate) | 1,024 | 0.67x |
| OpenAI (1536→128, truncate) | 512 | 0.33x |
SPARQL surface
The reduce option is available on both the SQL and SPARQL CREATE VECTOR INDEX surfaces. The SPARQL DDL forwards the option to the native index layer, which performs the truncation and L2-renormalization on insert and query.
-- Register the OpenAI model (native 1536 dimensions):
DB.DBA.ONNX_API_KEY_SET('ONNX_API_KEY_OPENAI', '<your-key>');
DB.DBA.ONNX_REGISTER_HTTP_EMBED_MODEL(
'openai-small', 'openai',
'text-embedding-3-small', 1536,
NULL, 'ONNX_API_KEY_OPENAI', 30);
-- Create an RDF vector index with Matryoshka reduction to 256 dimensions:
SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:docs:reduced-idx>
ON GRAPH <urn:docs:g>
for predicate <urn:doc:text>
USING MODEL 'openai-small'
WITH (
dimensions = 256, -- reduced dimension (not 1536)
reduce = 'truncate',
distance = 'cosine',
hnsw_m = 32,
ef_construction = 400
)
;The catalog records both the stored (reduced) dimension in SVI_DIMENSIONS and the model's native output dimension in SVI_MODEL_DIMENSIONS (here 256 and 1536). Querying with bif:vector_search is unchanged — the query text is embedded at the native dimension, then truncated and renormalized to match the stored vectors.
Query-time parameters
Query-time parameters are set in the query itself — not at index creation. They control how deeply the HNSW search explores the graph at query time. Because they require no index rebuild, they are the cheapest way to improve recall and should be tuned before considering build-time changes.
k parameter (X_SIMILAR 3rd argument)
The k parameter is the third argument to X_SIMILAR in SQL queries. It sets how many candidates the HNSW search returns, nearest first, and the search beam width that finds them. The query's other conditions — the X_SIMILAR threshold included — are then applied to those candidates.
- Default: the query's
TOP n. AWHEREthreshold with neitherknorTOPis not answered by the HNSW index at all: every row is scored exactly, and the result is complete. - Higher
k= better recall = slower query (the search explores more of the graph and re-ranks more candidates). - Lower
k= faster query = potentially lower recall, and at mostkrows: on a six-row table where every row scores above 0.7,X_SIMILAR (content, 'reset password', 2) > 0.7matches 2 rows.
The same holds for ORDER BY VECTOR_DISTANCE (...): it uses the index only with TOP n, and n is its k.
Recommended: set k to 5–10x your actual TOP N for good recall. For example, if you want TOP 10 results, use k = 100.
-- k=100 retrieves 100 candidates from HNSW, then TOP 10 picks the best:
SELECT TOP 10 id, content,
X_SIMILAR(content, 'reset password', 100) AS score
FROM DB.DBA.DOCS
WHERE X_SIMILAR(content, 'reset password', 100) > 0.0
ORDER BY X_SIMILAR(content, 'reset password', 100) DESC;limit and ef_search (SPARQL options)
bif:vector_search separates the two things SQL's k sets: limit is the number of results, and ef_search is the HNSW search beam width that finds them. Both go in the option (...) clause of the magic predicate.
limit— default 10, from 1 to 10000.ef_search— default 100; it must be at leastlimitand at most 100000.- Higher
ef_search= better recall = slower query. - Lower
ef_search= faster query = potentially lower recall.
Recommended: 100 for most use cases; 200–500 for high accuracy.
SPARQL DEFINE input:storage ""
SELECT ?doc ?score ?distance
WHERE {
?doc bif:vector_search "reset customer password"
option (
INDEX <urn:docs:idx>,
score ?score,
distance ?distance,
LIMIT 10,
ef_search 200
) .
}
ORDER BY DESC(?score)
;VectorANNCostBias (server setting)
Whether a SQL query lets the HNSW index lead the scan is a cost-based choice of the optimizer. It estimates a leading ANN search at ef_search × log2(graph nodes) × the index's own average search time, taken from counters the index keeps (with no searches yet, 0.01 ms per unit of ef_search), and multiplies that by VectorANNCostBias:
[Parameters]
VectorANNCostBias = 1.0The default is 1.0; a value of 0 or below, or one that does not parse, is taken as 1.0. Below 1 makes the ANN access path look cheaper, so the optimizer chooses it more often; above 1, less often. Another selective predicate on the table can still keep a query in filter/rerank mode. The setting is read when the server starts.
Tuning methodology
Tuning the vector index is a measured process: start with defaults, quantify recall, and increase parameters in order of cost — query-time first, build-time only when necessary. Follow these four steps.
Step 1: Start with defaults
Begin with the default build-time and query-time parameters. This gets you a working index quickly and establishes a recall baseline.
- Build-time:
hnsw_m = 16,ef_construction = 200 - Query-time:
k= yourTOP n(SQL), orlimit 10andef_search = 100(SPARQL)
CREATE VECTOR INDEX DOCS_VEC
ON DB.DBA.DOCS (content)
USING 'granite-384-local'
WITH (
dimensions = 384,
distance_metric = 'cosine',
hnsw_m = 16,
ef_construction = 200
);Step 2: Measure recall
Before tuning, quantify where you stand. Use the built-in __VEC_INDEX_VALIDATE function to measure ANN recall against an exact brute-force search:
SELECT obj2json (__VEC_INDEX_VALIDATE('DOCS_VEC'));This returns a vector of name/value pairs. Its recall_at_10 is the recall score, between 0 and 1, where 1.0 means the ANN search returned exactly the same results as a brute-force scan; the others say what was tested and whether the graph agrees with the stored embeddings. On a six-row table:
["queries_tested",6,"recall_at_10",1,"distance_metric","cosine","dimension",384,
"consistent",1,"consistency_detail","graph agrees with the stored embeddings",
"graph_epoch",6,"data_epoch",6,"active_nodes",6,"embedding_rows",6]Record this baseline — it tells you how much room for improvement you have. To read one value, use get_keyword ('recall_at_10', __VEC_INDEX_VALIDATE ('DOCS_VEC')).
Published recall baseline
For a sense of what "good" looks like, here are recall@10 figures from the engine's ANN-quality test in the test suite, which compares each search with a brute-force scan of the same vectors: 5,000 clustered 384-dimensional vectors (200 clusters), hnsw_m = 16, ef_construction = 64, ef_search = 128, k = 10, 100 queries. Measured on this build, 2026-10-20:
| Configuration | Distance metric | recall@10 |
|---|---|---|
| Full 384-d | cosine | 1.00 |
| Full 384-d | l2 | 0.99 |
| Full 384-d | l1 | 1.00 |
Matryoshka reduce = 'truncate', 384 → 192 | cosine | 0.69 |
| After deleting 20% of rows (survivors) | cosine | 0.80 |
hnsw_m = 4, ef_construction = 8, at ef_search = 16 | cosine | 0.11 (0.97 for the graph above at the same ef_search) |
Takeaways: at these parameters, all three full-precision metrics land at or near perfect recall on well-clustered data. Build parameters set the ceiling — the starved graph in the last row cannot be rescued at query time. Dimension truncation trades recall for space — halving the dimensions here cost roughly a third of recall, so benchmark reduce = 'truncate' on your own data before adopting it. Deleted rows are never returned, but the graph is not repaired around them: deleting a fifth of the rows cost a fifth of the recall, and a wider ef_search does not win it back. __VEC_INDEX_REBUILD builds a fresh graph from the remaining embeddings (see Durability and recovery).
Step 3: Increase query-time parameters first
If recall is below your target, increase the query-time parameters. This requires no index rebuild — you simply change the value in your query. This is the cheapest way to improve accuracy.
For SQL: increase the k argument to X_SIMILAR.
-- Increase k from 10 to 100 — no rebuild needed:
SELECT TOP 10 id, content,
X_SIMILAR(content, 'reset password', 100) AS score
FROM DB.DBA.DOCS
ORDER BY VECTOR_DISTANCE(content, 'reset password');For SPARQL: increase the ef_search option.
SPARQL DEFINE input:storage ""
SELECT ?doc ?score WHERE {
?doc bif:vector_search "reset customer password"
option (INDEX <urn:docs:idx>, score ?score, LIMIT 10, ef_search 200) .
}
ORDER BY DESC(?score)
;After each increase, re-run __VEC_INDEX_VALIDATE and check whether recall has improved to your target. Query-time tuning typically yields the largest recall gains for the least effort.
Step 4: Rebuild with higher build-time parameters
If query-time tuning alone does not reach your recall target, the graph structure itself is the limiting factor. Drop and recreate the index with higher hnsw_m and ef_construction values.
-- Drop the existing index:
DROP INDEX DOCS_VEC;
-- Recreate with production-grade parameters:
CREATE VECTOR INDEX DOCS_VEC
ON DB.DBA.DOCS (content)
USING 'granite-384-local'
WITH (
dimensions = 384,
distance_metric = 'cosine',
hnsw_m = 32,
ef_construction = 400
);After recreating, re-run __VEC_INDEX_VALIDATE to confirm the recall improvement. You may then be able to lower your query-time parameters (k or ef_search) to recover latency, since the higher-quality graph needs a narrower beam to achieve the same recall.
Storage estimation
Before building a production index, estimate how much space its graph will take so you can size your instance accordingly. A graph node holds the vector, its neighbor links, and a little metadata; how much disk that takes depends on the page format and on whether the vector fits inside its node.
Per-node size
- Legacy format (an incremental build, or nodes linked in later): one 8 KB page per node, plus a second, overflow page when the vector is wider than
inline_dim_limit(1,742 floats athnsw_m = 16, 1,470 athnsw_m = 32). - Packed format (
build = 'bulk', or after afastrebuild): node records share pages, up to eight to a page, and each stores only the links it has. A vector wider thaninline_dim_limitstill takes an overflow page of its own.
Measured with 2,000 vectors per index, 2026-10-20 (physical_node_pages and overflow_pages from __VEC_INDEX_PAGE_STATS):
| Vectors | hnsw_m | Legacy, per node | Packed, per node |
|---|---|---|---|
| 384-dim | 16 | 8 KB | ~2 KB (4 nodes per page) |
| 1536-dim | 16 | 8 KB | 8 KB (1 node per page) |
| 1536-dim | 32 | 16 KB (node page + overflow page) | ~9 KB (overflow page + record) |
1536 → 256 (reduce = 'truncate') | 32 | 8 KB | ~1.4 KB (6 nodes per page) |
The packed 384-dim figure holds at scale: 57,600 FiQA vectors took 14,064 pages after a bulk build, 4.1 nodes per page.
Worked example: 1536-dim, hnsw_m = 32
For 100,000 documents embedded with OpenAI text-embedding-3-small, with hnsw_m = 32:
packed: 100,000 nodes * ~9 KB/node ≈ 0.9 GB for the HNSW graph
legacy: 100,000 nodes * 16 KB/node ≈ 1.6 GBAt hnsw_m = 16 both formats come to about 0.8 GB, because a 1536-dim vector then fits inside its node; reduced to 256 dimensions, the packed graph is about 0.14 GB.
Performance characteristics
Understanding the algorithmic complexity of HNSW helps you reason about how the parameters scale with your data.
| Operation | Complexity | Meaning |
|---|---|---|
| Build time | O(N * log(N) * ef_construction) | Linearithmic in dataset size; linear in ef_construction |
| Query time | O(ef_search * log(N)) | Sublinear in dataset size; linear in ef_search |
| Space | O(N * (hnsw_m * layers + dim)) | Linear in dataset size, hnsw_m, and dim |
Practical expectations:
- Graph build time grows linearly with
ef_constructionand slightly super-linearly with dataset size; embedding the text often costs more than building the graph. - Query time is sublinear in dataset size — searching 10x more data does not take 10x longer. It grows with
ef_search(ork), so widening the beam costs latency. - Recall of 95% and above is the usual target for HNSW with
hnsw_m = 32,ef_construction = 400, andef_search = 100(ork = 100); higher targets call for larger values of all three. Measure on your own data with__VEC_INDEX_VALIDATE.
Parameter reference table
The following table summarizes all parameters, where they are set, their defaults, valid ranges, and primary effect.
| Parameter | Where set | Default | Range | Effect |
|---|---|---|---|---|
hnsw_m | CREATE VECTOR INDEX WITH clause | 16 | 1–118 (VEC17 above; see Troubleshooting) | Graph density (neighbors per node per layer) |
ef_construction | CREATE VECTOR INDEX WITH clause | 200 | 1+ | Build quality (search depth during construction) |
dimensions | CREATE VECTOR INDEX WITH clause | from the model | 1+ | Vector size (reduced dim if reduce='truncate'); required with reduce='truncate' or source_kind='vector' |
distance_metric | CREATE VECTOR INDEX WITH clause (SPARQL: distance) | cosine | cosine / l2 / l1 | Distance function |
reduce | CREATE VECTOR INDEX WITH clause | none | none / truncate | MRL dimension reduction (SQL and SPARQL) |
build | CREATE VECTOR INDEX WITH clause (SQL and SPARQL) | auto (= incremental) | incremental / bulk / auto | How the initial backfill builds the graph. bulk measured 2.2x faster and 4.1x smaller, but is non-deterministic — see build |
rebuild | CREATE VECTOR INDEX WITH clause | auto | replay / fast / auto | Default method for __VEC_INDEX_REBUILD (see Maintenance: rebuild methods) |
source_kind | CREATE VECTOR INDEX WITH clause | text | text / vector | text = the model path (needs USING); vector = the column already holds precomputed vectors, no model (see Bring your own vectors). Enabled with the __vvec_passthrough_column registry flag. |
maintenance | create vector index with clause (SPARQL only) | manual | manual / async | Whether graph changes are queued automatically |
label | create vector index with clause (SPARQL only) | none | free text, <= 255 chars | A short human name for the index; published as rdfs:label in <urn:vvec:indexes> — see Naming an index |
comment | create vector index with clause (SPARQL only) | none | free text, <= 2000 chars | What the index is for; published as rdfs:comment |
k | X_SIMILAR 3rd argument (SQL) | the query's TOP n | 1+ | HNSW candidates and beam width |
limit | bif:vector_search option (SPARQL) | 10 | 1–10000 | Number of results |
ef_search | bif:vector_search option (SPARQL) | 100 | limit–100000 | Search beam width |
ranking | bif:vector_search option (SPARQL) | raw for an index list, relevance for index * | raw / relevance | How a multi-index result is ordered: by raw score, or by each hit's rank within its own index. Raw requires every index in the set to share a model and metric — see Searching several indexes |
VectorANNCostBias | virtuoso.ini [Parameters] | 1.0 | > 0 | Multiplies the optimizer's cost of a leading ANN search; below 1 favours it — see VectorANNCostBias |
Accepted aliases
The SQL WITH clause accepts short aliases for each parameter. The SPARQL DDL takes only the full names (plus build_method and rebuild_method), and fails with VEC13 on any other.
| Parameter | Accepted names (SQL) |
|---|---|
dimensions | d, dimension, dimensions |
reduce | rd, reduce, dim_reduce, reduction |
distance_metric | mt, distance_metric, metric |
hnsw_m | hm, hnsw_m |
ef_construction | ef, ef_construction |
build | bd, build, build_method |
rebuild | rb, rebuild_method, rebuild |
source_kind | sk, source_kind |
Next steps
- Similarity metrics — Detailed explanation of cosine, L2, and L1 distance functions, score derivation, and how to choose the right metric for your data
- SQL vector index how-to — Complete guide for creating, querying, and maintaining SQL vector indexes, including the
kparameter and explicit-vector patterns - SPARQL vector index how-to — Complete guide for RDF vector indexes with
bif:vector_search, including theef_searchoption and graph ACL security - Maintenance — Queue-based index updates, workers, and operational guidance for keeping your index current
- Naming an index — The
labelandcommentoptions, the<urn:vvec:indexes>RDF view they are published in, and who may set and read them
Related content
Reference:
- Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs — Malkov & Yashunin, the foundational HNSW paper
- hnswlib on GitHub — Reference implementation of HNSW and background on the algorithm
- Vector index overview — Architecture, HNSW parameters, and embedding model options
PreviousEmbedding models for the Virtuoso vector index NextSimilarity metrics for the Virtuoso vector index