# How to build a company brain for AI

Canonical: https://www.swfte.com/how-to-build-a-company-brain-for-ai
Last verified: 2026-10-06
Difficulty: Intermediate
Time: Two to three days for one source and one team, then about a week per extra source
Cost: No licence cost for the open stack used here; your time and the compute for embeddings are the cost.
Hardware: A small server or laptop for one source. Embedding a large archive is the heavy part; plan it as a batch job.

## Short answer

A company brain is the layer that gives AI the organisation's knowledge and context under the same access rules as the source systems. Build it in this order: pick questions and one source, resolve identities, copy each item's access list with the item, store provenance, retrieve with the filter inside the query, then add freshness and tests. Skipping the permissions step is the failure that matters.

## Who this is for

- Engineering and data leads who must let AI assistants or agents use internal knowledge without exposing it to the wrong people.
- Teams who built a first retrieval demo and now face the question of what it is allowed to read.
- Security and platform owners who need to say how an AI answer was produced and who could have seen the source.

Not for:
- You have one folder of documents and one team. Build a plain [RAG system](https://www.swfte.com/how-to-build-a-rag-system) first; you can grow it into this.
- You want a finished product to buy. This guide is the design and a working open foundation, not a hosted service.

## Prerequisites

- Admin or read-only API access to at least one document source and to your directory (Microsoft Entra ID, Google Workspace or similar).
- A named owner for each source who can say who is allowed to read what.
- A PostgreSQL database with the pgvector extension. The [RAG guide](https://www.swfte.com/how-to-build-a-rag-system) shows how to start one with Docker.
- A written list of data you will not index: credentials, secrets and special-category personal data to begin with.

## Company brain, RAG and knowledge base: what each one is

People use these three words loosely, so fix them before you design anything. A knowledge base is content written for people to read. RAG is a technique: retrieve passages and give them to a model. A company brain is a governed model of the organisation (who people are, what systems exist, who owns what, and which documents say so), with RAG as one way to read from it. The longer comparison is in [Company brain vs RAG vs knowledge base](https://www.swfte.com/blog/company-brain-vs-rag-vs-knowledge-base-2026).

|  | Knowledge base | RAG | Company brain |
| --- | --- | --- | --- |
| Made for | People | A model | People, models and agents |
| Holds | Articles | Passages and vectors | Passages, people, systems, relationships and evidence |
| Permissions | Per article, in the tool | Whatever you build | Copied from each source, enforced in the query |
| Typical failure | Out of date | Right passage missing | A wrong answer that cites a stale or unauthorised source |

## Steps

### Step 1: Write down ten real questions and choose one source

Outcome: A list of ten questions, who asks each, and the single source that can answer most of them.

Collect ten questions people actually ask: from support tickets, chat threads and onboarding notes. Next to each, write who asks it and which system holds the answer. You will notice that two or three sources answer most of them. Start with one.

Pick a first source that is well organised and has clear owners, such as a policy wiki or a documentation space. Avoid chat and email at the start. They hold the most useful context and the hardest permission problems, because access depends on who was in each conversation. Do the easy permission model first and let the design harden.

Keep the ten questions. They become your test set in step 9, and they stop the project turning into "index everything".

| Question | Who asks | Source that answers it | Who may see the answer |
| --- | --- | --- | --- |
| How many days of leave carry over? | Any employee | Policies wiki | Everyone |
| Who owns the billing service? | On-call engineers | Service catalogue | Engineering |
| What is the notice period for the Acme contract? | Account managers | Contract repository | Legal and named account owners |

### Step 2: Inventory each source, its owner and its access model

Outcome: A table of sources with an owner, a sensitivity level and an answer to "how does this system decide who can read an item?".

For every source you plan to connect, record the owner, how it exposes permissions, how often it changes, and how sensitive it is. The permission column is the one that decides the work. Some systems give you an access list per item through an API. Some only expose roles on a folder. Some do neither, and you will have to decide whether to exclude them.

Write the rule for the unknown case now: if you cannot read an item's access list, the item is invisible to everyone except its owner. Public by default is how a brain leaks. The same rule applies when a sync of permissions fails halfway.

Also write the exclusion list. Credentials, API keys and password vaults stay out. Strip secret-looking strings before storage, and treat personal data as local-only unless there is a recorded reason to index it.

| Source | Owner | Permission model | Change rate | Sensitivity |
| --- | --- | --- | --- | --- |
| Policies wiki | People team | Space-level groups | Weekly | Internal |
| Contract repository | Legal | Per-document access list | Daily | Confidential |
| Service catalogue | Platform team | Everyone in engineering | Daily | Internal |

### Step 3: Resolve identities and group membership

Outcome: One person record per human, linked to every account they have, with their groups listed.

The same person appears as a Microsoft account, a Google account, a Git username and a ticketing login. If you cannot say those are one person, you cannot apply their permissions. Match accounts to a person by a stable key first (employee identifier, then email), record which key matched in `match_evidence`, and send anything uncertain to a human. Never merge on display name.

Read group membership from the directory with a read-only account. In Microsoft Graph, the transitive membership call returns groups the user belongs to directly or through nesting, which is what you want, because a nested group still grants access. The default page size is 100 and the maximum is 999, so follow the paging links. When you cast to groups, Microsoft documents that the `ConsistencyLevel: eventual` header and `$count` are required, and that the index behind it may lag recent changes. The application permission needed to read another user's memberships is `User.Read.All` as least privileged, per Microsoft's documentation.

For per-file permissions in Google Drive, the permissions list call returns who has access to one file. Request only the fields you need, page through the results and use a read-only scope.

Microsoft Graph: a user's groups, direct and nested:

```text
GET https://graph.microsoft.com/v1.0/users/{id}/transitiveMemberOf/microsoft.graph.group?$count=true&$select=id,displayName
ConsistencyLevel: eventual
```

Google Drive: who can read one file:

```text
GET https://www.googleapis.com/drive/v3/files/{fileId}/permissions?supportsAllDrives=true
```

> WARNING: Both calls need an authorised token from your own app registration. Request read-only scopes. Store the access tokens in a secret manager, not in the brain.

### Step 4: Create the schema: content, access lists, provenance and facts

Outcome: A Postgres database where every item and chunk carries its source, its version and who may read it.

Keep it in one database to start. Every item records where it came from (`source_id`, `source_ref`), which version it was, a content hash, when it was retrieved and the list of principals allowed to read it. A principal here is a string such as `user:42` or `group:engineering`; the identity step produces them. The chunks table carries a denormalised copy of that list so one indexed filter can serve the search.

The `facts` table is the graph, kept small: subject, predicate, object, the item that is the evidence, when it was observed and a status. Five statuses are enough to begin with: observed, verified, inferred, stale and disputed. A status you never use is clutter; a status you cannot show on an answer is worse.

Embeddings use `vector(768)` to match the embedding model in the RAG guide. Change it if you use another model. The GIN index on the principals array supports the overlap operator, and the HNSW index uses cosine distance, as the pgvector README documents.

schema.sql:

```text
-- Who and what exists
CREATE TABLE sources (
    id text PRIMARY KEY,              -- 'policies-wiki'
    owner text NOT NULL,              -- a named person or team
    sensitivity text NOT NULL,        -- 'internal', 'confidential', 'restricted'
    last_synced_at timestamptz
);

CREATE TABLE people (
    id bigserial PRIMARY KEY,
    display_name text NOT NULL,
    employee_id text UNIQUE
);

CREATE TABLE accounts (               -- one person, many accounts
    source_id text NOT NULL REFERENCES sources(id),
    account_ref text NOT NULL,        -- the id the source system uses
    person_id bigint NOT NULL REFERENCES people(id),
    match_evidence text NOT NULL,     -- 'employee_id', 'email', 'manual'
    PRIMARY KEY (source_id, account_ref)
);

-- Content, with provenance and the access list copied from the source
CREATE TABLE items (
    id bigserial PRIMARY KEY,
    source_id text NOT NULL REFERENCES sources(id),
    source_ref text NOT NULL,         -- URL or id in the source
    title text,
    version text,
    content_hash text NOT NULL,
    retrieved_at timestamptz NOT NULL DEFAULT now(),
    allowed_principals text[] NOT NULL,   -- e.g. {user:42,group:engineering}
    acl_synced_at timestamptz NOT NULL DEFAULT now(),
    deleted_at timestamptz,
    UNIQUE (source_id, source_ref)
);

CREATE TABLE chunks (
    id bigserial PRIMARY KEY,
    item_id bigint NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    position int NOT NULL,
    content text NOT NULL,
    allowed_principals text[] NOT NULL,   -- denormalised copy of the item's list
    embedding vector(768)
);

CREATE INDEX ON chunks USING GIN (allowed_principals);
CREATE INDEX ON chunks USING GIN (to_tsvector('english', content));
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);

-- Relationships, each with the evidence it came from
CREATE TABLE facts (
    id bigserial PRIMARY KEY,
    subject text NOT NULL,            -- 'service:billing-api'
    predicate text NOT NULL,          -- 'owned_by'
    object text NOT NULL,             -- 'team:payments'
    evidence_item_id bigint REFERENCES items(id),
    observed_at timestamptz NOT NULL,
    status text NOT NULL CHECK (status IN ('observed', 'verified', 'inferred', 'stale', 'disputed'))
);
```

### Step 5: Make the database enforce the filter

Outcome: A query that returns nothing for a caller who sets no identity, and only readable rows for one who does.

Application code that adds `WHERE allowed_principals && ...` will be forgotten in one place eventually. Add row-level security as a second lock. When it is enabled, Postgres says all normal access must be allowed by a policy, and with no policy the default is deny. Superusers, roles with `BYPASSRLS` and table owners normally bypass it, so connect your application as a role that owns nothing, and use `FORCE ROW LEVEL SECURITY` so that even the owner is subject to it.

The policy reads the caller's principals from a transaction-local setting. Postgres accepts a custom two-part setting name such as `app.principals` without declaring it, and `set_config` with its third argument true applies the value to the current transaction only. Passing `true` to `current_setting` returns NULL instead of an error when it is unset, which turns into "no rows". The value must come from your authentication layer, never from anything the user typed.

Enable the policy:

```text
-- The database enforces the filter even if application code forgets it.
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks FORCE ROW LEVEL SECURITY;

CREATE POLICY chunks_read ON chunks FOR SELECT
    USING (allowed_principals && string_to_array(current_setting('app.principals', true), ','));
```

Query as a caller (the $1 is the question embedding):

```text
BEGIN;
-- Set from the verified identity of the caller, never from the request body.
SELECT set_config('app.principals', 'user:42,group:engineering,group:everyone', true);

SELECT c.id, i.title, i.source_ref, i.retrieved_at, c.content
FROM chunks c
JOIN items i ON i.id = c.item_id
WHERE i.deleted_at IS NULL
ORDER BY c.embedding <=> $1
LIMIT 10;
COMMIT;
```

> TIP: Test the lock: connect as the application role, run the query without the set_config line, and confirm zero rows. Then run it with a principal that has no overlap and confirm zero rows again.

### Step 6: Ingest items with their access list and a content hash

Outcome: A repeatable sync that updates changed items, refreshes changed permissions and skips unchanged content.

For each source, a sync lists items, fetches each item's content and access list, resolves the access list into principals using the identity tables, and upserts. The upsert below touches a row only when the content hash or the access list changed, so a nightly run is cheap and a permission change takes effect on the next run. Chunk and embed changed items as in the [RAG guide](https://www.swfte.com/how-to-build-a-rag-system), copying the principals onto every chunk.

Two details earn their place. First, write the access list and the content in the same transaction, so a chunk is never stored with a stale list. Second, record the sync start time before listing, because the deletion sweep in the next step compares against it. If the sync fails, do not run the sweep.

Upsert one item (parameters are bound by your code):

```text
INSERT INTO items (source_id, source_ref, title, version, content_hash, allowed_principals)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (source_id, source_ref) DO UPDATE SET
    title = EXCLUDED.title,
    version = EXCLUDED.version,
    content_hash = EXCLUDED.content_hash,
    allowed_principals = EXCLUDED.allowed_principals,
    acl_synced_at = now(),
    retrieved_at = now(),
    deleted_at = NULL
WHERE items.content_hash IS DISTINCT FROM EXCLUDED.content_hash
   OR items.allowed_principals IS DISTINCT FROM EXCLUDED.allowed_principals;
```

### Step 7: Handle freshness, deletions and stale facts

Outcome: Deleted source items disappear from answers, and old facts say they are old.

A brain that returns last year's policy with confidence is worse than none. After a complete sync, mark any item the source no longer returned as deleted, and make every query exclude deleted items. Run the sweep only after a full successful pass, or a half-finished sync will hide half your content.

Facts age differently from documents. Decide how long each kind stays trustworthy without new evidence (ownership might be six months; a price list might be a week) and mark facts stale when they pass it or when their evidence item is deleted. Show the status and the date next to any answer that relies on a fact. People forgive an answer that says "last confirmed in March"; they do not forgive one that does not say.

Permissions need their own schedule. Sync access lists more often than content, because a person leaving a group should lose access within hours, not weeks.

Nightly sweep:

```text
-- After a complete, successful sync of one source, anything it did not return is gone.
UPDATE items
SET deleted_at = now()
WHERE source_id = $1
  AND deleted_at IS NULL
  AND retrieved_at < $2;   -- $2 = the time this sync started

-- Mark facts whose evidence is gone or old.
UPDATE facts SET status = 'stale'
WHERE status IN ('observed', 'verified', 'inferred')
  AND (observed_at < now() - interval '180 days'
       OR evidence_item_id IN (SELECT id FROM items WHERE deleted_at IS NOT NULL));
```

### Step 8: Return answers with evidence and keep an audit trail

Outcome: Every answer lists its sources, their dates and statuses, and every question leaves a record.

Pass the retrieved chunks to the model with their titles and dates, ask it to cite them, and show those citations to the user. The model should be told that if the sources do not contain the answer, it must say so. Return the item title, link, version and retrieval date with each citation, plus the status of any fact used.

Write an audit record for each question: who asked (the verified identity), the question, which items were retrieved, which model answered and when. This is how you answer "who could have seen this?" and "what did the assistant know when it said that?". Keep the log append-only and restrict who can read it, because it contains the questions themselves.

If agents, not people, will use the brain, give each agent its own identity and its own principals. An agent that borrows a human's access list can read everything that human can. See [how to govern AI agents](https://www.swfte.com/how-to-govern-ai-agents).

### Step 9: Test permissions, freshness, deletion and refusals

Outcome: A test run that fails if the brain leaks, serves deleted content, or answers without evidence.

Reuse the ten questions from step 1 and add four kinds of test. Permission tests: ask a question as a caller who must not see the answer and assert that no chunk from the restricted item appears. Deletion tests: delete a source item, run the sweep and assert it no longer appears. Freshness tests: change an item's content and permissions and assert both changes show after the next sync. Refusal tests: ask something the sources cannot answer and assert the system says so.

Run the permission tests as a gate: any leak fails the build. Then prove the gate works by removing the row-level policy in a test database and confirming the permission test fails. A test you have never seen fail is a guess.

Track two numbers over time: the share of your ten questions the brain answers correctly with a valid citation, and the share of items whose access list was synced in the last 24 hours. If the second falls, your permissions are rotting even if the first looks fine.

**The four test types**

| Test | Setup | Passes when |
| --- | --- | --- |
| Permission | Ask as a caller without access | No chunk from the restricted item is returned |
| Deletion | Delete an item at the source, run the sweep | The item no longer appears in any result |
| Freshness | Edit content and permissions at the source, sync | The answer and the visibility both change |
| Refusal | Ask for something not in the sources | The system says it cannot find it, with no citation |

## Knowledge graph or vector search: which do you need?

Start with vector and keyword search over documents. Add a graph of facts only when your questions are about relationships that no single document states: who owns this service, which team does this person report into, which contracts mention this supplier. Those answers come from joining facts, and a similarity search cannot join.

A graph is also where evidence matters most. Every fact should say which item it came from, when it was observed and whether anyone has checked it. The `facts` table in step 4 is enough to start with, in the same Postgres database. Move to a dedicated graph database when joins across many hops become the main workload, not before.

**Choosing a retrieval shape by question**

| Question looks like | Use | Why |
| --- | --- | --- |
| What does our leave policy say about carry-over? | Hybrid search over chunks | The answer sits in one passage |
| Who owns the billing service? | Facts table, with evidence | The answer is a relationship, not a passage |
| Which suppliers mention data transfers outside the EU? | Search, then a join to supplier facts | Passage search finds candidates; facts name the supplier |
| What changed in our incident process this quarter? | Search filtered by version and date | Provenance and dates carry the answer |

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Queries return zero rows for everyone, including admins | Row-level security is on, no principals were set, or the policy uses `current_setting` without the missing-ok argument. | Confirm `set_config('app.principals', ..., true)` runs in the same transaction as the query. Use `current_setting('app.principals', true)` so a missing value yields NULL and therefore no rows, not an error. |
| The application can read everything despite the policy | It connects as a superuser, a role with BYPASSRLS, or the table owner. | Connect as a separate role that owns nothing, and run `ALTER TABLE ... FORCE ROW LEVEL SECURITY` for the owner case. |
| A person who left a group still finds restricted content | Chunks carry a copy of the access list from the last sync. | Run access-list syncs more often than content syncs, or resolve group membership at query time. Record `acl_synced_at` and alert when it is old. |
| Graph API returns a limited object with only an id and a type | The app lacks permission to read that object type; Microsoft Graph returns limited information instead of an error. | Request the least privileged permission documented for the call, and check the response for objects whose other properties are null before treating them as groups. |
| Search returns far fewer results than expected for a narrow group | The approximate vector index applies the filter after scanning its candidates. | Enable `SET hnsw.iterative_scan = strict_order` as the pgvector README describes, and consider a partial index or partitioning for very selective filters. |
| Answers cite documents that were deleted last week | The sweep did not run, or queries do not filter on deleted_at. | Add `deleted_at IS NULL` to every retrieval query, run the sweep after each complete sync, and add the deletion test from step 9. |
| Two people were merged into one record | Accounts were matched on display name or a shared mailbox. | Match on employee identifier, then email; log `match_evidence`; send ambiguous cases to a person. Unmerge by deleting the wrong `accounts` rows and re-running the sync. |

## Verify it worked

- [ ] You have a written inventory with an owner and a permission model for every connected source.
- [ ] A query run without setting principals returns zero rows, and one run with an unrelated principal returns zero rows.
- [ ] A caller in the right group retrieves a restricted item, and removing them from the group removes access after the next access-list sync.
- [ ] Deleting an item at the source removes it from results after the sweep.
- [ ] Every answer shows its citations with title, version and retrieval date, and says so when there is no source.
- [ ] The permission tests fail when you disable the row-level policy in a test database.

## Next steps

- [Build a RAG system](https://www.swfte.com/how-to-build-a-rag-system): The chunking, embedding and hybrid retrieval code this guide builds on.
- [Govern AI agents](https://www.swfte.com/how-to-govern-ai-agents): Give agents their own identities before they read from the brain.
- [Validate your AI](https://www.swfte.com/how-to-validate-your-ai): Turn the test set into regression gates and monitoring.
- [Set up human approval for AI agents](https://www.swfte.com/how-to-set-up-human-approval-for-ai-agents): Decide which actions an agent may take on what it learns from the brain.
- [Building a company brain without losing control of your data](https://www.swfte.com/blog/building-a-company-brain-without-losing-control-of-your-data-2026): The control and data-location questions behind the design.

## FAQ

### What is a company brain for AI?

A company brain is a governed layer that holds an organisation's knowledge and context in a form AI can use, with the same access rules as the source systems. It combines searchable content, a model of people, systems and relationships, and evidence for each fact. RAG is one way to read from it.

### How is a company brain different from RAG?

RAG retrieves passages for a model. A company brain also models who people are, what owns what and where each fact came from, and carries permissions from each source. A brain can use RAG to read documents; a RAG system alone does not give you identity, relationships or evidence.

### Do I need a knowledge graph?

Not at first. Search over documents answers most questions. Add a graph of facts when questions are about relationships that no single document states, such as who owns a service. Keep each fact linked to the item that is its evidence.

### How do I stop an AI assistant showing people documents they cannot open?

Copy each item's access list from the source, store it with the content, and filter inside the search query using the caller's verified identity. Add row-level security so the database enforces it too, and test with callers who must not see a document.

### What should I not put in a company brain?

Passwords, API keys and other credentials. Special-category personal data unless you have a recorded reason. Anything whose access list you cannot read: treat it as invisible. Strip secret-looking strings from content before storing it.

### How long does it take to build a company brain?

Allow two to three days for one well-organised source and one team, then roughly a week for each further source, mostly spent on permissions. These are estimates; identity matching and messy permission models take longer than the code.

### Is a company brain the same as enterprise search?

Enterprise search finds documents for people. A company brain serves people, models and agents, and adds a model of the organisation and evidence for each fact. Many brains start as enterprise search over one source and grow outwards.

## How Swfte can help

Swfte is building this design as the company brain on its platform: a customer-hosted layer that holds an evidence-backed graph of the organisation. The status of each part is on its page.

- [Company brain](https://www.swfte.com/platform/company-brain): The design and the status of each part.
- [Sources the brain reads](https://www.swfte.com/platform/company-brain/sources): Which source families are built, in progress or on the roadmap.
- [Access and governance](https://www.swfte.com/platform/company-brain/access-and-governance): How permissions and audit work in the design.
- [Ask your company](https://www.swfte.com/platform/company-brain/ask-your-company): Search and answers over documents, with their status.

Built: the graph with evidence statuses, read-only directory sync (Active Directory and LDAP, Entra ID, Okta, Google Workspace), identity resolution and a hash-chained audit trail. In progress: search over documents and the sanitisation step before models. Roadmap: collectors for cloud, code, tickets and business systems, and the link to Cortex. Availability and pricing: <company brain availability and pricing - founder to fill>. You can follow every step above without it.

## Sources

- [pgvector README (GitHub)](https://github.com/pgvector/pgvector): vector columns, HNSW with vector_cosine_ops, iterative index scans, filtering advice, Postgres 13+ and version 0.8.7
- [PostgreSQL row security policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html): ENABLE ROW LEVEL SECURITY, CREATE POLICY ... USING, default deny, superuser/BYPASSRLS/owner bypass, FORCE ROW LEVEL SECURITY (PostgreSQL 18.6 docs)
- [PostgreSQL system administration functions](https://www.postgresql.org/docs/current/functions-admin.html): current_setting(name, missing_ok) and set_config(name, value, is_local)
- [PostgreSQL customized options](https://www.postgresql.org/docs/current/runtime-config-custom.html): Two-part custom setting names need no prior declaration
- [PostgreSQL string functions](https://www.postgresql.org/docs/current/functions-string.html): string_to_array(string, delimiter)
- [PostgreSQL array operators](https://www.postgresql.org/docs/current/functions-array.html): The && overlap operator
- [PostgreSQL GIN index documentation](https://www.postgresql.org/docs/current/gin.html): GIN array_ops supports &&; tsvector_ops supports @@
- [PostgreSQL INSERT](https://www.postgresql.org/docs/current/sql-insert.html): ON CONFLICT DO UPDATE SET ... WHERE and the EXCLUDED table
- [Microsoft Graph: list a user's memberships (direct and transitive)](https://learn.microsoft.com/en-us/graph/api/user-list-transitivememberof?view=graph-rest-1.0): GET /users/{id}/transitiveMemberOf, OData cast to groups, paging 100 default and 999 maximum, User.Read.All least privilege, limited information for inaccessible objects
- [Google Drive API: permissions.list](https://developers.google.com/workspace/drive/api/reference/rest/v3/permissions/list): GET /drive/v3/files/{fileId}/permissions, supportsAllDrives, paging, readonly scopes
- [Swfte company brain page](https://www.swfte.com/platform/company-brain): Status of each part of the company brain design (Built, In progress, Roadmap)

Last verified against these sources on 2026-10-06.
