Overview

GraphRD documentation

An RDF database in one binary: SPARQL 1.1 and 1.2, rule-based inference, full-text, vector and geospatial search, SHACL, GraphQL, SQL — with a web UI inside.

Getting startedGraphRD is a single binary: the server, the web UI and the offline tools. This page takes you fromCommand lineHTTP APIGraphRD answers four families of requests on one port (default 7200):SPARQLGraphRD implements SPARQL 1.1 Query and Update completely, SPARQL 1.2 (the W3C draft), and a fewGraphQLEvery repository answers GraphQL queries with a schema derived from its data: no schema to writeSQL over the PostgreSQL protocolWith --pg-port, GraphRD also speaks the PostgreSQL wire protocol (version 3), so psql, psycopg,AI queries — questions in plain languageGraphRD can answer a question written in plain language ("Which people over 30 work for ACME?")FormatsExamplesRunnable programs that talk to a running GraphRD server — in the repository'sInferenceEach repository has a ruleset. When statements are added, GraphRD derives what follows from themFull-text searchGraphRD can index the words of every literal and find literals by a text query, inside SPARQL.Search connectorsA connector keeps a repository's entities of chosen classes in an OpenSearch (or Elasticsearch)Vector searchGraphRD indexes embedding vectors stored as literals and finds the nearest ones from SPARQL, soGeoSPARQLGraphRD implements the GeoSPARQL 1.1 functions over geometry literals with a geometry engine ofSHACL validationA repository with isShacl on validates every write against SHACL shapes and refuses writes thatAdministrationClusterSeveral GraphRD servers form a cluster with the Raft consensus protocol. Every node holds all theStorageThis page describes how a repository keeps its data: the files, the indexes, the dictionary,PerformanceSecurity

Getting started

GraphRD is a single binary: the server, the web UI and the offline tools. This page takes you from

nothing to a first query.

Install

From source

GraphRD needs only a Rust toolchain (1.85 or newer, edition 2024); it has no third-party

dependencies.

Shell
git clone <repository-url> graphrd
cd graphrd
cargo build --release
./target/release/graphrd serve

The optional tls feature adds HTTPS through rustls:

Shell
cargo build --release --features tls

With Docker

The image runs as an unprivileged user and keeps its data in the /data volume.

Shell
docker build -t graphrd .
docker run -d --name graphrd -p 7200:7200 -v graphrd-data:/data \
  -e GRAPHRD_AUTH=admin:change-me graphrd

GRAPHRD_AUTH turns on authentication (see admin.md). Without it, anyone who can reach

the port can read and change data.

Start the server

Shell
graphrd serve

The server listens on http://127.0.0.1:7200/" target="_blank" rel="noopener">http://127.0.0.1:7200/ and stores its data in ./graphrd-data. The most

used options:

OptionMeaning
--port 7200HTTP port
--host 127.0.0.1bind address (0.0.0.0 for every interface)
--data ./graphrd-datadata directory
--memorykeep everything in memory, nothing on disk
--auth user:passwordrequire sign-in

Every option is listed in cli.md.

Create a repository

A repository holds RDF statements and has its own inference ruleset. Open the web UI at

http://127.0.0.1:7200/" target="_blank" rel="noopener">http://127.0.0.1:7200/ and choose Repositories → Create, or use the REST API:

Shell
curl -X POST localhost:7200/rest/repositories -H 'Content-Type: application/json' \
  -d '{"id":"zoo","params":{"ruleset":{"value":"rdfs"}}}'

Load data

Save this as animals.ttl:

Turtle
@prefix ex: <http://example.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

ex:Dog rdfs:subClassOf ex:Animal .
ex:rex a ex:Dog ; ex:name "Rex" .
ex:tom a ex:Animal ; ex:name "Tom" .

Then send it to the repository:

Shell
curl -X POST localhost:7200/repositories/zoo/statements \
  -H 'Content-Type: text/turtle' --data-binary @animals.ttl

Large files load much faster offline, while the server is stopped (see cli.md):

Shell
graphrd load --data ./graphrd-data --repo zoo animals.ttl

Run a query

Shell
curl -G localhost:7200/repositories/zoo -H 'Accept: text/csv' \
  --data-urlencode 'query=PREFIX ex: <http://example.org/> SELECT ?a ?name WHERE { ?a a ex:Animal ; ex:name ?name }'

Both Rex and Tom are animals: the rdfs ruleset inferred ex:rex a ex:Animal from the class

hierarchy. Add infer=false to see only what was stated:

Shell
curl -G localhost:7200/repositories/zoo -H 'Accept: text/csv' --data-urlencode infer=false \
  --data-urlencode 'query=PREFIX ex: <http://example.org/> SELECT ?a WHERE { ?a a ex:Animal }'

Prefixes declared in loaded Turtle are remembered by the repository, so later queries can use

ex: without a PREFIX line.

Update data

Shell
curl -X POST localhost:7200/repositories/zoo/statements \
  --data-urlencode 'update=PREFIX ex: <http://example.org/> INSERT DATA { ex:kit a ex:Dog ; ex:name "Kit" }'

Next steps

Command line

Text
graphrd <command> [options]
CommandWhat it does
serverun the HTTP server (and optionally the PostgreSQL port and a cluster node)
loadbulk-load RDF files into a repository, offline
dumpwrite a repository's statements to a file
queryrun a SPARQL query on a data directory
updaterun a SPARQL update on a data directory
backupcopy a data directory consistently
optimizecompact every repository
validatevalidate RDF files against SHACL shapes
convertconvert between RDF formats, or canonicalize (RDFC-1.0)
bench, comparebenchmarks

graphrd help prints the same summary; graphrd --version prints the version.

Shared options of the offline tools

load, dump, query, update, backup and optimize work on a data directory directly, without

a server. The directory is locked while they run, so they fail with

error: DIR is in use by another GraphRD process while a server uses it.

OptionDefaultMeaning
--data DIR./graphrd-datadata directory
--repo IDdefaultrepository

Format names accepted by --format, --from and --to: nt, nq, ttl, trig, rdf (RDF/XML),

jsonld, n3 (input only), or a media type such as text/turtle. Results of query take json,

xml, csv or tsv. Without --format, the file extension decides (.gz is not decompressed).

serve

Shell
graphrd serve [options]
OptionDefaultMeaning
--port N7200HTTP port
--host ADDR127.0.0.1bind address; a warning is printed when it is not local and --auth is missing
--data DIR./graphrd-datadata directory
--memoryoffkeep repositories, users and saved queries in memory only; no data directory is used and everything ends with the process (not with --cluster)
--auth USER:PASSnonerequire sign-in; this user is the administrator. Also read from GRAPHRD_AUTH
`--sync on\off`onfsync each commit before acknowledging it; off survives a crash of the process but not a power loss
--read-onlyoffrefuse every change with 403
--corsoffanswer cross-origin requests from any page
--timeout Snonetime limit in seconds for queries that do not set their own timeout
--default-repo IDsee belowrepository answered at /sparql, /query, /update
--service-cache Soffreuse SERVICE answers for S seconds (sparql.md)
--service-timeout S300time limit of one SERVICE call
--service-retries N0retry SERVICE calls that cannot connect or get a 5xx answer
--slow-query MSofflog queries and updates slower than this (see admin.md)
--llm-url URL, --llm-key KEY, --llm-model NAMEoffanswer questions in plain language with SPARQL written by a language model: its endpoint, key and model (also GRAPHRD_LLM_URL, GRAPHRD_LLM_API_KEY, GRAPHRD_LLM_MODEL); with only ANTHROPIC_API_KEY or OPENAI_API_KEY set, that provider's API and default model (see nl-query.md)
`--llm-api anthropic\openai`from the URL or keythe wire format
--llm-effort E, --llm-timeout Smodel default, 120effort (low … max) and the time limit of one call
--pg-port Noffalso accept SQL over the PostgreSQL protocol (see sql.md)
--ui-dir DIRbuilt inserve another web UI build at /; the built-in UI moves to /ui/
--tls-cert PEM, --tls-key PEMoffserve HTTPS (builds with --features tls)
--cluster LIST, --node-id ID, --cluster-secret SoffRaft cluster (see cluster.md); the secret is also read from GRAPHRD_CLUSTER_SECRET

Without --default-repo, /sparql answers the only repository when there is one, else the

repository named default.

load

Shell
graphrd load [--data DIR] [--repo ID] [--ruleset R] [--format F] [--graph IRI] [--base IRI] [--lenient] FILE...

Loads files (or - for standard input, with --format) into a repository, creating it if needed

with the ruleset --ruleset (default rdfsplus-optimized, see inference.md).

The data directory is locked, so load adds statements in bulk rather than through the server's

write path: files are parsed on all cores, each index gets the new statements as one sorted run,

and no write-ahead log is written. The repository's manifest is replaced at the end of each batch

(256 MB of N-Triples/N-Quads, or a whole file in other formats), so a crash during a load leaves

the repository as it was before that batch. Inference, full-text indexing and prefix registration

happen as with any other write. A repository with SHACL validation on loads through the checked

write path instead, batch by batch.

OptionMeaning
--graph IRIput statements without a graph into this named graph
--base IRIbase IRI for relative IRIs (Turtle, TriG, RDF/XML, JSON-LD)
--lenientskip statements that do not parse and count them: a line in N-Triples/N-Quads, a statement in Turtle, a graph block in TriG; other formats stay strict

Blank node labels mean the same node throughout one file, and different nodes in different files.

Shell
graphrd load --data db --repo zoo animals.ttl more.nq
graphrd load --data db --repo wiki --lenient latest-truthy.nt
zcat dump.nt.gz | graphrd load --data db --format nt -

dump

Shell
graphrd dump [--data DIR] [--repo ID] [--format F] [--graph IRI|default] [--output FILE]

Writes the explicit statements (not inferred ones) as N-Quads, or in the format named by --format

or the extension of --output. --graph limits the output to one named graph, or to the default

graph with --graph default.

query

Shell
graphrd query [--data DIR] [--repo ID] [--format F] [--output FILE] [--base IRI]
              [--infer false] [--explain true] (QUERY | --query-file FILE)

Without a query argument or --query-file, the query is read from standard input. SELECT and ASK

results default to JSON; CONSTRUCT and DESCRIBE to Turtle. --explain true prints the evaluation

plan and the time taken to standard error:

Shell
graphrd query --data db --repo zoo --format csv --explain true \
  'SELECT ?s WHERE { ?s a <http://example.org/Animal> }'

update

Shell
graphrd update [--data DIR] [--repo ID] [--base IRI] (UPDATE | --update-file FILE)

backup

Shell
graphrd backup [--data DIR] [--incremental] DESTINATION

Copies the data directory consistently. The copy is a data directory of its own: start a server on

it or use it with the other tools. --incremental updates an earlier backup in DESTINATION and

copies only what changed: index run files never change, so after the first backup only new runs,

the grown dictionary, the manifest and the logs are copied; files the data directory no longer has

are removed from the backup. Each backup is recorded in DESTINATION/backup.log. For backups of a

running server, use the REST API (see admin.md).

restore

Shell
graphrd restore --from BACKUP [--data DIR] [--to-time T]

Copies a backup into an empty data directory. --to-time (an xsd:dateTime such as

2026-10-16T09:30:00Z, or Unix milliseconds) then returns every repository to its state at that

moment, from its change history: point-in-time restore. Repositories need the

historyRetention setting, and T must lie within the kept history. The undone transactions also

leave the restored repository's history, so time travel there ends at T.

Shell
graphrd backup --data /var/lib/graphrd /backups/graphrd                 # first: full
graphrd backup --data /var/lib/graphrd --incremental /backups/graphrd   # later: changes only
graphrd restore --from /backups/graphrd --data /srv/graphrd-restored --to-time 2026-10-16T09:30:00Z

optimize

Shell
graphrd optimize [--data DIR]

Merges each index of every repository into a single run file. Reads then touch one file per index

and deletion markers are gone. Useful after large deletions or many small loads.

validate

Shell
graphrd validate --shapes shapes.ttl [--format turtle|json] [--base IRI] [--output FILE] data.ttl...

Validates RDF files against SHACL shapes in memory (no data directory) and writes the validation

report. Relative IRIs resolve against each file's own file:// URL unless --base is given; the

shapes file may also be one of the data files. See shacl.md.

convert

Shell
graphrd convert [--from F] [--to F] [--base IRI] [--graph IRI] [--from-graph IRI|default] [INPUT] [--output FILE]

Converts between RDF formats without a data directory.

OptionMeaning
--graph IRIput statements without a graph into this graph
`--from-graph IRI\default`keep only one graph of the input
--to rdfccanonical N-Quads (RDF Dataset Canonicalization, RDFC-1.0)
--to rdfc-mapthe canonical blank node labels as JSON
--hash SHA384hash function for RDFC-1.0 (default SHA256)
--jsonld key=valueJSON-LD options: processingMode=json-ld-1.0, rdfDirection=…, produceGeneralizedRdf=true, expandContext=FILE
Shell
graphrd convert --from trig --to nt --from-graph http://example.org/g data.trig
graphrd convert --to rdfc data.nq --output canonical.nq

Benchmarks

Shell
graphrd bench [--urls http://h:p[,http://h2:p2]] [--persons 100000] [--clients 8] [--seconds 5] [--repo bench]
graphrd compare --name NAME --query URL [--update URL] --load URL [--load-type T] [--graph IRI]
                [--persons 100000] [--clients 8] [--seconds 10] [--out FILE.json]
graphrd compare-report FILE.json...

bench loads a synthetic graph into GraphRD servers and measures load, query latency, concurrent

throughput and write latency. compare benchmarks any SPARQL 1.1 store through the standard

protocols only; compare-report prints Markdown tables of several runs. See

performance.md.

HTTP API

GraphRD answers four families of requests on one port (default 7200):

PathProtocol
/repositories/...the repository REST protocol: queries, updates, statements, namespaces, Graph Store
/sparql, /query, /updatethe SPARQL 1.1 Protocol at its standard names
/rest/...GraphRD's management API: repositories, import, users, monitoring, backup
/metricsPrometheus metrics

Everything else serves the web UI. With --auth, every request except /protocol, /rest/login,

/rest/logout, /rest/info/version and the UI files needs a user (see security.md).

Examples on this page assume a server on localhost:7200 without authentication.

Queries and updates

Repository endpoint: /repositories/{id}

MethodBodyDoes
GET ?query=…nonequery
POSTapplication/x-www-form-urlencoded with query= or update=query or update
POSTapplication/sparql-queryquery (the body is the query)
POSTapplication/sparql-updateupdate
GET (no query)nonethe SPARQL Service Description of the repository

Parameters (query string or form):

ParameterMeaning
query, updatethe SPARQL text (exactly one per request)
inferfalse to leave inferred statements out (default true); at-query to infer at query time (inference.md)
timeouttime limit in seconds (default: serve --timeout, else none)
default-graph-uri, named-graph-urithe query's dataset (repeatable); overrides FROM / FROM NAMED
using-graph-uri, using-named-graph-urian update's dataset; an error together with USING or WITH
baseURIbase IRI for relative IRIs (default: the endpoint's URL)
limit, offsetpage a SELECT result (GraphRD extension)
as-ofrun the query on the repository as it was then: dateTime, date or Unix ms; needs historyRetention (sparql.md)

The result format follows Accept:

ResultMedia types (first is the default)
SELECT, ASKapplication/sparql-results+json, application/sparql-results+xml, text/csv, text/tab-separated-values, application/x-sparqlstar-results+json
CONSTRUCT, DESCRIBEtext/turtle, application/n-triples, application/n-quads, application/trig, application/rdf+xml, application/ld+json
Shell
curl -G localhost:7200/repositories/zoo -H 'Accept: application/sparql-results+json' \
  --data-urlencode 'query=SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5'

curl -X POST localhost:7200/repositories/zoo -H 'Content-Type: application/sparql-update' \
  --data 'INSERT DATA { <http://example.org/a> <http://example.org/b> 1 }'

Status codes: 200 with results, 204 after an update, 400 for a syntax error or a protocol error

(both query and update, two queries), 405 for an update sent with GET, 415 for a body media

type the protocol does not define, 500 for an evaluation error. An update request is applied

completely or not at all.

SPARQL 1.1 Protocol: /sparql

/sparql, /query and /update behave exactly like /repositories/{id} for the repository chosen

with serve --default-repo (else the only repository, else the one named default). Any repository

also answers at /repositories/{id}/sparql. Generic SPARQL clients work with these names. The W3C Protocol and Service

Description test suites pass (see sparql.md).

Shell
curl -X POST localhost:7200/sparql -H 'Content-Type: application/sparql-query' \
  -H 'Accept: text/csv' --data 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'

Statements: /repositories/{id}/statements

MethodDoes
GETstatements matching subj, pred, obj, context (each optional, N-Triples syntax: <iri>, "literal", _:b); infer=false for explicit ones only
POSTadd the RDF in the body (format from Content-Type); with update= in a form body, run an update
PUTreplace the default graph, or the graphs named by context, with the body
DELETEremove statements matching subj, pred, obj, context

context is repeatable; null names the default

graph. baseURI sets the base for relative IRIs in the body. lenient=true (a GraphRD extension)

skips statements that do not parse and answers 200 with

{"added": n, "skipped": n, "errors": [...]} instead of rejecting the whole body.

Shell
# load Turtle into a named graph
curl -X POST 'localhost:7200/repositories/zoo/statements?context=%3Chttp://example.org/g%3E' \
  -H 'Content-Type: text/turtle' --data-binary @animals.ttl

# everything about one subject, as N-Triples
curl -G localhost:7200/repositories/zoo/statements -H 'Accept: application/n-triples' \
  --data-urlencode 'subj=<http://example.org/rex>'

# delete a graph's statements
curl -X DELETE 'localhost:7200/repositories/zoo/statements?context=%3Chttp://example.org/g%3E'

Other repository endpoints

EndpointDoes
GET /repositoriesthe repository list, as SPARQL results
PUT /repositories/{id}create a repository from a repository configuration in Turtle
DELETE /repositories/{id}delete a repository and its data
GET /repositories/{id}/sizenumber of statements (context= for given graphs)
GET /repositories/{id}/contextsnamed graphs, as SPARQL results
GET /repositories/{id}/namespacesprefixes, as SPARQL results; DELETE clears them
`GET\PUT\DELETE /repositories/{id}/namespaces/{prefix}`one prefix (the body of PUT is the namespace IRI)
/repositories/{id}/rdf-graphs/service?graph=IRI (or ?default)SPARQL 1.1 Graph Store protocol: GET, PUT, POST, DELETE
/repositories/{id}/rdf-graphs/{name}Graph Store for the graph …/rdf-graphs/{name}
GET /protocolthe repository protocol version (12)

Prefixes declared in Turtle and TriG uploads are added to the repository's namespaces (existing

prefixes are not changed), so queries can use them without PREFIX.

Management API: /rest

All bodies are JSON unless noted.

Repositories

EndpointDoes
GET /rest/repositorieslist: id, title, type, size, permissions
POST /rest/repositoriescreate: {"id": "zoo", "title": "…", "params": {"ruleset": {"value": "rdfs"}}}
GET /rest/repositories/{id}configuration
PUT /rest/repositories/{id}change the configuration (ruleset, FTS, SHACL, union default graph, history); the params given replace those of the same name, the others are kept
DELETE /rest/repositories/{id}delete
GET /rest/repositories/{id}/size{"explicit", "inferred", "total"}
GET /rest/repositories/{id}/historythe time-travel window: {"enabled", "start", "retentionMs"}
GET /rest/repositories/default-config/{type}the parameters a new repository takes, with defaults
GET /rest/repositories/ruleset/..., GET /rest/rulesetsthe built-in rulesets
GET /rest/repositories/{id}/search?query=Qfull-text search with highlights and facet counts (fts.md)
GET /rest/repositories/{id}/shacl/validateSHACL report for the whole repository (JSON; Turtle with ?format=turtle or Accept: text/turtle)
`GET\PUT\DELETE /rest/llm, POST /rest/llm/test, GET /rest/llm/status`the language model's settings, for administrators (see nl-query.md)
POST /rest/repositories/{id}/nl-querya question in plain language: the SPARQL a language model writes for it, and with "run": true its results (see nl-query.md)
`GET\POST /rest/repositories/{id}/graphql`GraphQL (see graphql.md)
`GET\PUT\DELETE /rest/repositories/{id}/connectors[/{name}]`search connectors (see connectors.md)

Repository parameters that take effect:

ParameterDefaultMeaning
rulesetrdfsplus-optimizedinference ruleset or path of a .pie file (inference.md)
disableSameAstruefalse turns on owl:sameAs reasoning in rulesets that have it
enableFtsIndexfalsefull-text index (fts.md)
isShaclfalsevalidate writes with SHACL (shacl.md)
unionDefaultGraphtruethe default graph of queries is the union of all graphs; false keeps it separate
historyRetentionemptykeep the change history for time travel this long (3600, 30m, 12h, 7d, unlimited; sparql.md)
titleemptydescription

Other parameters (queryTimeout, entityIndexSize, baseURL, …) are accepted for compatibility with

compatibility configurations and stored, but have no effect.

Import

Imports run inside the request and are recorded with their status and message.

EndpointBody
POST /rest/repositories/{id}/import/upload/text{"name", "data": "<RDF text>", "format": "text/turtle", "context", "baseURI", "replaceGraphs": [...], "lenient": true}
POST /rest/repositories/{id}/import/upload/filemultipart: file and importSettings (JSON as above)
POST /rest/repositories/{id}/import/upload/urlsettings with "data": "<url>"; the format comes from format, the response's content type, or the extension
GET /rest/repositories/{id}/import/serverfiles in DATA/import/ on the server
POST /rest/repositories/{id}/import/server{"fileNames": [...], "importSettings": {...}}
GET /rest/repositories/{id}/import/uploadrecorded imports
DELETE /rest/repositories/{id}/import/uploadforget recorded imports (?name= for one)

context is the target named graph (default or empty for the default graph); replaceGraphs

lists graphs to clear first.

EndpointDoes
`GET\POST\PUT\DELETE /rest/sparql/saved-queries`saved queries: {"name", "body"}
POST /rest/sparql/add-known-prefixesthe query in the body with PREFIX lines added for prefixes it uses but does not declare
GET /rest/autocomplete/query?q=…IRIs whose local name starts with q; the repository in the X-GraphRD-Repository header or ?repository=
POST /rest/rank/computecompute RDF Rank (repository in X-GraphRD-Repository or ?repository=)
GET /rest/rank/status?repository=IDEMPTY, COMPUTED or OUTDATED

Security, monitoring, backup

EndpointDoes
POST /rest/login{"username", "password"} → the user and a token, also in the Authorization: Bearer … response header
POST /rest/logoutend the token in Authorization
GET /rest/security/authenticated-userwho is signed in
`GET\POST\PATCH\DELETE /rest/security/users/{name}`users (admin.md)
GET /rest/monitor/slow-queriesthe slow query log
GET /rest/cluster/statusRaft state of this node (cluster.md)
GET /rest/recovery/backuptar archive of every repository (admin.md)
POST /rest/recovery/restorerestore such an archive (multipart file or the raw tar)
GET /rest/info/versionproduct name and version
GET /metricsPrometheus metrics (admin.md)

Errors

Errors are plain text (repository endpoints) or {"message": "…"} (management API), with 400 for bad

input, 401 without a valid user, 403 when the user lacks the permission or the server is

read-only, 404 for an unknown repository, 409 when creating a repository that exists, and 500

for failures of the server.

SPARQL

GraphRD implements SPARQL 1.1 Query and Update completely, SPARQL 1.2 (the W3C draft), and a few

extensions. Requests reach it through the endpoints in http-api.md or the

graphrd query / graphrd update tools (cli.md).

Query language

  • Forms: SELECT (with DISTINCT, REDUCED, expressions (expr AS ?v)), ASK, CONSTRUCT
  • (including CONSTRUCT WHERE), DESCRIBE.

  • Patterns: basic graph patterns, OPTIONAL, UNION, MINUS, FILTER, BIND, VALUES (inline and
  • trailing), GRAPH, sub-queries, EXISTS / NOT EXISTS, SERVICE.

  • Property paths: /, |, ^, *, +, ?, !(…), grouping.
  • Aggregates: COUNT (with DISTINCT and *), SUM, AVG, MIN, MAX, SAMPLE,
  • GROUP_CONCAT (with SEPARATOR); GROUP BY on variables or expressions, HAVING.

  • Solution modifiers: ORDER BY (ASC/DESC, expressions), LIMIT, OFFSET.
  • Datasets: FROM, FROM NAMED, or the protocol's default-graph-uri / named-graph-uri.
  • Functions: the whole SPARQL 1.1 library — terms (STR, LANG, DATATYPE, IRI, BNODE,
  • STRDT, STRLANG, isIRI, isBlank, isLiteral, isNumeric, sameTerm, LANGMATCHES,

    BOUND, IF, COALESCE, IN), strings (STRLEN, SUBSTR, UCASE, LCASE, STRSTARTS,

    STRENDS, CONTAINS, STRBEFORE, STRAFTER, ENCODE_FOR_URI, CONCAT, REGEX, REPLACE),

    numbers (ABS, ROUND, CEIL, FLOOR, RAND), dates (NOW, YEAR … SECONDS, TIMEZONE,

    TZ), hashes (MD5, SHA1, SHA256, SHA384, SHA512), UUID, STRUUID, and XSD casts

    (xsd:integer(…) and the other numeric, boolean, string and date types).

  • Regular expressions are GraphRD's own engine (a Pike VM): matching time is linear in the input,
  • so no pattern can stall the server. Flags i, s, m, x and q are supported.

SPARQL 1.2 and RDF 1.2

Triple terms <<( s p o )>>, reified triples << s p o >> and << s p o ~ :r >>, annotations

s p o {| :source :x |}, literals with a base direction ("…"@ar--rtl), VERSION, and the

functions TRIPLE, SUBJECT, PREDICATE, OBJECT, isTRIPLE, LANGDIR, hasLANG,

hasLANGDIR, STRLANGDIR. As in RDF 1.2, << s p o >> denotes a reifier of the triple (with

rdf:reifies), not the triple itself as in the older RDF-star drafts.

SPARQL
PREFIX ex: <http://example.org/>
SELECT ?who ?src WHERE {
  << ?who ex:age ?age >> ex:source ?src .
}

Update language

INSERT DATA, DELETE DATA, DELETE WHERE, DELETE … INSERT … WHERE with WITH and USING /

USING NAMED, LOAD <url> [INTO GRAPH <g>] (HTTP; HTTPS in builds with tls), CLEAR, DROP,

CREATE, ADD, MOVE, COPY (each with SILENT, DEFAULT, NAMED, ALL). Several operations

separated by ; form one request, applied completely or not at all: if one fails, the earlier ones

are undone.

Inference in queries

Repositories with a ruleset store inferred statements next to explicit ones

(inference.md). Queries see both unless infer=false is passed; infer=at-query

infers at query time instead (inference.md). Two pseudo-graphs

select one kind in a query:

SPARQL
PREFIX graph: <http://graphrd.org/graph#>
SELECT ?s ?p ?o WHERE { GRAPH graph:implicit { ?s ?p ?o } }    # inferred only
SELECT ?s ?p ?o WHERE { GRAPH graph:explicit { ?s ?p ?o } }    # stated only

The default graph

By default (unionDefaultGraph = true) the default graph of a query is the union of every graph,

With unionDefaultGraph = false it is only the statements without a graph, as the

SPARQL specification describes and the W3C test suites expect. FROM and default-graph-uri

override either.

Federated queries

SERVICE <endpoint> { … } sends the block to any SPARQL 1.1 endpoint and joins its results;

SERVICE ?var takes the endpoint from a binding made earlier in the query, and SERVICE SILENT

turns a failing endpoint into an empty match. Each distinct (endpoint, query) pair is called once

per query.

  • Parallel calls: when a query starts, every SERVICE block with a fixed endpoint is sent at
  • once, each on its own thread, so a query over three endpoints waits for the slowest one, not for

    the three in turn. Blocks with a variable endpoint are called when evaluation reaches them.

  • Answer cache: serve --service-cache 60 reuses an endpoint's answer to the same block for
  • 60 seconds across queries (off by default).

  • Failures: serve --service-timeout 10 bounds each call (within the query's own timeout);
  • serve --service-retries 2 tries again, with a short growing pause, when a call cannot connect

    or gets a 5xx answer (a 4xx is final). After that the query fails, or with SERVICE SILENT

    the block matches once with no bindings.

  • The W3C federated query tests pass (7 of 7) with local endpoints standing in for the remote ones.
SPARQL
SELECT ?item ?label WHERE {
  ?item a <http://example.org/Thing> .
  SERVICE <https://query.wikidata.org/sparql> { ?item <http://www.w3.org/2000/01/rdf-schema#label> ?label }
}

Extensions

ExtensionMeaning
?lit g:fts "query"full-text match (fts.md); g: = http://graphrd.org/fn#
g:ftsScore(?lit, "query")relevance of a full-text match
?v g:knn ?q, g:knnN, g:similarity(?a, ?b)nearest vectors and cosine similarity (vectors.md)
?node rank:score ?s (score3, score5)RDF Rank (inference.md); rank: = http://graphrd.org/rank#
geof:…GeoSPARQL functions (geosparql.md)
limit, offset request parameterspage a result without changing the query

The well-known RDF, RDFS, OWL, XSD, function and GeoSPARQL prefixes, and GraphRD's own

(g, graph, rank), are predefined in new repositories; prefixes from uploaded Turtle and TriG are added.

Time limits

timeout=<seconds> on a request (or serve --timeout for all requests without one) stops a

query that runs longer with an error. There is no limit otherwise.

Time travel

A repository with the historyRetention setting keeps the history of its changes, and a query

can then run on the repository as it was at an earlier moment: pass as-of with an

xsd:dateTime (UTC when it has no timezone), an xsd:date (its midnight) or Unix milliseconds.

Shell
# keep the history for 7 days (also: 3600 seconds, 30m, 12h, unlimited; empty = off)
curl -X PUT localhost:7200/rest/repositories/zoo -H 'Content-Type: application/json' \
  -d '{"params": {"historyRetention": {"value": "7d"}}}'
curl -X POST localhost:7200/repositories/zoo/statements -H 'Content-Type: application/sparql-update' \
  --data 'INSERT DATA { <http://example.org/rex> <http://example.org/age> 3 }'
# the window that can be reached: {"enabled": true, "start": <Unix ms>, "retentionMs": …}
curl localhost:7200/rest/repositories/zoo/history
# as it was at a moment: here the end of the current second, in Unix milliseconds; or e.g.
# as-of=2026-10-16T09:30:00Z (a time before the history starts is an error naming its start)
curl localhost:7200/repositories/zoo --data-urlencode 'query=SELECT (COUNT(*) AS ?n) { ?s ?p ?o }' \
  --data-urlencode "as-of=$(date +%s)999"

graphrd query --as-of <time> does the same offline. The result is exact for every moment from

the start of the kept history on: the history is complete from when historyRetention was set

(and, after pruning, from the oldest moment the retention still covers). Each transaction is kept

with its commit time, inferences included as they followed from the statements then (with the

repository's current ruleset), and bulk loads count as one transaction.

The query runs on the current snapshot with the transactions after the requested moment undone in

memory, so its cost grows with the number of changes made since then, not with the size of the

repository. The history is history.log in the repository's storage directory; it costs a second

copy of every change written, so it is off by default. Turning it off deletes the file. A restored

backup starts a new history.

Explaining a query

graphrd query --explain true prints the evaluation plan: the order chosen for each basic graph

pattern, joins, filters, and the time taken.

Shell
graphrd query --data db --repo zoo --explain true --format csv \
  'SELECT ?s WHERE { ?s a <http://example.org/Animal> ; <http://example.org/name> ?n }'

How queries run

  • Every query runs on a snapshot of the repository taken when it starts; it never waits for
  • writers and never sees half of an update.

  • Basic graph patterns are ordered by the number of bound positions, then smallest first by the
  • indexes' estimate of how many statements match (read from the run files' block index, no data

    read); large joins switch from index lookups to hash joins, whose probe of 100,000 or more rows

    is split over the cores. --explain shows the order, the estimates and the join chosen.

  • A SELECT with LIMIT and without ORDER BY, grouping or DISTINCT stops as soon as it has
  • enough rows.

  • A FILTER that reads one variable is evaluated once per distinct value of that variable.
  • ORDER BY … LIMIT k ranks distinct values once and keeps only rows that can be in the first k.
  • COUNT over one triple pattern (optionally with such a filter or grouped by its variables) counts
  • directly from the index.

Conformance

The W3C suites pass completely, including federated queries (7): SPARQL 1.0 (487), SPARQL 1.1 Query (353) and Update (157), result

formats (10), SPARQL 1.2 (271), and the SPARQL 1.1 Protocol and Service Description suites (37).

See the README's conformance section to run them.

GraphQL

Every repository answers GraphQL queries with a schema derived from its data: no schema to write

and nothing to keep in sync.

The derived schema

  • Every class (each object of rdf:type) is a root query field and a type, named by the class
  • IRI's local name (http://example.org/Person → Person).

  • Every predicate used by the instances of a class is a field of that type, named by its local
  • name (http://example.org/worksFor → worksFor).

  • id is the resource's IRI, types its classes.
  • A field without a selection set returns the value (or a list when there are several); with a
  • selection set it follows the links to other resources.

GET /rest/repositories/{id}/graphql?sdl returns the derived schema in SDL.

Queries

Shell
curl -X POST localhost:7200/rest/repositories/zoo/graphql -H 'Content-Type: application/json' \
  -d '{"query": "{ Person(limit: 5, orderBy: \"age\", desc: true) { id name age worksFor { name } } }"}'

Root field arguments:

ArgumentMeaning
idone resource, by IRI
whereequality on fields: where: {name: "Ann", city: "Ankara"} (all must hold)
orderBy, descsort by a field's first value, descending with desc: true
limit, offsetpage the result (default limit 100)

Nested fields take limit too. Variables (query ($n: String) { Person(where: {name: $n}) { id } }

with "variables": {"n": "Ann"}), aliases and __typename are supported. The body may also be

application/graphql with the query as the whole body.

The answer is the standard {"data": …} or {"errors": [{"message": …}]}. Selections nest at most

8 levels deep, and a result is cut off with an error when it grows too large: narrow it with

where and limit.

Mutations

The same schema has a Mutation type: for every type T, createT, updateT and deleteT.

Shell
curl -X POST localhost:7200/rest/repositories/zoo/graphql -H 'Content-Type: application/graphql' \
  -d 'mutation { createDog(id: "http://example.org/fido", input: {name: "Fido"}) { id name types } }'
FieldArgumentsEffect
createTid (optional), input, graph, typeadds id rdf:type <class> and the input's values; without id an IRI <namespace>t/<random hex> is made. An existing T with that id is an error
updateTid, input, graphreplaces the values of each field in input; null removes them; other fields stay
deleteTid, graphremoves the resource's statements and the statements linking to it

Each field returns the resource with its selection (deleteT as it was before), or the id when

the field has no selection set.

Input values:

  • A plain value follows what the predicate already holds: when its values are links, a string is
  • an IRI (knows: ["http://example.org/ann"]); when they are typed literals, the value gets that

    datatype (birthDate: "2001-05-04" becomes an xsd:date, and a value that is not a valid number

    for a numeric datatype is an error). Otherwise strings are xsd:string, integers

    xsd:integer, floats xsd:decimal and booleans xsd:boolean.

  • A list gives several values; {id: "…"} is a link and {value: "…", lang: "tr"} or
  • {value: "…", datatype: "…"} a literal spelled out.

  • A field the type does not have yet becomes a predicate in the class's namespace
  • (http://example.org/Person → http://example.org/email). A type the data does not have yet can

    be created with its class IRI: createRobot(type: "http://example.org/Robot", input: {…}).

New values go to the graph given, else to the graph of the resource's rdf:type statement;

graph also limits what update and delete remove. A mutation request is one transaction: the

fields run in order, and when one fails, or a SHACL repository refuses the result (the report is

in errors[0].extensions), nothing of the request stays. Mutations need write access to the

repository and a POST; queries need read access only. The web UI's GraphQL page runs both.

Limits

Names are local names, so two predicates with the same local name in different namespaces share a

field (a mutation picks the one in the type's namespace, else reports the field as ambiguous).

SQL over the PostgreSQL protocol

With --pg-port, GraphRD also speaks the PostgreSQL wire protocol (version 3), so psql, psycopg,

JDBC drivers and BI tools can query repositories in SQL.

Shell
graphrd serve --pg-port 5432
psql -h 127.0.0.1 -p 5432 -d zoo

The database name is the repository id. With --auth, sign in with the same users and passwords

as over HTTP (cleartext password authentication; use it on a trusted network or a tunnel), and the

same permissions apply: reading needs read access to the repository, INSERT, UPDATE,

DELETE and views need write access.

Tables

The tables are derived from the data, as for GraphQL:

  • every class is a table, named by its local name (Person, matched case-insensitively);
  • its columns are id (the IRI) and the local names of the predicates its instances use;
  • a column with several values for one row shows them joined with , .

SHOW TABLES, information_schema.tables, information_schema.columns and

information_schema.views list them and the views, so tools can browse the schema.

Statements

SQL
SELECT name, age FROM Person WHERE age > 30 ORDER BY age DESC LIMIT 10;
SELECT COUNT(*) FROM Person WHERE name LIKE 'A%';
SELECT * FROM Person WHERE city IS NOT NULL OFFSET 20 LIMIT 10;
SELECT id FROM Person WHERE name = 'Ann';
SELECT * FROM sparql('SELECT ?s ?n WHERE { ?s <http://example.org/name> ?n } LIMIT 5');
SELECT version();
PartSupported
select list, columns (p.name), col AS name, COUNT()
WHEREconditions joined with AND: =, <> / !=, <, <=, >, >=, LIKE (%, _), IS NULL, IS NOT NULL
FROMa table or a view, with an alias (Person p, Person AS p)
JOIN[INNER] JOIN / LEFT [OUTER] JOIN t ON a.col = b.col (several in a row)
ORDER BYone column (or a select-list AS name), ASC / DESC
LIMIT, OFFSETyes
sparql('…')any SPARQL SELECT; its variables are the columns

Both the simple and the extended (prepared statement) protocol work, with text and binary

parameters ($1, $2, …). SET, BEGIN, COMMIT and similar session statements are accepted and

do nothing.

Joins

A join matches equal values; a column with several values (a person who works for two companies)

matches each of them, so it gives one row per match. Columns are named alias.column; an

unqualified name works when only one of the joined tables has it.

SQL
SELECT p.name AS person, c.name AS company
FROM Person p JOIN Company c ON p.worksFor = c.id
WHERE c.name LIKE 'A%' ORDER BY person;

SELECT d.name, p.name FROM Dog d LEFT JOIN Person p ON d.owner = p.id;

Views

SQL
CREATE VIEW staff AS
  SELECT p.name AS person, p.age, c.name AS employer FROM Person p JOIN Company c ON p.worksFor = c.id;
SELECT person FROM staff WHERE employer = 'Acme' ORDER BY age DESC;
CREATE OR REPLACE VIEW elders AS SELECT name FROM Person WHERE age > 60;
DROP VIEW [IF EXISTS] elders;

A view is any SELECT above: over a table, a join, another view or sparql('…'). It is checked

when created (a view whose query fails is refused) and runs each time it is read. Views are kept in

the repository itself, as <urn:graphrd:sql-view:NAME> <urn:graphrd:sql> "SELECT …" in the graph

urn:graphrd:sql-views, so backups, time travel and a cluster carry them. Views cannot be written

to, and a view cannot take the name of a table.

Changing data

Rows are resources, as in GraphQL mutations:

SQL
INSERT INTO Person (id, name, age, worksFor)
  VALUES ('http://example.org/dan', 'Dan', 22, 'http://example.org/acme'), ('http://example.org/eve', 'Eve', 29, NULL);
INSERT INTO Person (name) VALUES ('Fay');           -- id made up: http://example.org/person/<hex>
UPDATE Person SET age = 23, email = 'dan@example.org' WHERE name = 'Dan';
UPDATE Person SET worksFor = NULL WHERE age > 60;  -- NULL removes the values
DELETE FROM Person WHERE age >= 65;
  • INSERT names its columns; id is the IRI. A row whose id already is a resource of that table
  • is an error.

  • UPDATE replaces the values of the columns it sets (the row's other columns stay); the WHERE
  • is the same as in SELECT. The id cannot be set.

  • DELETE removes a row's statements and the statements linking to it.
  • Values follow what the predicate already holds: a string for a link column is an IRI, a value
  • for a typed column gets its datatype (an invalid number is an error). Otherwise strings are

    xsd:string, integers xsd:integer, decimals xsd:decimal, TRUE/FALSE booleans. A column

    the table does not have yet becomes a predicate in the class's namespace.

Each statement is one transaction: when it fails, or a SHACL repository refuses the result,

nothing of it stays. The tag reports the rows (INSERT 0 2, UPDATE 1, DELETE 3). BEGIN /

COMMIT do not group statements. In a cluster, write through the leader; it replicates the

change.

Limits

No GROUP BY, OR, subqueries or expressions in the select list; join conditions are equalities.

INSERT creates rows of tables the data already has (a new class needs SPARQL or a GraphQL

create with its class IRI). Use sparql('…') for anything beyond these.

AI queries — questions in plain language

GraphRD can answer a question written in plain language ("Which people over 30 work for ACME?")

by having a language model write the SPARQL query, checking that query, and running it. It is off

until the server is given a model. GraphRD talks to either:

  • the Anthropic Messages API (--llm-api anthropic): Claude models at Anthropic or through a
  • compatible gateway;

  • OpenAI-style chat completions (--llm-api openai): OpenAI, and the many servers and gateways
  • that speak the same protocol: Ollama, vLLM, LM Studio, llama.cpp's server, LiteLLM, OpenRouter.

Nothing is sent anywhere until a question is asked.

Setting it up

Three settings: the endpoint, the API key (if the server needs one) and the model.

Each is a serve option or an environment variable:

OptionVariableDefaultMeaning
--llm-url URLGRAPHRD_LLM_URLsee belowthe model's server; for Anthropic GraphRD adds /v1/messages, for OpenAI-style servers /chat/completions (so give the URL up to /v1)
--llm-key KEYGRAPHRD_LLM_API_KEYnonethe server's API key: x-api-key for Anthropic, Authorization: Bearer for OpenAI-style servers
--llm-model NAMEGRAPHRD_LLM_MODELclaude-opus-5-5 at Anthropic, gpt-5 at OpenAI; required for other serversthe model, as the server names it
`--llm-api anthropic\openai`GRAPHRD_LLM_APIfrom the URL or keythe wire format
--llm-effort EGRAPHRD_LLM_EFFORTthe model's defaultlow, medium, high, xhigh or max: how hard the model thinks (output_config.effort for Anthropic, reasoning_effort for OpenAI-style servers)
--llm-timeout S120time limit of one call to the model

How they combine:

  • Only a provider's key: with ANTHROPIC_API_KEY (or OPENAI_API_KEY) in the environment and
  • no endpoint, GraphRD uses that provider's own API (https://api.anthropic.com,

    https://api.openai.com/v1) and its default model. When both are set, Anthropic is used;

    --llm-api or --llm-url chooses otherwise.

  • An endpoint: the wire format is Anthropic's for a URL with anthropic in it (Anthropic's
  • own, or an Anthropic-compatible one such as https://api.z.ai/api/anthropic) and OpenAI-style for

    any other (local servers and gateways speak it); --llm-api overrides that. A server other than

    the two providers' own needs --llm-model.

  • A key in GRAPHRD_LLM_API_KEY without an endpoint: a key starting sk-ant- means Anthropic,
  • any other OpenAI.

  • ANTHROPIC_API_KEY and OPENAI_API_KEY are only ever sent to their provider's own API: with
  • another endpoint, give its key with --llm-key or GRAPHRD_LLM_API_KEY.

Shell
# Claude at Anthropic: the key alone is enough (HTTPS: build with --features tls)
export ANTHROPIC_API_KEY=sk-ant-...
graphrd serve

# OpenAI with another model
export OPENAI_API_KEY=sk-...
graphrd serve --llm-model gpt-5-mini

# a model on this machine through Ollama's OpenAI-compatible endpoint (no key, plain HTTP)
graphrd serve --llm-url http://127.0.0.1:11434/v1 --llm-model qwen3-coder

# a gateway that needs its own key
GRAPHRD_LLM_API_KEY=gw-... graphrd serve --llm-url http://gateway:4000/v1 --llm-model claude-opus-5-5

In the web UI

Administrators (everyone, when the server runs without --auth) have an AI Assistant page:

the endpoint, key, model, protocol, effort and time limit, a Test button that calls the model

once, and Reset to go back to the startup settings. Saved settings are in use at once and

after a restart; they take precedence over the command-line options and environment. They are

kept in llm.json in the data directory, which only the server's user can read (with the key in

it: back it up and protect it like the rest of the data directory). The key is never sent back

to the browser. With --memory they last until the server stops. In a cluster, each server has

its own settings.

With a model in use, the SPARQL page shows Ask AI (marked with sparkles): GraphRD AI answers,

showing that it is writing while the model works, then typing the query into the editor (at once

when the system asks for less motion), which keeps a "Written by GraphRD AI" tag until it is

edited. Write query stops there, Ask and run also runs it. The model's name appears only

on the AI Assistant page.

The same through HTTP (administrators):

RequestMeaning
GET /rest/llm{"enabled", "active", "saved", "startup", "persistent"}: what is in use (api, url, model, effort, timeoutSecs, keySet, source), what is saved and the startup settings; never a key
PUT /rest/llmsave and apply {"api", "url", "key", "model", "effort", "timeoutSecs"} (empty or missing fields are unset; a missing key keeps the saved one, "" removes it); 400 when the settings are incomplete
DELETE /rest/llmremove the saved settings: back to the startup ones
POST /rest/llm/testcall the model once: {"ok", "model", "answer", "ms"} or {"ok": false, "error"}; with a JSON body, tries those settings without saving them
GET /rest/llm/status(any signed-in user) {"enabled", "model"}

The server log says which model, endpoint and source it uses (never the key). --llm-key shows

in process lists; prefer GRAPHRD_LLM_API_KEY where that matters.

HTTPS endpoints need a build with --features tls (see security.md); the default

build, which has no third-party crates, reaches http:// endpoints such as a model server on the

same machine or a gateway in the same network. In a build without TLS, a provider's key alone in

the environment leaves the feature off with a warning rather than stopping the server.

On Anthropic's own API with a model that supports it (Claude Fable 5.1, Opus 5.5, Opus 5 and

Sonnet 5.5), GraphRD asks for server-side fallback: if the model declines a question, the API

answers it with another suitable model in the same call.

Asking

POST /rest/repositories/{id}/nl-query with a JSON body (needs read access to the repository):

Shell
curl -s -X POST localhost:7200/rest/repositories/demo/nl-query \
  -H 'Content-Type: application/json' \
  -d '{"question": "Which people are older than 30?", "run": true}'
JSON
{
  "question": "Which people are older than 30?",
  "sparql": "PREFIX ex: <http://example.org/>\nSELECT ?name WHERE { ?p a ex:Person ; ex:name ?name ; ex:age ?a FILTER(?a > 30) } ORDER BY ?name LIMIT 1000",
  "model": "claude-opus-5-5",
  "attempts": 1,
  "results": { "head": { "vars": ["name"] }, "results": { "bindings": [ ... ] } }
}
  • run (default false): also run the query and include its results (SPARQL JSON results) as
  • results, or the error as error. Without it you get the query to look at, change or run

    yourself. Query parameters of the request (timeout, infer, as-of...) apply to the run.

  • attempts: 2 when the first query did not parse and the model corrected it.

Errors: 400 for a missing question, 501 when no model is configured, 502 when the model's server

fails, declines the question or does not write a usable query (the message says which).

What the model sees, and what it may write

With each question the model gets instructions and a summary of the repository, made from a

sample of the data: the classes (largest first, at most 60) with their number of instances, and

for each class the properties its instances use, with the kinds of values (linked classes,

literal datatypes and language tags) and an example literal:

Text
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>

class ex:Person (20224 instances)
  ex:name -> literal xsd:string, e.g. "Ceren Polat 0"
  ex:age -> literal xsd:integer, e.g. "45"
  ex:worksFor -> ex:Company
class ex:Post (about 100352 instances)
  ex:createdOn -> literal xsd:date, e.g. "2023-12-28"

The summary uses the repository's namespaces as prefixes, is made once and again when the

repository's size changes, and is sent as a cacheable block to Anthropic-style servers. Example

values come from the data, so whoever asks questions sees through the model what a read could

show them anyway; do not configure a model at a provider your data must not reach.

GraphRD then checks the answer before using it:

  • it must parse as a SPARQL query: updates are refused, so a question can never change data;
  • it must not use SERVICE, so a question cannot make the server call other hosts;
  • a query that does not parse goes back to the model once, with the parser's message.

The query runs like any other, with the asker's permissions, time limit and inference settings.

How well it works

tests/prod/nlq_eval.py measures it: 20 questions over the comparison data (people, companies,

cities, posts, tags), each checked against a reference query by its results.

Shell
ANTHROPIC_API_KEY=... python3 tests/prod/nlq_eval.py --llm-api anthropic
python3 tests/prod/nlq_eval.py --llm-url http://127.0.0.1:11434/v1 --llm-model qwen3-coder

Each run calls the model about 20 times, at the provider's prices. tests/prod/nlq.py checks the

protocol side (requests, retries, refusals, errors) against a stand-in server, without a model.

Formats

RDF

FormatMedia typesExtensionsReadWrite
Turtletext/turtle, application/x-turtle.ttlyesyes
TriGapplication/trig.trigyesyes
N-Triplesapplication/n-triples, text/plain.ntyesyes
N-Quadsapplication/n-quads, text/x-nquads.nqyesyes
RDF/XMLapplication/rdf+xml, application/xml.rdf, .owl, .xmlyesyes
JSON-LD 1.1application/ld+json.jsonld, .jsonyesyes
YAML-LD 1.0application/ld+yaml.yamlldyesyes
Notation3text/n3.n3yeswithout named graphs

The parsers follow the W3C grammars strictly and pass the W3C test suites for RDF 1.1 and RDF 1.2

(see the README). Notable details:

  • RDF 1.2 in every syntax: triple terms <<( s p o )>>, reified triples << s p o ~ :r >>,
  • annotations {| … |}, base direction "…"@ar--rtl, VERSION; in RDF/XML

    rdf:parseType="Triple", rdf:annotation and its:dir. The older RDF-star forms

    (application/x-turtlestar, application/x-trigstar) are read as the RDF 1.2 equivalents.

  • RDF/XML handles DTD entities, with expansion capped (no "billion laughs") and nesting limited.
  • JSON-LD is the full 1.1 toRdf algorithm, with remote contexts over HTTP(S) and the options of
  • graphrd convert --jsonld (processing mode, rdfDirection, generalized RDF, an expanded

    context). An optional safe mode turns silently dropped keys into errors.

  • JSON-LD 1.2 / RDF 1.2 triple terms (the JSON-LD-star draft): with
  • --jsonld processingMode=json-ld-1.2, or "@version": 1.2 in a context, a node object

    standing for exactly one relationship denotes that triple term — in @id or in value

    position — and @annotation asserts the triple and annotates the term:

Shell
curl -X POST localhost:7200/repositories/zoo/statements -H 'Content-Type: application/ld+json' \
  --data-binary '{"@context":{"ex":"http://example.org/"},
                  "@id":"ex:alice",
                  "ex:claims":{"@id":"ex:bob","ex:knows":{"@id":"ex:alice"}}}'
# :alice :claims <<( :bob :knows :alice )>> .
  • YAML-LD is a JSON-LD document in YAML syntax (the canonical profile: block and flow
  • mappings and sequences, plain, quoted and block scalars; anchors, aliases and tags are

    refused). Output is flow-style YAML. Example:

Shell
curl -X POST localhost:7200/repositories/zoo/statements -H 'Content-Type: application/ld+yaml' \
  --data-binary '{"@context": {"ex": "http://example.org/"}, "@id": "ex:book/1", "ex:name": "Safahat"}'

The same document in block style:

YAML
"@context":
  ex: http://example.org/
"@id": ex:book/1
ex:name: Safahat
  • Notation3: formulas { … } become blank-node named graphs (an empty formula is the literal
  • true); N3 variables have no RDF equivalent and are errors.

  • Lenient loading (graphrd load --lenient, ?lenient=true) skips what does not parse:
  • a line in N-Triples and N-Quads, a statement in Turtle, a graph block in TriG.

When writing, Turtle and TriG use the repository's prefixes; N3 output refuses data with named

graphs.

Canonical N-Quads

graphrd convert --to rdfc writes canonical N-Quads (RDF Dataset Canonicalization, RDFC-1.0, with

SHA-256 or --hash SHA384), and --to rdfc-map the canonical blank node labels as JSON. The W3C

RDFC-1.0 suite passes.

Query results

FormatMedia typeFor
SPARQL JSONapplication/sparql-results+json (default)SELECT, ASK
SPARQL XMLapplication/sparql-results+xmlSELECT, ASK
CSVtext/csvSELECT, ASK
TSVtext/tab-separated-valuesSELECT, ASK
SPARQL-star JSONapplication/x-sparqlstar-results+jsonSELECT with triple terms
any RDF format aboveby Accept (default Turtle)CONSTRUCT, DESCRIBE

application/json alone asks for SPARQL JSON results, not JSON-LD.

Content negotiation

Uploads take their format from Content-Type (or a format parameter or import setting); URLs

loaded with LOAD or import-from-URL take it from the response's content type, then from the file

extension, defaulting to Turtle. Downloads follow Accept.

Responses of 1 KB to 32 MB in a text format are gzip-compressed when the client sends

Accept-Encoding: gzip.

Examples

Runnable programs that talk to a running GraphRD server — in the repository's

examples/ and clients/ directories, one folder per language. They all play

the same small scene, so switching languages is a matter of reading another

folder, not another story.

The scene

Create the repository demo, load this JSON-LD document, ask "who works at

Acme", and read the graph back:

JSON
{
  "@context": {
    "name": "http://example.org/name",
    "age": "http://example.org/age",
    "salary": "http://example.org/salary",
    "worksFor": "http://example.org/worksFor",
    "Person": "http://example.org/Person",
    "Company": "http://example.org/Company"
  },
  "@graph": [
    { "@id": "http://example.org/alice", "@type": "Person", "name": "Alice",
      "age": 34, "salary": 82000, "worksFor": { "@id": "http://example.org/acme" } },
    { "@id": "http://example.org/bob", "@type": "Person", "name": "Bob",
      "age": 28, "salary": 61000, "worksFor": { "@id": "http://example.org/acme" } },
    { "@id": "http://example.org/acme", "@type": "Company", "name": "Acme" }
  ]
}

The endpoints the scene uses — everything else follows the same shape:

StepCall
create the repositoryPOST /rest/repositories with {"id":"demo"}
load JSON-LDPOST /repositories/demo/statements with Content-Type: application/ld+json
ask a questionPOST /repositories/demo with Content-Type: application/sparql-query
read the graphGET /repositories/demo/statements with Accept: application/ld+json

Python, whole and runnable

Standard library only; every other language in examples/ does exactly this.

python
BASE = "http://localhost:7200"          # or the URL in GRAPHRD_URL
REPO = "demo"

call("POST", "/rest/repositories",
     json.dumps({"id": REPO}).encode(), "application/json")       # 409 = already there
call("POST", f"/repositories/{REPO}/statements",
     json.dumps(DATA).encode(), "application/ld+json")            # the scene's data
_, body = call("POST", f"/repositories/{REPO}", QUERY.encode(),
               "application/sparql-query", "application/sparql-results+json")
for row in json.loads(body)["results"]["bindings"]:
    print(row["name"]["value"])                                    # Alice, Bob

The other folders

FolderOne line about it
examples/curlthe scene in five shell commands, no dependencies
examples/pythonthe scene, standard library only
examples/nodethe scene on the built-in fetch (Node 18+)
examples/gothe scene, standard library only
examples/quarkusMicroProfile REST client and JDBC over the PostgreSQL wire protocol

Every example honors GRAPHRD_URL when the server is elsewhere than

http://localhost:7200, and each was run against a live server before it

shipped. Hibernate over the SQL wire works too — that is the subject of

SQL, not of a separate example.

The showcase: a personnel application

examples/spring-boot-graphrd is the example to read when one example is all

you will read: a complete Spring Boot application whose data lives nowhere but

GraphRD, built on the client library below.

LayerWhat is in it
modelEmployee, Company, SalaryReview — Lombok entities mapped with @RdfType/@RdfId/@RdfProperty
repositorythin, typed wrappers over the mapper
servicevalidation (duplicate email, raises above 25%), raises that leave a record, paging
controllercompanies, employees, reports
configtimeouts in application.yml, beans closed by Spring on shutdown

What asking it looks like:

These are the answers of the showcase's own API (mvn spring-boot:run, port

8080), not of the GraphRD server (7200) — shown here as documentation:

Text
GET /employees?minAge=30&company=http%3A%2F%2Fexample.org%2Fcompany%2Facme
→ {"content":[{"name":"Alice", "companyName":"Acme", …}], "page":{"totalElements":1, …}}

POST /employees/raise?id=…%2Falice  {"amount":9000,"reason":"promotion"}
→ Alice at 91000 — and a SalaryReview on record

GET /reports/payroll
→ Acme: 2 people, 143000 total, 71500 average — a GROUP BY the server ran

The same employee also comes out as RDF — content negotiation picks the

format, one endpoint for all: Accept: application/ld+json, text/turtle,

application/n-triples (GET /employees/one?id=… and the whole listing at

GET /employees/rdf; em.toRdf(entity, RdfFormat) in the library).

The Java client library

clients/graphrd-client is the pure-Java library the showcase runs on: a low-level

RdfClient (SPARQL, statements, repositories; pooled connections, timeouts,

zero dependencies) and an RdfEntityManager for annotated entities — no

Spring in the jar. All Spring integration (starter + Spring Data) lives in

clients/graphrd-spring-data.

java
try (RdfClient client = RdfClient.builder()
             .baseUrl("http://localhost:7200").connectTimeout(ofSeconds(5)).build();
     RdfEntityManager em = new RdfEntityManager(client, "demo")) {
    em.save(new Person("http://example.org/alice", "Alice", acme));   // one request
    em.saveSmart(alice);                    // writes only what differs
    Person alice = em.find(Person.class, "http://example.org/alice");
    em.query(Person.class).where(Person::getAge, Op.GT, 30)          // typo-proof
      .orderBy(Person::getSalary, true).limit(10).list();
}

Derived queries (findByAgeGreaterThanAndSalaryLessThan(…, …) through a JDK

proxy), lazy Ref<T> references and lang/datatype literals are in the

library README — and in a Spring Boot application the jar configures itself:

graphrd.* in application.yml is all it takes.

Its tests boot a throwaway server from target/release/graphrd and run the

whole suite against it — mvn test in clients/graphrd-client is itself an example.

For Spring's own interface structure there is an optional module,

clients/graphrd-spring-data: GraphRdRepository extends Spring Data's

repositories, so the standard CRUD, Pageable, Sort and derived method

names (findByAgeGreaterThanAndSalaryLessThan, `Page<Person> findBy…(…,

Pageable)`) arrive as they always do — Spring Data parses the names, GraphRD

answers them — and custom queries are @Query SPARQL with :param binding,

mapped to records (the showcase's payroll reports are exactly that). The module being on the classpath is the whole setup: the

application's packages are scanned, no annotation anywhere, just like JPA.

What the examples cover

Every operation below was run against a live server somewhere in the example

suite — nothing on this page is aspirational:

OperationWhere it shows up
point read by IRI, filters, LIKE, ranges, IN, OR, EXISTSthe query languages, the library's criteria queries
joins between classesthe personnel app (worksFor), examples/curl step 5
aggregates — COUNT, SUM, AVG, MIN, MAX, GROUP BY, HAVING/reports/payroll (computed by the server)
create — JSON-LD, Turtle, N-Triples, SPARQL INSERT DATAevery language's step 2
update — computed (salary * 1.10), with a record kept/employees/raise, SalaryReview
delete — by condition, by IRI, whole graph (DROP GRAPH)the Graph Store tour
named graphs — PUT/POST/GET via the Graph Store, FROM <g>, WITH <g>the Graph Store tour
CONSTRUCT and DESCRIBE answersthe query languages

Authentication

The examples assume graphrd serve with authentication off. With --auth,

log in first (POST /rest/login with {"username","password"}) and carry the

returned bearer token on later requests; the same users and permissions then

apply to SQL connections as well.

Inference

Each repository has a ruleset. When statements are added, GraphRD derives what follows from them

by forward chaining and stores the results as inferred statements, next to the explicit ones

that were stated. Queries see both, so reading is as fast as for explicit data, and inferred

statements survive restarts like any other data.

Rulesets

Set the ruleset when creating the repository (params.ruleset), with graphrd load --ruleset, or

by changing the repository's configuration (PUT /rest/repositories/{id}); changing it recomputes

the inferred statements.

RulesetDerives
emptynothing (see query-time inference to infer without storing)
rdfRDF entailment: every predicate is an rdf:Property
rdfsRDFS entailment: rdfs:domain, rdfs:range, rdfs:subClassOf and rdfs:subPropertyOf (with transitivity), plus the RDFS axioms and the rdfs:Resource / rdf:Property / rdfs:Class typing of everything used
rdfsplusrdfs, plus owl:inverseOf, owl:SymmetricProperty, owl:TransitiveProperty, owl:equivalentClass, owl:equivalentProperty, and with owl:sameAs on: owl:FunctionalProperty, owl:InverseFunctionalProperty and owl:sameAs closure
owl-horstrdfsplus, plus owl:hasValue, owl:someValuesFrom, owl:allValuesFrom restrictions and owl:Class typing
owl2-rlthe OWL 2 RL/RDF rules: owl-horst, plus the schema rules (scm-*: domains and ranges along the hierarchies, subsumption between restrictions, every class between owl:Nothing and owl:Thing), domains and ranges across owl:inverseOf, someValuesFrom owl:Thing, and the axioms written as RDF lists: owl:intersectionOf (both directions), owl:unionOf, owl:propertyChainAxiom, owl:oneOf, and with owl:sameAs on owl:hasKey, owl:maxCardinality 1 and eq-ref (everything the same as itself; not in -optimized)
*-optimizedthe same without the axioms and the typing of every resource (x a rdfs:Resource, p a rdf:Property, …), which most applications never query and which multiplies the data
owl-maxalias of owl2-rl
owl2-qlalias of rdfsplus

OWL 2 RL's list axioms are compiled into rules for the lists the data holds. When such an axiom is

added or removed, the rules are compiled again and every inference is derived again, so the

schema may arrive before or after the data. OWL 2 RL's rules that only detect inconsistencies

(owl:disjointWith, owl:AsymmetricProperty, owl:Nothing members, …) derive nothing here.

The default for new repositories is rdfsplus-optimized. owl:sameAs reasoning is off unless the

repository sets disableSameAs to false.

Explicit and inferred statements

Shell
# only what was stated
curl -G localhost:7200/repositories/zoo --data-urlencode infer=false \
  --data-urlencode 'query=SELECT * WHERE { ?s a ?type }'

# sizes
curl localhost:7200/rest/repositories/zoo/size
# {"inferred":12,"total":19,"explicit":7}

In a query, GRAPH http://graphrd.org/graph#explicit" target="_blank" rel="noopener">http://graphrd.org/graph#explicit { … } and

GRAPH http://graphrd.org/graph#implicit" target="_blank" rel="noopener">http://graphrd.org/graph#implicit { … } select one kind (see sparql.md).

Exports (graphrd dump, GET /statements?infer=false, backups) contain explicit statements only.

Stating something that was inferred makes it explicit; it then stays when the premises go.

Query-time inference

infer=at-query (on the query endpoints, or graphrd query --infer at-query) infers at query time

instead of reading stored inferences, so a repository with the empty ruleset can still be queried

with RDFS and OWL property semantics, and a schema change takes effect without recomputing

anything. The schema of the explicit statements is read when the query starts; then each triple

pattern of the default graph with a fixed predicate is answered by backward chaining:

  • ?x rdf:type C matches what is stated of C and of its subclasses (rdfs:subClassOf,
  • owl:equivalentClass), and the subjects and objects of properties whose domain or range is

    one of them;

  • ?x p ?y matches p and its subproperties (rdfs:subPropertyOf, owl:equivalentProperty),
  • read backwards for owl:inverseOf and owl:SymmetricProperty, and transitively for an

    owl:TransitiveProperty.

Shell
curl -G localhost:7200/repositories/zoo --data-urlencode infer=at-query \
  --data-urlencode 'query=SELECT ?a WHERE { ?a a <http://example.org/Animal> }'

The answers are those of rdfsplus-optimized materialization (without owl:sameAs). Patterns with

a variable predicate and patterns inside GRAPH are answered as stated. Each pattern costs a few

index scans more than a stored one: materialize for heavy query loads, use at-query for

changing schemas or occasional reasoning.

Deleting

Removing an explicit statement retracts what was derived from it, and only that: GraphRD uses the

DRed algorithm (delete and re-derive). It first removes every inference that might depend on the

deleted statements, then derives again those that still have another support. A deletion costs

time in proportion to what depends on it, not to the size of the repository.

Custom rules (.pie files)

Set the ruleset to the path of a .pie file to use your own rules. The format has three blocks:

Text
// family rules
Prefices
{
  ex : http://ex.org/
}
Axioms
{
  <ex:ancestorOf> <ex:kind> <ex:Transitive>
}
Rules
{
Id: parent_is_ancestor
  x <ex:parentOf> y
  ------------------------
  x <ex:ancestorOf> y

Id: ancestor_transitive
  x <ex:ancestorOf> y
  y <ex:ancestorOf> z   [Constraint x != z]
  ------------------------
  x <ex:ancestorOf> z
}
  • Prefices maps prefixes to namespaces; IRIs are written <prefix:local> or as full IRIs.
  • Axioms are triples that always hold.
  • Each rule has an Id:, one or more premises (triple patterns; single letters are variables), a
  • dashed line, and one or more consequences. [Constraint a != b] after a premise requires two

    variables to differ.

Shell
curl -X POST localhost:7200/rest/repositories -H 'Content-Type: application/json' \
  -d '{"id":"family","params":{"ruleset":{"value":"/srv/rules/family.pie"}}}'

A rule file that cannot be read or parsed turns inference off for the repository, with an error on

standard error.

Conformance

The SPARQL 1.1 entailment regime tests (tests/w3c/run.py … entailment) run each test under the

weakest regime it allows that GraphRD implements: RDF (rdf), RDFS and D (rdfs), OWL 2

RDF-Based Semantics (owl2-rl, with owl:sameAs). 46 of 48 pass; the other 22 accept only OWL 2

Direct Semantics or RIF, which need a description logic reasoner. The two failing tests list both

OWL Direct and RDF-Based semantics and expect only named classes, where RDF-Based semantics also

entails the anonymous restriction class GraphRD returns.

How it runs

Rules are evaluated semi-naively: each round joins only the statements new in the previous round

with the rest of the data, until nothing new appears. A rule is skipped when one of its other

premises cannot match anything in the repository (instance data without schema triples costs

almost nothing). Bulk loads run inference once over the whole batch.

Forward chaining on a large A-box (bench/inference.py: the comparison data plus an ontology with

class hierarchies, domains and ranges, a symmetric and an inverse property; graphrd load on an

Apple M2 Pro):

Ruleset0.9M triples: loadinferred8M triples: loadinferred
empty1.4 s010.5 s0
rdfs-optimized2.3 s122,15123.2 s1,110,151
rdfsplus-optimized3.8 s322,11735.6 s2,610,107
owl2-rl-optimized4.4 s322,13241.0 s2,610,122

RDF Rank

RDF Rank is PageRank over the links between resources (statements whose object is an IRI).

Shell
curl -X POST 'localhost:7200/rest/rank/compute?repository=zoo'
curl 'localhost:7200/rest/rank/status?repository=zoo'     # EMPTY, COMPUTED or OUTDATED
SPARQL
PREFIX rank: <http://graphrd.org/rank#>
SELECT ?node ?score WHERE { ?node rank:score ?score } ORDER BY DESC(?score) LIMIT 10

rank:score3 and rank:score5 give the score rounded to 3 or 5 digits. The status becomes

OUTDATED when the data changes; compute again to refresh it.

Full-text search

GraphRD can index the words of every literal and find literals by a text query, inside SPARQL.

Turning it on

Set enableFtsIndex when creating the repository, or later (existing literals are indexed then):

Shell
curl -X POST localhost:7200/rest/repositories -H 'Content-Type: application/json' \
  -d '{"id":"docs","params":{"enableFtsIndex":{"value":"true"}}}'

The index is kept up to date on every write, including bulk loads, and is stored in its own LSM

index next to the quad indexes. Turning it off drops it.

Querying

The predicate g:fts (g: = http://graphrd.org/fn#, predefined) relates a literal to a query

string. With the literal unbound it searches the index; with it bound it checks that one literal.

SPARQL
PREFIX g: <http://graphrd.org/fn#>
PREFIX ex: <http://example.org/>
SELECT ?doc ?title ?score WHERE {
  ?title g:fts "graph database" .
  ?doc ex:title ?title .
  BIND(g:ftsScore(?title, "graph database") AS ?score)
}
ORDER BY DESC(?score)
LIMIT 10

A query on a repository without the index fails with

Full-text search is not enabled for this repository (set enableFtsIndex).

Query syntax

QueryMatches literals that contain
graph databaseevery word (in any order), in any inflection (databases, kitaplar → kitabı)
"graph database"the exact phrase (exact words, not stemmed)
data*a word starting with data (the prefix needs 3 or more characters)
databse~a word within edit distance 1 (up to 5 letters) or 2 (longer); databse~1 / ~2 set it
graph OR networkeither word
graph -relationalgraph and not relational

Words are compared case-insensitively. A word is a run of letters and digits in any script (Unicode

alphanumerics); everything else separates words. There is no stop-word list.

Stemming

Words match in any inflection: English words through the Porter stemmer (running → run,

databases → databas), Turkish words through a light stemmer that strips up to three noun suffixes

(case, possessive, plural, copula) and undoes final consonant softening (kitaplarımızdan,

kitabı → kitap). A literal tagged @en… is stemmed only as English, one tagged @tr… only as

Turkish, any other literal both ways. Quoted phrases match exact words.

Typos

word~ matches words within an edit distance (insertions, deletions, substitutions and swapped

neighbours each count 1). A query that matches nothing at all is run again with each of its words of

3 or more letters allowed one edit, then (if that finds nothing either) the full word~ allowance,

so relatonal databses still finds "Relational databases". Typo-tolerant matches score lower than

exact ones. Two edits on a word of 6 to 8 letters match a large part of the vocabulary's candidates,

so an explicit word~2 is slower than word~1.

Relevance

g:ftsScore(?literal, "query") returns the BM25 score (k1 = 1.2, b = 0.75) of a literal that

matches, and is unbound for one that does not: rare words weigh more than common ones, and a match in

a short literal more than in a long one. Each literal counts as one document; document frequencies

and the average length come from the index.

Highlighting

g:ftsHighlight(?literal, "query" [, pre, post]) returns the literal's text (with its language tag)

with every word the query matches wrapped in pre and post (default <b> and </b>):

SPARQL
PREFIX g: <http://graphrd.org/fn#>
SELECT ?title (g:ftsHighlight(?title, "graph") AS ?snippet) WHERE { ?title g:fts "graph" }

Search endpoint

GET /rest/repositories/{id}/search runs a query and returns the matching statements (subject,

predicate, literal), best first, with highlights, plus facet counts: for each facet predicate, how

many of the matching subjects have each value.

ParameterDefault
query (or q)the full-text query
limit, offset10, 0the page of hits (limit at most 1000)
faceta predicate IRI; repeat for more facets (up to 100 values each, most frequent first)
pre, post<b>, </b>highlight markers
Shell
curl -G localhost:7200/rest/repositories/docs/search --data-urlencode 'query=graph' \
  --data-urlencode 'facet=http://www.w3.org/1999/02/22-rdf-syntax-ns#type'
JSON
{"query": "graph", "total": 2,
 "hits": [{"subject": "<http://example.org/a>", "predicate": "<http://example.org/title>",
           "literal": "\"Graph databases\"@en", "score": 0.89, "highlight": "<b>Graph</b> databases"}],
 "facets": {"http://www.w3.org/1999/02/22-rdf-syntax-ns#type": [{"value": "<http://example.org/Book>", "count": 2}]}}

How it works

Each word of a literal becomes postings [hash(key), literal id] for the word itself, its English

and Turkish stems and every prefix of 3 or more characters (for prefix*); its character trigrams

padded with $$ go into a vocabulary (trigram → word): a word within edit distance d shares all but

4·d of its trigrams, so a typo-tolerant word looks up the words sharing enough of them and takes

their literals as candidates. One more posting per literal records its length in words, for BM25. A query collects candidate literals from the postings and

verifies each against its actual text, so hash collisions never produce wrong results and phrases

are checked exactly. Stores whose index predates stems and trigrams are re-indexed once when opened

(200,000 literals: about 8 s).

Measured with bench/fts.py on 200,000 literals (4 to 13 words, a fifth Turkish), against the

previous index: loading

over HTTP 3.7 → 4.4 s (the stem postings), two-word and phrase queries within 5%, a typo that needs

the fallback (databse, 40,000 hits) about 0.2 s.

Search connectors

A connector keeps a repository's entities of chosen classes in an OpenSearch (or Elasticsearch)

index, so the search engine's relevance ranking, analyzers, fuzzy matching and aggregations work

on graph data, and SPARQL queries can start from a search.

Creating a connector

Shell
curl -X PUT localhost:7200/rest/repositories/zoo/connectors/people -H 'Content-Type: application/json' -d '{
  "type": "opensearch",
  "url": "http://localhost:9200",
  "index": "zoo-people",
  "types": ["http://example.org/Person"],
  "fields": [
    {"name": "name", "property": "http://example.org/name"},
    {"name": "city", "property": "http://example.org/city"}
  ]
}'
KeyMeaning
typeopensearch or elasticsearch (the same API)
urlthe engine's base URL; https:// needs a build with the tls feature
indexindex name (lower case; default: the connector name)
typesclasses whose instances are indexed; inferred types count
fieldsdocument fields and the predicate each one takes its values from

The index is created when it does not exist (fields as text with a .raw keyword subfield), and

every entity is sent once. A connector that cannot reach its engine is refused with 502.

Connectors are saved with the repository (connectors.json) and start again with the server.

What gets indexed

Each entity becomes a document whose _id is its IRI, with id, types and one value or a list

of values per field (literals as their text, resources as their IRI). The store records the

subjects every write touches; a thread per repository sends their documents with the _bulk API

every few hundred milliseconds, and deletes the documents of those that are no longer instances of

the connector's classes. Writes never wait for the engine: when it is unreachable, the subjects stay

queued and the error is shown in the connector's status until a later round succeeds.

Searching from SPARQL

?e http://graphrd.org/connector/NAME" target="_blank" rel="noopener">http://graphrd.org/connector/NAME "query" binds ?e to the entities the engine finds for a

query_string query (its full syntax: fields, AND/OR, wildcards, fuzzy ~, boosts), best first

(up to 1,000):

SPARQL
PREFIX ex: <http://example.org/>
SELECT ?person ?name WHERE {
  ?person <http://graphrd.org/connector/people> "name:smi* AND city:ankara" .
  ?person ex:name ?name ; ex:worksFor ?company .
}

Managing connectors

EndpointDoes
GET /rest/repositories/{id}/connectorsevery connector with its status
GET /rest/repositories/{id}/connectors/{name}definition and status: documents indexed and deleted, subjects pending, last sync, last error
PUT /rest/repositories/{id}/connectors/{name}create or replace (a full sync follows)
DELETE /rest/repositories/{id}/connectors/{name}remove; ?dropIndex=true also deletes the index

Creating and removing connectors needs the repository manager role.

Testing

tests/prod/opensearch.py starts OpenSearch in Docker and checks indexing (including inferred

types), updates, inserts, deletes, searches joined with graph patterns, a server restart, an

unreachable engine and removal with the index.

Limits

  • In a cluster, connectors are set up on each node separately (they are not replicated).
  • After a restart a connector sends every entity again, since changes made while the server was
  • down were not recorded for it.

Vector search

GraphRD indexes embedding vectors stored as literals and finds the nearest ones from SPARQL, so

semantic search (for example over text embedded by an LLM) runs next to the graph, the full-text

index and the rest of the query.

Storing vectors

A vector is a literal of datatype g:vector (http://graphrd.org/fn#vector) listing its numbers:

Turtle
@prefix ex: <http://example.org/> .
@prefix g: <http://graphrd.org/fn#> .

ex:doc1 ex:text "Graph databases store relations" ;
        ex:embedding "[0.12, -0.53, 0.33, 0.08]"^^g:vector .

Every g:vector literal added to a repository is indexed: no setting to turn on. All vectors of a

repository should have the same length; vectors of another length than the first indexed one are

stored but not indexed. Similarity is the cosine (vectors are normalized when indexed).

Nearest neighbours

?v g:knn ?q binds ?v to the 10 vector literals nearest to the vector ?q; g:knnN (for

example g:knn50) returns N. ?q is a vector literal in the query, or a variable bound before

the pattern (by VALUES, BIND or another pattern).

SPARQL
PREFIX g: <http://graphrd.org/fn#>
PREFIX ex: <http://example.org/>
SELECT ?doc ?score WHERE {
  ?emb g:knn5 "[0.10, -0.50, 0.30, 0.10]"^^g:vector .
  ?doc ex:embedding ?emb .
  BIND(g:similarity(?emb, "[0.10, -0.50, 0.30, 0.10]"^^g:vector) AS ?score)
}
ORDER BY DESC(?score)

g:similarity(?a, ?b) is the cosine similarity of two vectors (an xsd:double between -1 and 1).

Combine the full-text index and the vector index in one query: take the text matches, then rank

them by similarity, or add both scores.

SPARQL
PREFIX g: <http://graphrd.org/fn#>
PREFIX ex: <http://example.org/>
SELECT ?doc ?score WHERE {
  ?text g:fts "graph database" .
  ?doc ex:text ?text ; ex:embedding ?emb .
  BIND(g:ftsScore(?text, "graph database") / 10 + g:similarity(?emb, "[0.10, -0.50, 0.30, 0.10]"^^g:vector) AS ?score)
}
ORDER BY DESC(?score)
LIMIT 10

Or the other way round: ?emb g:knn100 ?q for candidates, then ?text g:fts … to keep those that

also match the words.

How it works

The index is an HNSW graph (hierarchical navigable small world: 16 links per node, 32 on the

bottom layer, neighbours chosen to spread in every direction). Searches return approximate

nearest neighbours: on clustered data, recall@10 is 1.0 at 100,000 vectors and 0.96 at a million.

The graph is kept in memory (about the vectors' size plus 128 bytes a node) and written to

vectors.bin at checkpoints; vectors added since are indexed again when the log is replayed.

Literals the repository no longer holds are skipped by searches.

Measured with uv run --with numpy python3 bench/vectors.py (64-dimensional vectors around 1,000

centres, knn10 queries over HTTP, Apple M2 Pro):

VectorsIndexing (graphrd load)p50p95p99recall@10
100,00022 s0.51 ms0.71 ms0.86 ms1.000
1,000,000267 s0.82 ms1.47 ms2.07 ms0.957

Indexing runs on one core; loading vectors over HTTP indexes them as they arrive.

GeoSPARQL

GraphRD implements the GeoSPARQL 1.1 functions over geometry literals with a geometry engine of

its own (no GEOS or PROJ). Its topology and set operations are checked against GEOS on random

geometries: DE-9IM matrices and overlays agree on 10,000 pairs (tests/prod/geo_diff.py).

Geometry literals

DatatypeExample
geo:wktLiteral"POINT(28.97 41.01)"^^geo:wktLiteral, optionally with a CRS IRI first: "http://www.opengis.net/def/crs/EPSG/0/32635" target="_blank" rel="noopener">http://www.opengis.net/def/crs/EPSG/0/32635 POINT(666370 4541552)"
geo:geoJSONLiteral"{\"type\":\"Point\",\"coordinates\":[28.97,41.01]}"^^geo:geoJSONLiteral

Geometry types: POINT, LINESTRING, POLYGON (with holes), MULTIPOINT, MULTILINESTRING,

MULTIPOLYGON, GEOMETRYCOLLECTION, and EMPTY forms.

Coordinate reference systems

A WKT literal without a CRS IRI is in CRS84 (longitude, latitude; the GeoSPARQL default), and so

is every GeoJSON literal. These CRS IRIs are understood as well:

CRS IRISystemAxis order
http://www.opengis.net/def/crs/OGC/1.3/CRS84WGS 84longitude, latitude
http://www.opengis.net/def/crs/EPSG/0/4326 (also 4258 ETRS89, 4269 NAD83)WGS 84latitude, longitude, as EPSG defines it
http://www.opengis.net/def/crs/EPSG/0/3857Web Mercatorx, y in metres
http://www.opengis.net/def/crs/EPSG/0/326zz, 327zzWGS 84 / UTM zone zz north, southeasting, northing in metres

Coordinates are converted to CRS84 when a literal is read, so geometries in different systems can

be compared directly; constructed geometries (envelope, intersection…) come back in CRS84,

while getSRID reports the literal's own CRS. ETRS89 and NAD83 are taken as WGS 84 (they differ by

about a metre); the UTM conversion agrees with PROJ to within a millimetre inside a zone.

The prefixes are predefined: geo: = http://www.opengis.net/ont/geosparql#,

geof: = http://www.opengis.net/def/function/geosparql/, uom: =

http://www.opengis.net/def/uom/OGC/1.0/.

Functions

FamilyFunctions
Simple Features relationssfEquals, sfDisjoint, sfIntersects, sfTouches, sfCrosses, sfWithin, sfContains, sfOverlaps
Egenhofer relationsehEquals, ehDisjoint, ehMeet, ehOverlap, ehCovers, ehCoveredBy, ehInside, ehContains
RCC8 relationsrcc8eq, rcc8dc, rcc8ec, rcc8po, rcc8tppi, rcc8tpp, rcc8ntpp, rcc8ntppi
DE-9IMrelate(a, b, "T*F**FFF*")
Set operationsintersection, union, difference, symDifference
Constructionsenvelope, convexHull, centroid, boundary
Propertiesdimension, coordinateDimension, spatialDimension, isEmpty, isSimple, getSRID, numGeometries, minX, minY, maxX, maxY
SerializationasWKT, asGeoJSON
Measuresdistance(a, b, uom), area(g, uom), length(g, uom), perimeter(g, uom)

Relations are computed in coordinate space (planar), as in most GeoSPARQL implementations.

Measures are computed on the sphere and returned in the unit given: uom:metre (the default),

uom:kilometre, uom:degree or uom:radian. area uses the square of the unit (uom:metre gives

square metres).

SPARQL
PREFIX geo:  <http://www.opengis.net/ont/geosparql#>
PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
PREFIX uom:  <http://www.opengis.net/def/uom/OGC/1.0/>
PREFIX ex:   <http://example.org/>

SELECT ?place ?km WHERE {
  ?place geo:hasGeometry/geo:asWKT ?wkt .
  FILTER(geof:sfWithin(?wkt, "POLYGON((26 36, 45 36, 45 42, 26 42, 26 36))"^^geo:wktLiteral))
  BIND(geof:distance(?wkt, "POINT(32.85 39.93)"^^geo:wktLiteral, uom:kilometre) AS ?km)
}
ORDER BY ?km

Spatial index

Every repository indexes its geometry literals (WKT and GeoJSON objects) in a quadtree over the

CRS84 plane, kept up to date on every write and by bulk loads; a repository created by an earlier

version builds it once when it is opened. No setting is needed.

A FILTER over a basic graph pattern uses the index when one of its conjuncts (&&) relates the

geometry variable to a constant geometry:

  • any relation function except the disjointness ones (sfDisjoint, ehDisjoint, rcc8dc), with
  • the variable on either side: geof:sfWithin(?wkt, "POLYGON(…)"^^geo:wktLiteral);

  • geof:distance(?wkt, <geometry>[, unit]) < r (also <=, or written r > …).

The constant can also be a variable bound by another part of the same pattern (for example

ex:district geo:asWKT ?area next to FILTER(geof:sfWithin(?wkt, ?area))). Only the geometries

whose cells meet the constant's bounding box (widened by the distance) are then tried; the filter

still decides each of them exactly. A filter the index cannot answer (a disjointness test, a

relation under || or !, a box wider than half the world) is evaluated geometry by geometry

as before.

Measured with bench/geo.py (100,000 points and small polygons over Europe, on the development Mac,

median of 10 runs over HTTP):

QueryIndexWithout index (same build)Before the index
sfWithin, 2 x 1 km area (6 rows)0.43 ms108 ms502 ms
sfWithin, 17 x 11 km area (COUNT)2.9 ms114 ms512 ms
distance < 5 km (321 rows)2.0 ms246 ms394 ms
sfWithin of an area read from the data2.8 ms116 ms508 ms

"Without index" hides the call from the rewrite (FILTER((…) || false)); it is faster than before

because geometries whose bounding boxes are apart are now decided without the full DE-9IM matrix,

and a point against polygons is located directly.

Limits

  • Query rewriting of the GeoSPARQL relation properties (?a geo:sfWithin ?b as a triple pattern
  • computed from geometries) is not implemented; use the functions in FILTER.

  • Relations are planar in degrees; the index does not wrap across the antimeridian (a distance
  • filter near it falls back to all longitudes).

SHACL validation

A repository with isShacl on validates every write against SHACL shapes and refuses writes that

would leave the data invalid.

Turning it on

Shell
curl -X POST localhost:7200/rest/repositories -H 'Content-Type: application/json' \
  -d '{"id":"people","params":{"isShacl":{"value":"true"}}}'

Shapes

Shapes are statements in the named graph urn:graphrd:shapes (a compatibility name some

clients use is answered as the same graph). Load them like any data:

Turtle
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix ex: <http://example.org/> .

ex:PersonShape a sh:NodeShape ;
  sh:targetClass ex:Person ;
  sh:property [
    sh:path ex:name ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ;
    sh:message "A person has exactly one name"
  ] ;
  sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:maxInclusive 150 ] .
Shell
curl -X POST 'localhost:7200/repositories/people/statements?context=%3Curn%3Agraphrd%3Ashapes%3E' \
  -H 'Content-Type: text/turtle' --data-binary @shapes.ttl

Shapes take effect for the next write. Shapes are not validated against the data when loaded; use

the report endpoint below to check existing data.

Supported features

All of SHACL Core and SHACL-SPARQL, plus SPARQL-based targets from SHACL Advanced Features. The W3C SHACL test suite passes

120 of 120 tests (core 98, sparql 22; see Conformance).

KindSupported
Targetssh:targetClass (with rdfs:subClassOf*), sh:targetNode, sh:targetSubjectsOf, sh:targetObjectsOf, implicit class targets (a shape that is also an rdfs:Class), sh:target [ a sh:SPARQLTarget ; sh:select "SELECT ?this ..." ]
Pathsa predicate IRI, sequence paths ( ex:a ex:b ), sh:inversePath, sh:alternativePath, sh:zeroOrMorePath, sh:oneOrMorePath, sh:zeroOrOnePath, nested
Cardinalitysh:minCount, sh:maxCount
Valuessh:datatype (ill-formed literals fail), sh:class, sh:nodeKind, sh:in, sh:hasValue
Rangessh:minInclusive, sh:maxInclusive, sh:minExclusive, sh:maxExclusive
Stringssh:pattern (with sh:flags), sh:minLength, sh:maxLength, sh:languageIn, sh:uniqueLang
Property pairssh:equals, sh:disjoint, sh:lessThan, sh:lessThanOrEquals
Logic and shapessh:and, sh:or, sh:not, sh:xone, sh:node, sh:property, sh:qualifiedValueShape with sh:qualifiedMinCount / sh:qualifiedMaxCount / sh:qualifiedValueShapesDisjoint
Othersh:closed with sh:ignoredProperties, sh:deactivated, sh:severity (sh:Violation, sh:Warning, sh:Info), sh:message
SPARQLsh:sparql constraints (sh:select, sh:prefixes / sh:declare, sh:message with {?var}), SPARQL-based constraint components (sh:ConstraintComponent with sh:parameter, sh:validator ASK, sh:nodeValidator, sh:propertyValidator)

Shapes referring to each other recursively are followed 64 levels deep; deeper references count as

conforming (SHACL leaves recursion undefined).

SPARQL constraints

A SELECT query runs for each focus node with $this bound to it (and $currentShape,

$shapesGraph; $PATH is replaced by the property shape's path). Every solution is a result: its

?value and ?path become sh:value and sh:resultPath, and ?failure true makes the validation

fail. As SHACL requires, the query may not use MINUS, VALUES or SERVICE or assign $this,

and a subquery must project the pre-bound variables.

Turtle
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .

ex:OrderShape a sh:NodeShape ;
  sh:targetClass ex:Order ;
  sh:sparql [
    sh:message "The total {?t} is negative" ;
    sh:select "SELECT $this ?t WHERE { $this <http://example.org/total> ?t FILTER (?t < 0) }"
  ] .

Severity

Only sh:Violation results (the default) reject a write; sh:Warning and sh:Info results show up

in the report of the whole repository.

Validation of writes

Each write (upload, update, delete, import) is checked for the nodes it touches and the nodes that

link to them. If a violation remains, the write is rolled back and answered with 400 and the

report:

JSON
{
  "conforms": false,
  "results": [
    {
      "focusNode": "<http://example.org/ann>",
      "resultPath": "<http://example.org/name>",
      "value": null,
      "sourceShape": "_:b0",
      "sourceConstraintComponent": "http://www.w3.org/ns/shacl#MinCountConstraintComponent",
      "resultSeverity": "Violation",
      "resultMessage": "A person has exactly one name"
    }
  ]
}

graphrd load into a SHACL repository goes through the same checks, batch by batch (see

cli.md).

Validating everything

Shell
curl localhost:7200/rest/repositories/people/shacl/validate                         # JSON
curl -H 'Accept: text/turtle' localhost:7200/rest/repositories/people/shacl/validate  # sh:ValidationReport

Validating files

graphrd validate validates RDF files against a shapes file in memory, without a data directory,

and prints the sh:ValidationReport (Turtle, or --format json):

Shell
graphrd validate --shapes shapes.ttl data.ttl more-data.ttl

Conformance

tests/w3c/shacl.py runs the W3C SHACL test suite through graphrd validate:

Shell
git clone --depth 1 https://github.com/w3c/data-shapes /tmp/data-shapes
uv run --with rdflib python3 tests/w3c/shacl.py /tmp/data-shapes

A report matches when sh:conforms agrees and the results agree as a multiset of focus node, path

(by structure), value, source shape, constraint component, severity and, where the expected report

names one, sh:sourceConstraint. Blank nodes compare equal to each other, and messages are not

compared.

Administration

Users and permissions

Without --auth there are no users and every request is allowed (keep such a server on

localhost). serve --auth admin:secret (or GRAPHRD_AUTH=admin:secret) turns security on: this

user always exists with ROLE_ADMIN, and its password is reset to the given one at each start.

AuthorityAllows
ROLE_ADMINeverything: users, backup and restore, every repository
ROLE_REPO_MANAGERcreate, configure and delete repositories; read and write all of them
ROLE_USERsign in
READ_REPO_<id>query repository <id>
WRITE_REPO_<id>query and change repository <id>
READ_REPO_, WRITE_REPO_the same for every repository

Manage users in the UI (Users) or through the API (admin only):

Shell
A='admin:secret'
curl -u $A -X POST localhost:7200/rest/security/users/alice -H 'Content-Type: application/json' \
  -d '{"password":"pw-alice","grantedAuthorities":["ROLE_USER","READ_REPO_zoo"]}'
curl -u $A localhost:7200/rest/security/users                      # list
curl -u $A -X PUT localhost:7200/rest/security/users/alice -H 'Content-Type: application/json' \
  -d '{"grantedAuthorities":["ROLE_USER","WRITE_REPO_zoo"]}'      # change authorities
curl -u $A -X DELETE localhost:7200/rest/security/users/alice

Users change their own password with PATCH /rest/security/users/<own name> and

{"password": "…"}. A password change ends the user's open sessions. The bootstrap administrator

cannot be deleted or lose ROLE_ADMIN. Users are stored in DATA/users.json (salted, iterated

hashes; see security.md).

Clients authenticate with HTTP Basic, or with a token: POST /rest/login with

{"username", "password"} answers with Authorization: Bearer <token> to send on later requests.

Backup and restore

MethodHow
Running serverGET /rest/recovery/backup returns a tar archive of every repository's configuration, namespaces and explicit statements ({"repositories": ["zoo"]} in a POST body for some); POST /rest/recovery/restore with the archive (multipart file or the raw tar) brings them back. Also on the Repositories page.
Stopped servergraphrd backup --data DIR DEST copies the data directory consistently; the copy is a data directory of its own. --incremental updates an earlier backup with what changed since.
Point in timegraphrd restore --from DEST --data NEW --to-time T restores a backup as it was at T, from the repositories' change history (historyRetention); see cli.md.
Shell
curl -u admin:secret localhost:7200/rest/recovery/backup -o graphrd-backup.tar
curl -u admin:secret -X POST localhost:7200/rest/recovery/restore --data-binary @graphrd-backup.tar

Inferred statements are not in archives; they are derived again on restore.

For point-in-time recovery, give repositories a historyRetention that covers the window you want

to go back over (for example 7d), take backups regularly with --incremental (cheap: mostly

new run files), and restore the latest backup to the moment you need. tests/prod/pitr.py tests

the whole cycle.

Monitoring

GET /metrics returns Prometheus metrics (with --auth, any signed-in user may read them; give

Prometheus a user and basic_auth):

MetricType
graphrd_query_duration_seconds, graphrd_update_duration_secondshistograms
graphrd_wal_fsync_duration_secondshistogram of write-ahead log fsyncs
graphrd_query_errors_total, graphrd_update_errors_total, graphrd_slow_queries_totalcounters
graphrd_http_responses_total{code="2xx"} …counters by status class
graphrd_statements_loaded_totalstatements added by uploads and imports
graphrd_statements{repository="…"}, graphrd_repositoriesgauges
graphrd_resident_memory_bytesresident memory (Linux)
graphrd_cluster_leader, graphrd_cluster_term, graphrd_cluster_commit_index, graphrd_cluster_applied_indexcluster nodes only
graphrd_uptime_seconds, graphrd_info{version}gauges
YAML
# prometheus.yml
scrape_configs:
  - job_name: graphrd
    static_configs: [{ targets: ["graphrd:7200"] }]
    basic_auth: { username: monitor, password: "…" }

serve --slow-query 500 writes every query or update slower than 500 ms to standard error as one

JSON line ({"slow":"query","repository":…,"ms":…,"text":…}) and keeps the last 100 for

GET /rest/monitor/slow-queries. The UI's Monitoring page charts queries and updates per

second, latency percentiles and memory from /metrics, and lists slow queries.

Every request to /repositories and /rest is logged to standard error with its status and time.

Upkeep

A repository whose writes stop for a few seconds is checkpointed in the background: memtables

become run files and the write-ahead log is emptied, so idle servers stay small and restarts replay

nothing. graphrd optimize (server stopped) merges each index into one file.

Limits for exposed servers

graphrd::http::set_limits(max_body, max_connections) (when embedding) bounds request bodies

(default 2 GB) and concurrent connections. --read-only refuses every change, --timeout bounds

queries, and --cors lets web pages on other origins call the API.

Cluster

Several GraphRD servers form a cluster with the Raft consensus protocol. Every node holds all the

data; writes are replicated to every node in the same order; the cluster keeps working while a

majority of its nodes is up (2 of 3, 3 of 5).

Starting a cluster

Each node gets the full member list, its own id and a shared secret:

Shell
MEMBERS="n1=10.0.0.1:7200,n2=10.0.0.2:7200,n3=10.0.0.3:7200"
graphrd serve --host 0.0.0.0 --data /var/lib/graphrd --cluster "$MEMBERS" --node-id n1 --cluster-secret "$SECRET"   # on 10.0.0.1
graphrd serve --host 0.0.0.0 --data /var/lib/graphrd --cluster "$MEMBERS" --node-id n2 --cluster-secret "$SECRET"   # on 10.0.0.2
graphrd serve --host 0.0.0.0 --data /var/lib/graphrd --cluster "$MEMBERS" --node-id n3 --cluster-secret "$SECRET"   # on 10.0.0.3

--cluster-secret can come from GRAPHRD_CLUSTER_SECRET instead. Nodes talk to each other over

the same HTTP port (/cluster/raft/vote, /cluster/raft/append), authenticated by the secret. A

cluster cannot run with --memory.

Reads and writes

  • Writes (updates, uploads, imports, repository changes) can be sent to any node. A follower
  • forwards them to the leader. The leader appends the change to its log, replicates it, and answers

    once a majority has stored it; every node then applies it. If a majority cannot be reached, the

    write is undone and answered with 503.

  • Reads are answered by the node that receives them, from its own copy, which may lag the
  • leader by a moment. A read with the header X-GraphRD-Consistency: leader is forwarded to the

    leader and sees every acknowledged write.

Shell
curl -H 'X-GraphRD-Consistency: leader' -G http://10.0.0.2:7200/repositories/zoo \
  --data-urlencode 'query=SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'

Put the nodes behind a load balancer for reads; any node accepts writes.

Read replicas (learners)

--cluster-learners id=host:port,... adds members that receive every change but do not vote and

do not count for a majority: read replicas. Give every node the same --cluster and

--cluster-learners lists; a learner starts with its own id in the learner list. Learners answer

reads from their copy and forward writes to the leader like followers; they never become leader,

and adding or losing them never affects whether the cluster can commit.

Shell
graphrd serve --data /var/lib/graphrd --cluster "$MEMBERS" --cluster-learners "r1=10.0.0.9:7200" \
  --node-id r1 --cluster-secret "$SECRET"

Log compaction and snapshots

Applied entries are folded away once 10,000 of them have accumulated: the data holds their effect,

the log keeps only what follows, and a restart does not apply them again. A member that needs

entries the leader has compacted (one that was down for long, or a new member) receives a snapshot

instead: a backup archive of every repository, which replaces its repositories, after which it

continues from the log. GRAPHRD_RAFT_COMPACT=<n> changes the threshold.

Status and failover

GET /rest/cluster/status shows the node's role (leader, follower, candidate, learner),

term, leader, log length, compaction index (snapshotIndex), commit and applied indexes and peers;

on the leader (or with X-GraphRD-Consistency: leader) each peer also has the index it has

replicated to and when it last answered. The UI's Cluster page draws a map of the members (the

leader in the middle, links coloured by health and labelled with how far behind each member is,

learners dashed), refreshed every two seconds; /metrics has graphrd_cluster_* gauges.

When the leader stops, the others elect a new one within about a second, and writes continue

through it. A follower that receives a write while the leader is being replaced holds it until the

new leader is known (up to five seconds) instead of refusing it, so clients see a short pause, not

an error. A node that was down catches up from the leader's log when it returns. The term, vote

and log are stored in DATA/cluster/.

bench/cluster-test.sh runs a three-node cluster through election, forwarded writes, replication,

leader failover and catch-up after a restart. tests/prod/cluster2.py runs three voters and a

learner with a client writing all along: the leader is killed with SIGKILL and later restarted after

the log was compacted. About 700 writes are acknowledged, none refused, the longest pause is under

a second, the restarted node catches up from a snapshot, and every node, the learner included,

ends with exactly the acknowledged statements.

Limits

  • Membership is fixed by --cluster; adding or replacing a node means restarting the members with
  • the new list.

  • Every node holds all the data (no sharding).

Storage

This page describes how a repository keeps its data: the files, the indexes, the dictionary,

durability and concurrency.

The data directory

Text
graphrd-data/
  repositories/
    zoo/
      config.json           repository configuration (ruleset, FTS, SHACL, …)
      namespaces.json       prefixes
      storage/
        MANIFEST            which run files are live, dictionary size, statement counts
        wal.log             write-ahead log
        history.log         change history for time travel, with commit times (historyRetention)
        terms.dat           dictionary: encoded terms, appended
        terms.idx           dictionary: offset of each term id
        terms.hash          dictionary: open-addressing hash table, term -> id
        spog-00000012.run   index runs (spog, posg, ospg, gspo, fts, geo)
        …
  users.json                users (with --auth)
  saved-queries.json
  cluster/                  Raft term, vote and log (cluster nodes)
  import/                   files offered for server-side import

A data directory is locked by the process that uses it (.lock), so a server and an offline tool

cannot open it at the same time.

Dictionary

Every distinct term (IRI, blank node, literal, triple term) gets a 32-bit id. Statements are stored

as four ids [subject, predicate, object, graph]; graph id 0 is the default graph, and a reserved

id marks inferred statements. New terms are kept in memory until a checkpoint appends them to

terms.dat / terms.idx and inserts them into terms.hash (rebuilt into a new file when it grows,

so a crash leaves either the old or the new table). Decoded terms and lookups are cached in sharded

caches of 200,000 entries each.

Indexes

Each repository has four indexes over the same statements, in the orders SPOG, POSG, OSPG and GSPO,

so every triple pattern is a range scan of one of them. The full-text index is a fifth index of the

same kind (fts.md).

Each index is a log-structured merge tree:

  • Memtable: new entries go to a sorted in-memory map (key → present or deleted). Readers share
  • frozen, immutable sorted levels of it.

  • Runs: a checkpoint writes the memtable as an immutable run file: a header, fixed-size
  • entries (16-byte key plus a flag), a sparse index of the first key of every 256-entry block, and

    a footer. Runs are written to a temporary file, fsynced and renamed.

  • Merging: runs of similar size are merged (size-tiered), so their number stays logarithmic in
  • the data size. graphrd optimize merges everything into one run per index.

  • Reads merge the memtable levels and the runs, newest first. Blocks are read on demand through
  • a shared LRU cache of about 16,000 decoded blocks (~70 MB at most).

Writes and durability

  1. A transaction (an update request, an upload, a delete) changes the memtables and appends its
  2. statements to wal.log, followed by a commit marker.

  3. The log is fsynced before the client gets its answer (--sync on, the default). Concurrent
  4. writers share one fsync (group commit). The log file is zero-filled ahead of its end so an fsync

    writes data only, not file-size metadata.

  5. A checkpoint (when the memtables hold 2 million entries, the log reaches 256 MB, or writes have
  6. stopped for a few seconds) writes run files, appends new terms to the dictionary, writes a new

    MANIFEST atomically, and empties the log.

  7. On start, the log is replayed after the last checkpoint. A transaction without its commit marker
  8. (torn by a crash) is dropped whole.

With --sync off, commits are not fsynced: they survive a crash of the process (the log is in the

operating system's cache) but not a power loss. graphrd load skips the log altogether and makes

each batch live with one manifest write (cli.md).

A SPARQL update request is all or nothing: its changes are recorded while it runs and undone if any

operation fails or SHACL rejects the result.

Concurrency: snapshots

Readers never wait for writers, and writers never wait for readers. Each query takes a snapshot of

the repository: the current memtable levels and the list of runs, shared by reference counting. The

writer works on its own memtable head and publishes a new snapshot when its transaction is done.

Writers are serialized among themselves by a lock that is released before the fsync, so the next

writer can proceed while the previous one waits for the disk.

Run files that merging replaced stay readable by older snapshots; they are deleted after the

manifest no longer names them.

Inferred statements

Inferred statements are stored in the same indexes under the reserved graph id, so queries read

them like explicit ones, and they survive restarts without being derived again

(inference.md).

Memory

Memory use is bounded by the memtables (checkpointed at 2 million entries), the block cache

(~70 MB) and the dictionary caches, not by the data size. Snapshots share structure, so many

concurrent queries cost little extra. After a burst of writes, the background checkpoint empties

the memtables and returns freed memory to the operating system.

In-memory repositories

serve --memory keeps everything in memory with the same index and query code: no files, no log,

and no checkpoints. The data ends with the process.

Performance

Where GraphRD stands

Measured in Docker with 4 CPUs and 6 GB, on synthetic data

(900,231 triples: persons, companies, cities, tags, posts), median of 30 runs after 3 warm-up runs,

CSV results over HTTP:

QueryGraphRD (ms)
Q1 point lookup0.58
Q2 COUNT + FILTER on age1.86
Q3 GROUP BY company, top 52.55
Q4 AVG over a join, top 512.4
Q5 OPTIONAL + LIMIT 2001.18
Q6 REGEX count7.56
Q7 property path knows+16.6
Q8 ORDER BY DESC + LIMIT 10 over 100K10.0
Q9 join count (20K)6.60
Q10 UNION count0.80
Q11 date filter count8.08
Q12 join count (198K)35.2
single INSERT DATA (fsync on)0.71

Bulk loading the same file offline: load takes 0.6 s (about 1.5 million lines/s), 1.8 s

including container start. The concurrency, cluster and durability measurements are below.

Tuning

SettingEffect
graphrd load instead of HTTP uploadsthe fastest way in: parallel parsing, no write-ahead log, one sorted run per index
ruleset: empty or an -optimized rulesetinference costs time on writes and space for inferred statements; the -optimized rulesets skip the axioms and resource typing nobody queries
--sync offwrites take ~0.3 ms instead of an fsync; they survive a crash of the process, not a power loss
batching writesone update or upload with many statements costs one fsync; concurrent writers share fsyncs anyway (group commit)
graphrd optimizeafter large deletes or many small loads: one run per index, no deletion markers
infer=falsequeries skip inferred statements when they do not need them
LIMIT without ORDER BYthe query stops as soon as it has enough rows
timeout / serve --timeoutbounds runaway queries
unionDefaultGraphtrue (default) queries every graph as the default graph; false reads only statements without a graph

Memory is bounded by the memtables (2 million entries), the block cache (~70 MB) and the dictionary

caches; it does not grow with the data. Data larger than memory works; hot parts stay in the block

cache and the operating system's page cache.

Measuring

ToolMeasures
bench/docker.sh up, then queries, cold, mem, loadfresh servers in Docker, on volumes that keep the loaded data
python3 bench/queries.py ENDPOINT…the 12 queries and single inserts against any running servers, side by side
python3 bench/geo.py [BIN]GeoSPARQL filters on 100,000 geometries with and without the spatial index (geosparql.md)
graphrd benchload, query latency, concurrent throughput and write latency on a synthetic graph
graphrd compare / compare-reportany SPARQL 1.1 store through the standard protocols only
bench/run.sh, bench/cluster-bench.shthe GraphRD-only numbers, and a cluster
graphrd query --explain truethe plan and time of one query
GET /metrics, --slow-querylatency histograms and slow queries of a running server (admin.md)

When comparing two builds, run them side by side on copies of the same data and alternate the

order: figures from different runs or different data layouts are not comparable.

Security

Defaults

  • The server binds to 127.0.0.1. Binding elsewhere without --auth prints a warning: anyone who
  • can reach the port could read and change data.

  • With --auth, every endpoint except /protocol, /rest/login, /rest/logout,
  • /rest/info/version and the UI's files needs a signed-in user with the right permission

    (admin.md). Requests without one get 401, requests beyond the

    user's rights 403.

Authentication

  • HTTP Basic (Authorization: Basic …), checked on each request, or
  • Tokens: POST /rest/login returns Authorization: Bearer <token>; tokens expire after 12 hours
  • and end at logout or when the password changes.

  • API keys (X-API-Key: grd_… or Authorization: ApiKey grd_…) for programs: an
  • administrator makes one for a user (POST /rest/security/apikeys {"user": "ci", "name": "build"});

    the key is shown once, only a digest of it is kept, and it has its user's rights.

  • OpenID Connect access tokens (Authorization: Bearer <JWT>) from an identity provider such as
  • Keycloak, Entra ID, Okta or Auth0, with --oidc-issuer (below).

  • LDAP: users that are not local sign in with their directory password, with --ldap-url
  • (below).

  • The PostgreSQL port uses cleartext password authentication with the same users; use it on a
  • trusted network or behind TLS termination.

OpenID Connect

Shell
graphrd serve --auth admin:change-me --oidc-issuer https://keycloak.example/realms/main \
  --oidc-audience graphrd-api --oidc-role-map "graph-editors=WRITE_REPO_zoo,graph-admins=ROLE_ADMIN"

GraphRD finds the provider's signing keys through discovery (/.well-known/openid-configuration,

then its JWKS; fetched again when a token names a new key) and accepts a token when its RS256

signature verifies, its issuer is --oidc-issuer, --oidc-audience is in aud or is azp, and

it has not expired. The user name is preferred_username (else sub); the roles are read from

--oidc-roles-claim (default realm_access.roles, Keycloak's) and mapped with

--oidc-role-map role=AUTHORITY[+AUTHORITY],...; roles already named like authorities

(ROLE_ADMIN, READ_REPO_zoo...) need no mapping. Every token user gets ROLE_USER. Without

--auth, OpenID Connect alone turns security on, with no local administrator. The signature check

is GraphRD's own RSA code (no crypto library); keys below 2048 bits are refused.

tests/prod/keycloak.py runs the whole flow against a Keycloak in Docker.

LDAP

Shell
graphrd serve --auth admin:change-me --ldap-url ldap://ldap.example:389 \
  --ldap-user-dn "uid={user},ou=people,dc=example,dc=org" \
  --ldap-group-base "ou=groups,dc=example,dc=org" --ldap-group-map "zoo-writers=WRITE_REPO_zoo" \
  --ldap-bind-dn "cn=reader,dc=example,dc=org" --ldap-bind-password "$PW"

A user that is not local signs in by binding to the directory as --ldap-user-dn (the name is

escaped, so it cannot change the DN; empty passwords are refused, since they would be anonymous

binds). Their authorities: --ldap-default-roles, plus those of the groups under

--ldap-group-base that list them as a member, mapped with --ldap-group-map (or named like

authorities). Groups are searched as the user, or as --ldap-bind-dn where users cannot read

them; when they cannot be read, the user signs in with the default roles only. A directory

sign-in is trusted for five minutes before the password is checked with the directory again;

session tokens from /rest/login work for directory users too. The directory is reached over plain

ldap://: keep it on a trusted network. tests/prod/ldap.py runs against OpenLDAP in Docker.

Passwords are stored as salted SHA-256 iterated 10,000 times (GraphRD has no third-party crypto

library in its default build). Comparisons do not leak timing.

Lockout: after 10 wrong passwords for a user, that user is locked for 15 minutes, even with the

right password; this stops online guessing.

The UI handles 401 with its own sign-in form; it never triggers the browser's Basic prompt.

Scripted requests (fetch, XHR) get no WWW-Authenticate challenge either.

Audit log

serve --audit-log /var/log/graphrd/audit.jsonl appends one JSON line for every change (with the

SPARQL update text, up to 2,000 characters), sign-in and sign-out, and refused request (401, 403);

--audit-reads adds reads. Each line has time (Unix ms), user (for a refused request, the name

it claimed), method, path, repository, status, ms and client (from

X-Forwarded-For).

JSON
{"time":1792140000000,"user":"ci","method":"POST","path":"/repositories/zoo/statements","repository":"zoo","status":204,"ms":3,"client":null,"update":"INSERT DATA { ... }"}

Transport

Build with --features tls and start with --tls-cert cert.pem --tls-key key.pem to serve HTTPS

(rustls with the ring provider). Otherwise terminate TLS in a reverse proxy. The tls build also

fetches https:// URLs (LOAD, imports, SERVICE, JSON-LD contexts), trusting the Mozilla roots plus

any CA in SSL_CERT_FILE. The library contains an ACME client (graphrd::acme) for Let's Encrypt

certificates.

Cluster traffic between nodes is authenticated with the shared --cluster-secret; run it on a

private network or behind TLS.

Hostile input

InputGuard
HTTPheader size capped at 64 KB, body size and connection count bounded (set_limits), slow clients time out after 120 s
Queriesparser nesting and chain-length limits; regular expressions run in linear time (no catastrophic backtracking); timeout per request
Turtle, JSONnesting limits, so deep documents cannot exhaust the stack
RDF/XMLentity expansion capped (no "billion laughs"), nesting limited, no external entities fetched
Repository ids, import file namesrestricted to safe characters; server-side imports read only DATA/import/
Panicsa request that panics is answered with 500; the server keeps running

The production test suite (tests/prod/robustness.py) runs protocol abuse, slow clients, deeply

nested input and 2,300 fuzzed requests against a real server, including the PostgreSQL port.

Supply chain

The default build has no third-party crates: the whole server, from HTTP to SPARQL, is in this

repository. Only the optional tls feature adds rustls and its dependencies. The container image

is distroless and runs as an unprivileged user.

Reporting

Report vulnerabilities privately to the maintainers rather than in a public issue.