
This blog is written by AI for SEO
How to Build a Company Brain Knowledge Graph: Docs and People
Every fast-growing engineering team eventually watches its internal knowledge shatter across three or four SaaS tools. An engineer remembers that someone approved an architectural change six months ago, but finding it means searching Slack channels, skimming Notion PRDs, and checking Google Drive folders. Standard vector search over raw text chunks fails here because chunks drop the relational context. A chunk describing an API deprecation does not know who wrote it, which team owned the service, or what pull request closed the loop.
A company brain knowledge graph fixes this by treating documents, people, and decisions as connected entities instead of flat text embeddings. People, teams, documents, and decisions become nodes joined by explicit edges, so an AI agent can retrieve the exact context around an event. This guide walks through exporting raw metadata from your tools, resolving cross-platform identities, modeling the graph, loading it into HelixDB, and querying relationships alongside vector similarity and BM25 text search.
Step 1: Look at what your tools actually export (Slack history, Notion pages, Drive file lists)
Before designing schemas, look at the payloads your tools return. Most SaaS APIs do not export unified documents. They export fragmented event streams and nested trees of blocks.
Slack exposes message history through the conversations.history and conversations.replies REST endpoints. Each message object has fields like user (a Slack member ID such as U024BE7LH), ts (an epoch timestamp string like 1711928400.023400), text, and optionally a thread_ts pointing to a parent message. If a team member types @alex, Slack stores that in the text field as <@U024BE7LH>. The export has no email address or team affiliation for Alex. It has opaque IDs and raw markdown strings.
Notion represents page content as nested blocks retrieved via the block-children endpoint (such as GET /v1/blocks/{block_id}/children), while /v1/pages retrieves page properties rather than page content. A page metadata payload gives you id, created_time, last_edited_time, and created_by (a Notion user UUID). The actual text is split into paragraphs, callouts, and bullet list items, each with its own block ID. Mentions appear as rich text annotations pointing to internal user UUIDs or external links.
Google Drive files returned by files.list provide id, name, mimeType, owners (with displayName and emailAddress), and modifiedTime. Unlike Notion and Slack, Drive metadata usually includes real email addresses, but you have to download the document body separately as plain text or markdown.
The core discrepancy shows up immediately. Slack tracks users by workspace member IDs, Notion by platform UUIDs, and Google Drive by email addresses. Semantic search over internal documents falls flat when your retriever cannot connect a Slack decision to the person who wrote the design doc in Drive.
Step 2: Map the exports to nodes and edges (Person, Team, Document, Decision, Project; AUTHORED, MENTIONS, DECIDED_IN, OWNS)
Don't map every field from your tool exports into the graph. A bloated ontology gives you slow traversals and painful maintenance. Keep the schema to five core entity types: Person, Team, Document, Decision, and Project.
A Person node is a real coworker, with properties like email, name, and role. A Team node is a group such as Core Infrastructure or Product Analytics. A Document node is a Notion page, a Google Doc, or an RFC, with properties like title, url, raw_text, and created_at. A Decision node is an agreed technical or operational outcome, such as migrating to Kafka or sunsetting an internal service. A Project node tracks a high-level initiative.
Connect these entities with four directed edge types:
AUTHORED connects a Person to a Document or Decision.
MENTIONS connects a Document or Decision to a Person, Project, or Document.
DECIDED_IN connects a Decision to the Document or Slack thread where it happened.
OWNS connects a Person or Team to a Project or Document.
This simple structure answers questions that vector embeddings can't untangle. Say an engineer asks, 'What decisions did Sarah make regarding auth before leaving the team?' The retriever does not scan every document that mentions auth. It starts at the Person node for Sarah, follows incoming AUTHORED edges to Decision nodes, and checks the DECIDED_IN edge back to the original Slack thread or RFC.
Step 3: Resolve the same person across Slack ids, Notion users and emails
Entity resolution is where most internal company brains break. If Slack user U024BE7LH and Notion user 9a6e1a74-d4b1-4f3b-8219-48227b4cb7df stay separate entities in your database, the graph splits into disconnected islands. You need a canonical Person node before you load documents.
Use corporate email as the ground truth identifier. To build the mapping, query the Slack users.list endpoint to get profile.email for every active user. Fetch Notion workspace users with /v1/users to extract person.email. Pull Google Workspace directory records or Drive owner objects for standard corporate emails. Then build an in-memory or persisted dictionary that maps platform IDs to a single canonical email:
interface ResolvedIdentity {
canonicalId: string;
name: string;
email: string;
aliases: {
slackId?: string;
notionId?: string;
driveEmail?: string;
};
}Normalize emails by lowercasing them and stripping plus-addressing tags. If your team has been through mergers or contractor conversions, keep an explicit alias list for secondary personal emails. When you process incoming Slack threads and Notion blocks, look up every user reference in this map. Write edges straight to the canonical Person node instead of creating synthetic nodes for each tool. When you deduplicate entities in an agent memory knowledge graph at ingestion time, your graph traversals stay clean and reliable.
Step 4: Load the graph into HelixDB with embeddings, full-text fields and timestamps
HelixDB is an open-source graph-vector database built in Rust that handles graph traversal, vector search, and BM25 full-text indexing in a single engine. Vectors in HelixDB are properties directly on nodes and edges, so you don't have to coordinate writes across a separate vector store, graph store, and application database.
Use the official TypeScript SDK (@helix-db/helix-db) to batch your writes. Prepare your document chunks with an embedding model (normalizing outputs to float32), format your full-text properties, and attach timestamps as integer Unix epoch values for efficient indexing.
import { Client, NodeRef, g, writeBatch, type NodeId } from '@helix-db/helix-db';
const client = Client.server('http://localhost:6969');
async function ingestDocumentWithDecision(doc: NotionDoc, authorId: NodeId) {
const batch = writeBatch()
// Canonical Document node; the embedding is a top-level float32 array property
.varAs('doc', g().addN('Document', {
docId: doc.id,
title: doc.title,
content: doc.content,
embedding: doc.vector,
created_at: doc.timestamp,
source: 'notion',
}))
// Decision node extracted from the page text
.varAs('decision', g().addN('Decision', {
summary: doc.decisionSummary,
embedding: doc.decisionVector,
created_at: doc.timestamp,
}))
// Link the Decision and the canonical Person to the Document
.varAs('decidedIn', g().n(NodeRef.var('decision')).addE('DECIDED_IN', NodeRef.var('doc')))
.varAs('authored', g().n(authorId).addE('AUTHORED', NodeRef.var('doc')))
.returning(['doc', 'decision']);
await client.query(batch.toQueryRequest()).send();
}Graph relationships and vector indexes commit atomically in HelixDB, so your edges and vector properties never fall out of sync.
Step 5: Query one neighbourhood: vector search pre-filtered to a project plus BM25 for exact names
Standard RAG queries scan a global vector index, pull the top ten closest chunks, and hand them to an LLM. In an enterprise, this fails constantly. A semantic search for 'rate limiting policy' pulls fragments from old RFCs, discarded prototypes, and unrelated team docs. The query has no structural scope.
HelixDB lets you run vector pre-filtering: vector search scoped to related entities instead of the whole database. If an engineer asks how the Payments team handles rate limiting, your application resolves the Payments Project node, traverses to its connected documents, and runs approximate vector search only over that neighborhood. In the same query you can combine BM25 and vector search to match exact terms like error codes (ERR_RATE_LIMIT_EXCEEDED) or specific engineer handles.
import { Client, PropertyProjection, g, readBatch, type NodeId } from '@helix-db/helix-db';
const client = Client.server('http://localhost:6969');
async function searchProjectDecisions(projectId: NodeId, queryVector: number[], keyword: string) {
const batch = readBatch()
// Vector search scoped to Decisions linked to this Project's Documents
.varAs('decisions', g()
.n(projectId)
.out('OWNS')
.hasLabel('Document')
.in('DECIDED_IN')
.hasLabel('Decision')
.vectorSearch('Decision', 'embedding', queryVector, 10)
.project([
PropertyProjection.renamed('$id', 'id'),
PropertyProjection.renamed('$distance', 'distance'),
PropertyProjection.new('summary'),
]))
// BM25 over the same Project's Documents for exact terms and error codes
.varAs('documents', g()
.n(projectId)
.out('OWNS')
.hasLabel('Document')
.textSearch('Document', 'content', keyword, 10)
.project([
PropertyProjection.renamed('$id', 'id'),
PropertyProjection.renamed('$score', 'score'),
PropertyProjection.new('title'),
]))
.returning(['decisions', 'documents']);
return client.query(batch.toQueryRequest()).send();
}In HelixDB's own benchmarks, native graph traversals run up to 1000x faster than Neo4j, while vector performance stays on par with Pinecone and Qdrant. Running vector similarity over graph-filtered candidate sets prevents orphaned chunk retrieval and keeps agent answers relevant.
Step 6: Add time: decisions from last quarter with a range index, and valid_from if you need history
Engineering decisions rarely last forever. A startup picks MongoDB in 2022, migrates to Postgres in 2024, and adds ClickHouse in 2025. If your agent treats every ingested document as equally current, it will retrieve obsolete setup instructions and conflicting architectural decisions.
HelixDB provides efficient time-range indexing, but temporal and point-in-time modeling is not built into the engine. You model time yourself on nodes and edges. Store timestamps as plain numeric properties, such as integer Unix epoch milliseconds in created_at, valid_from, and valid_to.
When querying for recent context, use range predicates over your indexed timestamp property:
import { Client, Predicate, g, readBatch } from '@helix-db/helix-db';
const client = Client.server('http://localhost:6969');
async function getRecentDecisions(startTimestamp: number, endTimestamp: number) {
const batch = readBatch()
.varAs('documents', g()
.nWithLabel('Decision')
.where(Predicate.between('created_at', startTimestamp, endTimestamp))
.out('DECIDED_IN')
.hasLabel('Document')
.valueMap(['$id', 'title', 'created_at']))
.returning(['documents']);
return client.query(batch.toQueryRequest()).send();
}If you need point-in-time validity for organizational changes, put valid_from and valid_to properties on OWNS edges. When Core Infrastructure hands a service to the Platform team, set valid_to on the existing edge and insert a new edge with valid_from set to the current epoch. When your agent asks who owns a service, it filters for edges where valid_to is null or greater than the target query time.
Step 7: Enforce access in your app before the query (tenant partitioning is not access control)
Draw a hard line around database multi-tenancy. HelixDB supports tenant-partitioned indexes to isolate data across different organizations or enterprise tenants. But tenant partitioning is not per-user access control or row-level security.
Never send raw user identity tokens into your database and expect the engine to calculate read permissions across Slack channels and Google Drive ACLs. In a company knowledge base, an engineer might have access to general engineering channels, public Notion spaces, and specific Drive folders, while being locked out of executive compensation discussions and pending HR investigations.
Enforce access control in your application layer before you build the graph query. Run an access control evaluation service that inspects the requesting user's identity, queries your corporate directory or identity provider (such as Okta or Google Workspace), and resolves an allowed_resource_ids list.
Pass those resource IDs into the query as an explicit filter predicate. You can also scope the initial graph traversal to namespaces the user is cleared to read:
import { g } from '@helix-db/helix-db';
// The application resolves which Project IDs this user may read before HelixDB runs the query
const allowedProjectIds = await authService.getAllowedProjects(userContext);
const q = g()
.n(allowedProjectIds)
.out('OWNS')
.hasLabel('Document');Keeping authorization logic in the application layer means your security boundaries match the source systems, and you don't have to sync fragile permissions into your database schema.
What to do next and troubleshooting
Once your ingestion pipeline and query boundaries work, watch how the system behaves during production updates. Common failure modes trace back to three areas: traversal candidate ceilings, entity resolution drift, and dangling edges.
First, monitor candidate set sizes. In HelixDB, an unrestricted vector search caps at 800 effective results, and queries that process more than 1,000,000 unique candidate entities return an error instead of silently clamping. If your traversals hit these limits, narrow your entry points with date range filters or shallower document traversal depths.
Second, clean up stale references. When an employee leaves the company or changes teams, don't delete their Person node. Deleting nodes without pruning connected edges leaves orphaned relationships. Mark the Person node inactive and use edge properties to close ownership windows.
To move forward, start with a small slice of your stack. Run HelixDB locally via Docker or in-process with the TypeScript SDK, write an ingestion script for one active Slack channel and its associated Notion docs, and test how accurately your agent traces technical decisions back to their original authors.
Conclusion
Connecting Slack threads, Notion specs, and Drive files into a company brain knowledge graph cuts the hallucinations and orphaned chunks that plague traditional RAG. Pair graph relationships with vector and full-text search in one engine, and your agents can traverse from a technical decision back to the exact meeting notes and engineers behind it.
HelixDB gives you graph traversal, vector similarity, and BM25 search in a single Rust engine. Star HelixDB on GitHub, run helix init local then helix start dev to get a local instance on port 6969 (it needs Docker or Podman), and start loading your team's knowledge into one graph.