Build a Semantic Data App with Streamlit and Neo4j
Build a Streamlit app that semantically browses an ontology-enriched Neo4j movie graph, using class hierarchies from an OWL ontology authored in Protégé.
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
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 moviesCALL apoc.load.json( "https://raw.githubusercontent.com/jbarrasa/goingmeta/main/session15/data/movies.json") YIELD valueCREATE (:Movie { title: value.title, released: value.released, tagline: value.tagline });// Load people with multiple labels and relationshipsCALL apoc.load.json( "https://raw.githubusercontent.com/jbarrasa/goingmeta/main/session15/data/people.json") YIELD valueUNWIND value.people AS personMERGE (p { name: person.name }) ON CREATE SET p.born = person.bornWITH p, personCALL apoc.create.addLabels(p, person.labels) YIELD nodeFOREACH (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-isCALL n10s.graphconfig.init({ handleVocabUris: "IGNORE" });// Import the ontology — Class, Property, and Relationship nodes are createdCALL 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 graphsCALL db.schema.visualization();// Semantic layer query — a cleaner actor-movie-director-birthplace patternMATCH pattern = (a:Actor)-[:ACTED_IN]->(:Movie)<-[:DIRECTED]-(:Director)-[:PLACE_OF_BIRTH]->(pob) <-[:PLACE_OF_BIRTH]-(a)RETURN pattern LIMIT 2;
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.
import streamlit as stfrom graphdatascience import GraphDataScienceneo = 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.
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.
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.
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.
The Neosemantics HTTP endpoint can serialize any Cypher result as RDF. Here is an example exporting the subgraph of screenwriters connected to Top Gun:
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.