GraphRAG & Knowledge Graph Construction
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-09-30 against Neo4j 2026.09.0, neo4j-graphrag 1.21.0 with the neo4j Python driver
6.3.1, langchain-neo4j 0.10.0 and Microsoft graphrag 3.2.0 on Python 3.12, Spring AI 2.0.1
(spring-ai-starter-vector-store-neo4j) and LangChain4j 1.20.2 with langchain4j-community-neo4j and
langchain4j-community-llm-graph-transformer 1.20.0-beta30 (see the
version baseline). The Python and Java code was checked against the
released packages (imports resolved, Java compiled) but not run against a Neo4j database or a model; the Cypher was not
executed.
Neo4j — Vector Search and GenAI covers the basics: vector indexes in
Neo4j, the SEARCH clause, and GraphRAG as vector retrieval followed by graph expansion. The Neo4j data model itself is
in Data Modeling and Cypher in
Cypher Fundamentals. This page is about building the graph from
DocsAssistant’s corpus with a language model, keeping it correct as the corpus changes, and deciding when it is worth
it. Querying it — vector-Cypher, hybrid and text-to-Cypher retrievers — is on the next page,
Text2Cypher & Graph Retrievers.
When a graph beats vector-only retrieval
Vector search finds passages similar to the question. Some questions are not about similarity:
| Question shape | Vector-only | KG-hybrid | Why |
|---|---|---|---|
"What does |
Good |
Same |
One passage answers it. |
"Which components depend on the sorting library?" |
Poor |
Good |
The answer is a set of relationships spread over many pages. |
"How are Kafka CDC and the RAG ingestion pipeline connected?" |
Partial |
Good |
Multi-hop: the link goes through intermediate concepts. |
"How many pages document OAuth flows?" |
Poor |
Good (Cypher count) |
Aggregation over entities, not text. |
"What are the main themes across all AI pages?" |
Poor |
Good (global search) |
Needs a summary of the whole corpus, not the top five chunks. |
The cost of the graph is construction (one or more model calls per chunk), maintenance (every change must update
entities and relationships) and schema design. Build it when a measurable share of real questions has the shapes in the
lower rows — which the router of Query Understanding &
Routing can tell you by logging graph routes — not because graphs are fashionable.
Microsoft GraphRAG: local and global search
Microsoft’s GraphRAG indexes a corpus by extracting entities and relationships from every chunk with a model, detecting communities of densely connected entities with the Leiden algorithm, hierarchically, and writing a model-generated community report for each community at each level. Two query modes use that index:
-
Local search starts from the entities that match the question, gathers their neighbourhood — related entities, relationships, the source chunks and the reports of their communities — and answers from that context. It suits questions about specific things.
-
Global search answers corpus-wide questions by map-reduce over community reports: each report at a chosen level produces a partial answer with a relevance score, and the best partial answers are reduced into the final one. It suits "what are the main themes" questions that no top- retrieval can answer.
The price is indexing. With chunks, extraction calls per chunk (GraphRAG may re-prompt for missed entities) and communities summarised across levels, the model calls for a full index are roughly
and every call reads a chunk-sized prompt plus instructions. For a corpus of 20 000 chunks that is tens of thousands of
calls before the first question, and a global query costs one call per community report consulted. Incremental
indexing reduces the rebuild cost, but community reports still need refreshing when their members change. The
graphrag package ships its own pipeline and storage (Parquet tables by default); DocsAssistant uses its ideas — communities and reports as an extra, global layer — on top of the Neo4j graph below, computing communities with GDS
Louvain or Leiden (see Community Detection Algorithms).
LLM entity and relationship extraction with a schema
Free-form extraction ("find all entities and relationships") produces an unusable graph: Kafka, Apache Kafka and
kafka broker as three nodes, and a zoo of relationship types. A schema constrains the model to the node labels,
relationship types and patterns that the questions need. DocsAssistant’s schema:
| Element | Values |
|---|---|
Node labels |
|
Relationship types |
|
Patterns |
|
neo4j-graphrag SimpleKGPipeline
SimpleKGPipeline runs the whole construction: split the text, embed the chunks, write the lexical graph (Document
and Chunk nodes with FROM_DOCUMENT and NEXT_CHUNK relationships and chunk embeddings), extract entities and
relationships per chunk under the schema, link them to their chunks, and resolve duplicate entities:
neo4j-graphrag 1.21.0)import asyncio
import os
import neo4j
from neo4j_graphrag.embeddings import OllamaEmbeddings
from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline
from neo4j_graphrag.llm import OllamaLLM
SCHEMA = {
"node_types": [
{"label": "Component", "description": "A documented library or documentation section"},
{"label": "Concept", "description": "A technical concept explained in the docs"},
{"label": "API", "description": "A class, function or HTTP endpoint"},
{"label": "Tool", "description": "A third-party product such as Kafka, Neo4j or pgvector"},
],
"relationship_types": [{"label": t} for t in ("DEPENDS_ON", "EXPLAINS", "IMPLEMENTS", "USES", "RELATED_TO")],
"patterns": [
("Component", "DEPENDS_ON", "Component"), ("Component", "EXPLAINS", "Concept"),
("API", "IMPLEMENTS", "Concept"), ("Component", "USES", "Tool"), ("Concept", "RELATED_TO", "Concept"),
],
}
async def build(pages: list[tuple[str, str, dict]]) -> None:
"""pages: (source id, text, metadata such as acl and component)."""
driver = neo4j.GraphDatabase.driver(os.environ["NEO4J_URI"],
auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]))
llm = OllamaLLM(model_name=os.environ["EXTRACTION_MODEL"],
model_params={"format": "json", "options": {"temperature": 0}})
pipeline = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=OllamaEmbeddings(model=os.environ["EMBEDDING_MODEL"]),
schema=SCHEMA,
from_file=False, # we pass text that was already parsed
perform_entity_resolution=True,
on_error="IGNORE", # a failed chunk is logged, not fatal
)
for source, text, metadata in pages:
await pipeline.run_async(text=text, document_metadata={"source": source, **metadata})
# the vector search filters Chunk nodes, not the Document: copy the ACL onto this document's chunks
driver.execute_query("MATCH (c:Chunk)-[:FROM_DOCUMENT]->(:Document {source: $source}) "
"SET c.acl = $acl, c.`metadata.acl` = $acl", source=source, acl=metadata["acl"])
driver.close()
if __name__ == "__main__":
asyncio.run(build([("vector-rag/ann-indexes", open("ann-indexes.txt").read(), {"acl": "public"})]))
The Neo4j credentials come from the environment (a secrets manager in production; see
Deployment Patterns). document_metadata is written onto the
Document node only, but vector searches filter the Chunk nodes, so the loop copies the ACL onto each chunk of the
document: as acl for Cypher and the filters argument of the neo4j-graphrag retrievers, and as metadata.acl, the
property Spring AI’s Neo4jVectorStore filters on for the metadata key acl. The graph retrievers must filter on it
exactly like the vector retrievers do.
The Neo4j LLM Graph Builder
The LLM Graph Builder is a Neo4j Labs application (a web UI with a backend built on LangChain) that does the same from uploaded files, web pages and videos, with a choice of models and an optional schema, and shows the result in the browser. It is the fastest way to prototype a schema on a sample of the corpus — look at what the model extracts, adjust the allowed labels, repeat — before writing the production pipeline. It is not an ingestion service for a changing corpus.
LangChain LLMGraphTransformer
langchain-neo4j ships LLMGraphTransformer, which turns LangChain Document objects into GraphDocument objects
(nodes and relationships) using a chat model’s structured output, and Neo4jGraph.add_graph_documents, which writes
them with MERGE:
langchain-neo4j 0.10.0)import os
from langchain.chat_models import init_chat_model
from langchain_core.documents import Document
from langchain_neo4j import LLMGraphTransformer, Neo4jGraph
graph = Neo4jGraph(url=os.environ["NEO4J_URI"], username=os.environ["NEO4J_USER"],
password=os.environ["NEO4J_PASSWORD"], refresh_schema=False)
transformer = LLMGraphTransformer(
llm=init_chat_model(os.environ["EXTRACTION_MODEL"], temperature=0),
allowed_nodes=["Component", "Concept", "API", "Tool"],
allowed_relationships=[("Component", "DEPENDS_ON", "Component"), ("Component", "EXPLAINS", "Concept"),
("API", "IMPLEMENTS", "Concept"), ("Component", "USES", "Tool"),
("Concept", "RELATED_TO", "Concept")],
strict_mode=True, # drop anything outside the schema
)
def add_chunks(chunks: list[Document]) -> None:
graph_docs = transformer.convert_to_graph_documents(chunks)
# include_source links every node to a Document node for its chunk ((:Document)-[:MENTIONS]->(node))
graph.add_graph_documents(graph_docs, include_source=True, baseEntityLabel=True)
Source: LangChain — Neo4j and LangChain — Neo4j provider.
baseEntityLabel=True adds an Entity label to every extracted node, which gives one index for entity resolution
and clean-up queries across labels.
LangChain4j LLMGraphTransformer
LangChain4j’s community modules provide the same transformer for Java and a KnowledgeGraphWriter that stores the
graph documents in Neo4j:
package com.example.docsassistant.l4j.graph;
import java.util.List;
import dev.langchain4j.community.data.document.graph.GraphDocument;
import dev.langchain4j.community.data.document.transformer.graph.LLMGraphTransformer;
import dev.langchain4j.community.rag.content.retriever.neo4j.KnowledgeGraphWriter;
import dev.langchain4j.community.rag.content.retriever.neo4j.Neo4jGraph;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.model.chat.ChatModel;
import org.neo4j.driver.Driver;
public class KnowledgeGraphIngestion {
private final LLMGraphTransformer transformer;
private final KnowledgeGraphWriter writer;
public KnowledgeGraphIngestion(ChatModel extractionModel, Driver driver) {
this.transformer = LLMGraphTransformer.builder()
.model(extractionModel)
.allowedNodes(List.of("Component", "Concept", "API", "Tool"))
.allowedRelationships(List.of("DEPENDS_ON", "EXPLAINS", "IMPLEMENTS", "USES", "RELATED_TO"))
.maxAttempts(2)
.build();
this.writer = KnowledgeGraphWriter.builder()
.graph(Neo4jGraph.builder().driver(driver).build())
.label("__Entity__")
.build();
}
public int ingest(List<Document> chunks) {
List<GraphDocument> graphDocuments = transformer.transformAll(chunks);
writer.addGraphDocuments(graphDocuments, true); // true: link entities to their source document
return graphDocuments.size();
}
}
Source: LangChain4j — Neo4j (community module) and
the langchain4j-community repository’s Neo4j and graph-transformer modules.
Spring AI has no graph extraction component; on Spring AI, run the Python pipeline, or call the LangChain4j transformer
from the same application, and query the graph with the Neo4j driver and Neo4jVectorStore as shown at the end of this
page.
Entity resolution
Extraction produces duplicates: Kafka, Apache Kafka, kafka; refresh token and refresh tokens. Resolution
merges nodes that denote the same thing, and it is where most graph quality is won or lost:
-
Normalise at extraction time: trim, case-fold for matching (keep a display name), singularise, strip version numbers into a property.
-
Exact match on the normalised name within a label.
neo4j-graphrag’s `SinglePropertyExactMatchResolver(the default whenperform_entity_resolution=True) merges entities with the same label andname. -
Fuzzy match for spelling variants:
FuzzyMatchResolvermerges nodes whose names are similar above a threshold.SpaCySemanticMatchResolveruses word vectors for near-synonyms. -
Curated aliases for the rest: a small table (
Apache Kafka→Kafka) applied before merging, maintained from the review of low-confidence merges.
Merge conservatively: a wrong merge (Spring AI into Spring) corrupts every answer that traverses it, while a missed
merge only costs some recall. Keep the merged names as an aliases property so that text-to-Cypher and full-text
search still find the node by any of them.
neo4j-graphrag 1.21.0)import asyncio
import os
import neo4j
from neo4j_graphrag.components.resolver import FuzzyMatchResolver
async def resolve() -> None:
driver = neo4j.GraphDatabase.driver(os.environ["NEO4J_URI"],
auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]))
resolver = FuzzyMatchResolver(driver, resolve_properties=["name"], similarity_threshold=0.92)
stats = await resolver.run()
print(stats) # number of nodes considered and merged
driver.close()
asyncio.run(resolve())
Source: neo4j-graphrag — Entity Resolver.
In neo4j-graphrag 1.21.0 the resolvers moved from neo4j_graphrag.experimental.components.resolver to
neo4j_graphrag.components.resolver; the old path still works with a deprecation warning and is scheduled for removal
in 2.0.
Incremental updates: CDC, MERGE and tombstoning
The graph must follow the corpus, exactly like the vector index
(Ingestion Pipelines & Freshness). Model the provenance so
that updates are local: every extracted entity and relationship is reachable from the chunks that mention it. The three
libraries on this page record that provenance differently, all with the Entity label on extracted nodes:
| Library | Provenance edge |
|---|---|
|
|
|
|
LangChain4j |
|
Pick one convention per graph. DocsAssistant uses the neo4j-graphrag one, and the queries below follow it. Then a
change event for a document touches only its chunks:
// 1. Upsert the document and tombstone its previous chunks (they may still be read by in-flight queries)
MERGE (d:Document {source: $source})
SET d.acl = $acl, d.sourceHash = $sourceHash, d.updatedAt = datetime()
WITH d
MATCH (c:Chunk)-[:FROM_DOCUMENT]->(d)
WHERE c.version < $version
SET c.deletedAt = datetime();
// 2. The pipeline writes the new chunks (version = $version) and MERGEs their entities and FROM_CHUNK edges.
// 3. Remove tombstoned chunks and entities that no live chunk mentions any more
MATCH (c:Chunk) WHERE c.deletedAt IS NOT NULL AND c.deletedAt < datetime() - duration('PT10M')
DETACH DELETE c;
MATCH (e:__Entity__) WHERE NOT EXISTS { MATCH (e)-[:FROM_CHUNK]->(:Chunk) }
DETACH DELETE e;
Source: Cypher Manual — MERGE and Cypher Manual — EXISTS subqueries.
Four rules keep this correct:
-
MERGE on stable keys only (
sourcefor documents, a deterministic chunk ID, the normalisednameplus label for entities), with uniqueness constraints on those keys (see Indexes and Constraints);MERGEon a whole pattern creates duplicates when any property differs. -
Tombstone before delete. Readers filter
WHERE c.deletedAt IS NULL; a background job deletes after a grace period. A delete in the middle of a traversal otherwise produces half-answers. -
Relationships carry provenance. An extracted
DEPENDS_ONedge gets asourceslist property; when a chunk is deleted, remove its source from the list and delete the edge when the list is empty — otherwise a relationship stated only by a deleted page survives. -
Community summaries go stale. Recompute communities and regenerate the reports of changed communities on a schedule, not per event.
The CDC event source can be Debezium on the CMS database, as on the ingestion page, or Neo4j’s own change data capture when other systems write to the graph directly.
Java: Neo4j vector stores plus Cypher
Both Java frameworks store chunk embeddings in Neo4j through their vector-store abstraction, which makes vector search
and graph expansion one database round trip away. Spring AI’s Neo4jVectorStore is a normal VectorStore (ACL filters
work as on Access Control & Privacy); the graph expansion is a
Cypher query with the driver, parameterised by the IDs of the retrieved chunks:
package com.example.docsassistant.rag.graph;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.neo4j.driver.Driver;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
public class GraphExpandingRetriever implements DocumentRetriever {
private static final String EXPAND = """
MATCH (c:Chunk) WHERE c.id IN $chunkIds AND c.deletedAt IS NULL
MATCH (e:__Entity__)-[:FROM_CHUNK]->(c)
MATCH (e)-[r]-(n:__Entity__)
WHERE type(r) IN ['DEPENDS_ON', 'EXPLAINS', 'IMPLEMENTS', 'USES']
RETURN DISTINCT e.name AS from, type(r) AS rel, n.name AS to
LIMIT 40""";
private static final Filter.Expression PUBLIC_ONLY = new FilterExpressionBuilder().eq("acl", "public").build();
private final VectorStore neo4jVectorStore; // Neo4jVectorStore over (:Chunk {embedding})
private final Driver driver;
public GraphExpandingRetriever(VectorStore neo4jVectorStore, Driver driver) {
this.neo4jVectorStore = neo4jVectorStore;
this.driver = driver;
}
@Override
public List<Document> retrieve(Query query) {
// the caller's filter, passed per request as the FILTER_EXPRESSION advisor parameter; public only if missing
Filter.Expression aclFilter = query.context().get(VectorStoreDocumentRetriever.FILTER_EXPRESSION)
instanceof Filter.Expression requestFilter ? requestFilter : PUBLIC_ONLY;
List<Document> chunks = neo4jVectorStore.similaritySearch(
SearchRequest.builder().query(query.text()).topK(5).filterExpression(aclFilter).build());
List<String> ids = chunks.stream().map(Document::getId).toList();
List<String> facts = driver.executableQuery(EXPAND)
.withParameters(Map.of("chunkIds", ids))
.execute()
.records().stream()
.map(r -> r.get("from").asString() + " " + r.get("rel").asString() + " " + r.get("to").asString())
.toList();
List<Document> result = new ArrayList<>(chunks);
if (!facts.isEmpty()) {
result.add(Document.builder().id("graph-facts").text("Related facts:\n" + String.join("\n", facts))
.metadata(Map.of("source", "knowledge-graph")).build());
}
return result;
}
}
The retriever is a singleton bean shared by every caller, so the ACL filter is not a constructor argument: it is read
per request from query.context(), where RetrievalAugmentationAdvisor puts the advisor parameters, under the same
VectorStoreDocumentRetriever.FILTER_EXPRESSION key the controller sets on
Access Control & Privacy.
The expansion only starts from chunks the caller could read, but it can reach entities that were extracted from chunks
the caller cannot read. For strict ACLs, restrict the expansion to relationships whose sources intersect readable
documents, or keep one graph per ACL domain.
In LangChain4j, Neo4jEmbeddingStore takes a retrievalQuery — a Cypher fragment appended to the vector search that
can traverse from each matched node — so the expansion happens inside the store call:
package com.example.docsassistant.l4j.graph;
import dev.langchain4j.community.store.embedding.neo4j.Neo4jEmbeddingStore;
import org.neo4j.driver.Driver;
public final class GraphRagStore {
private GraphRagStore() {
}
public static Neo4jEmbeddingStore create(Driver driver, int dimension) {
return Neo4jEmbeddingStore.builder()
.driver(driver)
.label("Chunk")
.indexName("chunk_embeddings")
.embeddingProperty("embedding")
.textProperty("text")
.dimension(dimension)
.retrievalQuery("""
OPTIONAL MATCH (e:__Entity__)-[:FROM_CHUNK]->(node)
OPTIONAL MATCH (e)-[r]-(n:__Entity__)
WITH node, score, collect(DISTINCT e.name + ' ' + type(r) + ' ' + n.name)[..10] AS facts
RETURN node.text + '\\nRelated facts: ' + apoc.text.join(facts, '; ') AS text,
score, node {.*, embedding: null, text: null} AS metadata""")
.build();
}
}
Source: LangChain4j — Neo4j embedding store (custom retrieval query).
The retrieval query must return the columns the store expects (text, score, metadata, plus the ID and embedding
columns where the store configuration requires them); check the store’s documentation for the exact contract of the
version in use. apoc.text.join needs the APOC plugin (see APOC and
Extensions); reduce over the list works without it.
Knowledge-enhanced RAG — ontologies, KG-hybrid retrieval, Microsoft GraphRAG local versus global search and indexing
cost, and graph update patterns with CDC, event-driven extraction, entity merges and tombstoning — follows the concepts
of Mendelevitch and Bao, Hands-On RAG for Production (Chapter 9), and knowledge-graph modelling and GraphRAG basics
follow Anthapu and Agarwal, Building Neo4j-Powered Applications with LLMs (Chapters 3 to 5 and 9), paraphrased; the
book predates SimpleKGPipeline, the LLM Graph Builder and the current LangChain4j and Spring AI Neo4j modules, which are
documented here from Neo4j’s and the frameworks' documentation. See the
bibliography.