Skip to content

GraphRAG Deep Dive

Duration: 90 minutes Level: Advanced Module: fcc.rag.graphrag Pillar: v1.5.0 Pillar B

This tutorial teaches you how to run graph-aware retrieval-augmented generation over an FCC KnowledgeGraph. You will learn the six-stage pipeline (seed retrieval, BFS graph walk, Zachman-cell filter, persona-aware scoring, LYRA augmentation, context assembly), tune each knob safely, and decide when GraphRAG is the right tool versus a flat RAG pipeline.

Why GraphRAG?

Classical RAG retrieves a flat list of chunks ranked by embedding similarity. You get documents that look like the query, but not necessarily documents that are connected to the answer. For a framework like FCC — where the knowledge graph already encodes nine edge types (dependsOn, orchestrates, critiques, federatesTo, and others) — that structural signal is too valuable to throw away.

GraphRAG walks the graph. A query resolves to one or more seed nodes; a BFS traversal expands out to a configurable depth; the candidate set is the union of semantically-similar chunks and graph-neighborhood nodes. A Zachman-cell filter narrows the result to the enterprise perspective you care about, and a persona-aware scoring pass reranks whatever remains.

The module lives at src/fcc/rag/graphrag.py. It is deterministic (BFS order, persona boost, and Zachman filter are all deterministic, so downstream audit and replay work), gracefully degrades (no retriever, no LYRA bridge, no problem — it falls back to a label-match retriever and the built-in LyraMockBridge), and returns frozen results (GraphRAGResult is a frozen dataclass of immutable tuples, directly JSON-serializable for provenance logs).

The six-stage pipeline

A single GraphRAG.query() call runs six stages in order:

  1. Seed retrieval. Resolve the query text into up to k seed node IDs. When a SemanticRetriever is wired up, the retriever surfaces candidate chunks whose chunk_id is expected to match a KG node_id. When no retriever is supplied, a case-insensitive label/id substring match runs over every node in the graph.
  2. BFS graph walk. From each seed, walk outward up to max_hops. The walk follows both outgoing and incoming edges (undirected traversal) and records the shortest path for every visited node.
  3. Zachman-cell filter (optional). When zachman_cell is provided, drop any visited node whose properties["zachman_cell"] does not match either the exact coordinate ("row:col") or the target perspective alone. Edges and paths are trimmed in lockstep so the subgraph remains coherent.
  4. Persona-aware scoring. Each surviving node gets a deterministic score in [0.1, 1.0]. Signals: exact label/id substring match against the query (+0.5), persona match anywhere in the node (+0.4), baseline presence (+0.1). Scores are sorted descending.
  5. LYRA augmentation. The LYRA bridge's graph_of_thought_query is invoked to surface cross-namespace concepts not present in the local KG. Deduplicated nodes/edges/paths merge into the result at a baseline 0.25 score.
  6. Context assembly. The top-scored nodes are rolled up into an LLM-ready string via assemble_context(), respecting a max_tokens budget (4 chars per token heuristic).

Architecture overview

+--------------------------+
|  GraphRAG.query(text,    |
|    k, persona_id,        |
|    zachman_cell,         |
|    max_hops)             |
+------+------+------+-----+
       |      |      |
       v      v      v
  +---------+ +----+ +-------------+
  |  Seed   | | BFS| | Zachman     |
  | retriever |    | | filter      |
  +----+----+ +-+--+ +---+---------+
       |        |        |
       v        v        v
    +----------------------+
    | persona-aware scoring |
    +----------+-----------+
               |
               v
    +----------+-----------+
    |  LYRA augmentation   |
    +----------+-----------+
               |
               v
    +----------+-----------+
    | assemble_context()   |
    +----------+-----------+
               |
               v
         GraphRAGResult

Prerequisites

Install the framework in dev mode (gives you a KG builder, a mock LYRA bridge, and a mock embedding provider out of the box):

make install-dev

No API keys are required for this tutorial — GraphRAG runs fully offline against the built-in 164-persona KG.

A minimal first query

from fcc.knowledge.builders import build_full_fcc_graph
from fcc.rag.graphrag import GraphRAG

graph = build_full_fcc_graph()
pipeline = GraphRAG(knowledge_graph=graph)

result = pipeline.query("research crafter")
print(f"Nodes surfaced: {len(result.nodes)}")
print(f"Top scored: {result.scored[:3]}")
print()
print(result.context_text[:400])

No retriever, no Zachman cell, no persona — just a text query. The pipeline falls back to label matching, runs a three-hop BFS walk, and rolls up the highest-scored nodes into a context string you can drop straight into a system prompt.

Adding a semantic retriever

When the KG is large, label matching is too coarse. Wire up a SemanticRetriever so the seed step uses embedding similarity:

from fcc.knowledge.builders import build_full_fcc_graph
from fcc.rag.graphrag import GraphRAG, retriever_from_graph

graph = build_full_fcc_graph()

# Convenience helper — turns every KG node into a one-sentence chunk keyed by
# node_id so the retriever surfaces them via label match.
retriever = retriever_from_graph(graph)

pipeline = GraphRAG(knowledge_graph=graph, retriever=retriever)
result = pipeline.query("how do personas coordinate across federated projects", k=5)

for nid, score in result.scored[:5]:
    print(f"  [{score:.2f}] {nid}")

When you already have a prose corpus (persona YAML files, guidebook chapters, ADRs), build the retriever from those chunks instead and pass it through. The pipeline maps each retrieved chunk back to a KG node by chunk_id, and falls back to label matching when no direct match is found.

Filtering by Zachman cell

The Zachman Framework is a 6x6 matrix. Rows are perspectives (planner, owner, architect, engineer, subcontractor, user); columns are interrogatives (what, how, where, who, when, why). A ZachmanCell is the intersection — ZachmanCell(Perspective.ARCHITECT, Interrogative.HOW) is the architect's process view.

Filtering narrows the subgraph to the cell the caller cares about. A workflow operator answering "how does the incident runbook get authored?" filters to HOW. A data architect answering "what entities does this vocabulary cover?" filters to WHAT.

from fcc.classification.zachman import (
    Interrogative, Perspective, ZachmanCell,
)
from fcc.knowledge.builders import build_full_fcc_graph
from fcc.rag.graphrag import GraphRAG

graph = build_full_fcc_graph()
pipeline = GraphRAG(knowledge_graph=graph)

cell = ZachmanCell(
    perspective=Perspective.ARCHITECT,
    interrogative=Interrogative.HOW,
)

result = pipeline.query(
    "how is a runbook approved before production release",
    zachman_cell=cell,
    max_hops=2,
)
print(f"After HOW-ARCHITECT filter: {len(result.nodes)} nodes remain")

Nodes survive the filter when their properties["zachman_cell"] either equals the exact coordinate ("architect:how") or has the same perspective row ("architect"). Nodes without a zachman_cell property are dropped.

Two-level filter matching

The filter accepts three encodings in the node's properties["zachman_cell"]:

  • A ZachmanCell instance (matched exactly or by row).
  • A dict deserializable via ZachmanCell.from_dict.
  • A string — either "architect:how" (full coordinate) or "architect" (perspective only).

This permissive matching is deliberate: not every KG node cares about the column dimension, so allowing a node to anchor to a row-only perspective keeps the filter useful during ecosystem evolution.

Persona-aware scoring

Passing persona_id boosts nodes that reference that persona. The matcher checks label, node_id, all properties keys/values, and the node_type == NodeType.PERSONA identity test.

from fcc.knowledge.builders import build_full_fcc_graph
from fcc.rag.graphrag import GraphRAG

graph = build_full_fcc_graph()
pipeline = GraphRAG(knowledge_graph=graph)

result = pipeline.query(
    "what quality gates guard a deployment",
    persona_id="DGS",  # Data Governance Steward
    k=8,
)
for nid, score in result.scored[:5]:
    node = graph.get_node(nid)
    print(f"  [{score:.2f}] {nid}: {node.label if node else '?'}")

Score anatomy under persona boost:

Condition Contribution
Node present in the BFS result +0.1 baseline
Query text appears in node label/id +0.5
Persona id appears in node label/id/properties +0.4
All three conditions 1.0 (clamped)

LYRA augmentation

LYRA is the graph-of-thought service in the FCC ecosystem. When lyra.api is not importable (the default today), GraphRAG's LYRA call falls through to the built-in LyraMockBridge, which walks a minimal seed KG backed by SKOS-proxy edges. Either way, augmentation nodes merge into the final result at a baseline score of 0.25.

To disable LYRA augmentation for A/B comparison, inject a bridge whose graph_of_thought_query returns an empty dict:

class NullBridge:
    def graph_of_thought_query(self, query, depth=3):
        return {"nodes": [], "edges": [], "paths": []}
    def vocab_lookup(self, term, namespace=""):
        return []
    def harmonic_expand(self, concept):
        return []

pipeline = GraphRAG(
    knowledge_graph=graph,
    lyra_bridge=NullBridge(),  # skip augmentation
)

To seed a richer KG into the mock bridge for testing:

from fcc.knowledge.lyra_bridge import LyraMockBridge
from fcc.knowledge.builders import build_full_fcc_graph

custom_bridge = LyraMockBridge(graph=build_full_fcc_graph())
pipeline = GraphRAG(knowledge_graph=graph, lyra_bridge=custom_bridge)

See the LYRA knowledge graph integration tutorial for the full LYRA bridge surface.

Inspecting the result

GraphRAGResult is a frozen dataclass with five fields:

result = pipeline.query("persona", zachman_cell=cell, persona_id="RC")

print(f"Nodes:        {len(result.nodes)}")
print(f"Edges:        {len(result.edges)}")
print(f"Paths:        {len(result.paths)}")
print(f"Top score:    {result.scored[0] if result.scored else 'N/A'}")
print(f"Context chars:{len(result.context_text)}")

# JSON-serialize for audit
import json
payload = json.dumps(result.to_dict(), indent=2)
# ... and deserialize
from fcc.rag.graphrag import GraphRAGResult
restored = GraphRAGResult.from_dict(json.loads(payload))
assert len(restored.nodes) == len(result.nodes)

Each path in result.paths is a tuple of node IDs from a seed to the visited node — use it to explain to the user why a given node ended up in the result ("seed X reached node Y through edge type Z").

Tuning guide

max_hops

The BFS depth governs recall at the cost of latency and drift. Practical settings:

max_hops Use case
0 Return only the seed nodes — useful when the seed set is already precise (e.g. you handed in persona ids directly).
1 One-hop neighborhood — fast, high precision, low recall. Good for interactive chat.
2 Default-sweet-spot for mixed ecosystem queries.
3 Ample recall for open-ended questions; the GraphRAG.query default.
>= 4 Risky — in a dense KG each additional hop roughly doubles the candidate set. Use only with a tight Zachman filter.

k (seed count)

k caps how many seeds enter the BFS phase. Raising k gives the scoring stage more candidates to rerank but does not change BFS depth.

  • k=1 for precise nearest-neighbor questions
  • k=5 for "show me the relevant personas" questions
  • k=10+ only when a semantic retriever is wired up, since the label-match fallback becomes noisy with wide k.

zachman_cell

The filter is O(n) over visited nodes — cheap. Use it whenever you can identify a dominant enterprise perspective. When you are uncertain, omit it rather than picking the wrong cell: a missed cell filter drops legitimate answers.

Scoring knobs

Scoring weights are fixed in the current implementation. If you need a different weighting (e.g. de-emphasize persona matches), subclass GraphRAG and override _score_nodes. Keep the result deterministic — callers depend on that for audit replay.

When to use GraphRAG vs vanilla RAG

Signal Vanilla RAG GraphRAG
Corpus is mostly prose with no structural links Yes Overkill
Corpus maps to a curated knowledge graph Either Yes
Need to explain why an answer surfaced Hard Easy (paths)
Query is about a specific persona/archetype Either Yes (persona boost)
Enterprise perspective matters (architect vs engineer view) No tooling Yes (Zachman filter)
Latency budget under 100 ms Vanilla is faster Acceptable up to max_hops=2
Corpus is multi-project / federated Vanilla flattens Yes (LYRA + federation)

Rule of thumb: if the answer to your question would involve tracing relationships (e.g. "who critiques the Blueprint Crafter's output before the Build Reviewer signs off?"), reach for GraphRAG. If the answer is a block of prose (e.g. "summarize the RISCEAR overview"), vanilla RAG is cheaper and good enough.

End-to-end runnable example

from fcc.classification.zachman import (
    Interrogative, Perspective, ZachmanCell,
)
from fcc.knowledge.builders import build_full_fcc_graph
from fcc.knowledge.lyra_bridge import LyraMockBridge
from fcc.rag.graphrag import GraphRAG, assemble_context, retriever_from_graph

# 1. Build / obtain the KG
graph = build_full_fcc_graph()
print(f"KG nodes: {graph.node_count}, edges: {len(graph.all_edges())}")

# 2. Seed retriever from the KG itself (no prose corpus needed)
retriever = retriever_from_graph(graph)

# 3. LYRA bridge — mock with an augmented seed graph
lyra = LyraMockBridge(graph=graph)

# 4. Compose GraphRAG
pipeline = GraphRAG(
    knowledge_graph=graph,
    retriever=retriever,
    lyra_bridge=lyra,
)

# 5. Run a filtered query
result = pipeline.query(
    text="how do we audit documentation quality across projects",
    k=6,
    persona_id="DE",  # Documentation Evangelist
    zachman_cell=ZachmanCell(
        perspective=Perspective.ARCHITECT,
        interrogative=Interrogative.HOW,
    ),
    max_hops=2,
)

# 6. Inspect
print(f"Surfaced {len(result.nodes)} nodes along {len(result.paths)} paths")
for nid, score in result.scored[:5]:
    node = graph.get_node(nid)
    label = node.label if node else "?"
    print(f"  [{score:.2f}] {nid}: {label}")

# 7. Drop the context into an LLM prompt
context = assemble_context(result, max_tokens=1500)
system_prompt = (
    "You are a documentation auditor. Answer using only the context below.\n\n"
    f"{context}\n\n"
    "Question: how do we audit documentation quality across projects?"
)
print(system_prompt[:600])

Expected behavior: the result contains a mix of persona nodes (DE, peers who collaborate with DE), workflow-graph nodes filtered to the architect/how cell, and any LYRA-augmented cross-namespace concepts. Paths reveal which seed reached which node, and the assemble_context() output respects the token budget while preserving the top-scored node even if it is long.

Troubleshooting

No nodes surface at all. Check graph.node_count — an empty KG returns an empty result immediately. If the KG is non-empty, lower the Zachman filter or widen the query text. The label-match fallback needs at least one token in common with a node label.

Scoring looks wrong. Print result.scored and verify the persona id you passed is actually present in KG node properties. The persona boost is a substring match, so "RC" will match any node mentioning "RC" — narrow the identifier or lean on zachman_cell to disambiguate.

LYRA augmentation adds noise. Inject a NullBridge (see above) or use a stricter zachman_cell filter — LYRA nodes pass through the same filter as KG-sourced nodes.

context_text is truncated. Raise max_tokens on assemble_context() (default 2000, i.e. ~8000 chars). Remember: the top-scored node is always emitted in full even if it alone overflows the budget.

Summary

In this tutorial you learned how to:

  • Compose a GraphRAG pipeline with a KnowledgeGraph, optional SemanticRetriever, and optional LyraBridgeProtocol
  • Run a query through six stages: seed, BFS, Zachman filter, score, LYRA augment, assemble context
  • Tune max_hops, k, and the Zachman filter for recall vs latency
  • Pick GraphRAG versus vanilla RAG based on corpus structure and query shape
  • Serialize GraphRAGResult for audit and replay

Next steps

  • Wire GraphRAG into a chat loop — the context_text slot drops straight into a system prompt, and paths gives you explainability out of the box.
  • Benchmark the pipeline against your corpus with publications/scripts/benchmark_hot_paths.py.
  • Configure a live LYRA bridge once the lyra.api package is published. The graceful fallback means no code changes are needed — see the LYRA integration tutorial.
  • ADR-011 GraphRAG + Zachman filtering — docs/decisions/ADR-011_graphrag_zachman_filtering.md
  • Six Pillars overview — publications/chapters/arch/six-pillars.qmd
  • GraphRAG chapter stub — publications/chapters/arch/graphrag.qmd
  • Source — src/fcc/rag/graphrag.py
  • Sequence diagram — docs/architecture/sequence-diagrams/graphrag-zachman-filter.md