Build-Time Knowledge Graphs: When GraphRAG Is the Wrong Tool

Someone asked me to design a GraphRAG system over their internal documentation. Standard request in 2026, and I had the reference architecture half-drawn before I asked the question I should have asked first.

How many documents?

Eleven hundred. Markdown, in a git repo, mostly well-structured, with a table of contents somebody actually maintained. They had been quoted a six-figure platform build and were about to spend it, because every article they had read described indexing pipelines, community detection, a vector database, a graph database and an orchestration layer. All of that is real engineering. None of it was going to help them, and some of it was going to hurt.

At eleven hundred documents the entire graph fits in a browser tab.

The question that decides the architecture

Corpus size is the variable that should pick your knowledge-graph architecture, and it is the one almost never stated in the guides. Nearly everything written about GraphRAG assumes a corpus large enough that no human could hold it β€” which is exactly the regime where expensive machinery pays for itself, and exactly not the regime most people are in.

CorpusWhat actually worksWhy
Under ~1,000 docsBuild-time graph, static artifact, client-side queryWhole graph fits in memory. No server earns its keep.
~1k–100k docsBuild-time graph + a real vector index; graph still often fits in memoryRetrieval starts to need help; traversal still cheap.
Over ~100k docsGenuine GraphRAG: incremental indexing, graph DB, community summarisationNow there is enough structure that hierarchical summarisation adds real information.

⚠️ Treat 1,000 and 100,000 as heuristics, not thresholds. Document count is a convenient proxy, but it is not the real variable β€” several others move the boundary as much or more. High query concurrency pushes you toward a server well before 100k docs. A high mutation rate (documents changing constantly) can make even a small corpus expensive to keep re-extracted. Query shape matters: mostly-topical questions may never justify a graph at any size, while multi-hop relational questions justify one sooner. Tight latency budgets and high per-document extraction cost both pull the numbers around. Use the counts to start the conversation, then price your actual workload.

I wrote about when GraphRAG is worth the complexity last year, and that piece stands. This is the other half of the argument β€” the case where it is not, which in my experience is most of the cases where it gets proposed.

The tell is what Microsoft GraphRAG does on update. It extracts entities across the whole corpus, then does bottom-up Leiden clustering into a hierarchy of communities, then summarises each community. It does have incremental indexing now β€” graphrag update diffs new content and tries to slot new entities into existing communities β€” but "tries" is load-bearing: each update still pays LLM extraction on the new documents, and once enough changes accumulate the community layer recomputes and the cost degrades toward a full reindex. That's a reasonable trade at a million documents you reindex monthly. It's an expensive one at a thousand documents you touch weekly. LightRAG's central insight is to avoid the global clustering entirely: merge new nodes and edges into the existing graph with a union, so updates are reliably cheap rather than usually cheap.

What "pluggable" has to mean

The goal is a module you can point at a blog, a book repo, a notes vault, and a folder of PDFs, and get one graph out β€” without that module knowing anything about your website. That only works if you are strict about the contract.

Two rules make it a module rather than a framework:

  • Sources are adapters, not integrations. Every adapter returns the same normalised document: id, title, date, text, and whatever structured metadata it already has. A GitHub repo of markdown, an mdBook, an RSS feed and a directory of PDFs each get thirty lines of adapter. The core never learns their names.
  • Output is files, not an API. The module emits graph.json, entities.json and a search index, and then it is finished. Whatever consumes those β€” your site, a notebook, an MCP server β€” is not the module's business.

That second rule is the one people break, and breaking it is how a "knowledge base module" becomes a service you now operate. If the module owns a running process, you have adopted a platform.

graph LR
  A["Blog repo
markdown"] --> AD["Adapters
normalise to one doc shape"] B["Book repos
mdBook chapters"] --> AD C["Structured data
scorecards, indexes"] --> AD D["Notes, PDFs"] --> AD AD --> T1["Tier 1 deterministic
links, tags, frontmatter"] AD --> T2["Tier 2 extraction
NER or LLM over prose"] T1 --> M["Merge + resolve
alias table, dedupe"] T2 --> M M --> ART[("Static artifacts
graph.json, index")] ART --> V["Graph view"] ART --> S["Search"] ART --> MCP["MCP endpoint"]

The important boundary is the one after Merge. Everything left of the artifacts runs in CI on a schedule; everything right of it is a static file being read. No process spans the line, which is why there is nothing to operate.

Mine the structure you already wrote

Here's the part that saves the most money and gets skipped the most often. A well-maintained corpus already contains a hand-authored knowledge graph, and it is more accurate than anything you will extract.

Look at what is already sitting there in a typical technical blog or docs repo:

Existing signalGraph it yieldsAccuracy
Internal links / wikilinksArticle →REFERENCES→ ArticleExact — a human chose it
Tags, categoriesArticle →ABOUT→ TopicExact, if the vocabulary is controlled
Frontmatter, dates, authorsTemporal and provenance edgesExact
Headings hierarchyDocument structure, chunk boundariesExact
Any structured data you publishTyped entities and scored relationsExact
ProseEverything elseWhatever your extractor manages

Only the last row needs a model. The other five are a morning of parsing, and they produce edges you can put in front of a stranger without hedging. On my own corpus that deterministic layer alone yields a connected graph across 205 articles, 11 scored assessments and 16 topic clusters, before a single token is spent.

πŸ’‘ Do the boring layer first and measure the gap. Build the deterministic graph, look at it, and note where it is thin β€” usually the conceptual relationships that live in prose rather than in links. That gap is your extraction spec. Starting with an LLM instead means paying a model to rediscover metadata you already typed by hand, then debugging its mistakes.

This is the same argument I made about code knowledge graphs, and it generalises: structure that already exists should be recovered, not inferred. Grep threw it away; a parser can recover most of it deterministically. "Most" rather than "all" is deliberate β€” a parser resolves static structure exactly, but dynamic dispatch, reflection, runtime configuration and generated code are invisible to it, so the recovered graph is a high-accuracy floor, not a complete picture. It is still far more reliable than inferring the same edges from prose.

The extraction tier, and how to keep it cheap

For what prose genuinely hides β€” this pattern supersedes that one, this tool competes with that one, this article critiques this technique β€” you need extraction. Three decisions keep it from becoming a project.

Fix the ontology before you extract anything

Open-vocabulary extraction over a technical corpus produces a swamp. Declare the entity types and, more importantly, the relation types up front:

entities:  [Technology, Concept, Pattern, Vendor, Person, Document, Assessment]
relations:
  COMPARES_TO:  [Technology, Technology]
  SUPERSEDES:   [Technology, Technology]
  DEPENDS_ON:   [Technology, Technology]
  CRITIQUES:    [Document, Pattern]
  ABOUT:        [Document, Concept]
  SCORED_ON:    [Technology, Assessment]

A closed schema does two things: it makes extraction a classification problem rather than a generation problem, and it lets you typecheck every triple. A model proposing SUPERSEDES(Person, Concept) gets rejected mechanically, before it reaches the graph. That single check removes a surprising share of extraction noise.

Try a small model before a large one

GLiNER is worth knowing about here. It is an open-vocabulary NER model β€” you pass entity labels as natural language rather than training a classifier per type β€” and it runs on CPU. For a few thousand documents that means extraction finishes in CI in seconds, costs nothing per run, has no rate limit, and is deterministic, which matters more than it sounds: a deterministic extractor means a graph diff shows content changes rather than model temperature. GLiNER-Relex extends the architecture to joint entity and relation extraction.

Use an LLM for the relations a span-based model genuinely cannot see β€” causal and rhetorical ones β€” and constrain it with JSON-schema-forced output. Do not ask it for free-form triples and parse the result. I have written before about where LLM cost actually goes; a re-extraction loop over a whole corpus on every commit is one of the more effective ways to be surprised by a bill.

Chunk on structure, not on character count

Hierarchical chunking on the heading tree consistently beats fixed-size windows for both entity and relation extraction, and it is less code. Your <h2> boundaries are semantic boundaries an author chose. A 512-token window is a boundary a tokenizer chose.

Entity resolution is the actual work

Everything above is a weekend. This part is the project.

Extraction gives you mentions; a graph needs entities. ClickHouse, Clickhouse, CH, "the ClickHouse engine" and ClickHouse Cloud arrive as five strings that must become one or two nodes, and the decision about which is a domain judgement rather than a string-distance threshold. Get this wrong and the graph looks fine while being useless: your most important node is silently split into six weakly-connected fragments, so the traversals that should reveal structure return nothing.

⚠️ Budget more time for resolution than for extraction, and do not automate it fully. The pattern that works: a hand-curated aliases.yaml you own, plus embedding similarity to propose merges for review β€” never to apply them. You know your own domain vocabulary better than a model does, and a wrong auto-merge is close to undetectable afterwards, because the evidence that two things were ever distinct is gone. Merges should be reviewable and reversible; that means they live in a file in git, not in a similarity threshold.

A closed ontology helps here too. If Technology nodes must resolve against a known list, most of the swamp never forms.

Storage: ship a file, not a database

A few thousand nodes and a few tens of thousands of edges serialise to a handful of megabytes of JSON. Loaded into graphology in the browser, a two-hop traversal completes in under a millisecond β€” faster than the network round trip to any database, and with no database.

A graph database earns its place when the graph exceeds memory, when multiple writers need transactional concurrency, or when you need a query planner over millions of edges. Those are real situations. They are not your situation at a thousand documents, and adopting a graph DB anyway means you now run one. I covered the actual mechanics of when traversal beats joins in index-free adjacency and Cypher β€” the model is genuinely good, the operational cost is just badly matched to this scale.

⚠️ Check the pulse of anything a tutorial recommends. KΓΉzu β€” the embedded graph database that appears in a great many GraphRAG walkthroughs, including ones published this year β€” had its GitHub repository archived on 10 October 2025, and a February 2026 European Commission filing indicated Apple had acqui-hired the team. The project is not maintained. The community fork carrying it forward is LadybugDB, with Lance Graph, Raphtory, TuringDB and FalkorDB in adjacent territory. I mention it less as a recommendation than as a reminder: in a field moving this fast, a tutorial from six months ago is a historical document, and "popular in blog posts" and "maintained" have quietly decoupled.

Search: two designs, one real trade-off

The graph answers relational questions. You still need ordinary search for "find me something about Iceberg," and for a static site there are two credible open-source answers with a genuinely different shape.

PagefindOrama
Index deliverySplit into small binary chunks; downloads only what a query touchesWhole index loaded into memory up front
BandwidthRoughly flat as the corpus growsGrows with the corpus β€” reported ~600 KB vs ~50 KB on the same dataset
Typo toleranceNo β€” stemming and prefix matching onlyYes, Levenshtein-based
Vector searchNoYes
Best forBlogs and docs that keep growingSmall datasets where fuzzy matching matters

The bandwidth line is the decision for most people. Orama's in-memory design makes every visitor download the entire index to run one query β€” fine at 50 documents, rude at 5,000. Pagefind's chunked index is why it scales from ten pages to ten thousand without a config change. The cost is typo tolerance: Pagefind matches "run" to "running" but returns nothing for "rnning". Whether that matters depends on whether your users type your vocabulary or guess at it.

Retrieval quality is best when you run both surfaces and fuse β€” graph traversal for multi-hop and relational questions, lexical or vector search for topical ones. Graph-only retrieval underperforms on exactly the queries users actually type. That hybrid framing is the same one from RAG from the ground up, just with a better-structured second index.

Visualisation: render a neighbourhood, never the whole thing

The default failure of every knowledge-graph UI is the hairball. Three thousand nodes in a force-directed layout is a screensaver: it looks like sophistication and conveys nothing, because every node is equidistant from meaning.

Default the view to one entity plus one or two hops, filtered by relation type. Offer the full graph as a deliberate opt-in. The neighbourhood view answers a question; the full view answers "does he have a lot of articles."

LibraryRenderingReach for it when
Sigma.jsWebGL, graphology-nativeDefault choice β€” same data structure you already query with
CosmographWebGL, GPU force simulationVery large graphs, or an embedding-space map alongside the graph
Cytoscape.jsCanvasYou need built-in graph algorithms, not just a picture
vis-networkCanvasSmall interactive diagrams; simplest API

Sigma is the one I would start with, for an unglamorous reason: it is built on graphology, which is the same in-memory structure the storage decision above already landed on. One data model for query and render is worth more than any feature difference between these libraries.

Worth knowing that this problem has a well-trodden solution in the notes world too. Quartz turns a folder of markdown into a static site with backlinks, a graph view and full-text search out of the box, and if your corpus is a notes vault you may be finished before you start. The reason to build the module instead is if you need typed relations and multi-repo sources β€” Quartz's graph is link-based, which is the deterministic tier and nothing beyond it.

Incremental by content hash

The whole thing has to survive routine writing, so make re-extraction proportional to what changed:

import hashlib, json, pathlib

STATE = pathlib.Path("build/state.json")

def content_hash(doc: dict) -> str:
    # Hash the text AND the extractor version β€” bumping the prompt,
    # the ontology or the model must invalidate the cache, or you will
    # spend a week debugging a graph built by two different extractors.
    payload = f"{doc['text']}|{EXTRACTOR_VERSION}|{ONTOLOGY_VERSION}"
    return hashlib.sha256(payload.encode()).hexdigest()

def changed(docs: list[dict]) -> list[dict]:
    prev = json.loads(STATE.read_text()) if STATE.exists() else {}
    out = [d for d in docs if prev.get(d["id"]) != content_hash(d)]
    return out

# Extract only the changed docs, then union into the existing graph.
# Deletions matter too: drop edges whose source document is gone,
# or the graph slowly fills with claims from articles you deleted.

That comment on the last line is not hypothetical. Append-only merge is the easy half; the half that rots quietly is retraction. An edge is a claim made by a document, so when the document goes, the claim goes with it.

Provenance, or you have built a rumour mill

Every edge should carry where it came from: source document, character span, when it was extracted, and by which extractor version.

{
  "src": "clickhouse",
  "rel": "COMPARES_TO",
  "dst": "starrocks",
  "provenance": {
    "doc": "starrocks-vs-clickhouse-vs-doris",
    "span": [4192, 4310],
    "tier": "deterministic",
    "extracted_at": "2026-07-20",
    "extractor": "v3"
  }
}

This is not bookkeeping. It is what makes the difference between a system that can say "here is the sentence this came from" and one that produces confident unattributable claims β€” which is the failure mode that makes people distrust knowledge graphs and RAG systems alike. It also makes the graph debuggable: a wrong edge is traceable to a document and an extractor version, so you can fix a class of error rather than one instance.

And keep dates on claims. A 2019 conclusion about a platform is period-accurate, not current, and a graph that flattens that distinction will serve stale architecture advice with total confidence. If you are exposing the graph to an agent over MCP, this matters more, not less β€” the agent has no instinct for what looks out of date.

When this design is wrong

Three cases where the argument above inverts and you should build the heavier thing.

The corpus is large or churning fast. Past roughly a hundred thousand documents, community summarisation genuinely adds information you cannot get otherwise, and you need incremental indexing infrastructure. That is the regime GraphRAG was designed for and it works.

Many writers, concurrent updates. A build-time pipeline assumes one writer and a git history. A shared knowledge base with dozens of contributors editing continuously wants a database with transactions, not a CI job.

Access control per node. Shipping graph.json to the browser means shipping all of it. Any per-user authorisation and the query has to move server-side β€” at which point you have a service, and should design one deliberately rather than discovering it.

If none of those apply, the static build is not a compromise. It is the better system: nothing to operate, nothing to page you, and the artifact can be published for other people to check β€” which is the same discipline as publishing a data contract, applied to your own writing.

What to carry away

Ask how many documents before you draw anything. The knowledge-graph literature is written for a scale most projects are nowhere near, and adopting its architecture at a thousand documents buys you a platform to run and very little retrieval quality.

Under a few thousand documents, invert the design: build the graph in CI, ship it as a static artifact, query it in the browser. Recover the graph you already wrote by hand β€” links, tags, frontmatter, whatever structured data you publish β€” before asking a model to infer one, because that layer is exact and free. Then spend your effort on entity resolution, which is the part that actually decides whether the graph is any good.

Keep provenance on every edge, keep dates on every claim, and check whether the dependencies your tutorial recommends are still maintained. In this corner of the field that last one is not paranoia β€” it is a load-bearing habit.

Frequently asked questions

When is a corpus too small for GraphRAG?

Below roughly a thousand documents, the community detection and hierarchical summarisation that make Microsoft GraphRAG valuable have very little to summarise, while the indexing cost and the rebuild-on-update behaviour remain. At that scale a deterministic graph from links and metadata, plus a light extraction pass, delivers most of the value for a fraction of the cost and with no serving infrastructure.

Do I need a graph database for a knowledge graph?

Not below a few tens of thousands of nodes. A graph that size serialises to a few megabytes of JSON, loads into an in-memory library like graphology, and traverses faster than a network round trip. A graph database earns its place when the graph exceeds memory, when several writers need transactional concurrency, or when you need a planner over millions of edges.

What is the hardest part of building a knowledge graph?

Entity resolution, not extraction. Extraction produces plausible triples quickly; deciding that ClickHouse, Clickhouse, CH and "the ClickHouse engine" are one node is where the effort goes. A curated alias file plus embedding similarity to propose β€” never apply β€” merges handles most of it, and a closed ontology prevents much of the problem from forming.

Should I use Pagefind or Orama for static site search?

Pagefind splits its index into small binary chunks and downloads only what a query touches, so bandwidth stays roughly flat as the corpus grows. Orama loads the whole index into memory β€” faster once loaded, with typo tolerance and vector search, but every visitor downloads the entire index. Prefer Pagefind for a growing blog or docs site; Orama for a small dataset where fuzzy matching matters.

Can I just use Quartz instead of building this?

Often, yes. Quartz turns a folder of markdown into a static site with backlinks, graph view and search, and if your corpus is a single notes vault it may be all you need. The reasons to build a module instead are typed relations β€” SUPERSEDES rather than merely "links to" β€” and multiple heterogeneous sources across separate repositories.

References