Understanding the Fundamentals of Graph Retrieval-Augmented Generation (RAG) in LLMs

Graph Retrieval-Augmented Generation (RAG) combines the power of graph databases with large language models (LLMs) to enable more accurate and context-aware information retrieval. This post explores the fundamentals of Graph RAG, including its architecture, key components, and how it leverages graph structures to enhance LLM capabilities. It also walks through installing Neo4j on Ubuntu, from the Java prerequisite and APT repository setup to starting the service and accessing the browser interface.

graph-rag
neo4j
LLM
Author

Mei-Chin Pang

Published

August 17, 2026

Installing Neo4j on Ubuntu

Graph RAG relies on a graph database to store and query entities and their relationships. Neo4j is the most widely used option, so before we build anything we need it running locally. The steps below follow the official Neo4j Debian/Ubuntu installation guide.

1. Install the Java prerequisite

Neo4j 2025.x requires the Java 21 runtime (default). Starting with Neo4j 2025.10, Java 25 is also supported.

sudo apt-get update
sudo apt-get install openjdk-21-jdk
NoteDealing with multiple installed Java versions

You must configure your default Java version to point to Java 21 or Java 25 (starting with Neo4j 2025.10), or Neo4j 2026.07.1 will be unable to start. Do so with the update-java-alternatives command.

  1. List all your installed versions of Java with update-java-alternatives --list. Your results may vary, but this is an example of the output:
java-1.25.0-openjdk-amd64 2511 /usr/lib/jvm/java-1.25.0-openjdk-amd64
java-1.21.0-openjdk-amd64 2111 /usr/lib/jvm/java-1.21.0-openjdk-amd64
java-1.17.0-openjdk-amd64 1711 /usr/lib/jvm/java-1.17.0-openjdk-amd64
  1. Identify your Java 21 version (default) from the list of installed Javas. In this case, it is java-1.21.0-openjdk-amd64.

  2. Set Java 21 as the default by replacing <java21name> with its name:

sudo update-java-alternatives --jre --set <java21name>
  1. Confirm which version of Java is the default using java -version.

2. Add the Neo4j repository

Run the following commands as a sudo user to add the Neo4j repository to the package manager.

  1. Create the keyrings directory for the Neo4j GPG key if it does not already exist:
sudo mkdir -p /etc/apt/keyrings
  1. Download and install the Neo4j GPG key:
wget -O - https://debian.neo4j.com/neotechnology.gpg.key \
  | sudo gpg --dearmor -o /etc/apt/keyrings/neotechnology.gpg > /dev/null
  1. Ensure the key file is world-readable:
sudo chmod a+r /etc/apt/keyrings/neotechnology.gpg
  1. Add the Neo4j APT repository:
echo 'deb [signed-by=/etc/apt/keyrings/neotechnology.gpg]' \
     'https://debian.neo4j.com stable latest' \
  | sudo tee -a /etc/apt/sources.list.d/neo4j.list > /dev/null
  1. Update package lists:
sudo apt-get update
  1. Once the repository has been added to apt, you can verify which Neo4j versions are available by running:
apt list -a neo4j

3. Install Neo4j

Note

In Ubuntu server installations, you also need to make sure that the universe repository is enabled. If the universe repository is not present, the Neo4j installation will fail with the error Depends: daemon but it is not installable.

This can be fixed by running the command:

sudo add-apt-repository universe

To install Neo4j, run one of the following commands depending on which version you want to install. Note that the version includes an epoch version component (1:), following the Debian policy on versioning.

Neo4j Community Edition (CE):

sudo apt-get install neo4j=1:2026.07.1

Neo4j Enterprise Edition (EE):

sudo apt-get install neo4j-enterprise=1:2026.07.1

4. Start the service

Start the database server immediately:

sudo systemctl start neo4j

5. Manage and verify

Verify the service is running:

sudo systemctl status neo4j

Enable Neo4j to start on boot:

sudo systemctl enable neo4j

Stop the database server:

sudo systemctl stop neo4j

6. Access the interface

Open your browser and navigate to http://localhost:7474, then log in with the default username neo4j and the default password neo4j. Change the password immediately when prompted. Alternatively, connect from the command line with cypher-shell:

cypher-shell -u neo4j -p neo4j

With Neo4j running, we are ready to model our knowledge graph and start building the Graph RAG pipeline.

Import Dataset into Neo4j

Before loading the data into Neo4j, let’s preview the sample_tracks.csv file with pandas to understand its structure:

import pandas as pd

df_tracks = pd.read_csv("import/sample_tracks.csv")
print(df_tracks.head(3))
                 track_id                                         track_name  \
0  1FTSo4v6BOZH9QxKc3MbVM                   Song 2 - 2012 Remastered Version   
1  6mcxQ1Y3uQRU0IHsvdNLH1                                  Where Is My Mind?   
2  3J28CGmR9cnA1jW6TNACkh  I Do What I Want (Capablanca vs Moscoman Version)   

   track_duration  track_popularity  track_explicit  \
0          121160              74.0               0   
1          236973              70.0               0   
2          353786               2.0               0   

                                   track_preview_url  \
0  https://p.scdn.co/mp3-preview/183c0855e94b58dc...   
1  https://p.scdn.co/mp3-preview/5ecbfac4d7b32924...   
2  https://p.scdn.co/mp3-preview/56ace2fcd724ec2d...   

                              track_uri  track_index                album_id  \
0  spotify:track:1FTSo4v6BOZH9QxKc3MbVM            1  7HvIrSkKGJCzd8AKyjTJ6Q   
1  spotify:track:6mcxQ1Y3uQRU0IHsvdNLH1            2  2l7RPWC3E6eStJJLBsUeCI   
2  spotify:track:3J28CGmR9cnA1jW6TNACkh            1  1YqsJO3NzjKBI0OLpNQgf0   

                 album_name                             album_uri  \
0    Blur [Special Edition]  spotify:album:7HvIrSkKGJCzd8AKyjTJ6Q   
1  Surfer Rosa (Remastered)  spotify:album:2l7RPWC3E6eStJJLBsUeCI   
2     I Do What I Want - EP  spotify:album:1YqsJO3NzjKBI0OLpNQgf0   

               artist_name                             artist_uri  \
0                     Blur  spotify:artist:7MhMgCo0Bl0Kukl93PZbYS   
1                   Pixies  spotify:artist:6zvul52xwTWzilBZl6BUbT   
2  Tristesse Contemporaine  spotify:artist:7p2tK3ousYPinaQMX5I2lW   

                artist_id  
0  7MhMgCo0Bl0Kukl93PZbYS  
1  6zvul52xwTWzilBZl6BUbT  
2  7p2tK3ousYPinaQMX5I2lW  

We load data into the graph with Cypher’s LOAD CSV clause. However, Neo4j restricts where CSV files can be read from, so a query like the following will fail with 22N43: Data exception - unable to load external resource if the file lives in an arbitrary location such as your project folder:

LOAD CSV WITH HEADERS FROM 'file:///<user>/.../sample_tracks.csv' AS row
RETURN row
LIMIT 5;

There are two reasons for this:

  1. Import directory restriction. By default Neo4j only reads files from its own configured import folder (/var/lib/neo4j/import), and file:/// URLs are resolved relative to that folder. An import/ folder inside your project is unrelated to Neo4j’s.
  2. File permissions. The neo4j service user cannot traverse /home/<user> (home directories are usually 750), so even after changing settings the read would still fail.

Creating Nodes from the Dataset

Previewing rows only reads the CSV; it does not yet store anything in the graph. To actually build the knowledge graph, we combine LOAD CSV with a CREATE clause that turns each row into a node. For example, the following query creates one Track node per row:

LOAD CSV WITH HEADERS FROM 'file:///sample_tracks.csv' AS row
CREATE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
    track.name = row.track_name

Cypher fundamentals

Cypher is Neo4j’s query language, and it describes graph patterns using an ASCII-art syntax where nodes are drawn as parentheses () and relationships as arrows -->. A few core concepts explain what the query above does:

  • Nodes are the entities in the graph. (track) introduces a node and binds it to the variable track, so we can refer to it later in the same query.
  • Labels classify nodes by type. In (track:Track), Track is the label, the equivalent of a table name in a relational database. Labels let us group and index nodes, for example “all Track nodes”.
  • Properties are key/value pairs stored on a node (or relationship), written inside curly braces {}. They are analogous to columns on a row.
  • Variables such as track and row are temporary names scoped to the query. row comes from LOAD CSV and represents the current CSV line, so row.track_id reads the track_id column of that line.

Reading the query line by line

CREATE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
    track.name = row.track_name
  • CREATE (track:Track {id: row.track_id}) creates a new node, labels it Track, and sets its id property to the track_id value from the current CSV row. Setting the identifying property inline at creation time is a common idiom.
  • SET track.uri = row.track_uri, track.name = row.track_name assigns two more properties to that same node: uri (from track_uri) and name (from track_name). SET can add or overwrite any number of properties.

CREATE vs. MERGE

CREATE always inserts a brand-new node, so if the query runs more than once, or the CSV contains duplicate track_id values (our sample data does), you will end up with duplicate Track nodes. When an id should be unique, use MERGE instead, which matches an existing node or creates it if none is found:

LOAD CSV WITH HEADERS FROM 'file:///sample_tracks.csv' AS row
MERGE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
    track.name = row.track_name
Tip

Pair MERGE with a uniqueness constraint to make the import idempotent and much faster, since the constraint adds an index on the matched property:

CREATE CONSTRAINT track_id_unique IF NOT EXISTS
FOR (t:Track) REQUIRE t.id IS UNIQUE;

Connecting Nodes with Relationships

Creating nodes gives us the entities, but the value of a graph comes from the relationships between them. Once we have matched or created the album, track, and artist nodes for a row, we connect them with the following query:

CREATE (album)-[:HAS_TRACK]->(track)
CREATE (track)-[:ARTIST]->(artist);

How relationships are written

In Cypher, a relationship is drawn as an arrow between two nodes, with the relationship type in square brackets:

(startNode)-[:TYPE]->(endNode)
  • The parentheses () refer to nodes. Here album, track, and artist are variables bound to nodes created (or matched) earlier in the same query.
  • The square brackets [:HAS_TRACK] and [:ARTIST] hold the relationship type, the label that describes what the connection means. By convention relationship types are written in UPPER_SNAKE_CASE.
  • The arrow -> gives the relationship a direction, pointing from the start node to the end node.

Reading the query line by line

  • CREATE (album)-[:HAS_TRACK]->(track) creates a HAS_TRACK relationship directed from the album node to the track node, expressing that the album contains that track.
  • CREATE (track)-[:ARTIST]->(artist) creates an ARTIST relationship directed from the track node to the artist node, expressing who performed the track.

Together these two statements wire each track into the graph: every track is linked upward to the album it belongs to and across to the artist who made it. The result is a connected structure such as (album)-[:HAS_TRACK]->(track)-[:ARTIST]->(artist) that we can later traverse, for example to find all tracks by an artist or every artist featured on an album.

Note

Just like nodes, CREATE always inserts a new relationship, so re-running the import can produce duplicate edges. Use MERGE on the relationship pattern instead when you need the connection to be created only once:

MERGE (album)-[:HAS_TRACK]->(track)
MERGE (track)-[:ARTIST]->(artist);

Putting it together: importing one track

We can now combine everything, reading a row, creating the Track, Album, and Artist nodes, and wiring them together, into a single query. To keep things manageable while learning, we use WITH row LIMIT 1 to process only the first row of the CSV:

// Import one track from the CSV and create its nodes and relationships
LOAD CSV WITH HEADERS FROM "file:///sample_tracks.csv" AS row
WITH row LIMIT 1

CREATE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
    track.name = row.track_name

CREATE (album:Album {id: row.album_id})
SET album.uri = row.album_uri,
    album.name = row.album_name

CREATE (artist:Artist {id: row.artist_id})
SET artist.uri = row.artist_uri,
    artist.name = row.artist_name

CREATE (album)-[:HAS_TRACK]->(track)
CREATE (track)-[:ARTIST]->(artist);

Reading it top to bottom:

  • LOAD CSV WITH HEADERS FROM "file:///sample_tracks.csv" AS row streams the CSV from Neo4j’s import folder, exposing each line as a row map keyed by column name.
  • WITH row LIMIT 1 passes only the first row through to the rest of the query, so we import a single track while testing the pattern.
  • The three CREATE ... SET ... blocks build a Track, an Album, and an Artist node from that row, each identified by its id and enriched with a uri and name.
  • The final two CREATE statements connect the nodes: (album)-[:HAS_TRACK]->(track) and (track)-[:ARTIST]->(artist).

The result is a small connected subgraph for one track. Once the pattern looks correct, removing the WITH row LIMIT 1 line lets the same query run over every row in the file.

Querying the Graph

With the data imported, we can query the graph to explore the connections we built. The following query returns the full album, track, and artist paths:

MATCH path=(artist:Artist)<-[:ARTIST]-(t:Track)<-[:HAS_TRACK]-(album:Album)
RETURN path
Figure 2: The returned path rendered as a subgraph in the Neo4j Browser, with the selected Album node’s properties shown in the details panel.

Reading the query line by line

  • MATCH (artist:Artist)<-[:ARTIST]-(t:Track)<-[:HAS_TRACK]-(album:Album) describes a pattern to search for: an Album that HAS_TRACK a Track, which in turn points via ARTIST to an Artist. Neo4j finds every part of the graph that matches this shape.
  • The arrows show relationship direction. We created the edges as (album)-[:HAS_TRACK]->(track) and (track)-[:ARTIST]->(artist), so reading from the artist the arrows point backwards (<-). The pattern above is simply those same relationships traversed in the opposite direction; it matches exactly the edges we inserted.
  • path=(...) assigns the whole matched pattern to the variable path. A path is the ordered sequence of nodes and relationships that satisfied the match.
  • RETURN path outputs each matched path. In the Neo4j Browser this renders as a visual subgraph of the connected Album, Track, and Artist nodes.

Because the pattern names all three labels and both relationship types, the query returns only fully connected album -> track -> artist chains, which is a quick way to confirm the import wired everything together correctly.

Inspecting node details

Clicking a node in the Neo4j Browser opens the Node details panel on the right, which lists the selected node’s label and every property stored on it. In Figure 2 the selected Album node shows:

  • <id>: the internal element id Neo4j assigns to every node (for example 4:40548a74-6728-4cca-a57c-f9c184371f3e:1). It uniquely identifies the node inside the database and is generated automatically, distinct from the business id we imported.
  • id: the property we set from the CSV ("7HvIrSkKGJCzd8AKyjTJ6Q", the Spotify album id). This is the value the MERGE/CREATE pattern keys on.
  • name: the human-readable album title, "Blur [Special Edition]".
  • uri: the Spotify URI, "spotify:album:7HvIrSkKGJCzd8AKyjTJ6Q".

These are exactly the three properties (id, name, uri) we assigned when creating Album nodes, so the panel is a convenient way to verify that the import populated each node correctly. Selecting the Track or Artist node instead would show its own label and the corresponding properties.

Viewing the entire graph

The path query above only returns fully connected album -> track -> artist chains. To see everything in the database, including any isolated nodes that have no relationships, use:

MATCH (n)
OPTIONAL MATCH (n)-[r]->(o)
RETURN *
Figure 3: The complete graph returned by the query, showing every node and relationship in the database.
  • MATCH (n) matches every node in the database and binds each to n.
  • OPTIONAL MATCH (n)-[r]->(o) tries to follow an outgoing relationship r from n to another node o. Because it is optional, a node with no outgoing relationship is still kept, with r and o bound to null rather than being dropped from the result. This is the graph equivalent of a SQL LEFT JOIN.
  • RETURN * returns all bound variables (n, r, and o), so the Browser can render the complete graph.

The key difference from the earlier path query is the OPTIONAL MATCH: a plain MATCH (n)-[r]->(o) would silently exclude any node without an outgoing edge, whereas this query guarantees every node appears, whether or not it is connected.

The problem with duplicated nodes

Looking at Figure 3, the graph doesn’t look quite right: it shows two separate chains that should be joined. Each chain comes from a different CSV file:

  • The first pattern represents a row from the playlists file: (:User)-[:OWNS]->(:Playlist)-[:HAS_TRACK]->(:Track)
  • The second pattern represents a row from the tracks file: (:Album)-[:HAS_TRACK]->(:Track)-[:ARTIST]->(:Artist)

The Track in both patterns is the same track and should therefore be the same node. Because we used CREATE, however, Neo4j blindly created a brand-new Track node for each file, leaving two disconnected copies instead of one shared node linking the two subgraphs.

We can confirm this by clicking the Track node in each chain and comparing the Node details panel. Their internal <id> values differ (they are two distinct nodes), but their id property is identical:

Figure 4: The Track node in the first chain has id: "1FTSo4v6BOZH9QxKc3MbVM".
Figure 5: The Track node in the second chain has the same id: "1FTSo4v6BOZH9QxKc3MbVM".

Both nodes carry id: "1FTSo4v6BOZH9QxKc3MbVM", which is exactly the value of the track_id column for that song in both CSV files, the playlists file and the tracks file. Since we set {id: row.track_id} when creating the node in each import script, the same track_id produced two separate nodes sharing one business id. That shared track_id is precisely the key we can use to recognise the row as the same track and collapse the duplicates into a single node.

Merging safely to avoid duplicates

CREATE simply creates a node or relationship as instructed. But every track in the CSV has a unique track id, and if a track already exists in the graph we do not want to create it again. The fix is to use MERGE instead of CREATE.

MERGE is a combination of MATCH and CREATE: it tries to find the pattern in its entirety, and if it already exists, nothing is created. Only if the pattern cannot be matched is the whole pattern created. To avoid duplicate Track nodes, MERGE needs a reliable key to match on first, and the track’s id (like the album and artist ids) is unique, so it can be used to determine whether the node already exists:

MERGE (track:Track {id: row.track_id})
SET track.uri = row.track_uri,
    track.name = row.track_name

Written this way, loading the playlists file will match the existing Track node created by the tracks file (rather than making a second one), so both patterns attach to a single shared node and the graph becomes properly connected.

Tip

Also create a uniqueness constraint on the key property. Besides speeding up the MERGE match with an index, it guarantees duplicates cannot be created even when data is ingested in parallel:

CREATE CONSTRAINT track_id_unique IF NOT EXISTS
FOR (t:Track) REQUIRE t.id IS UNIQUE;

Resetting the Graph

While experimenting it is common to wipe the database and start over, for example after fixing the import query. The following query clears the entire graph:

MATCH (n)
DETACH DELETE n

Reading the query line by line

  • MATCH (n) finds every node in the database. MATCH is Cypher’s read clause for locating existing data, and the pattern (n) is a node with no label and no properties, so it matches all nodes and binds each one to the variable n.
  • DETACH DELETE n deletes those nodes. The DETACH keyword first removes any relationships attached to each node, then deletes the node itself.

The DETACH part is essential: Neo4j refuses to delete a node that still has relationships, because that would leave dangling edges. A plain DELETE n on a connected node fails with an error, whereas DETACH DELETE removes the node and its relationships together. Since MATCH (n) selects everything, this query empties the graph completely, giving you a clean slate to re-run the import.

Warning

This is irreversible and removes all nodes and relationships. On a large database it can also exhaust memory, since every change is held in one transaction. Only use it on databases you intend to reset, and for very large graphs delete in batches instead, for example with CALL { MATCH (n) RETURN n LIMIT 10000 } DETACH DELETE n (repeated until empty) or the apoc.periodic.iterate procedure.

References

  1. Misquitta, L. and Willemsen, C. (2025) Neo4j: The Definitive Guide: Hands-On Recipes for Production Ready Graph Implementations. O’Reilly Media.

  2. Bratanic, T. and Hane, O., 2025. Essential GraphRAG: Knowledge Graph-Enhanced RAG. Simon and Schuster.

  3. Neo4j (2026) Debian-based distributions (.deb). Available at: https://neo4j.com/docs/operations-manual/current/installation/linux/debian/ (Accessed: 17 August 2026).