9.6 Million Edges, No Extraction Model: Building a Graph From Metadata You Already Have
not extracted by a model
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.
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.
Here you will find all about Technology, Food, Travel and about our life.
Search This Blog
Blog Archive
- September 2026 (1)
- August 2026 (1)
- July 2026 (3)
- June 2026 (2)
- April 2026 (1)
- December 2025 (1)
- October 2025 (1)
- August 2025 (1)
- July 2025 (1)
- February 2025 (1)
- October 2022 (1)
- November 2019 (1)
- June 2019 (1)
- July 2016 (1)
- February 2009 (1)
-
I wrote my first programs in Notepad. Not VS Code, not Sublime, not even a basic IDE. Plain old Notepad. No IntelliSense. No Stack Overflow...
-
The Aggregator Trap How Food Delivery Platforms Are Destroying India’s Restaurant Ecosystem A Wake-Up Call for India’s Food In...
-
เคाเคฃเค्เคฏ Ancient Wisdom · Modern Ruthlessness Stop Being the Bigger Person. Chanakya ...
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... ...
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.