Build · Intermediate

How to build a company brain for AI

  • 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.
  • Level: Intermediate
On this page
  1. Short answer
  2. Before you start
  3. Company brain, RAG and knowledge base: what each one is
  4. 1. Write down ten real questions and choose one source
  5. 2. Inventory each source, its owner and its access model
  6. 3. Resolve identities and group membership
  7. 4. Create the schema: content, access lists, provenance and facts
  8. 5. Make the database enforce the filter
  9. 6. Ingest items with their access list and a content hash
  10. 7. Handle freshness, deletions and stale facts
  11. 8. Return answers with evidence and keep an audit trail
  12. 9. Test permissions, freshness, deletion and refusals
  13. Knowledge graph or vector search: which do you need?
  14. Troubleshooting
  15. Verify it worked
  16. Next steps
  17. FAQ
  18. How Swfte can help
  19. Sources and last verified

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.

The steps at a glance

  1. Write down ten real questions and choose one source
  2. Inventory each source, its owner and its access model
  3. Resolve identities and group membership
  4. Create the schema: content, access lists, provenance and facts
  5. Make the database enforce the filter
  6. Ingest items with their access list and a content hash
  7. Handle freshness, deletions and stale facts
  8. Return answers with evidence and keep an audit trail
  9. Test permissions, freshness, deletion and refusals

Before you start

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.

Probably not for you if

  • You have one folder of documents and one team. Build a plain 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 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.
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.
Skill
Intermediate backend and SQL; directory administration for the identity step

Estimates are ours, not measurements, and move with your hardware, data and network.

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.

Knowledge baseRAGCompany brain
Made forPeopleA modelPeople, models and agents
HoldsArticlesPassages and vectorsPassages, people, systems, relationships and evidence
PermissionsPer article, in the toolWhatever you buildCopied from each source, enforced in the query
Typical failureOut of dateRight passage missingA wrong answer that cites a stale or unauthorised source
  1. Step 1Write down ten real questions and choose one source

    You end up with: 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".

    QuestionWho asksSource that answers itWho may see the answer
    How many days of leave carry over?Any employeePolicies wikiEveryone
    Who owns the billing service?On-call engineersService catalogueEngineering
    What is the notice period for the Acme contract?Account managersContract repositoryLegal and named account owners
  2. Step 2Inventory each source, its owner and its access model

    You end up with: 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.

    SourceOwnerPermission modelChange rateSensitivity
    Policies wikiPeople teamSpace-level groupsWeeklyInternal
    Contract repositoryLegalPer-document access listDailyConfidential
    Service cataloguePlatform teamEveryone in engineeringDailyInternal
  3. Step 3Resolve identities and group membership

    You end up with: 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

    Checked against: Microsoft Graph: list a user's memberships (direct and transitive), Google Drive API: permissions.list

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

    You end up with: 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'))
    );

    Checked against: pgvector README (GitHub), PostgreSQL GIN index documentation, PostgreSQL array operators

  5. Step 5Make the database enforce the filter

    You end up with: 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;

    Checked against: PostgreSQL row security policies, PostgreSQL customized options, PostgreSQL system administration functions, PostgreSQL string functions

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

    You end up with: 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, 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;

    Checked against: PostgreSQL INSERT

  7. Step 7Handle freshness, deletions and stale facts

    You end up with: 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));
  8. Step 8Return answers with evidence and keep an audit trail

    You end up with: 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.

  9. Step 9Test permissions, freshness, deletion and refusals

    You end up with: 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
    TestSetupPasses when
    PermissionAsk as a caller without accessNo chunk from the restricted item is returned
    DeletionDelete an item at the source, run the sweepThe item no longer appears in any result
    FreshnessEdit content and permissions at the source, syncThe answer and the visibility both change
    RefusalAsk for something not in the sourcesThe 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 likeUseWhy
What does our leave policy say about carry-over?Hybrid search over chunksThe answer sits in one passage
Who owns the billing service?Facts table, with evidenceThe answer is a relationship, not a passage
Which suppliers mention data transfers outside the EU?Search, then a join to supplier factsPassage search finds candidates; facts name the supplier
What changed in our incident process this quarter?Search filtered by version and dateProvenance and dates carry the answer

Troubleshooting

What you seeLikely causeFix
Queries return zero rows for everyone, including adminsRow-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 policyIt 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 contentChunks 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 typeThe 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 groupThe 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 weekThe 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 recordAccounts 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

Next steps

Related guides

Frequently asked questions

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.

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.

Missing a step or found a command that no longer works? Tell us, or request a how-to.

Sources and last verified

Commands, versions and facts in this guide were checked against the sources below on . Tools change quickly: if something differs from what you see, trust the official documentation and let us know.

  1. pgvector README (GitHub): vector columns, HNSW with vector_cosine_ops, iterative index scans, filtering advice, Postgres 13+ and version 0.8.7
  2. PostgreSQL row security policies: ENABLE ROW LEVEL SECURITY, CREATE POLICY ... USING, default deny, superuser/BYPASSRLS/owner bypass, FORCE ROW LEVEL SECURITY (PostgreSQL 18.6 docs)
  3. PostgreSQL system administration functions: current_setting(name, missing_ok) and set_config(name, value, is_local)
  4. PostgreSQL customized options: Two-part custom setting names need no prior declaration
  5. PostgreSQL string functions: string_to_array(string, delimiter)
  6. PostgreSQL array operators: The && overlap operator
  7. PostgreSQL GIN index documentation: GIN array_ops supports &&; tsvector_ops supports @@
  8. PostgreSQL INSERT: ON CONFLICT DO UPDATE SET ... WHERE and the EXCLUDED table
  9. Microsoft Graph: list a user's memberships (direct and transitive): GET /users/{id}/transitiveMemberOf, OData cast to groups, paging 100 default and 999 maximum, User.Read.All least privilege, limited information for inaccessible objects
  10. Google Drive API: permissions.list: GET /drive/v3/files/{fileId}/permissions, supportsAllDrives, paging, readonly scopes
  11. Swfte company brain page: Status of each part of the company brain design (Built, In progress, Roadmap)

Topics

  • company brain
  • knowledge
  • permissions
  • retrieval
  • knowledge graph

Machine-readable copies: this guide as markdown, index of all guides (JSON). Canonical address: https://www.swfte.com/how-to-build-a-company-brain-for-ai.

Ready to build with Swfte?

One platform for the agents, models and workflows your team ships. Free to start, no card required.