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 ef_search control?"

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’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

Knowledge graph construction for DocsAssistant. Documents are parsed and chunked with acl, source and hash; a lexical graph of Document and Chunk nodes with embeddings and NEXT relationships is written; an LLM extracts entities and relationships per chunk under a schema of node types, relationship types and patterns; entity resolution normalises names and merges exact and fuzzy duplicates; MERGE on stable keys writes the entities with FROM_CHUNK edges to their chunks. The Neo4j graph holds components, pages, concepts and chunks with vector and full-text indexes. Incremental CDC updates re-extract changed chunks, delete or tombstone deleted ones, clean up orphan entities and merge entities idempotently. Retrieval uses vector search with graph expansion, hybrid search, text-to-Cypher, and local and global search.

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

Component (a documented library or section), Concept (HNSW, CDC, OAuth refresh token), API (a class, function or endpoint), Tool (Kafka, Neo4j, pgvector)

Relationship types

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)

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:

docsassistant/graph/build_kg.py — schema-guided KG construction (Python, 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:

docsassistant/graph/langchain_kg.py — extraction with LLMGraphTransformer (Python, 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)

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:

KnowledgeGraphIngestion.java — extraction and writing on the JVM (Java, LangChain4j community 1.20.0-beta30)
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:

  1. Normalise at extraction time: trim, case-fold for matching (keep a display name), singularise, strip version numbers into a property.

  2. Exact match on the normalised name within a label. neo4j-graphrag’s `SinglePropertyExactMatchResolver (the default when perform_entity_resolution=True) merges entities with the same label and name.

  3. Fuzzy match for spelling variants: FuzzyMatchResolver merges nodes whose names are similar above a threshold. SpaCySemanticMatchResolver uses word vectors for near-synonyms.

  4. 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.

Resolving fuzzy duplicates after a load (Python, 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())

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

neo4j-graphrag SimpleKGPipeline

(:Entity)-[:FROM_CHUNK]→(:Chunk)-[:FROM_DOCUMENT]→(:Document), with (:Chunk)-[:NEXT_CHUNK]→(:Chunk)

langchain-neo4j with include_source=True

(:Document)-[:MENTIONS]→(:Entity), where the Document node is the source chunk

LangChain4j KnowledgeGraphWriter

(:Document)-[:HAS_ENTITY]→(:Entity) by default (relType changes the type)

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:

Cypher — replace one document’s chunks and clean up orphans
// 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;

Four rules keep this correct:

  • MERGE on stable keys only (source for documents, a deterministic chunk ID, the normalised name plus label for entities), with uniqueness constraints on those keys (see Indexes and Constraints); MERGE on 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_ON edge gets a sources list 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:

GraphExpandingRetriever.java — vector search, then graph expansion (Java, Spring AI 2.0.1, Neo4j Java driver)
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:

GraphRagStore.java — a Neo4j embedding store with a graph-expanding retrieval query (Java, LangChain4j community 1.20.0-beta30)
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.