The previous posts in this series used a small music/playlist graph and the built-in Movie graph to learn Cypher. From here on we switch to a larger and more interesting graph, an airport dataset, because it is the kind of data the Graph Data Science (GDS) algorithms were designed for: airports are nodes, the places they belong to are nodes, and questions such as which places are most central in the network? or which nodes form tightly connected communities? only make sense on a graph of this shape.
Starting the flights DBMS
We do not need a new setup for this. The Docker Compose file introduced in Understanding the Fundamentals of Graph RAG, under A worked example: one DBMS per dataset, already defines a second service called flights next to the movies one. That service is the one we use throughout this post, and it was deliberately configured for exactly this work:
| Setting | Value for flights |
|---|---|
| Browser | http://localhost:7475 |
| Bolt | bolt://localhost:7688 |
| User | neo4j |
| Password | flightspassword |
| Plugins | GDS + APOC |
| Heap | 4G |
| Import mount | ~/neo4j-volumes/flights/import |
The GDS plugin is only installed on this instance, which is why the graph algorithms in the rest of this post run here and not on the movies DBMS.
From the folder that holds docker-compose.yml, bring up just this one service:
docker compose up -d flights # start only the flight DBMS
docker compose ps # check that it is running and healthy
docker compose logs -f flights # follow the startup log if it is notThen open http://localhost:7475 and log in as neo4j / flightspassword. When you are finished:
docker compose stop flights # stop it, keep the dataThe flights service mounts ${HOME}/neo4j-volumes/flights/import rather than the project’s ./import folder. Keeping the database volumes outside the Quarto project avoids the PermissionDenied ... readdir error that quarto render otherwise hits when it scans the root-owned folders Neo4j creates.
Step 1: Load the CSV dataset
Making the CSV visible to Neo4j
LOAD CSV with a file:/// URL does not read from your host filesystem, it reads from Neo4j’s import directory inside the container. For the flights service that directory is mapped to ~/neo4j-volumes/flights/import, so the airport file has to be copied there first:
mkdir -p ~/neo4j-volumes/flights/import
cp posts/14_graph_rag/import/airport-node-list.csv \
~/neo4j-volumes/flights/import/Neo4j picks the file up immediately; there is no need to restart the container. If the folder is owned by root because Neo4j created it first, prefix the copy with sudo and make sure the file stays readable (sudo chmod a+r ~/neo4j-volumes/flights/import/airport-node-list.csv).
Inspecting the file before importing
The airport file has a header row, so we read it with LOAD CSV WITH HEADERS, which turns every line into a map keyed by the column names. Before creating a single node it is worth simply looking at the data. Returning the raw row for the first few lines shows the column names, the values, and, importantly, how Neo4j has typed them:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
RETURN row
LIMIT 5;
Two things are worth reading carefully in the result:
- The columns. Each airport carries an
id, theiataandicaocodes,city, a longerdescr,region,runways,longest,altitude,country,continent, and itslat/loncoordinates. - The types.
LOAD CSVreturns every field as a string, quotes in the file or not. That meansrunways,longest,altitude,lat, andlonarrive as text such as"5"and"33.6366996765137", and will have to be converted withtoInteger()andtoFloat()when we create the nodes. Skipping that conversion is the single most common mistake in aLOAD CSVimport: the data loads without any error, and only later does sorting by altitude or computing distances start returning nonsense.
The LIMIT 5 keeps this a cheap preview. To confirm that the whole file is readable and to see how many airports we are about to import, count the rows instead:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
RETURN count(row) AS airports;
The file holds 3503 airports.
Step 2: Adapting the Movie import to the airport data
In the Movie post the graph was written out by hand: every MERGE named a specific film or person. Here the same ideas apply, but the values come from the CSV instead of from the query text. The translation is mechanical:
| Movie graph | Airport graph |
|---|---|
MERGE (:Movie {title:'The Matrix'}) |
MERGE (:Airport {iata: row.iata}) |
MERGE (:Person {name:'Keanu Reeves'}) |
MERGE (:City {name: row.city, ...}) |
ON CREATE SET m.released=1999 |
ON CREATE SET a.altitude = toInteger(row.altitude) |
| One block per film | One pass over the CSV, one block per row |
ACTED_IN, DIRECTED, PRODUCED |
IN_CITY, IN_REGION, IN_COUNTRY, ON_CONTINENT |
airport-node-list.csv is exactly what its name says: a list of nodes. Every row describes one airport and nothing else, so the file on its own would make a table rather than a graph. What it does contain is geography, city, region, country, and continent, and each of those repeats across many rows. Turning those repeated strings into their own nodes is what gives us relationships to traverse and, later, to run algorithms over.
Adding uniqueness constraints
Exactly as in the Movie post, we add the constraints before importing. Besides preventing duplicates, each constraint creates an index that makes the MERGE statements below much faster, and with 3503 rows each doing five MERGEs, that difference is no longer academic:
CREATE CONSTRAINT airport_iata IF NOT EXISTS
FOR (a:Airport) REQUIRE a.iata IS UNIQUE;
CREATE CONSTRAINT city_name_country IF NOT EXISTS
FOR (c:City) REQUIRE (c.name, c.country) IS UNIQUE;
CREATE CONSTRAINT region_code IF NOT EXISTS
FOR (r:Region) REQUIRE r.code IS UNIQUE;
CREATE CONSTRAINT country_code IF NOT EXISTS
FOR (c:Country) REQUIRE c.code IS UNIQUE;
CREATE CONSTRAINT continent_code IF NOT EXISTS
FOR (c:Continent) REQUIRE c.code IS UNIQUE;
The choice of key is the one real decision here:
Airport.iata. The three-letter IATA code is the natural business key, and a quick check of the file confirms it is present and unique on every row. The numericidcolumn would work too, butiatais what people actually recognise and what other flight datasets join on.Cityneeds two properties. City names are not globally unique, so a single-property constraint onnamewould refuse to import the second Springfield or the second San José. A composite uniqueness constraint on(name, country)is the correct key, and it is available in Community Edition (onlyIS NODE KEYis Enterprise-only).Region,Country,Continent. These are already codes in the file (US-GA,US,NA), so the code itself is the key.
Importing the airports and their geography
The script below builds the whole graph in one pass. Like the Movie script it uses MERGE throughout, so it is idempotent: running it twice does not create duplicate nodes or relationships. Each row introduces one airport, the places it belongs to, and the relationships that connect them:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
MERGE (a:Airport {iata: row.iata})
ON CREATE SET a.id = toInteger(row.id),
a.icao = row.icao,
a.descr = row.descr,
a.runways = toInteger(row.runways),
a.longest = toInteger(row.longest),
a.altitude = toInteger(row.altitude),
a.location = point({
latitude: toFloat(row.lat),
longitude: toFloat(row.lon)})
MERGE (c:City {name: row.city, country: row.country})
MERGE (r:Region {code: row.region})
MERGE (co:Country {code: row.country})
MERGE (con:Continent {code: row.continent})
MERGE (a)-[:IN_CITY]->(c)
MERGE (a)-[:IN_REGION]->(r)
MERGE (r)-[:IN_COUNTRY]->(co)
MERGE (co)-[:ON_CONTINENT]->(con);
Figure 1 shows what a successful run looks like. The sidebar is the quickest sanity check: the labels Airport, City, Continent, Country and Region are all present, the relationship types are exactly the four the script creates, and the property keys include location, which confirms the spatial point was stored rather than a pair of strings. The 8,677 nodes are the 3,503 airports plus the roughly 5,000 distinct cities, regions, countries and continents they resolve to, which is precisely the deduplication that MERGE is doing on our behalf.
The parts worth pausing on:
- Type conversion. This is where the observation from Step 1 is paid off.
toInteger()andtoFloat()turn the CSV’s strings into real numbers, soORDER BY a.altitudesorts numerically instead of alphabetically. point(). Rather than storinglatandlonas two loose floats, we store a single spatialpoint. Neo4j can then compute great-circle distances withpoint.distance(a.location, b.location), which turns “how far apart are these two airports?” into a one-line query.ON CREATE SETversusSET. As in the Movie script,ON CREATE SETwrites the properties only when the node is genuinely new. Re-running the import therefore leaves existing airports untouched. Use plainSETinstead if you want a re-run to refresh the properties from an updated file.MERGEon the relationships.MERGE (a)-[:IN_CITY]->(c)looks for the whole pattern first, so the second airport in Atlanta reuses the existingCitynode instead of creating a second one. This is precisely the duplicate-node problem we ran into withCREATEin theMERGEpost.- Direction. The relationships point from the specific to the general, airport → city, region → country, country → continent, which reads naturally and keeps traversals such as “every airport in Europe” short.
3503 rows import comfortably in a single transaction. For a larger file, wrap the work in a batched call so Neo4j commits as it goes:
LOAD CSV WITH HEADERS FROM 'file:///airport-node-list.csv' AS row
CALL {
WITH row
MERGE (a:Airport {iata: row.iata})
// ... the rest of the statement above
} IN TRANSACTIONS OF 1000 ROWS;
Checking the result
A quick count confirms that the import produced what we expect, one node per airport plus the far smaller set of geography nodes:
MATCH (n)
RETURN labels(n) AS label, count(*) AS nodes
ORDER BY nodes DESC;
Figure 2 breaks the 8,677 total down by label, and the shape of that breakdown is the import working as intended. Airport is 3503, exactly one node per row of the CSV, which confirms that no airport was duplicated or dropped. Every label below it is smaller than 3503 because those values repeat across rows: 3409 cities means a few dozen cities have more than one airport, 1527 regions and 232 countries are plausible administrative counts, and the 6 continents are simply the six continent codes the file uses. Had we used CREATE instead of MERGE, every one of these rows would read 3503 instead.
The most useful check, though, is the schema itself. As in the Movie post, db.schema.visualization() draws the model rather than the data, one circle per label and one arrow per relationship type:
CALL db.schema.visualization();
Airport, City, Region, Country and Continent, joined by the four relationship types created by the import. Clicking a label shows its constraints; Continent carries the continent_code uniqueness constraint and its owned index.
Figure 3 is exactly the shape the import script describes, and reading it confirms three things at once. Airport sits at the centre with two outgoing arrows, IN_CITY and IN_REGION; the geography then chains upwards, Region → IN_COUNTRY → Country → ON_CONTINENT → Continent. There are no stray labels or relationship types, which means no typo created a :Countries or :IN_Country variant. Selecting a label shows its constraints in the details panel, so this one view also verifies that the uniqueness constraints from the previous section are attached to the right labels.
CALL db.schema.visualization() reads the database’s token store rather than the data, so a label or relationship type that was created and later deleted can still appear until the store is recomputed. It describes what can exist, not what currently does.
And a single airport shows the whole pattern in one picture, with the numeric properties now genuinely numeric:
MATCH path = (:Airport {iata:'ATL'})-[*1..2]->()
RETURN path;
If something looks wrong and you want to start again, the constraints and the data can be cleared independently:
MATCH (n) DETACH DELETE n; // remove the data, keep the constraints
DROP CONSTRAINT airport_iata; // remove a constraint, if needed
Step 3: Projecting the graph for GDS
Everything so far has been ordinary Cypher, and it wrote to the database on disk. The GDS algorithms do not run against that stored graph. They run against an in-memory projection: a compact, read-only copy of just the labels, relationship types and properties an algorithm actually needs, held in the heap of the flights DBMS. This is why the flights service was given a 4G heap and the GDS plugin, and it is why every algorithm in the rest of this series starts by naming a projection rather than a pattern.
The canonical projection for this dataset is the airports together with the cities they serve:
CALL gds.graph.project(
'airports-cities',
['Airport', 'City'],
'IN_CITY'
)
YIELD
graphName, nodeProjection, nodeCount, relationshipProjection,
relationshipCount;
The three positional arguments are all there is to it:
- The name the projection is stored under (
airports-cities). - The node projection (every node labelled
AirportorCity). - The relationship projection (every
IN_CITYrelationship between them).
The YIELD clause echoes back what was built, and nodeCount and relationshipCount are the two numbers worth reading, since a relationshipCount of 0 is the usual sign that the type was misspelled or that the relationships were never created.
Both node labels are passed as a list, because a projection must contain every node that its relationships connect: projecting IN_CITY without also projecting City would leave every relationship dangling, and GDS would drop it.
airports-cities projection built in Neo4j Browser. The result row reports a nodeCount of 6912 and a relationshipCount of 3503, and the whole projection was assembled in well under a second.
Both numbers in Figure 4 are worth checking against what we know about the data. The 3503 relationships are exactly one IN_CITY per airport, as expected since every row of the CSV names a city. The 6912 nodes are the 3503 airports plus 3409 distinct cities, which says that a few hundred cities serve more than one airport, the deduplication MERGE performed in Step 2 now visible as a number.
Adding properties and multiple types
The simple three-argument form is the shorthand. The full form replaces the second and third arguments with maps, which is what lets a projection carry several labels, several relationship types, node properties for the algorithm to read, and a relationship property to use as a weight:
CALL gds.graph.project(
'geography',
{
Airport: { properties: ['altitude', 'runways'] },
City: {},
Region: {},
Country: {}
},
{
IN_CITY: { orientation: 'NATURAL' },
IN_REGION: { orientation: 'NATURAL' },
IN_COUNTRY: { orientation: 'UNDIRECTED' }
}
)
YIELD graphName, nodeCount, relationshipCount;
The three settings that matter most:
propertieson a node. Only the properties listed here are copied into memory, and only numeric ones can be.altitudeandrunwaysare usable because we converted them withtoInteger()in Step 2; had we left them as the stringsLOAD CSVproduced, this projection would fail outright. That is the type-conversion mistake finally announcing itself.orientation.NATURALkeeps the stored direction,REVERSEflips it, andUNDIRECTEDstores both directions. The choice is not cosmetic: PageRank on aNATURALprojection measures something quite different from PageRank on anUNDIRECTEDone, and most community-detection algorithms expectUNDIRECTED.- A relationship property. A relationship can carry a numeric property in the same way,
{ IN_CITY: { properties: ['weight'] } }, which is what shortest-path and weighted-centrality algorithms read when you ask them to minimise a cost rather than a number of hops.
Managing projections
Projections live in memory and belong to the user who created them, so they survive between queries but not a database restart. Three procedures cover day-to-day use:
CALL gds.graph.list()
YIELD graphName, nodeCount, relationshipCount, memoryUsage;
CALL gds.graph.exists('geography')
YIELD exists;
CALL gds.graph.drop('geography')
YIELD graphName;
gds.graph.list() showing the single projection currently held in memory: airports-cities, with 6912 nodes, 3503 relationships, and a memoryUsage of 2027 KiB.
Figure 5 is the answer to “what is currently occupying the heap?”. Only one row is returned, airports-cities, and its counts match what was reported when the projection was built. The interesting column is memoryUsage: roughly 2 MB for a graph of almost 7000 nodes, a fraction of what the same data occupies on disk, because the projection stores only the topology and the properties we asked for. That compactness is the whole point of projecting, and it is also why keeping stale projections around is worth avoiding on a graph large enough to matter.
gds.graph.exists('geography') returning a single boolean column, TRUE.
Figure 6 shows the lighter-weight check. Where gds.graph.list() returns a row per projection with all its statistics, gds.graph.exists() answers one yes/no question about one name, which is what you want in a script before deciding whether to project or to reuse. The TRUE here confirms the geography projection from the previous section is still resident.
gds.graph.project fails if the name is already taken, so gds.graph.drop is the command you will reach for most often while iterating on a projection. Dropping one frees the heap immediately and never touches the stored graph.
Failed to invoke procedure `gds.graph.project`: Caused by:
java.lang.IllegalArgumentException: A graph with name 'airports-cities'
already exists.
This is what re-running the projection looks like: the name is still held in memory from the first call. Drop it before projecting again, or guard the call with gds.graph.exists:
CALL gds.graph.drop('airports-cities', false);
Step 4: Ranking nodes with PageRank Algorithms
With a projection in memory, an algorithm is a single call. PageRank is the natural first one: it gives a node a high score when many other well-connected nodes point at it. Run it over the geography projection, which has to be built with all four relationship types so that rank has somewhere to flow:
CALL gds.graph.project(
'geography',
['Airport', 'City', 'Region', 'Country', 'Continent'],
['IN_CITY', 'IN_REGION', 'IN_COUNTRY', 'ON_CONTINENT']
);
geography projection built with all five labels and four relationship types. The result row reports a nodeCount of 8677 and a relationshipCount of 8771, and the nodeProjection and relationshipProjection columns expand to show every label and type that was copied into memory.
CALL gds.pageRank.stream('geography')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS n, score AS pageRank
RETURN coalesce(n.iata, n.name, n.code) AS place,
labels(n)[0] AS type,
pageRank
ORDER BY pageRank DESC, place ASC
LIMIT 20;
The pattern here is the one every GDS algorithm follows:
.streamis the execution mode. It returns one row per node and writes nothing back. The alternatives are.write, which stores the score as a property on the stored graph,.mutate, which stores it on the projection so a later algorithm can read it, and.stats, which returns only summary numbers.YIELD nodeId, score. GDS works with internal numeric ids, not nodes, so the raw result is deliberately anonymous.gds.util.asNode(nodeId). This is the step that turns the projection’s id back into a real node from the stored graph, which is the only way to get at properties such asiataanddescr; those were never copied into the projection.coalesce(n.iata, n.name, n.code). The projection mixes labels and each one keeps its identifier in a different property, so a baren.iatawould returnnullfor every city and country.LIMIT 20. Without it the query streams all 8677 nodes.
AS at 107.99, with the United States the only country to break into the list at 38.41.
The result in Figure 8 is dominated by continents and large countries rather than airports, and that is the correct answer for this graph: every airport pushes rank towards its region, every region towards its country, and every country towards its continent, so rank accumulates at the top of the hierarchy. The ordering within that top group is informative in its own right, since it tracks how many airports feed each continent, AS ahead of EU, AF and NA. The one country in the list, US, is there because it has far more regions beneath it than any other. It is a useful sanity check precisely because the ranking is predictable, which tells us the call is wired up correctly.
Filtering to the airports
If you only want airports in the output, filter after converting the id back to a node rather than trying to filter inside the algorithm:
CALL gds.pageRank.stream('geography')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS n, score AS pageRank
WHERE n:Airport
RETURN n.iata AS iata, n.descr AS description, pageRank
ORDER BY pageRank DESC, iata ASC
LIMIT 10;
0.15, the algorithm’s baseline value, because no airport has another airport pointing at it, the IN_CITY and IN_REGION relationships all lead upward to geography nodes.
The call takes one input and yields two values back:
'geography'. The name of the projection to stream from, exactly as it was registered withgds.graph.project.nodeId. The internal id of each node, converted back to a real node withgds.util.asNodeso theWHERE n:Airportfilter and theiata/descrproperties become available.score. The PageRank value for that node, aliased topageRankfor theRETURNandORDER BY.
Bear in mind what these scores mean. They rank airports by their position in the geographic hierarchy, not by how busy they are, because that hierarchy is the only structure this dataset contains. Ranking airports by traffic would need a second file describing the connections between them, which is a topic for a later post.
Writing the scores back
.stream is fine for looking at a result once, but the scores vanish as soon as the query returns. Switching to the .write execution mode stores each score as a property on the stored graph, so it becomes ordinary data that any later Cypher query can read, filter and sort:
CALL gds.pageRank.write('geography', {
writeProperty: 'pageRank'
})
YIELD nodePropertiesWritten, ranIterations, didConverge;
.write execution mode reporting its result. nodePropertiesWritten is 8677, one per node in the projection, ranIterations is 4, and didConverge is TRUE, so the scores settled well before the iteration limit and are now stored on the graph.
The call takes two inputs and reports three values back:
'geography'. The name of the projection to run against, exactly as it was registered withgds.graph.project.writeProperty: 'pageRank'. Names the property to create on every node in the projection, airports and geography alike.nodePropertiesWritten. How many nodes had the property written, one per node in the projection.ranIterations. How many iterations the algorithm ran before stopping.didConverge. Whether the scores settled before hitting the iteration limit rather than being cut off at it.
Reading the scores back is then plain Cypher, with no GDS involved at all:
MATCH (a:Airport)
RETURN a.iata AS iata, a.descr AS description, a.pageRank AS pageRank
ORDER BY a.pageRank DESC, a.iata ASC
LIMIT 10;
pageRank property back with plain Cypher, no GDS procedure involved. The scores match the streamed values exactly, confirming that .write persisted them onto the Airport nodes as ordinary properties.
Compare this with the streaming version above. There is no gds.util.asNode, because we are matching real nodes from the start, and the label filter is the MATCH (a:Airport) pattern rather than a WHERE clause applied afterwards. Filtering by label is also cheap here, since MATCH uses the label index instead of streaming all 8677 nodes and discarding most of them.
Because pageRank is now a stored property, it composes with everything else in the graph:
MATCH (a:Airport)-[:IN_REGION]->(:Region)-[:IN_COUNTRY]->(c:Country {code:'US'})
RETURN a.iata AS iata, a.descr AS description, a.pageRank AS pageRank
ORDER BY a.pageRank DESC, a.iata ASC
LIMIT 10;
The stored score is also visible in the graph itself, not just in tabular results. Returning the nodes and relationships rather than their properties draws the pattern in Neo4j Browser, and clicking any node shows pageRank alongside its other properties:
MATCH (a:Airport)-[in_region:IN_REGION]->(region:Region)
RETURN a, in_region, region
IN_REGION pattern drawn in Neo4j Browser, with the US-AK region node selected. The Node details panel lists its pageRank of 9.71 next to the code, confirming that the score written by .write is now an ordinary node property that the graph view can read like any other.
.write genuinely modifies the database. Re-running it overwrites the property, and a different projection or a different algorithm writing to the same writeProperty will silently replace the earlier values. Clearing the scores is a single statement:
MATCH (n) WHERE n.pageRank IS NOT NULL REMOVE n.pageRank;
Step 5: Finding communities with Louvain community detection algorithm
PageRank scored every node on its own; community detection does something different, it partitions the graph into groups whose members are more densely connected to each other than to the rest. The Louvain algorithm is the usual first choice: it optimises modularity, repeatedly merging nodes into communities as long as doing so raises how much more connected each group is than chance would predict.
Two properties of this dataset shape what Louvain can and cannot find, and both are worth stating before running it:
- There are no routes between airports. A flight dataset of the kind Louvain was built for would connect airports directly, and its communities would then be regions of heavy traffic. Our file is a node list, so the only edges are the hierarchy
airport → city → region → country → continent. Louvain therefore recovers that hierarchy, grouping each continent’s subtree into a community, which is a useful check that the algorithm is wired up correctly rather than a surprising discovery. - Louvain requires undirected relationships. Modularity is defined on an undirected graph, so GDS refuses to run Louvain on the
NATURALprojection from Step 4. The projection has to be rebuilt with every type orientedUNDIRECTED.
Projecting the graph undirected
The projection carries the same five labels and four relationship types as the geography projection, with orientation: 'UNDIRECTED' on each type so that rank, or here membership, can flow in both directions:
CALL gds.graph.project(
'geography-undirected',
['Airport', 'City', 'Region', 'Country', 'Continent'],
{
IN_CITY: { orientation: 'UNDIRECTED' },
IN_REGION: { orientation: 'UNDIRECTED' },
IN_COUNTRY: { orientation: 'UNDIRECTED' },
ON_CONTINENT: { orientation: 'UNDIRECTED' }
}
)
YIELD graphName, nodeCount, relationshipCount;
geography-undirected projection built with all five labels and every relationship type oriented UNDIRECTED. The result row reports a nodeCount of 8677 and a relationshipCount of 17542, double the 8771 of the NATURAL geography projection because each edge is now stored in both directions.
The full map form of the relationship projection is what lets us set orientation per type, exactly as in the Adding properties and multiple types section above. The nodeCount is the same 8677 as before, but the relationshipCount doubles to 17542, because UNDIRECTED stores each edge in both directions.
Running Louvain algorithms
With the undirected projection in memory, the call follows the same .stream pattern as PageRank, YIELD the raw result, turn the id back into a node, then aggregate:
CALL gds.louvain.stream('geography-undirected')
YIELD nodeId, communityId
WITH gds.util.asNode(nodeId) AS n, communityId
WHERE n:Airport
MATCH (n)-[:IN_CITY]->(c:City)
RETURN
communityId,
count(n) AS numberOfAirports,
collect(DISTINCT c.name) AS cities
ORDER BY numberOfAirports DESC, communityId;
numberOfAirports and the list of cities those airports serve; the largest communities correspond to the continent subtrees, exactly as the hierarchy predicts.
The parts worth pausing on:
'geography-undirected'. The name of the undirected projection to stream from, exactly as it was registered withgds.graph.project.WHERE n:Airport. Louvain assigns acommunityIdto every node in the projection, cities and continents included. Filtering to airports after converting the id back to a node counts each community in airports alone, which is what makesnumberOfAirportsmeaningful.count(n). The idiomatic aggregate for a row count, replacing theSIZE(COLLECT(n))idiom.(n)-[:IN_CITY]->(c:City)andc.name. In this graphcityis a node, not a property on the airport, so the city name is reached by traversingIN_CITYrather than readingn.city. This works becausegds.util.asNodereturns the real stored node, whose relationships are still there to follow.
The communities that come back track the continent hierarchy, the largest holding the airports of the busiest continents, which is the expected result for a graph whose only structure is geographic.
To store the community label instead of streaming it, switch to .write exactly as with PageRank:
CALL gds.louvain.write('geography-undirected', {
writeProperty: 'community'
})
YIELD communityCount, modularity;
.write execution mode reporting its result: communityCount is the number of communities Louvain settled on and modularity scores how well-separated they are, confirming the community property is now stored on every node.
communityCount is how many communities Louvain settled on, and modularity scores how well-separated they are, from 0 (no better than random) towards 1.
Visualising the communities
Once community is a stored property, drawing the result is plain Cypher, the same move used for pageRank above: return the nodes and relationships rather than a table, and Neo4j Browser renders the subgraph. The one thing to know is that Browser colours nodes by their label, not by an arbitrary property, so seeing the communities takes one of the two approaches below.
The most direct is to draw a single community at a time. Pick a communityId from the streamed result and match just that group, so every node on screen belongs to it:
MATCH (a:Airport {community: 42})-[r:IN_CITY]->(c:City)
RETURN a, r, c;
To choose a community worth looking at, list the largest few first:
MATCH (a:Airport)
RETURN a.community AS community, count(*) AS airports
ORDER BY airports DESC
LIMIT 5;
To see several communities at once, return a capped subgraph and set the Airport caption to community in the Browser sidebar (click the Airport label chip, then Caption, then community). Every node then prints its community number, and the LIMIT keeps the render responsive:
MATCH (a:Airport)-[r:IN_CITY]->(c:City)
RETURN a, r, c
LIMIT 300;
Airport nodes fanning out to the City nodes they serve. The separation between the groups is the community structure itself, shown as a picture rather than a table of ids.
Plain Browser cannot colour nodes by a property value. To colour each community distinctly, open the graph in Neo4j Bloom, which can style node colour straight from a numeric property such as community. The alternative, promoting the community to a label so Browser colours it, works but writes a label per community into the token store, so it is best reserved for a quick look and dropped afterwards.
Step 6: Comparing nodes with node similarity
PageRank scored nodes and Louvain grouped them; node similarity answers a third kind of question, which pairs of nodes are alike? GDS computes the Jaccard similarity of their neighbourhoods, so two airports come out similar when they point at the same nodes. In the geography projection those neighbours are the geography an airport belongs to, so the algorithm reports airports that share a city or a region rather than airports that share traffic, the same consequence of a route-free node list we have met throughout this post.
The call follows the familiar .stream pattern. Because a city here is a node reached through IN_CITY rather than a property on the airport, the city name is read by traversing that relationship after gds.util.asNode turns each id back into a real node:
CALL gds.nodeSimilarity.stream('geography')
YIELD node1, node2, similarity
WITH gds.util.asNode(node1) AS n1, gds.util.asNode(node2) AS n2, similarity
WHERE n1:Airport AND n2:Airport
MATCH (n1)-[:IN_CITY]->(c1:City)
MATCH (n2)-[:IN_CITY]->(c2:City)
RETURN
n1.iata AS iata,
c1.name AS city,
COLLECT({iata: n2.iata, city: c2.name, similarityScore: similarity}) AS similarAirports
ORDER BY city
LIMIT 20;
iata and city and the similarAirports list of the airports GDS scored as most alike; every similarityScore is 1.0, because airports are compared only on the geography they share and airports in the same city point at exactly the same nodes.
Figure 17 is the expected shape for this graph. The pairs come back with a perfect similarityScore of 1.0 because two airports serving the same city share an identical neighbourhood, the same City and Region nodes, so their Jaccard similarity is exactly one. As with PageRank and Louvain, the result reflects the geographic hierarchy rather than traffic, since a route file describing airport-to-airport connections is the missing ingredient that would let node similarity find airports that are alike in how they are flown.
The parts worth pausing on:
'geography'. The name of the projection to stream from, exactly as it was registered withgds.graph.project. ItsIN_CITYandIN_REGIONedges are the neighbourhoods being compared.node1andnode2. Node similarity yields a pair of internal ids per row, each converted back to a real node withgds.util.asNodeso theWHERE n:Airportfilter and theiataproperty become available.(n)-[:IN_CITY]->(c:City). The city is a node in this graph, so its name is reached by traversingIN_CITYrather than reading a property off the airport.COLLECT(...). Gathers every airport found similar ton1into one row, each with itsiata,cityand thesimilarityScorethat ranks it.
Limiting the result with topK and topN
Streaming every similar pair is fine on this graph, but on a larger one the number of pairs explodes. Two configuration parameters keep the result small without a manual LIMIT: topK caps how many neighbours each node keeps, and topN caps how many pairs the whole call returns. Asking for the single best match per airport and the ten strongest pairs overall is one line of configuration:
CALL gds.nodeSimilarity.stream(
'geography',
{
topK: 1,
topN: 10
}
)
YIELD node1, node2, similarity
WITH gds.util.asNode(node1) AS n1, gds.util.asNode(node2) AS n2,
similarity AS similarityScore
WHERE n1:Airport AND n2:Airport
MATCH (n1)-[:IN_CITY]->(c1:City)
MATCH (n2)-[:IN_CITY]->(c2:City)
RETURN
n1.iata AS iata,
c1.name AS city,
{iata: n2.iata, city: c2.name} AS similarAirport,
similarityScore
ORDER BY city;
The two parameters do different jobs:
topK: 1. For each airport, keep only its single most similar neighbour rather than every airport it shares geography with. This is a per-node cap, so the result still has one row per airport.topN: 10. Across the whole result, return only the ten highest-scoring pairs. This is a global cap applied aftertopK, which is what makes the explicitLIMITunnecessary.
Unlike the collected form above, each row here names a single similarAirport rather than a list, because topK: 1 already reduced every airport to its one best match.
topK/topN node similarity stream returning a single row: DFW in Dallas paired with DAL, also in Dallas, at a similarityScore of 1.0.
Figure 18 is what the capped query returns: one row, DFW paired with DAL, both in Dallas, at a perfect similarityScore of 1.0. Reading it takes three steps:
Why these two. Dallas is served by two airports, Dallas/Fort Worth (
DFW) and Dallas Love Field (DAL). In thegeographyprojection an airport’s only neighbours are the geography nodes it points at throughIN_CITYandIN_REGION, so because both airports sit in the same city and region their neighbourhoods are identical.Why the score is exactly
1.0. Node similarity scores a pair with the Jaccard coefficient, the size of the intersection of their neighbour sets divided by the size of the union:J(A, B) = \frac{|A \cap B|}{|A \cup B|}.
Writing A and B for the neighbour sets of
DFWandDAL, both are{Dallas city, Texas region}, so the intersection and the union are the same two nodes and J = \tfrac{2}{2} = 1.0, the maximum the coefficient can reach. An airport sharing only a region but not a city would score a fraction below one, and two airports with no shared geography would score0.Why only one row despite
topN: 10.topNis an upper bound, not a quota. AftertopK: 1collapses each airport to its single best match and the symmetricDAL → DFWduplicate is dropped, only this one pair reaches a perfect score, so the call returns a single row rather than ten.
As with PageRank and Louvain, the result is a statement about shared geography rather than shared traffic: DFW and DAL come out identical only because they hang beneath the same city node, and telling them apart by how they are actually flown would need a routes file connecting airports directly.
Step 7: Building a weighted projection
Every projection so far has been unweighted: each relationship counted as a single hop, and the algorithms treated all edges as equal. A weighted projection copies a numeric relationship property into memory alongside the topology, so shortest-path and weighted-centrality algorithms can minimise a cost rather than a number of hops.
This dataset makes one thing awkward, though: its relationships carry no numeric property to weight by. The hierarchy edges IN_CITY, IN_REGION, IN_COUNTRY and ON_CONTINENT were created bare in Step 2, so before a weighted projection has anything to read, a weight has to be written onto the relationship. A natural choice here is the airport’s runway count, which turns the IN_CITY edge into “how much capacity this airport brings to its city”:
MATCH (a:Airport)-[r:IN_CITY]->(:City)
SET r.weight = a.runways;
With a numeric property now present on IN_CITY, the projection is the same shape as the original, only naming the labels, relationship type and property this graph actually has. The relationship needs both its endpoints, so the node projection lists Airport and City rather than Airport alone:
CALL gds.graph.project(
'airports-cities-weighted',
['Airport', 'City'],
'IN_CITY',
{
relationshipProperties: 'weight'
}
) YIELD
graphName, nodeProjection, nodeCount, relationshipProjection,
relationshipCount;
The change from the unweighted airports-cities projection in Step 3 is the fourth argument alone:
relationshipProperties: 'weight'. Copies theweightwe just wrote into the projection so an algorithm can read it. Only numeric properties can be projected, which is why theSETabove usesrunwaysrather than a string.['Airport', 'City']. Both endpoint labels are still required, exactly as before, so that noIN_CITYrelationship is left dangling.- A weighted algorithm opts in. Projecting the property does not force its use; a call such as
gds.pageRank.streamstays unweighted until you pass{ relationshipWeightProperty: 'weight' }, at which point it reads the values copied here.
airports-cities-weighted projection built in Neo4j Browser. The result row reports a nodeCount of 6912 and a relationshipCount of 3503, matching the unweighted airports-cities projection, while the relationshipProjection column now expands to show the weight property copied in alongside each IN_CITY edge.
Figure 19 confirms the weight came across. The node and relationship counts are identical to the unweighted airports-cities projection from Step 3, because adding a relationship property changes what each edge carries, not how many edges or nodes there are. The difference is in the relationshipProjection column, which now lists weight under the IN_CITY type, the sign that the property is resident in memory and ready for a weighted algorithm to read.
Step 8: Finding a shortest path with Dijkstra
A weighted projection is what shortest-path algorithms need. Dijkstra finds the lowest-cost path between two nodes, adding up a relationship weight along the way rather than counting hops. The catch on this dataset is the one we keep meeting: there are no direct edges between airports, so the only path from one airport to another runs up and over the hierarchy, from the airport to the geography it shares with the other airport and back down again.
Two consequences follow, and both change the original query:
- The two airports must share geography. A path from Denver to a Maldivian airport does not exist here, because they climb to different continents that are never joined. Two airports in the same country, such as
DENandJFK, do connect, through theUScountry node they both sit beneath. - The hierarchy must be undirected and carry a weight on every type. The stored edges point specific → general, so without
UNDIRECTEDthere is no way back down to the target airport, and Dijkstra needs a weight on each type it traverses, not justIN_CITY.
Since this dataset has no natural edge distance, the simplest honest weight is a flat cost of one per hop, written onto every hierarchy relationship so that totalCost counts the steps in the path:
MATCH ()-[r:IN_CITY|IN_REGION|IN_COUNTRY|ON_CONTINENT]->()
SET r.weight = 1.0;
The projection then carries all five labels and all four relationship types, each oriented UNDIRECTED and each copying the weight into memory:
CALL gds.graph.project(
'geography-weighted',
['Airport', 'City', 'Region', 'Country', 'Continent'],
{
IN_CITY: { orientation: 'UNDIRECTED', properties: 'weight' },
IN_REGION: { orientation: 'UNDIRECTED', properties: 'weight' },
IN_COUNTRY: { orientation: 'UNDIRECTED', properties: 'weight' },
ON_CONTINENT: { orientation: 'UNDIRECTED', properties: 'weight' }
}
)
YIELD graphName, nodeCount, relationshipCount;
With that projection in memory, the shortest path between the two airports is the same call as the original, only naming the projection, weight and endpoints this graph actually has:
MATCH (source:Airport {iata: 'DEN'}), (target:Airport {iata: 'JFK'})
CALL gds.shortestPath.dijkstra.stream('geography-weighted', {
sourceNode: source,
targetNode: target,
relationshipWeightProperty: 'weight'
})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
RETURN
index,
gds.util.asNode(sourceNode).iata AS sourceNodeName,
gds.util.asNode(targetNode).iata AS targetNodeName,
totalCost,
[nodeId IN nodeIds |
coalesce(gds.util.asNode(nodeId).iata,
gds.util.asNode(nodeId).name,
gds.util.asNode(nodeId).code)] AS nodeNames,
costs,
nodes(path) AS path
ORDER BY index;
The changes from the original are all forced by this dataset:
'geography-weighted'andrelationshipWeightProperty: 'weight'. The projection and property built above replace theroutes-weighted/distancepair, which described airport-to-airport routes this graph does not have.'DEN'and'JFK'. Both are United States airports, so a path exists through their sharedUScountry node. The originalMLEtarget sits on another continent and would return no path.coalesce(...)on the path nodes. The path does not stay among airports: it steps throughRegionandCountrynodes, which have noiata. Falling back tonamethencodenames each hop by whichever identifier its label carries, exactly as the PageRank stream did in Step 4.
The totalCost that comes back is the number of hops between the two airports, DEN up to the US country node and back down to JFK, and nodeNames spells that route out one node at a time. It is a path through geography rather than through the air, the same limitation of a route-free node list that has shaped every algorithm in this post.