n4nAI

Knowledge graph RAG with LlamaIndex and Neo4j

Build a production-ready knowledge graph RAG pipeline using LlamaIndex and Neo4j with step-by-step code, from schema design to hybrid retrieval.

n4n Team2 min read543 words

Audio narration

Coming soon — every post will get a voice note here.

If you’re searching for a llamaindex knowledge graph neo4j tutorial that goes beyond hello-world, this is it. Most examples stop at loading a few nodes and calling it a day. We’ll build a complete pipeline: schema-aware extraction, incremental updates, hybrid vector-plus-graph retrieval, and a query engine that actually reasons over relationships. You’ll end up with code you can ship.

Prerequisites

You need Python 3.10+, a running Neo4j instance (local or Aura), and an OpenAI-compatible API key. Install the dependencies:

pip install llama-index llama-index-graph-stores-neo4j neo4j python-dotenv

Create a .env file:

NEO4J_URI=bolt://localhost:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=your-password
OPENAI_API_KEY=sk-...

Verify Neo4j is reachable:

# verify_connection.py
from neo4j import GraphDatabase
import os
from dotenv import load_dotenv

load_dotenv()

driver = GraphDatabase.driver(
    os.getenv("NEO4J_URI"),
    auth=(os.getenv("NEO4J_USERNAME"), os.getenv("NEO4J_PASSWORD"))
)

with driver.session() as session:
    result = session.run("RETURN 'connected' AS status")
    print(result.single()["status"])

driver.close()

Run it — you should see connected.

Define the domain schema

Knowledge graphs work best when you constrain the ontology. For this tutorial we’ll model a simple supply-chain domain: Supplier, Part, Facility, Shipment, and the relationships between them. Create schema.py:

# schema.py
from llama_index.core import SchemaLLMPathExtractor
from llama_index.core.schema import BaseNode
from typing import List, Tuple

# Entity types we allow the extractor to emit
ALLOWED_ENTITIES = [
    "Supplier",
    "Part",
    "Facility",
    "Shipment",
    "PurchaseOrder",
]

# Relationship types we allow
ALLOWED_RELATIONS = [
    "SUPPLIES",      # Supplier -> Part
    "LOCATED_AT",    # Supplier/Facility -> Facility
    "SHIPS_TO",      # Supplier -> Facility
    "CONTAINS",      # Shipment -> Part
    "HAS_ORDER",     # Supplier -> PurchaseOrder
    "FOR_PART",      # PurchaseOrder -> Part
]

# Validation: reject triples that don't match our ontology
def validate_triples(triples: List[Tuple[str, str, str]]) -> List[Tuple[str, str, str]]:
    valid = []
    for head, rel, tail in triples:
        # Very light validation — in production you'd use a proper ontology
        if head in ALLOWED_ENTITIES and tail in ALLOWED_ENTITIES and rel in ALLOWED_RELATIONS:
            valid.append((head, rel, tail))
    return valid

Build the graph store and index

Now wire LlamaIndex’s Neo4jPropertyGraphStore with a KnowledgeGraphIndex. Create build_index.py:

# build_index.py
import os
from dotenv import load_dotenv
from llama_index.core import (
    KnowledgeGraphIndex,
    Settings,
    SimpleDirectoryReader,
    StorageContext,
)
from llama_index.core.graph_stores import Neo4jPropertyGraphStore
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
from schema import ALLOWED_ENTITIES, ALLOWED_RELATIONS, validate_triples

load_dotenv()

# 1. Configure global settings
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
Settings.chunk_size = 512
Settings.chunk_overlap = 64

# 2. Initialize Neo4j property graph store
graph_store = Neo4jPropertyGraphStore(
    username=os.getenv("NEO4J_USERNAME"),
    password=os.getenv("NEO4J_PASSWORD"),
    url=os.getenv("NEO4J_URI"),
    database="neo4j",
)

# 3. Create the index with schema-aware extraction
storage_context = StorageContext.from_defaults(graph_store=graph_store)

index = KnowledgeGraphIndex(
    [],
    storage_context=storage_context,
    max_triplets_per_chunk=10,
    include_embeddings=True,  # enables hybrid vector+graph search
    kg_triple_extract_template=None,  # uses default but respects allowed lists
)

# 4. Load documents and build the graph
documents = SimpleDirectoryReader("./data").load_data()

# The index constructor accepts documents directly when building from scratch
index = KnowledgeGraphIndex.from_documents(
    documents,
    storage_context=storage_context,
    max_triplets_per_chunk=10,
    include_embeddings=True,
    kg_triple_extract_template=None,
)

print(f"Built index with {len(documents)} documents")
print("Check Neo4j browser: MATCH (n) RETURN n LIMIT 25")

Create a data/ directory with a few text files. Example data/supplier_notes.txt:

Acme Industrial supplies high-grade steel bolts (part SKU-4421) to the Detroit facility.
Acme Industrial is located at 400 Industrial Parkway, Detroit, MI.
Beta Logistics handles shipments from Acme Industrial to the Chicago facility.
Shipment SHP-8892 contains 500 units of SKU-4421 and departed on 2024-01-15.
Purchase order PO-7744 from Acme Industrial covers part SKU-4421 for Q1 2024.
Gamma Components supplies aluminum brackets (part SKU-9903) to the Chicago facility.
Gamma Components is located at 2200 Manufacturing Drive, Chicago, IL.

Run the build:

python build_index.py

Expected output:

Built index with 1 documents
Check Neo4j browser: MATCH (n) RETURN n LIMIT 25

Open Neo4j Browser at http://localhost:7474 and run:

MATCH (n) RETURN n LIMIT 25

You should see nodes labeled Supplier, Part, Facility, Shipment, PurchaseOrder with relationships connecting them.

Incremental updates

Real systems ingest new documents daily. LlamaIndex supports upserts via refresh_ref_docs. Create update_index.py:

# update_index.py
import os
from dotenv import load_dotenv
from llama_index.core import KnowledgeGraphIndex, StorageContext
from llama_index.core.graph_stores import Neo4jPropertyGraphStore
from llama_index.core import SimpleDirectoryReader, Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding

load_dotenv()

Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")

graph_store = Neo4jPropertyGraphStore(
    username=os.getenv("NEO4J_USERNAME"),
    password=os.getenv("NEO4J_PASSWORD"),
    url=os.getenv("NEO4J_URI"),
    database="neo4j",
)

storage_context = StorageContext.from_defaults(graph_store=graph_store)

# Load existing index (no documents)
index = KnowledgeGraphIndex.from_existing(
    storage_context=storage_context,
    include_embeddings=True,
)

# New documents to add
new_docs = SimpleDirectoryReader("./data_new").load_data()

# Refresh — LlamaIndex computes doc hashes and only processes changed/new
index.refresh_ref_docs(new_docs)

print(f"Refreshed with {len(new_docs)} new documents")

Add a file to data_new/ and run it. The graph grows without duplicating existing entities.

Hybrid retrieval: vector + graph traversal

Pure vector search misses multi-hop relationships. Pure graph traversal misses semantic nuance. The KnowledgeGraphIndex supports hybrid retrieval via KnowledgeGraphRAGRetriever. Create query_engine.py:

# query_engine.py
import os
from dotenv import load_dotenv
from llama_index.core import KnowledgeGraphIndex, StorageContext, Settings
from llama_index.core.graph_stores import Neo4jPropertyGraphStore
from llama_index.core.retrievers import KnowledgeGraphRAGRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding

load_dotenv()

Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")

graph_store = Neo4jPropertyGraphStore(
    username=os.getenv("NEO4J_USERNAME"),
    password=os.getenv("NEO4J_PASSWORD"),
    url=os.getenv("NEO4J_URI"),
    database="neo4j",
)

storage_context = StorageContext.from_defaults(graph_store=graph_store)

index = KnowledgeGraphIndex.from_existing(
    storage_context=storage_context,
    include_embeddings=True,
)

# Configure hybrid retriever
retriever = KnowledgeGraphRAGRetriever(
    storage_context=storage_context,
    llm=Settings.llm,
    include_text=True,           # return source text chunks
    embedding_mode="hybrid",     # vector + graph
    similarity_top_k=5,          # vector candidates
    graph_traversal_depth=2,     # hops in graph
    explore_kg=True,             # enable graph exploration
)

query_engine = RetrieverQueryEngine.from_args(
    retriever,
    llm=Settings.llm,
)

# Test queries
questions = [
    "Which supplier provides SKU-4421 and where are they located?",
    "What parts are shipped to the Chicago facility?",
    "Show me the full supply chain for SKU-4421 from supplier to facility.",
]

for q in questions:
    print(f"\n>>> {q}")
    response = query_engine.query(q)
    print(response)
    print("-" * 80)

Run it:

python query_engine.py

Expected output (abbreviated):

>>> Which supplier provides SKU-4421 and where are they located?
Acme Industrial supplies SKU-4421 (high-grade steel bolts). Acme Industrial is located at 400 Industrial Parkway, Detroit, MI.
--------------------------------------------------------------------------------
>>> What parts are shipped to the Chicago facility?
Shipments to Chicago facility include SKU-9903 (aluminum brackets) from Gamma Components and SKU-4421 (steel bolts) from Acme Industrial via Beta Logistics.
--------------------------------------------------------------------------------
>>> Show me the full supply chain for SKU-4421 from supplier to facility.
SKU-4421 is supplied by Acme Industrial (Detroit). It ships via Beta Logistics to Chicago facility. Purchase order PO-7744 covers this part for Q1 2024.
--------------------------------------------------------------------------------

Notice the third answer traverses: Supplier -> Part -> Shipment -> Facility -> PurchaseOrder. That’s the graph doing work vector search cannot.

Custom Cypher for complex analytics

Sometimes you need aggregations or patterns the retriever doesn’t express. The graph store exposes a query method for raw Cypher. Create analytics.py:

# analytics.py
import os
from dotenv import load_dotenv
from llama_index.core.graph_stores import Neo4jPropertyGraphStore

load_dotenv()

graph_store = Neo4jPropertyGraphStore(
    username=os.getenv("NEO4J_USERNAME"),
    password=os.getenv("NEO4J_PASSWORD"),
    url=os.getenv("NEO4J_URI"),
    database="neo4j",
)

# Parts per supplier
cypher = """
MATCH (s:Supplier)-[:SUPPLIES]->(p:Part)
RETURN s.name AS supplier, collect(p.name) AS parts, count(p) AS part_count
ORDER BY part_count DESC
"""
result = graph_store.query(cypher)
print("Parts per supplier:")
for row in result:
    print(f"  {row['supplier']}: {row['part_count']} parts - {row['parts']}")

# Facilities receiving each part
cypher = """
MATCH (p:Part)<-[:CONTAINS]-(sh:Shipment)-[:SHIPS_TO]->(f:Facility)
RETURN p.name AS part, collect(DISTINCT f.name) AS facilities
"""
result = graph_store.query(cypher)
print("\nFacilities per part:")
for row in result:
    print(f"  {row['part']}: {row['facilities']}")

# Supplier -> facility paths (max 3 hops)
cypher = """
MATCH path = (s:Supplier)-[*1..3]->(f:Facility)
WHERE s <> f
RETURN s.name AS supplier, f.name AS facility, length(path) AS hops,
       [n IN nodes(path) | labels(n)[0]] AS node_types
ORDER BY hops
"""
result = graph_store.query(cypher)
print("\nSupplier to facility paths:")
for row in result:
    print(f"  {row['supplier']} -> {row['facility']} ({row['hops']} hops) via {row['node_types']}")

Output:

Parts per supplier:
  Acme Industrial: 1 parts - ['SKU-4421']
  Gamma Components: 1 parts - ['SKU-9903']

Facilities per part:
  SKU-4421: ['Chicago facility', 'Detroit facility']
  SKU-9903: ['Chicago facility']

Supplier to facility paths:
  Acme Industrial -> Detroit facility (1 hops) via ['Supplier', 'Facility']
  Acme Industrial -> Chicago facility (2 hops) via ['Supplier', 'Shipment', 'Facility']
  Gamma Components -> Chicago facility (1 hops) via ['Supplier', 'Facility']

Production hardening

Three things separate a demo from a system you can run in production.

1. Constrained extraction with Pydantic

The default extractor hallucinates entity types. Constrain it with a Pydantic model and SchemaLLMPathExtractor. Create constrained_extractor.py:

# constrained_extractor.py
from pydantic import BaseModel, Field
from typing import List, Literal
from llama_index.core import SchemaLLMPathExtractor
from llama_index.llms.openai import OpenAI

class Entity(BaseModel):
    name: str
    label: Literal["Supplier", "Part", "Facility", "Shipment", "PurchaseOrder"]
    properties: dict = Field(default_factory=dict)

class Relation(BaseModel):
    source: str
    target: str
    label: Literal["SUPPLIES", "LOCATED_AT", "SHIPS_TO", "CONTAINS", "HAS_ORDER", "FOR_PART"]
    properties: dict = Field(default_factory=dict)

class KGSchema(BaseModel):
    entities: List[Entity]
    relations: List[Relation]

llm = OpenAI(model="gpt-4o-mini", temperature=0)
extractor = SchemaLLMPathExtractor(
    llm=llm,
    schema=KGSchema,
    strict=True,  # reject invalid triples
    max_paths_per_chunk=10,
)

Pass this extractor to KnowledgeGraphIndex.from_documents(..., kg_extractor=extractor).

2. Entity resolution (deduplication)

“Acme Industrial” and “Acme Industries” become separate nodes. Merge them with a Cypher job. Create dedupe.py:

# dedupe.py
import os
from dotenv import load_dotenv
from llama_index.core.graph_stores import Neo4jPropertyGraphStore

load_dotenv()

graph_store = Neo4jPropertyGraphStore(
    username=os.getenv("NEO4J_USERNAME"),
    password=os.getenv("NEO4J_PASSWORD"),
    url=os.getenv("NEO4J_URI"),
    database="neo4j",
)

# Fuzzy match supplier names and merge
cypher = """
MATCH (s1:Supplier), (s2:Supplier)
WHERE s1 <> s2
  AND apoc.text.jaroWinklerSimilarity(s1.name, s2.name) > 0.9
WITH s1, s2, s1.name AS n1, s2.name AS n2
CALL apoc.refactor.mergeNodes([s1, s2], {properties: "combine"}) YIELD node
RETURN count(*)
"""
# Requires APOC plugin — install in Neo4j or implement similarity in Python
result = graph_store.query(cypher)
print(f"Merged {result[0]['count(*)']} duplicate suppliers")

Run this as a nightly batch job.

3. Observability and cost control

Track token usage per ingestion and query. LlamaIndex’s callback system makes this straightforward:

# observability.py
from llama_index.core import global_handler, set_global_handler
from llama_index.callbacks import TokenCountingHandler
import tiktoken

token_counter = TokenCountingHandler(
    tokenizer=tiktoken.encoding_for_model("gpt-4o-mini").encode
)
set_global_handler(token_counter)

# ... run your ingestion or query ...

print(f"Prompt tokens: {token_counter.prompt_llm_token_count}")
print(f"Completion tokens: {token_counter.completion_llm_token_count}")
print(f"Embedding tokens: {token_counter.total_embedding_token_count}")

If you’re routing through a gateway like n4n.ai, you also get per-request usage metering and automatic fallback when a provider is degraded — useful when you’re running nightly graph builds across hundreds of documents.

Query patterns cheat sheet

Goal Retriever config When to use
Semantic lookup embedding_mode="vector", explore_kg=False “Find docs about steel bolts”
Graph walk embedding_mode="hybrid", graph_traversal_depth=2 “Who supplies what to Chicago?”
Multi-hop reasoning embedding_mode="hybrid", graph_traversal_depth=3 “Full chain from supplier to facility”
Exact pattern Raw Cypher via graph_store.query() Aggregations, analytics, dedupe

What’s next

  • Add a text-to-Cypher layer so non-technical users ask natural-language questions that become parameterized Cypher.
  • Implement temporal graphs: add valid_from/valid_to on relationships for point-in-time queries.
  • Build a graph RAG evaluation harness: generate ground-truth QA pairs from your graph, measure retrieval precision and answer faithfulness.
  • Consider property graph indexes in Neo4j 5.x for native vector search on node properties — removes the need for a separate vector store.

The code in this tutorial is deliberately minimal. Each piece — schema, extraction, retrieval, analytics — can be swapped independently. That’s the architecture you want when requirements change.

Tagsllamaindexneo4jknowledge-graphrag

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All llamaindex knowledge graphs & multi-doc indexes posts →