You've got 500 documents. A contract references a statement of work. That SOW mentions three vendors. One of those vendors appears in a risk assessment from last year. A junior team member needs to see all of it—not search for it piece by piece.
Most document tools treat this as a retrieval problem. AiFiler treats it as a relationship problem.
The Problem We Solved
Traditional document management systems are built around a simple model: files in folders. Search for what you need. Get a list back. Click through manually. This works fine when you have 50 documents. It collapses when you have 5,000, and it's useless when you have 50,000.
The real cost isn't storage—it's discovery. It's the 15 minutes spent reconstructing context because the system doesn't understand that Document A is a dependency of Document B, which contradicts Document C, which was superseded by Document D.
We needed a way to:
- Capture relationships automatically without manual tagging
- Query across relationships in milliseconds
- Scale to millions of edges without degrading performance
- Handle 8 different relationship types (dependency, contradiction, supersession, reference, etc.) with the same architecture
How We Structured It
The knowledge graph isn't a separate database. It lives inside Supabase as a set of normalized tables with a carefully designed query layer.
The Core Tables
documents (id, workspace_id, name, created_at, ...)
edges (id, source_id, target_id, edge_type, confidence, created_at)
edge_types (type_name: 'dependency' | 'reference' | 'contradiction' | ...)
This is intentionally simple. No RDF triples. No graph database. Just relational tables with a query pattern that treats relationships as first-class data.
The edges table is the heart of it. Every relationship is a row:
- source_id: The document that initiates the relationship
- target_id: The document being referenced
- edge_type: One of 8 types (dependency, reference, contradiction, supersession, related_to, author_of, version_of, contains)
- confidence: A float (0.0-1.0) indicating how certain the AI is about this relationship
Why 8 Edge Types?
We didn't start with eight. We started with three: "references," "contradicts," "depends_on." Then we watched how teams actually worked.
A team member would ask: "Show me all documents that depend on this contract." Another would ask: "Which documents supersede this policy?" A third needed to see version chains. An author needed to find all documents they'd written.
Each edge type serves a specific query pattern that users actually run:
| Edge Type | Query Pattern | Example |
|---|---|---|
| dependency | "What breaks if I change this?" | Contract depends on SOW |
| reference | "What mentions this?" | Analysis references competitor data |
| contradiction | "What conflicts with this?" | Old policy contradicts new one |
| supersession | "What replaced this?" | v2.0 supersedes v1.0 |
| related_to | "What else is relevant?" | Similar risk assessments |
| author_of | "What did this person write?" | Team member authored 47 docs |
| version_of | "What are the versions?" | All iterations of a proposal |
| contains | "What's inside this?" | Folder contains 12 contracts |
The Ingestion Pipeline
Documents don't just appear in the graph. They have to be analyzed, and relationships have to be extracted.
When you upload a document to AiFiler, here's what happens:
- File parsing (
lib/ingest/parseFile.ts) extracts text from docx, xlsx, pptx, pdf - AI analysis sends the document content to Claude with a prompt asking: "What documents does this reference? What policies does it contradict? What does it depend on?"
- Relationship extraction parses Claude's response and creates edge rows
- Confidence scoring assigns a 0.0-1.0 score based on how explicitly the AI found each relationship
- Deduplication checks if that edge already exists (we don't want duplicate relationships)
The prompt Claude sees is structured to extract each edge type separately:
Analyze this document for relationships to other documents in the workspace:
DEPENDENCIES: What documents must exist or be true for this one to be valid?
REFERENCES: What other documents does this cite or mention?
CONTRADICTIONS: What documents contradict claims made here?
SUPERSESSIONS: What documents does this replace or update?
This isn't magic. It's a structured extraction task that Claude handles well because we've given it clear categories.
The Query Layer
Raw SQL against the edges table would be slow. We built a query abstraction that handles the common patterns.
// From lib/intelligence/universalRouter.ts pattern
const getRelatedDocuments = async (docId: string, edgeType: string) => {
const { data } = await supabase
.from('edges')
.select('target_id, confidence')
.eq('source_id', docId)
.eq('edge_type', edgeType)
.order('confidence', { ascending: false });
return data;
};
But the real power is in multi-hop queries. "Show me all documents that depend on documents that this contract references."
const getTransitiveRelationships = async (
docId: string,
hops: number = 2
) => {
// Start with direct relationships
let current = [docId];
let visited = new Set([docId]);
for (let i = 0; i < hops; i++) {
const next = await supabase
.from('edges')
.select('target_id')
.in('source_id', current)
.not('target_id', 'in', `(${Array.from(visited).join(',')})`)
.limit(100);
current = next.data.map(e => e.target_id);
current.forEach(id => visited.add(id));
}
return Array.from(visited);
};
This is bounded (we limit to 100 per hop) to prevent runaway queries. In practice, most teams never need more than 2-3 hops.
Why This Architecture Scales
Three decisions make this work at scale:
1. Normalized storage. We don't duplicate relationship data. One edge row, indexed on both source and target. This keeps the table lean.
2. Bounded queries. We never traverse the entire graph. Queries are scoped to a workspace, limited by hop count, and capped at result size. A workspace with 100,000 documents might have 500,000 edges, but a single query touches maybe 10,000.
3. Confidence filtering. Not all edges are created equal. Low-confidence relationships (0.3 or below) are stored but excluded from default queries. This cuts noise without losing data.
We've tested this with a synthetic workspace of 50,000 documents and 2 million edges. A 3-hop transitive query completes in 180ms. A single-hop query with sorting by confidence takes 40ms.
How This Changes User Experience
Users don't see SQL or graph theory. They see it in three places:
Universal Command (Ctrl+Shift+A) has a "Related documents" intent. Type a document name, and AiFiler shows you:
- Documents that reference it (reference edges)
- Documents it depends on (dependency edges)
- Documents that contradict it (contradiction edges)
- All sorted by confidence
Knowledge View shows a visual representation of these relationships. Click on a document, and you see incoming and outgoing edges as a small network diagram. Click through to any related document.
Batch operations use the graph to prevent mistakes. Try to delete a document that other documents depend on? AiFiler warns you and shows the dependency chain.
The Lessons We Learned
Confidence matters more than completeness. We initially tried to extract every possible relationship. The result was noise. By focusing on high-confidence relationships (0.7+), we improved signal-to-noise by 10x. Users trust the graph now.
Edge types need to be user-facing. We almost kept the edge type system hidden. Instead, we exposed it in the UI. Now users can say "show me only references, not all relationships." They use it constantly.
Transitive relationships are expensive. We learned this the hard way. A query that traverses 4 hops can touch 100,000+ edges. We now cap at 2 hops by default and require explicit opt-in for deeper traversal.
Stale relationships are worse than missing ones. When a document is deleted, we need to clean up all its edges immediately. A stale edge pointing to a deleted document breaks the entire relationship chain. We now have a cascading delete trigger.
What This Means for You
If you're using AiFiler with 1,000+ documents, the knowledge graph is working behind the scenes on every search, every batch operation, every "show me related documents" query. You're not just searching—you're querying a structured model of how your documents relate to each other.
This is why finding a contract and its dependencies takes seconds instead of minutes. Why moving documents doesn't break references. Why you can ask "what contradicts this policy?" and get an answer instead of a blank stare.
The architecture is deliberately invisible. You should never have to think about edges or hops or confidence scores. You should just notice that your documents feel connected—that the system understands your knowledge, not just your files.
Enjoyed this article?
Get more articles like this delivered to your inbox. No spam, unsubscribe anytime.