Schema Diagram and Graph Statistics
Scale factor 100 of the LDBC Social Network Benchmark, Business Intelligence workload.
Only the initial_snapshot is loaded; the inserts and
deletes update streams are not applied, because this implementation runs
read-only queries. The graph is therefore a fixed snapshot whose latest
creationDate is 2012-11-29T02:52:42.849+00:00.
Several labels in the table below describe the same nodes, so the node total is the
sum of the eight base labels only. Comment and Post both
carry the Message label, which is what lets the queries match
(:Message) across the two, so the Message row is the sum of
those two rather than an additional set of nodes. Place and
Organisation take a concrete second label from a :LABEL
column in the source CSV, so City + Country +
Continent are the Place nodes and University +
Company are the Organisation nodes.
Node Statistics
| Node Label | Count |
|---|---|
Message |
249,524,693 |
Comment |
185,495,476 |
Post |
64,029,217 |
Forum |
4,611,436 |
Person |
473,001 |
Tag |
16,080 |
Organisation |
7,955 |
University |
6,380 |
Company |
1,575 |
Place |
1,460 |
City |
1,343 |
Country |
111 |
Continent |
6 |
TagClass |
71 |
Edge Statistics
| Relationship | Count |
|---|---|
(:Place)-[:IS_PART_OF]->(:Place) |
1,454 |
(:TagClass)-[:IS_SUBCLASS_OF]->(:TagClass) |
70 |
(:Organisation)-[:IS_LOCATED_IN]->(:Place) |
7,955 |
(:Tag)-[:HAS_TYPE]->(:TagClass) |
16,080 |
(:Comment)-[:HAS_CREATOR]->(:Person) |
185,495,476 |
(:Comment)-[:IS_LOCATED_IN]->(:Country) |
185,495,476 |
(:Comment)-[:REPLY_OF]->(:Comment) |
85,829,276 |
(:Comment)-[:REPLY_OF]->(:Post) |
99,666,200 |
(:Forum)-[:CONTAINER_OF]->(:Post) |
64,029,217 |
(:Forum)-[:HAS_MEMBER]->(:Person) |
303,838,931 |
(:Forum)-[:HAS_MODERATOR]->(:Person) |
4,611,436 |
(:Forum)-[:HAS_TAG]->(:Tag) |
15,195,472 |
(:Person)-[:HAS_INTEREST]->(:Tag) |
11,057,039 |
(:Person)-[:IS_LOCATED_IN]->(:City) |
473,001 |
(:Person)-[:KNOWS]->(:Person) |
18,655,515 |
(:Person)-[:LIKES]->(:Comment) |
184,325,690 |
(:Person)-[:LIKES]->(:Post) |
98,423,296 |
(:Post)-[:HAS_CREATOR]->(:Person) |
64,029,217 |
(:Comment)-[:HAS_TAG]->(:Tag) |
238,074,593 |
(:Post)-[:HAS_TAG]->(:Tag) |
78,380,259 |
(:Post)-[:IS_LOCATED_IN]->(:Country) |
64,029,217 |
(:Person)-[:STUDY_AT]->(:University) |
378,582 |
(:Person)-[:WORK_AT]->(:Company) |
1,029,492 |
Total nodes: 254,634,696 · Total relationships: 1,703,042,944
Disk space: 201G
Total import time: 3,967 seconds (1h 6m 7s)
Data Loading
Overview
At 254.6 million nodes and 1.71 billion relationships, SF100 is loaded in a single
offline pass with neo4j-admin database import full, which writes store
files directly, rather than with incremental Cypher statements.
The generated CSV files carry no header row: they are
|-delimited data only, and the column definitions live in a parallel
tree of header files, one per entity. Each entity is a directory of Spark output
shards named part-*.csv, some of which are legitimately empty, so every
--nodes and --relationships argument lists the header file
first and then all the data shards.
Only initial_snapshot is imported. The inserts and
deletes update streams that ship with the dataset are not applied,
because this implementation runs the read-only BI queries rather than the full BI
workload with batch updates. The result is a fixed snapshot whose latest
creationDate is 2012-11-29T02:52:42.849+00:00.
Create Indices
Uniqueness constraints and secondary indices are built during the
import by passing this file to --schema, not afterwards. Creating the
Message(id) uniqueness constraint after the fact would mean populating
an index over 249.5 million nodes as a separate pass.
// uniqueness constrains (implying an index) CREATE CONSTRAINT FOR (n:City) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Comment) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Country) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Forum) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Message) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Organisation) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Person) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Post) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT FOR (n:Tag) REQUIRE n.id IS UNIQUE; // name/firstName CREATE INDEX FOR (n:Country) ON n.name; CREATE INDEX FOR (n:Person) ON n.firstName; CREATE INDEX FOR (n:Tag) ON n.name; CREATE INDEX FOR (n:TagClass) ON n.name; // creationDate of nodes CREATE INDEX FOR (n:Message) ON n.creationDate; CREATE INDEX FOR (n:Post) ON n.creationDate; CREATE INDEX FOR (n:Forum) ON n.creationDate; // creationDate of edges CREATE INDEX FOR ()-[e:KNOWS]-() ON e.creationDate;
The City and Country constraints and the
Country(name) index are included because five queries
(Q13, Q18, Q20, Q38 and Q39) look up a country by name and Q11 looks up cities by
id.
Import Script
The script below is the whole import, end to end: preflight, dropping the target
database, assembling the 8 node groups and 23 relationship groups, running the
importer, registering the resulting store as a database, and verifying the counts.
The add_nodes and add_rels calls are where the schema
mapping lives — Comment and Post both get the
Message label there, and Place and
Organisation take their concrete label from a :LABEL
column in the source CSV.
Two details are easy to get wrong. The database name is a positional argument and
must come before --nodes and
--relationships, which are declared variadic and would otherwise
swallow it as one more data file. And the importer runs as the neo4j
user, so its temporary directory and report file have to live somewhere that user
can write.
#!/usr/bin/env bash
#
# Bulk-import the LDBC SNB BI SF100 initial snapshot into the Neo4j database "snb100".
#
# Usage:
# MODE=offline ./import_snb100.sh # stop Neo4j during the import; the importer gets the
# # whole machine and runs much faster
# MODE=online ./import_snb100.sh # keep the other databases online; the importer runs
# # with a capped memory budget and takes far longer
# SCHEMA_FILE= ./import_snb100.sh # skip building indices during the import
#
# Only the target database is ever touched. This host's data directory also holds several
# unrelated benchmark databases and the "system" registry, so the databases directory is
# never cleared as a whole.
set -euo pipefail
# ----- Configuration ---------------------------------------------------------
DB_NAME=snb100
CSV_DIR=/datasets/prop_datasets/ldbc_100/csv
HEADER_DIR=/datasets/prop_datasets/ldbc_snb_bi/neo4j/headers
BOLT=bolt://localhost:7689
OUT_DIR=/datasets/prop_datasets/ldbc_100/scripts
TIMING_FILE=${OUT_DIR}/import-timing.tsv
# The importer runs as the "neo4j" user and cannot write into scripts/ (owned by exouser,
# mode 775). Its own outputs -- temporary files and the bad-entries report -- therefore go
# to a directory it owns on local disk, which is also faster than NFS.
WORK_DIR=/media/volume/sdb/neo4j-import
TEMP_PATH=${WORK_DIR}/tmp
REPORT_FILE=${WORK_DIR}/import-report.txt
# Indices and constraints are built during the import rather than afterwards: populating
# the Message(id) uniqueness constraint over 249.5 million nodes as a separate pass would
SCHEMA_FILE=${SCHEMA_FILE-${OUT_DIR}/indices.cypher}
MODE=${MODE:-online}
if [[ "${MODE}" == "offline" ]]; then
# The off-heap cap cannot be sized against the whole machine: the importer's JVM heap
# (10g, from server.memory.heap.max_size in neo4j.conf) sits on top of it, plus roughly
# 5g for the id mapper and other structures. A 40g cap drove anon-rss to 52.5 GiB during
# the relationship phase and systemd-oomd killed the process on this 58 GiB host.
# 24g + 10g heap + overhead is about 40g, leaving 18g of headroom.
MAX_OFF_HEAP=24g
THREADS=16
else
# The running server already holds 10g of heap and 16g of page cache
MAX_OFF_HEAP=8g
THREADS=14 # leave 2 of 16 cores to the server
fi
# The system default JVM is Java 17; neo4j-admin needs 21. The importer's own heap comes
# from neo4j.conf -- the 2026.05.0 NeoAdminBoot launcher no longer reads HEAP_SIZE -- so
# --max-off-heap-memory below is the only memory knob available here. It covers the page
# cache and the id-mapping structures.
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
# The safety net that matters for an unattended run: whatever makes this script exit -- an
# OOM during the import, a full disk, a bad argument, a failed preflight -- the Neo4j service
# must come back. On the previous attempt set -e terminated the script at the import and the
# systemctl start at the end never ran, leaving nine other databases offline for an hour.
restore_service () {
local rc=$?
if [[ "${MODE}" == "offline" ]] && ! systemctl is-active --quiet neo4j; then
echo "== Exit code ${rc}: restoring the Neo4j service =="
sudo systemctl start neo4j || true
fi
if [[ ${rc} -ne 0 ]]; then
echo "== Script exited with code ${rc} (import did not complete) =="
fi
return ${rc}
}
trap restore_service EXIT
# ----- Guard rail -------------------------------------------------------------
if [[ -z "${DB_NAME}" || "${DB_NAME}" == "system" || "${DB_NAME}" == "neo4j" ]]; then
echo "Refusing to run: DB_NAME is empty or points at system/neo4j." >&2
exit 1
fi
echo "== Configuration =="
echo " database ${DB_NAME}"
echo " mode ${MODE}"
echo " off-heap ${MAX_OFF_HEAP} threads ${THREADS}"
echo " CSV ${CSV_DIR}"
echo " headers ${HEADER_DIR}"
echo " temp dir ${TEMP_PATH}"
echo " schema ${SCHEMA_FILE:-(skipped)}"
# ----- Preflight: all 31 entity directories present and non-empty -------------
echo "== Checking data directories =="
missing=0
for sub in static dynamic; do
while read -r h; do
entity=$(basename "${h}" .csv)
d="${CSV_DIR}/initial_snapshot/${sub}/${entity}"
if [[ ! -d "${d}" ]]; then
echo " missing directory: ${d}" >&2; missing=1
elif ! find "${d}" -maxdepth 1 -name 'part-*.csv*' -type f -print -quit | grep -q .; then
echo " no part files: ${d}" >&2; missing=1
fi
done < <(find "${HEADER_DIR}/${sub}" -name '*.csv')
done
[[ ${missing} -eq 0 ]] || { echo "Preflight failed, aborting." >&2; exit 1; }
echo " all 31 entity directories present"
# ----- Take the target database offline and drop it (repeatable) --------------
# Drop and re-create rather than overwrite in place: dropping also clears the database's
# transaction logs and its system-database entry, so no stale metadata survives.
if cypher-shell -a "${BOLT}" -d system "RETURN 1" >/dev/null 2>&1; then
echo "== Stopping and dropping ${DB_NAME} (other databases unaffected) =="
cypher-shell -a "${BOLT}" -d system "STOP DATABASE ${DB_NAME} IF EXISTS WAIT" || true
cypher-shell -a "${BOLT}" -d system "DROP DATABASE ${DB_NAME} IF EXISTS WAIT"
cypher-shell -a "${BOLT}" -d system "SHOW DATABASES YIELD name, currentStatus"
else
echo "== Server not running, skipping STOP/DROP (re-run case) =="
fi
# Clear any temporary files left by a previous run. The cleanup at the end of this script
# does not run if the process is killed (an OOM, for instance), and the leftover temp-<db>
# directory can be tens of gigabytes.
sudo rm -rf "${TEMP_PATH}"
# The directory must be owned by neo4j or the importer cannot write into it
sudo mkdir -p "${TEMP_PATH}"
sudo chown -R neo4j:neo4j "${WORK_DIR}"
if ! sudo -u neo4j touch "${WORK_DIR}/.probe" 2>/dev/null; then
echo "Preflight failed: the neo4j user cannot write to ${WORK_DIR}" >&2; exit 1
fi
sudo -u neo4j rm -f "${WORK_DIR}/.probe"
echo " neo4j can write to ${WORK_DIR}"
if [[ -n "${SCHEMA_FILE}" ]] && ! sudo -u neo4j test -r "${SCHEMA_FILE}"; then
echo "Preflight failed: the neo4j user cannot read ${SCHEMA_FILE}" >&2
exit 1
fi
if [[ "${MODE}" == "offline" ]]; then
if systemctl is-active --quiet neo4j; then
echo "== MODE=offline: stopping Neo4j (data untouched, restarted afterwards) =="
sudo systemctl stop neo4j
else
echo "== Server already stopped =="
fi
fi
# ----- Assemble the --nodes / --relationships arguments -----------------------
PART='part-*.csv*'
args=()
declare -A GROUP_BYTES=() # input bytes per group, for throughput afterwards
add_nodes () { # $1=labels $2=static|dynamic $3=entity
args+=( "--nodes=$1=${HEADER_DIR}/$2/$3.csv$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf ',%p')" )
GROUP_BYTES["node/$1/$3"]=$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf '%s\n' | awk '{s+=$1} END{print s+0}')
}
add_rels () { # $1=RELTYPE $2=static|dynamic $3=entity
args+=( "--relationships=$1=${HEADER_DIR}/$2/$3.csv$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf ',%p')" )
GROUP_BYTES["rel/$1/$3"]=$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf '%s\n' | awk '{s+=$1} END{print s+0}')
}
# Eight node groups. Comment and Post both carry the Message label, which is what lets
# the queries match (:Message) across the two. The Place and Organisation headers end in a
# :LABEL column, so a city arrives as (:Place:City), a university as (:Organisation:University).
add_nodes Place static Place
add_nodes Organisation static Organisation
add_nodes TagClass static TagClass
add_nodes Tag static Tag
add_nodes Forum dynamic Forum
add_nodes Person dynamic Person
add_nodes Message:Comment dynamic Comment # Comment and Post both get :Message
add_nodes Message:Post dynamic Post
# Twenty-three relationship groups, collapsing into eleven relationship types
add_rels IS_PART_OF static Place_isPartOf_Place
add_rels IS_SUBCLASS_OF static TagClass_isSubclassOf_TagClass
add_rels IS_LOCATED_IN static Organisation_isLocatedIn_Place
add_rels HAS_TYPE static Tag_hasType_TagClass
add_rels HAS_CREATOR dynamic Comment_hasCreator_Person
add_rels IS_LOCATED_IN dynamic Comment_isLocatedIn_Country
add_rels REPLY_OF dynamic Comment_replyOf_Comment
add_rels REPLY_OF dynamic Comment_replyOf_Post
add_rels CONTAINER_OF dynamic Forum_containerOf_Post
add_rels HAS_MEMBER dynamic Forum_hasMember_Person
add_rels HAS_MODERATOR dynamic Forum_hasModerator_Person
add_rels HAS_TAG dynamic Forum_hasTag_Tag
add_rels HAS_INTEREST dynamic Person_hasInterest_Tag
add_rels IS_LOCATED_IN dynamic Person_isLocatedIn_City
add_rels KNOWS dynamic Person_knows_Person
add_rels LIKES dynamic Person_likes_Comment
add_rels LIKES dynamic Person_likes_Post
add_rels HAS_CREATOR dynamic Post_hasCreator_Person
add_rels HAS_TAG dynamic Comment_hasTag_Tag
add_rels HAS_TAG dynamic Post_hasTag_Tag
add_rels IS_LOCATED_IN dynamic Post_isLocatedIn_Country
add_rels STUDY_AT dynamic Person_studyAt_University
add_rels WORK_AT dynamic Person_workAt_Company
echo "== Argument groups: ${#args[@]} (expected 8 nodes + 23 relationships = 31) =="
# Persist the per-group input sizes so throughput can be derived later
{ echo -e "group\tbytes"; for k in "${!GROUP_BYTES[@]}"; do echo -e "${k}\t${GROUP_BYTES[$k]}"; done | sort; } > "${OUT_DIR}/import-input-bytes.tsv"
# ----- Import -----------------------------------------------------------------
# Argument order matters: ${DB_NAME} must precede --nodes/--relationships, which are
# declared variadic (<files>...) and would swallow a trailing database name as one more
# data file, failing with "NoSuchFileException: snb100".
# --id-type=INTEGER integer ids map far more cheaply than strings over 254.6M nodes
echo "== Starting the import; expect hours (source and target both on NFS) =="
T0=$(date +%s); date
sudo -u neo4j --preserve-env=JAVA_HOME \
neo4j-admin database import full \
"${DB_NAME}" \
--id-type=INTEGER \
--ignore-empty-strings=true \
--bad-tolerance=0 \
--delimiter='|' \
--array-delimiter=';' \
--threads="${THREADS}" \
--max-off-heap-memory="${MAX_OFF_HEAP}" \
--temp-path="${TEMP_PATH}" \
--report-file="${REPORT_FILE}" \
${SCHEMA_FILE:+--schema="${SCHEMA_FILE}"} \
--overwrite-destination \
--verbose \
"${args[@]}"
T1=$(date +%s); date
echo "== Import finished in $(( (T1-T0)/3600 ))h $(( (T1-T0)%3600/60 ))m $(( (T1-T0)%60 ))s =="
# The report is written by neo4j on local disk; copy it back for convenience
sudo cp "${REPORT_FILE}" "${OUT_DIR}/import-report.txt" 2>/dev/null && sudo chown exouser:exouser "${OUT_DIR}/import-report.txt" || true
echo -e "phase\tseconds" > "${TIMING_FILE}"
echo -e "total_wall_clock\t$((T1-T0))" >> "${TIMING_FILE}"
if [[ "${MODE}" == "offline" ]]; then
echo "== Restarting Neo4j =="
sudo systemctl start neo4j
for i in $(seq 1 120); do
cypher-shell -a "${BOLT}" -d system "RETURN 1" >/dev/null 2>&1 && break
sleep 5
done
fi
# ----- Register the store as a database and bring it online -------------------
echo "== Creating and starting ${DB_NAME} =="
T2=$(date +%s)
cypher-shell -a "${BOLT}" -d system "CREATE DATABASE ${DB_NAME} WAIT"
T3=$(date +%s)
echo -e "create_database_online\t$((T3-T2))" >> "${TIMING_FILE}"
cypher-shell -a "${BOLT}" -d system "SHOW DATABASES YIELD name, currentStatus"
# ----- Verify -----------------------------------------------------------------
echo "== Node count per label =="
cypher-shell -a "${BOLT}" -d "${DB_NAME}" \
"MATCH (n) UNWIND labels(n) AS l RETURN l AS label, count(*) AS cnt ORDER BY cnt DESC" \
| tee "${OUT_DIR}/count-nodes.txt"
echo "== Relationship count per type =="
cypher-shell -a "${BOLT}" -d "${DB_NAME}" \
"MATCH ()-[r]->() RETURN type(r) AS relType, count(*) AS cnt ORDER BY cnt DESC" \
| tee "${OUT_DIR}/count-rels.txt"
echo "== Index and constraint state =="
cypher-shell -a "${BOLT}" -d "${DB_NAME}" "SHOW INDEXES YIELD name, state, owningConstraint"
sudo rm -rf "${TEMP_PATH}" 2>/dev/null || true
echo
echo "Done. Output files:"
echo " ${OUT_DIR}/import-report.txt importer bad-entries report"
echo " ${TIMING_FILE} timings"
echo " ${OUT_DIR}/import-input-bytes.tsv input bytes per group"
echo " ${OUT_DIR}/count-nodes.txt node count per label"
echo " ${OUT_DIR}/count-rels.txt relationship count per type"
Queries
Queries are numbered in workload order, so the numbering does not match the LDBC
BI query numbers. Every query states its origin under its description. Because the
BI queries are shown exactly as the LDBC reference implementation ships them, their
first comment line still carries the LDBC number — Q11 below, for instance,
opens with // Q19. because it is bi-19.cypher.
20 of the 41 come from the LDBC SNB Business Intelligence workload.
Their Cypher is the LDBC reference implementation unmodified, and each names the
bi-N.cypher file it came from. Their parameters come from the official
parameters-sf100 set; queries whose parameter set has a and
b variants are run once per variant against the same Cypher.
The other 21 were written for this benchmark and are not part of the LDBC BI workload. The BI workload exercises only 7 of the 28 PropGBench query types, so these fill the remaining ones: each adapts the GQL template given on that type’s workload-description page to the SNB schema, and each was run against the SF100 instance.
These queries have no official parameter files. The values used to validate them on
SF100 were personId = 100316,
person1Id = 10006 with person2Id = 2199023261649 (a
connected pair), language = 'en' or 'es',
tagA = 'Thomas_More' with
tagB = 'Michelangelo', and date ranges inside
2010–2012.
The PropGBench query types are single primitives — a degree statistic, a triangle, a shortest path — whereas an SNB BI query is a complete analytical question that usually composes several of them. 1 query sits under Hybrid because it falls squarely on two defined types at once, and 9 sit under Others because their distinguishing work — summarising a node property, comparing two time windows, deriving a subpopulation and then measuring within it — matches no single defined type. Each of those queries states in its description why it does not fit.
Select a query below to view its logic and description.
Q1. One-hop friends of a given person
Return the direct KNOWS neighbours of one person. This is the baseline local traversal: a single indexed node lookup followed by one adjacency expansion.
Source: written for PropGBench, adapting the T1.1 template from the workload description.
Parameters: personId: ID · limit: INT
// T1.1. One-hop friends of a given person
MATCH (:Person {id: $personId})-[:KNOWS]-(friend:Person)
RETURN friend.id AS personId, friend.firstName AS firstName, friend.lastName AS lastName
ORDER BY personId
LIMIT $limit
Q2. Persons within three friendship hops
Return every distinct person reachable within one to three KNOWS hops of a start person. The frontier grows quickly on a social graph, so this measures expansion cost and duplicate elimination across overlapping paths.
Source: written for PropGBench, adapting the T1.2 template from the workload description.
Parameters: personId: ID · limit: INT
// T1.2. Persons within three friendship hops of a given person
MATCH (start:Person {id: $personId})-[:KNOWS*1..3]-(neighbor:Person)
WHERE neighbor <> start
RETURN DISTINCT neighbor.id AS personId, neighbor.firstName AS firstName, neighbor.lastName AS lastName
ORDER BY personId
LIMIT $limit
Q3. Persons exactly three friendship hops away
Return the persons reached by paths of length exactly three, rather than everything within three hops. Only the start person is excluded, so a person who is also reachable in fewer hops still appears if a three-hop path to them exists.
Source: written for PropGBench, adapting the T1.3 template from the workload description.
Parameters: personId: ID · limit: INT
// T1.3. Persons exactly three friendship hops away
MATCH (start:Person {id: $personId})-[:KNOWS*3]-(neighbor:Person)
WHERE neighbor <> start
RETURN DISTINCT neighbor.id AS personId, neighbor.firstName AS firstName, neighbor.lastName AS lastName
ORDER BY personId
LIMIT $limit
Q4. Persons within two hops speaking a given language
Expand one to two KNOWS hops and keep only the persons who speak a given language and were born on or after a given date. The speaks property is an array, so the filter is a list membership test. It measures whether the planner pushes the predicate into the expansion or filters the fully expanded frontier afterwards.
Source: written for PropGBench, adapting the T1.4 template from the workload description.
Parameters: personId: ID · language: STRING · birthday: DATE · limit: INT
// T1.4. Persons within two friendship hops who speak a given language
MATCH (start:Person {id: $personId})-[:KNOWS*1..2]-(neighbor:Person)
WHERE neighbor <> start
AND $language IN neighbor.speaks
AND neighbor.birthday >= $birthday
RETURN DISTINCT neighbor.id AS personId,
neighbor.firstName AS firstName,
neighbor.lastName AS lastName,
neighbor.birthday AS birthday
ORDER BY birthday DESC, personId
LIMIT $limit
Q5. Persons speaking a given language who registered in a period
Filter Person nodes on an array property and a creationDate range with no traversal at all. This is the single-node path case, measuring predicate evaluation and label-scan or index-lookup performance in isolation.
Source: written for PropGBench, adapting the T2.1 template from the workload description.
Parameters: language: STRING · startDate: DATETIME · endDate: DATETIME · limit: INT
// T2.1. Persons who speak a given language and registered in a given period
MATCH (person:Person)
WHERE $language IN person.speaks
AND person.creationDate >= $startDate
AND person.creationDate <= $endDate
RETURN person.id AS personId,
person.firstName AS firstName,
person.lastName AS lastName,
person.creationDate AS creationDate
ORDER BY creationDate DESC, personId
LIMIT $limit
Q6. Friendship edges created within a given window
Filter KNOWS relationships by their creationDate and return both endpoints. A relationship-property index on KNOWS(creationDate) is created during the import, so this exercises relationship index lookup rather than a full relationship scan.
Source: written for PropGBench, adapting the T2.2 template from the workload description.
Parameters: startDate: DATETIME · endDate: DATETIME · limit: INT
// T2.2. Friendship edges created within a given window
MATCH (person1:Person)-[knows:KNOWS]->(person2:Person)
WHERE knows.creationDate >= $startDate
AND knows.creationDate <= $endDate
RETURN person1.id AS person1Id,
person2.id AS person2Id,
knows.creationDate AS creationDate
ORDER BY creationDate, person1Id
LIMIT $limit
Q7. Whether two persons are connected within four friendship hops
Return a single boolean saying whether a bounded KNOWS path exists between two persons. The EXISTS subquery lets the engine stop at the first matching path, so this measures short-circuiting rather than full path enumeration.
Source: written for PropGBench, adapting the T2.3 template from the workload description.
Parameters: person1Id: ID · person2Id: ID
// T2.3. Whether two persons are connected within four friendship hops
MATCH (source:Person {id: $person1Id}),
(target:Person {id: $person2Id})
RETURN EXISTS {
MATCH (source)-[:KNOWS*1..4]-(target)
} AS reachable
Q8. Shortest friendship path between two persons
Return the shortest KNOWS path between two persons, bounded at six hops, as the list of person ids along it. Neo4j’s shortestPath runs a bidirectional search and stops at the first shortest match, so unlike the simple-path enumeration it prunes aggressively. The weighted variants of this problem are Q10, Q11 and Q12.
Source: written for PropGBench, adapting the T2.4 template from the workload description.
Parameters: person1Id: ID · person2Id: ID
// T2.4. Shortest friendship path between two persons
MATCH p = shortestPath(
(:Person {id: $person1Id})-[:KNOWS*1..6]-(:Person {id: $person2Id})
)
RETURN [n IN nodes(p) | n.id] AS personIds, length(p) AS hopCount
Q9. All simple friendship paths between two persons up to length three
Enumerate every path between two persons up to three hops, keeping only simple paths. Cypher’s variable-length matching already forbids repeating a relationship but permits revisiting a node, so the all(... single(...)) predicate removes the paths that do. Unlike reachability or shortest path this materialises every result, so it is the most resource-intensive of the path queries.
Source: written for PropGBench, adapting the T2.5 template from the workload description.
Parameters: person1Id: ID · person2Id: ID · limit: INT
// T2.5. All simple friendship paths between two persons up to length three
MATCH p = (:Person {id: $person1Id})-[:KNOWS*1..3]-(:Person {id: $person2Id})
WHERE all(i IN range(0, size(nodes(p)) - 1)
WHERE single(n IN nodes(p) WHERE n = nodes(p)[i]))
RETURN [n IN nodes(p) | n.id] AS personIds, length(p) AS hopCount
ORDER BY hopCount ASC
LIMIT $limit
Q10. Weighted interaction paths
Find the most trusted path between two persons. Edge weight is derived from how strongly the two interacted inside forums created in a given window (a reply counts 1.0 or 0.5, and the weight is 1/(w+1)), then Dijkstra is run over that weighting. The weights are not stored on the edges but computed into a GDS projection, which is what makes a weighted-path query meaningful on an otherwise unweighted social graph. The query drops and rebuilds its own projection inline, so it needs no separate precomputation step.
Source: LDBC SNB BI workload, bi-15.cypher, used unmodified.
Also exercises: T2.4 Shortest Path
Parameters: person1Id: ID · person2Id: ID · startDate: DATE · endDate: DATE
// Q15. Weighted interaction paths
// Requires the Neo4j Graph Data Science library
CALL gds.graph.drop('bi15', false)
YIELD graphName
// ----------------------------------------------------------------------------------------------------
WITH count(*) AS dummy
// ----------------------------------------------------------------------------------------------------
CALL gds.graph.project.cypher(
'bi15',
'MATCH (p:Person) RETURN id(p) AS id',
'MATCH (pA:Person)-[knows:KNOWS]-(pB:Person)
OPTIONAL MATCH (pA)<-[:HAS_CREATOR]-(m1:Message)-[r:REPLY_OF]-(m2:Message)-[:HAS_CREATOR]->(pB)
OPTIONAL MATCH (m1)-[:REPLY_OF*0..]->(:Post)<-[:CONTAINER_OF]-(forum:Forum)
WHERE forum.creationDate >= datetime({epochmillis: ' + $startDate.epochMillis + '})
AND forum.creationDate <= datetime({epochmillis: ' + $endDate.epochMillis + '})
WITH pA, pB,
sum(CASE forum IS NOT NULL
WHEN true THEN
CASE (m1:Post OR m2:Post) WHEN true THEN 1.0
ELSE 0.5 END
ELSE 0.0 END
) AS w
RETURN
id(pA) AS source,
id(pB) AS target,
1/(w+1) AS weight
'
)
YIELD graphName
// ----------------------------------------------------------------------------------------------------
WITH count(*) AS dummy
// ----------------------------------------------------------------------------------------------------
CALL {
MATCH (person1:Person {id: $person1Id}), (person2:Person {id: $person2Id})
CALL gds.shortestPath.dijkstra.stream('bi15', {
sourceNode: person1,
targetNode: person2,
relationshipWeightProperty: 'weight'
})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
RETURN totalCost
UNION ALL
RETURN -1.0 AS totalCost
}
RETURN max(totalCost) AS totalCost
.stream mode, so nothing is written back to the database.Q11. Interaction path between cities
Find the cheapest weighted path between persons living in two given cities. Edge weight falls as reply interaction rises (round(40-sqrt(numInteractions)), floored at 1), so heavily interacting pairs sit closer together. The projection carrying those weights must be built beforehand with bi-19-create-graph.cypher.
Source: LDBC SNB BI workload, bi-19.cypher, used unmodified.
Also exercises: T2.4 Shortest Path
Parameters: city1Id: ID · city2Id: ID
// Q19. Interaction path between cities
// Requires the Neo4j Graph Data Science library
MATCH
(person1:Person)-[:IS_LOCATED_IN]->(city1:City {id: $city1Id}),
(person2:Person)-[:IS_LOCATED_IN]->(city2:City {id: $city2Id})
CALL gds.shortestPath.dijkstra.stream('bi19', {
sourceNode: person1,
targetNode: person2,
relationshipWeightProperty: 'weight'
})
YIELD totalCost
WITH person1.id AS person1Id, person2.id AS person2Id, totalCost AS totalWeight
ORDER BY totalWeight ASC, person1.id ASC, person2.id ASC
WITH collect({person1Id: person1Id, person2Id: person2Id, totalWeight: totalWeight}) AS results
UNWIND results AS result
WITH result.person1Id AS person1Id, result.person2Id AS person2Id, result.totalWeight AS totalWeight
WHERE totalWeight = results[0].totalWeight
RETURN person1Id, person2Id, totalWeight
ORDER BY person1Id, person2Id
.stream mode, so nothing is written back to the database.Projection built before the query runs (bi-19-create-graph.cypher):
CALL gds.graph.project.cypher(
'bi19',
'MATCH (p:Person) RETURN id(p) AS id',
'MATCH
(personA:Person)-[:KNOWS]-(personB:Person),
(personA)<-[:HAS_CREATOR]-(:Message)-[replyOf:REPLY_OF]-(:Message)-[:HAS_CREATOR]->(personB)
WITH
id(personA) AS source,
id(personB) AS target,
count(replyOf) AS numInteractions
RETURN
source,
target,
CASE WHEN round(40-sqrt(numInteractions)) > 1 THEN round(40-sqrt(numInteractions)) ELSE 1 END AS weight'
)
Released afterwards (bi-19-drop-graph.cypher):
CALL gds.graph.drop('bi19', false)
A variant that builds the weights inline instead of relying on a precomputed projection is also provided as bi-19-without-precomputation.cypher.
Q12. Recruitment
From every current employee of a target company, find the cheapest weighted path to a given candidate. Edge weight is the smallest difference in enrolment year between two persons who studied at the same university (min(abs(classYear difference)) + 1). The projection must be built beforehand with bi-20-create-graph.cypher.
Source: LDBC SNB BI workload, bi-20.cypher, used unmodified.
Also exercises: T2.4 Shortest Path
Parameters: company: STRING · person2Id: ID
// Q20. Recruitment
// Requires the Neo4j Graph Data Science library
MATCH
(company:Company {name: $company})<-[:WORK_AT]-(person1:Person),
(person2:Person {id: $person2Id})
CALL gds.shortestPath.dijkstra.stream('bi20', {
sourceNode: person1,
targetNode: person2,
relationshipWeightProperty: 'weight'
})
YIELD totalCost
WHERE person1.id <> $person2Id
WITH person1.id AS person1Id, totalCost AS totalWeight
ORDER BY totalWeight ASC
WITH collect({person1Id: person1Id, totalWeight: totalWeight}) AS results
UNWIND results AS result
WITH result.person1Id AS person1Id, result.totalWeight AS totalWeight
WHERE totalWeight = results[0].totalWeight
RETURN person1Id, totalWeight
ORDER BY person1Id ASC
LIMIT 20
.stream mode, so nothing is written back to the database.Projection built before the query runs (bi-20-create-graph.cypher):
CALL gds.graph.project.cypher(
'bi20',
'MATCH (p:Person) RETURN id(p) AS id',
'MATCH
(personA:Person)-[:KNOWS]-(personB:Person),
(personA)-[saA:STUDY_AT]->(u:University)<-[saB:STUDY_AT]-(personB)
RETURN
id(personA) AS source,
id(personB) AS target,
min(abs(saA.classYear - saB.classYear)) + 1 AS weight'
)
Released afterwards (bi-20-drop-graph.cypher):
CALL gds.graph.drop('bi20', false)
A variant that builds the weights inline instead of relying on a precomputed projection is also provided as bi-20-without-precomputation.cypher.
Q13. Friend triangles
Count friendship triangles inside one country where all three KNOWS edges were created within a given window. The a.id < b.id < c.id ordering deduplicates, so each triangle is counted once rather than six times.
Source: LDBC SNB BI workload, bi-11.cypher, used unmodified.
Parameters: country: STRING · startDate: DATE · endDate: DATE
// Q11. Friend triangles
MATCH (a:Person)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(country:Country {name: $country}),
(a)-[k1:KNOWS]-(b:Person)
WHERE a.id < b.id
AND $startDate <= k1.creationDate AND k1.creationDate <= $endDate
WITH DISTINCT country, a, b
MATCH (b)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(country)
WITH DISTINCT country, a, b
MATCH (b)-[k2:KNOWS]-(c:Person),
(c)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(country)
WHERE b.id < c.id
AND $startDate <= k2.creationDate AND k2.creationDate <= $endDate
WITH DISTINCT a, b, c
MATCH (c)-[k3:KNOWS]-(a)
WHERE $startDate <= k3.creationDate AND k3.creationDate <= $endDate
WITH DISTINCT a, b, c
RETURN count(*) AS count
Q14. Friendship squares around a given person
Find four-node cycles through a given person: the person, two of their friends, and a fourth person who knows both. All six inequality constraints are stated explicitly so that degenerate matches collapsing two roles onto one person are excluded.
Source: written for PropGBench, adapting the T3.2 template from the workload description.
Parameters: personId: ID · limit: INT
// T3.2. Friendship squares around a given person
MATCH (a:Person {id: $personId})-[:KNOWS]-(b:Person),
(b)-[:KNOWS]-(c:Person),
(c)-[:KNOWS]-(d:Person),
(d)-[:KNOWS]-(a)
WHERE a <> b AND a <> c AND a <> d
AND b <> c AND b <> d AND c <> d
RETURN DISTINCT b.id AS firstFriendId, c.id AS bridgeFriendId, d.id AS secondFriendId
LIMIT $limit
Q15. Forums with the most members among those created after a date
Find hub forums: those created after a given date whose membership exceeds a threshold, ranked by member count. HAS_MEMBER is the largest relationship type in the graph at 303.8 million edges and forum membership is heavily skewed, so this is the star pattern under a realistic degree distribution.
Source: written for PropGBench, adapting the T3.3 template from the workload description.
Parameters: date: DATETIME · minDegree: INT · limit: INT
// T3.3. Forums with the most members among those created after a given date MATCH (forum:Forum)-[:HAS_MEMBER]->(person:Person) WHERE forum.creationDate > $date WITH forum, count(DISTINCT person) AS memberCount WHERE memberCount >= $minDegree RETURN forum.id AS forumId, forum.title AS title, memberCount ORDER BY memberCount DESC, forumId LIMIT $limit
Q16. Groups of four mutually acquainted persons around a person
Find 4-cliques containing a given person, where all six pairs among the four are connected by KNOWS. The b.id < c.id < d.id ordering reports each clique once instead of six times. It is the densest pattern in the set and the most demanding on join ordering.
Source: written for PropGBench, adapting the T3.4 template from the workload description.
Parameters: personId: ID · limit: INT
// T3.4. Groups of four mutually acquainted persons around a given person
MATCH (a:Person {id: $personId})-[:KNOWS]-(b:Person),
(a)-[:KNOWS]-(c:Person),
(a)-[:KNOWS]-(d:Person),
(b)-[:KNOWS]-(c),
(b)-[:KNOWS]-(d),
(c)-[:KNOWS]-(d)
WHERE b.id < c.id AND c.id < d.id
RETURN b.id AS person2Id, c.id AS person3Id, d.id AS person4Id
LIMIT $limit
Q17. Friend recommendation
For two persons who share an interest in a tag but are not yet connected by KNOWS, count their mutual friends and rank by that count. This is the common-neighbour join T3.5 describes, combined with a negative pattern (NOT (person1)-[:KNOWS]-(person2)).
Source: LDBC SNB BI workload, bi-18.cypher, used unmodified.
Parameters: tag: STRING
// Q18. Friend recommendation
MATCH (tag:Tag {name: $tag})<-[:HAS_INTEREST]-(person1:Person)-[:KNOWS]-(mutualFriend:Person)-[:KNOWS]-(person2:Person)-[:HAS_INTEREST]->(tag)
WHERE person1 <> person2
AND NOT (person1)-[:KNOWS]-(person2)
RETURN person1.id AS person1Id, person2.id AS person2Id, count(DISTINCT mutualFriend) AS mutualFriendCount
ORDER BY mutualFriendCount DESC, person1Id ASC, person2Id ASC
LIMIT 20
Q18. Popular topics in a country
A long chain running Country → City → Person → Forum → Post → Message → Tag → TagClass, with a variable-length REPLY_OF*0.. segment walking the reply tree in the middle, counting the messages per forum that hit a given TagClass.
Source: LDBC SNB BI workload, bi-3.cypher, used unmodified.
Parameters: tagClass: STRING · country: STRING
// Q3. Popular topics in a country
MATCH
(:Country {name: $country})<-[:IS_PART_OF]-(:City)<-[:IS_LOCATED_IN]-
(person:Person)<-[:HAS_MODERATOR]-(forum:Forum)-[:CONTAINER_OF]->
(post:Post)<-[:REPLY_OF*0..]-(message:Message)-[:HAS_TAG]->(:Tag)-[:HAS_TYPE]->(:TagClass {name: $tagClass})
RETURN
forum.id,
forum.title,
forum.creationDate,
person.id,
count(DISTINCT message) AS messageCount
ORDER BY
messageCount DESC,
forum.id ASC
LIMIT 20
Q19. Related Topics
Follow a three-hop line from a tag through its messages and their replies to the other tags those replies carry, excluding replies that are themselves tagged with the starting tag. The exclusion is a negative pattern over the same three-hop shape.
Source: LDBC SNB BI workload, bi-7.cypher, used unmodified.
Parameters: tag: STRING
// Q7. Related Topics
MATCH
(tag:Tag {name: $tag})<-[:HAS_TAG]-(message:Message),
(message)<-[:REPLY_OF]-(comment:Comment)-[:HAS_TAG]->(relatedTag:Tag)
WHERE NOT (comment)-[:HAS_TAG]->(tag)
RETURN
relatedTag.name,
count(DISTINCT comment) AS count
ORDER BY
count DESC,
relatedTag.name ASC
LIMIT 100
Q20. International dialog
For friend pairs split across two countries, score their interaction: a reply scores 4 or 1 and a like scores 10 or 1. Each of the four OPTIONAL MATCH branches is a line running person1 → message → message → person2 or person1 → message → person2, so the pair is connected through chained multi-hop relations rather than a single shared neighbour. Only the highest-scoring pair per city is returned.
Source: LDBC SNB BI workload, bi-14.cypher, used unmodified.
Also exercises: T3.5 Two-hop Join Pattern
Parameters: country1: STRING · country2: STRING
// Q14. International dialog
MATCH
(country1:Country {name: $country1})<-[:IS_PART_OF]-(city1:City)<-[:IS_LOCATED_IN]-(person1:Person),
(country2:Country {name: $country2})<-[:IS_PART_OF]-(city2:City)<-[:IS_LOCATED_IN]-(person2:Person),
(person1)-[:KNOWS]-(person2)
WITH person1, person2, city1, 0 AS score
// case 1
OPTIONAL MATCH (person1)<-[:HAS_CREATOR]-(c:Comment)-[:REPLY_OF]->(:Message)-[:HAS_CREATOR]->(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN c IS NULL THEN 0 ELSE 4 END) AS score
// case 2
OPTIONAL MATCH (person1)<-[:HAS_CREATOR]-(m:Message)<-[:REPLY_OF]-(:Comment)-[:HAS_CREATOR]->(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN m IS NULL THEN 0 ELSE 1 END) AS score
// case 3
OPTIONAL MATCH (person1)-[:LIKES]->(m:Message)-[:HAS_CREATOR]->(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN m IS NULL THEN 0 ELSE 10 END) AS score
// case 4
OPTIONAL MATCH (person1)<-[:HAS_CREATOR]-(m:Message)<-[:LIKES]-(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN m IS NULL THEN 0 ELSE 1 END) AS score
// preorder
ORDER BY
city1.name ASC,
score DESC,
person1.id ASC,
person2.id ASC
WITH city1, collect({score: score, person1Id: person1.id, person2Id: person2.id})[0] AS top
RETURN
top.person1Id,
top.person2Id,
city1.name,
top.score
ORDER BY
top.score DESC,
top.person1Id ASC,
top.person2Id ASC
LIMIT 100
Q21. Most active Posters of a given Topic
For each person posting about a tag, aggregate three per-node degrees — messages created, replies received and likes received — with two OPTIONAL MATCH clauses collecting the latter two, then group by person and rank by 1×messages + 2×replies + 10×likes.
Source: LDBC SNB BI workload, bi-5.cypher, used unmodified.
Also exercises: T5.1 Degree Centrality
Parameters: tag: STRING
// Q5. Most active Posters of a given Topic
MATCH (tag:Tag {name: $tag})<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(person:Person)
OPTIONAL MATCH (message)<-[likes:LIKES]-(:Person)
WITH person, message, count(likes) AS likeCount
OPTIONAL MATCH (message)<-[:REPLY_OF]-(reply:Comment)
WITH person, message, likeCount, count(reply) AS replyCount
WITH person, count(message) AS messageCount, sum(likeCount) AS likeCount, sum(replyCount) AS replyCount
RETURN
person.id,
replyCount,
likeCount,
messageCount,
1*messageCount + 2*replyCount + 10*likeCount AS score
ORDER BY
score DESC,
person.id ASC
LIMIT 100
Q22. How many persons have a given number of posts
Compute the distribution of how many persons created N qualifying messages, where a message must have content, be shorter than a threshold, be newer than a start date, and belong to a post in one of the given languages. This is a per-person degree histogram over HAS_CREATOR.
Source: LDBC SNB BI workload, bi-12.cypher, used unmodified.
Parameters: startDate: DATE · lengthThreshold: INT · languages: STRING[]
// Q12. How many persons have a given number of posts MATCH (person:Person) OPTIONAL MATCH (person)<-[:HAS_CREATOR]-(message:Message)-[:REPLY_OF*0..]->(post:Post) WHERE message.content IS NOT NULL AND message.length < $lengthThreshold AND message.creationDate > $startDate AND post.language IN $languages WITH person, count(message) AS messageCount RETURN messageCount, count(person) AS personCount ORDER BY personCount DESC, messageCount DESC
Q23. Enrolment-year statistics over all study relationships
Count every STUDY_AT relationship and summarise its numeric classYear property with a global average, minimum and maximum. No grouping and no node properties are involved, so it isolates relationship scanning and aggregation.
Source: written for PropGBench, adapting the T4.3 template from the workload description.
Parameters:
// T4.3. Enrolment-year statistics over all study relationships
MATCH (:Person)-[studyAt:STUDY_AT]->(:University)
RETURN count(studyAt) AS edgeCount,
avg(studyAt.classYear) AS avgClassYear,
min(studyAt.classYear) AS minClassYear,
max(studyAt.classYear) AS maxClassYear
Q24. Enrolment-year statistics by university and gender
The grouped counterpart of the previous query: the same classYear statistics, grouped by a property of the target node (the university name) and one of the source node (the person’s gender), ordered by edge count. It measures multidimensional grouping and top-K materialisation.
Source: written for PropGBench, adapting the T4.4 template from the workload description.
Parameters: limit: INT
// T4.4. Enrolment-year statistics grouped by university and gender
MATCH (person:Person)-[studyAt:STUDY_AT]->(university:University)
RETURN university.name AS universityName,
person.gender AS gender,
count(studyAt) AS edgeCount,
avg(studyAt.classYear) AS avgClassYear,
min(studyAt.classYear) AS minClassYear,
max(studyAt.classYear) AS maxClassYear
ORDER BY edgeCount DESC, universityName, gender
LIMIT $limit
Q25. Friendship path counts by length and gender reached
Enumerate KNOWS paths up to three hops from one person and report, for each combination of path length and the gender of the person reached, how many paths there are and how many distinct persons they reach. Grouping by an endpoint property on top of path length is what separates this from a plain path-count query.
Source: written for PropGBench, adapting the T4.6 template from the workload description.
Parameters: personId: ID · limit: INT
// T4.6. Friendship path counts by length and by the gender reached
MATCH p = (:Person {id: $personId})-[:KNOWS*1..3]-(neighbor:Person)
RETURN length(p) AS pathLength,
neighbor.gender AS gender,
count(p) AS pathCount,
count(DISTINCT neighbor) AS distinctPersonCount
ORDER BY pathLength ASC, pathCount DESC
LIMIT $limit
Q26. Persons interested in both of two given tags
Return the persons who have a HAS_INTEREST edge to each of two named tags. Two MATCH clauses over the same variable express the intersection; replacing the second with an OPTIONAL MATCH would give the union instead.
Source: written for PropGBench, adapting the T4.7 template from the workload description.
Parameters: tagA: STRING · tagB: STRING · limit: INT
// T4.7. Persons interested in both of two given tags
MATCH (person:Person)-[:HAS_INTEREST]->(:Tag {name: $tagA})
MATCH (person)-[:HAS_INTEREST]->(:Tag {name: $tagB})
RETURN DISTINCT person.id AS personId,
person.firstName AS firstName,
person.lastName AS lastName
ORDER BY personId
LIMIT $limit
Q27. Degree centrality over the friendship graph
Rank persons by how many friends they have on the undirected KNOWS graph. It is the cheapest of the analytics queries — a single pass over the adjacency structure with no iteration — and gives a baseline against which PageRank’s cost can be read.
Source: written for PropGBench, adapting the T5.1 template from the workload description.
Parameters:
The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.
CALL gds.graph.project(
'KnowsGraph',
'Person',
{
KNOWS: {
type: 'KNOWS',
orientation: 'UNDIRECTED'
}
}
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;
Then run the following analytic query.
CALL gds.degree.stream('KnowsGraph')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS person, score
RETURN person.id AS personId,
person.firstName AS firstName,
person.lastName AS lastName,
toInteger(score) AS degree
ORDER BY degree DESC
LIMIT 10;
.stream mode, so nothing is written back to the database.Q28. PageRank over the friendship graph
Rank persons by PageRank on the undirected KNOWS graph to find the most structurally influential accounts. The algorithm iterates until the scores converge or the iteration limit is reached.
Source: written for PropGBench, adapting the T5.2 template from the workload description.
Parameters:
The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.
CALL gds.graph.project(
'KnowsGraph',
'Person',
{
KNOWS: {
type: 'KNOWS',
orientation: 'UNDIRECTED'
}
}
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;
Then run the following analytic query.
CALL gds.pageRank.stream('KnowsGraph')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS person, score
RETURN person.id AS personId,
person.firstName AS firstName,
person.lastName AS lastName,
score AS pagerank
ORDER BY pagerank DESC
LIMIT 10;
.stream mode, so nothing is written back to the database.Q29. Weakly connected components of the friendship graph
Find the connected regions of the undirected KNOWS graph and report the largest by size. On a generated social network almost every person falls in one giant component, so the result also shows how many isolated accounts the generator produced.
Source: written for PropGBench, adapting the T5.3 template from the workload description.
Parameters:
The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.
CALL gds.graph.project(
'KnowsGraph',
'Person',
{
KNOWS: {
type: 'KNOWS',
orientation: 'UNDIRECTED'
}
}
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;
Then run the following analytic query.
CALL gds.wcc.stream('KnowsGraph')
YIELD nodeId, componentId
RETURN componentId, count(*) AS componentSize
ORDER BY componentSize DESC
LIMIT 10;
.stream mode, so nothing is written back to the database.Q30. Strongly connected components of the directed friendship graph
Find the maximal sets of persons that can all reach each other following KNOWS edges in their stored direction. This needs a separate NATURAL projection rather than the undirected one. Note that SNB stores each friendship once, as a single directed edge, so mutual reachability is rare and the components are expected to be mostly singletons.
Source: written for PropGBench, adapting the T5.4 template from the workload description.
Parameters:
The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.
CALL gds.graph.project(
'KnowsDirectedGraph',
'Person',
{
KNOWS: {
type: 'KNOWS',
orientation: 'NATURAL'
}
}
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;
Then run the following analytic query.
CALL gds.scc.stream('KnowsDirectedGraph')
YIELD nodeId, componentId
RETURN componentId, count(*) AS componentSize
ORDER BY componentSize DESC
LIMIT 10;
.stream mode, so nothing is written back to the database.Q31. Louvain communities of the friendship graph
Partition the undirected KNOWS graph into densely connected communities with Louvain and report the largest. The generator correlates friendship with location and interests, so the communities it finds are not arbitrary.
Source: written for PropGBench, adapting the T5.5 template from the workload description.
Parameters:
The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.
CALL gds.graph.project(
'KnowsGraph',
'Person',
{
KNOWS: {
type: 'KNOWS',
orientation: 'UNDIRECTED'
}
}
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;
Then run the following analytic query.
CALL gds.louvain.stream('KnowsGraph')
YIELD nodeId, communityId
RETURN communityId, count(*) AS communitySize
ORDER BY communitySize DESC
LIMIT 10;
.stream mode, so nothing is written back to the database.Q32. Top thread initiators
Count the posts each person started within a window, together with the messages in the whole reply tree beneath them (REPLY_OF*0..) that also fall in the window. It combines two workloads: the bounded-length path enumeration of T4.5, and the ungrouped per-node count of T4.1 applied to the thread initiator, since the result is a count of distinct posts and distinct replies per person rather than a breakdown by path length.
Source: LDBC SNB BI workload, bi-9.cypher, used unmodified.
Parameters: startDate: DATE · endDate: DATE
// Q9. Top thread initiators MATCH (person:Person)<-[:HAS_CREATOR]-(post:Post)<-[:REPLY_OF*0..]-(reply:Message) WHERE post.creationDate >= $startDate AND post.creationDate <= $endDate AND reply.creationDate >= $startDate AND reply.creationDate <= $endDate RETURN person.id, person.firstName, person.lastName, count(DISTINCT post) AS threadCount, count(DISTINCT reply) AS messageCount ORDER BY messageCount DESC, person.id ASC LIMIT 100
Q33. Posting summary
Build a histogram over every message created before a given instant, grouped by year, by whether the message is a comment, and by length bucket (<40, <80, <160, rest), reporting count, average length, total length and share of all messages. It does not match a defined type because it summarises a node property (length) with no traversal at all, whereas the aggregation types cover degree statistics, edge-property statistics and path counts.
Source: LDBC SNB BI workload, bi-1.cypher, used unmodified.
Parameters: datetime: DATETIME
// Q1. Posting summary
MATCH (message:Message)
WHERE message.creationDate < $datetime
WITH count(message) AS totalMessageCountInt // this should be a subquery once Cypher supports it
WITH toFloat(totalMessageCountInt) AS totalMessageCount
MATCH (message:Message)
WHERE message.creationDate < $datetime
AND message.content IS NOT NULL
WITH
totalMessageCount,
message,
message.creationDate.year AS year
WITH
totalMessageCount,
year,
message:Comment AS isComment,
CASE
WHEN message.length < 40 THEN 0
WHEN message.length < 80 THEN 1
WHEN message.length < 160 THEN 2
ELSE 3
END AS lengthCategory,
count(message) AS messageCount,
sum(message.length) / toFloat(count(message)) AS averageMessageLength,
sum(message.length) AS sumMessageLength
RETURN
year,
isComment,
lengthCategory,
messageCount,
averageMessageLength,
sumMessageLength,
messageCount / totalMessageCount AS percentageOfMessages
ORDER BY
year DESC,
isComment ASC,
lengthCategory ASC
Q34. Tag evolution
For every tag of a given TagClass, compare how many messages referenced it in two adjacent 100-day windows and rank by the difference. The distinguishing work is the comparison of two independent time windows and the arithmetic between them, which no defined type covers.
Source: LDBC SNB BI workload, bi-2.cypher, used unmodified.
Parameters: date: DATE · tagClass: STRING
// Q2. Tag evolution
MATCH (tag:Tag)-[:HAS_TYPE]->(:TagClass {name: $tagClass})
// window 1
OPTIONAL MATCH (message1:Message)-[:HAS_TAG]->(tag)
WHERE $date <= message1.creationDate
AND message1.creationDate < $date + duration({days: 100})
WITH tag, count(message1) AS countWindow1
// window 2
OPTIONAL MATCH (message2:Message)-[:HAS_TAG]->(tag)
WHERE $date + duration({days: 100}) <= message2.creationDate
AND message2.creationDate < $date + duration({days: 200})
WITH
tag,
countWindow1,
count(message2) AS countWindow2
RETURN
tag.name,
countWindow1,
countWindow2,
abs(countWindow1 - countWindow2) AS diff
ORDER BY
diff DESC,
tag.name ASC
LIMIT 100
Q35. Top message creators in a country
First pick the 100 forums with the most members, then aggregate the message output of those forums’ members, using UNION ALL to keep members with zero messages in the result. It is a two-phase query whose second phase consumes the top-K output of the first, so neither phase alone characterises it.
Source: LDBC SNB BI workload, bi-4.cypher, used unmodified.
Parameters: date: DATE
// Q4. Top message creators in a country
MATCH (country:Country)<-[:IS_PART_OF]-(:City)<-[:IS_LOCATED_IN]-(person:Person)<-[:HAS_MEMBER]-(forum:Forum)
WHERE forum.creationDate > $date
WITH country, forum, count(person) AS numberOfMembers
ORDER BY numberOfMembers DESC, forum.id ASC, country.id
WITH DISTINCT forum AS topForum
LIMIT 100
WITH collect(topForum) AS topForums
CALL {
WITH topForums
UNWIND topForums AS topForum1
MATCH (topForum1)-[:CONTAINER_OF]->(post:Post)<-[:REPLY_OF*0..]-(message:Message)-[:HAS_CREATOR]->(person:Person)<-[:HAS_MEMBER]-(topForum2:Forum)
WITH person, message, topForum2
WHERE topForum2 IN topForums
RETURN person, count(DISTINCT message) AS messageCount
UNION ALL
// Ensure that people who are members of top forums but have 0 messages are also returned.
// To this end, we return each person with a 0 messageCount
WITH topForums
UNWIND topForums AS topForum1
MATCH (person:Person)<-[:HAS_MEMBER]-(topForum1:Forum)
RETURN person, 0 AS messageCount
}
RETURN
person.id AS personId,
person.firstName AS personFirstName,
person.lastName AS personLastName,
person.creationDate AS personCreationDate,
sum(messageCount) AS messageCount
ORDER BY
messageCount DESC,
person.id ASC
LIMIT 100
Q36. Most authoritative users on a given topic
A two-level authority score: person1’s score comes from the person2 who liked their message, and person2’s weight in turn depends on how many likes person2’s own messages received. count(DISTINCT like) ensures each person2’s popularity is added only once per person1. It propagates score through the graph like PageRank but at a fixed depth of two rather than iterating to convergence, so it is neither a degree statistic nor an iterative algorithm.
Source: LDBC SNB BI workload, bi-6.cypher, used unmodified.
Parameters: tag: STRING
// Q6. Most authoritative users on a given topic
MATCH (tag:Tag {name: $tag})<-[:HAS_TAG]-(message1:Message)-[:HAS_CREATOR]->(person1:Person)
OPTIONAL MATCH (message1)<-[:LIKES]-(person2:Person)
OPTIONAL MATCH (person2)<-[:HAS_CREATOR]-(message2:Message)<-[like:LIKES]-(person3:Person)
RETURN
person1.id,
// Using 'DISTINCT like' here ensures that each person2's popularity score is only added once for each person1
count(DISTINCT like) AS authorityScore
ORDER BY
authorityScore DESC,
person1.id ASC
LIMIT 100
// We need to use a redundant computation due to the lack of composable graph queries in the currently supported Cypher version.
// This might change in the future with new Cypher versions and GQL.
Q37. Central Person for a Tag
Score the followers and posters of a tag (100 points for an interest edge, 1 point per message in the window), then add up the same score across each person’s friends and return the highest combined total. The score is a weighted mix of two edge types that is then aggregated over a one-hop neighbourhood, which does not reduce to any single defined type.
Source: LDBC SNB BI workload, bi-8.cypher, used unmodified.
Parameters: tag: STRING · startDate: DATE · endDate: DATE
// Q8. Central Person for a Tag
MATCH (tag:Tag {name: $tag})
// score
OPTIONAL MATCH (tag)<-[interest:HAS_INTEREST]-(person:Person)
WITH tag, collect(person) AS interestedPersons
OPTIONAL MATCH (tag)<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(person:Person)
WHERE $startDate < message.creationDate
AND message.creationDate < $endDate
WITH tag, interestedPersons, interestedPersons + collect(person) AS persons
UNWIND persons AS person
WITH DISTINCT tag, person
WITH
tag,
person,
100 * size([(tag)<-[interest:HAS_INTEREST]-(person) | interest]) + size([(tag)<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(person) WHERE $startDate < message.creationDate AND message.creationDate < $endDate | message])
AS score
OPTIONAL MATCH (person)-[:KNOWS]-(friend)
// We need to use a redundant computation due to the lack of composable graph queries in the currently supported Cypher version.
// This might change in the future with new Cypher versions and GQL.
WITH
person,
score,
100 * size([(tag)<-[interest:HAS_INTEREST]-(friend) | interest]) + size([(tag)<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(friend) WHERE $startDate < message.creationDate AND message.creationDate < $endDate | message])
AS friendScore
RETURN
person.id,
score,
sum(friendScore) AS friendsScore
ORDER BY
score + friendsScore DESC,
person.id ASC
LIMIT 100
Q38. Experts in social circle
Among the persons whose KNOWS distance from a start person falls in the ring [minPathDistance, maxPathDistance], find those who live in a given country and have posted about a given TagClass. The ring is obtained by taking two reachable sets with apoc.path.subgraphNodes and subtracting the nearer one. It goes beyond a neighbourhood query because the ring is then joined against a location pattern and a TagClass pattern and aggregated per tag.
Source: LDBC SNB BI workload, bi-10.cypher, used unmodified.
Parameters: personId: ID · country: STRING · tagClass: STRING · minPathDistance: INT · maxPathDistance: INT
// Q10. Experts in social circle
// Requires the Neo4j APOC library
MATCH (startPerson:Person {id: $personId})
CALL apoc.path.subgraphNodes(startPerson, {
relationshipFilter: "KNOWS",
minLevel: 1,
maxLevel: $minPathDistance-1
})
YIELD node
WITH startPerson, collect(DISTINCT node) AS nodesCloserThanMinPathDistance
CALL apoc.path.subgraphNodes(startPerson, {
relationshipFilter: "KNOWS",
minLevel: 1,
maxLevel: $maxPathDistance
})
YIELD node
WITH nodesCloserThanMinPathDistance, collect(DISTINCT node) AS nodesCloserThanMaxPathDistance
// compute the difference of sets: nodesCloserThanMaxPathDistance - nodesCloserThanMinPathDistance
WITH [n IN nodesCloserThanMaxPathDistance WHERE NOT n IN nodesCloserThanMinPathDistance] AS expertCandidatePersons
UNWIND expertCandidatePersons AS expertCandidatePerson
MATCH
(expertCandidatePerson)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(:Country {name: $country}),
(expertCandidatePerson)<-[:HAS_CREATOR]-(message:Message)-[:HAS_TAG]->(:Tag)-[:HAS_TYPE]->
(:TagClass {name: $tagClass})
MATCH
(message)-[:HAS_TAG]->(tag:Tag)
RETURN
expertCandidatePerson.id,
tag.name,
count(DISTINCT message) AS messageCount
ORDER BY
messageCount DESC,
tag.name ASC,
expertCandidatePerson.id ASC
LIMIT 100
apoc.path.subgraphNodes twice and takes the set difference to isolate the ring of persons at a given KNOWS distance.Q39. Zombies in a country
Select the zombie accounts of a country by a rate test (fewer than one message per month over the account’s lifetime), then compute for each zombie the share of its likes that came from other zombies. The query first derives a subpopulation and then measures a ratio within it, which no defined type describes.
Source: LDBC SNB BI workload, bi-13.cypher, used unmodified.
Parameters: country: STRING · endDate: DATE
// Q13. Zombies in a country
MATCH (country:Country {name: $country})<-[:IS_PART_OF]-(:City)<-[:IS_LOCATED_IN]-(zombie:Person)
WHERE zombie.creationDate < $endDate
WITH country, zombie
OPTIONAL MATCH (zombie)<-[:HAS_CREATOR]-(message:Message)
WHERE message.creationDate < $endDate
WITH
country,
zombie,
count(message) AS messageCount
WITH
country,
zombie,
12 * ($endDate.year - zombie.creationDate.year )
+ ($endDate.month - zombie.creationDate.month)
+ 1 AS months,
messageCount
WHERE messageCount / months < 1
WITH
country,
collect(zombie) AS zombies
UNWIND zombies AS zombie
OPTIONAL MATCH
(zombie)<-[:HAS_CREATOR]-(message:Message)<-[:LIKES]-(likerZombie:Person)
WHERE likerZombie IN zombies
WITH
zombie,
count(likerZombie) AS zombieLikeCount
OPTIONAL MATCH
(zombie)<-[:HAS_CREATOR]-(message:Message)<-[:LIKES]-(likerPerson:Person)
WHERE likerPerson.creationDate < $endDate
WITH
zombie,
zombieLikeCount,
count(likerPerson) AS totalLikeCount
RETURN
zombie.id,
zombieLikeCount,
totalLikeCount,
CASE totalLikeCount
WHEN 0 THEN 0.0
ELSE zombieLikeCount / toFloat(totalLikeCount)
END AS zombieScore
ORDER BY
zombieScore DESC,
zombie.id ASC
LIMIT 100
Q40. Fake news detection
For each of two (tag, date) combinations, find the persons who posted about that tag on that day and who have at most maxKnowsLimit friends doing the same, then keep only the persons present in both. UNWIND plus a CALL subquery runs the same correlated pattern twice and the results are intersected, so the query is a parameterised self-join rather than a plain set operation over two label values.
Source: LDBC SNB BI workload, bi-16.cypher, used unmodified.
Parameters: tagA: STRING · dateA: DATE · tagB: STRING · dateB: DATE · maxKnowsLimit: INT
// Q16. Fake news detection
// These parameters return a 'false positive' as the maxKnowsLimit is set too high.
UNWIND [
{letter: 'A', tag: $tagA, date: $dateA},
{letter: 'B', tag: $tagB, date: $dateB}
] AS param
WITH param.letter AS paramLetter, param.tag AS paramTagX, param.date AS paramDateX
CALL {
WITH paramTagX, paramDateX
MATCH (person1:Person)<-[:HAS_CREATOR]-(message1:Message)-[:HAS_TAG]->(tag:Tag {name: paramTagX})
WHERE date(message1.creationDate) = date(paramDateX)
// filter out Persons with more than $maxKnowsLimit friends who created the same kind of Message
OPTIONAL MATCH (person1)-[:KNOWS]-(person2:Person)<-[:HAS_CREATOR]-(message2:Message)-[:HAS_TAG]->(tag)
WHERE date(message2.creationDate) = date(paramDateX)
WITH person1, count(DISTINCT message1) AS cm, count(DISTINCT person2) AS cp2
WHERE cp2 <= $maxKnowsLimit
// return count
RETURN person1, cm
}
WITH person1, collect({letter: paramLetter, messageCount: cm}) AS results
WHERE size(results) = 2
RETURN
person1.id,
[r IN results WHERE r.letter = 'A' | r.messageCount][0] AS messageCountA,
[r IN results WHERE r.letter = 'B' | r.messageCount][0] AS messageCountB
ORDER BY messageCountA + messageCountB DESC, person1.id ASC
LIMIT 20
Q41. Information propagation analysis
Structurally the heaviest query in the set: one MATCH co-joins six patterns spanning two distinct forums, three persons and two variable-length REPLY_OF*0.. segments, then requires message2 to be more than delta hours later than message1 and negates person1’s membership of forum2. It is a branched multi-way join with a temporal predicate, not a line, a cycle or a clique.
Source: LDBC SNB BI workload, bi-17.cypher, used unmodified.
Parameters: tag: STRING · delta: INT
// Q17. Information propagation analysis
MATCH
(tag:Tag {name: $tag}),
(person1:Person)<-[:HAS_CREATOR]-(message1:Message)-[:REPLY_OF*0..]->(post1:Post)<-[:CONTAINER_OF]-(forum1:Forum),
(message1)-[:HAS_TAG]->(tag),
// Having two HAS_MEMBER edges in the same MATCH clause ensures that person2 and person3 are different
// as Cypher's edge-isomorphic matching does not allow for such a match in a single MATCH clause.
(forum1)<-[:HAS_MEMBER]->(person2:Person)<-[:HAS_CREATOR]-(comment:Comment)-[:HAS_TAG]->(tag),
(forum1)<-[:HAS_MEMBER]->(person3:Person)<-[:HAS_CREATOR]-(message2:Message),
(comment)-[:REPLY_OF]->(message2)-[:REPLY_OF*0..]->(post2:Post)<-[:CONTAINER_OF]-(forum2:Forum)
// The query allows message2 = post2. If this is the case, their HAS_TAG edges to tag overlap,
// and Cypher's edge-isomorphic matching does not allow for such a match in a single MATCH clause.
// To work around this, we add them in separate MATCH clauses.
MATCH (comment)-[:HAS_TAG]->(tag)
MATCH (message2)-[:HAS_TAG]->(tag)
WHERE forum1 <> forum2
AND message2.creationDate > message1.creationDate + duration({hours: $delta})
AND NOT (forum2)-[:HAS_MEMBER]->(person1)
RETURN person1.id, count(DISTINCT message2) AS messageCount
ORDER BY messageCount DESC, person1.id ASC
LIMIT 10