Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jbarrasa/goingmeta/llms.txt

Use this file to discover all available pages before exploring further.

Session 15 of Going Meta (broadcast April 5, 2023) brings together ontology design, graph data, and interactive web apps. An OWL ontology for the movie domain is authored in Protégé and loaded into Neo4j alongside the movie dataset. A Streamlit application then uses the ontology’s class hierarchy and property definitions to drive a fully semantic browsing experience — users navigate by type, see inferred instances through class inheritance, and explore relationship definitions without writing any Cypher.

Watch Recording

Full session recording on YouTube

Source Code

Python app, Cypher setup scripts, and ontology file

Overview

BroadcastApril 5, 2023
TagsPython Ontology Streamlit Protege
Key librarygraphdatascience (Neo4j Python driver wrapper)
Ontology formatOWL/Turtle, authored in Protégé

What You Will Learn

  • Loading a movie graph from JSON using APOC and enriching it with ontology labels
  • Importing an OWL ontology into Neo4j with Neosemantics (n10s.onto.import.fetch)
  • Using n10s.inference.nodesLabelled to retrieve instances through class inheritance
  • Querying class hierarchies, property definitions, domain/range constraints from Cypher
  • Building a Streamlit app that reads the ontology to drive its own UI automatically
  • Exporting a named subgraph as RDF from the Neosemantics HTTP endpoint

Setting Up the Graph

1

Load the movie data

Use APOC JSON loading procedures to create Movie nodes and Person nodes with multiple labels (e.g. Actor, Director), then wire up all relationships including PLACE_OF_BIRTH.
// Load movies
CALL apoc.load.json(
  "https://raw.githubusercontent.com/jbarrasa/goingmeta/main/session15/data/movies.json"
) YIELD value
CREATE (:Movie { title: value.title, released: value.released, tagline: value.tagline });

// Load people with multiple labels and relationships
CALL apoc.load.json(
  "https://raw.githubusercontent.com/jbarrasa/goingmeta/main/session15/data/people.json"
) YIELD value
UNWIND value.people AS person
MERGE (p { name: person.name })
  ON CREATE SET p.born = person.born
WITH p, person
CALL apoc.create.addLabels(p, person.labels) YIELD node
FOREACH (mov IN person.directed  | MERGE (m:Movie {title: mov}) MERGE (m)<-[:DIRECTED]-(p))
FOREACH (mov IN person.acted     | MERGE (m:Movie {title: mov}) MERGE (m)<-[:ACTED_IN]-(p))
FOREACH (mov IN person.produced  | MERGE (m:Movie {title: mov}) MERGE (m)<-[:PRODUCED]-(p))
FOREACH (mov IN person.reviewed  | MERGE (m:Movie {title: mov}) MERGE (m)<-[:REVIEWED]-(p))
FOREACH (mov IN person.wrote     | MERGE (m:Movie {title: mov}) MERGE (m)<-[:WROTE]-(p))
FOREACH (loc IN person.pob       |
  MERGE (pl:Place {country: loc.country, region: loc.region, location: loc.location})
  MERGE (pl)<-[:PLACE_OF_BIRTH]-(p));
2

Import the OWL ontology

Configure Neosemantics to ignore namespace URIs (since we are matching by label name) and import the movie ontology from GitHub.
// Set up n10s to ignore URIs — labels and relationship types are used as-is
CALL n10s.graphconfig.init({ handleVocabUris: "IGNORE" });

// Import the ontology — Class, Property, and Relationship nodes are created
CALL n10s.onto.import.fetch(
  "https://raw.githubusercontent.com/jbarrasa/goingmeta/main/session15/onto/complete-movies-goingmeta.ttl",
  "Turtle",
  { languageFilter: "en" }
);
After import, the graph contains both data nodes (e.g. :Movie, :Actor) and ontology nodes (:Class, :Property, :Relationship) linked by :SCO (subClassOf), :DOMAIN, :RANGE, and similar ontology edges. The Streamlit app reads from both layers.
3

Verify the semantic layer

A quick schema exploration shows why using the ontology is more informative than db.schema.visualization().
// Raw schema — output can be confusing for large graphs
CALL db.schema.visualization();

// Semantic layer query — a cleaner actor-movie-director-birthplace pattern
MATCH pattern =
  (a:Actor)-[:ACTED_IN]->(:Movie)<-[:DIRECTED]-(:Director)-[:PLACE_OF_BIRTH]->(pob)
                         <-[:PLACE_OF_BIRTH]-(a)
RETURN pattern LIMIT 2;

The Streamlit Application

The semantic.py application connects to Neo4j using the graphdatascience driver and uses three Cypher-powered helper functions to build a fully ontology-driven UI.

Listing Available Categories

import streamlit as st
from graphdatascience import GraphDataScience

neo = GraphDataScience("neo4j://localhost:7687", auth=("neo4j", "neoneoneo"), database="movies2")

def list_categories():
    query = """
    CALL db.labels() YIELD label
    MATCH hierarchy = (:Class { name: label })-[:SCO*0..]->(p)
    WHERE size([(p)-[s:SCO]->() | s]) = 0
    UNWIND [n IN nodes(hierarchy) | n.name] AS name
    RETURN DISTINCT name
    """
    return neo.run_cypher(query)
This query finds every node label in the database that corresponds to an ontology class, then traverses the SCO hierarchy up to the root to return a flat de-duplicated list. Only classes actually present as node labels are shown.

Fetching Class Details

def cat_details(cat_name):
    query = """
    MATCH (c:Class { name: $name })
    OPTIONAL MATCH (c)-[:SCO*0..]->()<-[:DOMAIN]-(outgoing_rel:Relationship)-[:RANGE]->(related_cat:Class)
    WITH c, collect({ name: outgoing_rel.name, comment: coalesce(outgoing_rel.comment,''),
                      other: related_cat.name }) AS outgoing
    OPTIONAL MATCH (c)-[:SCO*0..]->()<-[:RANGE]-(incoming_rel:Relationship)-[:DOMAIN]->(related_cat:Class)
    WITH c, [x IN outgoing WHERE x.name IS NOT NULL | x] AS outgoing,
         collect({ name: incoming_rel.name, comment: coalesce(incoming_rel.comment,''),
                   other: related_cat.name }) AS incoming
    OPTIONAL MATCH (c)-[:SCO*0..]->()<-[:DOMAIN]-(prop:Property)-[:RANGE]->(datatype)
    WITH c, outgoing, [x IN incoming WHERE x.name IS NOT NULL | x] AS incoming,
         collect({ name: prop.name, comment: coalesce(prop.comment,''),
                   type: datatype.name }) AS props
    RETURN c.name AS name, coalesce(c.comment,'') AS def, outgoing, incoming,
           [x IN props WHERE x.name IS NOT NULL | x] AS props
    """
    result = neo.run_cypher(query, params={ "name": cat_name })
    return result.iloc[0]
The [:SCO*0..]->() pattern is key: it traverses the class hierarchy upward so that properties and relationships defined on a parent class are automatically inherited by child classes.

Retrieving Instances with Semantic Inference

def cat_instances(cat_info):
    query_parts = [" CALL n10s.inference.nodesLabelled($name) YIELD node RETURN id(node) AS id"]
    for prop in cat_info['props']:
        query_parts.append(
            " node['" + prop['name'] + "'] as `" + prop['name'] + "`"
        )
    for outgoing in cat_info['outgoing']:
        query_parts.append(
            "size([(node)-[:`" + outgoing['name'] + "`]->(x:`" + outgoing['other'] +
            "`)|x]) + ' " + outgoing['other'] + "' as `" +
            outgoing['name'] + " - " + outgoing['other'] + "`"
        )
    return neo.run_cypher(','.join(query_parts), params={ "name": cat_info['name'] })
n10s.inference.nodesLabelled is the semantic inference procedure from Neosemantics: it returns all nodes labelled with the named class or any of its subclasses, enabling queries like “show me all Person instances” to automatically include Actor, Director, and ScreenWriter nodes.

Streamlit UI Assembly

st.header('Semantic Explorer')
cats = list_categories()
if cats.empty:
    st.error("No ontology found")
else:
    selected_class = st.radio(
        "In this DB you will find nodes of the following types 👇",
        [row['name'] for index, row in cats.iterrows()],
        horizontal=True
    )
    if selected_class:
        class_info = cat_details(selected_class)
        with st.sidebar:
            st.markdown("# **:blue[" + selected_class + "]** ")
            st.markdown("_" + class_info['def'] + "_")
            st.markdown("**Properties:**")
            for prop in class_info['props']:
                st.markdown("**:blue[" + prop['name'] + "]** _(" + prop['type'] + ")_ : " + prop['comment'])
            st.markdown("**Relationships:**")
            for outgoing in class_info['outgoing']:
                st.markdown("➡️ **:blue[" + outgoing['name'] + "]** _(connects " +
                             selected_class + " to " + outgoing['other'] + ")_ : " + outgoing['comment'])
            for incoming in class_info['incoming']:
                st.markdown("⬅️ **:blue[" + incoming['name'] + "]** _(connects " +
                             incoming['other'] + " to " + selected_class + ")_ : " + incoming['comment'])
        st.markdown("### Instances of **:blue[" + selected_class + "]**:")
        st.dataframe(cat_instances(class_info))
Launch the app from the terminal with streamlit run semantic.py. Make sure the Neo4j instance is running and the movies database is loaded before starting.

Exporting as RDF

The Neosemantics HTTP endpoint can serialize any Cypher result as RDF. Here is an example exporting the subgraph of screenwriters connected to Top Gun:
POST http://localhost:7474/rdf/movies/cypher

{
  "cypher": "match subgraph = (sw:ScreenWriter)-[:WROTE]->(:Movie { title: 'Top Gun' }) return subgraph"
}

Key Concepts

Ontology-driven UI — The Streamlit app never hard-codes class names or property lists. Every element of the interface is derived from the ontology stored in Neo4j. Adding a new class to the ontology automatically makes it browsable in the app without any code changes. n10s.inference.nodesLabelled — This procedure implements basic RDFS-style class inference. Querying for instances of Person transparently includes all nodes that carry a subclass label (Actor, Director, etc.), giving users a semantically complete view without requiring complex UNION queries. [:SCO*0..]->() traversal — The zero-or-more SCO (subClassOf) path pattern propagates inherited properties and relationships from parent classes down to their subclasses within the same Cypher statement.
The movie ontology was designed in Protégé, a free, open-source OWL ontology editor from Stanford. The .ttl file exported from Protégé is loaded directly into Neo4j via Neosemantics without any conversion step.

Resources

Streamlit Documentation

Build interactive data apps in pure Python

Neosemantics (n10s)

RDF and ontology integration for Neo4j

Protégé Ontology Editor

Free OWL ontology editor from Stanford University

Neo4j GraphDataScience Python Client

Official Python client for Neo4j GDS and Cypher

Build docs developers (and LLMs) love