Graph Rag

Learn when a knowledge graph beats top-k retrieval, how GraphRAG and cheaper variants build and query one, what it costs and how to keep it in sync.

Last generated

Lesson 37 of 38 available15 practice questions

SPACED REPETITION Β· 15 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

The question top-k could not answer

It is the quarterly reliability review. The SRE lead asks the incident assistant: "What are the recurring root causes of our incidents?" The assistant is a classic RAG pipeline over 2,000 postmortems: embed the question, take the top 3 reports, answer from them. Here is a ten-report slice of that corpus, searched with the small open model the other lessons use, all-MiniLM-L6-v2:

import numpy as np
from sentence_transformers import SentenceTransformer

POSTMORTEMS = {
    "INC-101": "INC-101 (Jan 9): the TLS certificate on the public ingress expired. cert-manager could not "
               "renew it because its DNS credentials had been rotated. checkout-api and auth-service "
               "returned 503s for 47 minutes.",
    "INC-104": "INC-104 (Feb 2): a rate-limiter config was pushed to every region at once with a limit of 0. "
               "mobile-gateway rejected all requests for 12 minutes until the push was reverted.",
    "INC-107": "INC-107 (Feb 20): a retry storm from checkout-api exhausted the connection pool of orders-db. "
               "Orders failed for 22 minutes; retries now use jittered backoff.",
    "INC-112": "INC-112 (Mar 14): the Cert Manager renewal webhook timed out, so the internal mTLS certificate "
               "of search-api expired. Search results were empty for 31 minutes.",
    "INC-115": "INC-115 (Apr 3): a feature-flag change was pushed to all users without a canary and turned off "
               "push notifications in notification-service for 3 hours.",
    "INC-118": "INC-118 (Apr 29): a DNS resolver change left ingest-worker unable to resolve the Kafka brokers. "
               "Event ingestion lagged by 2 hours.",
    "INC-121": "INC-121 (May 30): after an upgrade, certmanager stopped issuing certificates and the webhook "
               "certificate of notification-service expired. Email sending stopped for 58 minutes.",
    "INC-124": "INC-124 (Jun 18): a deploy doubled the connections each auth-service pod opens to the session "
               "store. The pool ran out and logins failed for 16 minutes.",
    "INC-127": "INC-127 (Jul 7): a malformed routing rule was pushed to the ingress without staged rollout. "
               "search-api answered 404 for 9 minutes before rollback.",
    "INC-130": "INC-130 (Aug 12): reindexing the Atlas cluster behind search-api ran out of disk. Queries "
               "timed out for 25 minutes; the Atlas account team was told the next day.",
}
IDS = list(POSTMORTEMS)
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
VECS = model.encode([POSTMORTEMS[i] for i in IDS])        # unit-length rows

def search(query, k=3):
    scores = VECS @ model.encode(query)
    top = np.argsort(-scores)[:k]
    return [(IDS[i], round(float(scores[i]), 3)) for i in top]

question = "What are the recurring root causes of our incidents?"
for rank, (report, score) in enumerate(search(question, k=10), start=1):
    print(rank, report, score)

Predict first: three of the ten incidents (INC-101, INC-112, INC-121) were expired certificates, tied with unstaged config pushes (INC-104, INC-115, INC-127) as the most common cause in the slice. Where do the certificate incidents rank?

Check your answer
1 INC-118 0.148
2 INC-107 0.123
3 INC-104 0.123
4 INC-130 0.108
5 INC-124 0.1
6 INC-127 0.099
7 INC-115 0.092
8 INC-121 0.083
9 INC-101 0.039
10 INC-112 0.01

Last three places. The top 3 are a DNS change, a connection pool and a config push, one incident each. Every score sits between 0.01 and 0.15: no report is much closer to the question than any other.

The assistant writes something like "Recurring root causes include DNS changes, connection-pool exhaustion and config pushes." Two of those are real themes by luck, the DNS change happened once, and expired certificates, one of the two biggest themes, are missing. Nothing in the trace looks broken.

Why tuning will not fix it. No report is about recurring root causes. The question asks for an aggregate: find the cause of every incident, count, rank. Top-k retrieval returns the k chunks nearest the question, which is a sample. A better embedding model, BM25 or a reranker reorders the sample; none of them counts. Raising k to 10 works on this slice and fails on 2,000 reports. Edge et al. (2024) call these global questions ("What are the main themes in the dataset?") and built Microsoft's GraphRAG for them.

The same afternoon an engineer asks a second question, and dense search does well on the first half of it:

print(search("Which teams were affected by incidents involving cert-manager?"))
# [('INC-121', 0.367), ('INC-101', 0.356), ('INC-112', 0.274)]

All three cert-manager incidents come back, even though the reports spell it "cert-manager", "Cert Manager" and "certmanager". Embeddings shrug off spelling. But no report names a team. Ownership lives in the service catalog, so this is a bridge question: hop from incidents to the services they hit, then from services to their owners. Asked once, a bounded agent loop handles it (Agentic RAG Systems). When the ops team asks it for every component every morning and needs the complete list, the hops are the same each time: store them as data and answer with one traversal.

Both questions are what this lesson is about:

Graph RAG: retrieval over a graph of entities and relationships built from your sources. You either traverse it from the entities a question names (local questions), or summarize groups of closely connected entities ahead of time and answer from those summaries (global questions).

"GraphRAG", one word, is Microsoft's open-source implementation; "Graph RAG" is the family. And the graph here is a knowledge graph of entities, not the HNSW neighbor graph inside a vector index from ANN Algorithms.

Before any graph: does this question need one?

Most questions do not. Give each question shape the cheapest design that works:

Question shape Example Cheapest design that works Where it is taught
Single fact "How long did INC-118 lag ingestion?" Classic pipeline Classic RAG Pipeline
One-off bridge "Which team owns the service INC-130 hit?", asked once A bounded agent loop Agentic RAG Systems
The same relational hops, asked often, answer must be complete "Which teams were affected by incidents involving X?" for any component X Relations stored as data: a graph traversal, or a SQL join if the relations already sit in tables This lesson, Graph RAG with Neo4J, Vectorless RAG
Global: themes and patterns across the corpus "What are the recurring root causes?" Community summaries plus map-reduce, a query-time variant such as LazyGraphRAG, or long context if the corpus fits the window This lesson, RAG Architecture & Implementation

Here is the map. Each move starts from a symptom you can see:

# Symptom Move
1 The relation you need exists only as prose, spread over many documents Extract entities and relationships, with provenance
2 A traversal finds one of three incidents, or one node mixes two things Resolve entities: one thing, one node
3 "What recurs across…?" is answered from a sample Summarize communities, then map-reduce (global search)
4 The question names entities and asks how they connect Start from the named entities (local search, typed paths)
5 The first index costs more than the whole vector stack Choose where the LLM work happens: index time or query time
6 The assistant still reports a root cause that was corrected last week Keep the graph in sync through provenance

This lesson assumes the failure taxonomy from RAG Architecture & Implementation. Hands-on graph database work (the property graph model, Cypher, vector indexes in Neo4j, text-to-Cypher) is in Graph RAG with Neo4J.

Move 1: Extract entities and relationships

Symptom: the relation you need exists only as prose, spread over many documents.

An extractor, usually an LLM, reads each chunk and returns the entities it names (name, type, description) and the relationships between them (source, target, description). Microsoft's GraphRAG prompt asks for exactly that, plus a strength score per relationship. Its default entity types in graphrag 3.2.0 are organization, person, geo and event. For postmortems those are nearly useless. You want incident, service, component, team and failure_mode, because those are the things your questions ask about. GraphRAG's prompt-tune command (with --discover-entity-types) can propose types from a sample of your data; either way, choose the types from the questions you expect.

Here is what an extractor returned for our ten reports, stored so the lesson runs offline. The graph code in this lesson needs pip install networkx==3.7 graspologic-native==1.2.5. Pin both: 1.2.5 is the newest graspologic-native that graphrag 3.2.0 accepts (it requires a version below 1.3), and the community outputs in Moves 3 and 6 are the ones it prints. Other versions can split the same graph differently.

import networkx as nx

# What an LLM extractor returned for each report: (source, relation, target),
# with names spelled exactly as they appear in the text. Stored here so the lesson runs offline.
EXTRACTED = {
    "INC-101": [("INC-101", "affected", "checkout-api"), ("INC-101", "affected", "auth-service"),
                ("INC-101", "involved", "cert-manager"), ("INC-101", "involved", "ingress"),
                ("INC-101", "failure", "expired certificate")],
    "INC-104": [("INC-104", "affected", "mobile-gateway"), ("INC-104", "involved", "rate-limiter"),
                ("INC-104", "failure", "unstaged config push")],
    "INC-107": [("INC-107", "affected", "checkout-api"), ("INC-107", "involved", "orders-db"),
                ("INC-107", "failure", "connection pool exhaustion")],
    "INC-112": [("INC-112", "affected", "search-api"), ("INC-112", "involved", "Cert Manager"),
                ("INC-112", "failure", "expired certificate")],
    "INC-115": [("INC-115", "affected", "notification-service"), ("INC-115", "involved", "feature-flags"),
                ("INC-115", "failure", "unstaged config push")],
    "INC-118": [("INC-118", "affected", "ingest-worker"), ("INC-118", "involved", "dns-resolver"),
                ("INC-118", "involved", "kafka"), ("INC-118", "failure", "dns misconfiguration")],
    "INC-121": [("INC-121", "affected", "notification-service"), ("INC-121", "involved", "certmanager"),
                ("INC-121", "failure", "expired certificate")],
    "INC-124": [("INC-124", "affected", "auth-service"), ("INC-124", "involved", "session-store"),
                ("INC-124", "failure", "connection pool exhaustion")],
    "INC-127": [("INC-127", "affected", "search-api"), ("INC-127", "involved", "ingress"),
                ("INC-127", "failure", "unstaged config push")],
    "INC-130": [("INC-130", "affected", "search-api"), ("INC-130", "involved", "Atlas"),
                ("INC-130", "failure", "disk full")],
}
# Ownership already lives in the service catalog, a table: import it, don't extract it.
OWNERS = {"checkout-api": "Payments", "auth-service": "Identity", "search-api": "Discovery",
          "notification-service": "Messaging", "mobile-gateway": "Mobile",
          "ingest-worker": "Data Platform"}

def build(extracted, canon=lambda name: name):
    g = nx.Graph()
    for source_id, triples in extracted.items():
        for s, rel, t in triples:
            s, t = canon(s), canon(t)
            if g.has_edge(s, t):
                g.edges[s, t]["sources"].add(source_id)      # provenance: who said so
            else:
                g.add_edge(s, t, rel=rel, sources={source_id})
    for service, team in OWNERS.items():
        g.add_edge(canon(service), team, rel="owned_by", sources={"catalog"})
    return g

def teams_affected(g, component):
    """component -> incidents -> affected services -> owning teams"""
    incidents = sorted(n for n in g[component] if g.edges[component, n]["rel"] == "involved")
    services = {s for i in incidents for s in g[i] if g.edges[i, s]["rel"] == "affected"}
    teams = {t for s in services for t in g[s] if g.edges[s, t]["rel"] == "owned_by"}
    return incidents, sorted(teams)

g = build(EXTRACTED)
print(g.number_of_nodes(), "nodes,", g.number_of_edges(), "edges")
print(teams_affected(g, "cert-manager"))

Three design choices in that block matter more than the library:

  • Every edge carries provenance. sources records which document supports the edge. Answers cite it, and Move 6 uses it to take a document back. GraphRAG stores the same thing as text_unit_ids on every entity and relationship.
  • Structured relations are imported, not extracted. Service ownership is already a table. Paying an LLM to rediscover it from prose adds cost and errors.
  • Types come from the questions. failure edges exist because someone will ask about causes.

Predict: three incidents involved cert-manager, affecting four services owned by four teams. What does the last line print?

Check your answer
38 nodes, 39 edges
(['INC-101'], ['Identity', 'Payments'])

One incident and two teams. "cert-manager", "Cert Manager" and "certmanager" are three different nodes, so the traversal from cert-manager never reaches INC-112 or INC-121. Dense search found all three. The graph, which was supposed to be the precise tool, is the one that missed them. Move 2 fixes it.

The real extraction call

With a real model, the extraction step is one call per chunk. This sketch uses Anthropic's Messages API with structured outputs, which constrain the response to a JSON Schema. It needs the anthropic package (1.8.0 here) and an API key, so it is not part of the offline run:

import json
import anthropic

MODEL = "claude-haiku-4-5-20251001"   # example id as of 2026-09; pick a current model
client = anthropic.Anthropic()        # reads ANTHROPIC_API_KEY from the environment

SYSTEM = ("Extract entities and relationships from one incident postmortem. "
          "Write service and component names exactly as they appear in the text. "
          "Name the failure mode with a short generic phrase, such as 'expired certificate'. "
          "Only include relationships the text states.")
ENTITY = {"type": "object", "additionalProperties": False,
          "required": ["name", "type", "description"],
          "properties": {"name": {"type": "string"},
                         "type": {"type": "string", "enum": ["incident", "service", "component",
                                                            "team", "failure_mode"]},
                         "description": {"type": "string"}}}
RELATION = {"type": "object", "additionalProperties": False,
            "required": ["source", "relation", "target"],
            "properties": {"source": {"type": "string"},
                           "relation": {"type": "string", "enum": ["affected", "involved", "failure"]},
                           "target": {"type": "string"}}}
SCHEMA = {"type": "object", "additionalProperties": False,
          "required": ["entities", "relationships"],
          "properties": {"entities": {"type": "array", "items": ENTITY},
                         "relationships": {"type": "array", "items": RELATION}}}

def extract(text):
    response = client.messages.create(
        model=MODEL, max_tokens=4000, system=SYSTEM,
        messages=[{"role": "user", "content": text}],
        output_config={"format": {"type": "json_schema", "schema": SCHEMA}})
    if response.stop_reason != "end_turn":             # e.g. max_tokens: a partial extraction
        raise RuntimeError(f"extraction stopped early: {response.stop_reason}")
    data = json.loads(next(b.text for b in response.content if b.type == "text"))
    names = {e["name"] for e in data["entities"]}
    return [(r["source"], r["relation"], r["target"]) for r in data["relationships"]
            if r["source"] in names and r["target"] in names]    # drop dangling edges

A schema guarantees the shape of the answer, not its truth. It cannot stop a relationship that points at an entity the model never declared (the last line drops those), a relation the text does not state, or an entity the model skipped. The structured outputs docs list the JSON Schema features that are supported. Cache each extraction under a key made of the chunk's content hash, the extraction prompt version, the entity types and the model. A rebuild with the same prompt then pays only for chunks that changed. A change to the prompt, the types or the model misses the cache for every chunk, so try it on a sample first. A key made of the content hash alone would silently return old extractions after such a change. GraphRAG caches its LLM calls by default, keyed on the whole request (messages and model included).

How much the extractor sees at once

Chunk size changes what gets extracted. Edge et al. found that GPT-4 extracted almost twice as many entity references from 600-token chunks as from 2,400-token chunks. GraphRAG's answer is gleaning: after the first pass, it tells the model that many entities were missed and asks for more, re-sending the conversation. The defaults in graphrag 3.2.0 are 1,200-token chunks with 100 tokens of overlap and one gleaning pass, so two LLM calls per chunk. Smaller chunks and more gleaning passes raise recall and the bill together. Choosing chunk sizes in general is covered in Smart Chunking.

⚠️ Tables and figures. The extractor reads text. A table flattened into a run of cells loses its row structure, and a diagram is invisible. If a table is really structured data (an ownership list, a dependency matrix), import it as relations the way OWNERS is imported. Parsing tables and figures is covered in Document Processing.

Measure it. Hand-label the triples you expect in 30–50 chunks and compute the extractor's precision (share of extracted triples that are right) and recall (share of expected triples it found). Then check coverage: for each labeled question, is the answer entity a node at all? Han et al. (2025) found only about 65.8% of answer entities in the graphs they built for HotpotQA. A question whose answer never became a node cannot be answered by traversal, however good the traversal is.

Move 2: One thing, one node

Symptom: a traversal finds one of three incidents. Or one node collects facts about two different things.

Three jobs hide behind the word "resolution". This course uses one name for each:

  • Entity resolution (merge): different strings, one thing. "cert-manager", "Cert Manager" and "certmanager".
  • Disambiguation (split): one string, different things. INC-130 names the Atlas search cluster and the Atlas customer account.
  • Entity linking: mapping a mention to a canonical id in a source you trust: the service catalog, a drug vocabulary, a company registry.

What GraphRAG does out of the box: graphrag 3.2.0 uppercases every extracted name and type and groups the extracted entities by (title, type). Everything after that is keyed by the name alone: relationships are grouped by source and target names, the graph is clustered on names, and the final entity table keeps the first row for each name. So case differences disappear, "CERT MANAGER" and "CERTMANAGER" stay two nodes, and two different things that share a name become one node even when the extractor gave them different types. Edge et al. note that duplicates tend to land in the same community, which is why global summaries tolerate them. The traversal above shows that local questions do not.

The cheapest fix is a normalization key:

import re

def key(name):
    return re.sub(r"[^a-z0-9]", "", name.lower())    # "Cert Manager" -> "certmanager"

CANON = {}                                           # key -> canonical display name
def canon(name):
    return CANON.setdefault(key(name), name)          # the first spelling seen wins

g = build(EXTRACTED, canon)
print(g.number_of_nodes(), "nodes,", g.number_of_edges(), "edges")
print(teams_affected(g, "cert-manager"))
36 nodes, 39 edges
(['INC-101', 'INC-112', 'INC-121'], ['Discovery', 'Identity', 'Messaging', 'Payments'])

Two nodes disappeared, the edge count stayed the same, and the answer went from two teams to four. Scored as a set against the gold list of four teams, recall went from 2/4 = 0.5 to 1.0, and precision stayed 1.0.

Why not fuzzy matching?

The next step people reach for is string similarity. Here is what it says about five pairs:

from difflib import SequenceMatcher

pairs = [("cert-manager", "certmanager"), ("auth-service", "oauth-service"),
         ("Payments", "Payment"), ("search-api", "search-ui"), ("orders-db", "orders-db-replica")]
for a, b in pairs:
    print(f"{a:14} {b:18} {SequenceMatcher(None, a.lower(), b.lower()).ratio():.2f}")
cert-manager   certmanager        0.96
auth-service   oauth-service      0.96
Payments       Payment            0.93
search-api     search-ui          0.84
orders-db      orders-db-replica  0.69

A correct merge and a wrong one both score 0.96, and "Payments" vs "Payment", probably the same team, scores lower than the wrong one. No threshold separates them. Embedding similarity has the same problem in a different form: names that are close in meaning are not always the same thing, and a primary database and its replica are close in meaning while being two different machines. What works, cheapest first:

  1. Normalization keys per type: case, spaces, hyphens, underscores. Safe for most identifiers, but check your vocabulary (a stripped "C++" becomes "c").
  2. Link to systems of record: map mentions to catalog ids, and keep an alias table ("certmanager" β†’ cert-manager) that grows with every review.
  3. Candidate pairs within a type, decided with context: string or embedding similarity only proposes pairs; shared neighbors, descriptions, an LLM or a person decide. Spend the review effort on high-degree nodes, where one wrong merge moves the most answers.
  4. Keep merges reversible: store the mapping from mention to canonical id, not just the merged graph.

As a dated example, the neo4j-graphrag Python package (1.21) ships exact-match, fuzzy and spaCy-similarity entity resolvers; Graph RAG with Neo4J works with that package.

⚠️ A false merge is worse than a duplicate. A duplicate loses edges: some answers become incomplete. A false merge invents paths: the traversal happily walks from one thing into another and returns confident, wrong answers. It also creates a hub that pulls unrelated incidents into one community (Move 3).

Your turn. INC-130's report mentions the Atlas cluster and the Atlas customer account. The extractor returns both as Atlas, typed component. What goes wrong, and what do you change?

Check your answer

Both mentions become one node. The customer account is now "involved" in a disk-full incident, and any question about the customer can walk into search infrastructure (or the reverse). A separate customer type is worth adding, but on its own it does not split the node: GraphRAG keys its graph by name, so two ATLAS entities of different types still become one node. Tell the extractor to qualify ambiguous names ("Atlas (customer)"), then link customers to CRM ids and components to catalog ids, so the key is an id, not a string. Before trusting it, label a sample of ambiguous names and check how many were split correctly.

Measure it. Label 100–200 candidate pairs as same or different and compute pairwise merge precision (merges that were right) and recall (true duplicates that were merged). After each rebuild, list the nodes whose degree jumped: a sudden hub is usually a false merge.

Move 3: Summarize communities, then map-reduce

Symptom: a global question is answered from whichever k chunks sit nearest to it.

The GraphRAG recipe does its heavy work at index time:

  1. Find communities: groups of entities more densely connected to each other than to the rest of the graph. GraphRAG uses the Leiden algorithm (Traag, Waltman & van Eck, 2019), which fixed a defect of the older Louvain algorithm: Louvain can return badly connected, even disconnected, communities.
  2. Make them hierarchical. Leiden on its own returns one partition. GraphRAG runs hierarchical Leiden: cluster the graph (level 0, the root: the fewest, largest communities), then cluster again every community with at least max_cluster_size members (default 10) into sub-communities at level 1, and so on. A higher level means smaller communities, and a community already below the size limit is final, so it has no children. By default, "the graph" means only its largest connected component; more on that below.
  3. Write a report for every community at every level, with an LLM: a title, a summary, an impact rating and a list of findings, built from the members' descriptions and relationships.

At query time, global search takes the community reports up to a chosen level (--community-level, default 2). In graphrag 3.2.0 that means every report at levels 0 to 2, parents and their sub-communities alike (read in its source and checked by running its report loader on this lesson's graph). It packs those reports into batches of up to 12,000 tokens and sends one map call per batch, in parallel. Each map call returns key points with an importance score from 0 to 100. One reduce call ranks the points and writes the answer.

index time:  chunks ──▢ extract ──▢ merge entities ──▢ graph ──▢ hierarchical Leiden ──▢ one report per community
query time:  question + the reports up to the chosen level ──▢ map (one call per batch, in parallel)
                                                           ──▢ reduce (one call) ──▢ answer

Here is the hierarchy on our resolved graph. It makes the same Leiden call GraphRAG makes, with the same seed and parameters, except max_cluster_size=6: at the default of 10, this 36-node toy stops at level 0.

import graspologic_native as gn          # the Leiden implementation GraphRAG calls
from collections import defaultdict

def communities(g, max_cluster_size=6):
    """(level, community id) -> set of nodes, via hierarchical Leiden."""
    # As in GraphRAG: every pair in a fixed order and the edge list sorted, so the result
    # does not depend on the order the edges were added. Weight = number of sources
    # (GraphRAG sums the extractor's strength scores instead).
    edges = sorted((min(s, t), max(s, t), float(len(d["sources"])))
                   for s, t, d in g.edges(data=True))
    clusters = gn.hierarchical_leiden(edges=edges, max_cluster_size=max_cluster_size,
                                      seed=0xDEADBEEF, starting_communities=None,
                                      resolution=1.0, randomness=0.001,
                                      use_modularity=True, iterations=1)
    members = defaultdict(set)
    for c in clusters:
        members[(c.level, c.cluster)].add(c.node)
    return members

members = communities(g)
for (level, cid), nodes in sorted(members.items()):
    incidents = sorted(n for n in nodes if n.startswith("INC-"))
    print(f"level {level}  community {cid:2}  {len(nodes)} nodes  {incidents}")

Predict: INC-104, INC-115 and INC-127 were all unstaged config pushes, and all three have a failure edge to the same node. Do they end up in one level-0 community?

Check your answer
level 0  community  0  6 nodes  ['INC-118']
level 0  community  1  7 nodes  ['INC-127', 'INC-130']
level 0  community  2  7 nodes  ['INC-107', 'INC-124']
level 0  community  3  7 nodes  ['INC-101', 'INC-112', 'INC-121']
level 0  community  4  5 nodes  ['INC-104']
level 0  community  5  4 nodes  ['INC-115']
level 1  community  6  2 nodes  []
level 1  community  7  4 nodes  ['INC-118']
level 1  community  8  3 nodes  ['INC-130']
level 1  community  9  2 nodes  []
level 1  community 10  2 nodes  ['INC-127']
level 1  community 11  2 nodes  []
level 1  community 12  3 nodes  ['INC-107']
level 1  community 13  2 nodes  ['INC-124']
level 1  community 14  4 nodes  ['INC-112', 'INC-121']
level 1  community 15  3 nodes  ['INC-101']

No: three different communities. INC-127 sits with INC-130, because both hit search-api. INC-104 sits with mobile-gateway and the rate limiter, and INC-115 with notification-service and the Messaging team. Each incident's one edge to "unstaged config push" is outweighed by its ties to its own service. Communities follow the density of connections, not topics. Communities 4 and 5 (5 and 4 nodes) have no level-1 children: they were already below the size limit.

These are graspologic-native 1.2.5's numbers. Under 1.3.1 the level-0 incident groups come out the same, but the ids, some non-incident members and the level-1 split differ. Keep that in mind for Move 6.

Now the reports and the map-reduce. The report writer and the map and reduce calls are LLM calls in GraphRAG; here deterministic stand-ins play their parts, so the flow runs offline:

def write_report(nodes):
    """Stands in for the LLM that writes one community report at index time."""
    incidents = sorted(n for n in nodes if n.startswith("INC-"))
    return [(i, t) for i in incidents for t in g[i] if g.edges[i, t]["rel"] == "failure"]

community_reports = {cid: write_report(nodes)
                     for (level, cid), nodes in sorted(members.items()) if level == 0}

def map_step(report):
    """Stands in for one map call: the key points one report contributes."""
    return report

def reduce_step(points):
    """Stands in for the reduce call: merge the key points of every report and rank them."""
    by_failure = defaultdict(list)
    for incident, failure in points:
        by_failure[failure].append(incident)
    return sorted(by_failure.items(), key=lambda kv: (-len(kv[1]), kv[0]))   # ties by name

for cid, report in community_reports.items():
    print(cid, report)
points = [p for report in community_reports.values() for p in map_step(report)]
for failure, incidents in reduce_step(points):
    print(f"{failure:28} {len(incidents)}  {incidents}")
0 [('INC-118', 'dns misconfiguration')]
1 [('INC-127', 'unstaged config push'), ('INC-130', 'disk full')]
2 [('INC-107', 'connection pool exhaustion'), ('INC-124', 'connection pool exhaustion')]
3 [('INC-101', 'expired certificate'), ('INC-112', 'expired certificate'), ('INC-121', 'expired certificate')]
4 [('INC-104', 'unstaged config push')]
5 [('INC-115', 'unstaged config push')]
expired certificate          3  ['INC-101', 'INC-112', 'INC-121']
unstaged config push         3  ['INC-127', 'INC-104', 'INC-115']
connection pool exhaustion   2  ['INC-107', 'INC-124']
disk full                    1  ['INC-130']
dns misconfiguration         1  ['INC-118']

Communities 1 and 5 report a config push although the unstaged config push node lives in community 4. GraphRAG does the same: a report's context includes its members' relationships to the rest of the graph.

Why it works

Map-reduce reads every community's report, so coverage no longer depends on similarity to the question. The config-push theme was split across three communities and was still counted three times. Top-k put the three certificate incidents last; the reduce counts them 3, tied for first with the config pushes. (A real reduce call ranks by the map calls' importance scores; this stand-in breaks the tie by name.) The communities do not have to match your themes. They only have to cut the corpus into pieces small enough to summarize, with related facts close together.

Coverage now depends on the graph instead: on what became a node, and on what got a community. Here is the catch in GraphRAG's defaults:

largest = max(nx.connected_components(g), key=len)   # all that GraphRAG clusters by default
print(len(largest), "of", g.number_of_nodes(), "nodes in the largest connected component")
print("left out:", sorted(set(g) - largest))
30 of 36 nodes in the largest connected component
left out: ['Data Platform', 'INC-118', 'dns misconfiguration', 'dns-resolver', 'ingest-worker', 'kafka']

⚠️ GraphRAG clusters only the largest connected component by default (cluster_graph.use_lcc: true in graphrag 3.2.0). INC-118 shares no entity with the other nine incidents, so its six nodes get no community and no report, and "dns misconfiguration" would vanish from the global answer above, with no error. Run graphrag 3.2.0's own clustering function on this graph and INC-118 is the one incident left without a community. In a real extraction, a team whose incidents share no entity with the rest disappears the same way. Set use_lcc: false under cluster_graph in settings.yaml, or at least measure how many documents have an entity inside the clustered component.

Why summaries rather than map-reduce over all the raw chunks? That also covers everything, and Edge et al. tested it as a baseline ("TS"). Community reports are a compressed layer computed once: in the paper, root-level reports needed over 97% fewer tokens than map-reduce over source texts, for a modest drop in quality, and community reports generally gave small but consistent gains in comprehensiveness and diversity over it. Against vector RAG, the global approaches (GraphRAG's community levels and map-reduce over source text alike) won 72–83% of LLM-judged comparisons on comprehensiveness and 62–82% on diversity. The two corpora were podcast transcripts (about 1 million tokens) and news articles (about 1.7 million), and vector RAG gave the most direct answers. That is the evidence: two corpora, their questions, an LLM judge.

⚠️ The graph can only summarize what it has nodes for. With GraphRAG's default entity types there is no failure-mode entity. The causes survive only inside entity descriptions, and a theme shows up only if a report writer happens to notice it in each community. Choose entity types for the questions you expect, and rebuild when the questions change.

A cheaper way to run global search, also in graphrag 3.2.0: dynamic community selection starts at the root and has an LLM rate each report's relevance, dropping irrelevant reports and their sub-communities before the map step. Using a small model for the rating, Microsoft reported an average 77% cost reduction at level 1 on 50 global questions over AP News articles, with comparable quality.

Measure it. Global questions have no single gold chunk. Three practical checks:

  • Theme recall: have a person list the themes in a stratified sample of documents, then check which of them the answer names.
  • Pairwise judging against your current system on comprehensiveness and diversity, run in both orders. Han et al. found that swapping the order of two summaries changed the judge's verdict substantially, sometimes reversing it.
  • Community coverage: the share of documents with at least one entity in a community. With GraphRAG's default it would be 9 of 10 here, and the missing one is exactly the theme that disappears.

Judging methods are covered in Generation Quality.

Move 4: Start from the entities in the question

Symptom: the question names entities and asks how they connect.

GraphRAG's local search starts from the question's entities. In graphrag 3.2.0 it embeds the question and looks up the entities whose description embeddings match best (the setting says 10; the code oversamples by 2 and keeps up to 20). Then it fills a 12,000-token context: 50% source text linked to those entities, 15% reports of their communities, and the rest entity and relationship descriptions. That is a neighborhood. It suits "tell me about cert-manager". It is not a path.

Our question is a path of known types: component β†’ incident β†’ affected service β†’ owning team. How big a neighborhood do you need to reach the teams?

for radius in (1, 2, 3):
    near = nx.ego_graph(g, "cert-manager", radius=radius)
    teams = sorted(set(near) & set(OWNERS.values()))
    print(radius, near.number_of_nodes(), "of", g.number_of_nodes(), "nodes", teams)

Predict: at which radius do the four teams first appear, and how much of the graph does that radius cover?

Check your answer
1 4 of 36 nodes []
2 10 of 36 nodes []
3 19 of 36 nodes ['Discovery', 'Identity', 'Messaging', 'Payments']

Radius 3, and by then the neighborhood holds more than half of this toy graph. In a real graph, three hops out from a busy component can reach thousands of nodes. teams_affected walked the same three hops but followed only typed edges, so it returned exactly the four teams, and every edge it walked carries its sources for the citation.

Why it works

For a question shape you see every day, write the traversal once, as a parameterized query over typed edges, and let the LLM do only two things: fill in the parameter (cert-manager) and write the answer from the rows. The result is exact and complete for everything the graph contains. In Neo4j that query is Cypher; Graph RAG with Neo4J writes it, including generating it safely from text. Generic neighborhoods are for questions you did not predict.

Compare this with the agent loop. The loop does its hops at query time, one model call each. At the prices Agentic RAG Systems assumes (USD 3 per million input tokens, 15 per million output tokens), a four-call loop costs USD 48.60 per 1,000 queries against 12.90 for a single pass, about 3.8Γ—. A stored graph moves the hops to index time: one traversal, which takes milliseconds, then one generation call. You pay instead in extraction, resolution and keeping the graph in sync (Moves 1, 2 and 6).

Research has also moved multi-hop retrieval into the graph itself. HippoRAG (GutiΓ©rrez et al., NeurIPS 2024) extracts a knowledge graph from the corpus and runs Personalized PageRank from the entities in the question, so one retrieval step can reach passages several hops away. The authors report results comparable to or better than iterative retrieval such as IRCoT, while being 10–30Γ— cheaper and 6–13Γ— faster. HippoRAG 2 (ICML 2025) targets the factual recall that graph methods often lose. Treat both as results on their benchmarks.

GraphRAG's own answer for local questions that need more context is DRIFT search (also in graphrag 3.2.0). It widens local search with community context: it compares the question with the most similar community reports, drafts a first answer with follow-up questions, and answers those with local searches. On 50 local questions over AP news articles, Microsoft reported that DRIFT beat plain local search on comprehensiveness 78% of the time and on diversity 81% of the time (LLM-judged). It gives a local question a broader answer, at the price of more LLM calls. It is not a cheaper global search.

Failure modes, and what the fix costs

⚠️ Hubs. A node such as kubernetes or production touches everything, and any expansion through it floods the context. Exclude generic types from expansion, down-weight or prune very high-degree nodes, and prefer typed paths. The cost is that a legitimate path through a hub now needs its own query.

⚠️ "Not in the graph" is not "does not exist". Extraction recall is below 1 (Move 1). An empty traversal means "no extracted relation", and the answer should say so. You can also fall back to text search over the source chunks.

⚠️ The graph is a derived copy. Access control still applies to every source chunk behind an edge. If a user may not read INC-130, the edge from INC-130 must not show up in their traversal either. Carry the source's permissions onto the edges and filter on them, as in Metadata & Filtering. Edges are the easy part. Entity and relationship descriptions are LLM summaries merged across every source that mentions them, so the search-api description can repeat what INC-130 says, and local search spends about a third of its context on those descriptions. Community reports summarize many sources in the same way. Filtering edges stops none of this. Keep descriptions per source and filter them per user, build separate graphs and reports per permission group, or keep graph summaries to corpora with a single audience.

Measure it. List-shaped questions have a gold set. Score the returned set with set precision and recall, the way Move 2 scored the team list. Track traversal latency and the number of nodes each query touches.

Move 5: Choose where the LLM work happens

Symptom: the first full index costs more than the whole vector stack, or a prompt change means re-indexing everything.

Put numbers on it before you commit. The prompt size comes from GraphRAG's extraction prompt, counted with tiktoken; everything else is an assumption you should replace with your own:

import math

# Assumptions: round numbers for the exercise, not a price quote.
PRICE_IN, PRICE_OUT = 1.00, 5.00   # USD per million tokens, a small hosted LLM
PRICE_EMBED = 0.02                 # USD per million tokens, an embedding API
N_REPORTS, TOKENS = 2_000, 1_100   # 2,000 incident reports; each fits one 1,200-token chunk
PROMPT = 1_726                     # tokens in GraphRAG's extraction prompt (o200k_base)

steps = {                          # name: (calls, input tokens per call, output tokens per call)
    "extract entities":   (N_REPORTS, PROMPT + TOKENS, 700),
    "gleaning pass":      (N_REPORTS, PROMPT + TOKENS + 700 + 34, 300),  # re-sends the history
    "merge descriptions": (3_000, 650, 150),
    "community reports":  (1_200, 4_000, 1_000),
}
def usd(calls, tokens_in, tokens_out):
    return calls * (tokens_in * PRICE_IN + tokens_out * PRICE_OUT) / 1e6

total = sum(usd(*v) for v in steps.values())
for name, v in steps.items():
    print(f"{name:18} {v[0]:5,} calls  USD {usd(*v):6.2f}")
extraction = usd(*steps["extract entities"]) + usd(*steps["gleaning pass"])
print(f"index total USD {total:.2f}; extraction is {extraction / total:.0%} of it")
print(f"embedding the same text: USD {N_REPORTS * TOKENS * PRICE_EMBED / 1e6:.3f}")

# Global search: 500 reports of about 1,200 tokens up to the chosen level, 12,000-token map batches
maps = math.ceil(500 * 1_200 / 12_000)
q_in, q_out = maps * 13_000 + 10_000, maps * 500 + 1_000     # + ~1,000 prompt per map, a reduce
print(f"global search: {maps} map calls, USD {1000 * usd(1, q_in, q_out):,.0f} per 1,000 questions")
print(f"classic RAG, 6 chunks: USD {1000 * usd(1, 3_000 + 6 * TOKENS, 500):.2f} per 1,000 questions")
extract entities   2,000 calls  USD  12.65
gleaning pass      2,000 calls  USD  10.12
merge descriptions 3,000 calls  USD   4.20
community reports  1,200 calls  USD  10.80
index total USD 37.77; extraction is 60% of it
embedding the same text: USD 0.044
global search: 50 map calls, USD 790 per 1,000 questions
classic RAG, 6 chunks: USD 12.10 per 1,000 questions

Read it three ways:

  • The absolute number can be small. USD 37.77 for 2,000 reports is not a budget meeting. What scales is the ratio: about 860Γ— the cost of embedding the same text. At a million reports and these prices, one full build costs about USD 19,000, and every change to the extraction prompt, the entity types or the model pays it again.
  • Extraction dominates. It is 60% here. GraphRAG's documentation estimates graph extraction at roughly 75% of indexing cost. Your share depends on how many entities and communities your corpus produces. The community reports, the part that is easiest to blame, are the smaller share.
  • Global search is not cheap either. Under these assumptions it costs about 65Γ— a classic answer per question (both at this block's prices of USD 1 in and 5 out per million tokens). The map calls run in parallel, so the wait is roughly one map call plus the reduce, but your rate limit sees all 50.

Time matters as much as money: Edge et al. report 281 minutes to index their 1-million-token podcast corpus (600-token chunks, a public OpenAI endpoint). Extraction is a batch job that runs into rate limits.

The variants: move the LLM work to query time, or remove it

As of September 2026:

System Index time Query time Status
GraphRAG, standard LLM extracts entities and relationships (plus gleaning), merges descriptions, writes a report per community local, global, DRIFT, basic (plain vector search) graphrag 3.2.0 (2026-09-23), MIT; the README says the project is largely in maintenance mode
GraphRAG, fast NLP noun phrases (NLTK, spaCy) and co-occurrence edges; the LLM writes community reports from the source text the same four Same package, --method fast. The docs call its graph "quite a bit noisier" and recommend it mainly for global summary questions
LazyGraphRAG No LLM: noun-phrase concepts, a co-occurrence graph, communities, chunk embeddings An LLM finds subqueries and refines them with concepts from the graph; chunks are ranked by embedding, communities by their chunks; an LLM rates chunk relevance, community by community, until a relevance-test budget runs out; the answer is built from claims extracted from the relevant text Microsoft Discovery (Azure) and Azure Local services (public preview, per Microsoft's June 2025 note); not in the open-source graphrag package as of 3.2.0
LightRAG LLM extracts entities and relationships and writes their descriptions; no communities An LLM pulls low-level and high-level keywords from the query, matched against entity and relationship vectors, plus neighbors and source chunks lightrag-hku 1.5.7 (2026-09-02), MIT; modes local, global, hybrid, naive, mix (default), bypass (product names: LightRAG's global matches relationships, not community reports, and its hybrid merges its local and global modes; neither is this course's hybrid retrieval)
HippoRAG 2 LLM extracts triples Personalized PageRank from the entities linked to the query Research code from the OSU NLP Group

LazyGraphRAG is the clearest example of "move it to query time". Microsoft's announcement (November 2024) puts its indexing cost as "identical to vector RAG and 0.1% of the costs of full GraphRAG". Our arithmetic agrees: embeddings cost about 0.12% of the GraphRAG build. On 5,590 AP news articles and 100 synthetic questions (50 local, 50 global), Microsoft reported answer quality comparable to GraphRAG global search on global questions at more than 700 times lower query cost, and that with a budget of 500 relevance tests (4% of the query cost of GraphRAG global search) it significantly outperformed every compared method on both local and global questions. Their later BenchmarkQED study (June 2025, open source) reported that LazyGraphRAG won all 96 comparisons against same-model (GPT-4o) conditions, all but one of them statistically significant, including GraphRAG local, global and DRIFT search, vector RAG with 8k and 120k windows, LightRAG, RAPTOR and TREX. Against vector RAG with a 1M-token window (GPT-4.1) it had higher win rates, with one comparison not significant. These are LLM-judged win rates on comprehensiveness, diversity, empowerment and relevance, measured by the team that built the method. They are evidence for the design, not a promise about your corpus.

LightRAG (Guo et al., 2024) keeps LLM extraction at index time but skips communities and their reports. That makes inserts and deletes cheaper (Move 6), while each build still pays the extraction share of the bill above.

Why lazy works

The index-time bill is paid once per build and shared by every question; a query-time bill is paid per question. Many questions over a stable corpus favor precomputed summaries. Few questions over a large or fast-changing corpus favor spending at query time, only on what the question touches. The deciding numbers are questions per build and builds per month.

Your turn: change one requirement. The product team asks only a few global questions. Using the numbers above, compare GraphRAG (the USD 37.77 build plus 50 map calls per question) with map-reduce over all 2,000 raw reports on every question, no graph at all: 2,200,000 tokens in 12,000-token batches, same per-call overhead. From how many global questions per build does the graph pay for itself? The corpus changes weekly, so you rebuild every week: how many global questions a month is that?

Check your answer

Raw map-reduce: ⌈2,200,000 / 12,000βŒ‰ = 184 map calls. Input 184 Γ— 13,000 + 10,000 = 2,402,000 tokens, output 184 Γ— 500 + 1,000 = 93,000 tokens, so 2.402 + 0.465 = USD 2.87 per question. GraphRAG costs USD 0.79 per question after the build. The build pays for itself after 37.77 / (2.87 βˆ’ 0.79) β‰ˆ 18.2, so from the 19th global question per build. Weekly builds are 52 / 12 β‰ˆ 4.33 a month, so that is about 18.2 Γ— 4.33 β‰ˆ 79 global questions a month. Below that, spend at query time. Two caveats: answer quality is not identical (Edge et al. found community reports slightly better than map-reduce over source text), and fact questions should not go through either path.

Move 6: Keep the graph in sync

Symptom: a postmortem was corrected last week, and the assistant still reports its old root cause.

A vector index replaces a document by upserting its new chunks and then deleting the document's leftover chunks by document id (Data Pipeline & Indexing). A graph has more derived layers: the edges the document contributed, entity descriptions merged across documents, community membership, community reports, and the embeddings of all of those. Provenance is what makes it tractable. Every edge knows who said so, so you can take one document back without touching what other documents also support.

INC-121's postmortem is revised: the root cause was the upgrade itself, not an expired certificate.

def remove_source(g, source_id):
    """Take back what one document contributed; keep what other sources also support."""
    touched = set()
    for s, t, d in list(g.edges(data=True)):
        if source_id in d["sources"]:
            d["sources"].discard(source_id)
            touched |= {s, t}
            if not d["sources"]:                  # no other source supports this edge
                g.remove_edge(s, t)
    g.remove_nodes_from([n for n in touched if g.degree(n) == 0])
    return touched

def add_source(g, source_id, triples):
    for s, rel, t in triples:
        s, t = canon(s), canon(t)
        if g.has_edge(s, t):
            g.edges[s, t]["sources"].add(source_id)
        else:
            g.add_edge(s, t, rel=rel, sources={source_id})
    return {canon(x) for s, _, t in triples for x in (s, t)}

old = [nodes for (level, _), nodes in sorted(members.items()) if level == 0]

# INC-121's postmortem is revised: the root cause was the upgrade itself, not expiry.
touched = remove_source(g, "INC-121")
touched |= add_source(g, "INC-121", [("INC-121", "affected", "notification-service"),
                                     ("INC-121", "involved", "certmanager"),
                                     ("INC-121", "failure", "bad upgrade")])
print("touched nodes:", sorted(touched))
print("old communities touched:", [sorted(n for n in c if n.startswith("INC-")) for c in old if c & touched])

new = [nodes for (level, _), nodes in sorted(communities(g).items()) if level == 0]
for c in new:
    if c not in old:                                  # compare by member set, not by id
        was = max(old, key=lambda o: len(o & c))      # the old community it overlaps most
        print("changed:", sorted(n for n in c if n.startswith("INC-")),
              "gained", sorted(c - was), "lost", sorted(was - c))

Predict: the edit touches the certificate community and the notification-service community. Which communities change when you re-cluster?

Check your answer
touched nodes: ['INC-121', 'bad upgrade', 'cert-manager', 'expired certificate', 'notification-service']
old communities touched: [['INC-101', 'INC-112', 'INC-121'], ['INC-115']]
changed: ['INC-107', 'INC-124'] gained ['Payments', 'checkout-api'] lost ['Identity', 'auth-service']
changed: ['INC-101', 'INC-112', 'INC-121'] gained ['Identity', 'auth-service', 'bad upgrade'] lost ['Payments', 'checkout-api']

The certificate community changed, as expected: it gained the new bad upgrade node. It also traded checkout-api and Payments for auth-service and Identity, and the connection-pool community took the other side of the trade, although the revised document touches neither of those services. INC-101 hit both services, so the two communities compete for them, and a small change elsewhere tips the balance. The notification-service community did not change. "expired certificate" survived, because INC-101 and INC-112 still support it.

The demo retracts before it adds, which is fine on an in-memory copy. On a live graph store, apply both steps in one transaction, so no query sees INC-121 with its edges gone.

Why it works, and where it stops

Provenance makes the edge update exact: remove this source, keep every edge someone else still supports. Communities are different. Leiden optimizes over the whole graph, so one edit can move nodes in communities the edit never touched, even with a fixed seed, and community ids are renumbered. Three rules follow:

  • Compare communities by member set, not by id, and regenerate reports only where membership, or a member's description, changed. Here that is two reports out of six. The "touched" list would also have picked two, but only one of them is right: it flags the notification-service community, which did not change, and misses the connection-pool community, which did.
  • Pin the clustering library. Under graspologic-native 1.3.1 the same edit changes five of the seven new level-0 communities. The partition depends on the whole graph, the library version and the seed, so treat a library upgrade like a full re-cluster.
  • Decide how often to re-cluster. Between re-clusterings, new entities need a home. GraphRAG's update clusters the new documents' part of the graph on its own and appends those communities; you can also attach each new entity to the community of its strongest neighbor. Re-cluster on a schedule, and regenerate the reports that changed.

What the tools do today (as of September 2026, read in their source):

  • graphrag 3.2.0: graphrag update indexes the documents whose title is new (for text files, the file name). It merges their entities into the old ones by title, re-summarizes the merged descriptions, and appends the new documents' communities and reports next to the old ones instead of re-clustering the whole graph. A document edited in place under the same name is not re-processed. Deleted documents are detected but not removed. For corrections and deletions, plan full rebuilds (Move 5 prices them) or run your own provenance-based pipeline like the one above.
  • LightRAG 1.5.7: adelete_by_doc_id removes a document's chunks and graph elements, and rebuilds partly affected entities and relationships from cached extractions. There are no communities to re-cluster.
  • LazyGraphRAG-style indexes use no LLM at index time, so a rebuild costs about what re-embedding costs.

⚠️ Use a document id that is stable across edits, and never put a version or content hash in it. Otherwise an edited document looks new, and the old version's edges stay behind next to the new ones. Freshness in general, including deletions that must propagate to caches and backups, is covered in Data Freshness & Lifecycle.

Measure it. Track three numbers:

  • Index lag: time from a source change to the graph change.
  • Orphan edges: edges whose every source has been deleted. This should always be 0, and a nightly check can prove it.
  • Spot checks: every night, re-extract 20 recently edited documents and compare the result with the edges the graph holds for them.

Prove it helped, or don't build it

A graph is a second index with its own extraction errors, resolution errors, bill and sync job. Skip it when:

  • The questions are single facts or passages. Han et al. (2025) found RAG better on single-hop, detail-oriented questions, and GraphRAG-Bench (Xiang et al., 2025) found vanilla RAG comparable to or better than graph methods on simple fact retrieval.
  • The relations already live in a database. Query it directly (Vectorless RAG). Import them into a graph only when you must join them with relations extracted from text.
  • Multi-hop questions are rare and unpredictable. An agent loop pays per question, and only for the questions that need it.
  • The corpus fits the context window and global questions are rare. Long context or map-reduce over all chunks has nothing to extract, resolve or sync (RAG Architecture & Implementation).
  • You cannot resolve your entities, or the corpus changes hourly with no sync plan. Fix those first.

Where graphs earned their keep in published comparisons: multi-hop reasoning and summarization-style questions (Han et al. on HotpotQA and MultiHop-RAG; GraphRAG-Bench on complex reasoning and contextual summarization). The costs showed up too: Han et al. report substantially higher construction cost, and GraphRAG-Bench's token table (its Figure 8) lists about 331,000 tokens per question on average for GraphRAG global search and about 39,000 for local search, against about 900 for vanilla RAG. The paper's text says global search reaches "up to" 40,000 tokens, which matches the local figure instead. The table is in line with Move 5's arithmetic, where one global question over 500 reports reads about 660,000 input tokens. Han et al. also found that routing each question to RAG or GraphRAG by type, or combining the evidence of both, improved results. That is the "start classic, then route the exceptions" advice of RAG Architecture & Implementation, applied to graphs.

The evaluation plan, per question shape:

Shape Metric Baseline to beat
Fact Answer accuracy; retrieval recall@k of the gold chunk The classic pipeline: the graph route must not make these worse
Relational list Set precision and recall against a gold list; share of answer entities present in the graph Agent loop or classic pipeline on the same questions
Global Theme recall on a hand-built theme list; pairwise judging in both orders Classic pipeline, long context or map-reduce over chunks
All Cost per 1,000 questions, index cost per build, p95 latency, index lag Your current system

Report every number per shape and weighted by traffic. A single average hides a graph that helps the 10% of questions that need it and slows down the other 90%. The metrics themselves are covered in Evaluation & Quality Metrics and Retrieval Metrics.

Final round: no label on the problem

Real traces do not say which move they need. Read the symptom, then pick.

Challenge 1: the Kubernetes answer

A local-search assistant over 40,000 engineering documents answers "What depends on payments-api?" with a list that includes the CI system, the office VPN and a data-science notebook. The trace shows the expansion went through the node Kubernetes, which has 1,900 edges.

Check your answer

Move 4: a hub. Expanding through Kubernetes reaches everything that runs on the cluster. Exclude generic infrastructure types from expansion, or down-weight edges through nodes above a degree cap. For this daily question, write a typed path over depends_on edges only. Then measure set precision on 30 labeled dependency questions before and after.

Challenge 2: the zombie incident

A duplicate postmortem, INC-240, was deleted from the wiki two weeks ago. It is still counted in the monthly theme report. The team runs graphrag update (3.2.0) every night.

Check your answer

Move 6. graphrag update adds documents with new titles; it detects deleted documents but does not remove them, so INC-240's entities, edges and communities are still in the index. Rebuild from the current corpus, or move to a provenance-based pipeline that deletes a document's edges when the source disappears. Add the orphan-edge check (edges whose every source is gone) to the nightly job, with an alert above 0.

Challenge 3: the expensive "who owns it?"

After launch, every question goes to GraphRAG global search at level 3, including "Who owns search-api?". Cost per 1,000 questions is 60Γ— the old bot's, and owners' names come back buried in paragraphs about themes.

Check your answer

Route by shape ("Before any graph"). A fact lookup belongs to the classic pipeline or a catalog lookup, not a map-reduce over hundreds of reports. Send only global questions to global search, and try a coarser level (a lower number, so fewer reports) or dynamic community selection there (Move 3). Label 100 real questions by shape, then measure accuracy and cost per 1,000 questions per shape before and after routing.

Challenge 4: the slow daily report

Every morning, an agent loop answers "Which customers were hit by yesterday's incidents?" for the support team. It takes 4 model calls and 9 seconds, and on busy days it lists only the customers named in the first few reports it read.

Check your answer

Move 4 (and the Agentic RAG advice): the same hops every day, and the answer must be complete. Extract incident β†’ affected β†’ customer relations at index time, link customers to CRM ids (Move 2), and answer with one typed traversal plus one generation call. Measure set recall against the support team's own list for two weeks, and track cost per 1,000 questions against the loop.

Cheat sheet: symptom β†’ move β†’ cost

Symptom Move What it costs Measure
Relations exist only in prose Extract entities and relationships with an LLM; import tables as relations One or two LLM calls per chunk; extraction errors Triple precision and recall on labeled chunks; answer-entity coverage
A traversal misses spelling variants, or one node mixes two things Normalization keys, catalog linking, reviewed merges, reversible mappings Review effort on candidate pairs Pairwise merge precision and recall; degree jumps after rebuilds
Global questions answered from a sample Communities, a report per community, map-reduce Report-writing calls at index time; many map calls per question Theme recall; pairwise judging in both orders
Relational questions asked daily, answers must be complete Typed traversal from linked entities; generic neighborhoods only for the unexpected Graph building and upkeep Set precision and recall; traversal latency
The index bill blows the budget Move LLM work to query time (LazyGraphRAG-style) LLM spend on every question Index cost per build; cost per 1,000 questions; break-even questions per build
Inserts, deletes and re-clustering are too slow or costly Drop communities (LightRAG-style) Still pays for extraction; no community summaries for global questions Update cost and time per changed document; answer quality on global questions
Answers cite a corrected or deleted document Provenance-based delete and re-extract; re-cluster on a schedule; compare communities by members A sync pipeline; periodic rebuilds Index lag; orphan edges; nightly spot checks
Fact questions got slower and pricier Route by question shape A router to maintain Accuracy and cost per shape, weighted by traffic

Before moving on, take one question your system answers badly and write down:

  • its shape, from the table in "Before any graph";
  • the entity types and relations a graph would need to answer it;
  • where those relations come from: text, which needs extraction, or a table, which you import;
  • the index cost per build and the builds per month;
  • the labeled questions that would prove the graph beats your current pipeline for that shape.

How would you measure it on your own data? Collect 100–200 real questions and label each one's shape. For relational questions, label the gold answer set; for global ones, have a person list the themes in a sample. Run the current pipeline and the graph route side by side and compare, per shape: accuracy or set recall, theme recall, cost per 1,000 questions, p95 latency, plus index cost and lag. Ship the graph only for the shapes where it wins by more than it costs.

Next: Graph RAG with Neo4J builds this in a graph database with Cypher, vector indexes and text-to-Cypher. Agentic RAG Systems covers the hops you did not predict, Vectorless RAG covers relations that already live in databases, and Evaluation & Quality Metrics helps you prove any of it helped.