Introduction
Artificial intelligence applications increasingly require structured knowledge and complex relationship reasoning that traditional databases cannot provide. Neo4j’s graph database is uniquely positioned to power AI applications, from building knowledge graphs that augment LLMs to enabling graph neural networks and feature engineering for machine learning. This article explores how to leverage Neo4j for AI applications. The progression is intentional: we start with knowledge graphs, then add vector embeddings for semantic search, then combine both in GraphRAG, and finally move into graph-native machine learning. Each stage builds on the previous one, so the code samples compose into a complete pipeline by the end.
Knowledge Graphs for AI
Knowledge graphs provide structured, interpretable representations of domain knowledge that AI systems can reason over. Unlike a flat vector store, a knowledge graph preserves the relationships between concepts, which is exactly what makes it useful for tasks that require multi-hop reasoning. The graph also doubles as an explanation surface: when an AI system answers a question, the path it traversed through the graph is visible and auditable. The examples in this section build a small knowledge graph, populate it from text, and then show how to fill in missing connections.
Building Domain Knowledge Graphs
The Cypher statement below creates the skeleton of a domain knowledge graph in a single query.
Each CREATE clause defines a node with a label and a set of properties, and each relationship clause connects two nodes with a typed edge.
The resulting structure is a taxonomy: Machine Learning is a subfield of Artificial Intelligence, Deep Learning is a subfield of Machine Learning, and so on.
The IS_A, USES, and ENABLES relationship types carry real semantic weight, because they are exactly the kinds of edges that later traversal and reasoning queries will follow.
Notice that creating data and creating structure happen in the same statement, which is a small taste of why graph databases are so concise for modeling relationships.
The design decision worth highlighting is the choice of relationship types.
IS_A encodes a hierarchy, USES encodes a dependency between concepts, and ENABLES encodes a capability claim.
Using distinct, semantically meaningful relationship types instead of a single generic “related to” edge is what later allows precise queries — you can ask for only the subfields, or only the technologies a field enables.
It also makes the graph readable to both humans and the LLMs that will later be asked to reason over it.
// Create a domain knowledge graph
CREATE
// Entities
(ai:Concept {name: 'Artificial Intelligence', category: 'field'}),
(ml:Concept {name: 'Machine Learning', category: 'subfield'}),
(dl:Concept {name: 'Deep Learning', category: 'subfield'}),
(nn:Concept {name: 'Neural Networks', category: 'technique'}),
(transformer:Concept {name: 'Transformer', category: 'architecture'}),
// Relationships
(ml)-[:IS_A]->(ai),
(dl)-[:IS_A]->(ml),
(nn)-[:IS_A]->(ml),
(transformer)-[:USES]->(nn),
(dl)-[:ENABLES]->(transformer),
// Additional context
(ai)-[:HAS_APPLICATION]->(nlp:NLP {name: 'Natural Language Processing'})
One operational note: the CREATE statement assumes the graph is empty or that you are building a fresh project.
For production ingestion, you would use MERGE instead of CREATE so that re-running the script is idempotent and does not duplicate nodes.
The taxonomy shape you choose here also determines how the rest of the pipeline behaves, so it is worth designing the schema before writing any extraction or embedding code.
Extracting Knowledge from Text
Real-world graphs are rarely written by hand; they are mined from documents.
The Python example below combines spaCy’s natural language processing with Neo4j to extract entities and relationships from prose.
The extract_knowledge function runs two passes over the text.
The first pass uses spaCy’s named entity recognizer to find entities like people, organizations, and dates, then MERGEs each one into the graph as an Entity node.
The second pass parses each sentence’s dependency tree to find subject–verb–object triples — patterns like “machine learning uses neural networks” — and creates a RELATES relationship between the two entities involved.
The choice of MERGE in both loops is deliberate and important.
MERGE only creates a node or relationship if it does not already exist, which makes the extraction function safe to run repeatedly against the same text.
Re-running the script will not create duplicate entities or duplicate edges.
The relationship extraction logic is intentionally simple — it looks for the nsubj dependency on a verb and its dobj child — which keeps the example readable, but the same pattern generalizes to more sophisticated extractors such as those built with LLMs.
The trade-off is that simple dependency-based extraction misses more complex linguistic structures, which is acceptable for a first pass and can be improved later.
# Extract entities and relationships from text
from neo4j import GraphDatabase
import spacy
nlp = spacy.load("en_core_web_sm")
driver = GraphDatabase.driver("bolt://localhost:7687")
def extract_knowledge(text):
doc = nlp(text)
with driver.session() as session:
# Extract entities
for ent in doc.ents:
session.run("""
MERGE (e:Entity {name: $name})
SET e.label = $label
""", name=ent.text, label=ent.label_)
# Extract relationships
for sent in doc.sents:
for token in sent:
if token.dep_ == "nsubj" and token.head.pos_ == "VERB":
subject = token.text
verb = token.head.text
for child in token.head.children:
if child.dep_ == "dobj":
obj = child.text
session.run("""
MATCH (s:Entity {name: $subject})
MERGE (o:Entity {name: $obj})
MERGE (s)-[:RELATES {verb: $verb}]->(o)
""", subject=subject, verb=verb, obj=obj)
extract_knowledge("Machine learning uses neural networks to learn patterns.")
Extraction quality is the ceiling for everything downstream. If entities are misspelled, inconsistently cased, or merged incorrectly, every query and model built on top inherits those errors. That is why entity resolution, covered later in this article, is a necessary companion to extraction. It is also why the pipeline stores both the raw text and the extracted structure, so the source can always be re-examined.
Knowledge Graph Completion
Knowledge graphs built from limited text are inevitably incomplete.
A graph constructed from ten documents may be missing relationships that a reader would infer from context.
Knowledge graph completion uses machine learning to predict those missing links, and Neo4j’s Graph Data Science (GDS) library provides link prediction out of the box.
The Cypher below calls the link prediction procedure on the knowledgeGraph projection, asking for the top ten most likely RELATES_TO relationships above a confidence threshold of 0.7.
// Predict missing relationships
CALL gds.linkPrediction.prediction(
'knowledgeGraph',
'Concept',
'RELATES_TO',
{
topN: 10,
threshold: 0.7
}
)
YIELD relationships, probability
The threshold parameter is the key tuning knob.
Set it too high and you miss genuine connections; set it too low and you flood the graph with low-confidence edges.
The probability scores returned by the procedure are valuable not just for filtering but as features in downstream models, as later sections will show.
Link prediction is also how a graph keeps itself current without manual curation.
Vector Embeddings in Neo4j
Knowledge graphs excel at explicit relationships, but they do not help with the fuzzier problem of finding content that is semantically similar to a query. Vector embeddings solve that problem by representing text as a high-dimensional vector where similar meanings land near each other. Neo4j can store and query vector embeddings for similarity search. This means you can keep structured knowledge and unstructured content in the same database and query both with a single tool. The examples below show the full flow: storing embeddings on nodes, generating them from text, and querying by similarity.
Storing Embeddings
The first step is mechanical but essential: attach a vector to each node and build an index that makes similarity queries fast.
The Cypher below sets an embedding property on every Person node, then creates a vector index on that property.
The index is what turns a naive scan of every embedding into an efficient nearest-neighbor search.
The IF NOT EXISTS guard makes the index creation idempotent, so the script can be re-run without error.
Note that vector indexes require Neo4j 5.x or later, which is the version assumption for all the code in this article.
// Add embedding property to nodes
MATCH (p:Person)
SET p.embedding = [0.123, -0.456, 0.789, ...] // 128-dim vector
// Create index for similarity search
// Note: Neo4j 5.x+ supports vector indexes
CREATE INDEX embedding_idx IF NOT EXISTS
FOR (n:Entity) ON (n.embedding)
The choice of embedding dimension deserves attention. The example comments say 128 dimensions, but the right value depends on your embedding model and dataset size. Larger dimensions capture more nuance but cost more memory and slower queries. Whatever dimension you choose, it must match the output size of the embedding model you use, because similarity search compares vectors of the same length. Keep the embedding as a property of the node itself, as shown, so that graph traversal and vector search can operate on the same data in a single query.
Semantic Search
With embeddings stored, you can now search by meaning rather than by keyword.
The Python function below generates an embedding for a piece of text using OpenAI’s embedding API, then stores the text, its embedding, and arbitrary metadata as a Document node.
The design separates concerns cleanly: the embedding model runs outside Neo4j, while the graph stores the result alongside the raw text.
Storing the raw text next to the embedding is a deliberate choice, because retrieval is almost always followed by feeding that text to an LLM, and you want the source available without a second lookup.
The metadata parameter is also stored as a node property so you can filter by topic or source during retrieval.
# Generate embeddings and store in Neo4j
from neo4j import GraphDatabase
from openai import OpenAI
client = OpenAI()
driver = GraphDatabase.driver("bolt://localhost:7687")
def store_with_embeddings(text, metadata):
# Generate embedding
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
embedding = response.data[0].embedding
# Store in Neo4j
with driver.session() as session:
session.run("""
CREATE (d:Document {
text: $text,
embedding: $embedding,
metadata: $metadata
})
""", text=text, embedding=embedding, metadata=metadata)
# Store documents
store_with_embeddings(
"Machine learning is a subset of artificial intelligence.",
{"topic": "AI", "source": "textbook"}
)
The function uses CREATE rather than MERGE because each call is expected to insert a new document.
For an ingestion pipeline that re-processes documents, you would MERGE on a document ID to avoid duplicates.
The embedding call itself can be a bottleneck at scale, so production pipelines typically batch the API calls and write embeddings in bulk.
Similarity Queries
Querying for similar documents mirrors the storage path.
The find_similar function below embeds the query text with the same model used at ingest time, then runs a Cypher query that computes the similarity between the query embedding and every stored document embedding.
It orders the results by descending similarity and returns the top-k matches.
The apoc.algo.similarity function computes a cosine-style similarity score, which is the standard measure for comparing embeddings from the same model space.
The most important detail in this function is that the same model — text-embedding-3-small — is used for both storage and query, because embeddings from different models are not directly comparable.
# Find similar documents
def find_similar(query, top_k=5):
# Generate query embedding
response = client.embeddings.create(
model="text-embedding-3-small",
input=query
)
query_embedding = response.data[0].embedding
with driver.session() as session:
result = session.run("""
MATCH (d:Document)
WITH d,
apoc.algo.similarity(d.embedding, $embedding) AS sim
RETURN d.text, sim
ORDER BY sim DESC
LIMIT $topK
""", embedding=query_embedding, topK=top_k)
return [(record['d.text'], record['sim']) for record in result]
# Find similar documents
results = find_similar("What is deep learning?")
for text, score in results:
print(f"Score: {score:.3f} - {text[:50]}...")
The similarity threshold, when you introduce one, is the tuning knob that separates a search tool from a search product.
Too low a threshold returns irrelevant documents; too high returns almost nothing.
The LIMIT $topK keeps the result set bounded, which is important for latency.
The same similarity computation becomes the backbone of the GraphRAG pipeline in the next section, where retrieved documents are handed to an LLM as context.
GraphRAG: Graph + Retrieval Augmented Generation
GraphRAG combines knowledge graphs with LLMs for improved question answering. Retrieval Augmented Generation (RAG) works by fetching relevant context and stuffing it into the prompt so the LLM can answer from the retrieved facts instead of relying solely on its training data. What makes GraphRAG different is the source of that context: instead of retrieving flat chunks of text, it retrieves a structured subgraph — the entities and relationships around the question. That structure lets the LLM reason over connections rather than just matched keywords, which is a substantial advantage for questions that require multi-hop reasoning or explicit relationship knowledge. The pipeline has two stages, retrieval and generation, and both are visible in the class below.
Building the RAG Pipeline
The GraphRAG class below is a complete, if compact, implementation of the pattern.
The retrieve_context method is the heart of the retrieval stage.
It first runs a fuzzy match to find entities in the graph whose names appear in the question, then expands each match by fetching the paths incident to it, and finally flattens those paths into a list of triples describing from node, relationship type, and to node.
The answer method then serializes those triples into a single textual context block and builds a prompt that tells the LLM to answer the question using that context.
This two-step design keeps retrieval and generation independently testable, and it means you can swap either stage — a different retrieval strategy or a different model — without touching the other.
# Complete GraphRAG implementation
from neo4j import GraphDatabase
from openai import OpenAI
class GraphRAG:
def __init__(self, neo4j_uri, neo4j_user, neo4j_password, openai_key):
self.driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_user, neo4j_password))
self.client = OpenAI(api_key=openai_key)
def retrieve_context(self, question, max_nodes=10):
"""Extract relevant subgraph from knowledge graph"""
with self.driver.session() as session:
# Extract key entities from question
entity_result = session.run("""
MATCH (e:Entity)
WHERE toLower(e.name) CONTAINS toLower($question)
RETURN e
LIMIT 5
""", question=question)
entities = [record['e'] for record in entity_result]
# Get related context
context = []
for entity in entities:
result = session.run("""
MATCH path = (e)-[r]-(related)
WHERE e.name = $entity_name
RETURN path
LIMIT 3
""", entity_name=entity['name'])
for record in result:
path = record['path']
for rel in path.relationships:
context.append({
'from': rel.start_node['name'],
'relationship': rel.type,
'to': rel.end_node['name']
})
return context
def answer(self, question):
# Retrieve context
context = self.retrieve_context(question)
# Build prompt
context_str = "\n".join([
f"{c['from']} -{c['relationship']}-> {c['to']}"
for c in context
])
prompt = f"""Based on this knowledge graph information:
{context_str}
Question: {question}
Answer:"""
# Generate answer
response = self.client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
temperature=0.7
)
return response.choices[0].message.content
def close(self):
self.driver.close()
# Usage
rag = GraphRAG(
"bolt://localhost:7687",
"neo4j", "password",
"your-openai-key"
)
answer = rag.answer("What is the relationship between machine learning and neural networks?")
print(answer)
rag.close()
The GraphRAG pattern fixes the main weakness of plain RAG.
A vector search can retrieve documents that mention the same terms, but it cannot tell the LLM that one concept causes another or that two concepts are both subtypes of a third.
GraphRAG supplies that relational context, and the prompt format used here — a list of from -RELATIONSHIP-> to triples — presents it in a form the LLM can consume directly.
The trade-off is retrieval precision: the fuzzy entity match used here can miss entities phrased differently in the question, which is where the hybrid search in the next section helps.
Hybrid Search with Vector + Graph
The most effective modern RAG systems do not choose between vectors and graphs; they combine both.
The hybrid_search function below shows the simplest useful combination: a vector similarity search filtered by an optional graph-aware property.
It embeds the query, computes cosine similarity against every document embedding, and narrows the results to a matching topic when a topic filter is supplied.
The filter is the graph-flavored part — d.topic is a property on the document nodes, and applying it as a filter lets you do “semantic search, but only within this category,” which neither pure vector search nor pure keyword search handles well on its own.
# Combine vector similarity with graph traversal
def hybrid_search(query, topic_filter=None):
# Get query embedding
response = client.embeddings.create(
model="text-embedding-3-small",
input=query
)
query_embedding = response.data[0].embedding
with driver.session() as session:
# Vector similarity search
result = session.run("""
MATCH (d:Document)
WITH d,
apoc.algo.similarity(d.embedding, $embedding) AS sim
WHERE $topic IS NULL OR d.topic = $topic
RETURN d.text, d.topic, sim
ORDER BY sim DESC
LIMIT 10
""", embedding=query_embedding, topic=topic_filter)
return [(dict(record['d']), record['sim']) for record in result]
The $topic IS NULL OR d.topic = $topic predicate is a compact way to make the filter optional in a single query.
When no topic is passed, the condition short-circuits and the search behaves like a plain semantic search.
This function is only the seed of a true hybrid approach; production systems go further and enrich the vector results with the results of graph traversal, for example retrieving the neighbors of each matched document to deepen the context handed to the LLM.
The key architectural takeaway is that vectors and graphs are complementary retrieval channels, and combining them improves both precision and recall.
Machine Learning Features
Neo4j Graph Data Science (GDS) library enables ML on graph data. So far the article has used the graph as a retrieval layer for LLMs. GDS unlocks a second major use case: using the graph itself as a source of machine learning features. Graph algorithms compute structural properties of nodes — how central a node is, how many neighbors it has, which community it belongs to — and those properties make excellent inputs for traditional ML models. The examples below compute a set of graph features, export them to a DataFrame, and train a classifier on them.
Feature Engineering
The Cypher block below runs five Graph Data Science algorithms against a projected graph named myGraph.
PageRank computes a measure of node importance based on the structure of inbound links.
Betweenness centrality measures how often a node lies on the shortest paths between other nodes.
The degree algorithm counts each node’s connections, optionally weighted by a relationship property.
Label propagation assigns each node to a community.
Finally, Node2Vec produces a 128-dimensional vector embedding of each node based on random walks through the graph.
Each call uses the write mode, meaning the computed value is written back to the node as a new property that later queries can read.
// Generate node features using GDS
// 1. PageRank
CALL gds.pageRank.write('myGraph', {
writeProperty: 'pageRank'
})
// 2. Betweenness centrality
CALL gds.betweenness.write('myGraph', {
writeProperty: 'betweenness'
})
// 3. Node degree
CALL gds.degree.write('myGraph', {
writeProperty: 'degree',
relationshipWeightProperty: 'strength'
})
// 4. Community detection
CALL gds.labelPropagation.write('myGraph', {
writeProperty: 'community'
})
// 5. Node2Vec embeddings
CALL gds.node2vec.write('myGraph', {
embeddingDimension: 128,
walkLength: 80,
walksPerNode: 10,
windowSize: 10,
writeProperty: 'embedding'
})
The choice of features is the real design decision here. PageRank and betweenness capture importance and bridging roles, which are strong signals in fraud detection and influence modeling. Community membership is categorical and often encodes a grouping that is invisible in tabular data. The Node2Vec embedding is a dense vector that can be consumed directly by models that expect vector input. These features are complementary, and feeding all of them to a model, as the next section does, usually beats relying on any single one.
Export Features for ML
The features written back to the graph are only useful if they can reach a modeling framework.
The export_node_features function below pulls the computed graph properties out of Neo4j and reshapes them into a pandas DataFrame.
The Cypher query selects each node’s ID, the graph features written by the GDS calls, and a label property that serves as the target variable for supervised learning.
The important transformation is the embedding flattening: the Node2Vec vector is stored as a list, and the code expands it into one column per dimension (emb_0, emb_1, and so on) so the DataFrame has a flat tabular structure that scikit-learn models can consume directly.
# Export graph features for ML training
import pandas as pd
def export_node_features():
with driver.session() as session:
result = session.run("""
MATCH (p:Person)
RETURN
p.id AS id,
p.pageRank AS pagerank,
p.betweenness AS betweenness,
p.degree AS degree,
p.community AS community,
p.embedding AS embedding,
p.label AS label // Target variable
""")
data = []
for record in result:
row = {
'id': record['id'],
'pagerank': record['pagerank'],
'betweenness': record['betweenness'],
'degree': record['degree'],
'community': record['community'],
'label': record['label']
}
# Flatten embedding
if record['embedding']:
for i, val in enumerate(record['embedding']):
row[f'emb_{i}'] = val
data.append(row)
return pd.DataFrame(data)
# Train ML model
df = export_node_features()
X = df.drop(['id', 'label'], axis=1)
y = df['label']
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
X_train, X_test, y_train, y_test = train_test_split(X, y)
model = RandomForestClassifier().fit(X_train, y_train)
accuracy = model.score(X_test, y_test)
The rest of the block is a standard scikit-learn workflow: split into train and test sets, fit a random forest, and score it.
The id column is dropped from the feature matrix because it is an identifier, not a predictive signal.
The design pattern here is worth keeping: the graph does the feature engineering, and a conventional library does the modeling.
This hybrid approach is usually easier to operate and explain than an end-to-end graph model, and it lets you keep your existing modeling stack.
Link Prediction
Feature engineering treats the graph as a source of node features.
Link prediction instead treats the graph as the thing being predicted, answering the question “which nodes should be connected that currently are not?”
This is the same idea as the knowledge graph completion shown earlier, now applied with a trained model.
The Cypher below runs the three phases of the GDS link prediction workflow.
First it projects the relevant portion of the graph into memory, keeping the weight property on KNOWS relationships.
Then it trains a model using the Adamic-Adar algorithm, which scores potential links by the overlap of their neighbors.
Finally it mutates the projected graph by writing the top 100 predicted links as PREDICTED_KNOWS relationships.
// Predict future connections
// Create graph projection
CALL gds.graph.project(
'linkPredGraph',
'Person',
'KNOWS',
{
relationshipProperties: 'weight'
}
)
// Train link prediction model
CALL gds.linkPrediction.train(
'linkPredGraph',
'KNOWS',
{
featureProperties: ['weight'],
algorithm: 'adamicAdar'
}
)
YIELD modelInfo
// Make predictions
CALL gds.linkPrediction.predict.mutate(
'linkPredGraph',
'KNOWS',
{
topN: 100,
mutateRelationshipType: 'PREDICTED_KNOWS'
}
)
YIELD relationshipsWritten
The mutate write mode is a deliberate safety choice.
It writes predictions into a separate relationship type — PREDICTED_KNOWS — rather than overwriting the real KNOWS edges.
That separation lets you inspect, validate, or discard predictions without touching the source data.
Link prediction has concrete applications across industries, from recommending friends in social graphs to predicting which entities belong together in fraud networks.
Entity Resolution
Graphs excel at identity resolution and entity matching.
When data comes from multiple sources, the same real-world entity often appears under slightly different representations.
Entity resolution is the task of discovering that these separate records refer to the same thing, and graph databases are a natural fit because the “same as” relation is itself a relationship.
The resolve_entities function below implements a simple but effective resolution workflow in three Cypher passes.
First, every incoming record is created as an Entity node keyed by its source and external ID.
Second, a matching query links entities that share a name but come from different sources with a SAME_AS relationship.
Third, a grouping query collects the duplicates attached to each canonical entity and counts them.
# Entity resolution using graph matching
def resolve_entities(records):
"""
Match duplicate entities across data sources
"""
with driver.session() as session:
# Create entity nodes
for record in records:
session.run("""
MERGE (e:Entity {source: $source, external_id: $id})
SET e.name = $name,
e.properties = $props
""",
source=record['source'],
id=record['id'],
name=record['name'],
props=record.get('properties', {})
)
# Link similar entities
session.run("""
MATCH (e1:Entity), (e2:Entity)
WHERE e1 <> e2
AND e1.name = e2.name
AND e1.source <> e2.source
MERGE (e1)-[:SAME_AS]->(e2)
""")
# Find canonical entities
result = session.run("""
MATCH (e1:Entity)-[:SAME_AS]->(e2:Entity)
WITH e1, collect(e2) AS duplicates
WHERE size(duplicates) > 0
RETURN e1.name AS canonical,
size(duplicates) AS dup_count
""")
return [(r['canonical'], r['dup_count']) for r in result]
The matching logic uses a deliberately conservative rule: identical names from different sources.
That keeps precision high — you never merge entities that merely look similar — at the cost of recall, because real duplicates often differ by typos or abbreviations.
The MERGE on (source, external_id) guarantees each incoming record maps to exactly one node, which is what makes the whole function idempotent and re-runnable.
The SAME_AS edges form connected components in the graph, and finding canonical entities reduces to grouping those components, which is exactly the kind of computation a graph engine performs efficiently.
Production systems upgrade the naive name match to embedding similarity or fuzzy matching and then score candidate pairs, but the graph-based organization of duplicates is identical.
Recommendation Systems
Graph-based recommendations leverage relationships. Recommendation engines are one of the most commercially important graph applications, because user–item interactions are naturally a graph: users rate items, buy items, or follow other users. The graph formulation lets a recommendation query express multi-hop logic that would require complex joins in a relational database. The Cypher query below implements collaborative filtering — the strategy of recommending items that similar users liked — in a single traversal. It starts from a target user, finds other users who rated the same items, scores those similar users by their number of common ratings, and then surfaces items those similar users rated highly that the target user has not yet seen.
# Collaborative filtering with Neo4j
def recommend_items(user_id, limit=10):
"""
Recommend items based on similar users
"""
with driver.session() as session:
result = session.run("""
// Find similar users
MATCH (user1:User {id: $userId})-[:RATED]->(item)<-[:RATED]-(user2:User)
WITH user2, count(item) AS commonRatings
// Find items user2 liked that user1 hasn't seen
MATCH (user2:User)-[r:RATED]->(item)
WHERE r.rating >= 4
AND NOT (user1:User {id: $userId})-[:RATED]->(item)
// Score and rank
WITH item, commonRatings, r.rating AS score
RETURN item.name AS item, SUM(score * commonRatings) AS recommendationScore
ORDER BY recommendationScore DESC
LIMIT $limit
""", userId=user_id, limit=limit)
return [dict(record) for record in result]
The scoring formula is the interesting design choice.
The recommendation score multiplies each item’s rating by the number of common ratings shared with the target user.
This produces a weighted rank where items liked by many similar users outrank items liked by only one, which is a simple but effective popularity-weighted collaborative filter.
The NOT clause that excludes items the user has already rated is what makes the query a recommendation rather than a list of everything.
The whole pipeline runs in a single round trip to the database, which is one of the strongest arguments for graph-based recommendations: the multi-hop logic is expressed declaratively instead of spread across application code.
Graph Neural Networks
For advanced ML, export graphs for GNN training. Graph Neural Networks (GNNs) take a different approach from the feature-engineering path shown earlier. Instead of computing hand-chosen features and feeding them to a classical model, a GNN learns a representation of each node by aggregating information from its neighbors, layer by layer. This makes GNNs powerful for tasks where the graph structure itself is the signal. Neo4j is not a GNN training framework, but it is an excellent source of the graph data those frameworks need. The function below exports the graph from Neo4j into a format that PyTorch Geometric, the standard GNN library, can consume directly.
# Export to PyTorch Geometric
import torch
from torch_geometric.data import Data
def export_to_pytorch_geometric():
with driver.session() as session:
# Get edge list
edges = session.run("""
MATCH (a:Entity)-[r]-(b:Entity)
RETURN id(a) AS source, id(b) AS target
""")
edge_list = [[r['source'], r['target']] for r in edges]
# Get node features
features = session.run("""
MATCH (n:Entity)
RETURN id(n) AS node_id, n.features AS features
""")
node_features = {}
for f in features:
node_features[f['node_id']] = f['features']
# Create PyTorch Geometric data
edge_index = torch.tensor(edge_list).t().contiguous()
x = torch.tensor([node_features[i] for i in range(len(node_features))])
data = Data(x=x, edge_index=edge_index)
return data
# Train GNN
data = export_to_pytorch_geometric()
The export is a two-query operation.
One query fetches the edge list as source–target node ID pairs, and the other fetches each node’s feature vector.
The code then assembles these into PyTorch Geometric’s Data object, which wraps a node feature matrix x and an edge index edge_index.
PyTorch Geometric expects edges in a specific two-row tensor layout, which is why the code transposes and makes the tensor contiguous before building the Data instance.
The simplicity of this bridge is the point: Neo4j handles storage, querying, and feature extraction, while PyTorch Geometric handles the deep learning, and the two integrate through a well-defined export format.
Complete AI Pipeline Example
The previous sections each solved one piece of the puzzle: knowledge graph construction, embedding storage, semantic retrieval, GraphRAG, feature engineering, and graph ML.
The Neo4jAIPipeline class below ties those pieces together into an end-to-end pipeline.
Its constructor opens a single driver and selects the embedding model, with a sensible default.
ingest_documents is the write path: for each document, it extracts entities, creates a Document node with content, metadata, and an embedding, and links the document to every entity it mentions via MENTIONS relationships.
That single ingestion step populates both the vector index for semantic search and the graph structure for relationship traversal, so the same data serves every downstream query.
# End-to-end Neo4j AI pipeline
class Neo4jAIPipeline:
def __init__(self, config):
self.driver = GraphDatabase.driver(config['neo4j_uri'], auth=config['auth'])
self.embedding_model = config.get('embedding_model', 'text-embedding-3-small')
def ingest_documents(self, documents):
"""Ingest documents into knowledge graph"""
for doc in documents:
self._store_document(doc)
def _store_document(self, doc):
with self.driver.session() as session:
# Extract entities
entities = self._extract_entities(doc['content'])
# Create document node
session.run("""
CREATE (d:Document {
id: $id,
content: $content,
metadata: $metadata,
embedding: $embedding
})
""",
id=doc['id'],
content=doc['content'],
metadata=doc.get('metadata', {}),
embedding=self._get_embedding(doc['content'])
)
# Link entities to document
for entity in entities:
session.run("""
MATCH (d:Document {id: $docId})
MERGE (e:Entity {name: $entity})
MERGE (d)-[:MENTIONS]->(e)
""", docId=doc['id'], entity=entity)
def query(self, question):
"""Answer questions using RAG"""
# Get relevant context
context = self._get_context(question)
# Generate answer
prompt = f"""Context:\n{context}\n\nQuestion: {question}\n\nAnswer:"""
# Return context for display
return {
'answer': prompt, # In production, call LLM
'context': context
}
def get_insights(self):
"""Generate graph insights"""
with self.driver.session() as session:
# Key entities
entities = session.run("""
MATCH (e:Entity)
RETURN e.name AS entity,
size((e)<-[:MENTIONS]-()) AS mentions
ORDER BY mentions DESC
LIMIT 10
""")
# Community structure
communities = session.run("""
CALL gds.labelPropagation.stream('knowledgeGraph')
YIELD nodeId, communityId
RETURN communityId, count(*) AS size
ORDER BY size DESC
""")
return {
'top_entities': [dict(r) for r in entities],
'communities': [dict(r) for r in communities]
}
The query and get_insights methods complete the pipeline.
query assembles a GraphRAG-style prompt from the retrieved context, and get_insights returns analytics — the most-mentionated entities and the community structure of the knowledge graph.
This final class demonstrates the architectural theme of the entire article: one graph database serving storage, retrieval, analytics, and ML, with each capability exercised through the same driver.
When you build a real system, start with this pipeline as the skeleton and then specialize each method — replace the placeholder entity extractor with a production LLM extractor, and route query through your LLM of choice.
Conclusion
Neo4j provides a powerful foundation for AI applications. From building knowledge graphs that augment LLMs to enabling sophisticated machine learning on graph data, Neo4j’s capabilities align perfectly with modern AI requirements. The GraphRAG pattern, vector embeddings, graph neural networks, and recommendation systems all benefit from Neo4j’s native graph representation. The recurring theme across every section is that the graph gives AI systems a structured, queryable memory: explicit relationships for reasoning, embeddings for semantic matching, and graph algorithms for learning.
Key capabilities include:
- Knowledge graphs for structured AI knowledge
- Vector embeddings for semantic search
- GraphRAG for enhanced LLM responses
- GDS library for graph machine learning
- Entity resolution and recommendation systems
Each capability maps to a specific pattern in this article, and they compose. Start with a knowledge graph, add embeddings, layer GraphRAG on top for LLM answering, then enrich with GDS features when you need predictive modeling. Choose the pieces that match the problem rather than adopting all of them at once, because each layer adds operational surface area. In the final article, we’ll explore real-world Neo4j use cases across industries.
Comments