9.6 Million Edges, No Extraction Model: Building a Graph From Metadata You Already Have

July 19, 2026 · By Satyendra Singh
graph-build.sh
sattu@arch-lab :~/kg/enterprise-docs $ ./build --corpus=enterprise --docs=1000000
> scanning metadata, not content...
DOCUMENTS    1,000,000
EDGE FAMILIES  6
TOTAL EDGES    9.6M
LLM CALLS      0
BUILD TIME     2m 48s
STORE         mmap arrays
A knowledge graph built from metadata
not extracted by a model
The model is the least interesting part. What your systems already recorded is everything.
9.6M
TOTAL EDGES
$0
MODEL SPEND
12 min
READ TIME
sattu.in / engineering

Between vendor decks promising "just point an LLM at your documents" and a five-figure invoice for a graph nobody asked to rebuild, there is a question worth answering: how much of a knowledge graph can you actually get without a model reading a single document?

No single embedding model or extraction pipeline gets you there on its own. GraphRAG did not become expensive because someone built it carelessly. Extraction is expensive because guessing at relationships is expensive, no matter who writes the guessing code. And whether extraction is even necessary is a question worth asking before you have indexed a million documents that already told you how they connect.

9.6Medges parsed from metadata
$0spent on extraction calls
2m 48sto build the entire graph

The corpus nobody extracts from

Most enterprise document systems already record relationships as structured fields, not prose. A contract amendment references the contract it amends. A support ticket links to the thread that resolved it. A report cites the source document it pulled numbers from. None of this needs a language model to discover. It needs a parser that knows where to look.

I took that literally: one million mixed documents, six structured edge families, and a rule that nothing counts as a relationship unless a system somewhere already recorded it as one.

01 Authors doc → authored_by → person Who wrote or filed it, pulled straight from system metadata, never inferred from the writing style.
1,000,000 edges
02 Owner doc → filed_under → department The owning team, taken from the workflow field the document was actually filed against.
1,000,000 edges
03 Type doc → is_a → doc_type Contract, ticket, report, memo. The system's own category, not a guess from content.
1,000,000 edges
04 Citation doc → references → doc A report's footnote, a memo's attachment reference, anything pointing at another document by ID.
3,100,000 edges
05 Thread msg → replies_to → msg Ticket and email reply chains, one directional edge per reply, never stored twice.
2,050,000 edges
06 Version doc → supersedes → doc Contract amendments and revised policies replacing an earlier, still-referenced document.
1,450,000 edges

Nine and a half million edges, and the parser never reads a sentence of body text. It reads field names.

record RawDoc(long id, String authorId, String dept, String docType,
              List<Long> references, Long repliesTo, Long supersedes) {}

record Edge(long src, long dst, EdgeKind kind) {}

List<Edge> edgesFor(RawDoc d) {
    List<Edge> out = new ArrayList<>();
    out.add(new Edge(d.id(), authorIndex.get(d.authorId()), EdgeKind.AUTHORED_BY));
    out.add(new Edge(d.id(), deptIndex.get(d.dept()), EdgeKind.FILED_UNDER));
    for (long ref : d.references())
        out.add(new Edge(d.id(), ref, EdgeKind.REFERENCES));
    // three more edge kinds follow the same shape
    return out;
}

Run that across a million records on a sixteen-core box with a fixed thread pool and you are done in under three minutes. No GPU, no API bill, no waiting on rate limits from a model provider. The parser is the entire graph-construction budget.

"A relationship your own systems already recorded doesn't need to be rediscovered. It needs to be read."

A lesson learned in production, not in a research paper

Four things that broke without raising an exception

The shared map that silently lost edges. The first version of the parser split the corpus across eight threads and accumulated results into one shared HashMap. It is not thread-safe under concurrent writes, and under load it does not usually throw. It just drops entries during a resize. The fix: each worker fills its own list, and a single merge step combines them at the end.

Reply threads, counted in both directions. Writing a forward edge and an inverse edge for the same reply doubled the threading family's numbers and made the graph look twice as connected as it was. One directional edge per relationship, with the reverse view derived at query time, fixed it for free.

The sentence that answers the question, cut off. Ticket bodies get truncated for preview fields, and the resolution note, the sentence that actually says what fixed the issue, routinely sits at the very end. Reusing that truncated preview for anything downstream means confidently answering with text that was never the answer.

The ranking cache that stopped noticing new questions. Results were cached by candidate document set, but the query text was never part of the cache key. Every later question that hit the same candidates got served the first question's ranking. Nothing errored. The answers were just quietly sorted by relevance to a question nobody asked.

Storing the graph without a graph database

Nine and a half million edges fits comfortably in memory, which raises an honest question: why not just load it into a graph database and query it with Cypher? Independent benchmarks of pointer-chasing graph databases against array-backed graph engines consistently show the array approach winning by one to two orders of magnitude on simple traversals, because a graph database pays a round trip and an index lookup for every hop, while an array is contiguous memory you slice.

int[] buildOffsets(int[] sources, int nodeCount) {
    int[] counts = new int[nodeCount + 1];
    for (int s : sources) counts[s + 1]++;
    for (int i = 0; i < nodeCount; i++) counts[i + 1] += counts[i];
    return counts; // offsets[i]..offsets[i+1] is node i's edge slice
}

Loading this way takes low single-digit milliseconds, and expanding a document's neighbors is an array slice. No lock, no network hop, no query planner. The trade-off is real: you give up ad-hoc query flexibility and take on the discipline of rebuilding the arrays when the data changes materially. For a corpus that updates in batches, that trade is an easy one.

Answering a question without letting a model invent a fact

The real design problem is not storage, it is grounding: turning a question into a walk over real edges instead of a model's best guess. The approach here matches known document types, departments, and reference targets with exact lookups, never a model call, and refuses outright the moment there is nothing solid to walk from.

Verdict evaluate(List<Concept> grounded, List<EvidencePath> paths) {
    if (grounded.isEmpty())
        return Verdict.refuse("nothing in the question matched a known document");
    if (paths.isEmpty())
        return Verdict.refuse("no recorded relationship connects the matched concepts");
    List<EvidencePath> fresh = paths.stream().filter(p -> !p.isSuperseded()).toList();
    if (fresh.isEmpty())
        return Verdict.refuse("every matching document has since been superseded");
    return Verdict.answer(fresh);
}

A question about a vendor that does not exist in the corpus dies at the first line. A question about a contract fully superseded by a later amendment dies at the third, which matters more in a contract archive than almost anywhere else, because an amended clause quoted as current is a materially different kind of wrong than a typo.

"A refusal that comes from missing evidence is trustworthy. A refusal a model decided to give you is a mood."

Earned opinion

Three ways to retrieve, and what each is actually good at

It is tempting to treat graph versus vector search as a competition with one winner. In practice they answer different questions well, and the honest comparison has to say so.

Approach Build cost Best at Weak at
LLM-extracted High. Every document read by a model at least once. Relationships nobody logged structurally, like two people who work together but were never formally linked. Cost and freshness. Re-indexing after any real update repeats the bill.
Metadata-parsed Near zero. A parsing job, not a model job. Precise, structural questions: who filed this, what supersedes what, what cites what. Anything not already recorded. No amount of parsing finds a connection nobody logged.
Vector search Low. One embedding pass over document text. Fuzzy, topical questions where phrasing varies and there is no clean entity to anchor on. Precision and refusal. A similar-sounding document is not a linked one, and it rarely says so.

In our own trial runs, plain vector search actually answered more topical questions correctly out of the box than the raw metadata graph did. Where the graph earned its keep was every question that needed a specific, traceable fact rather than a similar-sounding one. Vector search retrieves. The graph certifies. Neither replaces the other.

Where this generalizes past one archive

A fair amount of this year's guidance on hallucination has landed somewhere useful: retrieval, not generation, is usually where the failure starts, and the fix is architectural, not a stricter prompt. Once you are looking for structure instead of asking a model to invent it, most organizations are sitting on more of it than they think: git commits that reference ticket IDs, CRM logs that already link a contact to every deal they have touched, contract systems that track amendment lineage as a field instead of prose, approval workflows that record who signed off, in order, as structured state. None of it needs a model to discover. It needs someone to check the metadata columns before reaching for one.

"The graph doesn't make the model smarter. It makes the system honest about what it doesn't know."

Earned opinion, not a benchmark leaderboard

One last thing on the honest number

On a four-hundred-question internal test set, the metadata graph with its refusal gate answered correctly, refused appropriately, or partially answered a combined 71% of the time, with a near-zero rate of confidently wrong answers. That is a lower headline number than an unguarded model would happily produce, and it is the right trade for a domain where being wrong about a contract clause costs more than declining to answer.

Comments

No comments yet. Be the first to share your thoughts!

Leave a comment — enter your name and message below. The URL field is optional and can be left blank.

Here you will find all about Technology, Food, Travel and about our life.

Search This Blog

Powered by Blogger.

Java Arrives Late. On Purpose

arrives-late.sh sattu@arch-lab :~/java $ ./when-did-java-get-it --compare=everyone-else > checking release notes since 1995... ...

Contact Form

Name

Email *

Message *