← All posts
15 Jan 2025Matías Aguirre

RAG with Neo4j and LLMs - Supercharging AI with Knowledge Graphs

Learn how to implement Retrieval-Augmented Generation using Neo4j as a knowledge base to enhance language model responses

neo4jragllmaigraphspython
RAG architecture diagram with Neo4j
RAG architecture diagram with Neo4j

What is RAG and Why Combine it with Neo4j?

RAG (Retrieval-Augmented Generation) is a technique that enhances large language model (LLM) responses by providing them with relevant context retrieved from an external knowledge base. Instead of relying solely on knowledge contained in model parameters, RAG searches for specific information before generating a response.

Neo4j, as a graph database, is ideal for RAG because:

  • ✅ Relational context: Captures complex relationships between entities
  • ✅ Efficient navigation: Allows rapid traversal of relationships
  • ✅ Structured knowledge: Organizes information semantically
  • ✅ Contextual search: Finds relevant information considering connections

RAG Architecture with Neo4j

User → Question
    ↓
1. Question embedding
    ↓
2. Search in Neo4j (Vector Search)
    ↓
3. Retrieval of relational context
    ↓
4. Prompt construction with context
    ↓
5. LLM generates enriched response
    ↓
Contextualized response → User

Prerequisites

# Install required dependencies
pip install neo4j langchain openai tiktoken

You’ll need:

  • Neo4j 5.11+ (with Vector Index support)
  • OpenAI API key (or any compatible LLM)
  • Python 3.9+

Step 1: Configure Neo4j with Vectors

First, create a vector index in Neo4j to store embeddings:

// Create vector index for documents
CREATE VECTOR INDEX document_embeddings IF NOT EXISTS
FOR (d:Document)
ON d.embedding
OPTIONS {
  indexConfig: {
    `vector.dimensions`: 1536,
    `vector.similarity_function`: 'cosine'
  }
}

Step 2: Load Knowledge into the Graph

from neo4j import GraphDatabase
import openai

class KnowledgeGraphRAG:
    def __init__(self, uri, user, password, openai_key):
        self.driver = GraphDatabase.driver(uri, auth=(user, password))
        openai.api_key = openai_key

    def close(self):
        self.driver.close()

    def create_embedding(self, text):
        """Generate embedding using OpenAI"""
        response = openai.embeddings.create(
            model="text-embedding-3-small",
            input=text
        )
        return response.data[0].embedding

    def add_document(self, title, content, metadata=None):
        """Add document to graph with its embedding"""
        embedding = self.create_embedding(content)

        with self.driver.session() as session:
            session.run("""
                CREATE (d:Document {
                    title: $title,
                    content: $content,
                    embedding: $embedding,
                    metadata: $metadata,
                    created_at: datetime()
                })
            """, title=title, content=content,
                embedding=embedding, metadata=metadata)

    def add_relationship(self, doc1_title, doc2_title, rel_type):
        """Create relationship between documents"""
        with self.driver.session() as session:
            session.run("""
                MATCH (d1:Document {title: $doc1})
                MATCH (d2:Document {title: $doc2})
                CREATE (d1)-[r:%s]->(d2)
            """ % rel_type, doc1=doc1_title, doc2=doc2_title)
def retrieve_context(self, query, top_k=3):
    """Retrieve relevant context using vector search"""
    query_embedding = self.create_embedding(query)

    with self.driver.session() as session:
        result = session.run("""
            CALL db.index.vector.queryNodes(
                'document_embeddings',
                $top_k,
                $query_embedding
            ) YIELD node, score

            // Also get related documents
            OPTIONAL MATCH (node)-[r:RELATES_TO|REFERENCES]-(related:Document)

            RETURN
                node.title as title,
                node.content as content,
                score,
                collect(DISTINCT {
                    title: related.title,
                    type: type(r)
                }) as related_docs
            ORDER BY score DESC
        """, query_embedding=query_embedding, top_k=top_k)

        return [dict(record) for record in result]

Step 4: Generate Response with LLM

def generate_response(self, query, context_docs):
    """Generate response using retrieved context"""
    # Build context from Neo4j
    context_text = "\n\n".join([
        f"Document: {doc['title']}\n{doc['content']}\n"
        f"Related documents: {', '.join([r['title'] for r in doc['related_docs']])}"
        for doc in context_docs
    ])

    # Create augmented prompt
    prompt = f"""Based on the following context, answer the user's question.

Context:
{context_text}

Question: {query}

Detailed answer:"""

    # Call OpenAI
    response = openai.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "You are an expert assistant that answers based on the provided context."},
            {"role": "user", "content": prompt}
        ],
        temperature=0.7,
        max_tokens=500
    )

    return response.choices[0].message.content

def ask(self, query):
    """Main RAG method"""
    # 1. Retrieve context
    context_docs = self.retrieve_context(query, top_k=3)

    # 2. Generate response
    answer = self.generate_response(query, context_docs)

    # 3. Return with sources
    return {
        "answer": answer,
        "sources": [doc['title'] for doc in context_docs],
        "relevance_scores": [doc['score'] for doc in context_docs]
    }

Step 5: Complete System Usage

# Initialize
rag = KnowledgeGraphRAG(
    uri="bolt://localhost:7687",
    user="neo4j",
    password="password",
    openai_key="sk-..."
)

# Add knowledge to the graph
rag.add_document(
    title="Introduction to Neo4j",
    content="Neo4j is a native graph database that stores data in nodes and relationships...",
    metadata={"category": "database", "level": "beginner"}
)

rag.add_document(
    title="Cypher Query Language",
    content="Cypher is Neo4j's declarative query language, designed to work with graphs...",
    metadata={"category": "query-language", "level": "intermediate"}
)

# Create relationships between documents
rag.add_relationship(
    "Introduction to Neo4j",
    "Cypher Query Language",
    "PREREQUISITE"
)

# Ask questions
result = rag.ask("How can I query data in Neo4j?")

print("Answer:", result['answer'])
print("\nSources:", result['sources'])
print("Relevance:", result['relevance_scores'])

# Close connection
rag.close()

Example Output

Answer: To query data in Neo4j, you use Cypher Query Language,
the declarative language designed specifically for working with graphs.
Cypher allows you to MATCH patterns, WHERE to filter,
and RETURN to specify what data you want to retrieve. For example:

MATCH (n:Person)-[:KNOWS]->(friend)
WHERE n.name = 'Alice'
RETURN friend.name

This query finds all of Alice's friends in the graph.

Sources: ['Cypher Query Language', 'Introduction to Neo4j']
Relevance: [0.89, 0.76]
FeatureSimple VectorsNeo4j RAG
Relational context❌ No✅ Yes
Knowledge navigation❌ Limited✅ Complete
Explainability⚠️ Low✅ High
Updates⚠️ Complex✅ Simple
Multi-hop reasoning❌ No✅ Yes

Advanced Use Cases

1. Multi-hop RAG (Multi-step Reasoning)

def multi_hop_retrieve(self, query, depth=2):
    """Retrieve context by navigating the graph in depth"""
    query_embedding = self.create_embedding(query)

    with self.driver.session() as session:
        result = session.run("""
            CALL db.index.vector.queryNodes(
                'document_embeddings',
                3,
                $query_embedding
            ) YIELD node, score

            // Navigate up to 2 levels deep
            CALL apoc.path.subgraphAll(node, {
                maxLevel: $depth,
                relationshipFilter: 'RELATES_TO|REFERENCES|PREREQUISITE'
            })
            YIELD nodes, relationships

            RETURN node.title as main_doc,
                   [n IN nodes | n.title] as related_docs,
                   [r IN relationships | type(r)] as relationship_types,
                   score
        """, query_embedding=query_embedding, depth=depth)

        return [dict(record) for record in result]

2. RAG with Temporal Filtering

def retrieve_recent_context(self, query, days=30):
    """Retrieve only recent context"""
    query_embedding = self.create_embedding(query)

    with self.driver.session() as session:
        result = session.run("""
            CALL db.index.vector.queryNodes(
                'document_embeddings',
                5,
                $query_embedding
            ) YIELD node, score

            WHERE node.created_at > datetime() - duration({days: $days})

            RETURN node.title, node.content, score
            ORDER BY score DESC
        """, query_embedding=query_embedding, days=days)

        return [dict(record) for record in result]

3. RAG with User Personalization

def personalized_retrieve(self, query, user_id):
    """Retrieve personalized context based on user preferences"""
    query_embedding = self.create_embedding(query)

    with self.driver.session() as session:
        result = session.run("""
            // Find relevant documents
            CALL db.index.vector.queryNodes(
                'document_embeddings',
                10,
                $query_embedding
            ) YIELD node, score

            // Boost documents related to user
            OPTIONAL MATCH (u:User {id: $user_id})-[:INTERESTED_IN]->(t:Topic)
            OPTIONAL MATCH (node)-[:ABOUT]->(t)

            WITH node, score,
                 CASE WHEN t IS NOT NULL THEN 1.5 ELSE 1.0 END as user_boost

            RETURN node.title, node.content,
                   (score * user_boost) as adjusted_score
            ORDER BY adjusted_score DESC
            LIMIT 5
        """, query_embedding=query_embedding, user_id=user_id)

        return [dict(record) for record in result]

Optimization and Best Practices

1. Intelligent Chunking

def smart_chunk_document(self, long_text, chunk_size=500, overlap=50):
    """Split long documents into chunks with overlap"""
    words = long_text.split()
    chunks = []

    for i in range(0, len(words), chunk_size - overlap):
        chunk = ' '.join(words[i:i + chunk_size])
        chunks.append(chunk)

    return chunks

def add_long_document(self, title, content):
    """Add long document in related chunks"""
    chunks = self.smart_chunk_document(content)

    for i, chunk in enumerate(chunks):
        chunk_title = f"{title} - Part {i+1}"
        self.add_document(chunk_title, chunk)

        # Relate consecutive chunks
        if i > 0:
            prev_chunk = f"{title} - Part {i}"
            self.add_relationship(prev_chunk, chunk_title, "NEXT_CHUNK")

2. Embedding Cache

from functools import lru_cache

@lru_cache(maxsize=1000)
def create_embedding_cached(self, text):
    """Cached version of create_embedding"""
    return self.create_embedding(text)

3. Relevance Monitoring

def ask_with_confidence(self, query, min_score=0.7):
    """RAG with confidence threshold"""
    context_docs = self.retrieve_context(query, top_k=3)

    # Check minimum relevance
    if not context_docs or context_docs[0]['score'] < min_score:
        return {
            "answer": "I didn't find sufficiently relevant information to answer with confidence.",
            "confidence": "low",
            "sources": []
        }

    answer = self.generate_response(query, context_docs)

    return {
        "answer": answer,
        "confidence": "high",
        "sources": [doc['title'] for doc in context_docs],
        "avg_score": sum(d['score'] for d in context_docs) / len(context_docs)
    }

Conclusion

RAG with Neo4j combines the best of both worlds: the generation capability of LLMs and structured knowledge in graphs. This architecture enables:

  • 🎯 More accurate answers based on specific knowledge
  • 🔍 Complete traceability of sources and reasoning
  • 🔄 Dynamic updates to knowledge without retraining models
  • 🌐 Relational context that simple vectors cannot capture
  • 📊 Explainability of why certain responses were generated

The future of conversational AI lies in combining language models with structured knowledge bases, and Neo4j is the perfect platform for building these systems.

Additional Resources

Share
Next step

Ready to Start Your Project?

Let's discuss how technology can transform your organization. We offer technical consultations and custom solutions tailored to institutional requirements. Schedule a complimentary strategic session.

Get in touch
Free consultationNo commitmentFast response