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 8, 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.

Multiple Projects and DBMSs on Ubuntu

Most Neo4j tutorials organise work in Neo4j Desktop, where you create a project, add one or more DBMSs to it, and each DBMS in turn contains databases. Neo4j Desktop is not available on Linux, so on Ubuntu we reproduce the same hierarchy manually. The mapping is:

Desktop concept Ubuntu equivalent
Project A directory (or a Docker Compose project) that groups related instances. It is purely an organisational wrapper with no runtime meaning.
DBMS One Neo4j server instance: its own data directory, its own configuration file, its own ports, and its own neo4j user and password.
Database A database inside an instance, such as the default neo4j and system databases.

Multiple projects and DBMSs are useful when you want to keep the data of several projects on a single machine, or to separate environments such as development, testing, and production within one project. Each DBMS is configured independently and has its own set of databases, which gives better organisation and resource management.

ImportantTwo differences from Desktop
  1. Community Edition supports only one user database per DBMS. The neo4j and system databases exist by default, but CREATE DATABASE requires Enterprise Edition. To get several databases inside one DBMS you need Enterprise (free for development under the evaluation licence).
  2. More than one DBMS can run at the same time. Desktop enforces that only one DBMS is active, stopping the previous one when you start another. That is a Desktop limitation, not a Neo4j one. On Ubuntu you can run several instances concurrently, provided each uses a distinct set of ports.

Option 1: Docker Compose (closest to the Desktop workflow)

Docker is the simplest way to mimic Desktop, because each container is a fully isolated DBMS. Create one folder per project:

~/neo4j/
  project-a/docker-compose.yml
  project-b/docker-compose.yml

Two DBMSs under project A, ~/neo4j/project-a/docker-compose.yml:

services:
  dbms-1:
    image: neo4j:5-community
    container_name: proj-a-dbms-1
    ports: ["7474:7474", "7687:7687"]
    environment:
      NEO4J_AUTH: neo4j/password1
    volumes:
      - ./dbms-1/data:/data
      - ./dbms-1/logs:/logs
      - ./dbms-1/import:/var/lib/neo4j/import
      - ./dbms-1/plugins:/plugins

  dbms-2:
    image: neo4j:5-community
    container_name: proj-a-dbms-2
    ports: ["7475:7474", "7688:7687"]
    environment:
      NEO4J_AUTH: neo4j/password2
    volumes:
      - ./dbms-2/data:/data
      - ./dbms-2/logs:/logs
      - ./dbms-2/import:/var/lib/neo4j/import

~/neo4j/project-b/docker-compose.yml follows the same shape, with a third pair of ports such as 7476:7474 and 7689:7687.

The two port mappings matter: 7474 is the HTTP port used by the Neo4j Browser and 7687 is the Bolt port used by drivers and cypher-shell. The number on the left is the host port, so it must be unique across every DBMS you intend to run simultaneously. Mounting ./dbms-N/data gives each instance its own store, which is what makes them independent DBMSs rather than one instance restarted twice.

The Desktop start, stop, and delete buttons become:

cd ~/neo4j/project-a
docker compose up -d            # start every DBMS in the project
docker compose up -d dbms-1     # start a single DBMS
docker compose stop dbms-2      # stop a single DBMS
docker compose down             # stop and remove the containers
docker compose down -v          # also delete the data (irreversible)

Each DBMS then has its own browser interface: http://localhost:7474 for dbms-1, http://localhost:7475 for dbms-2, and http://localhost:7476 for project B.

Resetting the password of a running DBMS, the equivalent of Desktop’s reset password menu item:

docker exec -it proj-a-dbms-1 cypher-shell -u neo4j -p password1 \
  "ALTER CURRENT USER SET PASSWORD FROM 'password1' TO 'newpassword';"

Just as in Desktop, remove resources bottom-up: delete the DBMSs first (stopping them if they are running), and once the project holds no DBMSs, delete the project folder itself.

A worked example: one DBMS per dataset

The generic dbms-1/dbms-2 naming above is fine for illustration, but in practice it is clearer to name each service after the dataset it holds. This post works with two very different graphs, the music/playlist data used to learn Cypher and an airline routes dataset used later for graph algorithms, and mixing them in one store would be confusing. Since Community Edition allows only a single user database per DBMS, the way to keep them apart is to run two DBMSs:

services:
  movies:
    image: neo4j:5-community
    container_name: neo4j-movies
    restart: unless-stopped
    ports:
      - "7474:7474"   # HTTP / Neo4j Browser
      - "7687:7687"   # Bolt / drivers and cypher-shell
    environment:
      NEO4J_AUTH: neo4j/moviespassword
      NEO4J_PLUGINS: '["apoc"]'
      NEO4J_server_memory_heap_max__size: 2G
    volumes:
      - ${HOME}/neo4j-volumes/movies/data:/data
      - ${HOME}/neo4j-volumes/movies/logs:/logs
      - ./import:/var/lib/neo4j/import    # sample_tracks.csv, sample_playlists.csv
      - ${HOME}/neo4j-volumes/movies/plugins:/plugins

  flights:
    image: neo4j:5-community
    container_name: neo4j-flights
    restart: unless-stopped
    ports:
      - "7475:7474"
      - "7688:7687"
    environment:
      NEO4J_AUTH: neo4j/flightspassword
      NEO4J_PLUGINS: '["graph-data-science", "apoc"]'
      NEO4J_server_memory_heap_max__size: 4G
    volumes:
      - ${HOME}/neo4j-volumes/flights/data:/data
      - ${HOME}/neo4j-volumes/flights/logs:/logs
      - ${HOME}/neo4j-volumes/flights/import:/var/lib/neo4j/import
      - ${HOME}/neo4j-volumes/flights/plugins:/plugins

The two instances differ in every setting that has to be unique or that reflects how the dataset is used:

movies flights
Browser localhost:7474 localhost:7475
Bolt localhost:7687 localhost:7688
Password moviespassword flightspassword
Plugins APOC GDS + APOC
Heap 2G 4G
Import mount existing ./import ~/neo4j-volumes/flights/import

A few points worth noting:

  • Ports. Only the host-side numbers change; inside the container Neo4j always listens on 7474 and 7687. This is what allows both DBMSs to run at once.
  • Plugins. NEO4J_PLUGINS makes the official image download and configure a plugin at startup, including the dbms.security.procedures.unrestricted entry, so no manual jar copying is needed. Only flights gets GDS, because that is the dataset we run graph algorithms on.
  • Heap. NEO4J_server_memory_heap_max__size maps to the server.memory.heap.max_size setting; the double underscore encodes a literal underscore in the environment-variable form. The routes graph is the larger of the two, hence the bigger heap.
  • Import mount. movies mounts the project’s existing ./import folder straight into Neo4j’s import directory, so LOAD CSV FROM 'file:///sample_tracks.csv' works with no copying at all. flights gets its own empty folder.
  • restart: unless-stopped brings both instances back after a reboot, unless you stopped them explicitly.
  • Where the volumes live. The data, log and plugin mounts point at ${HOME}/neo4j-volumes/..., deliberately outside this Quarto project. Neo4j creates those folders as root/uid 7474 and gives import mode 0700; if they sat next to the .qmd files, quarto render would stop with PermissionDenied ... readdir as soon as it scanned the project for input files. Keeping the database state out of the site source also keeps it out of git.

Start whichever you need:

docker compose up -d              # start both
docker compose up -d movies       # start only the movie DBMS
docker compose up -d flights      # start only the flight DBMS
docker compose stop flights       # stop only the flight DBMS
docker compose down               # remove the containers, keep the data
docker compose down -v            # also delete the data (irreversible)

Because container_name is set explicitly, the name is global to the Docker daemon rather than scoped to the Compose project. If another project (or an earlier run from a different directory) already created a container with the same name, docker compose up stops with:

Error response from daemon: Conflict. The container name "/neo4j-flights"
is already in use by container "99665e881da4...". You have to remove
(or rename) that container to be able to reuse that name.

Check what is holding the name, then remove the stale container:

docker ps -a --filter name=neo4j-flights
sudo docker stop neo4j-flights && sudo docker rm neo4j-flights

Removing the container is safe here: the graph lives in the bind mounts under ${HOME}/neo4j-volumes/flights, so docker compose up -d flights recreates the container with the same data. Only docker compose down -v deletes it. If you genuinely need both projects running at once, give one of them a different container_name and a different host port pair instead.

ImportantConnecting to the right instance

Opening http://localhost:7475 loads the Browser served by flights, but the connect dialog still defaults its Bolt URL to neo4j://localhost:7687, which is the movies instance. Change it to neo4j://localhost:7688 and use the matching password, otherwise the log fills with:

WARN [bolt-5] The client is unauthorized due to authentication failure.

The username is always neo4j; the password is the second half of NEO4J_AUTH. Because that variable sets the password directly, neither instance prompts you to change it on first login. From the command line the target is unambiguous:

docker exec -it neo4j-flights cypher-shell -u neo4j -p flightspassword
Warning

Passwords are written in plain text here for readability. For anything beyond a local experiment, replace them with ${MOVIES_PASSWORD} style references backed by a .env file that is excluded from version control.

Option 2: Native tarball instances

If you prefer not to use Docker, install Neo4j from the tarball once per DBMS, since every instance needs its own conf/ and data/ directories. Note that this is different from the apt installation above, which sets up a single system-wide service managed by systemd.

mkdir -p ~/neo4j/project-a && cd ~/neo4j/project-a
curl -O https://dist.neo4j.org/neo4j-community-5.26.0-unix.tar.gz
tar -xf neo4j-community-5.26.0-unix.tar.gz
mv neo4j-community-5.26.0 dbms-1
cp -r dbms-1 dbms-2

Edit dbms-2/conf/neo4j.conf so the second instance does not clash with the first:

server.http.listen_address=:7475
server.bolt.listen_address=:7688
server.https.enabled=false

Set the initial password before the first start, then manage each instance with its own scripts:

~/neo4j/project-a/dbms-1/bin/neo4j-admin dbms set-initial-password mypassword
~/neo4j/project-a/dbms-1/bin/neo4j start
~/neo4j/project-a/dbms-2/bin/neo4j status
~/neo4j/project-a/dbms-2/bin/neo4j stop

Deleting a DBMS means stopping it and removing its folder; deleting a project means removing the project folder once it is empty. The Java prerequisite from the previous section applies here too.

Multiple databases inside one DBMS

To reproduce the Desktop behaviour where a single DBMS holds several databases, switch to Enterprise Edition. With Docker this is a one-line change:

image: neo4j:5-enterprise
environment:
  NEO4J_ACCEPT_LICENSE_AGREEMENT: "eval"
  NEO4J_AUTH: neo4j/password1

Databases are then managed from Cypher against the system database:

CREATE DATABASE movies;
SHOW DATABASES;

To summarise the hierarchy: a project groups DBMSs, each DBMS is an independent server instance with its own configuration, ports, and credentials, and each DBMS contains one or more databases.

Creating a New Database

Keeping separate datasets in separate databases is good practice: the music graph from this post and, say, an airline routes graph used for graph algorithms should not share a namespace. How you achieve that on Ubuntu depends entirely on which edition you installed.

First, check what you have

Run the following against the running server, either in the Neo4j Browser or in cypher-shell:

SHOW DATABASES;

A Community Edition install lists exactly two entries:

  • system, the internal database that stores users, roles, and the database catalogue itself. Administration commands are always executed against it.
  • neo4j, the single default user database where your graph lives.

Community Edition supports only one user database

Community Edition is limited to that one user database, so the administration command fails:

CREATE DATABASE flights;
Unsupported administration command: CREATE DATABASE flights

There are three ways forward, depending on what you actually need.

Option 1: Rename the single Community database

You cannot add a database, but you can change which one the server serves by default. Stop Neo4j, set the name in /etc/neo4j/neo4j.conf, and start it again:

initial.dbms.default_database=flights
sudo systemctl stop neo4j
sudo systemctl start neo4j

Neo4j creates an empty flights database and makes it the default. The old neo4j store stays on disk under /var/lib/neo4j/data/databases/, but it is no longer served, so this is a way of switching between datasets rather than of using both at once.

Note

The setting only takes effect on a database that has not been created yet, which is why it is named initial.. Changing it later does not rename an existing store; it points the server at a different (new or previously created) one.

Option 2: Use a second DBMS

If your goal is simply to keep two datasets apart, a second DBMS is easier than changing editions: each instance has its own neo4j database, entirely isolated from the first. This is the Docker Compose or tarball setup described in the previous section, with a distinct pair of ports per instance.

Option 3: Upgrade to Enterprise Edition

Multiple named databases inside one DBMS is an Enterprise feature, and Enterprise is free for development use under the evaluation licence. Because we installed from the APT repository, switching is just a package change:

sudo systemctl stop neo4j
sudo apt-get install neo4j-enterprise=1:2026.07.1
sudo systemctl start neo4j

Accept the evaluation licence when prompted. Your data directory, configuration, and the GDS plugin all carry over unchanged, since the jar and the dbms.security.procedures.unrestricted setting apply server-wide rather than per database.

Managing databases on Enterprise Edition

With Enterprise running, the full set of administration commands becomes available. They are executed against the system database, which the Browser and cypher-shell switch to automatically:

CREATE DATABASE flights;
CREATE DATABASE flights IF NOT EXISTS;   -- idempotent, no error if it exists
SHOW DATABASES;                          -- list all databases and their status

Creation is asynchronous, so SHOW DATABASES may briefly report the new database as starting before it becomes online.

Databases can be stopped and restarted without deleting them, which frees their memory while keeping the store on disk:

STOP DATABASE flights;
START DATABASE flights;

And removed entirely when no longer needed:

DROP DATABASE flights;
DROP DATABASE flights IF EXISTS;
Warning

DROP DATABASE deletes the database and its store files permanently. There is no undo, so take a backup first if the data matters. Use DROP DATABASE flights DUMP DATA if you want Neo4j to write a dump before deleting.

Connecting to a specific database

Creating a database does not switch you to it; the session still targets the default. Select the target explicitly:

  • Neo4j Browser: pick the database from the dropdown in the top-left of the editor, or run :use flights.
  • cypher-shell: pass -d, or switch mid-session with :use flights.
cypher-shell -u neo4j -p yourpassword -d flights
  • Python driver: name the database when opening the session.
with driver.session(database="flights") as session:
    session.run("MATCH (n) RETURN count(n)")

Note that LOAD CSV still reads from the server-wide import folder (/var/lib/neo4j/import) regardless of which database you are connected to, so no extra setup is needed there.

Installing the Graph Data Science (GDS) Plugin

The Graph Data Science library adds graph algorithms to Neo4j, such as PageRank, community detection, node similarity, and node embeddings. These are later useful in Graph RAG for ranking and clustering entities in the knowledge graph. GDS is a plugin, so it is not active out of the box, but if you installed Neo4j from the APT repository as described above, you do not need to download anything: the Debian package already ships the matching jar.

1. Locate the bundled jar

The Neo4j server distribution bundles the GDS plugin in its products directory:

ls /var/lib/neo4j/products/

On a 1:2026.07.1 installation this lists:

neo4j-genai-plugin-2026.07.1.jar  neo4j-graph-data-science-2026.07.0.jar
TipAlways prefer the bundled jar

The bundled plugin is guaranteed to be compatible with the server version it ships with, so GDS 2026.07.0 pairs with Neo4j 2026.07. Downloading a jar manually is the most common cause of a server that refuses to start after adding GDS, because the plugin and the server must match. The supported versions matrix lists the valid combinations if you do need to pick one yourself.

2. Copy the jar into the plugins directory

Neo4j loads plugins from the folder given by server.directories.plugins in /etc/neo4j/neo4j.conf, which on a Debian install is /var/lib/neo4j/plugins and initially contains only a README.txt. Copy the jar there and give it to the neo4j user so the service can read it:

sudo cp /var/lib/neo4j/products/neo4j-graph-data-science-2026.07.0.jar \
  /var/lib/neo4j/plugins/
sudo chown neo4j:adm /var/lib/neo4j/plugins/neo4j-graph-data-science-2026.07.0.jar

Adjust the version number to whatever ls reported in the previous step.

3. Unrestrict the GDS procedures

GDS accesses low-level components of Neo4j to maximise performance, so its procedures must be explicitly unrestricted. Append the following line to /etc/neo4j/neo4j.conf:

echo 'dbms.security.procedures.unrestricted=gds.*' | sudo tee -a /etc/neo4j/neo4j.conf

The shipped configuration file contains a commented-out example of this setting (#dbms.security.procedures.unrestricted=my.extensions.example,my.procedures.*). Either uncomment and edit that line or append a new one, but make sure only a single active dbms.security.procedures.unrestricted entry exists, since a duplicate key silently overrides the earlier value. If you also use APOC, list both: dbms.security.procedures.unrestricted=gds.*,apoc.*.

4. Check the procedure allowlist

Inspect whether an allowlist is in force:

grep -n "procedures" /etc/neo4j/neo4j.conf

By default dbms.security.procedures.allowlist is commented out, which means all procedures are loaded and there is nothing more to do. If the option is active in your configuration, it becomes an exhaustive list, so gds.* has to be added to it explicitly:

dbms.security.procedures.allowlist=apoc.coll.*,apoc.load.*,gds.*

5. Restart the server

Plugins are only picked up at startup:

sudo systemctl restart neo4j
sudo systemctl status neo4j

6. Verify the installation

From the Neo4j Browser or cypher-shell, ask the library for its version:

RETURN gds.version();

A returned version string confirms the plugin loaded. You can also count how many procedures were registered:

SHOW PROCEDURES YIELD name
WHERE name STARTS WITH 'gds'
RETURN count(*);
NoteIf the service fails to start

Check /var/log/neo4j/neo4j.log and /var/log/neo4j/debug.log. The usual causes are a version mismatch between the jar and the server, a jar the neo4j user cannot read, or a duplicated dbms.security.procedures.unrestricted key in the configuration file.

Installing GDS in a Docker DBMS

If you followed the Docker Compose approach above, the equivalent is to let the official image download the plugin for you with the NEO4J_PLUGINS environment variable, which also handles the unrestricted setting automatically:

services:
  dbms-1:
    image: neo4j:5-community
    ports: ["7474:7474", "7687:7687"]
    environment:
      NEO4J_AUTH: neo4j/password1
      NEO4J_PLUGINS: '["graph-data-science"]'
    volumes:
      - ./dbms-1/data:/data
      - ./dbms-1/plugins:/plugins

Recreate the container with docker compose up -d --force-recreate dbms-1 and verify with RETURN gds.version(); as before.

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

Reading the query line by line

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.

Nodes, labels, and properties

Figure 2: The three building blocks of a graph: nodes, labels, and properties.

Figure 2 shows those first three building blocks side by side in a small example graph:

  1. Nodes are the primary entities in a graph, the “things” you want to store: a person, a location, a book, or in our dataset a track, an album, or an artist. Each circle in the figure is one node, and nodes are connected to one another by relationships (the arrows).
  2. Labels categorise nodes by type. Person, location, and Book are labels, roughly the graph equivalent of a table name in a relational database. Two nodes share a label when they are the same kind of thing: Alice and Marc are both Person nodes, which is why they are drawn in the same colour. Labels let us query a subset of the graph efficiently, for example “all Person nodes”, and a node may carry more than one label.
  3. Properties are key-value pairs that store the additional information describing a node. The Alice node holds Name: "Alice", Age: 30, and Email: "alice@example.com"; the Book node holds Author: "Marc" and Title: "Databases". Properties are the analogue of columns on a row, but unlike a table there is no fixed schema: nodes sharing a label are free to have different properties.
NoteNaming conventions

The figure writes one label in lowercase (location) while the others are capitalised. Neo4j itself is case-sensitive but imposes no rule here; by convention labels are written in PascalCase (Person, Location, Book), relationship types in UPPER_SNAKE_CASE (HAS_TRACK, ARTIST), and property keys and variables in camelCase (trackId, album). Following the convention keeps queries readable, so we use Track, Album, and Artist rather than track or TRACK throughout this post.

Notice that the same information can be modelled either as a property or as a node. In the figure, Alice’s home city is not a string property on the person; it is a separate location node connected by a relationship. Promoting a value to its own node is what makes it shareable and traversable, so several people can point at the same London node, and we can ask “who else lives here?”. This is exactly the choice we make below when Album, Track, and Artist each become nodes rather than columns on a single record.

A closer look at properties

Figure 3: Properties are key-value pairs, and they can be stored on relationships as well as on nodes.

Figure 3 zooms in on properties, the mechanism a graph uses to store the actual data:

  • Every property is a key-value pair. The key is the property name (Title, Published) and the value is the data itself ("The Great Gatsby", 1925). In Cypher they are written inside curly braces at creation time, {title: "The Great Gatsby", published: 1925}, and read back with dot notation, book.title.
  • Values are typed. Neo4j supports strings, numbers (integer and float), booleans, temporal types such as dates and datetimes, spatial points, and homogeneous lists of those types. A property cannot itself hold a node or a nested map, which is why anything with its own structure has to become a node. Published: 1925 is stored as an integer, not as the string "1925", so it can be compared and ordered in queries such as WHERE book.published < 1950.
  • Relationships carry properties too. This is the key point of Figure 3: the arrow between the two nodes has its own (Key:Value) attached, exactly like the circles do. That lets us qualify a connection rather than just assert it, for example (:Person)-[:RATED {stars: 5, on: date()}]->(:Book), where the rating and the date belong to the relationship, not to either node.
  • There is no fixed schema. Two nodes sharing a label may hold completely different property keys, and a key can simply be absent rather than NULL. Storing a property with a null value is in fact the same as not storing it at all: Neo4j removes the property instead.

In our music graph the Track nodes follow exactly this pattern: id, uri, and name are three string properties keyed by name, set with {id: row.track_id} at creation and with SET track.uri = ... afterwards. The HAS_TRACK relationship carries no properties yet, but we could add one, such as the position of the track in the playlist, without changing a single node.

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);

What a relationship is

Figure 4: Relationships connect nodes and carry a type, a direction, and optionally their own properties.

Figure 4 extends the earlier example graph with the edges between the nodes, and it highlights the four things that define a relationship:

  1. Relationships define how nodes are related. They are the connections that turn a set of isolated nodes into a graph. Here Alice Lives_in London, Alice KNOWS Marc, Marc Has_Written the book, and Alice Has_Purchased it. In Neo4j a relationship is a first-class entity stored alongside the nodes, not a join computed at query time, which is why traversing from one node to its neighbours is a constant-time hop rather than an index lookup.
  2. Types specify the relationship’s nature. LIVES_IN, KNOWS, HAS_WRITTEN, and HAS_PURCHASED are relationship types, the equivalent of a label on a node. The type is what distinguishes “Alice knows Marc” from “Alice purchased a book”, and it lets us restrict a query to one kind of edge, for example MATCH (:Person)-[:KNOWS]->(:Person). Every relationship has exactly one type, and it must be given when the relationship is created.
  3. Relationships have direction. Each edge runs from a start node to an end node, drawn as an arrow. Direction matters when creating data: (marc)-[:HAS_WRITTEN]->(book) says Marc wrote the book, not the reverse. When querying, though, direction is optional: -[:KNOWS]- (no arrow) matches the relationship regardless of which way it points, which is useful for naturally symmetric connections such as KNOWS.
  4. Relationships can also have properties. Just like nodes, an edge carries key-value pairs. The figure stores since: 01/09/2015 on Lives_in, since: 05/04/2010 on KNOWS, and both on: 12/10/2014 and rated: 4 on Has_Purchased. These facts belong to the connection rather than to either node: a rating is not a property of Alice nor of the book, it only makes sense for the act of purchasing. In Cypher they are written the same way as node properties:
CREATE (alice)-[:HAS_PURCHASED {on: date('2014-10-12'), rated: 4}]->(book)
Note

The figure writes types inconsistently (Lives_in, KNOWS, Has_Written). Neo4j treats these as three different, case-sensitive types, so pick one style and stay with it; the convention is UPPER_SNAKE_CASE, which is what we use for HAS_TRACK and ARTIST below.

Two nodes may be connected by any number of relationships, of the same type or of different types, and a relationship must always have a start and an end node, so it cannot dangle. That is exactly why deleting a connected node requires DETACH DELETE, which we come back to when resetting the graph.

TipSketching a graph before you build it

Diagrams like Figure 2 and Figure 4 can be drawn interactively with Arrows.app, a free browser-based tool from Neo4j for visualising graph models. You drag out nodes, connect them with relationships, and fill in labels and properties directly on the canvas, with nothing to install and no database required.

It is a quick way to design a data model before writing any Cypher, and to communicate it afterwards: the finished sketch can be exported as an image (SVG or PNG) for documentation, saved as JSON to reopen later, or exported as Cypher CREATE statements that recreate the sketched graph in Neo4j.

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 5: 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 5 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 6: 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 6, 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 7: The Track node in the first chain has id: "1FTSo4v6BOZH9QxKc3MbVM".
Figure 8: 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).

  4. Neo4j (2026) Graph Data Science: Installation on a Neo4j Server. Available at: https://neo4j.com/docs/graph-data-science/current/installation/neo4j-server/ (Accessed: 28 August 2026).

  5. Cramlays and Gujral, N. (2026) Neo4j: Cypher, GDS, GraphQL, LLM, Knowledge Graphs for RAG. Hands-on Course on Neo4j, Cypher, GDS, GraphQL, GraphRAG and Building Knowledge Graph from Unstructured Data Using LLM [Online course]. Udemy, April 2026. Available at: https://www.udemy.com/course/knowledge-graph-with-neo4j-cypher-gds/.