You've got 500 documents. A contract references a statement of work, which mentions a client, which appears in three proposals, two of which cite a specific regulatory document. Finding those connections manually takes hours. Finding them with the wrong architecture takes minutes. Finding them with the right one takes milliseconds.
This is the problem the knowledge graph solves. But building a graph database that doesn't slow down as your document collection grows is harder than it sounds.
The Core Problem: Relationships at Scale
Most document management tools treat documents as isolated objects. You search for a filename or keyword, and you get back a list. That works until you need to answer questions like:
- "Show me every document this client appears in"
- "What contracts reference this regulatory requirement?"
- "Which team members collaborated on documents related to this project?"
Those questions require understanding relationships. And relationships are expensive to compute if you haven't designed for them from the start.
The naive approach: query all documents, load them into memory, parse them, extract entities, and build the graph on-the-fly. That scales to maybe 100 documents before your API response time hits 5 seconds. At 500 documents, you're looking at 30-second timeouts.
AiFiler's approach is different. We pre-compute the graph, store it efficiently, and query it like a database—because it is one.
The Eight Edge Types: What We Actually Connect
Not all relationships are created equal. We identified eight distinct types of connections that matter for knowledge work:
Document-to-Document edges represent direct references. One document cites another. We detect these through AI analysis (Claude reads the document and identifies citations) and explicit user links (you click "Link to document").
Document-to-Entity edges connect documents to extracted concepts: clients, projects, regulatory requirements, people. A contract document links to the "ACME Corp" entity and the "Q2 2024 Budget" project.
Entity-to-Entity edges capture relationships between concepts. "ACME Corp" is a client of "Smith & Associates." "Q2 2024 Budget" is part of "2024 Strategic Plan." These are often inferred from document content or user input.
Document-to-Tag edges are lightweight categorization. You tag a document "urgent" or "awaiting-review." These are fast to query and useful for filtering.
User-to-Document edges track ownership and collaboration. Who created this document? Who has access? Who last modified it?
Workspace-to-Document edges establish document membership. Which workspace owns this document? (Workspaces are how AiFiler isolates data for different teams or projects.)
Document-to-Chunk edges connect documents to their parsed sections. A 50-page contract is split into chunks (introduction, terms, signatures). This matters for precise search and citation.
Version-to-Document edges track document history. Document v1 became v2 became v3. Understanding the lineage is crucial for compliance and audit trails.
Eight edge types. Each queryable in milliseconds. Each updated asynchronously so they never block your workflow.
The Data Model: How We Store It
Here's where architecture decisions matter.
We use Supabase PostgreSQL with a normalized schema. Each edge type has its own table. This seems redundant—why not one giant "edges" table with a type column?—but it's not. Here's why:
Different edge types have different query patterns. When you ask "Show me all documents related to this client," you're querying Document-to-Entity edges with a specific entity filter. That's a simple indexed lookup. When you ask "Show me all documents this user created," you're querying User-to-Document edges with a specific user ID. Also simple.
If we crammed all edges into one table, every query would need to filter by edge type first, then by the actual relationship. That's an extra WHERE clause on every query. At scale, that matters.
Each table is indexed on:
source_id(the document or entity on the left side of the relationship)target_id(the document or entity on the right side)created_at(for temporal queries)- Composite indexes on
(source_id, edge_type)for the most common queries
The schema looks roughly like this:
CREATE TABLE document_to_entity_edges (
id UUID PRIMARY KEY,
document_id UUID REFERENCES documents(id),
entity_id UUID REFERENCES entities(id),
confidence FLOAT, -- how sure are we about this relationship?
source TEXT, -- 'ai_extracted' or 'user_created'
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX (document_id),
INDEX (entity_id),
INDEX (document_id, entity_id)
);
The confidence field is critical. When Claude extracts "ACME Corp" from a document, it's 95% confident. When you manually link a document to an entity, it's 100% confident. Queries can filter by confidence threshold. This prevents noise—you won't see weak inferences cluttering your results.
The Ingestion Pipeline: How Edges Get Created
When you upload a document, here's what happens:
-
File parsing (
lib/ingest/parseFile.ts): The document is converted to text. A 50-page PDF becomes structured chunks. -
AI analysis (Claude via
lib/ai/client.ts): Claude reads the document and extracts entities (clients, projects, people, requirements). It identifies citations to other documents in your workspace. It assigns confidence scores. -
Edge creation: For each extracted entity, we create a Document-to-Entity edge. For each citation, we create a Document-to-Document edge. For each entity relationship Claude identifies, we create an Entity-to-Entity edge.
-
Indexing: Edges are written to PostgreSQL. Indexes are updated. The graph is now queryable.
This all happens asynchronously. Your upload completes immediately. The graph updates in the background. By the time you open the document, the edges are usually already computed.
The key insight: we don't wait for the graph to be perfect. Claude might miss a relationship. You can manually add it. The graph is a starting point, not a final answer.
The Query Layer: How We Fetch Relationships Fast
When you click on a document in AiFiler, you see a "Related Documents" section. That's a graph query.
Under the hood, we're running something like:
-- Find all documents related to this one
SELECT DISTINCT d.id, d.name, d.created_at
FROM documents d
WHERE d.id IN (
-- Documents directly linked
SELECT target_id FROM document_to_document_edges WHERE source_id = ?
UNION
-- Documents that share an entity
SELECT DISTINCT d2.id FROM documents d2
JOIN document_to_entity_edges d2e ON d2.id = d2e.document_id
WHERE d2e.entity_id IN (
SELECT entity_id FROM document_to_entity_edges WHERE document_id = ?
)
AND d2.id != ?
)
ORDER BY d.created_at DESC
LIMIT 20;
This query:
- Finds direct links (Document-to-Document edges)
- Finds indirect links (documents that share entities)
- Excludes the document itself
- Limits to 20 results to keep response time under 100ms
On a workspace with 5,000 documents, this runs in 40-80ms. On 50,000 documents, it's still under 200ms. That's because we're not scanning the entire document collection—we're following indexed edges.
The Real-Time Challenge: Keeping the Graph Fresh
Here's a problem most graph database articles gloss over: what happens when you update a document?
You edit a contract. You add a new client name. The graph is now stale. That client's entity doesn't link to your document yet.
We solve this with a two-tier update strategy:
-
Immediate user actions: When you manually link a document to an entity (via the "Link to document" button in the UI), that edge is created synchronously. You see the result immediately.
-
Background re-analysis: When you edit a document, we queue it for re-analysis. Claude re-reads it, extracts entities again, and updates edges. This happens in the background and usually completes within 30 seconds. Your search results reflect the new relationships within a minute.
We don't re-analyze on every keystroke—that would be wasteful. We re-analyze on save. This is a deliberate trade-off: you get fast edits and eventual consistency in the graph.
For critical workflows (like client deliverables), you can manually trigger a re-analysis via the three-dot menu on any document. That forces an immediate update.
Why This Matters for You
The knowledge graph isn't a feature you interact with directly. You don't open a "graph viewer" and see nodes and edges. Instead, you experience it as:
-
Faster search: Universal Command (Ctrl+Shift+A) can find related documents instantly because it's querying the graph, not scanning documents.
-
Better context: When you open a document, you immediately see what else it's connected to. No manual digging required.
-
Smarter batching: When you batch-move documents, the system can suggest related documents to move together. That's the graph identifying clusters.
-
Audit trails: For compliance, you can trace the lineage of a document—which versions exist, who touched it, what it references. That's the Version-to-Document and User-to-Document edges.
The architecture is invisible. The speed is not.
The Lessons We Learned
Separate tables for different edge types, not one monolithic table. It's more normalized, but it's also more queryable. The trade-off is worth it.
Confidence scores matter. Not all relationships are equal. Filtering by confidence prevents noise and lets users trust the graph.
Async ingestion is non-negotiable. Your upload shouldn't wait for the graph to be computed. Let it happen in the background. Users can manually refine edges if needed.
Index aggressively. A graph query is only fast if the indexes are right. We spend time thinking about the most common query patterns and index accordingly.
Don't try to be perfect. The graph will have gaps. That's okay. Users can fill them in. The system learns from their corrections.
Building a knowledge graph that scales requires thinking differently about how you store and query relationships. It's not about having the fanciest graph database. It's about understanding your query patterns, indexing for them, and keeping the ingestion pipeline fast enough that users never feel the latency.
At AiFiler, we chose PostgreSQL with careful indexing over a specialized graph database. It's simpler to operate, easier to back up, and fast enough for our use cases. For your use case, the answer might be different. But the principles are the same: know your edges, index your queries, and keep the ingestion asynchronous.
Enjoyed this article?
Get more articles like this delivered to your inbox. No spam, unsubscribe anytime.