---
title: "How-To: Build and Use the SPARQL Vector Index (Virtuoso)"
description: "Learn how to create, query, and maintain a native HNSW-backed vector index over RDF data in Virtuoso using SPARQL DDL and the bif:vector_search magic predicate."
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/)/How-To: Build and use the SPARQL vector index (Virtuoso)

# How-To: Build and use the SPARQL vector index (Virtuoso)

Learn how to create, query, and maintain a native HNSW-backed vector index over RDF data in Virtuoso using SPARQL DDL and the bif:vector_search magic predicate.

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 [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](urn:osdi:sitevirtuoso/docs/vector-index/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/)

This guide covers building and using the **RDF/SPARQL vector index** in Virtuoso Open Source. It indexes RDF resources by embedding text selected from RDF graphs, stores the embeddings in a native HNSW-backed vector index, and exposes graph-permission checked search through the SPARQL magic predicate `bif:vector_search`.

This article covers:

- **Prerequisites**: Build configuration and model setup
- **Creating an RDF vector index**: Shorthand and general selectors via SPARQL DDL
- **Populating and refreshing**: Manual and async maintenance
- **Querying with `bif:vector_search`**: The SPARQL search surface with options
- **Graph ACL and security**: Permission-checked search for application users
- **HTTP provider models**: Using OpenAI, Ollama, and other remote providers
- **Troubleshooting**: Common issues and diagnostics

Note

For the relational SQL vector index surface (`CREATE VECTOR INDEX ... ON DB.DBA.TABLE(col)`, `X_SIMILAR`, `VECTOR_DISTANCE`), see [SQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/). To search literal values wherever they occur, rather than subjects picked out by a selector, see [Vector search over RDF literals](urn:osdi:sitevirtuoso/docs/vector-index/knn-similar/).

---

## Prerequisites

Before you start, make sure you have:

- **Virtuoso Open Source** built with the ONNX plugin (`--enable-onnxruntime`) and the `vonnx` plugin loaded — see [Build Virtuoso with ONNX support](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/#build-virtuoso-with-onnx-support)
- **A registered embedding model** in `DB.DBA.ONNX_MODELS` (the bundled `granite-384-local` is auto-provisioned at startup)
- **RDF data** where each indexed entity has text to embed, usually a literal on a known predicate in a known graph

**Verify the plugin and model at startup:**

SQL

```
SELECT model_name, model_type, output_shape, handle
FROM DB.DBA.ONNX_MODELS
WHERE model_name = 'granite-384-local';
-- Expect: text_embedding, 1,384, handle = 1 (non-null means loaded)
```

For a local ONNX model, `handle` should be non-null. For HTTP provider models, the model type is the provider name and no local handle is required.

---

## Seed RDF data

The shorthand RDF vector index expects a fixed graph and predicate:

SQL

```
SPARQL DEFINE input:storage ""
CLEAR GRAPH <urn:docs:g>
;

SPARQL DEFINE input:storage ""
INSERT DATA {
  GRAPH <urn:docs:g> {
    <urn:doc:1> <urn:doc:text> "Password reset steps for the customer portal" .
    <urn:doc:2> <urn:doc:text> "Quarterly billing escalation workflow" .
    <urn:doc:3> <urn:doc:text> "How to rotate API credentials" .
  }
}
;
```

The subject IRI becomes the search result entity. The literal object is embedded.

---

## Create an RDF vector index

### Shorthand selector

Use this when all indexed text lives under one graph/predicate pair:

SQL

```
SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:docs:idx>
  ON GRAPH <urn:docs:g>
  for predicate <urn:doc:text>
  USING MODEL 'granite-384-local'
  WITH (
    dimensions = 384,
    distance = 'cosine',
    hnsw_m = 16,
    ef_construction = 200
  )
;
```

This query runs as follows:

1. **`on graph`** specifies the RDF graph to index.
2. **`for predicate`** specifies which predicate's literal value to embed.
3. **`using model`** binds the index to a model in `DB.DBA.ONNX_MODELS`.
4. **`with (...)`** sets HNSW and dimension parameters.

**Key concepts:**

- The catalog row is stored in `DB.DBA.SPARQL_VEC_INDEX`.
- A backing source table plus a native relational vector index are created.
- The implementation does **not** scan `DB.DBA.RDF_QUAD` at query time.

**What gets embedded.** Each subject that has the predicate in the graph gets at most **one** embedding, of one of its literal values:

| The object is | What happens |
| --- | --- |
| a plain, language-tagged or typed literal | its lexical text is embedded — `"chat"@fr` as `chat`, `"42"^^xsd:int` as `42` |
| a long literal (stored out of line, as an `RO_LONG` value) | its **full** text is embedded |
| an IRI or a blank node | skipped — it is not text, so its identifier is never embedded as if it were |
| an empty string | skipped |

A subject with only IRI, blank-node or empty objects gets no embedding and never appears in search results. A subject with several literals for the predicate gets exactly one of them embedded — which one is not specified, so give each subject a single text value (or a general selector that picks one) if it matters which.

A literal is identified by its id in `DB.DBA.RDF_OBJ`, not by its text, so a refresh re-embeds exactly the subjects whose literal changed, was added, or was removed. A refresh with nothing changed embeds nothing. For a shorthand selector the refresh reads `DB.DBA.RDF_QUAD` directly, as one range scan on the predicate: 9.7 million DBpedia descriptions enumerate in about 70 seconds.

Check the catalog:

SQL

```
SELECT SVI_IRI, SVI_STATE, SVI_SELECTOR_TYPE, SVI_SELECTOR_GRAPH,
       SVI_SELECTOR_PRED, SVI_MODEL_NAME, SVI_DIMENSIONS, SVI_DISTANCE
FROM DB.DBA.SPARQL_VEC_INDEX
WHERE SVI_IRI = 'urn:docs:idx';
```

`SVI_STATE` should be `active`.

### General selector

Use a general selector when the text is derived from a richer graph pattern:

SQL

```
SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:docs:general-idx>
  KEY ?doc
  EMBEDDING ?text
  WHERE {
    GRAPH <urn:docs:g> {
      ?doc <urn:doc:text> ?text .
      ?doc <urn:doc:type> <urn:doc:SupportArticle> .
    }
  }
  USING MODEL 'granite-384-local'
  WITH (dimensions = 384, distance = 'cosine')
;
```

Important

The `KEY` variable must identify the entity IRI. The `EMBEDDING` variable must be the text literal. The two variables must be different.

### Prefixes in the WHERE pattern

The `WHERE` pattern is compiled as SPARQL, so a prefixed name such as `foaf:name` resolves only if that prefix is defined. There are three ways to define it:

1. **Full IRIs** — always works, no prefix needed. Write the absolute IRI in angle brackets, exactly as the examples above do:```sparql where { graph <urn:docs:g> { ?doc <http://xmlns.com/foaf/0.1/name> ?text } }```
2. **Server-wide persistent prefixes** — Virtuoso ships with many namespace prefixes predeclared (`rdf`, `rdfs`, `owl`, `foaf`, `dc`, `xsd`, `skos`, …) that are usable in **any** query or DDL **without** a local `PREFIX`. Register your own **before** creating the index with the **`DB.DBA.XML_SET_NS_DECL`** stored procedure — the third argument `2` makes the declaration **persistent** (stored in `DB.DBA.SYS_XML_PERSISTENT_NS_DECL`):```sql -- DB.DBA.XML_SET_NS_DECL (prefix, namespace_uri, mode) -- mode 2 = persistent DB.DBA.XML_SET_NS_DECL ('acme', 'http://acme.example/ns#', 2);````sparql sparql define input:storage "" create vector index <urn:docs:general-idx> key ?doc embedding ?text where { graph <urn:docs:g> { ?doc acme:body ?text } } using model 'granite-384-local' with (dimensions = 384) ;```This is the option to use from the **VVec Console**'s Custom selector form, whose`WHERE`field cannot carry a local`PREFIX` (see option 3).
3. **Local `PREFIX` declarations** — when you write the DDL yourself, the whole statement is one SPARQL string, so ordinary `PREFIX` lines apply to the pattern. Order matters: any `define` pragma (such as `input:storage ""`) must come **first**, then the `PREFIX` declarations, then `create vector index`:```sparql sparql define input:storage "" prefix foaf: <http://xmlns.com/foaf/0.1/> create vector index <urn:docs:general-idx> key ?s embedding ?name where { graph <urn:docs:g> { ?s foaf:name ?name } } using model 'granite-384-local' with (dimensions = 384) ;```> [!NOTE] >`PREFIX`must come after any`define`pragma and before`create vector index`> (writing`prefix … define …`is a syntax error), and it is **not** valid inside > the`where { … }`group. The VVec Console fills only the inside of that > group, so a local`PREFIX` cannot be used there — use option 1 or 2 in the UI.

### Index options

| Option | Description | Default |
| --- | --- | --- |
| `dimensions` | Positive integer; can be inferred from model if omitted. When `reduce = 'truncate'`, this is the **reduced** dimension, not the native dimension. | — |
| `distance` | `cosine`, `l1`, or `l2` | `cosine` |
| `hnsw_m` | HNSW graph connectivity | 16 |
| `ef_construction` | Build-time search depth | 200 |
| `maintenance` | `manual` or `async` | `manual` |
| `reduce` | `none` or `truncate` — enables Matryoshka dimension reduction for MRL-trained models | `none` |
| `build` | `incremental` (link each embedded batch into the graph; `auto` means the same) or `bulk` (stage every vector, then derive the graph in one parallel pass) — see [Speed up a large build](#speed-up-a-large-build) | `incremental` |
| `rebuild` | How a graph is rebuilt from stored embeddings: `auto`, `fast` or `replay` — see [Index parameters](urn:osdi:sitevirtuoso/docs/vector-index/index-parameters/) | `auto` |
| `source_kind` | `text` (model path) or `vector` — recorded on the catalog row (`SVI_SOURCE_KIND`) but not acted on; see the note below | `text` |
| `label` | Free text, up to 255 characters. A short name for the index, published as `rdfs:label` in `<urn:vvec:indexes>` — see Naming an index | none |
| `comment` | Free text, up to 2000 characters. What the index is for, published as `rdfs:comment` | none |

Option names are SPARQL's own: the SQL spellings such as `distance_metric` are not accepted here, and the values of `distance`, `maintenance` and `reduce` are written in lower case.

Note

**Precomputed vectors.** A SPARQL vector index always embeds text with its model; `source_kind = 'vector'` is accepted and stored but does not change that. To index embeddings you computed yourself, keep them in a relational column and use the SQL [Bring your own vectors](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/#bring-your-own-vectors-precomputed-embeddings) path (`CREATE VECTOR INDEX … WITH (source_kind = 'vector')`).

### Matryoshka dimension reduction (reduce = 'truncate')

For models trained with Matryoshka Representation Learning (e.g. OpenAI `text-embedding-3-small`), you can reduce the stored vector dimension to save memory and speed up search. The model produces vectors at its native dimension, then the first `dimensions` elements are kept and the vector is L2-renormalized before storage.

SQL

```
-- Register an 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 a reduced RDF vector index (1536 -> 256):
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
    reduce          = 'truncate',
    distance        = 'cosine',
    hnsw_m          = 32,
    ef_construction = 400
  )
;
```

The catalog records the reduced dimension in `SVI_DIMENSIONS` and the model's native output dimension in `SVI_MODEL_DIMENSIONS`:

SQL

```
SELECT SVI_IRI, SVI_REDUCE, SVI_DIMENSIONS, SVI_MODEL_DIMENSIONS
FROM DB.DBA.SPARQL_VEC_INDEX
WHERE SVI_IRI = 'urn:docs:reduced-idx';
-- Expect: SVI_REDUCE = truncate, SVI_DIMENSIONS = 256, SVI_MODEL_DIMENSIONS = 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.

Important

MRL truncation preserves embedding quality **only** for models trained with Matryoshka Representation Learning. Truncating a model that was not trained that way — check its model card — will degrade search quality significantly. See [Index parameters: Matryoshka reduction](urn:osdi:sitevirtuoso/docs/vector-index/index-parameters/#matryoshka-dimension-reduction) for the full validation rules and memory comparison.

---

## Populate and refresh the index

After creating the index, run a refresh to evaluate the selector, embed text, and populate the HNSW graph:

SQL

```
SPARQL DEFINE input:storage ""
refresh VECTOR INDEX <urn:docs:idx>
;
```

Refresh is also the manual maintenance operation after bulk RDF loads or edits:

SQL

```
-- Delete old text, insert new text, then refresh:
SPARQL DEFINE input:storage ""
DELETE DATA {
  GRAPH <urn:docs:g> {
    <urn:doc:2> <urn:doc:text> "Quarterly billing escalation workflow" .
  }
}
;

SPARQL DEFINE input:storage ""
INSERT DATA {
  GRAPH <urn:docs:g> {
    <urn:doc:2> <urn:doc:text> "Billing escalation and invoice dispute workflow" .
  }
}
;

SPARQL DEFINE input:storage ""
refresh VECTOR INDEX <urn:docs:idx>
;
```

Tip

Refresh skips unchanged rows: it compares each subject's literal with the one it embedded last time — by the literal's `RDF_OBJ` id, or for a short literal stored inline, by a hash of its text. It also deletes stale vector entries when a subject no longer matches the selector.

### Speed up a large build

Two settings dominate how long a refresh takes. Both are covered in Maintenance and [Index parameters](urn:osdi:sitevirtuoso/docs/vector-index/index-parameters/#build-default-auto-which-means-incremental); in short:

SQL

```
-- 1. embed several batches at once (default 4, max 32). A serial caller pins
--    exactly one CPU core no matter how many the machine has.
registry_set ('__vec_embed_concurrency', '8');

-- 2. for a large FIRST build, defer the graph and derive it 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 57,600 FiQA documents, embedded with all-MiniLM-L6-v2 (384-d): 85 min with one embedding batch at a time, **16 min 12 s** at concurrency 4, and **5 min 51 s** with concurrency 8 plus `build = 'bulk'` — which also produces an index 4.1x smaller on disk.

Progress goes to the server log every 2000 rows with a rate and an ETA, so a long build reports how far it has got — see Build progress.

Important

`build = 'bulk'` is opt-in because the parallel graph builder is non-deterministic. It is the right choice for a large first build, but read the caveat in [Index parameters](urn:osdi:sitevirtuoso/docs/vector-index/index-parameters/#build-default-auto-which-means-incremental) if your corpus contains many near-duplicate texts and you rely on exact rank-1 self-retrieval.

### After a large refresh: compact with a fast rebuild

An incremental `REFRESH` — the default, `build = 'incremental'` — embeds and links the graph one batch at a time and leaves the legacy one-node-per-page format; it never uses the parallel bulk builder. (A `build = 'bulk'` index derives its graph in one pass, with the method its `rebuild` option names, so it does not need this step.)

After a big initial refresh, rebuild the graph from the embeddings already stored. This does **not** re-run the embedding model, so it costs seconds rather than repeating the whole build:

SQL

```
-- Internal index name for the SPARQL index IRI:
SELECT VEC_INDEX_GENERATE_NAME ('urn:docs:idx');
-- VECIDX_DA60E202D86E38DCF7897828F86C49BD

-- Compact to packed pages and rebuild the graph in parallel:
SELECT __VEC_INDEX_REBUILD (VEC_INDEX_GENERATE_NAME ('urn:docs:idx'), 'fast');

-- Confirm: format flips from 'legacy' to 'packed'
SELECT get_keyword ('format', __VEC_INDEX_PAGE_STATS (VEC_INDEX_GENERATE_NAME ('urn:docs:idx')));
```

Measured on a 57,600-document RDF index (vectors from all-MiniLM-L6-v2, 384-d, `hnsw_m = 16`), refresh followed by a `'fast'` rebuild:

|  | after `REFRESH` | after `'fast'` rebuild |
| --- | --- | --- |
| page format | legacy | packed |
| physical node pages | 57,600 | 14,067 (4.1× smaller) |
| graph build time | ~50 min (part of an 85-min refresh) | 6 s |
| nDCG@10 / Recall@10 | 0.3443 / 0.4051 | 0.3484 / 0.4105 |

The decisive gains are **build time and storage**. Retrieval quality improves only slightly at `ef_search = 100`, because a wider search beam largely compensates for the weaker incremental graph — so treat the rebuild as a compaction and build-time step, not a way to fix poor search results.

See Building vs rebuilding for the two stages and the method-precedence rules.

---

## Async RDF maintenance

`maintenance = 'async'` enables RDF quad triggers to enqueue changed subjects for both shorthand and general selectors. Nothing drains that queue on its own: the changes become searchable when you drain it — with `VEC_INDEX_QUEUE_DRAIN`, the worker's run-once call, or a scheduled event that calls one of them.

### Shorthand selector

For a shorthand selector, the dependency is fixed by the DDL — only changes to the specified `(graph, predicate)` pair enqueue the changed subject:

SQL

```
SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:docs:async-idx>
  ON GRAPH <urn:docs:g>
  for predicate <urn:doc:text>
  USING MODEL 'granite-384-local'
  WITH (
    dimensions = 384,
    distance = 'cosine',
    maintenance = 'async'
  )
;
```

### Queue control

SQL

```
-- Drain manually (returns the number of queue entries processed):
SELECT DB.DBA.VEC_INDEX_QUEUE_DRAIN(1000);

-- Suspend / resume draining (changes are still enqueued while suspended):
DB.DBA.VEC_INDEX_QUEUE_SUSPEND();
SELECT DB.DBA.VEC_INDEX_QUEUE_IS_SUSPENDED();   -- 1
DB.DBA.VEC_INDEX_QUEUE_RESUME();
```

### Worker control

SQL

```
DB.DBA.VEC_INDEX_WORKER_CONFIGURE(100, 5);   -- batch size 100 (1-10000); idle delay (1-3600)
DB.DBA.VEC_INDEX_WORKER_START();             -- enables the worker
DB.DBA.VEC_INDEX_WORKER_STATUS();            -- enabled, batch, delay, last run, pending, errors, processed
SELECT DB.DBA.VEC_INDEX_WORKER_RUN_ONCE();   -- drains one batch; 0 while the worker is stopped
DB.DBA.VEC_INDEX_WORKER_STOP();
```

`VEC_INDEX_WORKER_START` only enables the worker; it does not start a thread or schedule anything, and the idle delay is recorded but not used. Each call of `VEC_INDEX_WORKER_RUN_ONCE` drains one batch. To drain on a timer, schedule that call yourself — Virtuoso's scheduler counts in minutes and wakes every `SchedulerInterval` minutes (`[Parameters]`, 10 in the shipped `virtuoso.ini`):

SQL

```
INSERT INTO DB.DBA.SYS_SCHEDULED_EVENT (SE_NAME, SE_START, SE_INTERVAL, SE_SQL)
  VALUES ('VEC_INDEX_WORKER', now (), 1, 'DB.DBA.VEC_INDEX_WORKER_RUN_ONCE ()');
```

Note

The RDF-side queue (`SPARQL_VEC_QUEUE`) and worker are separate from the relational SQL queue (`VEC_SQL_QUEUE`).

### General selector

A general selector with `maintenance = 'async'` uses **predicate-precise `(graph, predicate)` dependency tracking**. At parse time, the parser walks the `WHERE` pattern and extracts one `(graph, predicate)` pair per indexed triple, storing them in `DB.DBA.SPARQL_VEC_INDEX_TRIGGER`. At runtime, only changes to a matching `(graph, predicate)` pair enqueue the changed subject — changes to unrelated predicates in the same graph do not.

SQL

```
SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:docs:async-general-idx>
  KEY ?doc
  EMBEDDING ?text
  WHERE {
    GRAPH <urn:docs:g> {
      ?doc <urn:doc:text> ?text .
      ?doc <urn:doc:type> <urn:doc:SupportArticle> .
    }
  }
  USING MODEL 'granite-384-local'
  WITH (
    dimensions = 384,
    distance = 'cosine',
    maintenance = 'async'
  )
;
```

In this example, the parser extracts two trigger rows for `<urn:docs:g>`: `(urn:docs:g, urn:doc:text)` and `(urn:docs:g, urn:doc:type)`. A change to either predicate in `<urn:docs:g>` enqueues the changed subject; a change to any other predicate in that graph does not.

Inspect the extracted trigger rows:

SQL

```
SELECT SVT_GRAPH_IRI, SVT_PRED_IRI
FROM DB.DBA.SPARQL_VEC_INDEX_TRIGGER
WHERE SVT_INDEX_IRI = 'urn:docs:async-general-idx';
```

#### Scoping rules

| `WHERE` pattern shape | Trigger row | Enqueue behaviour |
| --- | --- | --- |
| `graph <G> { ?s <P> ?o }` | `(G, P)` — predicate-precise | Only changes to `<P>` in `<G>` enqueue |
| `graph <G> { ?s ?p ?o }` (variable predicate) | `(G, '')` — graph-only fallback | Any predicate change in `<G>` enqueues |
| `?s <P> ?o` with `FROM <G>` on the selector | `(G, P)` for each `FROM` graph | Only changes to `<P>` in those graphs enqueue |
| `?s <P> ?o` with neither, or `graph ?g { … }` | none — not async-scopable | `maintenance = 'async'` is rejected with `VEC77` |

Important

A general selector with `maintenance = 'async'` requires every indexed triple pattern to be scoped to a named graph — inside an explicit `GRAPH <iri>`, or by a `FROM <iri>` clause on the selector (`FROM NAMED` does not count). Otherwise `CREATE VECTOR INDEX` is refused and nothing is left behind:

Output

```
VEC77  maintenance = async for a general (KEY/EMBEDDING/WHERE) vector index requires
       every indexed triple pattern to be scoped to a named graph -- either inside an
       explicit GRAPH <iri>, or by a FROM <iri> clause on the selector; use maintenance
       = 'manual' with periodic REFRESH VECTOR INDEX for patterns that span a variable graph
```

Like every SPARQL vector-index DDL error, it arrives as a `STATE`/`MESSAGE` row of the statement's result, not as a failed statement — read the rows.

Note

Incremental async maintenance enqueues the **changed subject** as the candidate key. This is exact when the `KEY` variable is the subject of the changed triples. If the `WHERE` joins such that a change to one subject alters the embedding text of a *different* key subject (multi-hop/join), that change is not tracked incrementally; run a periodic `REFRESH VECTOR INDEX` for such patterns. See Maintenance: async scoping for full details.

---

## Query with bif:vector_search

The public SPARQL search surface is a magic predicate:

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,
    score_limit 0.85,
    distance_limit 0.30,
    LIMIT 10,
    ef_search 100
  ) .
}
ORDER BY DESC(?score)
;
```

On the data above — after the [refresh example](#populate-and-refresh-the-index) rewrote `<urn:doc:2>` — and with the bundled model, only `<urn:doc:1>` clears the limits (score 0.938); without them `<urn:doc:3>` follows at 0.798 and `<urn:doc:2>` at 0.761 (0.769 with its original text). For a cosine index the score is `1 - distance / 2` — what `X_SIMILAR` returns in SQL — so the two limits here are the same condition.

This query runs as follows:

1. **`bif:vector_search`** embeds the query text once with the index's bound model.
2. **HNSW search** finds the nearest neighbors in the graph.
3. **Row IDs** are converted back to RDF IRIs.
4. **Graph permission checks** filter hits the user can't read.
5. **Score and distance** are bound as optional variables.

### Search options

| Option | Description | Default |
| --- | --- | --- |
| `index <iri>` | Explicit RDF vector index. If omitted, exactly one active index must exist. | — |
| `index (<a>, <b>, …)` | Search several indexes at once — see [Searching several indexes](#searching-several-indexes) | — |
| `index *` | Search every active index the caller can read | — |
| `ranking raw \| relevance` | How a multi-index result is ordered | `raw` for a list, `relevance` for `*` |
| `score ?var` | Bind a higher-is-better similarity score, from 0 to 1 (the `X_SIMILAR` scale) | — |
| `distance ?var` | Bind the native distance value | — |
| `score_limit expr` | Inclusive lower bound on score | — |
| `distance_limit expr` | Inclusive upper bound on distance | — |
| `limit expr` | Max results (1–10000) | 10 |
| `ef_search expr` | HNSW search beam width (>= limit, <= 100000) | 100 |

Caution

`score_limit` must be between 0 and 1 for every metric; `distance_limit` must be finite and non-negative — see [Similarity metrics](urn:osdi:sitevirtuoso/docs/vector-index/similarity-metrics/#sparql-bifvector_search). Because `ef_search` defaults to 100 and may not be smaller than `limit`, a `limit` above 100 needs `ef_search` as well; `limit 200` alone fails with `VEC56: ef_search must be at least limit and not greater than 100000`.

Note

Name the `score` and `distance` variables anything but `?s`: in this version a variable called `?s` in those options receives the subject instead of the value. `?score`, `?sim`, `?distance` and the like are fine.

Important

**One `index` option per search.** Writing `option (index <a>, index <b>)` is refused when the query is compiled — to search several indexes, list them in one option:

Output

```
SP031: SPARQL compiler: bif:vector_search accepts one INDEX option; to search several
indexes write them as one list, option (index (<a>, <b>)), or all of them with option (index *)
```

### Searching several indexes

`index (<a>, <b>)` searches the named indexes and returns one merged result. `index *` searches every active index the caller can read.

SQL

```
SPARQL DEFINE input:storage ""
SELECT ?doc ?score
WHERE {
  ?doc bif:vector_search "reset customer password"
  option (INDEX (<urn:docs:idx>, <urn:faq:idx>), score ?score, LIMIT 10) .
}
;
```

**`limit` means N results in total**, not N per index. It costs no accuracy: the top N of each index's top N is the top N of the union.

**A subject that sits in two indexes comes back once**, as whichever occurrence the ordering prefers, with that row's `score` and `distance`.

**Ordering.** `ranking raw` puts the highest raw score first. That is only a meaningful order when every index in the set shares a **model** and a **distance metric** — scores from two models sit on different baselines, so a union ordered by raw score is ordered by model rather than by relevance — and the engine refuses a mixed set rather than answering it wrongly:

Output

```
VEC68  Vector indexes <urn:docs:idx> and <urn:docs:openai-idx> use different embedding
       models (granite-384-local and openai-small), so their scores are not
       comparable. Use ranking relevance to merge them by rank within each
       index, or drop score_limit / distance_limit.
```

`ranking relevance` orders by each hit's position **within its own index**, ties broken by score. Rank is comparable across models by construction, so a mixed set is fine under it. `score_limit` and `distance_limit` are thresholds in the same units as the score, so they need the same agreement the raw ordering does, whichever ordering is asked for.

**`index *` orders by relevance unless told otherwise.** This is deliberately not adaptive and deliberately not raw. With an explicit list the author chose the indexes and can see whether the models match; with `*` they chose nothing — the set is whatever exists when the query runs, and it changes when a colleague adds an index next month. Rank is valid whatever that set turns out to be. An author who knows their indexes are homogeneous and wants the scores writes `option (index *, ranking raw)`, and is told if they are wrong.

`*` covers indexes whose graph the caller may read. Two users with different graph permissions get different answers from the same query, because the expansion happens when the query runs rather than when it is compiled. Indexes with a **general selector** are always included: they have no single graph to check, and their hits are filtered row by row like everyone else's. `*` matching nothing returns no rows rather than an error — an empty instance and an instance where you may see nothing look the same.

### Fixed graph queries

When a vector search appears inside a fixed `GRAPH` block, that graph is passed to the row source and enforced before a hit is yielded:

SQL

```
SPARQL DEFINE input:storage ""
SELECT ?doc
WHERE {
  GRAPH <urn:docs:g> {
    ?doc bif:vector_search "api credentials"
    option (INDEX <urn:docs:idx>, LIMIT 5, ef_search 50) .
  }
}
;
-- urn:doc:3, urn:doc:1, urn:doc:2 -- the same hits, in the same order, as
-- without the GRAPH block
```

Note

For shorthand indexes, a fixed graph different from the index graph returns no hits.

---

## Graph ACL and security

`bif:vector_search` is the public RDF search interface. It performs graph-permission checks before returning each candidate. A user with `SPARQL_SELECT` cannot read hits from graphs they cannot read.

### Grant search-only access

SQL

```
DB.DBA.USER_CREATE('APP_VEC_SEARCH', '<password>', VECTOR('SQL_ENABLE', 1));
GRANT SPARQL_SELECT TO APP_VEC_SEARCH;

-- Per-graph read permission (checked before returning hits):
DB.DBA.RDF_GRAPH_USER_PERMS_SET('urn:docs:g', 'APP_VEC_SEARCH', 1);
```

Important

Do not grant `DBA`, `SPARQL_UPDATE`, or direct execute rights on the vector-index management procedures to application users. Keep these DBA-only: `VEC_INDEX_BEGIN_CREATE`, `VEC_INDEX_REFRESH`, `VEC_INDEX_DROP`, `VEC_INDEX_QUEUE_*`, `VEC_INDEX_WORKER_*`, `VEC_INDEX_SEARCH`.

---

## HTTP provider models

The RDF layer uses the same model resolver as the SQL vector layer. Register provider models with the SQL-side helper, then reference the model name in SPARQL DDL:

SQL

```
-- Register an OpenAI model:
DB.DBA.ONNX_API_KEY_SET('ONNX_API_KEY_OPENAI', '<secret>');

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 using the remote model:
SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:docs:openai-idx>
  ON GRAPH <urn:docs:g>
  for predicate <urn:doc:text>
  USING MODEL 'openai-small'
  WITH (dimensions = 1536, distance = 'cosine')
;
```

Querying is unchanged. Query latency includes the provider call used to embed the query text.

---

## Verify the lowering path

Use `sparql_to_sql_text` to confirm the query lowers to the HNSW row source:

SQL

```
SELECT CAST(sparql_to_sql_text(
  'select ?doc where {
     ?doc bif:vector_search "reset customer password"
     option (index <urn:docs:idx>, limit 5, ef_search 50)
   }') AS VARCHAR);
```

The SQL should contain `DB.DBA.VEC_INDEX_SEARCH_ROWS`. It should **not** scan `RDF_QUAD` for the BIF IRI.

---

## Drop an index

SQL

```
SPARQL DEFINE input:storage ""
DROP VECTOR INDEX <urn:docs:idx>
;
```

Use `SILENT` to tolerate a missing index:

SQL

```
SPARQL DEFINE input:storage ""
DROP SILENT VECTOR INDEX <urn:docs:idx>
;
```

This removes the catalog record, queue entries, backing source table, and native vector index.

---

## Troubleshooting

| Symptom | Cause & fix |
| --- | --- |
| `VEC58` or `VEC59` from omitted `index` | Add `option (index <...>)` — omitting `index` requires exactly one active index. |
| No rows for a non-DBA user | Check graph ACLs — grant read permission for the graph. |
| No rows even as DBA | Refresh the index first (a SPARQL index is empty until `REFRESH VECTOR INDEX` runs), then test `SELECT obj2json (DB.DBA.VEC_INDEX_SEARCH('urn:docs:idx', 'query', 10, 100));` directly. |
| `CREATE VECTOR INDEX` "succeeds" but the index does not exist | The DDL reports errors as `STATE`/`MESSAGE` rows of its result (for example `VEC13 Unknown vector index option: distance_metric`) and rolls the create back. Read the rows; use the SPARQL option names. |
| Async changes never appear | Nothing drains the queue on its own — call `SELECT DB.DBA.VEC_INDEX_QUEUE_DRAIN(1000);` or schedule `VEC_INDEX_WORKER_RUN_ONCE` (see [Worker control](#worker-control)). Check suspension with `SELECT DB.DBA.VEC_INDEX_QUEUE_IS_SUSPENDED();`. |
| `VEC56: ef_search must be at least limit …` | A `limit` above 100 needs a matching `ef_search`. |
| `CREATE VECTOR INDEX` fails with `SP030: … PREDICATE expected after FOR in a vector index declaration` | The shorthand selector is `[ON GRAPH <g>] FOR PREDICATE <p>`; no other word may follow `FOR`. To embed anything else, use a [general selector](#general-selector). |

### Diagnosing empty results

SQL

```
-- 1. Verify the index has embedded rows — a list of (row id, distance) pairs:
SELECT obj2json (DB.DBA.VEC_INDEX_SEARCH('urn:docs:idx', 'test query', 10, 100));

-- 2. Check RDF data exists under the indexed graph/predicate:
SELECT COUNT(*) FROM DB.DBA.RDF_QUAD
WHERE G = iri_to_id('urn:docs:g')
  AND P = iri_to_id('urn:doc:text');

-- 3. Verify the query lowers correctly:
SELECT CAST(sparql_to_sql_text(
  'select ?doc where {
     ?doc bif:vector_search "test"
     option (index <urn:docs:idx>, limit 10)
   }') AS VARCHAR);
-- Should contain VEC_INDEX_SEARCH_ROWS
```

---

## Minimal end-to-end example

SQL

```
SPARQL DEFINE input:storage ""
CLEAR GRAPH <urn:demo:g>
;

SPARQL DEFINE input:storage ""
INSERT DATA {
  GRAPH <urn:demo:g> {
    <urn:demo:1> <urn:demo:text> "reset a user password" .
    <urn:demo:2> <urn:demo:text> "renew an API token" .
    <urn:demo:3> <urn:demo:text> "billing dispute workflow" .
  }
}
;

SPARQL DEFINE input:storage ""
DROP SILENT VECTOR INDEX <urn:demo:idx>
;

SPARQL DEFINE input:storage ""
CREATE VECTOR INDEX <urn:demo:idx>
  ON GRAPH <urn:demo:g>
  for predicate <urn:demo:text>
  USING MODEL 'granite-384-local'
  WITH (dimensions = 384, distance = 'cosine', hnsw_m = 16, ef_construction = 200)
;

SPARQL DEFINE input:storage ""
refresh VECTOR INDEX <urn:demo:idx>
;

SPARQL DEFINE input:storage ""
SELECT ?doc ?score ?distance
WHERE {
  ?doc bif:vector_search "password reset"
  option (INDEX <urn:demo:idx>, score ?score, distance ?distance, LIMIT 3, ef_search 50) .
}
ORDER BY DESC(?score)
;

SPARQL DEFINE input:storage ""
DROP SILENT VECTOR INDEX <urn:demo:idx>
;

SPARQL DEFINE input:storage ""
CLEAR GRAPH <urn:demo:g>
;
```

The search in the middle returns:

| ?doc | ?score | ?distance |
| --- | --- | --- |
| urn:demo:1 | 0.965 | 0.070 |
| urn:demo:2 | 0.814 | 0.371 |
| urn:demo:3 | 0.749 | 0.503 |

---

## Next steps

- **[SQL vector index how-to](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/)** — Use vector indexes with relational tables and SQL queries
- **[Vector index overview](urn:osdi:sitevirtuoso/docs/vector-index/)** — Architecture and concepts

## Related content

**Reference:**

- [SPARQL 1.1](https://www.w3.org/TR/sparql11-query/) — SPARQL query language standard
- [ONNX Runtime](https://github.com/microsoft/onnxruntime) — Microsoft ONNX Runtime project
- [granite-embedding-30m-english](https://huggingface.co/ibm-granite/granite-embedding-30m-english) — Bundled embedding model

[PreviousHow-To: Build and use the relational SQL vector index (Virtuoso)](urn:osdi:sitevirtuoso/docs/vector-index/howto-sql-vector-index/) [NextVector search over RDF literals (knnSimilar)](urn:osdi:sitevirtuoso/docs/vector-index/knn-similar/)

On this page

1. [Prerequisites](#prerequisites)
2. [Seed RDF data](#seed-rdf-data)
3. [Create an RDF vector index](#create-an-rdf-vector-index)
4. [Populate and refresh the index](#populate-and-refresh-the-index)
5. [Async RDF maintenance](#async-rdf-maintenance)
6. [Query with bif:vector_search](#query-with-bifvector_search)
7. [Graph ACL and security](#graph-acl-and-security)
8. [HTTP provider models](#http-provider-models)
9. [Verify the lowering path](#verify-the-lowering-path)
10. [Drop an index](#drop-an-index)
11. [Troubleshooting](#troubleshooting)
12. [Minimal end-to-end example](#minimal-end-to-end-example)
13. [Next steps](#next-steps)
14. [Related content](#related-content)
