GraphRAG: Knowledge Graphs + Retrieval-Augmented Generation

GraphRAG is retrieval-augmented generation in which some or all of the retrieval runs over a knowledge graph rather than over a flat index of text chunks. Instead of fetching the passages whose embeddings are nearest the question, the system identifies the entities in the question, traverses their relationships, and assembles a connected subgraph (often with the source passages attached) as context for the language model. The term covers a family of designs rather than one method, and none of them is automatically better than well-built vector retrieval.

Where plain vector RAG runs out

Standard RAG embeds document chunks, embeds the question, and returns the top k chunks by similarity. It works well when the answer sits inside one passage that resembles the question. Three question types break that assumption.

Multi-hop questions. “Which suppliers of our second-largest customer are also suppliers to a competitor?” No chunk contains that answer. The facts live in at least three places, and no chunk is textually similar to the question, because the question names none of the intermediate entities. Retrieving more chunks does not help: the relevant ones are not similar, they are connected.

Aggregate and global questions. “What are the main themes across these two hundred incident reports?” A top-k retriever returns ten chunks out of two hundred documents and the model summarizes those ten as if they were the whole corpus. The result reads confidently and describes a sample nobody chose deliberately.

Questions where an entity is described across many documents. A supplier appears in a contract, three emails and an audit note, each under a slightly different name. Vector similarity treats those as unrelated passages. Answering requires knowing they are the same supplier, which is entity resolution, not retrieval.

None of these are arguments that vector retrieval is bad. They are arguments that similarity is the wrong access path for questions whose answer is a structure rather than a passage.

What the graph adds

Four things, in order of how often they turn out to be the reason a system works.

Explicit relationships. The graph stores that A supplies B as an edge, so multi-hop questions become traversals of two or three steps instead of a hope that similarity happens to bridge the gap. Traversal is deterministic, and it either finds the path or reports that none exists.

Aggregation over the whole corpus. With entities and relationships extracted from every document, counting, grouping and ranking run over the graph rather than over a retrieved sample. “How many” gets an answer computed from all the data rather than inferred from ten chunks.

Entity resolution across chunks. During graph construction, mentions in different documents are reconciled to one node. Everything known about that supplier is reachable from one identifier regardless of how each document spelled the name. This step is the single largest determinant of whether a GraphRAG system beats its vector baseline, and it is covered at /concepts/entity-linking-ner-and-extraction/.

Provenance. Each edge can carry the document and character span it came from, so an answer built from a subgraph cites specific sentences rather than whole chunks, and a wrong answer traces back to the extraction that produced the wrong edge.

The common architectures

Three designs cover most implementations, and production systems usually combine two of them.

Extract a graph, retrieve a subgraph

The classic shape. An extraction pass over the corpus produces entities and relationships, which are loaded into a graph store (/build/graph-databases/). At query time the system finds the entities mentioned in the question, retrieves their neighborhood to a bounded depth, and passes the resulting subgraph plus the source text of its edges to the model.

The retrieval step is a bounded expansion around seed entities. On a small graph it is a few lines:

import networkx as nx

EDGES = [
    ("Marie Curie", "spouse", "Pierre Curie"),
    ("Marie Curie", "award", "Nobel Prize in Physics"),
    ("Pierre Curie", "award", "Nobel Prize in Physics"),
    ("Henri Becquerel", "award", "Nobel Prize in Physics"),
    ("Marie Curie", "alumniOf", "University of Paris"),
    ("Pierre Curie", "employer", "University of Paris"),
    ("Marie Curie", "birthPlace", "Warsaw"),
    ("Warsaw", "country", "Poland"),
]

g = nx.DiGraph()
for s, p, o in EDGES:
    g.add_edge(s, o, predicate=p, source_doc="curie-bio.txt")

def subgraph_for(seeds, hops=1):
    keep = set(seeds)
    frontier = set(seeds)
    for _ in range(hops):
        nxt = set()
        for node in frontier:
            nxt |= set(g.successors(node)) | set(g.predecessors(node))
        nxt -= keep
        keep |= nxt
        frontier = nxt
    return g.subgraph(keep)

sub = subgraph_for(["Marie Curie"], hops=1)
for s, o, data in sub.edges(data=True):
    print(f"{s} --{data['predicate']}--> {o}   [{data['source_doc']}]")

print(nx.shortest_path(g.to_undirected(), "Henri Becquerel", "Warsaw"))

The last line is the multi-hop case in miniature: nothing in the data states a relationship between Henri Becquerel and Warsaw, and the path through the shared award and Marie Curie is what a vector retriever would have to reconstruct by luck.

Against a real store the same retrieval is a query. This one needs a running Neo4j instance with data loaded and cannot be run cold:

MATCH (e:Entity {name: $seed})-[r*1..2]-(neighbor)
RETURN e.name AS start,
       [rel IN r | type(rel)] AS path,
       neighbor.name AS reached,
       neighbor.source_doc AS source
LIMIT 100

Variable-length patterns are what make this concise and also what makes it dangerous: raising the upper bound from 2 to 4 on a well-connected graph can return most of it. Bounded depth, a node cap, and filtering by relationship type are correctness constraints on the context window rather than optimizations. The same retrieval over an RDF graph is a SPARQL property path, described at /languages/sparql/.

Community summarization

The design published as Microsoft’s GraphRAG addresses global questions specifically. After extracting the graph, a community detection algorithm partitions it into clusters of densely connected entities, hierarchically, so small, tight communities sit inside larger ones. During indexing, a language model then writes a summary of each community at each level.

At query time, a global question is answered by consulting community summaries rather than passages: each relevant summary produces a partial answer, and those are combined. A local question about a specific entity drops down to that entity’s neighborhood instead.

The trade is explicit. Global questions get answers grounded in the whole corpus rather than in a top-k sample, at the price of a model call per community per level during indexing, and every summary goes stale when the underlying documents change.

Hybrid vector plus graph retrieval

The most common production shape, and usually the right default. Vector search finds an entry point, because natural-language questions rarely name their entities cleanly, and the graph expands from there. A typical sequence: embed the question and retrieve chunks; map those chunks to the entities they mention; expand one or two hops in the graph; score the resulting subgraph; assemble context from the original chunks plus the traversed edges and their source spans.

This keeps what vector retrieval is good at (fuzzy matching of language to content) and adds what it cannot do (following relationships), and it degrades gracefully, since a thin patch of graph still leaves the vector results in place. The comparison between the two storage models is at /concepts/knowledge-graph-vs-vector-database/.

ArchitectureBest atMain cost
Subgraph retrievalMulti-hop, entity-centric questionsExtraction quality, traversal blowup
Community summarizationGlobal and thematic questionsModel calls during indexing, staleness
Hybrid vector plus graphGeneral question mixTwo indexes to build and keep current

Hybrid retrieval: vectors find the entry point, the graph supplies the context

A common GraphRAG pattern does not replace vector search. It uses vector search for the step it handles well, matching a question to text that resembles it, and hands the result to the graph for the step it handles poorly, gathering facts that are related to the match but not worded like the question. The comparison between the two stores is covered at /concepts/knowledge-graph-vs-vector-database/.

The pipeline has four steps.

  1. Embed chunks. Source documents are split into chunks, each chunk is embedded, and each chunk records the entities it mentions. Those entity links come from extraction at indexing time (see /concepts/entity-linking-ner-and-extraction/), and they are what connect the text index to the graph.
  2. Find seed entities. The question is embedded with the same model, the most similar chunks are retrieved, and the entities those chunks mention become seeds.
  3. Expand a bounded neighborhood. From each seed, the retriever collects nodes within a fixed number of hops. The bound matters: in a connected graph, a few hops from a well-linked node can reach a large share of the graph.
  4. Assemble context with provenance. Each edge in the neighborhood becomes one line of context carrying the id of the chunk it was extracted from, so a generated answer can cite its sources and a reviewer can check them.

A runnable example

The script below implements all four steps with numpy and networkx and nothing else. The embeddings are a hashing bag-of-words vectorizer: each token is hashed with CRC32 into one of 256 dimensions and the vector is normalized to unit length, which makes cosine similarity a dot product. CRC32 is used instead of Python’s built-in hash() because the built-in is salted per process for strings, and the output would change between runs. A real system would use a trained embedding model, but the retrieval logic is the same.

import re
import zlib

import networkx as nx
import numpy as np

# 1. A tiny corpus. Each chunk has a source id and the entities it mentions.
CHUNKS = {
    "doc1#c1": ("Marie Curie was born in Warsaw and later moved to Paris.",
                ["Marie Curie", "Warsaw", "Paris"]),
    "doc1#c2": ("Marie Curie shared the 1903 Nobel Prize in Physics with Pierre Curie.",
                ["Marie Curie", "Nobel Prize in Physics", "Pierre Curie"]),
    "doc2#c1": ("Pierre Curie taught at the University of Paris.",
                ["Pierre Curie", "University of Paris"]),
    "doc3#c1": ("The Eiffel Tower was completed in Paris in 1889.",
                ["Eiffel Tower", "Paris"]),
    "doc4#c1": ("Albert Einstein received the Nobel Prize in Physics for 1921.",
                ["Albert Einstein", "Nobel Prize in Physics"]),
}

# 2. The graph: entity nodes, typed edges, and the chunk each edge came from.
TRIPLES = [
    ("Marie Curie", "born_in", "Warsaw", "doc1#c1"),
    ("Marie Curie", "lived_in", "Paris", "doc1#c1"),
    ("Marie Curie", "award", "Nobel Prize in Physics", "doc1#c2"),
    ("Marie Curie", "spouse", "Pierre Curie", "doc1#c2"),
    ("Pierre Curie", "award", "Nobel Prize in Physics", "doc1#c2"),
    ("Pierre Curie", "employer", "University of Paris", "doc2#c1"),
    ("University of Paris", "located_in", "Paris", "doc2#c1"),
    ("Eiffel Tower", "located_in", "Paris", "doc3#c1"),
    ("Albert Einstein", "award", "Nobel Prize in Physics", "doc4#c1"),
]

graph = nx.MultiDiGraph()
for subj, pred, obj, source in TRIPLES:
    graph.add_edge(subj, obj, predicate=pred, source=source)

# 3. Toy embeddings: a hashing bag-of-words vectorizer in numpy.
DIM = 256

def tokenize(text):
    return re.findall(r"[a-z0-9]+", text.lower())

def embed(text):
    vec = np.zeros(DIM)
    for token in tokenize(text):
        vec[zlib.crc32(token.encode()) % DIM] += 1.0
    norm = np.linalg.norm(vec)
    return vec / norm if norm else vec

chunk_ids = list(CHUNKS)
matrix = np.vstack([embed(CHUNKS[c][0]) for c in chunk_ids])

def retrieve(question, top_k=2, radius=1):
    # 4. Vector step: cosine similarity (vectors are unit length, so a dot product).
    scores = matrix @ embed(question)
    ranked = np.argsort(-scores)[:top_k]
    hits = [(chunk_ids[i], float(scores[i])) for i in ranked]

    # 5. Seed entities: the entities mentioned in the top chunks.
    seeds = []
    for chunk_id, _ in hits:
        for entity in CHUNKS[chunk_id][1]:
            if entity not in seeds:
                seeds.append(entity)

    # 6. Graph step: a bounded neighborhood around each seed, edges in both directions.
    undirected = graph.to_undirected(as_view=True)
    nodes = set()
    for seed in seeds:
        nodes |= set(nx.ego_graph(undirected, seed, radius=radius).nodes)
    sub = graph.subgraph(nodes)

    # 7. Context assembly: one fact per edge, each with its source id.
    facts = sorted({(s, d["predicate"], o, d["source"])
                    for s, o, d in sub.edges(data=True)})
    return hits, seeds, facts

question = "Who did Marie Curie share the Nobel Prize in Physics with?"
hits, seeds, facts = retrieve(question)

print("Question:", question)
print("Vector hits:")
for chunk_id, score in hits:
    print(f"  {chunk_id}  cosine={score:.3f}")
print("Seed entities:", ", ".join(seeds))
print("Context:")
for subj, pred, obj, source in facts:
    print(f"  {subj} --{pred}--> {obj}  [{source}]")

Running it with python3 graphrag_example.py prints:

Question: Who did Marie Curie share the Nobel Prize in Physics with?
Vector hits:
  doc1#c2  cosine=0.754
  doc4#c1  cosine=0.477
Seed entities: Marie Curie, Nobel Prize in Physics, Pierre Curie, Albert Einstein
Context:
  Albert Einstein --award--> Nobel Prize in Physics  [doc4#c1]
  Marie Curie --award--> Nobel Prize in Physics  [doc1#c2]
  Marie Curie --born_in--> Warsaw  [doc1#c1]
  Marie Curie --lived_in--> Paris  [doc1#c1]
  Marie Curie --spouse--> Pierre Curie  [doc1#c2]
  Pierre Curie --award--> Nobel Prize in Physics  [doc1#c2]
  Pierre Curie --employer--> University of Paris  [doc2#c1]
  University of Paris --located_in--> Paris  [doc2#c1]

The output shows both halves working. The top chunk, doc1#c2, is the one that answers the question. The second hit, doc4#c1, is a weaker match pulled in by shared words (“Nobel Prize in Physics”), which is how vector similarity behaves. The graph step then adds facts no chunk similar to the question contains, such as Pierre Curie’s employer, each traceable to its source. The Eiffel Tower fact is excluded even though the Eiffel Tower is connected to Paris: it sits two hops from the nearest seed, outside the radius of one. Raising radius to 2 brings it in, which is the trade-off the bound controls.

Einstein’s presence also shows why provenance matters. A generator given this context can attribute each claim to a chunk id, and an evaluator can see that the Einstein line came from a lower-scoring match.

What changes in production

Production systems keep the same four steps and swap the components. The similarity step moves to a vector database such as Qdrant or Milvus, and the neighborhood expansion to a graph store such as Neo4j, where it becomes a bounded traversal query (see /build/graph-databases/). Frameworks such as LlamaIndex and LangChain provide components that wire the two together; their interfaces change between releases, so their current documentation is the reference for the exact calls.

Where the costs land

Extraction quality is the ceiling. Everything downstream inherits the graph’s errors. A relation the extractor missed cannot be traversed, and a relation it invented will be traversed confidently and cited. Language models are good at proposing relations from prose and poor at doing so consistently, which is why constrained vocabularies, evidence spans, and post-hoc validation belong in the pipeline. That pipeline, including the validation code, is in/build/knowledge-graph-python/.

Index build time and cost are front-loaded. Vector indexing is one embedding call per chunk. Graph indexing is at least one model call per chunk for extraction, plus entity resolution across the whole corpus, plus community detection and summarization if that design is used.

Keeping the graph current is the recurring cost. A new document changes more than its own chunk: it may add edges to existing entities, merge two previously distinct entities, or invalidate a community summary written before it arrived. Incremental updates are harder than re-embedding one chunk, and systems that skip them drift until someone rebuilds from scratch.

Latency and context budget. Traversal adds a hop before generation, and a subgraph serialized into a prompt can consume more context than the chunks it replaced. Both need measuring rather than assuming.

When GraphRAG is not worth it

The honest cases against it are specific. If the questions are single-passage lookups (“what is the return policy”), a graph adds cost and no accuracy. If the corpus is small enough to fit in context, retrieval of any kind is the wrong problem. If the documents are not entity-dense (narrative prose, opinion, freeform notes), extraction produces a sparse graph with weak edges and traversal has little to follow. If nobody will own entity resolution, the graph will contain three nodes for every real thing and traversal will silently miss most of what it should find.

The strongest predictor of GraphRAG paying off is not corpus size but question shape. Multi-hop, aggregate and entity-comparison questions justify it. Vector retrieval that has been tuned properly (sensible chunking, a reranker, metadata filters, query rewriting) is a serious baseline, and comparisons that skip that tuning are not evidence.

Tooling

Microsoft’s GraphRAG is an open-source Python implementation of the community-summarization design, with an indexing pipeline and local and global query modes. Neo4j publishes an LLM Graph Builder that extracts entities and relationships from documents into a Neo4j graph, alongside integrations for LangChain and LlamaIndex. LangChain and LlamaIndex both ship graph-store abstractions and property-graph index types that combine vector and graph retrieval. Memgraph documents a GraphRAG approach over its own engine. Categories and alternatives are surveyed at /build/knowledge-graph-tools/; check each project’s current documentation and license terms before committing to one.

For developers

Build the vector baseline first and record its accuracy on a real question set, because without that number, you can’t tell whether the graph helped. Label each question in that set as single-passage, multi-hop, or aggregate; the mix determines whether this is worth building at all. Constrain the extraction vocabulary to a small set of entity types and predicates from the start, since open-vocabulary extraction produces a graph nobody can write a traversal against. Cap traversal depth and result size at the retrieval layer rather than trusting the query. Keep source document identifiers and offsets on every edge, for citation and for debugging a bad answer back to the sentence that caused it. Entity embeddings are a separate technique that can sit alongside this, described at /concepts/knowledge-graph-embeddings/.

For SEOs

GraphRAG is the internal, private counterpart to what large language models and AI search features do over public data. Both depend on the same thing: whether an entity can be identified, connected to related entities, and traced to a source. A brand described consistently across its own site, marked up with structured data, and linked to authority profiles through sameAs is easier for any such system to resolve, whether that system is a vendor’s internal graph or a search engine’s. The public-web side of this is covered at /seo/knowledge-graphs-ai-search-and-llms/, and the entity groundwork at /seo/entity-seo/.

Related pages

FAQ

What is GraphRAG?

GraphRAG is retrieval-augmented generation where retrieval runs over a knowledge graph instead of, or alongside, a vector index of text chunks. The system identifies entities in the question, traverses their relationships in the graph, and passes the connected subgraph and its source passages to the language model. It is a family of designs rather than a single method.

How is GraphRAG different from normal RAG?

Normal RAG retrieves the text chunks whose embeddings are most similar to the question, so it answers well when the answer sits in one passage. GraphRAG retrieves by following explicit relationships between entities, which handles questions whose answer spans several documents, questions that require aggregating over a whole corpus, and entities described under different names in different places.

Is GraphRAG better than vector RAG?

Not automatically. It helps with multi-hop questions, aggregate questions, and corpora where the same entity appears across many documents. It costs more to index, more to keep current, and its accuracy is capped by the quality of the entity and relation extraction. A properly tuned vector pipeline with good chunking and a reranker is a serious baseline that should be measured before adding a graph.

What do you need to build a GraphRAG system?

A document corpus, an extraction step that produces entities and relationships with a constrained vocabulary, entity resolution to merge mentions of the same thing, a graph store to hold the result, and a retrieval layer that maps a question to seed entities and expands from them with a depth limit. Most systems keep a vector index alongside the graph for entry points.

Sources and further reading

  • Microsoft Research, GraphRAG documentation: https://microsoft.github.io/graphrag/
  • Edge et al., From Local to Global: A Graph RAG Approach to Query-Focused Summarization: https://arxiv.org/abs/2404.16130
  • Neo4j, LLM Knowledge Graph Builder documentation: https://neo4j.com/docs/
  • LlamaIndex, property graph index documentation: https://docs.llamaindex.ai/
  • Hogan et al., Knowledge Graphs (open access book): https://kgbook.org/