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 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.
git clone <repository-url> graphrd
cd graphrd
cargo build --release
./target/release/graphrd serveThe optional tls feature adds HTTPS through rustls:
cargo build --release --features tlsWith Docker
The image runs as an unprivileged user and keeps its data in the /data volume.
docker build -t graphrd .
docker run -d --name graphrd -p 7200:7200 -v graphrd-data:/data \
-e GRAPHRD_AUTH=admin:change-me graphrdGRAPHRD_AUTH turns on authentication (see admin.md). Without it, anyone who can reach
the port can read and change data.
Start the server
graphrd serveThe 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:
| Option | Meaning |
|---|---|
--port 7200 | HTTP port |
--host 127.0.0.1 | bind address (0.0.0.0 for every interface) |
--data ./graphrd-data | data directory |
--memory | keep everything in memory, nothing on disk |
--auth user:password | require 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:
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:
@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:
curl -X POST localhost:7200/repositories/zoo/statements \
-H 'Content-Type: text/turtle' --data-binary @animals.ttlLarge files load much faster offline, while the server is stopped (see cli.md):
graphrd load --data ./graphrd-data --repo zoo animals.ttlRun a query
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:
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
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
- sparql.md: what the query engine supports and the request parameters.
- http-api.md: every HTTP endpoint.
- inference.md: rulesets and custom rules.
- formats.md: file formats for import and export.
Command line
graphrd <command> [options]| Command | What it does |
|---|---|
serve | run the HTTP server (and optionally the PostgreSQL port and a cluster node) |
load | bulk-load RDF files into a repository, offline |
dump | write a repository's statements to a file |
query | run a SPARQL query on a data directory |
update | run a SPARQL update on a data directory |
backup | copy a data directory consistently |
optimize | compact every repository |
validate | validate RDF files against SHACL shapes |
convert | convert between RDF formats, or canonicalize (RDFC-1.0) |
bench, compare | benchmarks |
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.
| Option | Default | Meaning |
|---|---|---|
--data DIR | ./graphrd-data | data directory |
--repo ID | default | repository |
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
graphrd serve [options]| Option | Default | Meaning | |
|---|---|---|---|
--port N | 7200 | HTTP port | |
--host ADDR | 127.0.0.1 | bind address; a warning is printed when it is not local and --auth is missing | |
--data DIR | ./graphrd-data | data directory | |
--memory | off | keep repositories, users and saved queries in memory only; no data directory is used and everything ends with the process (not with --cluster) | |
--auth USER:PASS | none | require sign-in; this user is the administrator. Also read from GRAPHRD_AUTH | |
| `--sync on\ | off` | on | fsync each commit before acknowledging it; off survives a crash of the process but not a power loss |
--read-only | off | refuse every change with 403 | |
--cors | off | answer cross-origin requests from any page | |
--timeout S | none | time limit in seconds for queries that do not set their own timeout | |
--default-repo ID | see below | repository answered at /sparql, /query, /update | |
--service-cache S | off | reuse SERVICE answers for S seconds (sparql.md) | |
--service-timeout S | 300 | time limit of one SERVICE call | |
--service-retries N | 0 | retry SERVICE calls that cannot connect or get a 5xx answer | |
--slow-query MS | off | log queries and updates slower than this (see admin.md) | |
--llm-url URL, --llm-key KEY, --llm-model NAME | off | answer 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 key | the wire format |
--llm-effort E, --llm-timeout S | model default, 120 | effort (low … max) and the time limit of one call | |
--pg-port N | off | also accept SQL over the PostgreSQL protocol (see sql.md) | |
--ui-dir DIR | built in | serve another web UI build at /; the built-in UI moves to /ui/ | |
--tls-cert PEM, --tls-key PEM | off | serve HTTPS (builds with --features tls) | |
--cluster LIST, --node-id ID, --cluster-secret S | off | Raft 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
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.
| Option | Meaning |
|---|---|
--graph IRI | put statements without a graph into this named graph |
--base IRI | base IRI for relative IRIs (Turtle, TriG, RDF/XML, JSON-LD) |
--lenient | skip 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.
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
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
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:
graphrd query --data db --repo zoo --format csv --explain true \
'SELECT ?s WHERE { ?s a <http://example.org/Animal> }'update
graphrd update [--data DIR] [--repo ID] [--base IRI] (UPDATE | --update-file FILE)backup
graphrd backup [--data DIR] [--incremental] DESTINATIONCopies 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
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.
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:00Zoptimize
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
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
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.
| Option | Meaning | |
|---|---|---|
--graph IRI | put statements without a graph into this graph | |
| `--from-graph IRI\ | default` | keep only one graph of the input |
--to rdfc | canonical N-Quads (RDF Dataset Canonicalization, RDFC-1.0) | |
--to rdfc-map | the canonical blank node labels as JSON | |
--hash SHA384 | hash function for RDFC-1.0 (default SHA256) | |
--jsonld key=value | JSON-LD options: processingMode=json-ld-1.0, rdfDirection=…, produceGeneralizedRdf=true, expandContext=FILE |
graphrd convert --from trig --to nt --from-graph http://example.org/g data.trig
graphrd convert --to rdfc data.nq --output canonical.nqBenchmarks
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
HTTP API
GraphRD answers four families of requests on one port (default 7200):
| Path | Protocol |
|---|---|
/repositories/... | the repository REST protocol: queries, updates, statements, namespaces, Graph Store |
/sparql, /query, /update | the SPARQL 1.1 Protocol at its standard names |
/rest/... | GraphRD's management API: repositories, import, users, monitoring, backup |
/metrics | Prometheus 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}
| Method | Body | Does |
|---|---|---|
GET ?query=… | none | query |
POST | application/x-www-form-urlencoded with query= or update= | query or update |
POST | application/sparql-query | query (the body is the query) |
POST | application/sparql-update | update |
GET (no query) | none | the SPARQL Service Description of the repository |
Parameters (query string or form):
| Parameter | Meaning |
|---|---|
query, update | the SPARQL text (exactly one per request) |
infer | false to leave inferred statements out (default true); at-query to infer at query time (inference.md) |
timeout | time limit in seconds (default: serve --timeout, else none) |
default-graph-uri, named-graph-uri | the query's dataset (repeatable); overrides FROM / FROM NAMED |
using-graph-uri, using-named-graph-uri | an update's dataset; an error together with USING or WITH |
baseURI | base IRI for relative IRIs (default: the endpoint's URL) |
limit, offset | page a SELECT result (GraphRD extension) |
as-of | run the query on the repository as it was then: dateTime, date or Unix ms; needs historyRetention (sparql.md) |
The result format follows Accept:
| Result | Media types (first is the default) |
|---|---|
| SELECT, ASK | application/sparql-results+json, application/sparql-results+xml, text/csv, text/tab-separated-values, application/x-sparqlstar-results+json |
| CONSTRUCT, DESCRIBE | text/turtle, application/n-triples, application/n-quads, application/trig, application/rdf+xml, application/ld+json |
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).
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
| Method | Does |
|---|---|
GET | statements matching subj, pred, obj, context (each optional, N-Triples syntax: <iri>, "literal", _:b); infer=false for explicit ones only |
POST | add the RDF in the body (format from Content-Type); with update= in a form body, run an update |
PUT | replace the default graph, or the graphs named by context, with the body |
DELETE | remove 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.
# 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
| Endpoint | Does | ||
|---|---|---|---|
GET /repositories | the 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}/size | number of statements (context= for given graphs) | ||
GET /repositories/{id}/contexts | named graphs, as SPARQL results | ||
GET /repositories/{id}/namespaces | prefixes, 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 /protocol | the 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
| Endpoint | Does | ||
|---|---|---|---|
GET /rest/repositories | list: id, title, type, size, permissions | ||
POST /rest/repositories | create: {"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}/history | the 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/rulesets | the built-in rulesets | ||
GET /rest/repositories/{id}/search?query=Q | full-text search with highlights and facet counts (fts.md) | ||
GET /rest/repositories/{id}/shacl/validate | SHACL 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-query | a 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:
| Parameter | Default | Meaning |
|---|---|---|
ruleset | rdfsplus-optimized | inference ruleset or path of a .pie file (inference.md) |
disableSameAs | true | false turns on owl:sameAs reasoning in rulesets that have it |
enableFtsIndex | false | full-text index (fts.md) |
isShacl | false | validate writes with SHACL (shacl.md) |
unionDefaultGraph | true | the default graph of queries is the union of all graphs; false keeps it separate |
historyRetention | empty | keep the change history for time travel this long (3600, 30m, 12h, 7d, unlimited; sparql.md) |
title | empty | description |
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.
| Endpoint | Body |
|---|---|
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/file | multipart: file and importSettings (JSON as above) |
POST /rest/repositories/{id}/import/upload/url | settings with "data": "<url>"; the format comes from format, the response's content type, or the extension |
GET /rest/repositories/{id}/import/server | files in DATA/import/ on the server |
POST /rest/repositories/{id}/import/server | {"fileNames": [...], "importSettings": {...}} |
GET /rest/repositories/{id}/import/upload | recorded imports |
DELETE /rest/repositories/{id}/import/upload | forget recorded imports (?name= for one) |
context is the target named graph (default or empty for the default graph); replaceGraphs
lists graphs to clear first.
Queries, prefixes, search
| Endpoint | Does | |||
|---|---|---|---|---|
| `GET\ | POST\ | PUT\ | DELETE /rest/sparql/saved-queries` | saved queries: {"name", "body"} |
POST /rest/sparql/add-known-prefixes | the 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/compute | compute RDF Rank (repository in X-GraphRD-Repository or ?repository=) | |||
GET /rest/rank/status?repository=ID | EMPTY, COMPUTED or OUTDATED |
Security, monitoring, backup
| Endpoint | Does | |||
|---|---|---|---|---|
POST /rest/login | {"username", "password"} → the user and a token, also in the Authorization: Bearer … response header | |||
POST /rest/logout | end the token in Authorization | |||
GET /rest/security/authenticated-user | who is signed in | |||
| `GET\ | POST\ | PATCH\ | DELETE /rest/security/users/{name}` | users (admin.md) |
GET /rest/monitor/slow-queries | the slow query log | |||
GET /rest/cluster/status | Raft state of this node (cluster.md) | |||
GET /rest/recovery/backup | tar archive of every repository (admin.md) | |||
POST /rest/recovery/restore | restore such an archive (multipart file or the raw tar) | |||
GET /rest/info/version | product name and version | |||
GET /metrics | Prometheus 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(withDISTINCT,REDUCED, expressions(expr AS ?v)),ASK,CONSTRUCT - Patterns: basic graph patterns,
OPTIONAL,UNION,MINUS,FILTER,BIND,VALUES(inline and - Property paths:
/,|,^,*,+,?,!(…), grouping. - Aggregates:
COUNT(withDISTINCTand*),SUM,AVG,MIN,MAX,SAMPLE, - Solution modifiers:
ORDER BY(ASC/DESC, expressions),LIMIT,OFFSET. - Datasets:
FROM,FROM NAMED, or the protocol'sdefault-graph-uri/named-graph-uri. - Functions: the whole SPARQL 1.1 library — terms (
STR,LANG,DATATYPE,IRI,BNODE, - Regular expressions are GraphRD's own engine (a Pike VM): matching time is linear in the input,
(including CONSTRUCT WHERE), DESCRIBE.
trailing), GRAPH, sub-queries, EXISTS / NOT EXISTS, SERVICE.
GROUP_CONCAT (with SEPARATOR); GROUP BY on variables or expressions, HAVING.
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).
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.
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:
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 onlyThe 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
- Answer cache:
serve --service-cache 60reuses an endpoint's answer to the same block for - Failures:
serve --service-timeout 10bounds each call (within the query's owntimeout); - The W3C federated query tests pass (7 of 7) with local endpoints standing in for the remote ones.
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.
60 seconds across queries (off by default).
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.
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
| Extension | Meaning |
|---|---|
?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 parameters | page 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.
# 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.
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
- Basic graph patterns are ordered by the number of bound positions, then smallest first by the
- A
SELECTwithLIMITand withoutORDER BY, grouping orDISTINCTstops as soon as it has - A
FILTERthat reads one variable is evaluated once per distinct value of that variable. ORDER BY … LIMIT kranks distinct values once and keeps only rows that can be in the firstk.COUNTover one triple pattern (optionally with such a filter or grouped by its variables) counts
writers and never sees half of an update.
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.
enough rows.
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 - Every predicate used by the instances of a class is a field of that type, named by its local
idis the resource's IRI,typesits classes.- A field without a selection set returns the value (or a list when there are several); with a
IRI's local name (http://example.org/Person → Person).
name (http://example.org/worksFor → worksFor).
selection set it follows the links to other resources.
GET /rest/repositories/{id}/graphql?sdl returns the derived schema in SDL.
Queries
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:
| Argument | Meaning |
|---|---|
id | one resource, by IRI |
where | equality on fields: where: {name: "Ann", city: "Ankara"} (all must hold) |
orderBy, desc | sort by a field's first value, descending with desc: true |
limit, offset | page 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.
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 } }'| Field | Arguments | Effect |
|---|---|---|
createT | id (optional), input, graph, type | adds 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 |
updateT | id, input, graph | replaces the values of each field in input; null removes them; other fields stay |
deleteT | id, graph | removes 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
- A list gives several values;
{id: "…"}is a link and{value: "…", lang: "tr"}or - A field the type does not have yet becomes a predicate in the class's namespace
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.
{value: "…", datatype: "…"} a literal spelled out.
(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.
graphrd serve --pg-port 5432
psql -h 127.0.0.1 -p 5432 -d zooThe 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
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();| Part | Supported |
|---|---|
| select list | , columns (p.name), col AS name, COUNT() |
WHERE | conditions joined with AND: =, <> / !=, <, <=, >, >=, LIKE (%, _), IS NULL, IS NOT NULL |
FROM | a 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 BY | one column (or a select-list AS name), ASC / DESC |
LIMIT, OFFSET | yes |
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.
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
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:
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;INSERTnames its columns;idis the IRI. A row whose id already is a resource of that tableUPDATEreplaces the values of the columns it sets (the row's other columns stay); theWHEREDELETEremoves 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
is an error.
is the same as in SELECT. The id cannot be set.
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 - OpenAI-style chat completions (
--llm-api openai): OpenAI, and the many servers and gateways
compatible gateway;
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:
| Option | Variable | Default | Meaning | |
|---|---|---|---|---|
--llm-url URL | GRAPHRD_LLM_URL | see below | the model's server; for Anthropic GraphRD adds /v1/messages, for OpenAI-style servers /chat/completions (so give the URL up to /v1) | |
--llm-key KEY | GRAPHRD_LLM_API_KEY | none | the server's API key: x-api-key for Anthropic, Authorization: Bearer for OpenAI-style servers | |
--llm-model NAME | GRAPHRD_LLM_MODEL | claude-opus-5-5 at Anthropic, gpt-5 at OpenAI; required for other servers | the model, as the server names it | |
| `--llm-api anthropic\ | openai` | GRAPHRD_LLM_API | from the URL or key | the wire format |
--llm-effort E | GRAPHRD_LLM_EFFORT | the model's default | low, medium, high, xhigh or max: how hard the model thinks (output_config.effort for Anthropic, reasoning_effort for OpenAI-style servers) | |
--llm-timeout S | 120 | time limit of one call to the model |
How they combine:
- Only a provider's key: with
ANTHROPIC_API_KEY(orOPENAI_API_KEY) in the environment and - An endpoint: the wire format is Anthropic's for a URL with
anthropicin it (Anthropic's - A key in
GRAPHRD_LLM_API_KEYwithout an endpoint: a key startingsk-ant-means Anthropic, ANTHROPIC_API_KEYandOPENAI_API_KEYare only ever sent to their provider's own API: with
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.
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.
any other OpenAI.
another endpoint, give its key with --llm-key or GRAPHRD_LLM_API_KEY.
# 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-5In 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):
| Request | Meaning |
|---|---|
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/llm | save 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/llm | remove the saved settings: back to the startup ones |
POST /rest/llm/test | call 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):
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}'{
"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(defaultfalse): also run the query and include its results (SPARQL JSON results) asattempts: 2 when the first query did not parse and the model corrected it.
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.
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:
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.
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-coderEach 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
| Format | Media types | Extensions | Read | Write |
|---|---|---|---|---|
| Turtle | text/turtle, application/x-turtle | .ttl | yes | yes |
| TriG | application/trig | .trig | yes | yes |
| N-Triples | application/n-triples, text/plain | .nt | yes | yes |
| N-Quads | application/n-quads, text/x-nquads | .nq | yes | yes |
| RDF/XML | application/rdf+xml, application/xml | .rdf, .owl, .xml | yes | yes |
| JSON-LD 1.1 | application/ld+json | .jsonld, .json | yes | yes |
| YAML-LD 1.0 | application/ld+yaml | .yamlld | yes | yes |
| Notation3 | text/n3 | .n3 | yes | without 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 >>, - 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
- JSON-LD 1.2 / RDF 1.2 triple terms (the JSON-LD-star draft): with
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.
graphrd convert --jsonld (processing mode, rdfDirection, generalized RDF, an expanded
context). An optional safe mode turns silently dropped keys into errors.
--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:
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:
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:
"@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 - Lenient loading (
graphrd load --lenient,?lenient=true) skips what does not parse:
true); N3 variables have no RDF equivalent and are errors.
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
| Format | Media type | For |
|---|---|---|
| SPARQL JSON | application/sparql-results+json (default) | SELECT, ASK |
| SPARQL XML | application/sparql-results+xml | SELECT, ASK |
| CSV | text/csv | SELECT, ASK |
| TSV | text/tab-separated-values | SELECT, ASK |
| SPARQL-star JSON | application/x-sparqlstar-results+json | SELECT with triple terms |
| any RDF format above | by 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:
{
"@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:
| Step | Call |
|---|---|
| create the repository | POST /rest/repositories with {"id":"demo"} |
| load JSON-LD | POST /repositories/demo/statements with Content-Type: application/ld+json |
| ask a question | POST /repositories/demo with Content-Type: application/sparql-query |
| read the graph | GET /repositories/demo/statements with Accept: application/ld+json |
Python, whole and runnable
Standard library only; every other language in examples/ does exactly this.
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, BobThe other folders
| Folder | One line about it |
|---|---|
examples/curl | the scene in five shell commands, no dependencies |
examples/python | the scene, standard library only |
examples/node | the scene on the built-in fetch (Node 18+) |
examples/go | the scene, standard library only |
examples/quarkus | MicroProfile 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.
| Layer | What is in it |
|---|---|
model | Employee, Company, SalaryReview — Lombok entities mapped with @RdfType/@RdfId/@RdfProperty |
repository | thin, typed wrappers over the mapper |
service | validation (duplicate email, raises above 25%), raises that leave a record, paging |
controller | companies, employees, reports |
config | timeouts 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:
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 ranThe 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.
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:
| Operation | Where it shows up |
|---|---|
| point read by IRI, filters, LIKE, ranges, IN, OR, EXISTS | the query languages, the library's criteria queries |
| joins between classes | the 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 DATA | every 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 answers | the 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.
| Ruleset | Derives |
|---|---|
empty | nothing (see query-time inference to infer without storing) |
rdf | RDF entailment: every predicate is an rdf:Property |
rdfs | RDFS 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 |
rdfsplus | rdfs, 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-horst | rdfsplus, plus owl:hasValue, owl:someValuesFrom, owl:allValuesFrom restrictions and owl:Class typing |
owl2-rl | the 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) |
*-optimized | the 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-max | alias of owl2-rl |
owl2-ql | alias 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
# 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 Cmatches what is stated of C and of its subclasses (rdfs:subClassOf,?x p ?ymatchespand its subproperties (rdfs:subPropertyOf,owl:equivalentProperty),
owl:equivalentClass), and the subjects and objects of properties whose domain or range is
one of them;
read backwards for owl:inverseOf and owl:SymmetricProperty, and transitively for an
owl:TransitiveProperty.
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:
// 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
}Preficesmaps prefixes to namespaces; IRIs are written<prefix:local>or as full IRIs.Axiomsare 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.
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):
| Ruleset | 0.9M triples: load | inferred | 8M triples: load | inferred |
|---|---|---|---|---|
empty | 1.4 s | 0 | 10.5 s | 0 |
rdfs-optimized | 2.3 s | 122,151 | 23.2 s | 1,110,151 |
rdfsplus-optimized | 3.8 s | 322,117 | 35.6 s | 2,610,107 |
owl2-rl-optimized | 4.4 s | 322,132 | 41.0 s | 2,610,122 |
RDF Rank
RDF Rank is PageRank over the links between resources (statements whose object is an IRI).
curl -X POST 'localhost:7200/rest/rank/compute?repository=zoo'
curl 'localhost:7200/rest/rank/status?repository=zoo' # EMPTY, COMPUTED or OUTDATEDPREFIX rank: <http://graphrd.org/rank#>
SELECT ?node ?score WHERE { ?node rank:score ?score } ORDER BY DESC(?score) LIMIT 10rank: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):
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.
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 10A query on a repository without the index fails with
Full-text search is not enabled for this repository (set enableFtsIndex).
Query syntax
| Query | Matches literals that contain |
|---|---|
graph database | every 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 network | either word |
graph -relational | graph 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>):
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.
| Parameter | Default | |
|---|---|---|
query (or q) | the full-text query | |
limit, offset | 10, 0 | the page of hits (limit at most 1000) |
facet | a predicate IRI; repeat for more facets (up to 100 values each, most frequent first) | |
pre, post | <b>, </b> | highlight markers |
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'{"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
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"}
]
}'| Key | Meaning |
|---|---|
type | opensearch or elasticsearch (the same API) |
url | the engine's base URL; https:// needs a build with the tls feature |
index | index name (lower case; default: the connector name) |
types | classes whose instances are indexed; inferred types count |
fields | document 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):
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
| Endpoint | Does |
|---|---|
GET /rest/repositories/{id}/connectors | every 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:
@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).
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).
Hybrid search
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.
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 10Or 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):
| Vectors | Indexing (graphrd load) | p50 | p95 | p99 | recall@10 |
|---|---|---|---|---|---|
| 100,000 | 22 s | 0.51 ms | 0.71 ms | 0.86 ms | 1.000 |
| 1,000,000 | 267 s | 0.82 ms | 1.47 ms | 2.07 ms | 0.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
| Datatype | Example |
|---|---|
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 IRI | System | Axis order |
|---|---|---|
http://www.opengis.net/def/crs/OGC/1.3/CRS84 | WGS 84 | longitude, latitude |
http://www.opengis.net/def/crs/EPSG/0/4326 (also 4258 ETRS89, 4269 NAD83) | WGS 84 | latitude, longitude, as EPSG defines it |
http://www.opengis.net/def/crs/EPSG/0/3857 | Web Mercator | x, y in metres |
http://www.opengis.net/def/crs/EPSG/0/326zz, 327zz | WGS 84 / UTM zone zz north, south | easting, 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
| Family | Functions |
|---|---|
| Simple Features relations | sfEquals, sfDisjoint, sfIntersects, sfTouches, sfCrosses, sfWithin, sfContains, sfOverlaps |
| Egenhofer relations | ehEquals, ehDisjoint, ehMeet, ehOverlap, ehCovers, ehCoveredBy, ehInside, ehContains |
| RCC8 relations | rcc8eq, rcc8dc, rcc8ec, rcc8po, rcc8tppi, rcc8tpp, rcc8ntpp, rcc8ntppi |
| DE-9IM | relate(a, b, "T*F**FFF*") |
| Set operations | intersection, union, difference, symDifference |
| Constructions | envelope, convexHull, centroid, boundary |
| Properties | dimension, coordinateDimension, spatialDimension, isEmpty, isSimple, getSRID, numGeometries, minX, minY, maxX, maxY |
| Serialization | asWKT, asGeoJSON |
| Measures | distance(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).
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 ?kmSpatial 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 geof:distance(?wkt, <geometry>[, unit]) < r(also<=, or writtenr > …).
the variable on either side: geof:sfWithin(?wkt, "POLYGON(…)"^^geo:wktLiteral);
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):
| Query | Index | Without index (same build) | Before the index |
|---|---|---|---|
sfWithin, 2 x 1 km area (6 rows) | 0.43 ms | 108 ms | 502 ms |
sfWithin, 17 x 11 km area (COUNT) | 2.9 ms | 114 ms | 512 ms |
distance < 5 km (321 rows) | 2.0 ms | 246 ms | 394 ms |
sfWithin of an area read from the data | 2.8 ms | 116 ms | 508 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 ?bas a triple pattern - Relations are planar in degrees; the index does not wrap across the antimeridian (a
distance
computed from geometries) is not implemented; use the functions in FILTER.
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
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:
@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 ] .curl -X POST 'localhost:7200/repositories/people/statements?context=%3Curn%3Agraphrd%3Ashapes%3E' \
-H 'Content-Type: text/turtle' --data-binary @shapes.ttlShapes 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).
| Kind | Supported |
|---|---|
| Targets | sh: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 ..." ] |
| Paths | a predicate IRI, sequence paths ( ex:a ex:b ), sh:inversePath, sh:alternativePath, sh:zeroOrMorePath, sh:oneOrMorePath, sh:zeroOrOnePath, nested |
| Cardinality | sh:minCount, sh:maxCount |
| Values | sh:datatype (ill-formed literals fail), sh:class, sh:nodeKind, sh:in, sh:hasValue |
| Ranges | sh:minInclusive, sh:maxInclusive, sh:minExclusive, sh:maxExclusive |
| Strings | sh:pattern (with sh:flags), sh:minLength, sh:maxLength, sh:languageIn, sh:uniqueLang |
| Property pairs | sh:equals, sh:disjoint, sh:lessThan, sh:lessThanOrEquals |
| Logic and shapes | sh:and, sh:or, sh:not, sh:xone, sh:node, sh:property, sh:qualifiedValueShape with sh:qualifiedMinCount / sh:qualifiedMaxCount / sh:qualifiedValueShapesDisjoint |
| Other | sh:closed with sh:ignoredProperties, sh:deactivated, sh:severity (sh:Violation, sh:Warning, sh:Info), sh:message |
| SPARQL | sh: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.
@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:
{
"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
curl localhost:7200/rest/repositories/people/shacl/validate # JSON
curl -H 'Accept: text/turtle' localhost:7200/rest/repositories/people/shacl/validate # sh:ValidationReportValidating 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):
graphrd validate --shapes shapes.ttl data.ttl more-data.ttlConformance
tests/w3c/shacl.py runs the W3C SHACL test suite through graphrd validate:
git clone --depth 1 https://github.com/w3c/data-shapes /tmp/data-shapes
uv run --with rdflib python3 tests/w3c/shacl.py /tmp/data-shapesA 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.
| Authority | Allows |
|---|---|
ROLE_ADMIN | everything: users, backup and restore, every repository |
ROLE_REPO_MANAGER | create, configure and delete repositories; read and write all of them |
ROLE_USER | sign 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):
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/aliceUsers 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
| Method | How |
|---|---|
| Running server | GET /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 server | graphrd 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 time | graphrd 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. |
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.tarInferred 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):
| Metric | Type |
|---|---|
graphrd_query_duration_seconds, graphrd_update_duration_seconds | histograms |
graphrd_wal_fsync_duration_seconds | histogram of write-ahead log fsyncs |
graphrd_query_errors_total, graphrd_update_errors_total, graphrd_slow_queries_total | counters |
graphrd_http_responses_total{code="2xx"} … | counters by status class |
graphrd_statements_loaded_total | statements added by uploads and imports |
graphrd_statements{repository="…"}, graphrd_repositories | gauges |
graphrd_resident_memory_bytes | resident memory (Linux) |
graphrd_cluster_leader, graphrd_cluster_term, graphrd_cluster_commit_index, graphrd_cluster_applied_index | cluster nodes only |
graphrd_uptime_seconds, graphrd_info{version} | gauges |
# 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:
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
- Reads are answered by the node that receives them, from its own copy, which may lag the
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.
leader by a moment. A read with the header X-GraphRD-Consistency: leader is forwarded to the
leader and sees every acknowledged write.
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.
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 - Every node holds all the data (no sharding).
the new list.
Storage
This page describes how a repository keeps its data: the files, the indexes, the dictionary,
durability and concurrency.
The data directory
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 importA 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
- Runs: a checkpoint writes the memtable as an immutable run file: a header, fixed-size
- Merging: runs of similar size are merged (size-tiered), so their number stays logarithmic in
- Reads merge the memtable levels and the runs, newest first. Blocks are read on demand through
frozen, immutable sorted levels of it.
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.
the data size. graphrd optimize merges everything into one run per index.
a shared LRU cache of about 16,000 decoded blocks (~70 MB at most).
Writes and durability
- A transaction (an update request, an upload, a delete) changes the memtables and appends its
- The log is fsynced before the client gets its answer (
--sync on, the default). Concurrent - A checkpoint (when the memtables hold 2 million entries, the log reaches 256 MB, or writes have
- On start, the log is replayed after the last checkpoint. A transaction without its commit marker
statements to wal.log, followed by a commit marker.
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.
stopped for a few seconds) writes run files, appends new terms to the dictionary, writes a new
MANIFEST atomically, and empties the log.
(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:
| Query | GraphRD (ms) |
|---|---|
| Q1 point lookup | 0.58 |
Q2 COUNT + FILTER on age | 1.86 |
Q3 GROUP BY company, top 5 | 2.55 |
Q4 AVG over a join, top 5 | 12.4 |
Q5 OPTIONAL + LIMIT 200 | 1.18 |
Q6 REGEX count | 7.56 |
Q7 property path knows+ | 16.6 |
Q8 ORDER BY DESC + LIMIT 10 over 100K | 10.0 |
| Q9 join count (20K) | 6.60 |
Q10 UNION count | 0.80 |
| Q11 date filter count | 8.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
| Setting | Effect |
|---|---|
graphrd load instead of HTTP uploads | the fastest way in: parallel parsing, no write-ahead log, one sorted run per index |
ruleset: empty or an -optimized ruleset | inference costs time on writes and space for inferred statements; the -optimized rulesets skip the axioms and resource typing nobody queries |
--sync off | writes take ~0.3 ms instead of an fsync; they survive a crash of the process, not a power loss |
| batching writes | one update or upload with many statements costs one fsync; concurrent writers share fsyncs anyway (group commit) |
graphrd optimize | after large deletes or many small loads: one run per index, no deletion markers |
infer=false | queries skip inferred statements when they do not need them |
LIMIT without ORDER BY | the query stops as soon as it has enough rows |
timeout / serve --timeout | bounds runaway queries |
unionDefaultGraph | true (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
| Tool | Measures |
|---|---|
bench/docker.sh up, then queries, cold, mem, load | fresh 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 bench | load, query latency, concurrent throughput and write latency on a synthetic graph |
graphrd compare / compare-report | any SPARQL 1.1 store through the standard protocols only |
bench/run.sh, bench/cluster-bench.sh | the GraphRD-only numbers, and a cluster |
graphrd query --explain true | the plan and time of one query |
GET /metrics, --slow-query | latency 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--authprints a warning: anyone who - With
--auth, every endpoint except/protocol,/rest/login,/rest/logout,
can reach the port could read and change data.
/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/loginreturnsAuthorization: Bearer <token>; tokens expire after 12 hours - API keys (
X-API-Key: grd_…orAuthorization: ApiKey grd_…) for programs: an - OpenID Connect access tokens (
Authorization: Bearer <JWT>) from an identity provider such as - LDAP: users that are not local sign in with their directory password, with
--ldap-url - The PostgreSQL port uses cleartext password authentication with the same users; use it on a
and end at logout or when the password changes.
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.
Keycloak, Entra ID, Okta or Auth0, with --oidc-issuer (below).
(below).
trusted network or behind TLS termination.
OpenID Connect
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
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).
{"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
| Input | Guard |
|---|---|
| HTTP | header size capped at 64 KB, body size and connection count bounded (set_limits), slow clients time out after 120 s |
| Queries | parser nesting and chain-length limits; regular expressions run in linear time (no catastrophic backtracking); timeout per request |
| Turtle, JSON | nesting limits, so deep documents cannot exhaust the stack |
| RDF/XML | entity expansion capped (no "billion laughs"), nesting limited, no external entities fetched |
| Repository ids, import file names | restricted to safe characters; server-side imports read only DATA/import/ |
| Panics | a 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.