+1 (415) 649-9454

Your First GraphRAG Pipeline with neo4j-graphrag

Vector-only retrieval answers "which paragraphs look like this question?" A knowledge graph answers "what is connected to what?" GraphRAG uses both: embed your documents for semantic recall, extract the entities and relationships they describe, and let the retriever walk the graph when the question needs more than one hop. neo4j-graphrag is Neo4j's official Python package for exactly this, and in this tutorial you will build a complete pipeline with it — PDFs in, grounded answers out — in under a hundred lines.

Prerequisites

  • A Neo4j 2025.06+ instance (the Docker setup from our earlier tutorial is ideal; Aura Free also works)
  • Python 3.10+
  • An OpenAI API key — the package supports other LLM and embedding providers, but OpenAI keeps the example short
pip install "neo4j-graphrag[openai]" "neo4j>=6"
export OPENAI_API_KEY=sk-...
export NEO4J_URI=bolt://localhost:7687
export NEO4J_USER=neo4j
export NEO4J_PASSWORD=change-me-please

The package is neo4j-graphrag on PyPI with its own reference docs. It replaced the earlier neo4j-genai package; if you have that one installed, uninstall it first.

Step 1 — build the knowledge graph from PDFs

SimpleKGPipeline wraps the whole construction chain: PDF loading, text splitting, embedding, LLM-driven entity and relationship extraction, and writing everything to Neo4j. It produces two layers in the graph: a lexical layer (DocumentChunk nodes carrying the text and its embedding) and an entity layer (the domain nodes the LLM extracted, linked back to the chunks they came from).

import asyncio, os
import neo4j
from neo4j_graphrag.embeddings import OpenAIEmbeddings
from neo4j_graphrag.llm import OpenAILLM
from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline

driver = neo4j.GraphDatabase.driver(
    os.environ["NEO4J_URI"],
    auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]),
)

llm = OpenAILLM(
    model_name="gpt-4o-mini",
    model_params={"temperature": 0, "response_format": {"type": "json_object"}},
)
embedder = OpenAIEmbeddings(model="text-embedding-3-small")  # 1536 dims

# Constrain extraction to a schema. Unconstrained extraction produces a
# graph nobody can query; a schema produces one you can.
entities = ["Company", "Person", "Product", "Regulation"]
relations = ["EMPLOYS", "SELLS", "SUBJECT_TO", "PARTNERS_WITH"]
potential_schema = [
    ("Company", "EMPLOYS", "Person"),
    ("Company", "SELLS", "Product"),
    ("Company", "SUBJECT_TO", "Regulation"),
    ("Company", "PARTNERS_WITH", "Company"),
]

kg_builder = SimpleKGPipeline(
    llm=llm,
    driver=driver,
    embedder=embedder,
    entities=entities,
    relations=relations,
    potential_schema=potential_schema,
    from_pdf=True,
)

async def build(paths):
    for path in paths:
        result = await kg_builder.run_async(file_path=path)
        print(path, result.result)

asyncio.run(build(["docs/annual-report-2025.pdf", "docs/supplier-terms.pdf"]))

Run it and then look at what you got:

MATCH (c:Chunk)-[:FROM_DOCUMENT]->(d:Document)   // lexical layer
RETURN d.path, count(c) AS chunks;

MATCH (e:__Entity__)-[:FROM_CHUNK]->(c:Chunk)     // entity layer
RETURN labels(e) AS labels, count(*) AS mentions
ORDER BY mentions DESC;

Two practical notes. First, extraction cost scales with chunk count times prompt size; on large corpora run the pipeline per document and checkpoint. Second, entity resolution ("Acme Corp" vs "ACME Corporation") is handled by a resolver component that, by default, merges on exact label-plus-name; for messy data swap in the fuzzy resolver the package provides, or write your own.

Step 2 — create the indexes the retrievers need

from neo4j_graphrag.indexes import create_vector_index, create_fulltext_index

create_vector_index(
    driver, name="chunk_embeddings", label="Chunk",
    embedding_property="embedding", dimensions=1536, similarity_fn="cosine",
)
create_fulltext_index(
    driver, name="chunk_text", label="Chunk", node_properties=["text"],
)

The equivalent Cypher, if you prefer to manage indexes in migrations:

CREATE VECTOR INDEX chunk_embeddings IF NOT EXISTS
FOR (c:Chunk) ON c.embedding
OPTIONS {indexConfig: {`vector.dimensions`: 1536, `vector.similarity_function`: 'cosine'}};

CREATE FULLTEXT INDEX chunk_text IF NOT EXISTS
FOR (c:Chunk) ON EACH [c.text];

Step 3 — retrievers: vector, hybrid, and graph-aware

neo4j-graphrag ships several retrievers with one interface. Start with the simplest and upgrade as your evaluation set demands.

VectorRetriever — pure semantic similarity over chunks:

from neo4j_graphrag.retrievers import VectorRetriever

vector_retriever = VectorRetriever(
    driver, index_name="chunk_embeddings", embedder=embedder,
    return_properties=["text"],
)

HybridRetriever — vector plus fulltext, merged by score. This is the one we deploy most often, because it catches exact identifiers (part numbers, clause references, people's names) that embeddings blur:

from neo4j_graphrag.retrievers import HybridRetriever

hybrid_retriever = HybridRetriever(
    driver,
    vector_index_name="chunk_embeddings",
    fulltext_index_name="chunk_text",
    embedder=embedder,
    return_properties=["text"],
)

VectorCypherRetriever — vector search to find the entry chunks, then a Cypher traversal to pull in the graph context around them. This is where GraphRAG earns its name:

from neo4j_graphrag.retrievers import VectorCypherRetriever

retrieval_query = """
// `node` is each chunk the vector index returned
MATCH (e:__Entity__)-[:FROM_CHUNK]->(node)
OPTIONAL MATCH (e)-[r]-(other:__Entity__)
WITH node, e, collect(DISTINCT type(r) + ' -> ' + coalesce(other.name, '')) AS rels
RETURN node.text AS text,
       collect(DISTINCT e.name + ' (' + rels[0..5] + ')') AS entities
"""

graph_retriever = VectorCypherRetriever(
    driver, index_name="chunk_embeddings", embedder=embedder,
    retrieval_query=retrieval_query,
)

The retrieval query is ordinary Cypher; node and score are bound for you. Anything you can express as a traversal — "also fetch the regulations this company is subject to", "include the parent company's products" — becomes context the LLM sees, and that is what closes the multi-hop gap.

Step 4 — generate grounded answers

from neo4j_graphrag.generation import GraphRAG

rag = GraphRAG(retriever=graph_retriever, llm=llm)

response = rag.search(
    query_text="Which of our suppliers are subject to the EU AI Act, and what do they sell us?",
    retriever_config={"top_k": 5},
    return_context=True,
)
print(response.answer)
for item in response.retriever_result.items:
    print("-", item.content[:120])

return_context=True is not optional in our engagements: you cannot evaluate a RAG system you cannot inspect. Log the retrieved items next to every answer.

Step 5 — wire it into a LangChain agent

Most teams already have an agent framework. The retrievers convert directly:

from langchain_core.tools import tool

@tool
def search_knowledge_graph(question: str) -> str:
    """Search internal documents and the entities they describe."""
    result = graph_retriever.search(query_text=question, top_k=5)
    return "\n\n".join(item.content for item in result.items)

Register search_knowledge_graph as a tool on your LangChain (or LangGraph) agent and the model decides when to consult the graph. If your agents speak MCP instead, Neo4j's official MCP server exposes schema inspection and read queries over the same database, and Microsoft's Agent Framework ships a Neo4j GraphRAG context provider that plugs into this same graph.

Evaluating before you ship

Build a set of 30–50 questions with known answers, at least a third of which need two or more hops ("who supplies the product that Company X's subsidiary sells?"). Run each retriever against the set, score answers with an LLM judge plus a human spot check, and keep the retriever that wins on your questions. In our benchmark write-up, GraphRAG vs. Plain RAG, the graph-aware retriever's advantage was almost entirely on multi-hop questions — if your users only ask single-hop questions, the simpler retriever may be the right answer, and it is cheaper.

Going to production

The pieces above are a working prototype. Production adds: a re-runnable ingestion job with deduplication, an entity-resolution pass tuned to your data, the native Vector type (2025.10+, Cypher 25 with v6 drivers) for compact storage, access control on the graph so retrieval respects document permissions, and an evaluation harness in CI. That hardening is most of what our GraphRAG consulting engagements consist of. If you want a second pair of senior eyes on your pipeline, contact us.