---
title: "Index Parameters and Tuning for Virtuoso Vector Index"
description: "Tune the HNSW-backed Virtuoso vector index for the right balance of recall, memory, and latency — build-time parameters (hnsw_m, ef_construction, dimensions, distance_metric, build) and query-time parameters (k, limit, ef_search) with a measured tuning methodology and measured storage sizes."
url: urn:osdi:this
dateModified: 2026-10-30T15:45:34Z
language: en
---
One page of the Vector Index documentation, MERGED by build_vector_index_docs.py from this template and the markdown in fixture/vector-index/. Do not edit the generated file: change this template or the generator and rebuild. It opens on the documentation header (dxshell.py), not on the site hero.

[Home](urn:osdi:site)/[Virtuoso](urn:osdi:sitevirtuoso/)/[Vector index](urn:osdi:sitevirtuoso/docs/vector-index/)/Index parameters and tuning for the Virtuoso vector index

# Index parameters and tuning for the Virtuoso vector index

Tune the HNSW-backed Virtuoso vector index for the right balance of recall, memory, and latency — build-time parameters (hnsw_m, ef_construction, dimensions, distance_metric, build) and query-time parameters (k, limit, ef_search) with a measured tuning methodology and measured storage sizes.

Start  [Overview](urn:osdi:sitevirtuoso/docs/vector-index/) [Quickstart](urn:osdi:sitevirtuoso/docs/vector-index/quickstart/) [Tutorial: FAQ semantic search](urn:osdi:sitevirtuoso/docs/vector-index/tutorial-faq-semantic-search/)  Build  [SQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/) [SPARQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sparql-vector-index/) [Search RDF literals (knnSimilar)](urn:osdi:sitevirtuoso/docs/vector-index/knn-similar/)  Configure  [Embedding models](urn:osdi:sitevirtuoso/docs/vector-index/embedding-models/) Index parameters [Similarity metrics](urn:osdi:sitevirtuoso/docs/vector-index/similarity-metrics/)  Operate  [VVec Console (web UI)](urn:osdi:sitevirtuoso/docs/vector-index/vvec-console/) [Troubleshooting](urn:osdi:sitevirtuoso/docs/vector-index/troubleshooting/)  Reference  [API reference](urn:osdi:sitevirtuoso/docs/vector-index/api-reference/)

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`, and `build`
- **Query-time parameters**: the `k` argument to `X_SIMILAR` and the `limit` and `ef_search` SPARQL options
- **Tuning methodology**: a four-step process to move from defaults to production-grade accuracy
- **Storage estimation**: how much space the HNSW graph takes

Note

These pages describe the vector index in Virtuoso Open Source 7.2 as merged into the `develop/7` branch (server version 07.20.3243), built with `--enable-onnxruntime` and with the `vonnx` plugin loaded. It is not yet part of a tagged release.

For an architectural introduction, see [Vector index overview](urn:osdi:sitevirtuoso/docs/vector-index/).

---

## 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 |

Important

Build-time parameters are baked into the graph structure. Changing them requires dropping and recreating the index. Query-time parameters can be adjusted per query with no rebuild cost, so you should **always tune query-time parameters first** and reserve build-time changes for when query-time tuning hits its ceiling.

---

## 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.

SQL

```
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
);
```

Note

This page covers the HNSW build-time knobs, which are common to both the SQL and SPARQL surfaces; the SPARQL DDL spells the metric `distance`. The **SPARQL** `create vector index` DDL also accepts a `maintenance = 'manual' | 'async'` option that has no SQL equivalent: relational indexes record every change through triggers in `DB.DBA.VEC_SQL_QUEUE`, which `VEC_SQL_QUEUE_DRAIN` or the SQL worker applies. See the [SPARQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sparql-vector-index/#async-rdf-maintenance) and Maintenance.

### 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](#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. |

Tip

As a rule of thumb, double `hnsw_m` (16 → 32) when you move from development to production. Recall gains are significant — especially with high-dimensional embeddings where the graph needs more connections to navigate accurately — but check the storage table first for wide vectors.

### 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.

Note

Unlike `hnsw_m`, `ef_construction` has **no effect on storage** — it only affects how much work the builder does to find good neighbors. A graph built with `ef_construction = 600` occupies the same space as one built with `ef_construction = 200` (given the same `hnsw_m`); it is simply better connected.

### 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`.

SQL

```
--  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 INDEX` to fail with `VEC19` (SQL) or `VEC75` (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](urn:osdi:sitevirtuoso/docs/vector-index/similarity-metrics/).

Tip

For text embeddings, `cosine` is almost always the right choice. For cosine, `X_SIMILAR` and the `bif:vector_search` score are both `1 - distance / 2`, from 0 to 1, so identical text scores 1.0 and a document scores the same on both surfaces — see [Similarity metrics](urn:osdi:sitevirtuoso/docs/vector-index/similarity-metrics/#how-scores-are-computed).

### 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 |

SQL

```
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:

SQL

```
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.

Important

`build = 'bulk'` is **opt-in, and deliberately not the default**, because the parallel builder is *non-deterministic*: two builds of identical data produce different graphs. On a corpus whose nearest neighbours are extremely close together that is observable in results. Measured with all-MiniLM-L6-v2 (384-d) on a 10,500-document corpus whose texts differ only by an embedded integer (cosine **0.994** between the two closest documents), searching for one document's own text returned that document at rank 1 in only 1 of 4 bulk builds at the default `ef_search`, where an incremental graph returned it every time.

On ordinary corpora the effect is not visible: over 648 FiQA queries at `ef_search=100` (the same all-MiniLM-L6-v2 build as the table above), `'bulk'` scored **nDCG@10 0.3481 / Recall@10 0.4096** against incremental's 0.3442 / 0.4067 — bulk was slightly *better*, and repeated bulk builds scored identically. Prefer `'bulk'` for a large first build; prefer `'incremental'` if your corpus is full of near-duplicates and you depend on exact rank-1 self-retrieval.

Note

`build` is **not** `rebuild`, but they meet. `build` decides *whether* a refresh embeds everything first and then derives the graph in one pass; `rebuild` decides *how* that pass — and every later `__VEC_INDEX_REBUILD` — derives it. A bulk build calls `__VEC_INDEX_REBUILD` with no method, so it follows the usual method precedence: the index's own `rebuild` option, then the `VVEC_FAST_REBUILD` environment variable, then `'auto'`. Two consequences:

- `build = 'bulk', rebuild = 'replay'` embeds in bulk but links the nodes one at a time, into the legacy page format.
- Under `'auto'`, a bulk build of fewer than **10,000** rows is a replay build too, and so is one whose estimated footprint exceeds the fast builder's memory cap, or whose transaction log would not fit under `TransactionAfterImageLimit`. Set `rebuild = 'fast'` to force the parallel builder and packed pages.

#### 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_pct` percent 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.

SQL

```
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 10
```

Linked 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 |

Important

MRL truncation preserves embedding quality **only** for models trained with Matryoshka Representation Learning. Truncating a non-MRL model will degrade search quality significantly. The bundled granite model is not documented as MRL-trained, so do not truncate it.

### 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'`:

1. The model produces vectors at its **native** dimension (e.g. 1536 for OpenAI text-embedding-3-small)
2. Only the first `dimensions` elements are kept (e.g. 256)
3. The truncated vector is **L2-renormalized** (divided by its magnitude) so that cosine similarity remains meaningful
4. The reduced vector goes into the HNSW graph — less space, faster search. The index's embedding table keeps the model's full-width vector.

Note

The `dimensions` parameter is the **reduced** dimension, not the native dimension, and it is required. With `reduce = 'truncate'` it may not exceed the model's native output dimension (SPARQL requires it to be smaller). When `reduce = 'none'` (default), `dimensions` must equal the model's native output dimension exactly.

### 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

SQL

```
-- 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 |

Tip

For most use cases, reducing OpenAI text-embedding-3-small from 1536 to 256 dimensions provides an excellent balance: 6x smaller vectors with minimal recall loss. Benchmark your specific data to find the optimal point.

Important

**"Bytes per vector" above is the raw payload in the graph, not the on-disk footprint.** What a node takes on disk depends on the page format and on whether the vector fits inside its node — see [Storage estimation](#storage-estimation). Measured with 2,000 vectors and `hnsw_m = 32`: 1536-dim vectors took about 9 KB per node in the packed format (an overflow page per vector plus its node record), and the same vectors reduced to 256 about 1.4 KB, six nodes to a page. The embedding table keeps each full 1536-dim vector (6,144 bytes) either way. Use `__VEC_INDEX_PAGE_STATS` to check the current format.

### 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.

SQL

```
-- 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`. A `WHERE` threshold with neither `k` nor `TOP` is 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 most `k` rows**: on a six-row table where every row scores above 0.7, `X_SIMILAR (content, 'reset password', 2) > 0.7` matches 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`.

SQL

```
-- 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;
```

Note

For remote HTTP embedding providers, embed the query text once and reuse the vector to avoid duplicate API calls. See [SQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/) for the explicit-vector pattern.

### 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 least `limit` and 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.

SQL

```
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)
;
```

Important

`ef_search` must be greater than or equal to your `limit`, or the query fails with `VEC56`: `limit 10, ef_search 5` errors, and so does `limit 200` on its own, since `ef_search` defaults to 100. The search beam must be at least as wide as the number of results you request.

### 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`:

INI

```
[Parameters]
VectorANNCostBias = 1.0
```

The 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` = your `TOP n` (SQL), or `limit 10` and `ef_search = 100` (SPARQL)

SQL

```
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:

SQL

```
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:

Output

```
["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'))`.

Tip

Run `__VEC_INDEX_VALIDATE` after every parameter change so you can compare recall scores directly. Recall is the only objective measure of whether a change helped — latency and memory are easy to observe, but recall requires this validation step.

#### 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`.

SQL

```
-- 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.

SQL

```
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.

SQL

```
-- 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.

Important

Recreating an index embeds and inserts every row again, so it takes time proportional to your dataset size and `ef_construction`. Schedule it during a maintenance window for large production indexes. For ongoing maintenance (inserts, updates, deletes), see Maintenance.

---

## 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 at `hnsw_m = 16`, 1,470 at `hnsw_m = 32`).
- **Packed format** (`build = 'bulk'`, or after a `fast` rebuild): node records share pages, up to eight to a page, and each stores only the links it has. A vector wider than `inline_dim_limit` still 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`:

Output

```
packed:  100,000 nodes * ~9 KB/node  ≈ 0.9 GB for the HNSW graph
legacy:  100,000 nodes * 16 KB/node  ≈ 1.6 GB
```

At `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.

Note

These figures cover the HNSW graph only. On top of it come the index's embedding table — every vector once more, at the model's full width, `dim * 4` bytes plus row overhead — the base table, Virtuoso's buffer pool (`NumberOfBuffers * 8 KB`), and, for a local model, the ONNX helper processes (two or more per model, about 265 MB each at rest and more while embedding long texts). Use `__VEC_INDEX_PAGE_STATS('index_name')` to check the current format and `physical_node_pages`. For full deployment sizing guidance, see [SQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/).

---

## 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_construction` and 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` (or `k`), so widening the beam costs latency.
- **Recall** of 95% and above is the usual target for HNSW with `hnsw_m = 32`, `ef_construction = 400`, and `ef_search = 100` (or `k = 100`); higher targets call for larger values of all three. Measure on your own data with `__VEC_INDEX_VALIDATE`.

Tip

Because query time is `O(ef_search * log(N))` and build time is `O(N * log(N) * ef_construction)`, increasing query-time parameters is dramatically cheaper than rebuilding with higher build-time parameters for any non-trivial dataset. Always exhaust query-time tuning first.

---

## 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](urn:osdi:sitevirtuoso/docs/vector-index/troubleshooting/#symptom-vec17-vector-storage-cannot-fit-node-metadata-for-hnsw_mn)) | 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](#build-default-auto-which-means-incremental) |
| `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](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/#bring-your-own-vectors-precomputed-embeddings)). 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](urn:osdi:sitevirtuoso/docs/vector-index/howto-sparql-vector-index/#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](#vectoranncostbias-server-setting) |

Important

Build-time parameters (`hnsw_m`, `ef_construction`, `dimensions`, `distance_metric`, `reduce`) require an index drop and recreate to change. `build` decides how a backfill or refresh builds the graph, so it matters at `REFRESH` time rather than being baked into the graph. Query-time parameters (`k`, `limit`, `ef_search`) can be adjusted per query with no rebuild. The `rebuild` parameter only sets a **default method** and can be overridden per call — `__VEC_INDEX_REBUILD('<name>', 'fast')` — without a recreate. `label` and `comment` describe the index rather than configuring it and can be changed at any time with `DB.DBA.VEC_INDEX_SET_METADATA`, with no recreate.

### 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](urn:osdi:sitevirtuoso/docs/vector-index/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](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/)** — Complete guide for creating, querying, and maintaining SQL vector indexes, including the `k` parameter and explicit-vector patterns
- **[SPARQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sparql-vector-index/)** — Complete guide for RDF vector indexes with `bif:vector_search`, including the `ef_search` option and graph ACL security
- **Maintenance** — Queue-based index updates, workers, and operational guidance for keeping your index current
- **Naming an index** — The `label` and `comment` options, 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](https://arxiv.org/abs/1603.09320) — Malkov & Yashunin, the foundational HNSW paper
- [hnswlib on GitHub](https://github.com/nmslib/hnswlib) — Reference implementation of HNSW and background on the algorithm
- [Vector index overview](urn:osdi:sitevirtuoso/docs/vector-index/) — Architecture, HNSW parameters, and embedding model options

[PreviousEmbedding models for the Virtuoso vector index](urn:osdi:sitevirtuoso/docs/vector-index/embedding-models/) [NextSimilarity metrics for the Virtuoso vector index](urn:osdi:sitevirtuoso/docs/vector-index/similarity-metrics/)

On this page

1. [Overview](#overview)
2. [Build-time parameters](#build-time-parameters)
3. [Matryoshka dimension reduction](#matryoshka-dimension-reduction)
4. [Query-time parameters](#query-time-parameters)
5. [Tuning methodology](#tuning-methodology)
6. [Storage estimation](#storage-estimation)
7. [Performance characteristics](#performance-characteristics)
8. [Parameter reference table](#parameter-reference-table)
9. [Next steps](#next-steps)
10. [Related content](#related-content)
