Preamble
The vector RAG stack from RAG Foundations: Embeddings, Chunking, and the Retrieval Loop answers questions like “what did Company X say about supply chain risk?"—embed the question, pull the top-k chunks, generate. It falls over on questions like “which companies in this corpus grew margins while cutting debt over the last three quarters?” That question is not answered by any single chunk. It is answered by relationships across documents: entities, metrics, time periods, and the edges between them.
GraphRAG is the pattern that addresses this: instead of (or alongside) a vector index, you extract a knowledge graph from the corpus and let retrieval traverse structure. This month I built one over a corpus of financial filings—10-Ks, 10-Qs, and earnings call transcripts that get appended to continuously—and wired it up so an LLM can actually use it: a MCP server exposes graph queries as tools, and a data agent keeps the graph fed and interrogates it to surface companies whose reported fundamentals look strong.
One framing note before any code: this system screens and summarizes reported financials. It is a retrieval-and-reasoning demo, not investment advice, and the honest output of such a system is “here are the companies whose filings show X, with citations”—never “buy this.”
Vector RAG vs GraphRAG: what actually changes
Both start the same way—ingest, chunk, index. The difference is what you index and how you retrieve.
| Dimension | Vector RAG | GraphRAG |
|---|---|---|
| Index unit | Chunk embedding | Entities + typed relationships (+ usually chunk embeddings too) |
| Retrieval | Nearest-neighbor similarity | Graph traversal, entity-anchored expansion, community summaries |
| Sweet spot | “What did X say about Y?” (local, single-document) | “How do X, Y, Z relate?” (multi-hop, cross-document, corpus-global) |
| Indexing cost | One embedding pass, cheap | An LLM call per chunk for extraction—orders of magnitude more compute |
| Update story | Append new vectors, done | Upsert nodes/edges, re-resolve entities, refresh community summaries |
| Failure mode | Retrieves plausible-but-irrelevant chunks | Extraction errors become persistent wrong facts in the graph |
| Explainability | “These chunks were similar” | “Here is the path of facts, each with a source span” |
The pros, honestly stated
- Multi-hop questions work. “Supplier of a supplier,” “companies exposed to the same customer,” “subsidiaries of firms that raised guidance”—these are one or two edge traversals, not a prayer that some chunk mentions everything at once.
- Global questions work. The Microsoft GraphRAG paper’s key contribution was community detection + summarization: cluster the graph (Leiden or Louvain), have an LLM write a summary per community, and answer corpus-wide questions (“what are the dominant risk themes this quarter?”) by map-reducing over community summaries instead of over ten thousand chunks.
- Provenance is structural. Every edge carries the chunk id and character span it was extracted from. An answer becomes a path through the graph, and every hop cites a filing.
- Structured facts stop being prose. Revenue, margin, debt—extracted once into typed edges—can be compared and sorted, which embeddings fundamentally cannot do. Cosine similarity does not know that 12% > 8%.
The cons, equally honestly
- Indexing is expensive. Every chunk goes through an LLM for entity/relation extraction. For a corpus that grows weekly, this is a real, recurring compute bill—which is exactly why self-hosting the extractor on vLLM (below) matters.
- Extraction quality is the ceiling. A hallucinated edge is worse than a bad retrieval, because it persists and gets cited confidently later. You need validation gates, not vibes.
- Entity resolution is genuinely hard. “Alphabet,” “Google,” “GOOGL,” and “Alphabet Inc. Class A” must collapse to one node or the graph silently fragments.
- More moving parts. A graph store, an extraction pipeline, a community-summary refresher, and a resolver—each a thing that can drift, each needing the same versioning discipline as the vector index in the foundations post.
- Latency. Multi-hop traversal plus summary assembly is slower than one ANN lookup. Fine for analysis workloads; think twice for chat-latency products.
The pragmatic verdict: keep the vector index. GraphRAG is a second retrieval mode layered on top, not a replacement. Local “what did they say” questions still route to vectors; relational and global questions route to the graph.
Architecture: three components, one contract
┌──────────────────────────────┐
new filings ────► │ DATA AGENT (ingestion loop) │
(10-K, 10-Q, │ fetch → chunk → extract → │
transcripts) │ resolve → upsert → summarize │
└──────────────┬───────────────┘
│ writes
▼
┌──────────────────────────────┐
│ GRAPHRAG STORE │
│ graph DB (nodes/edges w/ │
│ period + provenance) + │
│ vector index for chunks │
└──────────────┬───────────────┘
│ read-only tools
▼
┌──────────────────────────────┐
│ MCP SERVER │
│ find_company, get_metrics, │
│ neighbors, screen_companies, │
│ community_themes, get_chunk │
└──────────────┬───────────────┘
│ tool calls
▼
┌──────────────────────────────┐
│ ANALYST LLM (any MCP client) │
│ plans → calls tools → cites │
└──────────────────────────────┘
The separation earns its keep in two ways. First, the MCP server is read-only—the analyst LLM can never write a fact into the graph, so a bad generation cannot poison the store. Only the ingestion side of the data agent writes, and everything it writes passes validation. Second, MCP means the graph is a capability, not an integration: Claude Desktop, Cursor, a custom agent loop—any MCP client gets the same tools with the same schemas, and I never write bespoke glue per client.
Ingestion: chunking financial documents without destroying them
Financial filings punish naive chunking harder than any corpus I have worked with. Three structural facts drive the strategy:
- Filings have a canonical section structure. A 10-K’s
Item 1A(Risk Factors) andItem 7(MD&A) are semantically different universes. Chunks must never span section boundaries, and every chunk carries its item number as metadata—“risk” language inside Risk Factors is boilerplate; the same language inside MD&A is a signal. - The numbers live in tables, and tables are not prose. Splitting a consolidated income statement mid-table produces chunks where revenue has lost its fiscal-year column header. Worse, filings state scale once, at the top: "(in thousands, except per share data)”. A chunk that contains
4,127without that header is a 1000× error waiting to be extracted. The fix: parse tables as units, re-attach the scale header to every table chunk, and prefer XBRL (the machine-readable facts embedded in EDGAR filings) for core financial metrics—those go straight into the graph as structured edges, no LLM in the loop. The LLM extraction budget is spent on narrative text, where it is actually needed. - Earnings calls are dialogues. Chunk transcripts by speaker turn, keeping Q&A pairs together—an analyst’s question is the antecedent for the CFO’s “as I said, we expect that to normalize.” Splitting them orphans the answer, the same “lost antecedent” failure the foundations post flagged, but worse because the speaker (CEO guidance vs. an analyst’s skeptical premise) changes what a sentence means.
Extraction at scale with vLLM
Extraction is the recurring cost center: every narrative chunk, every week, forever. Two properties make vLLM (see Serving Distilled Models Behind an HTTP API for the serving fundamentals) the right tool here rather than a metered API:
- Continuous batching. Extraction is embarrassingly parallel—thousands of independent chunks. vLLM keeps the GPU saturated across concurrent requests, which is precisely the workload shape its scheduler was built for.
- Guided decoding. vLLM can constrain generation to a JSON schema (
guided_json/ structured outputs). The model cannot emit malformed JSON or invent relation types outside the enum. This kills the entire “regex the JSON out of the reply and pray” class of pipeline failure.
# Pin the image tag and model revision in real manifests
docker run --gpus all -p 8000:8000 \
vllm/vllm-openai:latest \
--model Qwen/Qwen2.5-14B-Instruct \
--max-model-len 16384 \
--dtype bfloat16
The extraction schema is deliberately narrow—a closed relation vocabulary, mandatory evidence spans, mandatory period:
# extraction.py — illustrative; pin versions and model revision in production
from pydantic import BaseModel
from openai import OpenAI
RELATIONS = [
"REPORTS_METRIC", # company -> metric value, for a period
"GUIDES", # forward-looking guidance (kept separate on purpose)
"SUPPLIES", "COMPETES_WITH", "SUBSIDIARY_OF", "CUSTOMER_OF",
"EXPOSED_TO_RISK",
]
class Triple(BaseModel):
subject: str
relation: str # validated against RELATIONS after decode
object: str
value: float | None # numeric metrics, normalized units
period: str | None # e.g. "FY2025", "Q1-2026"
evidence: str # verbatim quote from the chunk
class Extraction(BaseModel):
triples: list[Triple]
client = OpenAI(base_url="http://localhost:8000/v1", api_key="unused")
def extract(chunk_text: str, meta: dict) -> Extraction:
resp = client.chat.completions.create(
model="Qwen/Qwen2.5-14B-Instruct",
messages=[
{"role": "system", "content": EXTRACTION_PROMPT}, # relation defs + examples
{"role": "user", "content": f"[{meta['ticker']} {meta['form']} {meta['section']}]\n{chunk_text}"},
],
extra_body={"guided_json": Extraction.model_json_schema()},
temperature=0.0,
)
return Extraction.model_validate_json(resp.choices[0].message.content)
Schema-valid is not the same as true, so every triple passes validation gates before touching the graph:
- Evidence must be verbatim. If
evidenceis not a substring of the source chunk, the triple is dropped. This one check eliminates most hallucinated facts, because a model inventing a relation has to invent a quote, and the invented quote will not match. - Numbers must appear in the source. For
value, check that the digits exist in the chunk (allowing for scale-header multiplication). Avaluethe model “helpfully” computed is rejected—derived metrics are the query layer’s job, where the arithmetic is auditable. - Negation and hypothetical filters. Risk Factors are full of “if we were to lose our largest customer…"—an extractor happily emits
CUSTOMER_OFfrom a hypothetical. A cheap second-pass classifier on the evidence sentence (“is this asserted as fact, negated, or hypothetical?”) gates these; hypotheticals becomeEXPOSED_TO_RISKedges instead, which is what they actually are. - Forward-looking stays quarantined. Guidance (“we expect FY2027 revenue of…”) lands as
GUIDES, neverREPORTS_METRIC. Blending projections into reported facts is exactly the corruption a financial graph cannot survive.
The edge cases that define the system
These consumed far more of the month than the happy path, and each one is a design decision, not a bug fix.
Entity resolution: one company, one node
Filings refer to the same firm as “Alphabet Inc.,” “Alphabet,” “Google,” “the Company,” and “GOOGL”—sometimes all five in one document. Without resolution the graph fragments into disconnected aliases and every traversal silently misses facts. The workable recipe is layered: CIK/ticker lookup first (filings carry the SEC’s canonical company identifier—use it as the node key, it is free ground truth), then an alias table for well-known names, then embedding similarity over entity names with a human-review queue for low-confidence merges. Wrongly merging two companies is far worse than temporarily keeping duplicates—a merge contaminates both entities’ facts, so the threshold stays conservative and merges are logged and reversible.
Time: facts expire, edges must carry periods
“Revenue was $4.2B” is meaningless without which quarter. Every metric edge carries period, and queries default to latest available, per company—not one global cutoff, because a lagging filer’s newest 10-Q is older than a prompt filer’s. Two sharper sub-cases:
- Restatements. A company reissues corrected financials; the old fact is now wrong but must remain queryable (“what did they originally report?”). Edges get
superseded_bylinks rather than deletion—the graph is append-only with supersession, which also makes index rebuilds reproducible. - Fiscal-year misalignment. Company A’s “Q1 2026” and Company B’s “Q1 2026” can be different calendar months. Edges store both the company’s fiscal label and a normalized calendar range; cross-company comparisons (“this quarter, sector-wide”) join on the calendar range.
Incremental updates without weekly full rebuilds
The corpus grows continuously, and re-extracting everything weekly is unaffordable. New filings upsert nodes and edges incrementally—cheap. The expensive part is that community summaries go stale: a new 10-K can change what a cluster of companies is “about.” Full Leiden re-clustering on every filing is overkill, so summaries are marked dirty when any member entity gains edges and refreshed on a schedule (nightly worked), with full re-clustering monthly or when the dirty fraction crosses a threshold. Between refreshes, global queries can hit slightly stale summaries—acceptable for analysis, but the MCP layer exposes an as_of timestamp on every response so the consumer knows, the same “as of” honesty the foundations post demanded of vector indexes.
Duplicate facts from overlapping chunks
Chunk overlap—insurance in vector RAG—produces the same triple twice in GraphRAG. Dedupe on (subject, relation, object, period) and keep the union of evidence spans. A fact extracted from three chunks is not three facts; it is one fact with three citations, which is actually stronger provenance.
How an LLM actually queries a RAG system
A point worth slowing down for, because it is the most commonly hand-waved part of every RAG diagram: in classic RAG, the LLM never queries anything. The arrow from “model” to “vector database” that appears in a hundred architecture sketches does not exist. There are two genuinely different mechanics, and knowing which one you are running changes how you debug the system.
Mode 1: Orchestrated retrieval — the model is a passive reader
In the loop from RAG Foundations, retrieval happens before the LLM is ever invoked, performed by ordinary application code:
- The orchestrator (your Python service, LangChain chain, whatever) takes the user’s question.
- It embeds the question with the embedding model—a separate, much smaller model than the generator. The LLM’s own weights are not involved.
- It runs nearest-neighbor search against the vector index, applies metadata filters, maybe re-ranks.
- It pastes the winning chunks into the prompt, delimited and labeled, and only then calls the LLM.
What the generator actually receives looks like this—and this is the entire “query” from the model’s perspective:
SYSTEM: Answer using only the provided context. Cite passage ids.
If the context does not support an answer, say so.
CONTEXT:
[passage id=chunk-0412 source="ACME 10-K FY2025, Item 7"]
Revenue for fiscal 2025 increased 12% to $4.2 billion, driven by...
[passage id=chunk-0977 source="ACME Q3-2026 earnings call, CFO"]
We expect gross margin to normalize toward 41% as inventory...
USER: How has ACME's revenue been trending?
The model reads context that showed up in its window and writes prose about it. It cannot ask a follow-up, cannot request “the next page,” cannot say “these chunks are irrelevant, search again.” If retrieval fetched the wrong passages, the model’s only honest move is to refuse—and its dishonest move, the one you will actually observe, is to improvise. Every retrieval decision was made by code before the model woke up. This is why the foundations post insists chunking and embedding quality matter so much: in this mode they are the whole retrieval intelligence.
One refinement blurs the line without changing the mechanic: the orchestrator can use an LLM call to improve the query before searching—query rewriting (turn “what about their debt?” plus chat history into the standalone “what is ACME Corp’s debt position as of FY2025?”), decomposition (split a compound question into sub-queries retrieved separately), or HyDE (generate a hypothetical answer and embed that, since a fake answer often lives closer to real passages than the question does—covered in RAG in Production). But note who is in charge: the orchestrator decides to rewrite, decides to search, decides what k is. The LLM is a subroutine.
Mode 2: Agentic retrieval — the model drives, through tool calls
The second mode inverts control, and it is how the analyst agent in this post works. The LLM is told—in its system prompt, via the standard tool-calling API—that tools exist, with names, descriptions, and typed JSON schemas. When the model wants information, it does not emit prose; it emits a structured tool call:
{
"name": "get_metrics",
"arguments": {"company_id": "CIK-0001652044", "metrics": ["revenue", "gross_margin"], "periods": 8}
}
The runtime executes the call (here, the MCP client forwards it to the MCP server, which runs the real graph query), appends the JSON result to the conversation as a tool message, and hands the transcript back to the model, which now decides: answer, or call another tool. The loop repeats until the model produces a final response.
This is the crucial mental shift: the model still never touches the database. It emits intentions in JSON; deterministic code executes them. What changed from Mode 1 is who plans. The model can now chain queries (“resolve the ticker first, then fetch metrics, then check neighbors”), react to results (“the screen returned six companies—too many, tighten the threshold”), and recover from misses ("find_company('Alphabet') before guessing an id”). Retrieval becomes a dialogue instead of a single pre-baked lookup—which is exactly what multi-hop graph questions need, since the second hop depends on the first hop’s answer, something no one-shot retrieval can know in advance.
The cost is the flip side: each hop is a full model round-trip (latency), the transcript grows with every tool result (tokens), and a model with freedom to plan has freedom to plan badly—hence small tool counts, sharp descriptions, and typed arguments. The docstrings in the MCP server below are not documentation for humans; they are the prompt that teaches the model when to reach for each tool.
Where MCP fits: how the model learns the tools exist at all
A fair question hides inside Mode 2: how does the model know about get_metrics? It was not trained on my server. The answer is that it does not know anything until each session starts—and the machinery that tells it is exactly what MCP standardizes.
The demystifying fact first: the LLM never speaks MCP. MCP is a protocol between two pieces of ordinary software—the client (the host application: Claude Desktop, Cursor, my agent loop) and the server (the process wrapping my graph). The model only ever sees its native tool-calling format. The client is the translator in the middle, and a session unfolds in four steps:
- Discovery. On connect, the client calls the server’s
tools/listendpoint (JSON-RPC under the hood). The server replies with a manifest: every tool’s name, its natural-language description, and a JSON Schema for its arguments. In the FastMCP sketch below, that manifest is generated from the code itself—the function name, the docstring, and the Python type hints become the name, description, and schema. Nothing is written twice. - Injection. The client translates that manifest into the model’s tool-definition format and includes it in the request—alongside the system prompt, part of the context on every single call. This is the step that answers “how does it know the options available”: the tool catalog is simply text in the context window. The model reads the schemas the same way it reads the user’s question. No fine-tuning on my tools, no registration—if a tool is in the manifest, the model can see it this session; if I add a tool to the server tomorrow, every client picks it up on the next
tools/listwith zero model-side changes. - Invocation. When the model emits a tool call, the client validates the arguments against the schema, wraps the call as an MCP
tools/callrequest, and sends it to the server, which runs the real query and returns the result. The client appends that result to the transcript as a tool message and re-invokes the model. - Repeat until the model answers in prose.
So “how does the model know how to use the tools” decomposes into two different mechanisms. The mechanics of emitting well-formed tool calls come from the model’s training—modern chat models are post-trained heavily on tool-use transcripts, which is why they can pick up arbitrary unseen tools at inference time. The semantics—when to call screen_companies versus neighbors, what period: "FY2025" means—come entirely from the descriptions and schemas in the manifest. This is why I said the docstrings are the prompt: they are the only channel you have to teach the model your tools, and vague ones produce an agent that guesses. A description like “query the graph” gets misused; “reported metrics for the last N periods, with filing citations” gets called at the right moment with the right arguments.
What MCP buys over rolling your own tool plumbing is the standardization of steps 1 and 3. Before MCP, every application hand-maintained its own tool list and its own dispatch code per backend—N clients × M data sources of bespoke glue. With MCP, the server declares its capabilities once and any compliant client—today’s or next year’s—discovers and calls them the same way. That is the concrete meaning of the earlier claim that the graph becomes “a capability, not an integration.”
How a graph gets queried: the three GraphRAG retrieval patterns
Whichever mode drives, a natural-language question must still become an actual graph operation. In practice there are three patterns, and this system uses the first two:
- Local (entity-anchored) search — for “tell me about X and its relationships.” Extract the entity mentions from the question, resolve them to canonical nodes (the same resolver ingestion uses—
find_companyin the tool surface), expand the neighborhood one or two hops, collect the attached facts, evidence chunks, and any community summaries the entities belong to, then rank and pack that bundle into the context. It is vector RAG’s “retrieve then read,” except the retrieval unit is a subgraph instead of a similarity list. - Global (community) search — for questions with no entity anchor at all: “what are the dominant risk themes this quarter?” No node to start from, so traversal cannot help. Instead, map-reduce over community summaries: score or partially answer against each community’s pre-computed summary (map), then synthesize the partials into one answer (reduce). This is the Microsoft GraphRAG insight—pre-summarizing clusters at index time is what makes corpus-global questions answerable at query time without reading ten thousand chunks.
community_themesis this pattern behind a tool name. - Text-to-Cypher — have the LLM translate the question into a graph query language directly. Maximum flexibility, minimum reliability: the model must know your schema exactly, and a subtly wrong
MATCHclause returns a confidently empty (or subtly wrong) result set with no error. This is the fragility argument for the constrained tool surface below—each MCP tool is effectively a pre-written, parameterized, tested Cypher/SQL template, so the model fills in arguments instead of composing queries. The model contributes intent; the template contributes correctness.
The router question—“which pattern does this query need?"—is itself either a cheap classifier in Mode 1 or, in Mode 2, simply the agent’s tool choice: picking neighbors versus community_themes is the local/global routing decision, delegated to the model that read the question.
The MCP server: making the graph a first-class tool
MCP’s job here is to turn “we have a graph” into “any LLM can use the graph.” Design the tools around questions analysts ask, not around graph primitives—an LLM given only a raw “run arbitrary Cypher” tool writes bad Cypher and hallucinates schema. Constrained, purposeful tools with typed arguments get dramatically better agent behavior (and are safely read-only).
# server.py — illustrative FastMCP sketch
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("finance-graphrag")
@mcp.tool()
def find_company(query: str) -> list[dict]:
"""Resolve a name/ticker to canonical companies (id, name, ticker, sector)."""
return resolver.search(query)
@mcp.tool()
def get_metrics(company_id: str, metrics: list[str], periods: int = 4) -> dict:
"""Reported metrics for the last N periods, with filing citations and as_of."""
return graph.metric_history(company_id, metrics, periods)
@mcp.tool()
def neighbors(company_id: str, relation: str | None = None, hops: int = 1) -> dict:
"""Related entities: suppliers, customers, competitors, subsidiaries, shared risks."""
return graph.traverse(company_id, relation, max_hops=min(hops, 2))
@mcp.tool()
def screen_companies(min_revenue_growth: float | None = None,
max_debt_to_equity: float | None = None,
min_margin_trend: float | None = None,
sector: str | None = None) -> list[dict]:
"""Screen on metric thresholds computed from reported figures. Deterministic SQL/Cypher—the arithmetic happens here, not in the LLM."""
return graph.screen(locals())
@mcp.tool()
def community_themes(sector: str | None = None) -> list[dict]:
"""Community summaries: dominant themes/risks across the corpus (global search)."""
return graph.community_summaries(sector)
@mcp.tool()
def get_chunk(chunk_id: str) -> dict:
"""Verbatim source text for a citation—the audit trail's last hop."""
return store.chunk(chunk_id)
Two of these deserve emphasis. screen_companies exists because LLMs should not do arithmetic over retrieved prose—growth rates and ratios are computed in the database, deterministically, and the model only interprets the results. And get_chunk closes the provenance loop: every fact the agent cites can be traced to verbatim filing text by anyone reviewing the output.
The analyst agent: from graph to recommendation
With the tools in place, the analyst LLM needs no special training—just a system prompt establishing the workflow and the guardrails, running the agentic loop (Mode 2 above): plan, call a tool, read the result, decide the next hop. A real trace from the working system, condensed:
User: “Which companies in the corpus look strongest based on their recent reporting?”
Agent →
screen_companies(min_revenue_growth=0.05, max_debt_to_equity=1.0, min_margin_trend=0.0)→ 6 candidates Agent →get_metrics(...)for each → drops one (single strong quarter, longer trend flat) Agent →neighbors(company_id, relation="EXPOSED_TO_RISK")→ flags two sharing heavy customer-concentration risk with the same downstream buyer Agent →community_themes(sector="semiconductors")→ notes sector-wide inventory-correction theme, contextualizes one firm’s margin dip as industry-wide rather than idiosyncratic Agent → final answer: three companies, each with metric trajectories, the risk caveats, and filing citations per claim, closing with the system’s standing disclaimer.
Notice what the graph bought: the risk-overlap step (shared exposure discovered via traversal) and the sector context (community summary) are the parts vector RAG could not have produced. The screen itself is just SQL; the judgment—dropping the one-quarter wonder, discounting the industry-wide dip—is the LLM doing what it is actually good at, on top of numbers it did not compute and facts it did not invent.
The ingestion half of the data agent is the unglamorous twin: a scheduled loop that polls EDGAR for new filings, runs fetch → chunk → extract → validate → resolve → upsert, marks communities dirty, and writes an ingestion report (chunks processed, triples accepted/rejected, unresolved entities queued for review). The rejection rate is the metric to watch—when it spikes, either the extractor regressed or a new document format arrived, and either way you want the alarm before the graph degrades.
What I would tell a colleague starting this
- Start with the vector index; add the graph when questions demand it. If nobody asks multi-hop or corpus-global questions, GraphRAG is expensive ceremony.
- Spend the LLM budget on narrative, not numbers. XBRL gives you core financials structured for free. Extracting
REPORTS_METRICfrom prose when the same fact exists as machine-readable data is paying for hallucination risk. - Evidence-span validation is the highest-leverage line of code in the pipeline. Verbatim-quote checking rejected the majority of bad triples in my runs, at the cost of a substring check.
- Entity resolution and temporal modeling are the product. Community summaries are the flashy part of GraphRAG; alias tables and
periodfields are what make the answers true. - Constrain the tool surface. Purpose-built MCP tools with typed arguments beat a raw query tool—every time—for agent reliability, and read-only enforcement belongs at the server, not in the prompt.
- Keep the model out of the arithmetic. Screens and ratios are computed in the store; the LLM interprets. The moment a growth rate is generated rather than queried, your citations stop meaning anything.
Conclusion
GraphRAG earns its complexity exactly when the questions are relational or global—and a continuously growing corpus of financial filings generates those questions constantly. The stack that emerged is a pipeline of familiar disciplines: the chunking care from RAG Foundations, the self-hosted serving economics from Serving Distilled Models Behind an HTTP API applied to extraction throughput, and the evaluation instinct from RAG in Production: Re-ranking, HyDE, and Simple Evals pointed at a new target—triple accuracy instead of retrieval hit-rate.
The MCP layer is what turns the whole thing from a pipeline into a capability: the graph becomes a set of tools any agent can pick up, the agent’s answers become paths through cited facts, and the human reviewing a recommendation can walk every hop back to the filing that said it. That auditability—not the recommendation itself—is the real deliverable of putting a knowledge graph under an LLM.