SPARQL in Postgres 19: Knowledge Graphs Without a Second DB
A knowledge graph for an LLM application usually shows up as a second database: a triple store sitting next to Postgres, fed by an ETL job that copies facts out of your tables and goes stale the moment the job ends. Two security models, two backup schedules, two failover procedures, and no transaction that spans a row and a triple. On 23 October 2026, Igor Suhorukov published pg_knowledge_base, a PostgreSQL 19 extension that runs Oxigraph‘s SPARQL engine inside the server and keeps the triples in ordinary tables.
This is the most interesting extension to land in the Postgres AI tooling space this month, and it is worth an afternoon — not a production migration.
What it is: Oxigraph’s engine, Postgres’s storage
Oxigraph is a Rust graph database whose query stack is a set of small crates: spargebra parses SPARQL, sparopt optimises it, spareval evaluates it over anything implementing the QueryableDataset trait. pg_knowledge_base keeps that engine and replaces only the storage. The extension is C, built with PGXS; the engine is libkb_engine.so, Rust behind a C ABI. No core patches.
The conformance numbers are the reason to take it seriously: of the W3C SPARQL 1.1 query and update test cases, 990 pass and none fails; 11 differ exactly as Oxigraph’s own store differs (typed literals are kept as values, so "01"^^xsd:integer returns as 1), and 6 are skipped as Oxigraph skips them. The R2RML and Direct Mapping tests pass 95 of 95. That is a conformance claim, not a demo.
Installing it is a Rust build, not an apt package
The repository is one day old, tested against the official postgres:19rc1-trixie image, Oxigraph’s 0.6.0-dev branch and Rust 1.99. You build the engine, then the extension against PGXS; the endpoint needs shared_preload_libraries, the SQL functions do not.
make PG_CONFIG=/usr/lib/postgresql/19/bin/pg_config install
make PG_CONFIG=/usr/lib/postgresql/19/bin/pg_config install-engine \
KB_ENGINE=~/.cache/pg_accel/pg_knowledge_base/engine/lib/libkb_engine.so
CREATE EXTENSION pg_knowledge_base; -- installs into schema kb
Postgres 19 is still pre-GA and the schedule has slipped toward 19 November: benchmark on a scratch cluster now, plan production after GA, when packaging settles. The build also needs OpenSSL.
Triples beside your tables, in one query
Two ingestion paths exist. kb.load() reads Turtle, N-Triples, N-Quads, TriG, RDF/XML, JSON-LD and N3 into the store. kb.add_mapping() declares an existing table is RDF through an R2RML mapping, with nothing copied — every query reads the live rows.
SELECT kb.load($$
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.com/kb#> .
ex:Relational skos:prefLabel "Relational databases" ; skos:broader ex:Databases .
ex:PostgreSQL skos:prefLabel "PostgreSQL" ; skos:broader ex:Relational .
$$);
SELECT kb.add_mapping('hr', $$
@prefix rr: <http://www.w3.org/ns/r2rml#> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
<#Employee> rr:logicalTable [ rr:tableName "employees" ] ;
rr:subjectMap [ rr:template "http://example.com/emp/{id}" ; rr:class foaf:Person ] ;
rr:predicateObjectMap [ rr:predicate foaf:name ; rr:objectMap [ rr:column "name" ] ] .
$$);
Then one query walks the taxonomy in the store and pulls the names from the table. kb.sparql() is a set-returning function, so its output joins like any other relation:
SELECT e.dept, count(*) AS people
FROM employees e
JOIN kb.sparql($$
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX ex: <http://example.com/kb#>
SELECT DISTINCT ?id WHERE {
?person ex:hasSkill/skos:broader* ex:Databases .
BIND(STRAFTER(STR(?person), "/emp/") AS ?id)
}$$) AS t(id int) USING (id)
GROUP BY e.dept ORDER BY e.dept;
A pattern’s bound terms are inverted into SQL conditions — bind ?person to /emp/1 and the row is read by primary key. That SQL runs as the querying role, so table grants and row-level security apply to SPARQL too. This is what separates it from bolting on a separate graph database: kb.sparql_update() runs in the caller’s transaction, so an UPDATE employees and a SPARQL INSERT DATA commit or roll back together.
The endpoint your RAG service already speaks
SPARQL clients do not speak libpq, so the extension serves the SPARQL 1.1 Protocol and Graph Store Protocol on Oxigraph’s paths, prefixed by database name. Each request is a transaction, visible in pg_stat_activity and cancellable with pg_cancel_backend(); logins are checked against pg_hba.conf and ordinary role passwords.
kb.endpoint_listen_addresses = '*' # empty (the default) keeps the endpoint off
kb.endpoint_port = 7878
CREATE ROLE alice LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA kb TO alice;
GRANT SELECT ON ALL TABLES IN SCHEMA kb TO alice;
curl -u alice --data-urlencode [email protected] https://db:7878/company/query
Because the store is heap tables and btree indexes, replication and recovery come for free: a hot standby answers kb.sparql(), logical replication can publish the store’s tables, and pg_dump carries the knowledge base like any other.
The benchmark, read honestly
The author measured against Oxigraph’s RocksDB server on a 3,577,116-triple dataset shaped like the Berlin SPARQL Benchmark, medians of five runs on 32 cores:
- Nested-loop plans of index lookups ran 4-8x faster on Postgres, whose
shared_buffersheld the whole store. - A filtered, sorted top-10 over products: 14.9 ms on Oxigraph’s server, 2.81 ms through the endpoint, 2.58 ms through SQL.
- A full grouping over every offer by vendor country: 9,764 ms on Oxigraph, about 1,174 ms here.
- Loading is the weak spot: 43.8 s in one transaction, 19.1 s with
bulk => true, against 1.5 s for Oxigraph’s multithreaded bulk loader. - Disk is 1,613 MB (1,338 MB after a bulk load) against 324 MB — btree indexes lack RocksDB’s prefix compression.
Treat the query numbers as directional: Oxigraph ran its default 32 MB block cache while Postgres held the store in shared_buffers. The load and disk penalties, however, are structural.
Limits to design around
- R2RML is evaluated pattern by pattern: joins and aggregates over mapped tables run inside the SPARQL evaluator, not as one pushed-down SQL query — not Ontop-class federation.
- No
SERVICEfederation, and the endpoint speaks HTTP/1.1 with basic authentication only. - Row-level security applies to mapped tables but not to
kb.quads— a multi-tenant store needs partitioning or separate schemas. - The store gives the optimizer no statistics yet, so complex SPARQL may plan badly.
Houston angle: an ontology your ERP will not hold
The pattern that fits this city is equipment and vendor knowledge around energy operations: wells, pads, vendors, part numbers, certifications, and who can service what. RDF handles that shape better than a relational schema — skos:broader* walks a category tree to any depth in one query, while names, prices and lead times stay in the tables your ERP already writes. For a GraphRAG pipeline over maintenance history, graph traversal and vector lookup can live on the same connection: if you already run hybrid search with pgvector and tsvector, SPARQL becomes a third retrieval channel rather than a third database. If you are still choosing an index type for the vector side, start with HNSW vs DiskANN vs no graph at all first.
What to do this quarter
Spin up a postgres:19rc1 container, load one real table through R2RML, and try the two or three multi-hop questions your application answers today with application-side joins. If the queries get shorter and the numbers hold, the case for a second database weakens. Stay off production until 19 goes GA and the optimizer has statistics — a day of testing beats an architecture decision made on launch-day enthusiasm.
If you are weighing a knowledge graph, a vector store, or both against your Postgres estate, get a second opinion on your Postgres search architecture before you add another server.
