Finding Similarities in a Graph
One of the strengths of a graph model is that similarity between entities emerges naturally from shared relationships. If two playlists both contain the same track in the same slot, that is a strong hint that they were built with a similar intent. The Cypher query below surfaces exactly those overlaps.
MATCH path=(n:Playlist)-[r1:HAS_TRACK]->(track)<-[r2:HAS_TRACK]-(
other:Playlist)
WHERE r1.position = r2.position
RETURN path
LIMIT 5;
Reading the query
MATCH path=(n:Playlist)-[r1:HAS_TRACK]->(track)<-[r2:HAS_TRACK]-(other:Playlist)describes a path in which aPlaylistnodenpoints to atrackthrough aHAS_TRACKrelationshipr1, and a secondPlaylistnodeotherpoints to the sametrackthrough its ownHAS_TRACKrelationshipr2. Because both arrows converge on the sharedtrack, the pattern only matches tracks that appear in more than one playlist.WHERE r1.position = r2.positiontightens the match.HAS_TRACKstores the track’s slot within each playlist in apositionproperty, so this filter keeps only the cases where the track sits at the same position in both playlists, which is a much stronger signal of similarity than merely sharing a track.RETURN pathreturns the whole matched pattern (both playlists, the track, and the two relationships) rather than a single column, which lets the Neo4j Browser render it visually as a graph.LIMIT 5caps the output at five paths so the result stays readable while exploring.
Note that this pattern is symmetric: it also matches a playlist with itself (n and other bound to the same node) and returns each pair twice (once as n–other and once as other–n). Adding WHERE ... AND id(n) < id(other) would remove those mirror duplicates if needed.
The result
The graph view returns a single path made up of three nodes and two relationships:
- The central green node is the shared
track, Mess Around. - The two blue nodes are the playlists that both contain it:
yeehaw.on the left andIndie/Garage Rock/Grunge…on the right. - Each
HAS_TRACKarrow points from a playlist into the track, and theWHERE r1.position = r2.positionfilter guarantees that Mess Around occupies the identical slot in both playlists.
Even though the two playlists carry very different names and genres, the query reveals a concrete link between them: they both place Mess Around in the same position. Repeating this pattern across the whole dataset is the basis for recommendation-style queries such as “playlists similar to this one” or “tracks that tend to appear together”, all expressed directly as graph traversals instead of expensive joins.
Ranking the Most Similar Playlists
The previous query inspects one shared track at a time. To actually rank playlists by how much they overlap, we need to aggregate all the tracks a pair shares and then measure how many of them line up at the same position. The query below does exactly that.
// find playlists sharing tracks
MATCH path=(p:Playlist)-[r1:HAS_TRACK]->(track)<-[r2:HAS_TRACK]-(
other:Playlist)
WITH
p AS playlistLeft,
other AS playlistRight,
// collect the track and left, right positions
collect(
{
track: track,
positionLeft: r1.position,
positionRight: r2.position
}
) AS commonTracks
// omit if they don't share at least 5 tracks
WHERE size(commonTracks) > 5
RETURN
playlistLeft.name,
playlistRight.name,
size([track in commonTracks
WHERE track.positionLeft = track.positionRight])
AS tracksWithSamePosition,
size([track in commonTracks
WHERE NOT track.positionLeft = track.positionRight])
AS tracksAtDifferentPosition
ORDER BY tracksWithSamePosition DESC
LIMIT 100;
Breaking it down part by part
1. Match every shared track between two playlists
MATCH path=(p:Playlist)-[r1:HAS_TRACK]->(track)<-[r2:HAS_TRACK]-(other:Playlist)
This is the same convergent pattern as before: playlist p and playlist other both point to the same track. Crucially, there is no position filter here, so it captures every track two playlists have in common, regardless of where each one sits. Each row produced by the MATCH represents one shared track together with its two relationships r1 and r2.
2. Aggregate the shared tracks into a list with WITH + collect
WITH
p AS playlistLeft,
other AS playlistRight,
collect(
{track: track, positionLeft: r1.position, positionRight: r2.position}
) AS commonTracks
The WITH clause groups the rows by the two playlists (p and other, renamed to playlistLeft and playlistRight for readability). Whatever is not an aggregate becomes the grouping key, so Cypher collapses all the rows for a given playlist pair into a single row. collect(...) then gathers every shared track into a list called commonTracks, where each element is a small map recording the track itself and its position in each playlist (positionLeft from r1, positionRight from r2).
3. Keep only meaningfully overlapping pairs
WHERE size(commonTracks) > 5
size(commonTracks) is the number of tracks the two playlists share. This filter discards pairs with five or fewer common tracks so the result focuses on genuinely similar playlists rather than incidental single-track overlaps.
4. Compute the similarity metrics with list comprehensions
RETURN
playlistLeft.name,
playlistRight.name,
size([track in commonTracks
WHERE track.positionLeft = track.positionRight])
AS tracksWithSamePosition,
size([track in commonTracks
WHERE NOT track.positionLeft = track.positionRight])
AS tracksAtDifferentPosition
Each [track in commonTracks WHERE ...] is a list comprehension that filters the collected list, and wrapping it in size(...) counts how many elements survive:
tracksWithSamePositioncounts shared tracks whosepositionLeftequalspositionRight: tracks in the same slot in both playlists.tracksAtDifferentPositioncounts the rest: shared tracks that appear in different slots.
Together the two columns describe both how much two playlists overlap and how aligned that overlap is.
5. Order and cap the output
ORDER BY tracksWithSamePosition DESC
LIMIT 100;
Results are sorted so the most tightly aligned pairs (most tracks in identical positions) come first, and LIMIT 100 keeps the report to the top 100 pairs.
The result
The table returns one row per playlist pair, ranked by tracksWithSamePosition:
- The top two rows are “Folk Metal” and “Folk metal” (essentially the same playlist under two different capitalisations). They share 66 tracks in total (4 at the same position plus 62 at different positions), which strongly suggests they are duplicates or forks of one another.
- Because the pattern is symmetric, each pair appears twice with
leftandrightswapped (rows 1 and 2, rows 3 and 4). The metrics are identical in both directions, confirming the counts are order-independent. - The “Dangdut” / “dangdut” pair lower down shares far fewer tracks (2 aligned, 7 misaligned), so it ranks below the folk-metal pair even though it is also a near-duplicate name.
Notice that a high tracksAtDifferentPosition relative to tracksWithSamePosition (62 vs 4 for the folk-metal pair) tells us the two playlists contain almost the same music but in a reshuffled order, a subtle distinction that would be invisible if we only counted shared tracks. This kind of ranked, position-aware overlap is precisely what powers “playlists like this” recommendations directly from the graph.
Persisting Similarity as a Relationship
So far the similarity scores have only existed in query results. A more useful approach is to write them back into the graph as a dedicated SIMILAR relationship, so future queries can traverse similarity directly instead of recomputing it every time. The query below does exactly that.
// find playlists sharing tracks.cypher
MATCH path=(p:Playlist)-[r1:HAS_TRACK]->(track)<-[r2:HAS_TRACK]-(
other:Playlist)
WITH
p AS playlistLeft,
other AS playlistRight,
// collect the track and left, right positions
collect(
{
track: track,
positionLeft: r1.position,
positionRight: r2.position
}
) AS commonTracks
// omit if they don't share at least 5 tracks
WHERE size(commonTracks) > 5
WITH
playlistLeft,
playlistRight,
size([track in commonTracks
WHERE track.positionLeft = track.positionRight])
AS tracksWithSamePosition,
size([track in commonTracks
WHERE NOT track.positionLeft = track.positionRight])
AS tracksAtDifferentPosition
MERGE (playlistLeft)-[r:SIMILAR]->(playlistRight)
SET
r.samePosition = tracksWithSamePosition,
r.notSamePosition = tracksAtDifferentPosition;
Breaking it down part by part
1-3. Match, aggregate, and filter (unchanged)
The first three stages are identical to the ranking query in Ranking the Most Similar Playlists:
- the convergent
MATCHfinds every track two playlists share, - the first
WITH+collectgroups those rows per playlist pair into acommonTrackslist, and WHERE size(commonTracks) > 5drops pairs with too little overlap.
See that section for the full explanation of these steps.
4. Reshape into similarity metrics with a second WITH
WITH
playlistLeft,
playlistRight,
size([track in commonTracks
WHERE track.positionLeft = track.positionRight])
AS tracksWithSamePosition,
size([track in commonTracks
WHERE NOT track.positionLeft = track.positionRight])
AS tracksAtDifferentPosition
This is the same pair of list comprehensions used in the earlier RETURN, but here they feed a second WITH instead of ending the query. The difference matters: WITH keeps the pipeline open, carrying playlistLeft, playlistRight, and the two counts forward so the next clause can act on them rather than just report them.
5. Create (or reuse) the SIMILAR relationship with MERGE
MERGE (playlistLeft)-[r:SIMILAR]->(playlistRight)
MERGE is the match-first-then-create clause: if a SIMILAR relationship already exists between this pair it is reused, otherwise a new one is created. Using MERGE here (rather than CREATE) makes the query idempotent, you can re-run it as the data changes without piling up duplicate similarity edges.
6. Store the scores as relationship properties with SET
SET
r.samePosition = tracksWithSamePosition,
r.notSamePosition = tracksAtDifferentPosition;
Finally SET writes the two counts onto the new relationship r as the properties samePosition and notSamePosition. The similarity is now a first-class part of the graph: a query like MATCH (p:Playlist)-[:SIMILAR]->(q) RETURN q retrieves related playlists in a single hop, with the alignment scores available for ranking, no re-aggregation required.
Visualising the Similarity Network
Once the SIMILAR relationships have been written to the graph, exploring them becomes trivial. The query below simply asks for those edges and lets the Browser draw the resulting network.
MATCH path=(playlist1)-[:SIMILAR]-(playlist2)
RETURN path
LIMIT 100
Reading the query
MATCH path=(playlist1)-[:SIMILAR]-(playlist2)matches any two playlists connected by aSIMILARrelationship. Unlike the earlier queries, there is noHAS_TRACKtraversal or aggregation here. The hard work of computing similarity was already done and persisted, so this is a single, cheap hop.- The relationship is written without an arrow direction (
-[:SIMILAR]-rather than->). This makes the match undirected, so a pair is found regardless of which playlist was stored as the source or target. RETURN pathreturns the whole matched pattern so it renders as a graph, andLIMIT 100caps the number of relationships drawn.
The result
SIMILAR network, with hub playlists that many others resemble.Instead of a single pair, the Browser now shows a whole similarity network:
- Each blue node is a playlist and each
SIMILARedge means the two playlists share more than five tracks (the threshold set when the relationships were built). - Some playlists act as hubs, for example Electro-popunk… sits at the centre of many incoming
SIMILARedges, meaning a lot of other playlists resemble it. These hubs are natural anchors for “if you like this, try these” recommendations. - Smaller clusters also emerge, such as the Beach house → Kygo & Friends → Chill Deep House grouping on the left, which hangs together as a recognisable sub-genre neighbourhood.
Because similarity now lives in the graph as explicit edges, this entire view is produced by one short traversal rather than the multi-stage aggregation used to compute it. That is the payoff of persisting derived relationships: the expensive analysis runs once, and every later exploration is a fast walk over the stored network.
Inspecting a Single Playlist
Before building a recommendation, it helps to look at the playlist we are recommending for. A single-line MATCH retrieves it by name.
// For a given Playlist
MATCH (p:Playlist) WHERE p.name = "all that jazz"
RETURN p
Reading the query
MATCH (p:Playlist) WHERE p.name = "all that jazz"finds the onePlaylistnode whosenameproperty equals all that jazz.RETURN preturns that node. Although the query only asks for the playlist itself, the Neo4j Browser expands a returned node to show its immediate relationships, which is why the surrounding tracks appear in the graph view.
The result
HAS_TRACK.The blue node in the centre is the all that jazz playlist, surrounded by the green Track nodes it points to through HAS_TRACK relationships, songs such as Blowin’ The Blu…, When Sunny…, and Blue in Green. Two other blue Playlist nodes, smooth jazz and Funky / Jazz / B…, also appear because they are connected to all that jazz by the SIMILAR edges built in Persisting Similarity as a Relationship.
This view is essentially a snapshot of everything the recommendation query has to work with: the playlist’s own tracks (to exclude), and the similar playlists (to draw suggestions from). With that context in place, the next section walks through generating the actual recommendations.
Finding the Last Track
The recommendation query keys off a playlist’s last track. There is a neat way to isolate it directly in the WHERE clause, without collecting and slicing the whole list.
// Find the last track
MATCH (p)-[r:HAS_TRACK]->(t)
WHERE r.position = COUNT {(p)-[:HAS_TRACK]->()}
RETURN t;
Reading the query
MATCH (p)-[r:HAS_TRACK]->(t)walks everyHAS_TRACKrelationship from a playlistpto a trackt, exposing thepositionproperty onr( the same property introduced in Finding Similarities in a Graph).COUNT {(p)-[:HAS_TRACK]->()}is aCOUNTsubquery: for each playlist it counts how many tracks that playlist has. Since positions run sequentially up to the total number of tracks, this count equals the position of the final track.WHERE r.position = COUNT {...}therefore keeps only the relationship whose position matches the track count, i.e. the very last track in each playlist.RETURN treturns those last tracks.
This is a more compact alternative to the head/tail approach used in Recommending the Next Track: instead of collecting all tracks in reverse order and taking the head, it computes the last position inline and filters straight to it.
The result
Each green node in the middle row, for example Shell Shocked and Hot Like Sauce, is the final track of a playlist, matched because its position equals that playlist’s total track count. The blue Playlist nodes above them are the playlists that end on those tracks, and the lower green nodes (Wiz Khalifa, Ty Dolla $ign, Pretty Lights, …) are the artists the Browser reveals when it expands each returned track through its ARTIST relationships.
Recommending the Next Track
The pieces are now in place for an actual recommendation: given a playlist, look at its last track, hop to similar playlists that also contain that track, and suggest tracks from those playlists that the listener does not already have. The query below assembles that pipeline.
// find_10_most_popular_tracks_and_recommend.cypher
WITH COLLECT {
MATCH (popularTrack:Track)-[:HAS_TRACK]-(:Playlist)
WITH popularTrack, count(*) as playlistCount
ORDER BY playlistCount DESC
LIMIT 10
RETURN popularTrack
} AS popularTracks
// For a given Playlist
MATCH (p:Playlist) WHERE p.name = "all that jazz"
// Collect the tracks in reverse position
WITH p, popularTracks,
COLLECT {
MATCH (p)-[r:HAS_TRACK]->(t)
WITH t, r
ORDER BY r.position DESC
RETURN t
} AS playlistTracks
WITH
p AS playlist,
popularTracks,
head(playlistTracks) AS lastTrack,
tail(playlistTracks) AS previousTracks
// Find other playlists that have the same the last track
MATCH (lastTrack)<-[:HAS_TRACK]-(otherPlaylist)-[:SIMILAR]-(playlist)
WHERE otherPlaylist <> playlist
// Find other tracks which are not in the given playlist
MATCH (otherPlaylist)-[:HAS_TRACK]->(recommendation)
WHERE NOT recommendation IN previousTracks
AND NOT recommendation IN popularTracks
// Score them by how frequently they appear
RETURN recommendation.id as recommendedTrackId,
recommendation.name AS recommendedTrack,
otherPlaylist.name AS fromPlaylist,
count(*) AS score
ORDER BY score DESC
LIMIT 10
Breaking it down part by part
1. Precompute the 10 most popular tracks with COLLECT { ... }
WITH COLLECT {
MATCH (popularTrack:Track)-[:HAS_TRACK]-(:Playlist)
WITH popularTrack, count(*) as playlistCount
ORDER BY playlistCount DESC
LIMIT 10
RETURN popularTrack
} AS popularTracks
COLLECT { ... } is a subquery expression: it runs a full nested query and gathers its results into a list. Inside, the pattern counts how many playlists each track appears in (count(*)), orders by that count, and keeps the top 10. The outcome is a popularTracks list that later stages use as an exclusion set. Very popular tracks are poor recommendations precisely because everyone already has them.
2. Select the target playlist
MATCH (p:Playlist) WHERE p.name = "all that jazz"
A straightforward MATCH pins the query to a single playlist by name. This is the playlist we want recommendations for.
3. Collect the playlist’s tracks in reverse position order
WITH p, popularTracks,
COLLECT {
MATCH (p)-[r:HAS_TRACK]->(t)
WITH t, r
ORDER BY r.position DESC
RETURN t
} AS playlistTracks
Another COLLECT { ... } subquery gathers the playlist’s tracks, this time ordered by r.position descending so the most recently added track comes first. popularTracks is carried through untouched. This reuses the position property on HAS_TRACK introduced in Finding Similarities in a Graph.
4. Split the list into the last track and the rest with head / tail
WITH
p AS playlist,
popularTracks,
head(playlistTracks) AS lastTrack,
tail(playlistTracks) AS previousTracks
Because the list is in reverse order, head(playlistTracks) is the last track the listener added. The “current context” for the recommendation and tail(playlistTracks) is everything else, kept as previousTracks so those can be excluded later.
5. Hop to similar playlists that share the last track
MATCH (lastTrack)<-[:HAS_TRACK]-(otherPlaylist)-[:SIMILAR]-(playlist)
WHERE otherPlaylist <> playlist
This is the heart of the recommendation. It combines two hops: otherPlaylist must both contain the last track (lastTrack<-[:HAS_TRACK]-otherPlaylist) and be linked to our playlist by the SIMILAR edge built in Persisting Similarity as a Relationship. The WHERE otherPlaylist <> playlist guard stops the playlist from recommending itself. Reusing the stored SIMILAR relationship is what keeps this cheap, no similarity is recomputed here.
6. Gather candidate tracks, minus what the listener already has or knows
MATCH (otherPlaylist)-[:HAS_TRACK]->(recommendation)
WHERE NOT recommendation IN previousTracks
AND NOT recommendation IN popularTracks
From each similar playlist, take its tracks as recommendation candidates, then filter out anything already in previousTracks (the listener has it) or in popularTracks (too generic to be a useful suggestion).
7. Score by frequency and return the top 10
RETURN recommendation.id AS recommendedTrackId,
recommendation.name AS recommendedTrack,
otherPlaylist.name AS fromPlaylist,
count(*) AS score
ORDER BY score DESC
LIMIT 10
count(*) aggregates how often each candidate appears across the matched similar playlists: the more independent playlists suggest a track, the higher its score. Sorting by score descending and taking the top 10 yields the final recommendation list, along with the playlist each suggestion came from.
The result
The query returns a ranked table of suggestions for the all that jazz playlist:
- Each row is a
recommendedTrack, for example Stompin’ at the Savoy, I May Be Wrong, and My One And Only Love, none of which are already in the playlist or in the global top-10 popular tracks. - The
fromPlaylistcolumn shows they all surfaced via smooth jazz, a playlist connected to all that jazz by aSIMILARedge and sharing its last track. This confirms the traversal followed the intended path. - The
scoreof2means each track appeared twice across the matched similar playlists. With a small, tightly related neighbourhood the scores are low and close together; on a larger graph this frequency count is what separates strong recommendations from incidental ones.
Taken together, this query shows the whole point of the earlier steps paying off: the precomputed SIMILAR relationships turn what would be an expensive multi-join recommendation into a short, readable traversal that produces genuine “you might also like” suggestions.
Connecting Two Tracks with a Shortest Path
A different question a graph answers well is how are two tracks related? Even tracks that never share a playlist directly can be linked through a chain of shared playlists. Cypher’s shortest-path search finds that chain.
//find_shortest_path.cypher
MATCH (t1:Track {id: "7ysmJhXFQtiBQlk6EZ6sks"})
MATCH (t2:Track {id:"7N2UmTJG5Uv6zQvjf4eIjd"})
MATCH path = SHORTEST 5 (t1)-[r:HAS_TRACK]-+(t2)
RETURN path
Reading the query
MATCH (t1:Track {id: "..."})andMATCH (t2:Track {id:"..."})pin down the two endpoint tracks by their uniqueidproperty. Matching onid(rather than name) guarantees exactly one node each.MATCH path = SHORTEST 5 (t1)-[r:HAS_TRACK]-+(t2)is the key part:SHORTEST 5asks for the five shortest paths between the two tracks, ordered by length.-[r:HAS_TRACK]-+is a variable-length, undirected pattern. The+means “one or moreHAS_TRACKhops”, and the lack of an arrow lets the path traverse the relationship in either direction: track → playlist → track → playlist …, so it can weave through the shared playlists that connect the two songs.
RETURN pathreturns the matched paths so the Browser draws them.
Because tracks only connect through playlists, every step alternates between a Track and a Playlist, reusing the same HAS_TRACK relationship seen throughout this post, which is read here in both directions rather than just playlist → track.
The result
The graph shows the two endpoint tracks connected via the playlists that contain them. The blue Playlist nodes smooth jazz and all that jazz sit at the top, each fanning out through HAS_TRACK edges to the green Track nodes below (Now See How Y…, Journey Into M…, Georgia On My…, Greensleeves, Skating In Cent…, When Your Lo…). A path hops track → playlist → track, so the two playlists act as the bridges that join the endpoints in just a few steps. Returning several shortest paths at once reveals how many independent short connections exist, not just that a single one does, which is a compact way to gauge how closely two tracks are related within the graph.
All Paths vs. Shortest Paths
It is worth contrasting the shortest-path query with a superficially similar one that returns every path within a length bound.
//016-shortest-2.cypher
MATCH (t1:Track {id: "7ysmJhXFQtiBQlk6EZ6sks"})
MATCH (t2:Track {id:"7N2UmTJG5Uv6zQvjf4eIjd"})
MATCH p = ((t1)-[*..5]-(t2))
RETURN p
Reading the query
- The two endpoint
MATCHclauses are identical to Connecting Two Tracks with a Shortest Path: they pint1andt2byid. MATCH p = ((t1)-[*..5]-(t2))is the difference.-[*..5]-is an undirected variable-length pattern meaning “between one and five relationships of any type”, and most importantly there is noSHORTESTkeyword. Without it, Cypher returns every path up to length 5, not just the shortest ones.RETURN preturns all of those paths.
How it differs from the shortest-path block
SHORTEST 5 (t1)-[r:HAS_TRACK]-+(t2) |
(t1)-[*..5]-(t2) |
|
|---|---|---|
| Selection | The 5 shortest paths | All paths |
| Length control | No fixed cap, grows only as needed to find 5 | Hard upper bound of 5 hops |
| Relationship type | Restricted to HAS_TRACK |
Any relationship type (HAS_TRACK, ARTIST, SIMILAR, …) |
| Result size | Small and bounded (5 paths) | Potentially huge, every route within 5 hops |
In short, the first block asks a targeted question (“give me the few closest connections through shared playlists”), while the second asks an exhaustive one (“show me everything reachable within five hops”). The exhaustive form is useful for exploring a neighbourhood, but it can return a large, dense result because the number of paths grows quickly with each extra hop.
The result
The difference is immediately visible. Rather than a handful of clean bridges, the query streams back 330 records and fills the view with a dense web of nodes. The two blue Playlist hubs: Funky / Jazz / B… and all that jazz, each fan out through dozens of HAS_TRACK edges to green Track nodes such as Lonely Woman, Blues Walk, Naima, and The Final Come… (one of the highlighted endpoints). Because the pattern accepts any relationship of up to five hops, it captures the entire cluster of tracks reachable through those shared playlists, not just the shortest routes between the two endpoints. This illustrates why SHORTEST matters: the same neighbourhood, filtered to the few shortest paths, is far easier to interpret than the full enumeration shown here.
References
Misquitta, L. and Willemsen, C. (2025) Neo4j: The Definitive Guide: Hands-On Recipes for Production Ready Graph Implementations. O’Reilly Media.
Bratanic, T. and Hane, O., 2025. Essential GraphRAG: Knowledge Graph-Enhanced RAG. Simon and Schuster.