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 onChunk.embedding, then query through aVectorCypherRetrieverwrapped in theGraphRAGclass. neo4j-graphrag1.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
dimensionsmust match your embedding model exactly. - Passing
node_types,relationship_typesandpatternsis the biggest quality lever you have. With no schema, the library asks an LLM to invent one from your text and label drift follows. VectorRetrieveralone 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
SEARCHclause, 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.
- Start Neo4j with APOC enabled. The
NEO4J_PLUGINSvariable 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.06Swap the tag for
neo4j:5.26if you need the 5.x LTS line. Openhttp://localhost:7474and confirm you can log in before going further. - 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-genaiorsentence-transformersas needed. Theexperimentalextra covers the knowledge-graph construction pipeline. - 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. - 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=Falseand callrun_async(text="your text")when the text is already extracted. Keepon_error="RAISE"while you are developing so a bad extraction stops the run instead of vanishing. - Create the vector index over the chunk embeddings. The builder writes
Chunknodes with anembeddingproperty, 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. - 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__.DocumentandChunknodes do not, which is what lets you separate the lexical graph from the domain graph in a single query. - Build a retriever that traverses the graph. Inside a retrieval query,
nodeis the node the vector index matched andscoreis 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.
- Generate the answer. The
GraphRAGclass 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=Trueon 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.
| Retriever | How it retrieves | Question shape it suits | Cost per query |
|---|---|---|---|
VectorRetriever | Vector index only | Answer lives in one passage | One embedding call |
VectorCypherRetriever | Vector index, then your Cypher traversal | Answer needs the entities and relations around the passage | One embedding call plus one graph read |
HybridCypherRetriever | Vector plus full-text index, then traversal | Queries carrying exact identifiers, error codes or SKUs | Two index reads plus a graph read |
Text2CypherRetriever | An LLM writes and runs Cypher | Aggregations and counts over structured properties | Two 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 thepluginsdirectory of a bare-metal install. - The retriever returns nothing, or index creation errors on dimensions. The index
dimensionsmust equal your embedder's output width.text-embedding-3-smallis 1536,text-embedding-3-largeis 3072. Drop the index and recreate it rather than trying to patch it. entitiescomes 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 areFROM_CHUNK,FROM_DOCUMENT,NEXT_CHUNKand theembeddingproperty; some documentation pages still show older names, so trustMATCH (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
experimentalnamespace ahead of the 2.0 release. Import fromneo4j_graphrag.componentsnow; the old paths disappear in 2.0. - An Anthropic model suddenly raises during extraction.
AnthropicLLMnow declares structured-output support, so the extractor enables it automatically and requires a Claude 4.5 or newer model. Either upgrade the model or constructLLMEntityRelationExtractorwithuse_structured_output=False.
What Should You Do Next?
- 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.
- 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. - 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.
- 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
- Neo4j: User Guide, Knowledge Graph Builder
- Neo4j: User Guide, RAG
- neo4j/neo4j-graphrag-python on GitHub
- neo4j-graphrag on PyPI for supported Python versions and extras
- Neo4j Cypher Manual: vector indexes
- Neo4j Operations Manual: Docker
- Xu et al., Retrieval-Augmented Generation With Knowledge Graphs for Customer Service Question Answering (SIGIR 2024)