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:
- Seed retrieval. Resolve the query text into up to
kseed node IDs. When aSemanticRetrieveris wired up, the retriever surfaces candidate chunks whosechunk_idis expected to match a KGnode_id. When no retriever is supplied, a case-insensitive label/id substring match runs over every node in the graph. - 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. - Zachman-cell filter (optional). When
zachman_cellis provided, drop any visited node whoseproperties["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. - 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. - LYRA augmentation. The LYRA bridge's
graph_of_thought_queryis invoked to surface cross-namespace concepts not present in the local KG. Deduplicated nodes/edges/paths merge into the result at a baseline0.25score. - Context assembly. The top-scored nodes are rolled up into an LLM-ready
string via
assemble_context(), respecting amax_tokensbudget (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):
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
ZachmanCellinstance (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=1for precise nearest-neighbor questionsk=5for "show me the relevant personas" questionsk=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
GraphRAGpipeline with aKnowledgeGraph, optionalSemanticRetriever, and optionalLyraBridgeProtocol - 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
GraphRAGResultfor audit and replay
Next steps¶
- Wire GraphRAG into a chat loop — the
context_textslot drops straight into a system prompt, andpathsgives 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.apipackage is published. The graceful fallback means no code changes are needed — see the LYRA integration tutorial.
Related tutorials¶
- RAG Pipeline — the flat similarity baseline
- Knowledge Graphs — building and serializing KGs
- LYRA Knowledge Graph Integration — the LYRA bridge in depth
- Semantic Search — the
EmbeddingProvider/SearchIndexsubstrate - Bidirectional Federation Patterns — federated KGs under GraphRAG
Related references¶
- 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