Home Blog Resume Contact Ask AI About Me
Home/Blog/How to Build a GraphRAG Pipeline With Neo4j a…
How toLLM EngineeringGraphRAGNeo4jRAG

How to Build a GraphRAG Pipeline With Neo4j and Python

9 min readBy Miloš Mitrović

Flat chunk retrieval answers questions whose evidence sits in one paragraph. It breaks on questions whose evidence is scattered over ten documents, because cosine similarity has no way to know that two chunks describe the same customer. LinkedIn's support team solved that by building a knowledge graph over historical issues and, after roughly six months in production, reported a 28.6% drop in median per-issue resolution time in their SIGIR 2024 paper.

The whole build in one line: run Neo4j with APOC, install neo4j-graphrag, let SimpleKGPipeline extract a schema-constrained graph from your documents, index the chunk embeddings, then retrieve with a VectorCypherRetriever that hops from each matched chunk into the entities around it.

Key takeaways

  • Short answer: build the graph with SimpleKGPipeline, create a vector index on Chunk.embedding, then query through a VectorCypherRetriever wrapped in the GraphRAG class.
  • neo4j-graphrag 1.18.0 is Neo4j's first-party package. It needs Python 3.10 to 3.14 and Neo4j 5.18.1 or newer, plus the APOC core library for graph construction.
  • The pipeline writes the graph but does not create the vector index. You create it yourself, and its dimensions must match your embedding model exactly.
  • Passing node_types, relationship_types and patterns is the biggest quality lever you have. With no schema, the library asks an LLM to invent one from your text and label drift follows.
  • VectorRetriever alone gives you ordinary vector RAG that happens to live in a graph database. The Cypher traversal is what makes it GraphRAG.
  • Entity resolution runs by default after every pipeline run, merging nodes that share a label and a name.

What Do You Need Before You Start?

Five things, and the version numbers matter more than usual here.

  • A Neo4j instance on 5.18.1 or later (Aura 5.18 or later). Neo4j 2026.01 and above also enable in-index filtering through the Cypher SEARCH clause, which the library detects and uses automatically.
  • The APOC core library installed in that instance. Graph construction calls APOC procedures and fails without it.
  • Python 3.10 to 3.14 in a virtual environment.
  • An LLM for extraction and an embedding model. OpenAI works out of the box; Ollama, Anthropic, VertexAI, Mistral, Cohere and Bedrock each have a provider class. If you want the embeddings to stay on your own hardware, you can serve the embedding model locally with Hugging Face TEI and point an OpenAI-compatible client at it.
  • Twenty to fifty documents for the first pass. Extraction costs one LLM call per chunk, so a 10,000-PDF corpus is a budget decision, not a warm-up.

How Do You Build a GraphRAG Pipeline With Neo4j and Python?

Work through these eight steps in order. Steps 1 through 8 are the complete task; everything after them is depth.

  1. Start Neo4j with APOC enabled. The NEO4J_PLUGINS variable downloads and installs the plugin at container start.
    docker run --name neo4j-graphrag \
      -p 7474:7474 -p 7687:7687 \
      -e NEO4J_AUTH=neo4j/please-change-me \
      -e NEO4J_PLUGINS='["apoc"]' \
      -v $HOME/neo4j/data:/data \
      neo4j:2026.06

    Swap the tag for neo4j:5.26 if you need the 5.x LTS line. Open http://localhost:7474 and confirm you can log in before going further.

  2. Install the library with the extras you actually use. The base package ships no provider SDKs.
    python3 -m venv .venv && source .venv/bin/activate
    pip install "neo4j-graphrag[openai,experimental]"

    Add ollama, anthropic, google-genai or sentence-transformers as needed. The experimental extra covers the knowledge-graph construction pipeline.

  3. Declare the graph schema you want, not the one an LLM guesses. Node and relationship types accept a plain label string or a dict with a description and typed properties. Patterns are source, relationship, target triples.
    NODE_TYPES = [
        {"label": "Customer", "description": "A company or person that bought a product"},
        {"label": "Product", "properties": [
            {"name": "name", "type": "STRING"},
            {"name": "sku", "type": "STRING"},
        ]},
        "Issue",
        "Engineer",
    ]
    
    RELATIONSHIP_TYPES = [
        "REPORTED",
        {"label": "AFFECTS", "description": "The product an issue occurs in"},
        {"label": "RESOLVED_BY", "properties": [{"name": "resolvedOn", "type": "STRING"}]},
    ]
    
    PATTERNS = [
        ("Customer", "REPORTED", "Issue"),
        ("Issue", "AFFECTS", "Product"),
        ("Issue", "RESOLVED_BY", "Engineer"),
    ]

    A description on a type is not decoration. It goes into the extraction prompt and it is the cheapest way to stop the model from filing every organization as a Customer.

  4. Run the builder over your documents. The pipeline loads the file, splits it, embeds the chunks, extracts entities and relations, writes them, then resolves duplicates.
    import asyncio
    import neo4j
    from neo4j_graphrag.embeddings import OpenAIEmbeddings
    from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline
    from neo4j_graphrag.llm import OpenAILLM
    
    URI = "neo4j://localhost:7687"
    AUTH = ("neo4j", "please-change-me")
    
    async def build(paths):
        driver = neo4j.GraphDatabase.driver(URI, auth=AUTH)
        async with OpenAILLM(model_name="gpt-5", model_params={"temperature": 0}) as llm:
            kg = SimpleKGPipeline(
                llm=llm,
                driver=driver,
                embedder=OpenAIEmbeddings(model="text-embedding-3-small"),
                schema={
                    "node_types": NODE_TYPES,
                    "relationship_types": RELATIONSHIP_TYPES,
                    "patterns": PATTERNS,
                    "additional_node_types": False,
                },
                from_file=True,
                on_error="RAISE",
                perform_entity_resolution=True,
                neo4j_database="neo4j",
            )
            for path in paths:
                res = await kg.run_async(
                    file_path=path,
                    document_metadata={"source": path},
                )
                print(path, res.run_id)
        driver.close()
    
    asyncio.run(build(["docs/issue-4412.pdf", "docs/issue-4418.pdf"]))

    Set from_file=False and call run_async(text="your text") when the text is already extracted. Keep on_error="RAISE" while you are developing so a bad extraction stops the run instead of vanishing.

  5. Create the vector index over the chunk embeddings. The builder writes Chunk nodes with an embedding property, but it never creates the index. Do this after the first ingest run.
    from neo4j_graphrag.indexes import create_vector_index
    
    create_vector_index(
        driver,
        "chunk-embeddings",
        label="Chunk",
        embedding_property="embedding",
        dimensions=1536,          # text-embedding-3-small; 3072 for 3-large
        similarity_fn="cosine",
    )

    On Neo4j 2026.01 and later you can add filterable_properties=["source"] so metadata filters run inside the index instead of a brute-force scan afterwards.

  6. Verify the graph before you query it. Run these four statements in Neo4j Browser. If any of them returns zero, stop and fix it; a retriever over an empty graph fails silently with a confident answer.
    MATCH (c:Chunk) RETURN count(c) AS chunks;
    MATCH (e:__Entity__) RETURN labels(e) AS labels, count(*) AS n ORDER BY n DESC;
    MATCH ()-[r]->() RETURN type(r) AS rel, count(*) AS n ORDER BY n DESC;
    SHOW VECTOR INDEXES;

    Extracted entities carry their own label plus __Entity__. Document and Chunk nodes do not, which is what lets you separate the lexical graph from the domain graph in a single query.

  7. Build a retriever that traverses the graph. Inside a retrieval query, node is the node the vector index matched and score is its similarity. Everything after that is your Cypher.
    from neo4j_graphrag.retrievers import VectorCypherRetriever
    
    RETRIEVAL_QUERY = """
    WITH node AS chunk, score
    OPTIONAL MATCH (chunk)<-[:FROM_CHUNK]-(e:__Entity__)
    OPTIONAL MATCH (e)-[r]-(neighbor:__Entity__)
    RETURN chunk.text AS chunkText,
           score AS similarityScore,
           collect(DISTINCT e.name) AS entities,
           collect(DISTINCT type(r) + ' -> ' + coalesce(neighbor.name, '')) AS facts
    """
    
    retriever = VectorCypherRetriever(
        driver=driver,
        index_name="chunk-embeddings",
        retrieval_query=RETRIEVAL_QUERY,
        embedder=OpenAIEmbeddings(model="text-embedding-3-small"),
        neo4j_database="neo4j",
    )

    Return properties, not whole nodes. The retriever serializes whatever you return straight into the prompt, and a full node dump wastes tokens on internal identifiers.

  8. Generate the answer. The GraphRAG class joins retriever and LLM and handles the prompt assembly.
    from neo4j_graphrag.generation import GraphRAG
    from neo4j_graphrag.llm import OpenAILLM
    
    rag = GraphRAG(
        retriever=retriever,
        llm=OpenAILLM(model_name="gpt-5", model_params={"temperature": 0}),
    )
    
    answer = rag.search(
        query_text="Which products caused repeat issues for enterprise customers?",
        retriever_config={"top_k": 8},
        return_context=True,
        response_fallback="I have no relevant context in the graph for that.",
    )
    
    print(answer.answer)
    for item in answer.retriever_result.items[:3]:
        print(item.content)

    Keep return_context=True on during development. It is the only fast way to tell a retrieval problem apart from a generation problem.

Which Retriever Fits Which Question?

Four of the built-in retrievers cover almost every case, and they differ in what they cost per query as much as in what they can answer.

RetrieverHow it retrievesQuestion shape it suitsCost per query
VectorRetrieverVector index onlyAnswer lives in one passageOne embedding call
VectorCypherRetrieverVector index, then your Cypher traversalAnswer needs the entities and relations around the passageOne embedding call plus one graph read
HybridCypherRetrieverVector plus full-text index, then traversalQueries carrying exact identifiers, error codes or SKUsTwo index reads plus a graph read
Text2CypherRetrieverAn LLM writes and runs CypherAggregations and counts over structured propertiesTwo LLM calls, plus schema risk

Start with VectorCypherRetriever. Reach for Text2CypherRetriever only when the question is genuinely arithmetic ("how many issues per product last quarter"), because a generated query that returns nothing looks identical to a question with no answer.

The hybrid option needs a full-text index in the database as well as the vector index, and it earns its keep for the same reason combining dense and sparse vectors improves recall: exact tokens beat semantic similarity on identifiers.

How Do You Keep Extraction Cost and Graph Noise Down?

Three knobs move the needle, in this order.

Chunk size. The default splitter is configurable, and bigger chunks mean fewer extraction calls but coarser entity attribution.

from neo4j_graphrag.components.text_splitters.fixed_size_splitter import FixedSizeSplitter

kg = SimpleKGPipeline(
    # ...
    text_splitter=FixedSizeSplitter(chunk_size=2000, chunk_overlap=200),
)

The tradeoff mirrors the one you weigh when choosing between fixed, recursive and semantic chunking, with one extra wrinkle: here the chunk is also the unit an LLM reads to name entities, so a chunk that splits a sentence in half costs you a relation.

Schema strictness. Setting "additional_node_types": False tells the graph pruner to drop anything outside your declared types. Leaving the schema out entirely puts the library in EXTRACTED mode, where it derives a schema from your text once and reuses it, which is fine for exploration and poor for a graph you plan to query the same way every day.

Entity resolution. The default resolver merges nodes that share a label and an exact name. That leaves "Acme" and "Acme Corp" as two customers. Two better resolvers ship with the package: FuzzyMatchResolver (install the fuzzy-matching extra) and SpaCySemanticMatchResolver (install the nlp extra, on Python 3.13 or earlier).

Troubleshooting: The Errors You Will Actually Hit

  • "There is no procedure with the name apoc..." APOC is missing. Restart the container with NEO4J_PLUGINS='["apoc"]', or drop the plugin JAR into the plugins directory of a bare-metal install.
  • The retriever returns nothing, or index creation errors on dimensions. The index dimensions must equal your embedder's output width. text-embedding-3-small is 1536, text-embedding-3-large is 3072. Drop the index and recreate it rather than trying to patch it.
  • entities comes back empty from your traversal. Check the direction and the names. Extracted entities point at chunks, so the pattern is (chunk)<-[:FROM_CHUNK]-(e). Current defaults are FROM_CHUNK, FROM_DOCUMENT, NEXT_CHUNK and the embedding property; some documentation pages still show older names, so trust MATCH (n) RETURN DISTINCT labels(n) over any doc page.
  • Chunks silently produce no entities. The extractor defaults to swallowing parse failures. Set on_error="RAISE" and you will see the malformed model output instead of a thin graph.
  • DeprecationWarning on every import. Components moved out of the experimental namespace ahead of the 2.0 release. Import from neo4j_graphrag.components now; the old paths disappear in 2.0.
  • An Anthropic model suddenly raises during extraction. AnthropicLLM now declares structured-output support, so the extractor enables it automatically and requires a Claude 4.5 or newer model. Either upgrade the model or construct LLMEntityRelationExtractor with use_structured_output=False.

What Should You Do Next?

  1. Measure before you tune. Build a question set from real user queries and score retrieval and answer quality, which is exactly what a Ragas evaluation harness for a RAG pipeline is for. Without it, "the graph helped" stays an opinion.
  2. Improve what the chunk itself says before you widen the traversal. Adding a short document-level summary to each chunk ahead of embedding, the technique behind contextual retrieval in a RAG pipeline, usually beats another OPTIONAL MATCH.
  3. Read the two user guides end to end: the Knowledge Graph Builder guide for pipeline internals and the RAG guide for retrievers, prompts and rate-limit handling.
  4. Clone the examples folder in the repository. It carries runnable scripts for every retriever, including ones that query the public Neo4j movie demo database with no setup.

Where These Commands and Defaults Come From

M
Miloš Mitrović
Revenue Operations & AI Automation

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Ask AI About Me
Clicking an assistant copies the prompt and opens it: ready to run in ChatGPT, Perplexity, and Grok; in Claude, Gemini, or Copilot press Ctrl+V (Cmd+V on Mac) to paste. Use Copy prompt for any other AI. The assistant reads my site, so it needs web access.
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.