+1 (415) 649-9454

Ontology-Driven Knowledge Graphs in Neo4j: SKOS, OWL, and neosemantics in Practice

Every graph project eventually hits the same wall: the model in the database and the model in people's heads drift apart. A Customer node means one thing to the billing team and another to the risk team, two teams invent SUPPLIES and SUPPLIED_BY, and six months later nobody can say which labels are canonical. Neo4j's property graph is deliberately schema-flexible — that flexibility is why you chose it, and it is also why the drift happens.

An ontology fixes this without giving up flexibility. You keep your fast property graph for queries, and you add an explicit, queryable description of what the labels and relationships mean: classes, hierarchies, allowed relationships, synonyms. In 2026 there is a second reason to bother: an ontology is the most effective grounding artifact you can hand an LLM. Schema-aware text-to-Cypher and GraphRAG both get measurably better when the model can read a real taxonomy instead of guessing from label names.

This tutorial builds that out end to end: import a SKOS taxonomy and an OWL ontology with neosemantics, map them onto an existing property graph, enforce the parts worth enforcing, use the hierarchy in queries, and feed it to a retrieval agent.

What you need

  • Neo4j 2025.x or 2026.x (self-managed or Aura — note the plugin caveat below)
  • The neosemantics (n10s) plugin for RDF import
  • Any existing graph to attach the ontology to; the examples use a small supply-chain graph

Install n10s by dropping the jar matching your server version into plugins/ and allowing its procedures:

dbms.unmanaged_extension_classes=n10s.endpoint=/rdf
dbms.security.procedures.unrestricted=n10s.*
dbms.security.procedures.allowlist=n10s.*,apoc.*

On Aura you cannot install n10s. Two options: run the RDF conversion locally (or in a throwaway Docker container — see Getting Started with Neo4j 2026.x in Docker) and push the resulting nodes to Aura with a driver script, or parse the ontology in Python with rdflib and write it with parameterised Cypher. The rest of this tutorial works either way — only Step 2 and Step 3 are n10s-specific.

Step 1 — decide what the ontology is for

Before importing anything, write down which of these you actually want. They have very different costs:

GoalWhat you needCost
Shared vocabulary / documentationClass + relationship definitions as nodesLow
Hierarchical querying ("all Electronics, including subcategories")SCO / broader edges you traverseLow
Validation ("a SUPPLIES must go Supplier → Part")Constraints + a scheduled conformance queryMedium
LLM grounding for text-to-Cypher / GraphRAGOntology serialised into the promptLow
Full OWL reasoning (inferred types, transitivity, disjointness)External reasoner or materialised inferenceHigh

Neo4j does no OWL reasoning. Anything "inferred" is something you materialise yourself, usually as a scheduled Cypher job. Most engagements need the first four rows and stop there — and that is the right call. Teams that try to make the graph behave like a triple store lose the query performance they came for.

Step 2 — import a SKOS taxonomy with n10s

Start with the easy, high-value case: a controlled vocabulary of product categories in SKOS.

Initialise the n10s config once per database:

CALL n10s.graphconfig.init({
  handleVocabUris: 'MAP',        // map known vocabularies to friendly names
  handleMultival: 'ARRAY',
  keepLangTag: false,
  handleRDFTypes: 'LABELS'
});

CREATE CONSTRAINT n10s_unique_uri IF NOT EXISTS
FOR (r:Resource) REQUIRE r.uri IS UNIQUE;

Then import. n10s.rdf.import.fetch reads a URL; n10s.rdf.import.inline takes a string, which is much easier to keep in version control:

CALL n10s.rdf.import.inline('
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix cat:  <http://example.com/catalog#> .

cat:Electronics a skos:Concept ; skos:prefLabel "Electronics" .
cat:Semiconductors a skos:Concept ;
    skos:prefLabel "Semiconductors" ;
    skos:altLabel "Chips" ;
    skos:broader cat:Electronics .
cat:Microcontrollers a skos:Concept ;
    skos:prefLabel "Microcontrollers" ;
    skos:altLabel "MCUs" ;
    skos:broader cat:Semiconductors .
', 'Turtle');

The result is ordinary nodes and relationships — (:Concept {uri, prefLabel, altLabel})-[:broader]->(:Concept) — which is the whole point. No special query language, no separate triple store:

MATCH (c:Concept)-[:broader]->(parent:Concept)
RETURN c.prefLabel AS concept, parent.prefLabel AS broader
ORDER BY broader, concept;

For a real taxonomy (thousands of concepts from a standard like eCl@ss, UNSPSC, MeSH or SNOMED), fetch instead and batch the commits:

CALL n10s.rdf.import.fetch(
  'https://example.com/vocab/catalog.ttl', 'Turtle',
  { commitSize: 5000 }
);

Preview before committing anything you did not author yourself — n10s.rdf.preview.fetch returns the graph it would create, without writing:

CALL n10s.rdf.preview.fetch('https://example.com/vocab/catalog.ttl', 'Turtle');

Step 3 — import an OWL ontology of classes and relationships

The same procedures import OWL, but n10s.onto.import.* is purpose-built for it: it creates :Class, :Relationship and :Property nodes with SCO (subClassOf), SPO, DOMAIN and RANGE edges.

CALL n10s.onto.import.inline('
@prefix owl:  <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sc:   <http://example.com/supplychain#> .

sc:Organization a owl:Class ; rdfs:label "Organization" .
sc:Supplier  a owl:Class ; rdfs:label "Supplier"  ; rdfs:subClassOf sc:Organization .
sc:Customer  a owl:Class ; rdfs:label "Customer"  ; rdfs:subClassOf sc:Organization .
sc:Part      a owl:Class ; rdfs:label "Part" .
sc:Assembly  a owl:Class ; rdfs:label "Assembly" ; rdfs:subClassOf sc:Part .

sc:supplies a owl:ObjectProperty ;
    rdfs:label "SUPPLIES" ;
    rdfs:domain sc:Supplier ;
    rdfs:range  sc:Part .
sc:contains a owl:ObjectProperty ;
    rdfs:label "CONTAINS" ;
    rdfs:domain sc:Assembly ;
    rdfs:range  sc:Part .
', 'Turtle');

Now the schema is data you can query:

// What may legally point at what?
MATCH (r:Relationship)-[:DOMAIN]->(d:Class),
      (r)-[:RANGE]->(g:Class)
RETURN r.label AS relationship, d.label AS from, g.label AS to
ORDER BY relationship;

// Full class hierarchy, any depth (Cypher 25 quantified path pattern)
MATCH p = (c:Class)-[:SCO]->+(root:Class)
WHERE NOT (root)-[:SCO]->()
RETURN c.label AS class, [n IN nodes(p) | n.label] AS path;

If the ->+ syntax is new to you, it is the modern replacement for variable-length paths; see Quantified Path Patterns and SHORTEST k.

Step 4 — attach the ontology to your instance data

The ontology nodes are useless until your business nodes point at them. Two mappings, both cheap:

// Instance -> class
MATCH (s:Supplier), (c:Class {label: 'Supplier'})
MERGE (s)-[:INSTANCE_OF]->(c);

// Instance -> taxonomy concept, joined on your own category code
MATCH (p:Part)
MATCH (concept:Concept {uri: 'http://example.com/catalog#' + p.categoryCode})
MERGE (p)-[:HAS_CATEGORY]->(concept);

Index both ends so the joins stay fast:

CREATE INDEX part_category IF NOT EXISTS FOR (p:Part) ON (p.categoryCode);
CREATE INDEX concept_label IF NOT EXISTS FOR (c:Concept) ON (c.prefLabel);

Keep ontology nodes out of your application traversals by convention: they live behind INSTANCE_OF, HAS_CATEGORY and SCO, and an app query never crosses those edges unless it means to. If you find hot paths accidentally walking into the ontology, that is the supernode problem from Graph Model Debt — taxonomy roots and class nodes are, by construction, very high degree.

Step 5 — hierarchy-aware queries (the immediate payoff)

This is the query business users always ask for and a flat category column cannot answer: everything under a category, at any depth.

MATCH (root:Concept {prefLabel: 'Electronics'})
MATCH (root)<-[:broader]-*(sub:Concept)      // root plus all descendants
MATCH (part:Part)-[:HAS_CATEGORY]->(sub)
MATCH (s:Supplier)-[:SUPPLIES]->(part)
RETURN sub.prefLabel AS category,
       count(DISTINCT part) AS parts,
       count(DISTINCT s) AS suppliers
ORDER BY parts DESC;

Add synonym search so users can type the word they actually use ("chips", "MCUs") and still land on the right concept:

CREATE FULLTEXT INDEX concept_labels IF NOT EXISTS
FOR (c:Concept) ON EACH [c.prefLabel, c.altLabel];

CALL db.index.fulltext.queryNodes('concept_labels', $term)
YIELD node, score
MATCH (node)<-[:broader]-*(sub:Concept)<-[:HAS_CATEGORY]-(p:Part)
RETURN node.prefLabel AS matched, score, count(p) AS parts
ORDER BY score DESC LIMIT 5;

Step 6 — enforce what matters

Neo4j will not stop you writing a SUPPLIES edge from a Customer to an Assembly. So enforce in two layers.

At write time, use existence and type constraints plus node keys (Enterprise):

CREATE CONSTRAINT supplier_id IF NOT EXISTS
FOR (s:Supplier) REQUIRE s.id IS NODE KEY;

CREATE CONSTRAINT part_code_exists IF NOT EXISTS
FOR (p:Part) REQUIRE p.categoryCode IS NOT NULL;

CREATE CONSTRAINT part_code_type IF NOT EXISTS
FOR (p:Part) REQUIRE p.categoryCode IS :: STRING;

After the fact, run a conformance query in CI or on a schedule and fail the build on violations. This is the single most valuable thing the ontology buys an operations team:

// Edges whose endpoints violate the ontology's DOMAIN/RANGE
MATCH (r:Relationship)-[:DOMAIN]->(dom:Class), (r)-[:RANGE]->(rng:Class)
WITH r.label AS relType, dom.label AS domClass, rng.label AS rngClass
MATCH (a)-[e]->(b)
WHERE type(e) = relType
  AND NOT (
    (a)-[:INSTANCE_OF]->(:Class)-[:SCO]->*(:Class {label: domClass})
    AND (b)-[:INSTANCE_OF]->(:Class)-[:SCO]->*(:Class {label: rngClass})
  )
RETURN relType, labels(a) AS fromLabels, labels(b) AS toLabels, count(*) AS violations
ORDER BY violations DESC;

On a large graph, scope that per relationship type and run it against a sample or a restored backup rather than the live production instance. Drop it into the migration test suite from Neo4j in CI/CD and schema drift stops being something you discover in a dashboard six months late.

Step 7 — hand the ontology to the LLM

Text-to-Cypher fails mostly because the model does not know your vocabulary. db.schema.visualization tells it which labels exist; the ontology tells it what they mean and which combinations are legal. Serialise both into the prompt:

ONTOLOGY_QUERY = """
MATCH (r:Relationship)-[:DOMAIN]->(d:Class), (r)-[:RANGE]->(g:Class)
RETURN 'relationship' AS kind, r.label AS name,
       d.label AS lhs, g.label AS rhs, r.comment AS note
UNION
MATCH (c:Class)
OPTIONAL MATCH (c)-[:SCO]->(p:Class)
RETURN 'class' AS kind, c.label AS name,
       p.label AS lhs, null AS rhs, c.comment AS note
"""

def ontology_prompt(session):
    rows = session.run(ONTOLOGY_QUERY).data()
    classes = [r for r in rows if r["kind"] == "class"]
    rels = [r for r in rows if r["kind"] == "relationship"]
    lines = ["Classes (with parent class):"]
    lines += [
        f"- {c['name']}"
        + (f" is a {c['lhs']}" if c["lhs"] else "")
        + (f" -- {c['note']}" if c["note"] else "")
        for c in classes
    ]
    lines.append("Allowed relationships:")
    lines += [f"- (:{r['lhs']})-[:{r['name']}]->(:{r['rhs']})" for r in rels]
    return "\n".join(lines)

Two things this unlocks. First, generated Cypher stops inventing relationships, because the prompt enumerates the legal ones — pair it with the validation layer in Text-to-Cypher with Guardrails and you can reject any query using an edge type the ontology does not contain, before it ever runs.

Second, for GraphRAG the taxonomy gives you semantic expansion that embeddings do not: a question about "electronics suppliers" can be answered over parts categorised three levels down, because the retriever walks broader edges. Add the concept traversal to a VectorCypherRetriever query from Your First GraphRAG Pipeline:

// retrieval_query: `node` is each chunk returned by the vector index
MATCH (e:__Entity__)-[:FROM_CHUNK]->(node)
OPTIONAL MATCH (e)-[:HAS_CATEGORY]->(c:Concept)-[:broader]->*(broad:Concept)
RETURN node.text AS text,
       collect(DISTINCT e.name) AS entities,
       collect(DISTINCT broad.prefLabel) AS categories

Extraction benefits too: pass the ontology's class and relationship labels as the schema for SimpleKGPipeline and the LLM extracts into your vocabulary instead of inventing one of its own.

Step 8 — version it like code

An ontology that changes without a version number is worse than none, because downstream consumers cache assumptions. Practical rules we apply on client projects:

  1. The Turtle files live in the application repo, not only in the database.
  2. Import runs as a numbered migration, the same mechanism as index and constraint changes.
  3. Every :Class and :Relationship node carries an ontologyVersion property, and the conformance report states which version it validated against.
  4. Removing or narrowing a class is a breaking change — deprecate with owl:deprecated plus a replacedBy edge, and delete a release later.
  5. Export on demand for teams that want RDF back: CALL n10s.rdf.export.cypher('MATCH (n:Class)-[r]->(m) RETURN n,r,m'), or the /rdf endpoint.

Common mistakes

  • Importing a 200,000-concept industry standard whole. Import the branches you use. The rest is a very large, fast-growing set of nodes nobody queries.
  • Modelling instances as RDF resources. Keep business data as native property-graph nodes; the ontology describes them, it does not replace them.
  • Expecting inference. If you need subPropertyOf transitivity or inferred types in query results, materialise them on a schedule and document that they are materialised.
  • Ontology by committee, before any data. Import a 20-class draft, run it against real data with the conformance query, and let the violations tell you where the model is wrong. That loop takes a week; the committee takes a quarter.
  • No owner. An ontology is a product with a maintainer, or it is dead documentation in a new format.

Where this pays off

Master data management, regulatory and compliance graphs, product catalogues spanning several source systems, and any GraphRAG system where users ask questions in category terms rather than record terms. In each case the ontology is what lets a new team member — or a language model — ask the right question without a tribal-knowledge briefing first.

If you are standing up an ontology-driven knowledge graph, or you have a taxonomy in a spreadsheet and a property graph that has drifted away from it, our Neo4j consultants do this work regularly — see GraphRAG & knowledge graph AI consulting, or get in touch with the model you have.