Skip to content
Skip to lesson
Back to Topics
LearnAdvanced Agents & RetrievalGraphRAG & Knowledge Graphs
🔍HardRAG & Retrieval

GraphRAG & Knowledge Graphs

Build source-linked incident graphs, compare local retrieval with community-report search, and test where graph coverage, identity, and authorization can fail.

55 min read
Learning path
Step 123 of 197 in the full curriculum

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 during release 2026.06.14?" Ordinary retrieval quickly finds the write-up about that service.

Then an analyst asks: "What are the top three recurring reasons model-serving incidents breach latency SLOs across the fleet?" A small result set can look convincing while covering only a couple of incidents from the same cluster. Similarity doesn't by itself give you theme coverage over an entire corpus.

The previous chapter made adaptive: rewrite queries, generate hypothetical evidence, critique retrieved context, and retry when evidence falls short. changes the representation layer. It indexes relationship structure, entity graphs, and community summaries alongside text so a system can retrieve connected evidence or corpus-level themes, at the cost of extra extraction and query compute.

The engineering decision is knowing when that tradeoff complements 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 those artifacts for retrieval [1][2][3]. The same index supplies structured artifacts for entity-focused local context.

The original paper evaluates global sensemaking on podcast and news corpora using LLM judges, showing notable gains in comprehensiveness and diversity over a vector RAG baseline. Those preferences don't establish factual accuracy, exact incident counts, or superiority on every question.[1]

A broader roadmap by Pan et al. places this work in the wider effort to combine LLMs with structured knowledge graphs [4].

The executed lexical fixture scores record 3155 at three overlapping query terms, 1842 at two, and 2031 at zero. A directed dependency path adds the policy note from 2031; this recovers evidence, not a proven cause.
The lab below reproduces this lexical top-two miss. A dependency edge from record 1842 and a uses_policy edge from record 2031 recover the configuration note. No embedding model was run, and the linked policy is an investigation lead, not a proven cause.

Vector search ranks chunks that resemble the question. A graph index adds 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. The small examples below use a custom directed, typed property graph so you can inspect each hop. Microsoft's standard entity graph instead represents relationships with descriptions and weights; it doesn't supply this application-specific traversal contract. If your product needs an explicit path walk, implement that policy and test it.[3][5]

Why small top-k retrieval can be insufficient

The global query problem

Keep those 10,000 incident reports, runbooks, , 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. A few chunks about the API contract and runbook contain everything the system needs.

Global queries (small top-k retrieval misses coverage)

For a question that must synthesize across the entire corpus, small top-k vector retrieval runs into a different problem:

"What are the top 3 recurring reasons model-serving incidents breach the latency ?" 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 alone doesn't count every theme. Community reports broaden the evidence considered, yet they also compress and omit details. If "top three" means most frequent, define an incident taxonomy, label and deduplicate incidents, and count them. Report salience scores aren't frequency estimates.

Why similarity-only retrieval struggles

Similarity ranking doesn't guarantee a representative set. Three different failures deserve separate remedies:

  1. Scattered evidence: Individual write-ups may never state a fleet-wide theme. A summary question needs aggregation across records, not just the passage nearest to its wording.
  2. Repeated incidents: Near-duplicate gateway reports can occupy several result slots when they rank closest to the query. Nearest-neighbor search follows distance to the query; it doesn't always select the densest cluster. Deduplication and diversity ranking can improve coverage, but don't turn top-k into a statistical sample.
  3. A limited context budget: Raising kk can recover missing evidence. It also adds serialized text, with token growth depending on chunk lengths and packing. Long-context models may underuse information in some positions; the "lost in the middle" experiments demonstrate this risk, not an inevitable failure of every large prompt.[6]

Standard GraphRAG moves graph clustering and community summarization offline. Global search then distributes report batches across bounded prompts and reduces their partial answers. This broadens the summary units considered, but extraction and report compression can still omit facts. It neither guarantees complete source coverage nor computes the most frequent causes automatically.[1]

Its additional indexing stages are:

  1. Entity extraction across the entire corpus
  2. Relationship modeling between entities
  3. Community detection to find natural clusters of related information
  4. Hierarchical summarization at different levels of abstraction
FeatureVector Search (Standard RAG)GraphRAG
Available artifactsRanked text chunksText units, entities, relationships, community reports
Natural starting pointSpecific evidence lookup ("What is X?")Entity-focused or thematic analysis ("What are the trends?")
Context constructionSimilarity-ranked chunksRanked graph/text context or report map-reduce
Indexing workEmbedding and optional sparse indexAdditional extraction, clustering, reports, and embeddings
Query workRetrieval plus generationDepends 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 depends on redis-cache. Release 2026.06.14 of inference-api ran in us-east-1. Cache evictions and increased p95 latency were observed during the release." 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. restored latency."

The decision question is:

"Why did inference-api breach the latency SLO, and is this likely to happen again?"

Suppose a top-two retriever returns Incident 3155 and Incident 1842 but misses Incident 2031. That's an assumed result for comparing evidence paths, not an executed vector-search benchmark. A semantic retriever needn't require the service's exact name. Later, a small lexical baseline makes this particular miss reproducible.

That answer can identify the and cache eviction while missing the unresolved maxmemory policy.

Now add graph structure. Suppose extraction produced these entities and relationships, with source-chunk :

  • (inference-api) --[ran_in]--> (us-east-1)
  • (inference-api) --[depends_on]--> (redis-cache)
  • (inference-api) --[released_as]--> (release 2026.06.14)
  • (redis-cache) --[located_in]--> (us-east-1)
  • (redis-cache) --[uses_policy]--> (old maxmemory policy)
  • (old maxmemory policy) --[migration_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 report the observed latency, cache evictions, dependency, and still-active policy. Those facts justify investigating the cache path, but they don't identify the policy as the cause or estimate the probability of another breach. Even a triggered_by label would remain an extraction claim that needs source support.

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 ; 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. First check a smaller condition: does a proposed path connect, and do its source IDs resolve? This doesn't judge whether the sources entail the edge labels.

require-provenance-for-graph-paths.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Edge:
    source: str
    relation: str
    target: str
    source_chunks: tuple[str, ...]

def cited_path(edges: list[Edge], known_chunks: set[str]) -> tuple[bool, list[str]]:
    if not edges or any(not edge.source_chunks for edge in edges):
        return False, []
    if any(left.target != right.source for left, right in zip(edges, edges[1:])):
        return False, []
    if any(chunk not in known_chunks for edge in edges for chunk in edge.source_chunks):
        return False, []
    citations = sorted({chunk for edge in edges for chunk in edge.source_chunks})
    return True, citations

path = [
    Edge("inference-api", "depends_on", "redis-cache", ("incident-1842",)),
    Edge("redis-cache", "uses_policy", "old maxmemory policy", ("incident-2031",)),
]
unsupported_path = path + [
    Edge("old maxmemory policy", "will_cause", "future SLO breach", ()),
]

known_chunks = {"incident-1842", "incident-2031"}
print("connected path with known sources:", cited_path(path, known_chunks))
print("uncited prediction rejected:", cited_path(unsupported_path, known_chunks))
assert not cited_path([], known_chunks)[0]
assert not cited_path(list(reversed(path)), known_chunks)[0]
assert not cited_path([Edge("a", "uses", "b", ("invented",))], known_chunks)[0]
Output
connected path with known sources: (True, ['incident-1842', 'incident-2031'])
uncited prediction rejected: (False, [])

Entities, edges, and provenance

A knowledge graph represents information as a network of entities (nodes) and relationships (edges). A vector index ranks embeddings; its records can also carry metadata and links. The graph makes selected relationships explicit instead of relying on similarity alone.

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) --[uses_policy]--> (old maxmemory policy)
  • (old maxmemory policy) --[migration_blocked_by]--> (compatibility test)

This application uses a property graph rather than bare triples. Nodes and edges carry metadata such as type, description, source_chunk_ids, timestamps, or confidence. RDF-based knowledge graphs can also express provenance; property graphs aren't a requirement for graph-assisted retrieval.

Those fields are what you filter, cite, and rank at query time.

Within this single-environment fixture, use (canonical_title, canonical_type), not a title-only key. A real index also needs stable IDs scoped by tenant, environment, and source identity: even two Service:gateway records may name different services. Keep endpoint types and reviewed aliases, and reject ambiguous or unknown references. Case and whitespace normalization is a declared local convention; stripping punctuation would silently merge names such as a-bc and ab-c.

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 adds a separate learned model that combines node features, edges, and usually neighboring information to predict properties such as incident impact or service relevance.[7]

A makes that distinction concrete: each layer computes messages from neighboring node features, aggregates them, and updates the destination node's learned representation.[8]

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.

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

Two supplied incident records contribute to a reviewed Redis Cache Datastore identity. The custom resolver combines source IDs 1842 and 2031. Directed depends_on and uses_policy edges retain their separate source IDs; name similarity alone does not authorize merging.
This fixture supplies the identities and extraction results. Redis Cache bridges two records after a reviewed identity match; its merged source list doesn't authorize a causal claim. Each relationship retains its own supporting source.

Start with one text unit. Standard GraphRAG prompts an LLM to name entities and describe their relationships. Entity types can be tuned to the domain; a prompt's type list isn't an enforced ontology or proof of a clean graph. For this application, choose types such as Service, Datastore, Region, Release, ConfigPolicy, and TestGate, then validate them. Broad extraction can introduce low-information hubs; an overly narrow schema can lose useful connections. FastGraphRAG instead uses NLP noun-phrase extraction and co-occurrence edges, while still using an LLM for reports.[9]

This application-specific prompt proposes directed relation labels and typed endpoints. It isn't Microsoft's default extraction format:

step-1-entity-and-relationship-extraction.py
EXTRACTION_PROMPT = """
Extract all entities and relationships from the following text.

Entities should include: services, datastores, regions, releases, config policies, and test gates.
Relationships may include: ran_in, depends_on, released_as, located_in, uses_policy, migration_blocked_by.
Extract only relationships asserted by the text. Preserve negation and uncertainty.
Co-occurrence and temporal order do not establish a dependency or a cause.

Text: {chunk_text}

Output as JSON:
{
  "entities": [
    {"name": "...", "type": "...", "description": "..."}
  ],
  "relationships": [
    {
      "source": "...",
      "source_type": "...",
      "target": "...",
      "target_type": "...",
      "type": "...",
      "description": "...",
      "strength": 1.0
    }
  ]
}
"""

That prompt isn't a parser. Use when supported, or parse and validate the chosen format before indexing. A schema-valid object can still contain an unsupported relationship. Attach the input's source ID outside the model response. This application's contract requires typed entities and typed endpoints: source: "gateway" is ambiguous when both a Service and a Datastore use that title. The {chunk_text} marker is conceptual; if you interpolate this with Python's str.format, escape the literal JSON braces.

Entity resolution needs more than a plausible name match. The original GraphRAG experiments use exact string matching, and the dataflow docs describe merging entities with the same title and type.[1][3] They don't promise to establish that redis-cache, Redis Cache, and redis-prod identify one deployment. An LLM summary of merged descriptions isn't authoritative and can conceal contradictions. Preserve conflicting claims and their dates and sources.

The custom resolver below accepts only supplied aliases and rejects ambiguous references. In production, stable identifiers and reviewed alias rules should establish identity within the tenant and environment before descriptions are combined.

The executable version accepts only two exact fixture texts whose relationships were specified for this example. Changed or unknown text is rejected. Its rules aren't an NLP extractor, and its numeric weights aren't calibrated probabilities. A real extractor needs schema validation plus reviewed tests for negation, uncertain claims, incorrect merges, and missing relationships.

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.

step-1-entity-and-relationship-extraction-2.py
from collections import defaultdict
from dataclasses import dataclass, field

@dataclass
class Entity:
    name: str
    type: str
    description: str
    source_chunk_ids: list[int] = field(default_factory=list)
    aliases: list[str] = field(default_factory=list)

@dataclass
class Relationship:
    source: str
    source_type: str
    target: str
    target_type: str
    type: str
    description: str
    confidence: float
    source_chunk_ids: list[int] = field(default_factory=list)

def canonicalize(value: str) -> str:
    normalized = " ".join(value.casefold().split())
    if not normalized:
        raise ValueError("entity names and types must be nonempty")
    return normalized

def entity_key(name: str, entity_type: str) -> tuple[str, str]:
    """Identify an entity by canonical title and canonical type."""
    return canonicalize(name), canonicalize(entity_type)

def extract_from_chunk(chunk_id: int, chunk: str) -> tuple[list[Entity], list[Relationship]]:
    reviewed_texts = {
        1842: "inference-api depends on redis-cache. Release 2026.06.14 of inference-api ran in us-east-1. Cache evictions and increased p95 latency were observed during the release.",
        2031: "redis-cache in us-east-1 still uses the old maxmemory policy. The config migration is blocked by a compatibility test.",
    }
    if reviewed_texts.get(chunk_id) != chunk:
        raise ValueError("unknown or changed fixture text requires relationship review")
    chunk = chunk.casefold()
    entities: list[Entity] = []
    relationships: list[Relationship] = []

    if "inference-api" in chunk:
        entities.append(Entity("inference-api", "Service", "Inference API service", [chunk_id]))
    if "redis-cache" in chunk:
        entities.append(Entity("Redis Cache", "Datastore", "Shared cache dependency", [chunk_id]))
    if "us-east-1" in chunk:
        entities.append(Entity("US East 1", "Region", "Production region", [chunk_id]))
    if "release 2026.06.14" in chunk:
        entities.append(Entity("Release 2026.06.14", "Release", "Inference API deployment", [chunk_id]))
    if "old maxmemory policy" in chunk:
        entities.append(Entity("Old Maxmemory Policy", "ConfigPolicy", "Policy still in use", [chunk_id]))
    if "compatibility test" in chunk:
        entities.append(Entity("Compatibility Test", "TestGate", "Migration-blocking test", [chunk_id]))

    entity_keys = {entity_key(entity.name, entity.type) for entity in entities}
    if {entity_key("inference-api", "Service"), entity_key("Redis Cache", "Datastore")} <= entity_keys:
        relationships.append(
            Relationship(
                source="inference-api", source_type="Service",
                target="Redis Cache", target_type="Datastore",
                type="depends_on", description="Service uses cache",
                confidence=1.0, source_chunk_ids=[chunk_id],
            )
        )
    if {entity_key("inference-api", "Service"), entity_key("US East 1", "Region")} <= entity_keys:
        relationships.append(
            Relationship(
                source="inference-api", source_type="Service",
                target="US East 1", target_type="Region",
                type="ran_in", description="Service ran in region",
                confidence=0.9, source_chunk_ids=[chunk_id],
            )
        )
    if {entity_key("inference-api", "Service"), entity_key("Release 2026.06.14", "Release")} <= entity_keys:
        relationships.append(
            Relationship(
                source="inference-api", source_type="Service",
                target="Release 2026.06.14", target_type="Release",
                type="released_as", description="Service release observed in source",
                confidence=0.8, source_chunk_ids=[chunk_id],
            )
        )
    if chunk_id == 2031 and {entity_key("Redis Cache", "Datastore"), entity_key("US East 1", "Region")} <= entity_keys:
        relationships.append(
            Relationship(
                source="Redis Cache", source_type="Datastore",
                target="US East 1", target_type="Region",
                type="located_in", description="Cache region",
                confidence=0.8, source_chunk_ids=[chunk_id],
            )
        )
    if {entity_key("Redis Cache", "Datastore"), entity_key("Old Maxmemory Policy", "ConfigPolicy")} <= entity_keys:
        relationships.append(
            Relationship(
                source="Redis Cache", source_type="Datastore",
                target="Old Maxmemory Policy", target_type="ConfigPolicy",
                type="uses_policy", description="Cache still uses the old policy",
                confidence=0.8, source_chunk_ids=[chunk_id],
            )
        )
    if {entity_key("Old Maxmemory Policy", "ConfigPolicy"), entity_key("Compatibility Test", "TestGate")} <= entity_keys:
        relationships.append(
            Relationship(
                source="Old Maxmemory Policy", source_type="ConfigPolicy",
                target="Compatibility Test", target_type="TestGate",
                type="migration_blocked_by", description="Migration from this policy is blocked by the test",
                confidence=0.7, source_chunk_ids=[chunk_id],
            )
        )

    return entities, relationships

def deduplicate_entities(entities: list[Entity]) -> list[Entity]:
    merged: dict[tuple[str, str], Entity] = {}
    for entity in entities:
        key = entity_key(entity.name, entity.type)
        if key not in merged:
            merged[key] = Entity(
                name=entity.name,
                type=entity.type,
                description=entity.description,
                source_chunk_ids=sorted(set(entity.source_chunk_ids)),
                aliases=list(entity.aliases),
            )
            continue
        existing = merged[key]
        existing.source_chunk_ids = sorted(set(existing.source_chunk_ids + entity.source_chunk_ids))
        existing.aliases = sorted(set(existing.aliases + entity.aliases))
        if entity.name != existing.name and entity.name not in existing.aliases:
            existing.aliases.append(entity.name)
        if entity.description not in existing.description:
            existing.description = f"{existing.description}; {entity.description}"
    return [merged[key] for key in sorted(merged)]

def index_entities(
    entities: list[Entity],
) -> tuple[dict[tuple[str, str], Entity], dict[str, list[Entity]]]:
    by_key = {entity_key(entity.name, entity.type): entity for entity in entities}
    if len(by_key) != len(entities):
        raise ValueError("deduplicate identities before resolving references")
    by_name: dict[str, dict[tuple[str, str], Entity]] = defaultdict(dict)
    for entity in entities:
        key = entity_key(entity.name, entity.type)
        by_name[canonicalize(entity.name)][key] = entity
        for alias in entity.aliases:
            by_name[canonicalize(alias)][key] = entity
    return by_key, {name: list(candidates.values()) for name, candidates in by_name.items()}

def resolve_entity(
    name: str,
    entity_type: str | None,
    by_key: dict[tuple[str, str], Entity],
    by_name: dict[str, list[Entity]],
) -> Entity:
    """Resolve a typed reference, or reject an ambiguous untyped reference."""
    if entity_type:
        candidates = [candidate for candidate in by_name.get(canonicalize(name), [])
                      if canonicalize(candidate.type) == canonicalize(entity_type)]
        if len(candidates) != 1:
            raise ValueError(f"Unknown or ambiguous typed entity: {name!r} ({entity_type})")
        return candidates[0]

    candidates = by_name.get(canonicalize(name), [])
    if not candidates:
        raise ValueError(f"Unknown entity reference: {name!r}")
    if len(candidates) > 1:
        candidate_types = ", ".join(sorted({candidate.type for candidate in candidates}))
        raise ValueError(
            f"Ambiguous entity reference {name!r}; endpoint type required ({candidate_types})"
        )
    return candidates[0]

def resolve_relationships(
    relationships: list[Relationship],
    entities: list[Entity],
) -> list[Relationship]:
    """Resolve both endpoints by typed identity and merge duplicate edges."""
    by_key, by_name = index_entities(entities)
    merged: dict[tuple[tuple[str, str], tuple[str, str], str], Relationship] = {}
    for relationship in relationships:
        source = resolve_entity(relationship.source, relationship.source_type, by_key, by_name)
        target = resolve_entity(relationship.target, relationship.target_type, by_key, by_name)
        edge_key = (
            entity_key(source.name, source.type),
            entity_key(target.name, target.type),
            canonicalize(relationship.type),
        )
        resolved = Relationship(
            source=source.name,
            source_type=source.type,
            target=target.name,
            target_type=target.type,
            type=relationship.type,
            description=relationship.description,
            confidence=relationship.confidence,
            source_chunk_ids=sorted(set(relationship.source_chunk_ids)),
        )
        if edge_key not in merged:
            merged[edge_key] = resolved
            continue
        existing = merged[edge_key]
        existing.source_chunk_ids = sorted(
            set(existing.source_chunk_ids + resolved.source_chunk_ids)
        )
        existing.confidence = max(existing.confidence, resolved.confidence)
        if resolved.description not in existing.description:
            existing.description = f"{existing.description}; {resolved.description}"
    return [merged[key] for key in sorted(merged)]

def extract_graph_elements(chunks: dict[int, str]) -> tuple[list[Entity], list[Relationship]]:
    all_entities: list[Entity] = []
    all_relationships: list[Relationship] = []

    for chunk_id, chunk in chunks.items():
        entities, relationships = extract_from_chunk(chunk_id, chunk)
        all_entities.extend(entities)
        all_relationships.extend(relationships)

    deduped_entities = deduplicate_entities(all_entities)
    return deduped_entities, resolve_relationships(all_relationships, deduped_entities)

chunks = {
    1842: "inference-api depends on redis-cache. Release 2026.06.14 of inference-api ran in us-east-1. Cache evictions and increased p95 latency were observed during the release.",
    2031: "redis-cache in us-east-1 still uses the old maxmemory policy. The config migration is blocked by a compatibility test.",
}

entities, relationships = extract_graph_elements(chunks)
relationship_types = {rel.type for rel in relationships}

print(
    "entity identities:",
    ", ".join(sorted(f"{entity.type}:{entity.name}" for entity in entities)),
)
print("relationships:", ", ".join(sorted(relationship_types)))
print(
    "resolved edges:",
    ", ".join(
        sorted(f"{rel.source} -[{rel.type}]-> {rel.target}" for rel in relationships)
    ),
)

ambiguous_entities = deduplicate_entities(
    [
        Entity("gateway", "Service", "API gateway", [8]),
        Entity("gateway", "Datastore", "Gateway state store", [9]),
    ]
)
try:
    resolve_relationships(
        [
            Relationship(
                source="gateway",
                source_type="",
                target="gateway",
                target_type="Service",
                type="depends_on",
                description="Untrusted untyped source",
                confidence=0.5,
                source_chunk_ids=[10],
            )
        ],
        ambiguous_entities,
    )
except ValueError as error:
    print("ambiguous endpoint rejected:", error)
Output
entity identities: ConfigPolicy:Old Maxmemory Policy, Datastore:Redis Cache, Region:US East 1, Release:Release 2026.06.14, Service:inference-api, TestGate:Compatibility Test
relationships: depends_on, located_in, migration_blocked_by, ran_in, released_as, uses_policy
resolved edges: Old Maxmemory Policy -[migration_blocked_by]-> Compatibility Test, Redis Cache -[located_in]-> US East 1, Redis Cache -[uses_policy]-> Old Maxmemory Policy, inference-api -[depends_on]-> Redis Cache, inference-api -[ran_in]-> US East 1, inference-api -[released_as]-> Release 2026.06.14
ambiguous endpoint rejected: Ambiguous entity reference 'gateway'; endpoint type required (Datastore, Service)

Step 2: Build the knowledge graph

In this custom application, store the resolved elements 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 builds a small adjacency structure. It keeps typed canonical IDs internally and uses display names only when printing. Microsoft GraphRAG also stores structured artifacts in tables; its relationship schema and context policy differ from this sketch.[10]

step-2-build-the-knowledge-graph.py
from collections import defaultdict
from dataclasses import dataclass, field

@dataclass
class Entity:
    name: str
    type: str
    description: str
    source_chunk_ids: list[int] = field(default_factory=list)

@dataclass
class Relationship:
    source: str
    source_type: str
    target: str
    target_type: str
    type: str
    description: str
    confidence: float
    source_chunk_ids: list[int] = field(default_factory=list)

def canonicalize(value: str) -> str:
    normalized = " ".join(value.casefold().split())
    if not normalized:
        raise ValueError("entity names and types must be nonempty")
    return normalized

def entity_key(name: str, entity_type: str) -> tuple[str, str]:
    return canonicalize(name), canonicalize(entity_type)

def build_knowledge_graph(
    entities: list[Entity],
    relationships: list[Relationship],
) -> dict:
    """Store a directed property graph as nodes plus typed outbound edges."""
    nodes = {
        entity_key(entity.name, entity.type): {
            "name": entity.name,
            "type": entity.type,
            "description": entity.description,
            "source_chunk_ids": entity.source_chunk_ids,
        }
        for entity in entities
    }
    out_edges: dict[tuple[str, str], list[dict]] = defaultdict(list)
    if len(nodes) != len(entities):
        raise ValueError("deduplicate reviewed identities before graph construction")
    for rel in relationships:
        source_key = entity_key(rel.source, rel.source_type)
        target_key = entity_key(rel.target, rel.target_type)
        if not rel.source_chunk_ids:
            raise ValueError("edge has no source provenance")
        if source_key not in nodes or target_key not in nodes:
            raise ValueError(f"relationship references unknown entity: {rel.source} -> {rel.target}")
        out_edges[source_key].append(
            {
                "target": target_key,
                "type": rel.type,
                "description": rel.description,
                "weight": rel.confidence,
                "source_chunk_ids": rel.source_chunk_ids,
            }
        )
    return {"nodes": nodes, "out_edges": dict(out_edges)}

entities = [
    Entity("inference-api", "Service", "Inference API service", [0]),
    Entity("Redis Cache", "Datastore", "Shared cache dependency", [0]),
    Entity("US East 1", "Region", "Production region", [0, 1]),
]
relationships = [
    Relationship(
        "inference-api", "Service", "Redis Cache", "Datastore",
        "depends_on", "Service uses cache", 1.0, [0]
    ),
    Relationship(
        "inference-api", "Service", "US East 1", "Region",
        "ran_in", "Service ran in region", 0.9, [0]
    ),
]

graph = build_knowledge_graph(entities, relationships)
edge_tuples = sorted(
    (graph["nodes"][source]["name"], edge["type"], graph["nodes"][edge["target"]]["name"])
    for source, edges in graph["out_edges"].items()
    for edge in edges
)
depends_on = next(
    edge["weight"]
    for edge in graph["out_edges"][entity_key("inference-api", "Service")]
    if edge["type"] == "depends_on"
)

print("nodes:", sorted(node["name"] for node in graph["nodes"].values()))
print("edges:", edge_tuples)
print("US East 1 node type:", graph["nodes"][entity_key("US East 1", "Region")]["type"])
print("depends_on weight:", depends_on)
Output
nodes: ['Redis Cache', 'US East 1', 'inference-api']
edges: [('inference-api', 'depends_on', 'Redis Cache'), ('inference-api', 'ran_in', 'US East 1')]
US East 1 node type: Region
depends_on weight: 1.0

Step 3: Community detection (Leiden algorithm)

For this custom graph, one possible clustering input is an undirected projection. Retain the original directed records for retrieval rather than trying to reconstruct their types afterward. Microsoft's clustering code normalizes endpoint direction, deduplicates edge pairs, and passes weights to hierarchical Leiden; its optional largest-connected-component filter can omit smaller components.[11]

Clusters can suggest operational themes such as cache pressure versus eval harness flakiness. They depend on extracted topology and weights, not a test that members share a root cause.

Leiden addresses a defect of Louvain: moving a bridge node can leave an internally disconnected community. Leiden adds a refinement phase and guarantees connected communities under the paper's assumptions. Stronger guarantees require iteration; connectedness doesn't establish shared meaning or prevent a summarizer from inventing connections.[12]

Leiden can optimize different objectives, including modularity and the Constant Potts Model.[12] Microsoft's inspected wrapper chooses modularity with resolution 1.0.[13] Modularity compares within-community adjacency against a degree-based null model; a high score isn't a semantic quality or extraction-accuracy score.

For a simple, unweighted, undirected graph with m>0m>0 and resolution 1:

Q=12m∑i,j[Aij−kikj2m]δ(ci,cj)Q = \frac{1}{2m} \sum_{i,j} \left[ A_{ij} - \frac{k_i k_j}{2m} \right] \delta(c_i, c_j)

Breaking down each term reveals the intuition:

  • AijA_{ij} is the actual adjacency matrix entry (1 if an edge connects nodes ii and jj, and 0 otherwise).
  • kik_i and kjk_j are the degrees of nodes ii and jj.
  • mm is the total number of undirected edges (2m2m is the sum of all node degrees).
  • kikj2m\frac{k_i k_j}{2m} is the null model's expected adjacency contribution. It can exceed 1, so it isn't a probability of an edge in a simple graph.
  • δ(ci,cj)\delta(c_i, c_j) is the Kronecker delta (1 if nodes ii and jj belong to the same community, 0 otherwise).

Each same-community pair contributes a surplus or deficit. Q>0Q>0 requires a positive total, not one pair exceeding expectation. Putting all nodes in one community gives Q=0Q=0 for this definition.

Microsoft's hierarchical Leiden flow recursively splits communities toward a configured size threshold.[3] Don't confuse those recursive subdivisions with the aggregation phase inside a Leiden run. The hierarchy is a forest: Level 0 can contain several communities, and branches needn't reach the same depth. The original paper reports 34 Level-0 communities for its podcast corpus and 55 for its news corpus, not one all-corpus root.[1]

In an invented example, Level 0 might contain Serving & Latency and Quality & Tooling. A split of Serving & Latency could create Level-1 Cache and Queue communities. Those names and boundaries must be inferred and reviewed, not assumed from the level number.

Score two candidate partitions of the same undirected graph. The cache group contains five of six edges; the separate eval pair contains one. With no cross-group edge, their contributions are 5/6 - (10/12)^2 and 1/6 - (2/12)^2, totaling about 0.278. These two groups were chosen by hand. The code evaluates modularity; it doesn't run Leiden, find an optimum, or generate a hierarchy.

step-3-score-community-modularity.py
from collections import defaultdict

def modularity(
    nodes: list[str],
    undirected_edges: list[tuple[str, str]],
    community: dict[str, str],
) -> float:
    """Newman modularity Q for an unweighted undirected graph."""
    if len(set(nodes)) != len(nodes) or set(community) != set(nodes):
        raise ValueError("unique nodes and one community label per node are required")
    if not undirected_edges:
        raise ValueError("modularity is undefined for a graph with no edges")
    seen = set()
    adjacency: dict[str, set[str]] = defaultdict(set)
    degree: dict[str, int] = {node: 0 for node in nodes}
    for source, target in undirected_edges:
        edge = frozenset((source, target))
        if source not in degree or target not in degree or source == target or edge in seen:
            raise ValueError("expected a simple graph: known endpoints, no loops or duplicate edges")
        seen.add(edge)
        adjacency[source].add(target)
        adjacency[target].add(source)
        degree[source] += 1
        degree[target] += 1
    two_m = 2 * len(undirected_edges)
    total = 0.0
    for i in nodes:
        for j in nodes:
            if community[i] != community[j]:
                continue
            a_ij = 1.0 if j in adjacency[i] else 0.0
            total += a_ij - (degree[i] * degree[j]) / two_m
    return total / two_m

nodes = [
    "inference-api",
    "redis-cache",
    "us-east-1",
    "old-maxmemory",
    "compatibility-test",
    "eval-harness",
    "flaky-judge",
]
edges = [
    ("inference-api", "redis-cache"),
    ("inference-api", "us-east-1"),
    ("redis-cache", "us-east-1"),
    ("redis-cache", "old-maxmemory"),
    ("old-maxmemory", "compatibility-test"),
    ("eval-harness", "flaky-judge"),
]
one_bucket = {node: "all" for node in nodes}
split = {
    "inference-api": "cache",
    "redis-cache": "cache",
    "us-east-1": "cache",
    "old-maxmemory": "cache",
    "compatibility-test": "cache",
    "eval-harness": "eval",
    "flaky-judge": "eval",
}

print("one community Q:", round(modularity(nodes, edges, one_bucket), 3))
print("cache vs eval Q:", round(modularity(nodes, edges, split), 3))
Output
one community Q: 0.0
cache vs eval Q: 0.278

Hierarchical clustering creates nested groups, which can be summarized at several levels. Static global search uses reports at a chosen level. Dynamic community selection rates branches and descends selectively, skipping branches irrelevant to the user's question.

An invented community forest has two Level-0 roots. For a latency question, authored relevance scores admit Serving at 0.9, Cache at 0.8, and Queue at 0.7. Eval at 0.1 is rejected, so Judges and Metrics are not rated. The threshold is 0.5; these scores are not report impact ratings.
Two roots, not one corpus-wide root. These authored relevance scores use a 0.5 threshold; they are neither Leiden output nor a measured saving. Pruning a parent saves work but can hide a relevant child when the parent report or rater misses its evidence.

Step 4: Community summarization

Eligible communities become precomputed Community Reports. The inspected default prompt requests a descriptive title, summary, findings, a 0–10 impact severity rating, and a rating explanation.[14] The title is generated prose, not a canonical entity ID. Findings refer to supplied entity, relationship, and claim record IDs; resolve those records back to their text units before verifying source support.

The rating is a prompt-defined model judgment of impact or importance. It isn't calibrated outage severity, probability of recurrence, query relevance, or an incident count. A high-impact security community may be irrelevant to a latency query. Conversely, a low-impact configuration note may contain the exact evidence needed.

These precomputed reports become first-class retrieval artifacts. At query time, Global Search maps over these reports rather than digging through thousands of raw document chunks. The adapter below lists supplied evidence lines to illustrate the interface. Replacing it with an LLM summarizer requires verifying that every generated claim preserves its source chunk citations.

step-4-community-summarization.py
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)
class Entity:
    name: str
    description: str
    source_chunk_ids: tuple[int, ...]

@dataclass(frozen=True)
class Relationship:
    source: str
    type: str
    target: str
    source_chunk_ids: tuple[int, ...]

class CommunitySummarizer(Protocol):
    def summarize(self, prompt: str) -> str: ...

class EvidenceListingSummarizer:
    def summarize(self, prompt: str) -> str:
        return "\n".join(line for line in prompt.splitlines() if line.startswith("- "))

def summarize_community(
    community_entities: list[Entity],
    community_relationships: list[Relationship],
    level: int,
    summarizer: CommunitySummarizer,
) -> str:
    if (not community_entities or not community_relationships
        or any(not entity.source_chunk_ids for entity in community_entities)
        or any(not rel.source_chunk_ids for rel in community_relationships)):
        raise ValueError("report requires source-linked entity and relationship evidence")
    entity_lines = "\n".join(
        f"- {entity.name}: {entity.description} [chunks: {entity.source_chunk_ids}]"
        for entity in community_entities
    )
    relationship_lines = "\n".join(
        f"- {rel.source} {rel.type} {rel.target} [chunks: {rel.source_chunk_ids}]"
        for rel in community_relationships
    )
    prompt = (
        f"Community level: {level}\n"
        f"Entities:\n{entity_lines}\n"
        f"Relationships:\n{relationship_lines}\n"
        "Summarize the main operational pattern."
    )
    return summarizer.summarize(prompt)

summary = summarize_community(
    [Entity("Redis Cache", "Cache in us-east-1", (2031,))],
    [Relationship("Redis Cache", "uses_policy", "Old Maxmemory Policy", (2031,))],
    level=0,
    summarizer=EvidenceListingSummarizer(),
)

print(summary)
Output
- Redis Cache: Cache in us-east-1 [chunks: (2031,)]
- Redis Cache uses_policy Old Maxmemory Policy [chunks: (2031,)]

Authorization continuity for graph artifacts

Community reports aggregate dozens or hundreds of text units into a single summary. This creates a critical security risk: indirect information leakage. If any single text unit in that community is restricted, a global-search answer that synthesizes the report can leak confidential facts to callers who lack permission to read the original document, even when chunk-level vector search enforces strict .

Suppose three text units land in one Leiden community:

Text unitACLFact in unit
tu-public-sloall-staffp95 latency SLO is 200 ms
tu-security-keysecurity-onlyproduction key rotation requires break-glass approval
tu-public-runbookall-staffrestart cache before scale-out

An offline report over all three chunks can include the restricted key-rotation requirement. If global search admits that report for an unauthorized caller, the protected information has entered model context even though leaf retrieval blocked tu-security-key. Whether the final answer repeats it doesn't repair that access violation.

Admit a derived report only when the caller can read every contributing source at its current version. Sources may have different ACLs; a caller authorized for all of them can read the combined report. The report's permitted audience is their intersection, not their union. Output scrubbing cannot repair admitting an unauthorized source.

These are deployment controls to add and verify, not built-in authorization guarantees of the reference examples. Carry access grants through every tier of the graph index:

  1. Text units: Every chunk maintains tenant and principal ACL metadata (the standard contract for enterprise RAG).
  2. Entities and edges: Authorize the exact evidence used to construct each returned node description and relationship. A public endpoint doesn't make a restricted edge public. Similarly, an authorized fact about an entity doesn't grant access to descriptions merged from restricted documents.
  3. Community reports: Reject reports with any contributing source the caller cannot read. To serve different audiences, choose and evaluate a conservative pattern:
    • Equivalence-class partitioning: Build separate community hierarchies and reports strictly within homogeneous ACL boundaries (e.g. an all-staff report built exclusively from all-staff chunks).
    • Per-principal dynamic recomputation: At query time, filter the caller's authorized text units and generate community summaries on the fly from that permitted subset.
    • Fail-closed drop with local fallback: Drop any community report that contains one or more unauthorized source units from the map-reduce pool. If too many reports are dropped, fall back to local entity search over authorized nodes.
  4. Cascading revocation and deletion: Block dependent graph artifacts and cached contexts as soon as a source version or permission changes. Check current versions and grants on reads, then rebuild affected artifacts asynchronously. Track transitive dependencies through child reports too. A stale report must remain unreadable while rebuilding; asynchronous regeneration isn't permission to keep serving it.

The next lesson, RAG Security & Access Control, deepens prefilter vs post-filter vector search and operationalizes graph-artifact authorization. All graph retrieval modes require the same fail-closed principle: authz before map-reduce, not after the summary is already written into context.

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 [5][15][16].

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 [5].

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:

local-search-specific-questions.py
class EntityStore:
    def __init__(self, entity_names: list[str]) -> None:
        self.entity_names = entity_names

    def exact_name_matches(self, query: str, k: int = 10) -> list[str]:
        if type(k) is not int or k < 0:
            raise ValueError("k must be a nonnegative integer")
        query_lower = query.lower()
        matches = [
            entity for entity in self.entity_names if entity.lower() in query_lower
        ]
        return matches[:k]

def neighbors(graph: dict, node: str) -> set[str]:
    names = {edge["target"] for edge in graph["out_edges"].get(node, [])}
    for source, edges in graph["out_edges"].items():
        for edge in edges:
            if edge["target"] == node:
                names.add(source)
    return names

def expand_entity_neighborhood(graph: dict, entity_names: list[str], hops: int = 1) -> set[str]:
    if type(hops) is not int or hops < 0:
        raise ValueError("hops must be a nonnegative integer")
    nodes: set[str] = set(entity_names)
    frontier: set[str] = set(entity_names)
    for _ in range(hops):
        next_frontier: set[str] = set()
        for node in frontier:
            next_frontier.update(neighbors(graph, node))
        frontier = next_frontier - nodes
        nodes.update(frontier)
    return nodes

def collect_relationships(graph: dict, nodes: set[str]) -> list[tuple[str, str, str]]:
    return sorted(
        (source, edge["type"], edge["target"])
        for source, edges in graph["out_edges"].items()
        if source in nodes
        for edge in edges
        if edge["target"] in nodes
    )

def local_search(
    query: str,
    entity_store: EntityStore,
    graph: dict,
    text_units: dict[str, list[str]],
    community_reports: dict[str, list[str]],
) -> str:
    mapped_entities = entity_store.exact_name_matches(query, k=10)
    neighborhood_entities = sorted(expand_entity_neighborhood(graph, mapped_entities, hops=2))
    relationships = collect_relationships(graph, set(neighborhood_entities))
    context = "\n".join(
        [
            f"Entities: {neighborhood_entities}",
            f"Relationships: {relationships}",
            f"Text units: { {entity: text_units.get(entity, []) for entity in neighborhood_entities} }",
            f"Reports: { {entity: community_reports.get(entity, []) for entity in neighborhood_entities} }",
        ]
    )
    return f"Candidate context only (no generated answer):\n{context}"

graph = {
    "out_edges": {
        "inference-api": [{"target": "Redis Cache", "type": "depends_on"}],
        "Redis Cache": [{"target": "Old Maxmemory Policy", "type": "uses_policy"}],
    }
}

answer = local_search(
    "Why did inference-api breach the latency SLO?",
    EntityStore(["inference-api", "Redis Cache", "Old Maxmemory Policy"]),
    graph,
    text_units={"inference-api": ["inference-api breached the latency SLO after release 2026.06.14."]},
    community_reports={"Redis Cache": ["The cache still uses the old maxmemory policy; migration is blocked."]},
)

print(answer)
Output
Candidate context only (no generated answer):
Entities: ['Old Maxmemory Policy', 'Redis Cache', 'inference-api']
Relationships: [('Redis Cache', 'uses_policy', 'Old Maxmemory Policy'), ('inference-api', 'depends_on', 'Redis Cache')]
Text units: {'Old Maxmemory Policy': [], 'Redis Cache': [], 'inference-api': ['inference-api breached the latency SLO after release 2026.06.14.']}
Reports: {'Old Maxmemory Policy': [], 'Redis Cache': ['The cache still uses the old maxmemory policy; migration is blocked.'], 'inference-api': []}

This name matcher isn't semantic search. Its neighborhood function considers incoming and outgoing edges for candidate discovery while retaining each relation's original direction. The printed string is context, not an answer, and this isolated sketch has no ACL or source-content checks. The integrated exercise below adds those controls.

The actual context-builder problem includes a : entities, relationships, text units, and reports compete for room. The next greedy packer uses hypothetical scores and token counts. It doesn't optimize total utility; count the fully serialized context with the target tokenizer and reserve instructions, query, and output space separately.

pack-ranked-local-context-under-a-budget.py
from dataclasses import dataclass
import math

@dataclass(frozen=True)
class Candidate:
    source: str
    score: float
    tokens: int
    citation: str

def pack_context(candidates: list[Candidate], token_budget: int) -> list[Candidate]:
    if type(token_budget) is not int or token_budget < 0:
        raise ValueError("budget must be a nonnegative integer")
    if len({item.source for item in candidates}) != len(candidates):
        raise ValueError("deduplicate candidate identities before packing")
    if any(type(item.tokens) is not int or item.tokens <= 0 or not item.citation
           or not math.isfinite(item.score) for item in candidates):
        raise ValueError("candidates need positive token counts, finite scores, and citations")
    selected: list[Candidate] = []
    used = 0
    for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
        if used + candidate.tokens <= token_budget:
            selected.append(candidate)
            used += candidate.tokens
    return selected

candidates = [
    Candidate("relationship: depends_on", 0.98, 30, "incident-1842"),
    Candidate("text: maxmemory issue", 0.91, 55, "incident-2031"),
    Candidate("report: cache overview", 0.62, 80, "community-7"),
]
selected = pack_context(candidates, token_budget=90)

print("selected:", [item.source for item in selected])
print("citations:", [item.citation for item in selected])
print("tokens:", sum(item.tokens for item in selected))
Output
selected: ['relationship: depends_on', 'text: maxmemory issue']
citations: ['incident-1842', 'incident-2031']
tokens: 85

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

Map calls process separate report batches; reduce then selects rated points across those batches for the final answer. A point's rating is a relevance judgment, not an incident count or proof that its claim is true.[1][15]

The next synthetic fixture adds four report strings to expose map and reduce mechanics. A keyword rule assigns fixed ratings, and ties preserve input order. This is neither LLM summarization nor a ranking by incident frequency. It selects up to three matching reports and retains their report IDs; those IDs still need source-level provenance in a real index.

global-search-broad-questions.py
from dataclasses import dataclass

@dataclass(frozen=True)
class RatedPoint:
    text: str
    rating: int
    report_id: str

def batch_reports(reports: list[tuple[str, str]], batch_size: int = 2) -> list[list[tuple[str, str]]]:
    if type(batch_size) is not int or batch_size <= 0:
        raise ValueError("batch_size must be a positive integer")
    return [reports[index:index + batch_size] for index in range(0, len(reports), batch_size)]

def map_report_batch(query: str, report_batch: list[tuple[str, str]]) -> list[RatedPoint]:
    points: list[RatedPoint] = []
    query_lower = query.lower()
    asks_about_latency = any(term in query_lower for term in ("latency", "slo", "miss", "delay"))
    for report_id, report in report_batch:
        report_lower = report.lower()
        if asks_about_latency and any(term in report_lower for term in ("latency", "slo", "queue", "eviction")):
            points.append(RatedPoint(report, rating=9, report_id=report_id))
        elif "security" in query_lower and "credential" in report_lower:
            points.append(RatedPoint(report, rating=6, report_id=report_id))
    return points

def select_top_points(points: list[RatedPoint], top_k: int) -> list[RatedPoint]:
    if type(top_k) is not int or top_k < 0:
        raise ValueError("top_k must be a nonnegative integer")
    return sorted(points, key=lambda point: point.rating, reverse=True)[:top_k]

def global_search(
    query: str,
    reports_by_level: dict[int, list[tuple[str, str]]],
    level: int,
) -> str:
    """Select report-level candidates; no semantic synthesis is performed."""
    batches = batch_reports(reports_by_level[level], batch_size=2)
    mapped_points = [
        point
        for batch in batches
        for point in map_report_batch(query, batch)
    ]
    top_points = select_top_points(mapped_points, top_k=3)
    bullets = "\n".join(f"- [{point.report_id}] {point.text}" for point in top_points)
    return f"Selected synthetic report points for '{query}':\n{bullets or '(none)'}"

reports_by_level = {
    1: [
        ("report-cache", "Latency breaches cluster around cache eviction storms."),
        ("report-queue", "GPU queue starvation delays batch inference jobs."),
        ("report-eval", "Evaluation failures cluster around flaky judge prompts."),
        ("report-config", "SLO breaches recur after cache config drift."),
    ]
}

answer = global_search(
    "What are the main reasons services miss the latency SLO?",
    reports_by_level,
    level=1,
)

print(answer)
Output
Selected synthetic report points for 'What are the main reasons services miss the latency SLO?':
- [report-cache] Latency breaches cluster around cache eviction storms.
- [report-queue] GPU queue starvation delays batch inference jobs.
- [report-config] SLO breaches recur after cache config drift.

The level argument exposes a cost choice. Static global search builds token-bounded batches of eligible reports at the chosen level and runs map calls over those batches. Fifty reports don't imply fifty calls: packing, filtering, report lengths, and configured limits determine the batch count. Parallelism affects elapsed time, not the total number of billed calls.[17]

Dynamic community selection rates reports against the query before map-reduce. It starts with the Level-0 communities, explores children of admitted communities, and can exclude irrelevant branches.[18][19] In the illustrated latency example, Serving passes and its Cache and Queue children are rated; Eval fails and its children are skipped. These are supplied judgments, not conclusions from an executed model.

Predict the cost and recall consequence before running this custom policy. It admits only relevant leaf reports and has no recovery pass. Microsoft's selector has additional behavior, including a fallback when no relevant community is found; this cell isolates the pruning risk rather than copying that implementation.

a-relevance-mistake-hides-a-child.py
import math

roots = ["serving", "eval"]
children = {
    "serving": ["cache", "queue"],
    "eval": ["judges", "metrics"],
}

def select_leaf_reports(scores, threshold=0.5):
    if not math.isfinite(threshold) or not 0 <= threshold <= 1:
        raise ValueError("invalid threshold")
    visited, selected = [], []

    def visit(report):
        score = scores[report]
        if not math.isfinite(score) or not 0 <= score <= 1:
            raise ValueError("invalid authored relevance score")
        visited.append(report)
        if score < threshold:
            return
        descendants = children.get(report, [])
        if not descendants:
            selected.append(report)
        for child in descendants:
            visit(child)

    for root in roots:
        visit(root)
    return visited, selected

# Authored query-relevance inputs, not calibrated probabilities or severity.
scores = {"serving": 0.9, "eval": 0.1, "cache": 0.8, "queue": 0.7}
visited, selected = select_leaf_reports(scores)
print("rated:", visited)
print("selected:", selected)

missed_parent = dict(scores, serving=0.2)
visited, selected = select_leaf_reports(missed_parent)
assert "cache" not in visited and scores["cache"] >= 0.5
print("parent false negative:", visited, "selected:", selected)
Output
rated: ['serving', 'cache', 'queue', 'eval']
selected: ['cache', 'queue']
parent false negative: ['serving', 'eval'] selected: []

Microsoft's November 15, 2024 AP News experiment reports 77% lower average token cost for dynamic versus static Level-1 search on 50 global questions. The LLM-judge comparisons found no statistically significant difference on the reported quality criteria; this isn't a proof of equivalent factual accuracy.[18]

The deeper Level-3 comparison used the 29 of 50 questions for which it included more reports than static Level 1. Table 1 reports a 58.8% comprehensiveness win rate on that subset and 34% higher average cost. Neither result is a fixed saving for this incident example or an unbiased all-question estimate of deeper-search quality.[18]

Rating calls also cost tokens and time. Measure saved map work and missed evidence together: an inexpensive false negative at a parent can conceal a useful descendant.

DRIFT (Dynamic Reasoning and Inference with Flexible Traversal) bridges global thematic search with fine-grained local search [16].

DRIFT combines community context with iterative local-search follow-ups:

  1. Thematic primer: It uses semantically related community reports to build initial answers and follow-up questions.
  2. Follow-up query generation: Based on the primer, the engine generates targeted follow-up questions to drill into specific ambiguities or entity details.
  3. Local searches: It builds ranked local context for follow-up questions and can generate further questions. This isn't necessarily an explicit directed path traversal.
  4. Answer synthesis: The system merges the resulting question-answer tree into a final response grounded in both high-level context and low-level source citations.

Use DRIFT when a query spans both macro-level themes and micro-level entity facts, and benchmark its latency and token spend against standard local search on representative workloads.

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][5]. One hybrid architecture looks like this:

A router is a policy to evaluate, not a guarantee. Measure supported-answer accuracy, , 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. The numbers below are hypothetical gate inputs, not measurements from executed retrievers. The function only checks the threshold policy and returns no route if none qualifies.

release-graph-routes-from-evaluation.py
from dataclasses import dataclass
import math

@dataclass(frozen=True)
class RouteResult:
    route: str
    query_class: str
    supported_accuracy: float
    p95_ms: int

def release_route(
    results: list[RouteResult],
    query_class: str,
    minimum_accuracy: float,
    maximum_p95_ms: int,
) -> str | None:
    if not math.isfinite(minimum_accuracy) or not 0 <= minimum_accuracy <= 1 or not math.isfinite(maximum_p95_ms) or maximum_p95_ms < 0:
        raise ValueError("invalid quality or latency threshold")
    if any(not math.isfinite(r.supported_accuracy) or not 0 <= r.supported_accuracy <= 1
           or not math.isfinite(r.p95_ms) or r.p95_ms < 0 for r in results):
        raise ValueError("invalid evaluation metric")
    eligible = [
        result for result in results
        if result.query_class == query_class
        and result.supported_accuracy >= minimum_accuracy
        and result.p95_ms <= maximum_p95_ms
    ]
    if not eligible:
        return None
    return max(
        eligible,
        key=lambda result: (result.supported_accuracy, -result.p95_ms),
    ).route

results = [
    RouteResult("basic", "fact_lookup", 0.96, 95),
    RouteResult("local", "fact_lookup", 0.96, 240),
    RouteResult("basic", "corpus_trend", 0.61, 92),
    RouteResult("global", "corpus_trend", 0.91, 580),
]

print("fact route:", release_route(results, "fact_lookup", 0.90, 200))
print("trend route:", release_route(results, "corpus_trend", 0.90, 700))
Output
fact route: basic
trend route: global

One 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 [10].

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 [20]. Their indexing-methods docs estimate that graph extraction (entity and relationship extraction plus their summarization) is roughly 75% of standard indexing cost [9].

If your use case is mostly global summarization, benchmark FastGraphRAG. It replaces LLM entity extraction with NLP noun phrases and defines edges by co-occurrence within a text unit. Co-occurrence doesn't assert a dependency or cause. It still generates community reports with an LLM; actual savings and graph usefulness depend on the corpus and configuration.[9]

LazyGraphRAG in practice

Microsoft Research introduced LazyGraphRAG in November 2024 [21]. 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 6, 2025 editor's note reports integration into Microsoft Discovery and Azure Local services. That's historical product evidence, not a feature contract for the open-source CLI. Current Microsoft documentation calls the local offering Agentic Retrieval in Foundry Local and marks it as preview; verify the intended product's supported search modes before selecting an implementation.[22]

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 [21]. 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 [21]. 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 alternative architectures against standard GraphRAG.

LightRAG: Dual-level retrieval and incremental updates

LightRAG (Guo et al., first posted October 2024) extracts entities and relationships and creates text profiles for graph records. Its dual-level retrieval concerns query keywords: low-level keywords retrieve entity detail, while high-level keywords retrieve broader relationships. It combines graph records and linked source chunks without standard GraphRAG's hierarchy of community reports.[23]

High-level retrieval isn't a separate set of community-report-like topic nodes, nor does it count every corpus theme. The paper's incremental algorithm extracts a new document's graph and merges its nodes and edges into the existing graph. This avoids report regeneration, but doesn't make additions free or establish correct deletion, revocation, and contradiction handling.[23]

Compare what each design does, rather than assigning universal quality or cost rankings:

DesignIndexing distinctionQuery evidenceMaintenance question
Vector RAGChunk embeddings, optionally sparse indexingRanked source chunks; expansion can add hopsCan corrected and deleted chunks be withdrawn promptly?
Standard GraphRAGLLM graph extraction and reportsBasic, local, global, or DRIFT contextWhich merges, communities, and reports change after an update?
FastGraphRAGNLP co-occurrence graph plus LLM reportsReport synthesis or configured graph/text contextAre noisy noun phrases and report dependencies manageable?
LazyGraphRAGNLP concept graph; no precomputed reportsBudgeted relevance tests and query-time synthesisDoes deferred work suit query volume and refresh cadence?
LightRAGLLM graph records and profiles; no community-report hierarchyEntity and relationship retrieval plus source chunksAre incremental merges and source removal auditable?

If reusable community reports are a core product requirement, the standard index provides structured summary artifacts that alternative paths intentionally omit.

Use observed quality and workload volume to compare indexing strategies rather than choosing from architecture labels:

choose-index-strategy-from-workload.py
from dataclasses import dataclass
import math

@dataclass(frozen=True)
class Strategy:
    name: str
    index_cost: float
    query_cost: float
    supported_accuracy: float

def choose_strategy(
    strategies: list[Strategy], query_count: int, accuracy_floor: float
) -> tuple[str, float] | None:
    if type(query_count) is not int or query_count < 0 or not math.isfinite(accuracy_floor) or not 0 <= accuracy_floor <= 1:
        raise ValueError("invalid workload or quality threshold")
    if any(not all(math.isfinite(value) for value in (s.index_cost, s.query_cost, s.supported_accuracy))
           or min(s.index_cost, s.query_cost) < 0 or not 0 <= s.supported_accuracy <= 1 for s in strategies):
        raise ValueError("invalid cost or quality input")
    passing = [
        strategy for strategy in strategies
        if strategy.supported_accuracy >= accuracy_floor
    ]
    if not passing:
        return None
    winner = min(
        passing,
        key=lambda strategy: strategy.index_cost + query_count * strategy.query_cost,
    )
    total_cost = winner.index_cost + query_count * winner.query_cost
    return winner.name, total_cost

# Hypothetical cost units and quality inputs, not measured product results.
strategies = [
    Strategy("lazy", index_cost=1.0, query_cost=1.7, supported_accuracy=0.91),
    Strategy("precomputed_reports", index_cost=200.0, query_cost=0.8, supported_accuracy=0.93),
]

print("few queries:", choose_strategy(strategies, query_count=10, accuracy_floor=0.90))
print("many queries:", choose_strategy(strategies, query_count=500, accuracy_floor=0.90))
Output
few queries: ('lazy', 18.0)
many queries: ('precomputed_reports', 600.0)

Query cost

Total cost depends on index refreshes and query volume, not just one indexing bill. Runtime queries can also cost more than a simple vector baseline:

Mitigation strategies

At query time, evaluate whether coarse reports answer adequately before paying for finer context. Larger level numbers mean deeper subdivisions; more reports can increase map work, while missing reports and unbalanced branches complicate coverage. Greater depth doesn't guarantee a better answer.[1][15]

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 [24][10].

  • 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:

ScenarioStart with vector RAGAdd GraphRAG when
Fact lookup ("What is the embeddings API timeout?")Strong, cheap baselineExtra structure rarely pays off
Entity investigation ("Why did inference-api breach the SLO?")Works if evidence sits in similar chunksLinked entities or reports recover missing hops
Global summary ("What are the top 3 SLO reasons?")Small top-k underrepresents themesCommunity reports are built for this class
Relationship-heavy question ("How does Redis config drift affect inference latency?")Needs expansion or luckGraph-linked evidence can help; test path policy
Corpus change rateFrequent edits, direct lookupsStable 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:

  1. Users ask questions that need synthesis across many documents
  2. You need to trace influence chains (what depends on what) with provenance
  3. Global sensemaking is a core use case, not an edge case
  4. 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 , diagnose each trap as symptom, cause, and fix.

Mistake 1: "Use a bigger context window"

  • Symptom: You stuff 100,000 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 [6].

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

  • 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, and redis.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: Broad entity selection and poor normalization can introduce low-information hubs. This isn't an inevitable result of omitting a strict ontology; FastGraphRAG deliberately uses noun phrases.

  • Fix: Evaluate domain-relevant types such as Service, Datastore, Region, Incident, ConfigPolicy, and Runbook. Inspect high-degree nodes and review filtering against evidence recall so cleanup doesn't remove meaningful connections.

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.

Run one retrieval comparison

The earlier cells isolate mechanisms and run on CPU without credentials. Download the complete incident graph lab to execute the same three-record example end to end with uv run incident_graph_lab.py. It performs lexical top-two retrieval, follows two directed fixture edges, and returns source IDs. No embedding model, LLM extractor, Leiden optimizer, or answer generator is invoked.

The expected evidence set {1842, 2031, 3155} is a test-author annotation for the incident question. Each edge stores its supporting quotation and source-text hash. Before following it, retrieval checks the caller's group, the source version, and the quotation. A hash binds an edge to a version; it doesn't independently establish semantic support.

After saving the download under assets/, this cell runs its checks:

run-the-incident-retrieval-lab.py
import runpy

runpy.run_path("assets/incident_graph_lab.py", run_name="__main__")
Output
lexical top-two: [1842, 3155] evidence recall: 0.667
graph expansion: [1842, 2031, 3155] evidence recall: 1.0
false causal edge with genuine quote: source checks pass; claim remains unsupported
source mutation, deletion, revocation, direction, hop, and report-admission checks pass

The lexical baseline retrieves 1842 and 3155, so annotated evidence recall is 2/3. Graph expansion adds 2031 through the explicitly supplied dependency path, raising recall to 3/3 on this one fixture. Removing an edge loses that gain. Revoking, editing, or deleting 2031 blocks both its graph contribution and any report derived from its old version.

The lab also replaces the reviewed policy edge with inference-api --[will_cause]--> future SLO breach, keeping the genuine quotation and matching source hash. That false edge still adds record 2031 in one hop. Its source checks pass because the code verifies identity, version, access, and quotation presence, not whether the quotation entails the relation. A valid citation can therefore accompany a false claim. Measure extraction precision/recall and answer support separately on reviewed examples. The group argument also assumes an already authenticated caller; it isn't an authentication service or a complete multi-tenant policy engine.

Try it yourself

Stay with the three incidents from the opening. Work this on paper before reading the sketch.

Queries

  1. "What is the timeout for the embeddings API?"
  2. "Why did inference-api breach the latency SLO, and is this likely to happen again?"
  3. A staff user (not security) asks for "top recurring operational themes." One Leiden community mixed tu-public-slo, tu-security-key, and tu-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

  1. Basic / vector search. The timeout is a local fact. Don't pay for community map-reduce.

  2. Local search with provenance. Retrieve the dependency from 1842, policy use from 2031, and observed breach and rollback from 3155. State the observations and the unresolved migration. These records don't establish why the breach occurred or a recurrence probability. An uncited will_cause edge would add no support.

  3. Fail closed before map-reduce. Drop the mixed community report. Use an all-staff equivalence-class report, recompute a per-principal view without tu-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

The runnable comparison recovered one deliberately linked note. Now remove the dependency edge, add a false edge, and paraphrase the question. Record which failures belong to retrieval, extraction, or answer support rather than counting every retrieved ID as a correct answer.

Before choosing a production route, review real fact lookups, entity investigations, and corpus-wide questions separately. Compare cited-answer quality, source-access failures, latency, and total refresh-plus-query cost against the simpler baseline. One successful fixture doesn't establish a release threshold.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.An analyst asks for the top three recurring SLO-breach causes across 10,000 incidents. A vector route returns five highly similar write-ups from one service family. Which route addresses the unmet requirement?
2.A team is extending standard GraphRAG with a domain schema and reviewed identity rules. Which offline workflow preserves those controls and report provenance?
3.A query asks why inference-api breached its SLO. Evidence spans a service incident, a cache-eviction record, and a maxmemory note that omits the service name. Which context builder fits the entity-specific question?
4.A path has cited depends_on and uses_policy edges, then an uncited will_cause -> future breach edge. The user asks whether another breach will happen. Which answer boundary is defensible?
5.A Service and a Datastore both have title gateway, and one extracted edge omits its endpoint type. Which resolver behavior protects graph identity?
6.The only source text supporting redis-cache uses_policy old-maxmemory-policy is deleted. A community report also summarizes that configuration. Which update preserves graph integrity?
7.A latency query rates Serving at 0.2 and Eval at 0.1. A simple selector requires 0.5, visits children only after their parent passes, and has no fallback. Cache's supplied score is 0.8. What happens?
8.In a hypothetical evaluation, fact gates are accuracy at least 0.90 and p95 at most 200 ms; trend gates use 0.90 and 700 ms. Basic/local fact results are 0.96/95 and 0.96/240; basic/global trend results are 0.61/92 and 0.91/580. Which routes pass?
9.A corpus changes weekly, global questions are rare, and no product feature consumes reusable community reports. Which graph-assisted candidate best matches this workload for an initial benchmark?
10.A staff user's leaf retrieval is ACL-filtered, but global search admits a community report built from public incidents and a security-only key-rotation unit. Which control preserves authorization continuity?

10 questions remaining.

References

From Local to Global: A Graph RAG Approach to Query-Focused Summarization.

Edge, D., et al. · 2024 · arXiv preprint

https://arxiv.org/abs/2404.16130

GraphRAG Indexing Overview

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/index/overview/

GraphRAG Default Dataflow

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/index/default_dataflow/

Unifying Large Language Models and Knowledge Graphs: A Roadmap.

Pan, S., et al. · 2024 · arXiv preprint

https://arxiv.org/abs/2306.08302

GraphRAG Local Search

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/query/local_search/

Lost in the Middle: How Language Models Use Long Contexts

Liu, N.F., et al. · 2024 · TACL 2024

https://aclanthology.org/2024.tacl-1.9/

Semi-Supervised Classification with Graph Convolutional Networks

Kipf, T. N. & Welling, M. · 2016 · ICLR 2017

https://arxiv.org/abs/1609.02907

Neural Message Passing for Quantum Chemistry

Gilmer, J., Schoenholz, S. S., Riley, P. F., Vinyals, O., & Dahl, G. E. · 2017 · ICML 2017

https://arxiv.org/abs/1704.01212

GraphRAG Indexing Methods

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/index/methods/

GraphRAG Outputs

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/index/outputs/

GraphRAG: Undirected Weighted Clustering Input

Microsoft GraphRAG Contributors · 2026

https://github.com/microsoft/graphrag/blob/82b87bf0434fc69f857663280e2c36a1c48e52fe/packages/graphrag/graphrag/index/operations/cluster_graph.py

From Louvain to Leiden: guaranteeing well-connected communities.

Traag, V.A., Waltman, L., & van Eck, N.J. · 2019 · Scientific Reports

https://arxiv.org/abs/1810.08473

GraphRAG: Hierarchical Leiden Objective Configuration

Microsoft GraphRAG Contributors · 2026

https://github.com/microsoft/graphrag/blob/82b87bf0434fc69f857663280e2c36a1c48e52fe/packages/graphrag/graphrag/graphs/hierarchical_leiden.py

GraphRAG: Default Community Report Prompt

Microsoft GraphRAG Contributors · 2026

https://github.com/microsoft/graphrag/blob/82b87bf0434fc69f857663280e2c36a1c48e52fe/packages/graphrag/graphrag/prompts/index/community_report.py

GraphRAG Global Search

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/query/global_search/

GraphRAG DRIFT Search

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/query/drift_search/

GraphRAG: Global Search Report Context Builder

Microsoft GraphRAG Contributors · 2026

https://github.com/microsoft/graphrag/blob/82b87bf0434fc69f857663280e2c36a1c48e52fe/packages/graphrag/graphrag/query/structured_search/global_search/community_context.py

GraphRAG: Improving global search via dynamic community selection.

Li, B., Trinh, H., Edge, D., & Larson, J. · 2024 · Microsoft Research Blog

https://www.microsoft.com/en-us/research/blog/graphrag-improving-global-search-via-dynamic-community-selection/

GraphRAG: Dynamic Community Selection

Microsoft GraphRAG Contributors · 2026

https://github.com/microsoft/graphrag/blob/82b87bf0434fc69f857663280e2c36a1c48e52fe/packages/graphrag/graphrag/query/context_builder/dynamic_community_selection.py

GraphRAG Getting Started

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/get_started/

LazyGraphRAG: Setting a New Standard for Quality and Cost

Larson, J. & Truitt, S. (Microsoft Research) · 2024

https://www.microsoft.com/en-us/research/blog/lazygraphrag-setting-a-new-standard-for-quality-and-cost/

Agentic Retrieval in Foundry Local: Platform Overview

Microsoft Learn · 2026

https://learn.microsoft.com/en-us/azure/azure-arc/agents-tools-foundry-local/overview

LightRAG: Simple and Fast Retrieval-Augmented Generation

Guo, Z., Xia, L., Yu, Y., Ao, T., & Huang, C. · 2024

https://arxiv.org/abs/2410.05779

GraphRAG CLI Reference

Microsoft GraphRAG Documentation · 2025

https://microsoft.github.io/graphrag/cli/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.