Ontology in Graph Databases: Lessons from Building a Medical Diagnostics Knowledge Graph
Ontology in a graph database is not an academic exercise — it is the difference between a graph that answers questions and a graph that is a tangled hairball. An ontology defines what kinds of things exist in your domain, what properties they carry, and how they relate to one another. Without one, your graph grows without structure and querying becomes guesswork.
The LeadGraph project offers a concrete, production-grade example of ontology design. It is a market intelligence platform for Siemens Healthineers that ingests data from 15 external sources (FDA 510(k) clearances, clinical trials, patents, conference exhibitor lists, grant databases, GitHub repositories, and more), normalises everything into a Neo4j knowledge graph, and scores companies by commercial relevance.
This article walks through the ontology decisions in LeadGraph and extracts the general principles that apply to any graph database project.
What Ontology Means in Neo4j
In Neo4j, ontology manifests as:
- Node labels —
:Company,:Product,:Application,:Signal. These are your entity types. - Property constraints —
:Company.normalizedNameis unique;:Signal.typeis indexed. - Relationship types —
SUPPLIES,DEVELOPS,HAS_SIGNAL,USED_IN. These encode the semantic edges between entities. - Value ranges —
:Signal.confidenceis a float from 0 to 1;:Signal.typeis one of 12 enumerated signal types.
A well-designed ontology answers three questions: what exists, what matters about it, and how it connects.
The LeadGraph Ontology
The ontology is seeded explicitly — not discovered. The seedGraph() function in ontology.ts creates constraints, nodes, and relationships in a single transaction-friendly batch:
CREATE CONSTRAINT IF NOT EXISTS FOR (c:Company) REQUIRE c.normalizedName IS UNIQUE;
CREATE CONSTRAINT IF NOT EXISTS FOR (a:Application) REQUIRE a.name IS UNIQUE;
CREATE INDEX IF NOT EXISTS FOR (s:Signal) ON (s.type);
These three lines capture the ontology's backbone: every company must have a unique normalised name, every application area must be a named entity, and signals must be queryable by type.
Node Labels and Their Semantics
The ontology defines seven node labels:
| Label | Purpose | Key Properties | Uniqueness |
|---|---|---|---|
Company | An organisation in the diagnostics market | name, normalizedName, domain, segment, region | normalizedName |
Product | A specific Siemens product or reagent | catalogId, name, category | catalogId |
Application | A clinical application area | name, category | name |
Signal | An external indicator of activity | type, date, confidence, description, url | none (event) |
Contact | A person at a company | name, email, role | none |
PipelineStage | A sales pipeline milestone | stage, enteredAt | none |
Activity | A logged interaction | type, note, date | none |
The distinction between Company and Product is straightforward. The interesting design choice is Application as a separate node rather than a property on Company. This enables multi-hop traversal: you can find companies that develop assays in the same application area as a given Siemens product — a query that would be expensive with property-based filtering on a relationship.
Relationship Semantics
The six relationship types encode the domain's business logic:
(:Company)-[:SUPPLIES]->(:Product) // Siemens manufactures this product
(:Company)-[:DEVELOPS]->(:Application) // Company works in this application area
(:Company)-[:HAS_SIGNAL]->(:Signal) // Company triggered this external signal
(:Product)-[:USED_IN]->(:Application) // Product is relevant to this clinical area
(:Contact)-[:CONTACT_AT]->(:Company) // Person works at this organisation
(:Contact)-[:HAS_ACTIVITY]->(:Activity) // Person had an interaction
(:Contact)-[:IN_STAGE]->(:PipelineStage) // Current pipeline status
The DEVELOPS relationship is where the ontology does its heaviest lifting. Every external data point — an FDA clearance, a conference appearance, a new hire — is mapped through the application area classification. When the scoring engine runs, it computes productFitScore as the overlap ratio between a company's application areas and Siemens' product portfolio:
// scorer.ts (simplified)
const overlap = company.applications.filter(
app => siemensApps.has(app)
).length;
const productFitScore = (overlap / siemensApps.size) * 30; // [0-30]
This query works because the ontology maintains (:Company)-[:DEVELOPS]->(:Application) and (:Product)-[:USED_IN]->(:Application) as first-class paths.
The Signal Normalisation Pattern
The most instructive design in LeadGraph is the signal normalisation pattern. The project ingests from 15 wildly different data sources:
| Source | Raw Data | Normalised to |
|---|---|---|
| FDA 510(k) API | Medical device clearance records | Signal { type: "FDA_CLEARANCE", confidence, date } |
| ClinicalTrials.gov | Trial registry entries | Signal { type: "CLINICAL_TRIAL", ... } |
| EPO OPS (patents) | European patent filings | Signal { type: "PATENT", ... } |
| MEDICA API | Trade fair exhibitor list | Signal { type: "CONFERENCE", ... } |
| GitHub Search API | Repositories matching diagnostic keywords | Signal { type: "RESEARCH_PUBLICATION", ... } |
| FÖKAT (grants) | German government research grants | Signal { type: "FUNDING", ... } |
| DRKS (clinical trials DE) | German clinical trial registry | Signal { type: "CLINICAL_TRIAL", ... } |
Each source has an adapter implementing a common interface:
interface SourceAdapter {
readonly id: string;
fetch(): Promise<RawLead[]>;
normalize(raw: RawLead): LeadCandidate;
healthCheck(): Promise<boolean>;
}
The normalize() method is where each adapter maps its domain-specific data into the shared ontology. An FDA 510(k) clearance becomes:
{
companyName: "Euroimmun AG",
domain: "euroimmun.com",
applicationAreas: ["Autoimmune Diagnostics"],
signals: [{
type: "FDA_CLEARANCE",
date: "2025-12-10",
confidence: 0.9,
description: "FDA 510(k) for Euroimmun Anti-dsDNA ELISA"
}]
}
A MEDICA trade fair listing becomes:
{
companyName: "DIARECT AG",
applicationAreas: ["Autoimmune Diagnostics"],
signals: [{
type: "CONFERENCE",
date: "2025-10-15",
confidence: 0.6,
description: "Presenting new autoimmune panel at ADLM 2025"
}]
}
Different raw data, same ontology. This is the central value of a well-designed graph ontology: it makes disparate data sources queryable through a single model.
Why Confidence Is Part of the Ontology
Notice the confidence field on every signal. The ontology encodes uncertainty because not all data sources are equally reliable. An FDA clearance (confidence 0.9) is a stronger signal than a news article mentioning a company (confidence 0.5). By embedding confidence as a property, the scoring engine can weight signals by source reliability:
const signalScore = signals.reduce((sum, s) =>
sum + (weights[s.type] ?? 1) * s.confidence * recencyMultiplier(s.date), 0
); // [0-40]
This is a deliberate ontology decision: uncertainty is a first-class property of your data model, not an afterthought.
What Makes a Good Graph Ontology
Drawing from the LeadGraph example and general graph database practice, here are the principles:
1. Entities Are Nodes, Values Are Properties
A common mistake is storing important domain concepts as properties on other nodes. In LeadGraph, Application is a separate node, not a string array on Company. This seems trivial but has major implications:
- You can query all companies in an application area without full scans.
- You can attach metadata to the application area itself (market size, growth rate).
- You can join through application areas across different entity types (companies and products).
Rule of thumb: if you query by it, filter on it, or join through it, it should be a node.
2. Relationships Are Named, Not Tagged
Another common anti-pattern is using a generic RELATED_TO relationship with a type property to distinguish semantics. This forces every query to filter by property values, destroying performance and readability.
LeadGraph uses distinct relationship types (DEVELOPS, SUPPLIES, HAS_SIGNAL) that are self-documenting and indexable.
3. The Ontology Must Be Explicitly Seeded
LeadGraph seeds its ontology in code — the application areas and Siemens product portfolio are defined as TypeScript arrays:
const APPLICATION_AREAS = [
"Hemostasis & Thrombosis",
"Plasma Proteins",
"Infectious Disease & Serology",
"Cardiac Markers",
"Oncology & Tumor Markers",
"Autoimmune Diagnostics",
"Specialty Proteins & Reagents",
];
This is not an accident. An ontology that emerges organically from data is rarely coherent. Explicit seeding means every node label, relationship type, and property constraint is a conscious design decision.
4. External Data Must Be Normalised, Not Imported Raw
The adapter pattern is critical. Each data source maps to the shared ontology via normalize(). If you import raw FDA data in FDA's schema and raw patent data in the patent office's schema, you have not built a knowledge graph — you have a data lake.
5. Constraints Are Ontology Enforcement
Neo4j constraints (REQUIRE ... IS UNIQUE) are not optional performance hints. They enforce the ontology at the database level. If two source adapters produce a company with the same name but different spellings, the normalizedName constraint catches the collision. The normalizeCompanyName() function handles deduplication:
export function normalizeCompanyName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9\s]/g, "")
.replace(/gmbh|ag|limited|ltd|inc|corp|llc/g, "")
.trim()
.replace(/\s+/g, "-");
}
Common Ontology Mistakes
From building and iterating on the LeadGraph ontology, the recurring pitfalls are:
Over-normalisation. Splitting everything into nodes creates traversal hell. A company's region (EUROPE, NORTH_AMERICA) is a property, not a node — querying "all companies in Europe" requires only a property index, not a three-hop traversal.
Under-normalisation. Storing application areas as a comma-separated string on Company loses the ability to query across the application dimension. The rule of thumb above applies.
Ignoring time. Signals have dates, contacts have activity timestamps, pipeline stages have entry dates. If your ontology does not model time, your graph cannot answer "what changed."
Treating all relationships as equal. A HAS_SIGNAL relationship with a type property on the relationship is different from a named HAS_SIGNAL relationship with a Signal node that has a type property. The latter allows the signal to have its own properties (confidence, date, description) and to exist independently of the relationship.
Testing the Ontology
LeadGraph tests the ontology through integration tests that seed the graph and verify query results:
// From neo4j.test.ts
test("seeded ontology has correct structure", async () => {
const result = await seedGraph();
expect(result.constraintsCreated).toBe(3);
expect(result.companiesSeeded).toBeGreaterThan(1);
expect(result.applicationAreas).toBe(7);
expect(result.productsSeeded).toBeGreaterThan(0);
});
More importantly, the scoring tests validate that the ontology supports the expected queries:
test("product fit score uses application overlap", async () => {
const company = { applications: ["Hemostasis & Thrombosis"] };
const siemensApps = new Set(["Hemostasis & Thrombosis", "Cardiac Markers"]);
const score = computeProductFit(company, siemensApps);
expect(score).toBeGreaterThan(0);
});
If the ontology changes, these tests fail — providing a safety net for schema evolution.
Property Graph Ontology vs. Formal Ontology (RDF/OWL)
A question that arises naturally after building a property graph ontology is: how does this compare to formal ontology languages like RDF and OWL? The short answer is that property graphs and RDF/OWL occupy different points on the formality spectrum, and the right choice depends on whether your priority is operational query performance or semantic interoperability.
The Fundamental Difference
Property graph ontologies (Neo4j's model) are implicit. The ontology lives in the application code — the node labels, relationship types, and property constraints are conventions enforced by your application layer and database constraints. There is no formal language for declaring that :Company is a subclass of :Organisation, or that DEVELOPS is the inverse of DEVELOPED_BY.
RDF/OWL ontologies are explicit. They use formal languages (RDFS, OWL, SKOS) to declare classes, properties, axioms, and inference rules. A triple store reasons over these declarations automatically — if you assert that DEVELOPS rdfs:subPropertyOf :isInvolvedWith, the store infers that any DEVELOPS triple also implies isInvolvedWith.
| Dimension | Property Graph (Neo4j) | RDF/OWL (triple store) |
|---|---|---|
| Ontology representation | Implicit — labels, types, constraints in application code | Explicit — RDFS/OWL declarations stored as triples |
| Schema enforcement | Database constraints (UNIQUE, INDEX, NODE KEY) | OWL axioms, SHACL shapes, SPIN rules |
| Inference | Manual — join logic in queries or application code | Automatic — reasoners infer implicit triples |
| Query language | Cypher (pattern matching) | SPARQL (graph patterns + reasoning) |
| Interoperability | Proprietary — no standard exchange format for ontology | W3C standards — RDF, OWL, SKOS are vendor-neutral |
| Performance at scale | Excellent — traversal over indexed property graph | Variable — reasoning overhead grows with axiom complexity |
| Best for | Operational applications, real-time queries, OLTP | Data integration, linked data, semantic interoperability |
When to Push Your Property Graph Ontology Toward RDF
There are situations where a pure property graph ontology is insufficient and layering on RDF/OWL techniques adds value:
Cross-domain data integration. If your graph must merge ontologies from multiple independent sources — for example, combining a medical ontology (SNOMED CT) with a corporate registry (legal entity types) — RDF's URI-based naming avoids collisions. Two different concepts called :Product in different systems remain distinguishable as distinct URIs.
Inference-heavy queries. Suppose you define that :CardiacMarkers is a subclass of :Application. In Neo4j, querying "all applications" requires knowing to include :CardiacMarkers. This knowledge lives in your application code. In an OWL ontology, the query ?app rdf:type :Application automatically includes subclasses through the reasoner. Neo4j can approximate this with a label hierarchy pattern — using multiple labels (:Application:CardiacMarkers) on the same node — but it is a convention, not an inference rule.
Long-term schema governance. If your knowledge graph has a lifespan measured in years with multiple teams contributing, an explicit ontology document (an OWL file or a SHACL shapes graph) provides a single source of truth that application code alone cannot.
The Neosemantics Bridge
The neosemantics (n10s) plugin bridges the two worlds. It allows Neo4j to import RDFS/OWL/SKOS ontologies as property graphs and export property graphs back to RDF. This means you can:
- Define your ontology in a formal OWL file using tools like Protégé.
- Import it into Neo4j via
CALL n10s.onto.import.fetch("file:///ontology.owl", "RDF/XML"). - Use the imported labels and relationship types as the backbone of your property graph.
- Optionally export subsets back to RDF for exchange with external systems.
This pattern is especially useful for regulated industries (medical devices, financial compliance) where an explicit, auditable ontology is a regulatory requirement rather than an engineering preference.
Decision Framework
| Your Situation | Recommended Approach |
|---|---|
| Single team, single domain, operational app | Property graph ontology (Neo4j with constraints) |
| Multiple teams contributing to one KG | Property graph ontology + n10s for an OWL mapping layer |
| Cross-organisation data exchange | RDF/OWL triple store, or Neo4j + n10s export |
| Inference-heavy, class hierarchy queries | OWL reasoner, or Neo4j with label hierarchy convention |
| Regulated environment requiring auditable ontology | OWL file as source of truth, imported via n10s |
The LeadGraph project sits firmly in the property graph camp, which is the right choice for a single-team operational intelligence platform. The ontology is enforced through TypeScript code and Cypher constraints — explicit enough to be testable, implicit enough to avoid the overhead of a formal reasoner.
Ontology Versioning and Evolution
An ontology is not static. As LeadGraph added data sources, the original seven node labels and six relationship types evolved. New signal types were introduced, application areas were split, and property constraints were tightened. Managing this evolution without breaking existing queries is a distinct engineering challenge that deserves explicit treatment.
The Problem with Schema-Less Evolution
Because Neo4j does not enforce a schema at the database level (labels and relationship types are created on first use), it is easy to drift into an inconsistent state. Consider what happens when a developer adds a new signal type without updating the ontology definition:
// Old ontology: signals have type, date, confidence
CREATE (:Signal {type: "NEWS_ARTICLE", date: "2026-06-01", confidence: 0.5})
// But the new source also includes a `sourceUrl` property
CREATE (:Signal {type: "NEWS_ARTICLE", date: "2026-06-01", confidence: 0.5, sourceUrl: "https://..."})
Now some Signal nodes have sourceUrl and others do not. The ontology has drifted. Queries that expect uniform structure either break silently or produce incomplete results.
Four Strategies for Ontology Versioning
1. Schema Constraints as Contracts. Neo4j's NODE KEY constraint is more powerful than UNIQUE — it enforces that every node with a given label must have a specified set of properties. For LeadGraph, adding a node key for Signal would prevent property drift:
CREATE CONSTRAINT IF NOT EXISTS FOR (s:Signal)
REQUIRE (s.type, s.date, s.confidence) IS NODE KEY;
This constraint rejects any CREATE or MERGE that omits or adds properties beyond the key set. The trade-off is reduced flexibility — you must update the constraint before adding new properties.
2. Ontology Migration Scripts. Treat ontology changes like database migrations. Each change gets a versioned script that applies the mutation atomically:
// migration_003.cql — Add sourceUrl to Signal nodes
// Applied: 2026-06-15
// Step 1: Add the property to existing signals
MATCH (s:Signal)
WHERE s.sourceUrl IS NULL
SET s.sourceUrl = '';
// Step 2: Update the node key constraint
DROP CONSTRAINT IF EXISTS FOR (s:Signal) REQUIRE (s.type, s.date, s.confidence) IS NODE KEY;
CREATE CONSTRAINT IF NOT EXISTS FOR (s:Signal)
REQUIRE (s.type, s.date, s.confidence, s.sourceUrl) IS NODE KEY;
// Step 3: (Optional) Re-index if needed
CREATE INDEX IF NOT EXISTS FOR (s:Signal) ON (s.sourceUrl);
Version-controlled migration scripts make ontology history auditable and reversible.
3. Ontology Metadata in the Graph. For long-lived knowledge graphs, store the ontology version as metadata inside the graph itself:
CREATE (v:OntologyVersion {
version: "2.1.0",
appliedAt: datetime("2026-06-15T10:00:00Z"),
changes: ["Added sourceUrl to Signal node key", "Split Autoimmune Diagnostics into sub-application areas"],
migrationScript: "migration_003.cql"
});
This is useful for debugging — when a query returns unexpected results, check which ontology version was active when the data was ingested.
4. Backward-Compatible Query Views. When deprecating a label or relationship type, maintain backward compatibility through a transition period by creating a virtual mapping layer:
// Old applications were single labels. New ontology uses sub-areas.
// Maintain compatibility by querying through a union:
MATCH (c:Company)-[:DEVELOPS]->(app)
WHERE app.name IN ["Autoimmune Diagnostics"]
OR (app:Application AND exists((app)-[:SUB_AREA_OF]->(:Application {name: "Autoimmune Diagnostics"})))
RETURN c.name, app.name
As deprecated patterns phase out, the compatibility layer shrinks until it can be removed entirely.
Practical Versioning Workflow
| Phase | Action | Verification |
|---|---|---|
| Proposal | Write migration script in a PR; include expected query changes | Code review confirms backward compatibility |
| Staging | Run migration against a snapshot of production; compare query results | Integration tests pass; slow queries identified |
| Deployment | Apply migration; update ontology metadata node | CALL db.schema.visualization() confirms new structure |
| Monitoring | Track error rates for queries using deprecated patterns | Alert if query latency increases by more than 10% |
| Cleanup | Remove compatibility views after transition period | Remove deprecated labels; verify no queries reference them |
LeadGraph adopted the migration-script approach (strategy 2) after an incident where an unconstrained property addition caused a scoring query to return incorrect results. The migration scripts live alongside the source adapters in the repository and are applied as part of the deployment pipeline.
Summary
The LeadGraph project demonstrates that ontology design is the single most impactful decision in a graph database project. It determines what queries are possible, how performant they are, and whether the graph remains coherent as new data sources are added.
The key takeaways for building your own graph ontology:
- Explicitly seed your ontology — do not let it emerge from data.
- Entities are nodes, values are properties — if you query by it, make it a node.
- Name your relationships — generic
RELATED_TOis a code smell. - Normalise external data into your ontology — each source adapter is a translation layer.
- Model uncertainty — confidence scores are first-class ontology properties.
- Enforce with constraints —
UNIQUEandINDEXare ontology guarantees, not performance hacks. - Test the ontology — if a schema change breaks queries, your tests should catch it.
A graph without ontology is just a collection of nodes. A graph with ontology is a knowledge graph.
See Also
- Graph Data Modeling Patterns for Neo4j — Production design decisions for Neo4j: hypernode strategy, time-aware graphs, and polymorphic relationships.
- Knowledge Graph Construction from Unstructured Data — Building production pipelines for entity extraction, resolution, and GraphRAG integration.
- What Are Knowledge Graphs? — A foundational introduction to knowledge graphs, their structure, and their applications.
- From Knowledge Graphs to Context Graphs — How context graphs extend knowledge graphs to serve AI agents with situational awareness.
Further Reading
- Robinson, I., Webber, J. & Eifrem, E. Graph Databases, 2nd Edition. O'Reilly Media, 2015. ISBN 978-1-449-35625-5. — The definitive reference on graph database modeling, covering node labels, relationship types, and the property graph model.
- Noy, N. F. & McGuinness, D. L. Ontology Development 101: A Guide to Creating Your First Ontology. Stanford University, 2001. https://protege.stanford.edu/publications/ontology_development/ontology101-noy-mcguinness.html — The seminal methodology for ontology design: competency questions, class hierarchy, property facets, and iterative development.
- Gainey, C. Ontologies in Neo4j: Semantics and Knowledge Graphs. Neo4j Blog, 2020. https://neo4j.com/blog/knowledge-graph/ontologies-in-neo4j-semantics-and-knowledge-graphs/ — How to use neosemantics for RDF/OWL import and inference in Neo4j, and the three characteristics of formal ontologies.
- Neo4j. Graph Data Modeling Core Principles. GraphAcademy. https://neo4j.com/graphacademy/training-gdm-40/03-graph-data-modeling-core-principles/ — Official training covering node design, relationship specificity, and the gather-and-inspect anti-pattern.
- Neo4j. Modeling Designs. https://neo4j.com/docs/getting-started/data-modeling/modeling-designs/ — Reference for common graph modeling patterns: intermediate nodes, linked lists, fanout, and timeline trees.
- Neo4j. Importing Ontologies — Neosemantics (n10s). https://neo4j.com/labs/neosemantics/4.2/importing-ontologies/ — Reference for loading RDFS/OWL/SKOS ontologies into Neo4j.
- Mungall, C. Biological Knowledge Graph Modeling Design Patterns. 2019. https://douroucouli.wordpress.com/2019/03/14/biological-knowledge-graph-modeling-design-patterns/ — Practical patterns from large-scale biological KG projects: ontology classification as labels, edge properties, and the trade-off between KG modeling and OWL-level logical modeling.