EvoAge Neo4j Knowledge Graph Construction¶
1. Overview¶
This document describes how the EvoAge Neo4j graph was built, loaded, and maintained: schema design, indexing strategy, bulk-loading workflow, post-load enrichment, deduplication, and performance troubleshooting.
EvoAge is a multi-species aging knowledge graph integrating 48+ biomedical source databases (DRKG, PrimeKG, Hetionet, Monarch, CKG, STRING, miRTarBase, DrugAge, GenAge, CellAge, AgingBank, AgeXtend, Aging Atlas, and others) across six species: Human, Mouse, Zebrafish, Drosophila, C. elegans, and Yeast.
At current scale, the graph contains:
- ~45M nodes
- ~1.2B+ relationships
- 16 node labels
- ~85 relationship types
The graph is served by Neo4j (with the APOC plugin) running as a systemd service, queried via cypher-shell / the Bolt protocol.
2. Graph Schema¶
2.1 Node labels (16)¶
| Label | Description |
|---|---|
Gene |
Gene entities across all six species |
Protein |
Protein entities |
Disease |
Disease Ontology terms |
Phenotype |
Phenotype terms (e.g., HPO/MPO) |
Pathway |
Biological pathways |
BiologicalProcess |
GO biological process terms |
MolecularFunction |
GO molecular function terms |
CellularComponent |
GO cellular component terms |
ChemicalEntity |
Chemicals/compounds (PubChem CID-based) |
PlantSpecies |
Plant source species for chemicals |
Mirna |
microRNA entities |
Mutation |
Genetic mutations/variants |
AnatomicalEntity |
Anatomical structures |
Tissue |
Tissue types |
Species |
Species nodes (for cross-species edges) |
PMID |
Literature/publication references |
2.2 Relationship types (~85)¶
Relationships fall into two broad categories:
- Causal: e.g.
Inhibits,Promotes - Associative: e.g.
PositivelyAssociatedWithAging,NegativelyAssociatedWithAging
Plus standard biomedical edge types such as Gene_Gene (ortholog), Protein_Disease, Mutation_Gene, ChemicalEntity_Gene, Mirna_Gene, Gene_Pathway, Gene_Phenotype, species-association edges, etc. Every relationship type follows a HeadType_TailType (or descriptive) naming convention for traceability back to source.
2.3 Common node properties¶
| Property | Notes |
|---|---|
id |
Primary identifier (unique per label, indexed) |
model_id |
Internal numeric ID assigned post-load (see §7.1) |
name |
Human-readable name |
| (type-specific) | e.g. alternative_name, iupac_name, smiles on ChemicalEntity |
Each label has a unique RANGE index on id, created before bulk loading and used throughout for MERGE performance and coverage checks (see §4 — Indexing Strategy).
3. Infrastructure Setup¶
- Neo4j installed as a native systemd service, with the APOC plugin enabled.
- Access via
cypher-shell -a bolt://<host>:<port> -u <user> -p <password>. server.directories.importset to/so thatLOAD CSV FROM 'file:///...'can reference absolute paths anywhere on disk (rather than only Neo4j's defaultimport/folder). This was necessary because source CSVs live under a shared project directory, not the default import path.
3.1 Memory configuration¶
For a graph at this scale, default Neo4j memory settings (heap ~512MB, page cache ~10GB) are far too small and cause MemoryPoolOutOfMemoryError. On a machine with several hundred GB of RAM, neo4j.conf was tuned roughly as follows (values are illustrative — set relative to your machine's total RAM):
server.memory.heap.initial_size=31g
server.memory.heap.max_size=31g
server.memory.pagecache.size=150g
dbms.memory.transaction.total.max=15g
Verify applied settings with:
After editing neo4j.conf, restart the service and confirm no old cypher-shell jobs are still attached to the previous instance before relaunching heavy loads.
4. Indexing Strategy¶
Indexes were not an afterthought — they turned out to be the single biggest lever for load performance, and got created (and re-diagnosed) iteratively as loading problems surfaced.
4.1 Why indexing matters here¶
Every MERGE (n:Label {id: ...}) call needs to check whether a node with that id already exists. Without an index on id, Neo4j does this by scanning every existing node of that label (NodeByLabelScan) — cheap when a label has a handful of nodes, catastrophic once it has millions. This was the root cause behind one load (PROTEIN_DISEASE.csv, 31.2M rows) taking 7–8 hours: Protein and Disease nodes were shared across many relation files, so by the time this file loaded, both labels already held hundreds of thousands of nodes with no index to seek against.
4.2 Creating a unique constraint (which also creates an index) per label¶
A unique constraint on id was created for every one of the 16 node labels, before bulk loading began for that label. A unique constraint both enforces the id uniqueness invariant the whole KG relies on and implicitly creates the backing index used by MERGE:
CREATE CONSTRAINT gene_id IF NOT EXISTS FOR (n:Gene) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT protein_id IF NOT EXISTS FOR (n:Protein) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT disease_id IF NOT EXISTS FOR (n:Disease) REQUIRE n.id IS UNIQUE;
-- ... repeated for all 16 labels: Pathway, Phenotype, BiologicalProcess,
-- MolecularFunction, CellularComponent, ChemicalEntity, PlantSpecies,
-- Mirna, Mutation, AnatomicalEntity, Tissue, Species, PMID
IF NOT EXISTS makes this safe to re-run — it won't error if the constraint already exists, and if id isn't actually guaranteed unique for a given label, a plain range index is used instead:
4.3 Index creation on an already-loaded label¶
Constraints/indexes were ideally created before the first load touched a label, but in practice a few labels (Protein, Disease) were only indexed after the slow-load symptom appeared, i.e. against data that already existed. Neo4j handles this fine — index population runs as a background job over existing nodes:
Loads were paused until state = 'ONLINE' and populationPercent = 100 before resuming, since an index still populating doesn't yet give the MERGE speedup.
4.4 Verifying an index is actually being used¶
Rather than assume an index is helping, a small sample was profiled directly:
LOAD CSV WITH HEADERS FROM 'file:///path/to/sample_100_rows.csv' AS row
WITH row LIMIT 100
PROFILE
MERGE (h:Protein {id: row.head})
RETURN count(*);
The PROFILE output was checked for NodeUniqueIndexSeek (indexed — fast) rather than NodeByLabelScan (unindexed — slow). This confirmed the fix before committing to a multi-hour full-file reload.
4.5 Result¶
Once all 16 labels had ONLINE unique constraints on id, MERGE-based loads that had been taking hours dropped to minutes for comparable file sizes, since every node lookup became an index seek instead of a label scan.
5. Data Preparation (pre-Neo4j)¶
Before anything touches Neo4j, source databases are processed independently:
- Each source database is parsed into a unified triple schema:
Head | Head_name | Tail | Tail_name | Relation | Head_type | Tail_type | kg_source - Relation-wise CSVs are merged across sources (e.g. multiple
Gene_Genesources combined into oneGene_Gene.csv). - Each relation-wise CSV is deduplicated on
(Head, Relation, Tail). - Files with mixed
Head_type/Tail_typecolumns (e.g. cross-species association files) are kept separate from single-type files, since they require dynamic label resolution at load time (§6.2).
This stage produces the clean, relation-wise CSVs that are the direct input to the Neo4j loading step below.
6. Bulk Loading Pipeline¶
6.1 Standard load (fixed node types)¶
For files where head/tail node types are fixed and known in advance (e.g. Protein_Disease.csv), loading uses LOAD CSV + apoc.periodic.iterate for batching, rather than a raw LOAD CSV ... MERGE (which does not batch and will exhaust the transaction memory limit on large files):
CALL apoc.periodic.iterate(
"LOAD CSV WITH HEADERS FROM 'file:///path/to/Protein_Disease.csv' AS row RETURN row",
"MERGE (h:Protein {id: row.head})
ON CREATE SET h.name = row.head_name
MERGE (t:Disease {id: row.tail})
ON CREATE SET t.name = row.tail_name
MERGE (h)-[r:Protein_Disease]->(t)
ON CREATE SET r.kg_source = row.kg_source",
{batchSize: 500, parallel: false, logProgress: true}
);
6.2 Dynamic label resolution (mixed-type files)¶
For species-association files and similar cases where head_type/tail_type vary row-to-row, hardcoded labels are replaced with apoc.merge.node, which accepts the label as a parameter:
CALL apoc.periodic.iterate(
"LOAD CSV WITH HEADERS FROM 'file:///path/to/species_associated.csv' AS row RETURN row",
"CALL apoc.merge.node([row.head_type], {id: row.head}, {name: row.head_name}, {}) YIELD node AS h
CALL apoc.merge.node([row.tail_type], {id: row.tail}, {name: row.tail_name}, {}) YIELD node AS t
MERGE (h)-[r:ASSOCIATED_WITH]->(t)
ON CREATE SET r.kg_source = row.kg_source",
{batchSize: 300, parallel: false, logProgress: true}
);
The empty onMatchProps ({}) argument is important — it prevents apoc.merge.node from overwriting existing node properties on repeated matches.
6.3 Execution workflow¶
Every new load, without exception, follows this sequence:
- Dry run on 2 rows — append
LIMIT 2to theLOAD CSVstep andRETURNthe result to sanity-check parsing and typing in Neo4j Browser before committing to a full run. - Full run in the background — long-running loads (multi-hour, multi-million-row files) are launched with
nohupso they survive terminal/laptop disconnects: - Monitor progress:
- Verify the job is still alive with
ps aux | grep cypher-shellif the log goes quiet — a live PID with growing runtime is expected, not a hang, for files with tens of millions of rows atbatchSize:200–500.
6.4 Loader shell script — load_csv.sh¶
A reusable wrapper takes a CSV filename (relative to a fixed base directory), a log filename, and a .cypher file containing just the MERGE clause. It assembles the full apoc.periodic.iterate call and fires it off via nohup so the job survives disconnects:
#!/bin/bash
PASSWORD="<password>"
USERNAME="<username>"
CSV_BASE="<path>/evokg_new/rem_human_files/" # fixed base dir for this batch of files
CSV_PATH=$1
LOG_FILE=$2
MERGE_FILE=$3 # a .cypher file containing just the MERGE clause, not a raw string
if [[ -z "$CSV_PATH" || -z "$LOG_FILE" || -z "$MERGE_FILE" ]]; then
echo "Usage: ./load_csv.sh <CSV_PATH> <LOG_FILE> <MERGE_FILE>"
exit 1
fi
# Load the MERGE query safely from file (avoids quoting/escaping issues
# that come from inlining large Cypher blocks as shell strings)
MERGE_QUERY=$(<"$MERGE_FILE")
# Resolve full CSV path relative to the base directory
CSV_PATH="$CSV_BASE$CSV_PATH"
# Run the job in the background, output redirected to the log file
nohup cypher-shell \
-a bolt://<host>:<port> \
-u "$USERNAME" \
-p "$PASSWORD" <<EOF > "$LOG_FILE" 2>&1 &
CALL apoc.periodic.iterate(
'LOAD CSV WITH HEADERS FROM "file:///$CSV_PATH" AS row RETURN row',
"$MERGE_QUERY RETURN count(*)",
{batchSize:200, parallel:false, logProgress:true}
)
EOF
Usage:
Design notes:
- The
MERGEclause lives in its own.cypherfile ($MERGE_FILE) rather than being typed inline as a shell string — this avoids quote-escaping headaches for anything beyond a trivial one-lineMERGE. CSV_BASEis fixed per batch of files (e.g. one base dir per species or per data drop), so callers only pass the filename, not the full path — this cut down on path-typo errors across dozens of relation files.- The heredoc (
<<EOF ... EOF) feeds the assembled Cypher directly tocypher-shellon stdin, avoiding an extra intermediate.cypherfile per run. - Credentials and the base path are hardcoded at the top of the script rather than passed as arguments, since they're constant across an entire loading session — only
CSV_PATH,LOG_FILE, andMERGE_FILEvary per call.
7. Post-Load Enrichment¶
7.1 Assigning model_id¶
After the raw graph is loaded, every node across all 16 labels receives an internal model_id (used downstream for embedding/model training pipelines). This is done with a reusable add_model_id.sh script driving apoc.periodic.iterate at batchSize:500:
CALL apoc.periodic.iterate(
"MATCH (n:Gene) WHERE n.model_id IS NULL RETURN n",
"SET n.model_id = <assigned_id>",
{batchSize: 500, parallel: false, logProgress: true}
);
Coverage verification after assignment uses a per-label UNION ALL query (leveraging the unique id index on each label) to catch any nodes missed:
MATCH (n:Gene) WHERE n.model_id IS NULL RETURN 'Gene' AS label, count(n) AS missing
UNION ALL
MATCH (n:Protein) WHERE n.model_id IS NULL RETURN 'Protein' AS label, count(n) AS missing
// ... repeated for all 16 labels
This surfaced edge cases such as ChemicalEntity/PlantSpecies nodes whose id was a full chemical name string rather than a numeric CID — these were assigned model_ids manually.
7.2 Adding supplementary properties¶
Additional properties sourced from later-arriving data (e.g. alternative_name, iupac_name, smiles for ChemicalEntity) are added using the same apoc.periodic.iterate batched-update pattern, driven from a CSV keyed on id.