Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An AI platform has 10,000 incident reports, runbooks, and trace summaries. An on-call engineer asks: "Why did inference-api breach the latency service-level objective (SLO) during release 2026.06.14?" Ordinary retrieval can find the write-up about that service.
Then an analyst asks: "What are the top three recurring reasons model-serving incidents breach latency SLOs?" A small top-k result set can look convincing while covering only a few incidents. Similarity doesn't by itself give you theme coverage over the whole corpus.
The previous chapter made retrieval-augmented generation (RAG) adaptive: rewrite the query, generate hypothetical evidence, critique retrieved context, and retry when evidence is weak. GraphRAG changes the representation layer. It indexes relationship structure and summaries alongside text so a system can retrieve connected evidence or corpus-level themes, at extra extraction and query cost.
The engineering question is when that tradeoff complements vector search and when it adds work you don't need.
Microsoft's GraphRAG architecture builds a graph index of entities and relationships, pregenerates hierarchical community reports, and embeds those artifacts for retrieval [1][2][3]. The same index supplies structured artifacts for entity-focused local context.
In the original paper's global-sensemaking evaluation on podcast and news corpora in the roughly 1 million token range, GraphRAG beat a vector RAG baseline on LLM-judge comprehensiveness (about 72-83% win rate) and diversity (about 62-82% win rate) [1]. That's a result for that query class and those datasets, not a promise that every product question wants a graph.
A broader roadmap by Pan et al. places this work in the wider effort to combine LLMs with structured knowledge graphs [4].

Vector search ranks chunks that resemble the question. A graph index adds typed entity relationships and summary layers for cases where evidence spans multiple incidents. Standard GraphRAG local search builds ranked mixed context from graph and text artifacts. If your product needs an explicit path walk, you have to implement that policy and test it. The graph isn't a free multi-hop oracle.
Why small top-k retrieval can be insufficient
The global query problem
Keep those 10,000 incident reports, runbooks, traces, and postmortems as the running corpus. First ask what shape of evidence each question needs.
Local queries (vector search handles well)
For a specific question about one issue, vector search usually earns its keep:
"What is the timeout for the embeddings API?" Retrieval path: top-5 similar documents, then answer from local context. This works because the needed evidence is concentrated in a few chunks.
The question is self-contained, so a few chunks about the API contract and runbook contain everything the system needs.
Global queries (small top-k retrieval can miss coverage)
For a question that must synthesize across the entire corpus, small top-k vector retrieval has a different problem:
"What are the top 3 recurring reasons model-serving incidents breach the latency SLO?" Failure mode: no single chunk contains a cross-corpus summary, and a top-5 result set is unlikely to represent themes across 10,000 documents. The resulting answer can be incomplete or misleading.
Before raising k, predict what changes: coverage may grow, but top-k still has no mechanism to count or summarize every theme in 10,000 documents.
Why similarity-only retrieval struggles
Vector search retrieves the most similar chunks, not the most representative set. For "top three recurring SLO reasons," no single incident write-up is the answer. The index has to turn many local mentions into structures that can be grouped and summarized. GraphRAG builds those broader units in four offline steps:
- Entity extraction across the entire corpus
- Relationship modeling between entities
- Community detection to find natural clusters of related information
- Hierarchical summarization at different levels of abstraction
| Feature | Vector Search (Standard RAG) | GraphRAG |
|---|---|---|
| Available artifacts | Ranked text chunks | Text units, entities, relationships, community reports |
| Natural starting point | Specific evidence lookup ("What is X?") | Entity-focused or thematic analysis ("What are the trends?") |
| Context construction | Similarity-ranked chunks | Ranked graph/text context or report map-reduce |
| Indexing work | Embedding and optional sparse index | Additional extraction, clustering, reports, and embeddings |
| Query work | Retrieval plus generation | Depends on search mode; global search adds map-reduce calls |
A concrete multi-hop example
Before naming graph pieces, predict what each route can see. Compare the two approaches on this tiny incident corpus:
Incident 1842: "inference-api release 2026.06.14 ran in us-east-1 and hit p95 latency after redis-cache began evicting hot keys." Incident 2031: "redis-cache in us-east-1 still uses the old maxmemory policy. The config migration is blocked by a compatibility test." Incident 3155: "inference-api breached the inference latency SLO twelve minutes after deploy. Rollback restored latency."
The decision question is:
"Why did inference-api breach the latency SLO, and is this likely to happen again?"
Illustrative vector-only result: Suppose retrieval finds Incident 3155 (mentions "inference-api" and "latency SLO") and Incident 1842 (mentions "inference-api" and "redis-cache"), but not Incident 2031 because it doesn't mention the service name.
That answer can identify the rollout and cache eviction while missing the unresolved maxmemory policy.
Now add graph structure. Suppose extraction produced these entities and relationships, with source-chunk provenance:
(inference-api) --[ran_in]--> (us-east-1)(inference-api) --[depends_on]--> (redis-cache)(inference-api) --[triggered_by]--> (release 2026.06.14)(redis-cache) --[located_in]--> (us-east-1)(redis-cache) --[has_issue]--> (old maxmemory policy)(old maxmemory policy) --[blocked_by]--> (compatibility test)
An evidence-backed traversal can start at inference-api, follow depends_on to redis-cache, and retrieve evidence about both the eviction event and the unresolved maxmemory policy.
It may then answer: "inference-api breached the latency SLO after a rollout, and source material ties the incident to cache evictions plus an unresolved cache policy." It shouldn't predict another breach unless source evidence supports that claim.
This is relationship-heavy retrieval: useful evidence is connected through entities that weren't all present in the query.
Microsoft's standard local-search dataflow ranks graph and text artifacts into a context window; it isn't a promise that every answer executes a deterministic path. If explicit multi-hop paths matter, preserve edge provenance and test the traversal policy.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Edge:
5 source: str
6 relation: str
7 target: str
8 source_chunks: tuple[str, ...]
9
10def cited_path(edges: list[Edge]) -> tuple[bool, list[str]]:
11 if any(not edge.source_chunks for edge in edges):
12 return False, []
13 citations = sorted({chunk for edge in edges for chunk in edge.source_chunks})
14 return True, citations
15
16path = [
17 Edge("inference-api", "depends_on", "redis-cache", ("incident-1842",)),
18 Edge("redis-cache", "has_issue", "old maxmemory policy", ("incident-2031",)),
19]
20unsupported_path = path + [
21 Edge("old maxmemory policy", "will_cause", "future SLO breach", ()),
22]
23
24print("supported path:", cited_path(path))
25print("unsupported prediction:", cited_path(unsupported_path))1supported path: (True, ['incident-1842', 'incident-2031'])
2unsupported prediction: (False, [])Entities, edges, and provenance
A knowledge graph represents information as a network of entities (nodes) and relationships (edges). A vector index stores chunks as isolated embeddings.
The graph's extra value is the connection between facts.
The basic unit is the triple: (Subject) - [Predicate] -> (Object). Edge direction should follow the meaning of the relation, not a generic left-to-right habit.
From our running example:
(inference-api) --[depends_on]--> (redis-cache)(redis-cache) --[has_issue]--> (old maxmemory policy)(old maxmemory policy) --[blocked_by]--> (compatibility test)
A live index is usually a property graph rather than bare triples. Nodes and edges carry metadata such as type, description, source_chunk_ids, timestamps, or confidence.
Those fields are what you filter, cite, and rank at query time.
Give every entity a stable identity such as (canonical_title, canonical_type), not a title-only key. Keep the source and target type on each relationship, resolve both endpoints through that key, and merge repeated edges only after resolution. If a malformed edge omits a type, accept the name only when it maps to one candidate; reject ambiguous or unknown references so a convenient first match can't connect the wrong nodes.
This graph structure mirrors how an on-call engineer traces an incident. When a query mentions redis-cache, typed relationships and provenance can separate service dependencies, region-specific incidents, config drift, and release side effects.
Service-name similarity alone can't do that.
Graph traversal isn't learned message passing
Following inference-api -> redis-cache -> old maxmemory policy traverses existing relationships and returns their cited source chunks. That lookup doesn't train a model or update a node embedding.
A graph neural network (GNN) adds a separate learned model that combines node features, edges, and usually neighboring information to predict properties such as incident impact or service relevance.[5]
A message-passing neural network (MPNN) makes that distinction concrete: each layer computes messages from neighboring node features, aggregates them, and updates the destination node's learned representation.[6]
Suppose redis-cache and another dependency contribute scalar messages 0.4 and 0.8; mean aggregation produces 0.6, which becomes an input to the next inference-api update. That learned update can help rank candidates, but it doesn't establish source provenance or prove a predicted relationship is true.
Standard GraphRAG can extract entities, follow stored edges, cluster communities, and prepare reports without training either a GNN or an MPNN.
What makes a knowledge graph useful beyond storing triples?
Answer
The graph connects entities through typed relationships and provenance. Retrieval can then follow meaningful paths, filter by metadata, and explain which source chunks support the path.
The GraphRAG pipeline
Phase 1: Graph construction (indexing)
The indexing phase turns raw document chunks into a structured graph index.
Microsoft's default dataflow extracts entities and relationships, optionally extracts claims (covariates), uses entity resolution to identify descriptions of the same object, builds hierarchical communities, generates community reports, and embeds the artifacts used at query time [2][3]:

Step 1: Entity & relationship extraction

Start with one text unit. An extractor names entities and the relationships between them. Standard GraphRAG does this with an LLM. FastGraphRAG replaces that step with cheaper NLP heuristics so indexing costs less [7]. A simple extraction prompt looks like this:
1EXTRACTION_PROMPT = """
2Extract all entities and relationships from the following text.
3
4Entities should include: services, datastores, regions, releases, incidents, and config issues.
5Relationships should include: ran_in, depends_on, triggered_by, located_in, has_issue, blocked_by.
6
7Text: {chunk_text}
8
9Output as JSON:
10{
11 "entities": [
12 {"name": "...", "type": "...", "description": "..."}
13 ],
14 "relationships": [
15 {
16 "source": "...",
17 "source_type": "...",
18 "target": "...",
19 "target_type": "...",
20 "type": "...",
21 "description": "...",
22 "strength": 1.0
23 }
24 ]
25}
26"""That prompt isn't a parser. Use structured output (JSON schema or tool calling) so a missing brace doesn't kill a 10,000-chunk index job. Keep the contract the same either way: typed entities, typed edge endpoints, and source chunk IDs. An edge that says only source: "gateway" is incomplete when both a Service and a Datastore use that title.
The runnable version below uses a deterministic extractor so you can test that contract without calling a model. When you wire a real extractor, replace extract_from_chunk() with a structured-output call and keep the same Entity and Relationship objects.
After per-chunk extraction, GraphRAG merges entities that share a title and type, concatenates their descriptions, then asks a model to compress that list into one description per entity and relationship [3]. The merge below does the bookkeeping; the LLM summary is the step you'd swap in later. It uses a typed canonical key, carries endpoint types on every edge, and fails closed when an untyped name matches more than one entity.
1from collections import defaultdict
2from dataclasses import dataclass, field
3
4@dataclass
5class Entity:
6 name: str
7 type: str
8 description: str
9 source_chunk_ids: list[int] = field(default_factory=list)
10 aliases: list[str] = field(default_factory=list)
11
12@dataclass
13class Relationship:
14 source: str
15 source_type: str
16 target: str
17 target_type: str
18 type: str
19 description: str
20 confidence: float
21 source_chunk_ids: list[int] = field(default_factory=list)
22
23def canonicalize(value: str) -> str:
24 return "".join(character for character in value.casefold() if character.isalnum())
25
26def entity_key(name: str, entity_type: str) -> tuple[str, str]:
27 """Identify an entity by canonical title and canonical type."""
28 return canonicalize(name), canonicalize(entity_type)
29
30def extract_from_chunk(chunk_id: int, chunk: str) -> tuple[list[Entity], list[Relationship]]:
31 entities: list[Entity] = []
32 relationships: list[Relationship] = []
33
34 if "inference-api" in chunk:
35 entities.append(Entity("inference-api", "Service", "Inference API service", [chunk_id]))
36 if "redis-cache" in chunk:
37 entities.append(Entity("Redis Cache", "Datastore", "Shared cache dependency", [chunk_id]))
38 if "us-east-1" in chunk:
39 entities.append(Entity("US East 1", "Region", "Production region", [chunk_id]))
40 if "release 2026.06.14" in chunk:
41 entities.append(Entity("Release 2026.06.14", "Release", "Inference API deployment", [chunk_id]))
42 if "old maxmemory policy" in chunk:
43 entities.append(Entity("Old Maxmemory Policy", "ConfigIssue", "Cache eviction policy risk", [chunk_id]))
44 if "compatibility test" in chunk:
45 entities.append(Entity("Compatibility Test", "TestGate", "Migration-blocking test", [chunk_id]))
46
47 entity_keys = {entity_key(entity.name, entity.type) for entity in entities}
48 if {entity_key("inference-api", "Service"), entity_key("Redis Cache", "Datastore")} <= entity_keys:
49 relationships.append(
50 Relationship(
51 source="inference-api", source_type="Service",
52 target="Redis Cache", target_type="Datastore",
53 type="depends_on", description="Service uses cache",
54 confidence=1.0, source_chunk_ids=[chunk_id],
55 )
56 )
57 if {entity_key("inference-api", "Service"), entity_key("US East 1", "Region")} <= entity_keys:
58 relationships.append(
59 Relationship(
60 source="inference-api", source_type="Service",
61 target="US East 1", target_type="Region",
62 type="ran_in", description="Service ran in region",
63 confidence=0.9, source_chunk_ids=[chunk_id],
64 )
65 )
66 if {entity_key("inference-api", "Service"), entity_key("Release 2026.06.14", "Release")} <= entity_keys:
67 relationships.append(
68 Relationship(
69 source="inference-api", source_type="Service",
70 target="Release 2026.06.14", target_type="Release",
71 type="triggered_by", description="Latency followed release",
72 confidence=0.8, source_chunk_ids=[chunk_id],
73 )
74 )
75 if {entity_key("Redis Cache", "Datastore"), entity_key("US East 1", "Region")} <= entity_keys:
76 relationships.append(
77 Relationship(
78 source="Redis Cache", source_type="Datastore",
79 target="US East 1", target_type="Region",
80 type="located_in", description="Cache region",
81 confidence=0.8, source_chunk_ids=[chunk_id],
82 )
83 )
84 if {entity_key("Redis Cache", "Datastore"), entity_key("Old Maxmemory Policy", "ConfigIssue")} <= entity_keys:
85 relationships.append(
86 Relationship(
87 source="Redis Cache", source_type="Datastore",
88 target="Old Maxmemory Policy", target_type="ConfigIssue",
89 type="has_issue", description="Eviction policy risk",
90 confidence=0.8, source_chunk_ids=[chunk_id],
91 )
92 )
93 if {entity_key("Old Maxmemory Policy", "ConfigIssue"), entity_key("Compatibility Test", "TestGate")} <= entity_keys:
94 relationships.append(
95 Relationship(
96 source="Old Maxmemory Policy", source_type="ConfigIssue",
97 target="Compatibility Test", target_type="TestGate",
98 type="blocked_by", description="Migration gate",
99 confidence=0.7, source_chunk_ids=[chunk_id],
100 )
101 )
102
103 return entities, relationships
104
105def deduplicate_entities(entities: list[Entity]) -> list[Entity]:
106 merged: dict[tuple[str, str], Entity] = {}
107 for entity in entities:
108 key = entity_key(entity.name, entity.type)
109 if key not in merged:
110 merged[key] = Entity(
111 name=entity.name,
112 type=entity.type,
113 description=entity.description,
114 source_chunk_ids=sorted(set(entity.source_chunk_ids)),
115 aliases=list(entity.aliases),
116 )
117 continue
118 existing = merged[key]
119 existing.source_chunk_ids = sorted(set(existing.source_chunk_ids + entity.source_chunk_ids))
120 if entity.name != existing.name and entity.name not in existing.aliases:
121 existing.aliases.append(entity.name)
122 if entity.description not in existing.description:
123 existing.description = f"{existing.description}; {entity.description}"
124 return [merged[key] for key in sorted(merged)]
125
126def index_entities(
127 entities: list[Entity],
128) -> tuple[dict[tuple[str, str], Entity], dict[str, list[Entity]]]:
129 by_key = {entity_key(entity.name, entity.type): entity for entity in entities}
130 by_name: dict[str, dict[tuple[str, str], Entity]] = defaultdict(dict)
131 for entity in entities:
132 key = entity_key(entity.name, entity.type)
133 by_name[canonicalize(entity.name)][key] = entity
134 for alias in entity.aliases:
135 by_name[canonicalize(alias)][key] = entity
136 return by_key, {name: list(candidates.values()) for name, candidates in by_name.items()}
137
138def resolve_entity(
139 name: str,
140 entity_type: str | None,
141 by_key: dict[tuple[str, str], Entity],
142 by_name: dict[str, list[Entity]],
143) -> Entity:
144 """Resolve a typed reference, or reject an ambiguous untyped reference."""
145 if entity_type:
146 entity = by_key.get(entity_key(name, entity_type))
147 if entity is None:
148 raise ValueError(f"Unknown typed entity reference: {name!r} ({entity_type})")
149 return entity
150
151 candidates = by_name.get(canonicalize(name), [])
152 if not candidates:
153 raise ValueError(f"Unknown entity reference: {name!r}")
154 if len(candidates) > 1:
155 candidate_types = ", ".join(sorted({candidate.type for candidate in candidates}))
156 raise ValueError(
157 f"Ambiguous entity reference {name!r}; endpoint type required ({candidate_types})"
158 )
159 return candidates[0]
160
161def resolve_relationships(
162 relationships: list[Relationship],
163 entities: list[Entity],
164) -> list[Relationship]:
165 """Resolve both endpoints by typed identity and merge duplicate edges."""
166 by_key, by_name = index_entities(entities)
167 merged: dict[tuple[tuple[str, str], tuple[str, str], str], Relationship] = {}
168 for relationship in relationships:
169 source = resolve_entity(relationship.source, relationship.source_type, by_key, by_name)
170 target = resolve_entity(relationship.target, relationship.target_type, by_key, by_name)
171 edge_key = (
172 entity_key(source.name, source.type),
173 entity_key(target.name, target.type),
174 canonicalize(relationship.type),
175 )
176 resolved = Relationship(
177 source=source.name,
178 source_type=source.type,
179 target=target.name,
180 target_type=target.type,
181 type=relationship.type,
182 description=relationship.description,
183 confidence=relationship.confidence,
184 source_chunk_ids=sorted(set(relationship.source_chunk_ids)),
185 )
186 if edge_key not in merged:
187 merged[edge_key] = resolved
188 continue
189 existing = merged[edge_key]
190 existing.source_chunk_ids = sorted(
191 set(existing.source_chunk_ids + resolved.source_chunk_ids)
192 )
193 existing.confidence = max(existing.confidence, resolved.confidence)
194 if resolved.description not in existing.description:
195 existing.description = f"{existing.description}; {resolved.description}"
196 return [merged[key] for key in sorted(merged)]
197
198def extract_graph_elements(chunks: list[str]) -> tuple[list[Entity], list[Relationship]]:
199 all_entities: list[Entity] = []
200 all_relationships: list[Relationship] = []
201
202 for chunk_id, chunk in enumerate(chunks):
203 entities, relationships = extract_from_chunk(chunk_id, chunk)
204 all_entities.extend(entities)
205 all_relationships.extend(relationships)
206
207 deduped_entities = deduplicate_entities(all_entities)
208 return deduped_entities, resolve_relationships(all_relationships, deduped_entities)
209
210chunks = [
211 "inference-api hit p95 latency in us-east-1 because redis-cache started evicting hot keys during release 2026.06.14.",
212 "redis-cache in us-east-1 still uses the old maxmemory policy. Config migration is blocked by a compatibility test.",
213]
214
215entities, relationships = extract_graph_elements(chunks)
216relationship_types = {rel.type for rel in relationships}
217
218print(
219 "entity identities:",
220 ", ".join(sorted(f"{entity.type}:{entity.name}" for entity in entities)),
221)
222print("relationships:", ", ".join(sorted(relationship_types)))
223print(
224 "resolved edges:",
225 ", ".join(
226 sorted(f"{rel.source} -[{rel.type}]-> {rel.target}" for rel in relationships)
227 ),
228)
229
230ambiguous_entities = deduplicate_entities(
231 [
232 Entity("gateway", "Service", "API gateway", [8]),
233 Entity("gateway", "Datastore", "Gateway state store", [9]),
234 ]
235)
236try:
237 resolve_relationships(
238 [
239 Relationship(
240 source="gateway",
241 source_type="",
242 target="gateway",
243 target_type="Service",
244 type="depends_on",
245 description="Untrusted untyped source",
246 confidence=0.5,
247 source_chunk_ids=[10],
248 )
249 ],
250 ambiguous_entities,
251 )
252except ValueError as error:
253 print("ambiguous endpoint rejected:", error)1entity identities: ConfigIssue:Old Maxmemory Policy, Datastore:Redis Cache, Region:US East 1, Release:Release 2026.06.14, Service:inference-api, TestGate:Compatibility Test
2relationships: blocked_by, depends_on, has_issue, located_in, ran_in, triggered_by
3resolved edges: Old Maxmemory Policy -[blocked_by]-> Compatibility Test, Redis Cache -[has_issue]-> Old Maxmemory Policy, Redis Cache -[located_in]-> US East 1, inference-api -[depends_on]-> Redis Cache, inference-api -[ran_in]-> US East 1, inference-api -[triggered_by]-> Release 2026.06.14
4ambiguous endpoint rejected: Ambiguous entity reference 'gateway'; endpoint type required (Datastore, Service)Why should extracted relationships store source chunk IDs and endpoint types?
Answer
Graph paths still need evidence and unambiguous endpoints. Source chunk IDs let the answer cite the text that produced each edge, while endpoint types let the resolver distinguish same-titled entities such as a Service and a Datastore. If an edge omits a type and the title has multiple candidates, reject the edge instead of guessing.
Step 2: Build the knowledge graph
Once entities and relationships are extracted and merged, store them as a directed property graph. Nodes hold type and description; edges hold relation type, weight, and source chunk IDs.
Later retrieval can follow relationships that never appeared together in one chunk.
The next function takes those lists and builds a small adjacency structure. It keeps typed canonical IDs internally and uses display names only when printing a result. Production GraphRAG writes the same facts to tables (entities, relationships, text units) rather than requiring a graph database [8]:
1from collections import defaultdict
2from dataclasses import dataclass, field
3
4@dataclass
5class Entity:
6 name: str
7 type: str
8 description: str
9 source_chunk_ids: list[int] = field(default_factory=list)
10
11@dataclass
12class Relationship:
13 source: str
14 source_type: str
15 target: str
16 target_type: str
17 type: str
18 description: str
19 confidence: float
20 source_chunk_ids: list[int] = field(default_factory=list)
21
22def canonicalize(value: str) -> str:
23 return "".join(character for character in value.casefold() if character.isalnum())
24
25def entity_key(name: str, entity_type: str) -> tuple[str, str]:
26 return canonicalize(name), canonicalize(entity_type)
27
28def build_knowledge_graph(
29 entities: list[Entity],
30 relationships: list[Relationship],
31) -> dict:
32 """Store a directed property graph as nodes plus typed outbound edges."""
33 nodes = {
34 entity_key(entity.name, entity.type): {
35 "name": entity.name,
36 "type": entity.type,
37 "description": entity.description,
38 "source_chunk_ids": entity.source_chunk_ids,
39 }
40 for entity in entities
41 }
42 out_edges: dict[tuple[str, str], list[dict]] = defaultdict(list)
43 for rel in relationships:
44 source_key = entity_key(rel.source, rel.source_type)
45 target_key = entity_key(rel.target, rel.target_type)
46 if source_key not in nodes or target_key not in nodes:
47 raise ValueError(f"relationship references unknown entity: {rel.source} -> {rel.target}")
48 out_edges[source_key].append(
49 {
50 "target": target_key,
51 "type": rel.type,
52 "description": rel.description,
53 "weight": rel.confidence,
54 "source_chunk_ids": rel.source_chunk_ids,
55 }
56 )
57 return {"nodes": nodes, "out_edges": dict(out_edges)}
58
59entities = [
60 Entity("inference-api", "Service", "Inference API service", [0]),
61 Entity("Redis Cache", "Datastore", "Shared cache dependency", [0]),
62 Entity("US East 1", "Region", "Production region", [0, 1]),
63]
64relationships = [
65 Relationship(
66 "inference-api", "Service", "Redis Cache", "Datastore",
67 "depends_on", "Service uses cache", 1.0, [0]
68 ),
69 Relationship(
70 "inference-api", "Service", "US East 1", "Region",
71 "ran_in", "Service ran in region", 0.9, [0]
72 ),
73]
74
75graph = build_knowledge_graph(entities, relationships)
76edge_tuples = sorted(
77 (graph["nodes"][source]["name"], edge["type"], graph["nodes"][edge["target"]]["name"])
78 for source, edges in graph["out_edges"].items()
79 for edge in edges
80)
81depends_on = next(
82 edge["weight"]
83 for edge in graph["out_edges"][entity_key("inference-api", "Service")]
84 if edge["type"] == "depends_on"
85)
86
87print("nodes:", sorted(node["name"] for node in graph["nodes"].values()))
88print("edges:", edge_tuples)
89print("US East 1 node type:", graph["nodes"][entity_key("US East 1", "Region")]["type"])
90print("depends_on weight:", depends_on)1nodes: ['Redis Cache', 'US East 1', 'inference-api']
2edges: [('inference-api', 'depends_on', 'Redis Cache'), ('inference-api', 'ran_in', 'US East 1')]
3US East 1 node type: Region
4depends_on weight: 1.0Step 3: Community detection (Leiden algorithm)
Retrieval walks the directed property graph. Community detection usually clusters an undirected (often weighted) view of the same nodes: ignore edge type for clustering, then keep it for later context packing.
In this corpus, dense clusters become themes such as cache pressure versus eval flakiness. The groups come from connection density, not keyword matching.
The Leiden algorithm finds those clusters. Traag et al. prefer it to classic Louvain because Louvain can emit disconnected communities (nodes in the same group with no path between them). Leiden adds a refinement phase and guarantees connected communities [9]. That removes a structural defect before report generation.
Leiden still can't fix bad extraction or unsupported edges. Microsoft's GraphRAG pipeline applies hierarchical Leiden recursively until communities hit a size threshold, which gives it coarse and fine-grained views of the same corpus [3].
One quality function Leiden can optimize is modularity (). In plain terms: are there more edges inside each group than chance would predict in this extracted graph? A high means the clustering is strong on that graph. It doesn't prove the extractor captured a real topic.
For an unweighted, undirected graph:
is 1 if nodes and share an edge and 0 otherwise. and are node degrees. is the number of undirected edges. is 1 when both nodes sit in the same community. Put every node in one community and is identically 0, which is a useful sanity check.
What does high modularity mean in a GraphRAG entity graph?
Answer
Entities inside the same community connect more densely than random chance would predict in the extracted graph. Validate the entity and edge evidence before treating that cluster as a real topic.
Score two candidate partitions of the same undirected graph. Production GraphRAG runs hierarchical Leiden; scoring by hand shows what that algorithm is optimizing, using only the standard library.
1from collections import defaultdict
2
3def modularity(
4 nodes: list[str],
5 undirected_edges: list[tuple[str, str]],
6 community: dict[str, str],
7) -> float:
8 """Newman modularity Q for an unweighted undirected graph."""
9 adjacency: dict[str, set[str]] = defaultdict(set)
10 degree: dict[str, int] = {node: 0 for node in nodes}
11 for source, target in undirected_edges:
12 adjacency[source].add(target)
13 adjacency[target].add(source)
14 degree[source] += 1
15 degree[target] += 1
16 two_m = 2 * len(undirected_edges)
17 total = 0.0
18 for i in nodes:
19 for j in nodes:
20 if community[i] != community[j]:
21 continue
22 a_ij = 1.0 if j in adjacency[i] else 0.0
23 total += a_ij - (degree[i] * degree[j]) / two_m
24 return total / two_m
25
26nodes = [
27 "inference-api",
28 "redis-cache",
29 "us-east-1",
30 "old-maxmemory",
31 "compatibility-test",
32 "eval-harness",
33 "flaky-judge",
34]
35edges = [
36 ("inference-api", "redis-cache"),
37 ("inference-api", "us-east-1"),
38 ("redis-cache", "us-east-1"),
39 ("redis-cache", "old-maxmemory"),
40 ("old-maxmemory", "compatibility-test"),
41 ("eval-harness", "flaky-judge"),
42]
43one_bucket = {node: "all" for node in nodes}
44split = {
45 "inference-api": "cache",
46 "redis-cache": "cache",
47 "us-east-1": "cache",
48 "old-maxmemory": "cache",
49 "compatibility-test": "cache",
50 "eval-harness": "eval",
51 "flaky-judge": "eval",
52}
53
54print("one community Q:", round(modularity(nodes, edges, one_bucket), 3))
55print("cache vs eval Q:", round(modularity(nodes, edges, split), 3))1one community Q: 0.0
2cache vs eval Q: 0.278The clusters roll up in layers. Global search can start at a high report, then drill into the cache branch without paying for every eval leaf.

Step 4: Community summarization
The final indexing step compresses one community's entities, descriptions, and relationships into a report. Global search can map over that report later [1][3]:
1from dataclasses import dataclass
2from typing import Protocol
3
4@dataclass(frozen=True)
5class Entity:
6 name: str
7 description: str
8
9@dataclass(frozen=True)
10class Relationship:
11 source: str
12 type: str
13 target: str
14
15class CommunitySummarizer(Protocol):
16 def summarize(self, prompt: str) -> str: ...
17
18class FakeCommunitySummarizer:
19 def summarize(self, prompt: str) -> str:
20 return (
21 "Cache latency cluster: inference-api depends on redis-cache, "
22 "and cache evictions recur when the old maxmemory policy remains active."
23 )
24
25def summarize_community(
26 community_entities: list[Entity],
27 community_relationships: list[Relationship],
28 level: int,
29 summarizer: CommunitySummarizer,
30) -> str:
31 entity_lines = "\n".join(
32 f"- {entity.name}: {entity.description}" for entity in community_entities
33 )
34 relationship_lines = "\n".join(
35 f"- {rel.source} {rel.type} {rel.target}" for rel in community_relationships
36 )
37 prompt = (
38 f"Community level: {level}\n"
39 f"Entities:\n{entity_lines}\n"
40 f"Relationships:\n{relationship_lines}\n"
41 "Summarize the main operational pattern."
42 )
43 return summarizer.summarize(prompt)
44
45summary = summarize_community(
46 [Entity("Redis Cache", "Shared cache dependency")],
47 [Relationship("Redis Cache", "has_issue", "Old Maxmemory Policy")],
48 level=0,
49 summarizer=FakeCommunitySummarizer(),
50)
51
52print(summary)1Cache latency cluster: inference-api depends on redis-cache, and cache evictions recur when the old maxmemory policy remains active.Authorization continuity for graph artifacts
Community reports compress many text units into one artifact. If any unit in that community is confidential, a global-search answer that reads the report can leak facts the user isn't allowed to see, even when chunk-level vector search is correctly ACL-filtered.
Carry grants through the whole index, including derived artifacts beyond leaf chunks:
- Text units: every chunk keeps tenant + principal ACL metadata (same contract as enterprise RAG).
- Entities and edges: an edge is queryable only when the caller is allowed to see both endpoints' supporting evidence (or the edge fails closed).
- Community reports: never materialize a mixed-privilege community report for global search. Either:
- build reports only inside a single ACL equivalence class, or
- recompute a per-principal report view from allowed text units at query time, or
- drop the report and fall back to local search on authorized entities.
- Revocation: when a document's ACL shrinks, reindex affected edges and community reports. Tombstoning the leaf chunk alone isn't enough if a summary still quotes it.
Worked fail-closed sketch. Three text units land in one Leiden community:
| Text unit | ACL | Fact in unit |
|---|---|---|
tu-public-slo | all-staff | p95 latency SLO is 200 ms |
tu-security-key | security-only | production key rotation requires break-glass approval |
tu-public-runbook | all-staff | restart cache before scale-out |
A global community report over all three would compress the restricted rotation policy into staff-visible theme text. Fail-closed choices for a non-security principal:
| Choice | Report built from | Map-reduce admission |
|---|---|---|
| Equivalence-class reports | only all-staff units | admit public report; never the mixed report |
| Per-principal recompute | units the caller may read | build a view without tu-security-key |
| Drop report | none | fall back to local search on authorized entities only |
Don't map-reduce the mixed report and scrub afterward: the restricted fact is already in context.
The next lesson, RAG Security & Access Control, deepens prefilter vs post-filter vector search and operationalizes graph-artifact authorization. Graph modes need the same fail-closed habit: authz before map-reduce, not after the summary is already written into context.
Why can community reports leak data even when vector search is ACL-aware?
Answer
Reports summarize many chunks. If a community mixes public and restricted text units, global search can surface restricted facts through the summary without ever returning the restricted chunk as a retrieval hit.
Phase 2: Query processing (runtime)
At runtime, GraphRAG exposes Basic Search, Local Search, Global Search, and DRIFT Search. Local search builds mixed context for entity-centric questions, while global search synthesizes over community reports.
Basic Search is a text-unit vector baseline. DRIFT (Dynamic Reasoning and Inference with Flexible Traversal) starts from community reports, then uses local-search follow-ups [10][11][12].
Local search (specific questions)
Local search isn't just named-entity matching plus one-hop neighbors. In Microsoft's docs, it maps the query into semantically related entities first.
It then ranks a mixed context from connected entities, relationships, community reports, linked text units, and optionally covariates if claim extraction is on [10].
The runnable example below is a bounded neighborhood walk for intuition. For the incident question, predict that two hops from inference-api reach Redis Cache and Old Maxmemory Policy. The walk expands candidates; ranking and provenance still decide what enters context. It isn't a copy of GraphRAG's context builder or ranking policy:
1class EntityStore:
2 def __init__(self, entity_names: list[str]) -> None:
3 self.entity_names = entity_names
4
5 def similarity_search(self, query: str, k: int = 10) -> list[str]:
6 query_lower = query.lower()
7 matches = [
8 entity for entity in self.entity_names if entity.lower() in query_lower
9 ]
10 return matches[:k]
11
12def neighbors(graph: dict, node: str) -> set[str]:
13 names = {edge["target"] for edge in graph["out_edges"].get(node, [])}
14 for source, edges in graph["out_edges"].items():
15 for edge in edges:
16 if edge["target"] == node:
17 names.add(source)
18 return names
19
20def expand_entity_neighborhood(graph: dict, entity_names: list[str], hops: int = 1) -> set[str]:
21 nodes: set[str] = set(entity_names)
22 frontier: set[str] = set(entity_names)
23 for _ in range(hops):
24 next_frontier: set[str] = set()
25 for node in frontier:
26 next_frontier.update(neighbors(graph, node))
27 nodes.update(next_frontier)
28 frontier = next_frontier
29 return nodes
30
31def collect_relationships(graph: dict, nodes: set[str]) -> list[tuple[str, str, str]]:
32 return sorted(
33 (source, edge["type"], edge["target"])
34 for source, edges in graph["out_edges"].items()
35 if source in nodes
36 for edge in edges
37 if edge["target"] in nodes
38 )
39
40def local_search(
41 query: str,
42 entity_store: EntityStore,
43 graph: dict,
44 text_units: dict[str, list[str]],
45 community_reports: dict[str, list[str]],
46) -> str:
47 mapped_entities = entity_store.similarity_search(query, k=10)
48 neighborhood_entities = sorted(expand_entity_neighborhood(graph, mapped_entities, hops=2))
49 relationships = collect_relationships(graph, set(neighborhood_entities))
50 context = "\n".join(
51 [
52 f"Entities: {neighborhood_entities}",
53 f"Relationships: {relationships}",
54 f"Text units: { {entity: text_units.get(entity, []) for entity in neighborhood_entities} }",
55 f"Reports: { {entity: community_reports.get(entity, []) for entity in neighborhood_entities} }",
56 ]
57 )
58 return f"Answer using local GraphRAG context:\n{context}"
59
60graph = {
61 "out_edges": {
62 "inference-api": [{"target": "Redis Cache", "type": "depends_on"}],
63 "Redis Cache": [{"target": "Old Maxmemory Policy", "type": "has_issue"}],
64 }
65}
66
67answer = local_search(
68 "Why did inference-api breach the latency SLO?",
69 EntityStore(["inference-api", "Redis Cache", "Old Maxmemory Policy"]),
70 graph,
71 text_units={"inference-api": ["inference-api breached the latency SLO after release 2026.06.14."]},
72 community_reports={"Redis Cache": ["Cache eviction reports point to maxmemory policy risk."]},
73)
74
75print(answer)1Answer using local GraphRAG context:
2Entities: ['Old Maxmemory Policy', 'Redis Cache', 'inference-api']
3Relationships: [('Redis Cache', 'has_issue', 'Old Maxmemory Policy'), ('inference-api', 'depends_on', 'Redis Cache')]
4Text units: {'Old Maxmemory Policy': [], 'Redis Cache': [], 'inference-api': ['inference-api breached the latency SLO after release 2026.06.14.']}
5Reports: {'Old Maxmemory Policy': [], 'Redis Cache': ['Cache eviction reports point to maxmemory policy risk.'], 'inference-api': []}The actual context-builder problem includes a token budget: relevant entities, relationships, text units, and reports compete for room in one generation request. A production route needs ranking and provenance, not unbounded expansion.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Candidate:
5 source: str
6 score: float
7 tokens: int
8 citation: str
9
10def pack_context(candidates: list[Candidate], token_budget: int) -> list[Candidate]:
11 selected: list[Candidate] = []
12 used = 0
13 for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
14 if used + candidate.tokens <= token_budget:
15 selected.append(candidate)
16 used += candidate.tokens
17 return selected
18
19candidates = [
20 Candidate("relationship: depends_on", 0.98, 30, "incident-1842"),
21 Candidate("text: maxmemory issue", 0.91, 55, "incident-2031"),
22 Candidate("report: cache overview", 0.62, 80, "community-7"),
23]
24selected = pack_context(candidates, token_budget=90)
25
26print("selected:", [item.source for item in selected])
27print("citations:", [item.citation for item in selected])
28print("tokens:", sum(item.tokens for item in selected))1selected: ['relationship: depends_on', 'text: maxmemory issue']
2citations: ['incident-1842', 'incident-2031']
3tokens: 85Global search (broad questions)
For questions that need cross-corpus coverage, GraphRAG uses a map-reduce over community reports at a chosen hierarchy level. The docs batch reports and produce rated intermediate points during map.
Reduce then keeps the highest-value points for the final answer [1][11]:

The sketch below does that map-reduce on four community reports from the incident corpus. Predict its shape first: map keeps latency-related points, then reduce returns the top three:
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class RatedPoint:
5 text: str
6 rating: int
7
8def batch_reports(reports: list[str], batch_size: int = 2) -> list[list[str]]:
9 return [reports[index:index + batch_size] for index in range(0, len(reports), batch_size)]
10
11def map_report_batch(query: str, report_batch: list[str]) -> list[RatedPoint]:
12 points: list[RatedPoint] = []
13 query_lower = query.lower()
14 asks_about_latency = any(term in query_lower for term in ("latency", "slo", "miss", "delay"))
15 for report in report_batch:
16 report_lower = report.lower()
17 if asks_about_latency and any(term in report_lower for term in ("latency", "slo", "queue", "eviction")):
18 points.append(RatedPoint(report, rating=9))
19 elif "security" in query_lower and "credential" in report_lower:
20 points.append(RatedPoint(report, rating=6))
21 return points
22
23def select_top_points(points: list[RatedPoint], top_k: int) -> list[RatedPoint]:
24 return sorted(points, key=lambda point: point.rating, reverse=True)[:top_k]
25
26def global_search(
27 query: str,
28 reports_by_level: dict[int, list[str]],
29 level: int,
30) -> str:
31 """Answer dataset-level questions with map-reduce over community reports."""
32 batches = batch_reports(reports_by_level[level], batch_size=2)
33 mapped_points = [
34 point
35 for batch in batches
36 for point in map_report_batch(query, batch)
37 ]
38 top_points = select_top_points(mapped_points, top_k=3)
39 bullets = "\n".join(f"- {point.text}" for point in top_points)
40 return f"Top recurring themes for '{query}':\n{bullets}"
41
42reports_by_level = {
43 1: [
44 "Latency breaches cluster around cache eviction storms.",
45 "GPU queue starvation delays batch inference jobs.",
46 "Evaluation failures cluster around flaky judge prompts.",
47 "SLO breaches recur after cache config drift.",
48 ]
49}
50
51answer = global_search(
52 "What are the main reasons services miss the latency SLO?",
53 reports_by_level,
54 level=1,
55)
56
57print(answer)1Top recurring themes for 'What are the main reasons services miss the latency SLO?':
2- Latency breaches cluster around cache eviction storms.
3- GPU queue starvation delays batch inference jobs.
4- SLO breaches recur after cache config drift.The level argument hides a cost problem. Static global search maps over every community report at the chosen level, so cost scales with that whole level even when most communities are irrelevant.
Dynamic community selection starts near the top of the hierarchy, rates each report against the query, prunes irrelevant branches, and expands only sub-communities under surviving reports.
On an AP News evaluation, Microsoft reported about 77% lower token cost at community level 1 with LLM-judge quality comparable to static search (win rates near 50%). Letting the search continue to level 3 improved comprehensiveness and empowerment (about 59% and 60% win rate vs static level 1) but raised cost about 34% because more reports entered map-reduce [13].
Rating calls add work, so benchmark dynamic selection against static search on your workload.
DRIFT search
DRIFT sits between those modes. The primer compares the query with the top-k most similar community reports, drafts a broad answer, and emits follow-up questions.
Follow-up steps run local search to refine those questions, then the engine ranks the resulting question-answer tree [12]. Use it when a specific entity question still needs community-level breadth, and measure it against local search rather than assuming it's always better.
Hybrid graph-vector architecture
Don't treat vector search and GraphRAG as mutually exclusive. The GraphRAG stack already mixes graph structure with embeddings during both indexing and query-time context building [2][10]. One hybrid architecture looks like this:

A router is a policy to evaluate, not a guarantee. Measure supported-answer accuracy, p95 latency, and spend for each query class. In a workload dominated by direct evidence lookups, a text route may cover most requests; an analyst-heavy workload may justify more graph/report queries.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class RouteResult:
5 route: str
6 query_class: str
7 supported_accuracy: float
8 p95_ms: int
9
10def release_route(
11 results: list[RouteResult],
12 query_class: str,
13 minimum_accuracy: float,
14 maximum_p95_ms: int,
15) -> str:
16 eligible = [
17 result for result in results
18 if result.query_class == query_class
19 and result.supported_accuracy >= minimum_accuracy
20 and result.p95_ms <= maximum_p95_ms
21 ]
22 return max(
23 eligible,
24 key=lambda result: (result.supported_accuracy, -result.p95_ms),
25 ).route
26
27results = [
28 RouteResult("basic", "fact_lookup", 0.96, 95),
29 RouteResult("local", "fact_lookup", 0.96, 240),
30 RouteResult("basic", "corpus_trend", 0.61, 92),
31 RouteResult("global", "corpus_trend", 0.91, 580),
32]
33
34print("fact route:", release_route(results, "fact_lookup", 0.90, 200))
35print("trend route:", release_route(results, "corpus_trend", 0.90, 700))1fact route: basic
2trend route: globalOne subtle detail: GraphRAG the technique doesn't require a dedicated graph database. Microsoft's reference implementation writes structured output tables to disk and builds query context from those artifacts directly. A graph database like Neo4j or Neptune becomes useful when you need custom traversals, shared KG infrastructure, or analyst-facing graph queries outside the stock pipeline [8].
Query expansion and context bridging
Beyond simple routing, the knowledge graph layer can enrich vector results after initial retrieval.
Query expansion uses entities found in initial vector results to expand the search query, finding semantically related but textually distinct content.
Context bridging matters when two retrieved chunks don't directly connect. If edges retain supporting chunks, a traversal can identify intermediate entities for retrieval and citation.
For example, if one chunk mentions "inference-api depends on redis-cache" and another mentions "redis-cache still uses the old maxmemory policy," a policy can inspect inference-api -> redis-cache -> old maxmemory policy, then fetch source text before making a claim about the latency incident.
Result ranking can boost text results with short, supported paths to query entities. Treat this as a ranker feature to evaluate, not proof that a nearby node supports the answer.
What the extra index costs
Indexing cost
The biggest barrier to a standard GraphRAG index is the offline bill. Compared with vector-only RAG, its standard pipeline adds LLM-heavy graph extraction, summarization, community report generation, and multiple embedding passes [1][3].
Whether the resulting query quality justifies that work is an evaluation question.
Mitigation strategies
To keep indexing affordable, start cheap. Microsoft recommends fast, inexpensive models while you learn the system [14]. Their indexing-methods docs estimate that graph extraction (entity and relationship extraction plus their summarization) is roughly 75% of standard indexing cost [7].
If your use case is mostly global summarization, FastGraphRAG can cut that further. It replaces LLM-based entity extraction with NLP noun-phrase extraction (NLTK or spaCy) and defines relationships by entity co-occurrence inside a text unit. The graph is noisier and less reusable outside GraphRAG, but indexing is much cheaper [7].
LazyGraphRAG in practice
Microsoft Research introduced LazyGraphRAG in November 2024 [15]. It targets a central cost concern with standard GraphRAG: paying for an LLM-driven index before knowing how often graph-assisted queries will run.
A June 2025 editor's note on that post says the method is available in Microsoft Discovery and as an Azure Local public preview. Check those product pages before assuming it's in the open-source GraphRAG CLI.
LazyGraphRAG defers LLM use to query time. Its index uses NLP noun-phrase extraction and graph statistics for community structure, without entity summaries or precomputed community reports.
In Microsoft's reported experiment, indexing cost matched the vector RAG setup and was about 0.1% of full GraphRAG indexing cost [15]. Treat that as a benchmark result, not a constant for every corpus.
At query time, LazyGraphRAG blends vector similarity with community structure and exposes a relevance test budget that trades query cost for quality.
In the same write-up, a low-budget setting was comparable to GraphRAG global search on global queries at more than 700 times lower query cost, and a higher budget (about 4% of global-search query cost at community level 2) beat the tested alternatives on both local and global criteria [15]. Those comparisons used LLM-as-judge win rates on 100 synthetic queries over 5,590 AP news articles.
Precomputed community reports aren't the only candidate for global sensemaking. If the corpus changes frequently or global queries are rare, benchmark a lazy approach against standard GraphRAG.
If reusable community reports are a product output, the standard index provides artifacts the lazy path intentionally omits.
Use observed quality and workload volume to compare indexing strategies rather than choosing from architecture labels:
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Strategy:
5 name: str
6 index_cost: float
7 query_cost: float
8 supported_accuracy: float
9
10def choose_strategy(
11 strategies: list[Strategy], query_count: int, accuracy_floor: float
12) -> tuple[str, float]:
13 passing = [
14 strategy for strategy in strategies
15 if strategy.supported_accuracy >= accuracy_floor
16 ]
17 winner = min(
18 passing,
19 key=lambda strategy: strategy.index_cost + query_count * strategy.query_cost,
20 )
21 total_cost = winner.index_cost + query_count * winner.query_cost
22 return winner.name, total_cost
23
24# Illustrative measured values from one product evaluation, not vendor benchmarks.
25strategies = [
26 Strategy("lazy", index_cost=1.0, query_cost=1.7, supported_accuracy=0.91),
27 Strategy("precomputed_reports", index_cost=200.0, query_cost=0.8, supported_accuracy=0.93),
28]
29
30print("few queries:", choose_strategy(strategies, query_count=10, accuracy_floor=0.90))
31print("many queries:", choose_strategy(strategies, query_count=500, accuracy_floor=0.90))1few queries: ('lazy', 18.0)
2many queries: ('precomputed_reports', 600.0)What part of LazyGraphRAG makes its reported indexing cost close to the vector RAG comparison?
Answer
It skips LLM entity and relationship summarization and doesn't precompute community reports. Its index uses NLP noun-phrase extraction plus graph statistics, while LLM work happens at query time. The exact cost ratio still depends on the evaluated corpus and configuration.
Query cost
Indexing pays the bulk of the computational expense, but runtime queries can still cost more than standard vector search. The query strategy determines how much more:
- Local search: Usually moderate. You still have to build a mixed context from entity embeddings, graph neighborhoods, text units, and community reports, but the response path is much narrower than global search [10].
- Global search: Potentially expensive. Cost grows with the number of community-report batches you need to map over and the hierarchy level you choose [11].
Mitigation strategies
To manage these runtime costs, standard GraphRAG precomputes community reports at multiple hierarchical levels. At query time, evaluate whether a coarse level answers the question adequately before paying for more detailed reports. Lower levels tend to yield more thorough responses, but they can also increase report volume and LLM work [11].
Graph maintenance
Knowledge graphs aren't static; they must evolve as the underlying document corpus changes. Keeping the graph in sync with a live, mutating dataset adds engineering complexity.
Current GraphRAG releases expose explicit update flows and standard-update / fast-update methods. The output tables also include fields used for incremental update merges [16][8].
- Additions: When new documents arrive, the system must extract entities and relationships, then merge them into the existing graph. New connections can shift community structure, requiring reclustering and regeneration of affected reports.
- Deletions: Removing a document isn't as simple as deleting a row in a database. The system must trace and remove nodes or edges supported solely by that document. If removed evidence changes graph structure, communities and reports may need recomputation.
- Strategy: Even with update support, a deployment may prefer scheduled refreshes for structural artifacts. Entity merges, community boundaries, and report summaries can shift when new documents arrive, so online reclustering is harder to operate than plain vector re-indexing.
When to use GraphRAG
GraphRAG isn't a default replacement for retrieval. It adds indexing and maintenance for analytical workloads. Label real queries before committing to the architecture:
| Scenario | Start with vector RAG | Add GraphRAG when |
|---|---|---|
| Fact lookup ("What is the embeddings API timeout?") | Strong, cheap baseline | Extra structure rarely pays off |
| Entity investigation ("Why did inference-api breach the SLO?") | Works if evidence sits in similar chunks | Linked entities or reports recover missing hops |
| Global summary ("What are the top 3 SLO reasons?") | Small top-k underrepresents themes | Community reports are built for this class |
| Relationship-heavy question ("How does Redis config drift affect inference latency?") | Needs expansion or luck | Graph-linked evidence can help; test path policy |
| Corpus change rate | Frequent edits, direct lookups | Stable corpus, reusable reports, or rare global queries that still matter |
The pragmatic path: start with vector search. Add a graph layer only when you can show a failure that reports or cited paths fix:
- Users ask questions that need synthesis across many documents
- You need to trace influence chains (what depends on what) with provenance
- Global sensemaking is a core use case, not an edge case
- The corpus is large and stable enough that indexing cost can amortize
Failures after the 200-document sandbox
A 200-document prototype hides different classes of failure. Once it leaves the sandbox, diagnose each trap as symptom, cause, and fix.
Mistake 1: "Use a bigger context window"
-
Symptom: You stuff 100,000 tokens into a long-context model and ask for a summary. The output misses key themes or contradicts itself.
-
Cause: A 1M token context window doesn't automatically solve global sensemaking. You still have to decide what to include, very large prompts are expensive to run, and models can still underuse information buried in the middle of long contexts [17].
-
Fix: Benchmark selective retrieval and structured indexing against long-context prompting. Standard GraphRAG is one candidate when repeated global questions justify precomputed reports; it isn't required for every long document.
Mistake 2: Treating GraphRAG as a replacement for vector search
-
Symptom: You replace your entire vector index with a knowledge graph. Simple lookups become slow and expensive.
-
Cause: GraphRAG is complementary, not substitutive. Local search uses entity-description embeddings and linked text units as part of context construction. Global search adds report synthesis for questions that require coverage across many documents.
-
Fix: Use a hybrid router. Route simple fact lookups to vector search, entity questions to local search, and global summaries to global search.
Mistake 3: Ignoring entity resolution
-
Symptom: Your graph has three separate nodes for
RedisCache,redis-cache, andredis.internal. Queries that should traverse through the cache dependency fail because the path is broken. -
Cause: Entity extraction from unstructured text is inherently noisy. Skipping deduplication leaves near-duplicate nodes that fragment the graph.
-
Fix: Use
(canonical title, canonical type)as the entity key, carry both endpoint types on every edge, and reject unknown or ambiguous references. Candidate aliasing can use embeddings or string distance within one tenant and environment, but stable identifiers, source provenance, and a human-reviewed policy must authorize each merge. A model suggestion alone can't authorize a cross-tenant or cross-environment merge.
Mistake 4: Extracting every noun as a node
-
Symptom: The graph balloons to millions of nodes, most of them useless. Common words like "service," "alert," and "team" become nodes with thousands of meaningless edges.
-
Cause: Without a schema, the extractor treats every noun as an entity.
-
Fix: Define an ontology (a typed schema) before extraction. Restrict nodes to domain-relevant types like
Service,Datastore,Region,Incident,ConfigIssue, andRunbook. Filter low-information entities in a post-processing step.
Mistake 5: Underestimating indexing cost
-
Symptom: The prototype works on 200 documents, then the first full-corpus run burns budget on extraction, summarization, and embedding passes.
-
Cause: Standard GraphRAG does much more than embed chunks. It asks models to extract entities, extract relationships, summarize repeated descriptions, generate community reports, and embed several downstream artifacts.
-
Fix: Measure indexing cost before committing to the architecture. Start with a small representative corpus, use inexpensive models while tuning prompts, consider FastGraphRAG for global-summary-heavy workloads, and set a refresh cadence instead of pretending every graph update is free.
Mistake 6: Ignoring graph maintenance
-
Symptom: Search results become stale or paths break after documents are added, deleted, or corrected.
-
Cause: A graph index has structure. New evidence can merge entities, change edge weights, move community boundaries, and invalidate old community reports.
-
Fix: Treat graph refresh as a product requirement. Track provenance with
text_unit_ids, run incremental update flows where they are good enough, and schedule full rebuilds when entity resolution or community boundaries drift.
Try it yourself
Stay with the three incidents from the opening. Work this on paper before reading the sketch.
Queries
- "What is the timeout for the embeddings API?"
- "Why did inference-api breach the latency SLO, and is this likely to happen again?"
- A staff user (not security) asks for "top recurring operational themes." One Leiden community mixed
tu-public-slo,tu-security-key, andtu-public-runbook.
For each query, name the search mode you'd run first, which artifacts you'd admit into context, and what you must not claim.
Solution sketch
-
Basic / vector search. The timeout is a local fact. Don't pay for community map-reduce.
-
Local search with provenance. Map to
inference-api, pack cited neighbors (depends_onredis-cache from 1842,has_issuemaxmemory from 2031) under the token budget. Cite those two facts. Don't predict another SLO breach: thewill_causeedge has no source chunks. -
Fail closed before map-reduce. Drop the mixed community report. Use an
all-staffequivalence-class report, recompute a per-principal view withouttu-security-key, or fall back to authorized local search. Don't generate the mixed report and scrub the answer afterward.
When the graph earns its cost
Start by labeling 30 real queries as fact lookup, entity-specific investigation, or corpus-wide trend.
Use that table as a release gate: ship GraphRAG only for the bucket where vector retrieval fails and graph/report context improves cited-answer quality within your latency budget.