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.

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

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
  • 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 isWhat happens
a plain, language-tagged or typed literalits 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 nodeskipped — it is not text, so its identifier is never embedded as if it were
an empty stringskipped

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

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

OptionDescriptionDefault
dimensionsPositive integer; can be inferred from model if omitted. When reduce = 'truncate', this is the reduced dimension, not the native dimension.—
distancecosine, l1, or l2cosine
hnsw_mHNSW graph connectivity16
ef_constructionBuild-time search depth200
maintenancemanual or asyncmanual
reducenone or truncate — enables Matryoshka dimension reduction for MRL-trained modelsnone
buildincremental (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 buildincremental
rebuildHow a graph is rebuilt from stored embeddings: auto, fast or replay — see Index parametersauto
source_kindtext (model path) or vector — recorded on the catalog row (SVI_SOURCE_KIND) but not acted on; see the note belowtext
labelFree text, up to 255 characters. A short name for the index, published as rdfs:label in <urn:vvec:indexes> — see Naming an indexnone
commentFree text, up to 2000 characters. What the index is for, published as rdfs:commentnone

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.

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.


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

Speed up a large build

Two settings dominate how long a refresh takes. Both are covered in Maintenance and Index parameters; 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.

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 REFRESHafter 'fast' rebuild
page formatlegacypacked
physical node pages57,60014,067 (4.1× smaller)
graph build time~50 min (part of an 85-min refresh)6 s
nDCG@10 / Recall@100.3443 / 0.40510.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 ()');

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 shapeTrigger rowEnqueue behaviour
graph <G> { ?s <P> ?o }(G, P) — predicate-preciseOnly changes to <P> in <G> enqueue
graph <G> { ?s ?p ?o } (variable predicate)(G, '') — graph-only fallbackAny predicate change in <G> enqueues
?s <P> ?o with FROM <G> on the selector(G, P) for each FROM graphOnly changes to <P> in those graphs enqueue
?s <P> ?o with neither, or graph ?g { … }none — not async-scopablemaintenance = 'async' is rejected with VEC77

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

OptionDescriptionDefault
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—
index *Search every active index the caller can read—
ranking raw | relevanceHow a multi-index result is orderedraw for a list, relevance for *
score ?varBind a higher-is-better similarity score, from 0 to 1 (the X_SIMILAR scale)—
distance ?varBind the native distance value—
score_limit exprInclusive lower bound on score—
distance_limit exprInclusive upper bound on distance—
limit exprMax results (1–10000)10
ef_search exprHNSW search beam width (>= limit, <= 100000)100

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

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

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

SymptomCause & fix
VEC58 or VEC59 from omitted indexAdd option (index <...>) — omitting index requires exactly one active index.
No rows for a non-DBA userCheck graph ACLs — grant read permission for the graph.
No rows even as DBARefresh 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 existThe 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 appearNothing 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). 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 declarationThe shorthand selector is [ON GRAPH <g>] FOR PREDICATE <p>; no other word may follow FOR. To embed anything else, use a 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:10.9650.070
urn:demo:20.8140.371
urn:demo:30.7490.503

Next steps

Reference:

PreviousHow-To: Build and use the relational SQL vector index (Virtuoso) NextVector search over RDF literals (knnSimilar)