Request a callbackBook a call
← All posts

Build an AI Knowledge Agent Over Scattered Internal Docs: Connectors, Permissions, Freshness and Cost Per Question (2026)

TL;DR
  • Indexing a thousand-person company's entire knowledge base costs about $26 in embeddings on published rates. The index is not the expense and never was. Connector maintenance is, at roughly $900 per connector per month in engineering time, and it is the only line that grows without bound.
  • That single fact settles build versus buy. At 1,000 seats, building beats a $60-per-user-per-month vendor until you need around 63 connectors. You are not buying search from an enterprise vendor; you are buying the maintenance of a hundred integrations and the fidelity of their permission models.
  • Permission-aware retrieval has to be both early-binding and late-binding. Denormalised ACLs in the index make queries fast and go stale between syncs; a re-check on the final handful of documents before generation costs about 90 milliseconds and closes the window in which a revoked employee can still retrieve.
The four numbers that reframe this build
$26
modelled cost to embed a 1.2M-document corpus in full, at $0.02 per million tokens
$0.052
modelled blended cost of answering one employee question, including verification
~$900
modelled monthly engineering cost of maintaining one production connector
~63
connectors at which a $60-per-seat vendor becomes cheaper than building, at 1,000 seats
Read these in order and the project changes shape. The corpus is essentially free to index, questions are pennies to answer, and the entire economics live in the connectors — an integration surface that breaks whenever a vendor changes an API, a permission model or a rate limit, which they do continuously and without telling you. Every serious decision in this build is a decision about how many source systems you are willing to own.

What is an internal knowledge agent, architecturally?

A set of connectors that incrementally sync content and permissions, a normalisation and chunking layer, an index carrying an access-control predicate, a query planner, a retrieval step that filters by permission both at query time and again before generation, a freshness-aware ranker, and a verification gate that will refuse rather than guess. Eight parts, and only two of them are model calls.

The category is usually described as chat with your company's documents, which is a demo rather than a system. The demo works because someone indexed a folder that everyone can read, asked a question whose answer is in one document, and got a good answer. Production is different in three specific ways: the content is spread across a dozen systems with incompatible permission models, most of it is out of date, and the questions people actually ask are about things that were decided rather than things that were documented.

The scope worth targeting is a question-answering agent over the systems where knowledge already lives — a wiki, a document store, a code host, a ticket system, a chat platform, a support desk — that answers with citations, respects the asker's existing access, states how old its evidence is, and refuses when it cannot ground an answer. Refusal is a first-class output here, not a fallback. A knowledge agent that never says I could not find a current source for this is a knowledge agent that will confidently describe a process your company abandoned two years ago.

This is a reference design: how I would build it and what the arithmetic says it costs. It draws on architecting AccioMatrix, an event-driven AI platform orchestrating multiple LLMs, agents and MCP tooling for 20+ enterprise clients, and on running large Elasticsearch and Postgres workloads while cutting a $200K-a-year cloud bill by more than 70%. I have not shipped an enterprise knowledge agent in production, and nothing below is an invoice.

One number should reset your expectations before you read further. Embedding a corpus of 1.2 million documents — roughly 1.3 billion tokens once chunked with overlap — costs about $26 at the published $0.02 per million tokens for a small embedding model. Not $26,000. Twenty-six dollars. The instinct that indexing is the expensive part of a knowledge system is a decade out of date, and it leads teams to optimise the one line in the budget that rounds to nothing while ignoring the connectors, which do not.

ComponentWhat it doesImplementationCost at 1,000 seatsPrimary failure modeSkip in v1?
ConnectorsIncremental content and permission sync per source system, with backfill and tombstonesCursor-based sync plus webhooks where available, one worker per source~$900/month each in engineeringA vendor changes an API or a rate limit and the source silently stops syncingNo — but start with three, not twelve
Normalise + chunkOne document model across every source; chunks aligned to sections, threads or turnsDeterministic parsers per source type, chunk metadata carried throughIn infraFixed-token chunking splits a table or a thread mid-argument and both halves become uselessNo
ACL resolverTurns each source's sharing model into a principal set stored alongside the chunkGroup expansion from the identity provider, cached, refreshed on membership changeIn infraA group is expanded stale and a departed employee still matches the predicateAbsolutely not
Embed + indexVectors plus BM25 plus metadata, with the permission predicate as a queryable columnpgvector and BM25 in Postgres until a measured limit forces otherwise~$26 full index, ~$2.80/month incrementalRe-embedding everything on every schema change, turning a $26 job into a weekly ritualNo
Query plannerRewrites, decomposes and scopes the question; refuses out-of-scope questions earlyHaiku-class, 2k in / 200 out, structured output$0.003 per questionDecomposes a simple question into four retrievals and triples the cost for no gainPlanner: month two. Rewrite: v1
Permission-aware retrievalHybrid search with the ACL predicate inside the query, plus a late-binding re-checkPredicate in the SQL, then a live authorisation check on the final top-k$0.0003 amortisedPost-filtering instead of predicate filtering — leaks through counts, timing and eventually a bugNo
Freshness rankerRecency decay per source type, canonical-source preference, supersession awarenessDeterministic scoring features, not a model$0.002 with rerankA confident, well-written, abandoned wiki page from 2022 outranks the current oneNo
Answer synthesisGrounded answer with cited spans, source dates and an explicit coverage statementSonnet-class, ~14k in (4k cached) / 600 out$0.0268 per questionFluent synthesis across contradictory sources, silently picking oneNo
Verification gateSpan validation, freshness disclosure, permission re-check, refusal pathDeterministic checks plus a cheap-model grounding verifier$0.011 per questionConfident wrong answer reaches an employee who acts on itAbsolutely not

What does the full architecture look like?

Twelve components across four columns: sources and connectors, normalisation with ACL resolution and indexing, query planning with permission-aware retrieval and freshness ranking, and synthesis behind a verification gate that can refuse. The load-bearing detail is that the identity provider feeds the ACL resolver directly, and that permissions are checked twice — once as a predicate in the query and once on the final documents immediately before generation.

Start with connectors, because they are the component that determines whether this project succeeds, and they are almost always underestimated. A connector is not an API client. It is a cursor-based incremental sync with a durable checkpoint, a backfill path for the initial load and for recovery, tombstone handling so deletions actually remove content from the index, a permission sync that runs on a different cadence from the content sync, rate-limit backoff tuned to a vendor whose limits change without notice, and an alarm that fires when the source has produced no events for longer than it plausibly should. That is a week of work per source and about a day and a half a month forever.

Normalisation matters more than it looks because chunking strategy is source-specific and getting it wrong is invisible. A wiki page chunks on headings. A Slack thread chunks on the thread, not on the message, because a message in isolation is meaningless and a thread is the unit of meaning. A pull request chunks on the description plus the review conversation. A ticket chunks on the problem statement plus the resolution and nothing in between. Fixed-token chunking applied uniformly across all of these produces an index that retrieves fragments, and fragments produce answers that are technically grounded and practically useless.

The ACL resolver is the component that separates an enterprise knowledge agent from a demo. Every source system has a different sharing model — channel membership, folder inheritance, group grants, link sharing, repository visibility, ticket ownership — and all of them have to be projected onto a single principal set stored alongside each chunk. That projection is lossy at the edges and the edges are where the incidents are: a public channel that is public only to full members, a document shared by link to anyone with the link, a repository visible to a team that itself contains a contractor group.

Everything to the right of retrieval is about not being confidently wrong. The freshness ranker prefers current, canonical sources over old, well-written ones. The synthesiser cites spans. The verification gate checks that the spans exist, that the sources are recent enough to answer the question asked, and that the asker is still permitted to see every document cited — and if any of those fail, it refuses with a specific reason. The retrieval half of this design follows the same pattern as RAG over private documents; what is specific here is the connector layer and the double permission check.

The system
Internal knowledge agent — full reference architecturechanged items onlygroups, sharing rulesone doc modelprincipal set per chunklate-binding re-checkACL predicate in queryscoped, decomposed querytop 60 candidatesranked, dated evidencedraft + cited spansgrounded, fresh, permitted — or refuse
Source systemswiki · drive · chat · code · tickets
Connectorscursor sync · webhooks · backfill · tombstones
Identity + groupsIdP, group expansion, sharing rules
Normalise + chunkone doc model · source-specific chunking
ACL resolverprincipal set per chunk, cached
Embed + indexpgvector + BM25 + ACL column
Query plannerrewrite · decompose · scope · refuse early
Permission-aware retrievalACL predicate in the query
Freshness rankerrecency decay · canonical · supersession
Answer synthesiscited spans + source dates
Verification gatespans · freshness · ACL re-check
Answer or refusalcitations, dates, coverage note
Two edges reach the retrieval box from the permission side, and that redundancy is deliberate. The solid one is the denormalised ACL predicate that makes the query fast. The dashed one is a live authorisation check on the handful of documents that actually made the cut, run immediately before generation. The first is an optimisation; the second is the correctness guarantee, and it costs about 90 milliseconds.

How do connectors and incremental sync actually work?

A durable cursor per source, a change feed consumed forward from that cursor, tombstones for deletions, a separate and more frequent permission sync, a full backfill path that can run without stopping the incremental one, and a staleness alarm that fires when a source has gone quiet for longer than it plausibly should. Six requirements, and most naive connectors implement two.

The cursor is the whole design. Every serious source system exposes some form of monotonic change feed — a delta token, a change log, a modified-since query, a webhook stream — and the connector's job is to consume it forward, checkpoint durably after each batch, and be safe to restart from the last checkpoint. What makes this harder than it sounds is that checkpoints and side effects have to be ordered correctly: write the index changes first, then advance the cursor, so that a crash re-processes a batch rather than skipping it. Re-processing is harmless if your upserts are idempotent, which they must be anyway.

Deletions are the failure nobody plans for. If a document is deleted or unshared at the source and your connector only syncs modifications, that content stays in your index and stays answerable indefinitely. Some sources emit deletion events, some emit them unreliably, and some do not emit them at all — for those you need a periodic reconciliation pass that lists identifiers at the source and tombstones anything in your index that no longer appears. Reconciliation is expensive and boring and it is the difference between a knowledge agent and a leak.

Permission sync runs on a different and faster cadence than content sync, because the consequences differ. A document that is a few hours out of date produces a slightly stale answer. A permission that is a few hours out of date produces a retrieval by someone who should no longer have access. Membership changes and access revocations should be consumed from the identity provider in near real time where the API allows it, and the late-binding re-check described in the next section exists precisely because that near-real-time is never quite real time.

One practical note on transport. Where a source already exposes an MCP server, the July 2026 specification revision helps here in a specific way: responses from tools/list, resources/list and resources/read now carry ttlMs and cacheScope, so a client can cache listings instead of re-fetching them on every sync cycle. For a connector layer polling many sources on a schedule, that removes a meaningful amount of redundant traffic. The broader gateway pattern for third-party tool surfaces is in MCP in production.

connectors/sync.ts
type Cursor = { sourceId: string; token: string | null; lastEventAt: string | null };

type ChangeBatch = {
  upserts: { externalId: string; body: string; updatedAt: string; sharing: SharingModel }[];
  deletes: string[];
  nextToken: string | null;
  hasMore: boolean;
};

const STALENESS_LIMITS_MIN: Record<string, number> = {
  chat: 15, wiki: 120, drive: 120, code: 60, tickets: 60,
};

export async function syncSource(source: Source, store: Store, index: Index): Promise<void> {
  let cursor = await store.getCursor(source.id);

  do {
    const batch: ChangeBatch = await source.fetchChanges(cursor.token, { limit: 200 });

    for (const item of batch.upserts) {
      const doc = normalise(source.kind, item);          // source-specific chunking
      const principals = await resolvePrincipals(item.sharing); // the lossy, important part
      // Idempotent upsert keyed on (sourceId, externalId, chunkOrdinal).
      await index.upsertChunks(source.id, doc, principals, item.updatedAt);
    }

    // Deletions must be explicit. A source that does not emit them needs the
    // reconciliation pass below, or content stays answerable after removal.
    for (const externalId of batch.deletes) {
      await index.tombstone(source.id, externalId);
    }

    // Write side effects FIRST, then advance the cursor. A crash here
    // re-processes a batch, which is harmless because upserts are idempotent.
    cursor = { sourceId: source.id, token: batch.nextToken, lastEventAt: new Date().toISOString() };
    await store.putCursor(cursor);

    if (!batch.hasMore) break;
  } while (true);

  await checkStaleness(source, cursor);
}

/**
 * Permissions sync on a faster cadence than content. A stale document is an
 * accuracy problem; a stale permission is a security problem.
 */
export async function syncPermissions(source: Source, idp: Idp, index: Index): Promise<void> {
  const changes = await idp.membershipChangesSince(await index.lastAclSync(source.id));
  for (const change of changes) {
    await index.reprojectPrincipals(source.id, change.groupId, change.members);
  }
  await index.markAclSynced(source.id, new Date().toISOString());
}

/**
 * For sources with unreliable deletion events: list identifiers at the source
 * and tombstone anything in the index that no longer exists there.
 */
export async function reconcile(source: Source, index: Index): Promise<number> {
  const live = new Set(await source.listAllIds());
  const indexed = await index.listExternalIds(source.id);
  let removed = 0;
  for (const id of indexed) {
    if (!live.has(id)) { await index.tombstone(source.id, id); removed++; }
  }
  return removed;
}

async function checkStaleness(source: Source, cursor: Cursor): Promise<void> {
  const limit = STALENESS_LIMITS_MIN[source.kind] ?? 120;
  const last = cursor.lastEventAt ? Date.parse(cursor.lastEventAt) : 0;
  if (Date.now() - last > limit * 60_000) {
    // A silent connector looks identical to a quiet source. Alarm on it.
    await alert("connector_stale", { sourceId: source.id, limitMinutes: limit });
  }
}
The incremental sync loop. Three details separate this from a naive connector: index writes happen before the cursor advances, so a crash re-processes rather than skips; deletions are handled explicitly rather than assumed absent; and permissions sync on their own faster cadence because a stale permission is a security problem while a stale document is only an accuracy problem. The staleness alarm at the bottom catches the failure where a source silently stops emitting events.
Checklist
What a production connector needs that a demo connector does not
  • Durable cursor with side effects written before the checkpoint advancesAdvance the cursor last and a crash re-processes a batch. Advance it first and a crash silently skips one, permanently.
  • Explicit tombstone handling for deletions and unsharingWithout it, removed content stays answerable forever. This is the most common way a knowledge agent leaks.
  • Periodic reconciliation for sources with unreliable deletion eventsList identifiers at the source, tombstone anything in the index that no longer appears. Expensive, boring, non-optional.
  • Permission sync on a faster cadence than content syncA stale document is an accuracy problem. A stale permission is a security problem. They do not deserve the same interval.
  • Rate-limit backoff tuned per source, with a budgetVendors change limits without notice. A connector that retries aggressively during an outage will be the reason you are rate-limited when it recovers.
  • Staleness alarm per source kindA connector that silently stopped looks exactly like a quiet source. Alarm on time-since-last-event, with a threshold that reflects how chatty the source actually is.
  • Backfill that runs without pausing incremental syncNeeded the first time you change chunking strategy, which you will. Usually built during the first painful re-index rather than before it.
Seven requirements, of which a first attempt typically implements two — fetch changes, upsert documents. The other five are what turn a week of work into a system that stays correct, and they are the reason connector maintenance is modelled at roughly a day and a half per source per month rather than zero.

How do you make retrieval permission-aware without it going stale?

By doing it twice. Denormalise each chunk's principal set into the index so that access control is a predicate inside the search query rather than a filter over results — that is early binding, and it is what makes queries fast. Then re-check authorisation live against the source of truth for the handful of documents that survived ranking, immediately before generation. That is late binding, and it costs about 90 milliseconds.

Early binding alone is fast and stale. Between permission syncs there is a window in which someone who lost access still matches the stored predicate, and the length of that window is however long your slowest permission sync takes. Late binding alone is correct and slow, because checking authorisation on every candidate document at query time means dozens of calls into source systems per question. The hybrid gets both properties: the predicate narrows sixty candidates to a permitted set cheaply, and the live check validates only the six to ten documents that will actually be cited.

The rule that must not be broken is that early binding is a predicate, not a post-filter. Retrieving the top fifty results and then discarding the ones the user cannot see is a different system with worse behaviour: it leaks through result counts, it leaks through latency, it produces empty answers for questions that had permitted answers ranked fifty-first, and it will eventually leak through a bug in the filtering code. Put the access predicate inside the query, in the same store as the data, so that the database never returns a row the user is not entitled to.

There is a second, subtler leak that permission filtering does not solve. Even with perfect document-level access control, an agent that synthesises across many documents can disclose things no single document discloses — that a project exists, that a person is being discussed, that a number is unusually large. The mitigation is not clever: keep the answer strictly grounded in cited spans from documents the user can open, never allow the agent to reason about the absence of results in a way that confirms existence, and phrase refusals so that no-such-document and no-access are indistinguishable. That last one is a UX detail with a security consequence.

One design note on group expansion. Resolving a group to its members is the operation that dominates ACL work, and it must be cached or it will dominate your query latency instead. Cache the expansion with an aggressive invalidation on membership-change events from the identity provider, and treat a cache miss as a reason to go slow rather than a reason to skip the check. The one behaviour that is never acceptable is failing open — if the ACL check cannot complete, the document does not get cited.

retrieval/permission-aware.ts
type Principal = string; // "user:u_123" | "group:g_eng" | "org:acme"

export type Candidate = {
  chunkId: string;
  externalId: string;
  sourceId: string;
  text: string;
  updatedAt: string;
  score: number;
};

/**
 * EARLY BINDING. The access predicate is inside the query. The database
 * never returns a row this principal set cannot see, so there is nothing
 * to post-filter and nothing to leak through result counts or timing.
 */
export async function retrieve(
  db: Db,
  queryVector: number[],
  queryText: string,
  principals: Principal[],
  limit = 60,
): Promise<Candidate[]> {
  return db.query<Candidate>(
    "SELECT c.chunk_id, c.external_id, c.source_id, c.text, c.updated_at, " +
    "       (0.6 * (1 - (c.embedding <=> $1)) + 0.4 * ts_rank(c.tsv, plainto_tsquery($2))) AS score " +
    "  FROM chunks c " +
    " WHERE c.principals && $3::text[] " +   // <-- the predicate, not a filter
    "   AND c.tombstoned_at IS NULL " +
    " ORDER BY score DESC " +
    " LIMIT $4",
    [queryVector, queryText, principals, limit],
  );
}

/**
 * LATE BINDING. Re-check authorisation against the source of truth for only
 * the documents that will be cited. Closes the window between permission
 * syncs. Fails CLOSED: an unresolvable check drops the document.
 */
export async function reauthorise(
  candidates: Candidate[],
  userId: string,
  authz: Authz,
  timeoutMs = 120,
): Promise<{ allowed: Candidate[]; dropped: number }> {
  const settled = await Promise.allSettled(
    candidates.map((c) =>
      withTimeout(authz.canRead(userId, c.sourceId, c.externalId), timeoutMs),
    ),
  );

  const allowed: Candidate[] = [];
  let dropped = 0;
  settled.forEach((r, i) => {
    // Only an explicit true keeps the document. Rejections, timeouts and
    // undefined all drop it. Failing open here is how a revoked employee
    // gets a citation to a document they can no longer open.
    if (r.status === "fulfilled" && r.value === true) allowed.push(candidates[i]);
    else dropped++;
  });

  return { allowed, dropped };
}
Permission-aware retrieval in both phases. The early-binding predicate lives inside the SQL so the database never returns a row the asker is not entitled to — note that it is an ANY overlap against the stored principal array, not a filter applied afterwards. The late-binding check then re-validates only the documents that will actually be cited, closing the window between permission syncs. The check fails closed: an unresolvable authorisation drops the document rather than allowing it.
Three ways to enforce access, and what each one costs you
Post-filter after retrieval
Fast to write, structurally leaky
  • Leaks through result counts, latency and eventually a filtering bug
  • Permitted answers ranked below the cut vanish silently
  • Every new retrieval path needs the filter re-applied correctly
  • The design that produces the incident nobody can reproduce
Late binding only
Correct and unusably slow
  • Dozens of live authorisation calls per question
  • Latency governed by the slowest source system on the internet that day
  • No stale window at all, which is genuinely the right property
  • Fine for a hundred documents, impossible for a million
pick
Early predicate plus late re-check
Both properties, about 90ms
  • Predicate inside the query narrows 1.2M chunks to a permitted 60
  • Live re-check on the 6-10 documents that will actually be cited
  • Stale window closed at exactly the point where it matters
  • Fails closed — an unresolvable check drops the document, never allows it
The third column is not a compromise between the first two; it is strictly better than both and costs about 90 milliseconds. The reason teams end up with the first column is that it is what you get by default when retrieval and authorisation are owned by different people.

What happens on one question, end to end?

Rewrite and scope the question, retrieve sixty candidates with the access predicate inside the query, re-check authorisation on the survivors, rank for freshness and canonicity, synthesise a cited answer, verify it, and return it with source dates and an explicit coverage statement — or refuse. About four seconds, six model-adjacent steps, and one journal row per stage.

Query rewriting is worth its third of a cent because employees do not ask retrievable questions. They ask what is the process for contractor invoices now, where now is doing enormous work, or they ask about a system by an internal nickname that appears in no document. Rewriting expands nicknames from a glossary, resolves now against today's date so the freshness ranker has something to work with, and identifies scope — which sources plausibly contain this. Skipping it saves $0.003 and costs you retrieval quality on most real questions.

The ordering choice that matters is that reranking runs after the permission re-check, not before. Reranking sixty candidates costs about two tenths of a cent and a couple of hundred milliseconds; doing it on documents that are then dropped for access reasons is waste, and worse, it means the model's view of what is most relevant was shaped by documents the user cannot see. Filter first, rank second, and the ranking reflects the world as this particular employee is allowed to see it.

Streaming matters here more than the total latency does. Four seconds of silence reads as broken; four seconds with a first token at 1.2 seconds reads as thoughtful. But there is a tension with verification, because the verification gate runs on the completed answer — which means a streamed answer can be retracted. The honest resolution is to stream the answer body and hold the citations and the confidence framing until verification completes, then attach them. If verification fails, the answer is replaced with the refusal and the reason. Users tolerate that far better than they tolerate a wrong answer, provided it is rare.

Note the cost shape. The synthesis call is 62% of the per-question bill and everything else combined is the rest. Unlike the coding agent in the PR-shipping build, where turns dominate, or meeting intelligence, where audio dominates, this system's cost is one large model call over retrieved context. That means the lever here is context size — retrieving eight well-chosen chunks instead of twenty mediocre ones is simultaneously cheaper and better, which is a rare alignment.

The answer path
One employee question, end to end, with tokens and costEmployeePlannerIndexACL checkRankerSynthesiserVerifierJournal
how do we handle contractor invoices now?
INSERT query_run (principals resolved from IdP)
rewrite + scope: 2.0k in / 200 out
$0.003 · Haiku 4.5
hybrid search, ACL predicate inside the query
60 candidates, already permitted
$0.0003 amortised
late-binding re-check on top 20
18 allowed, 2 dropped (access revoked since sync)
90ms · fails closed
rerank 18 + freshness decay + canonical boost
$0.002
8 chunks: 1 wiki (11d), 3 threads (4-90d), 1 policy PDF (2y)
synthesise: 14.0k in (4k cached) / 600 out
$0.0268 · Sonnet 5
draft + 5 cited spans + source dates
span match, freshness check, ACL recheck: 9k in / 400 out
$0.011 · Haiku 4.5
UPDATE query_run (grounded=true, oldest_source=2y)
answer + citations + newest source is 11 days old
Total modelled cost: $0.0431 for a single-hop question, or about $0.052 blended once you account for the roughly 30% of questions that need a second retrieval round. The two documents dropped by the late-binding check are the entire justification for that stage — under early binding alone they would have been cited to someone whose access was revoked after the last permission sync.
Where four seconds goes
4085ms totalbudget 5000ms
Query rewrite + scope380ms
Hybrid retrieval (predicate in query)140ms
Late-binding ACL re-check90ms
Rerank + freshness scoring220ms
Answer synthesis (streamed)2600ms
Verification pass640ms
Freshness annotation + journal15ms
Just over four seconds against a five-second budget, and 64% of it is the synthesis call. The 90 milliseconds of late-binding authorisation is 2% of the budget and closes the entire stale-permission window, which is the best security-per-millisecond trade available in this design. Stream the body, hold the citations until verification returns, and the perceived latency is the 1.2 seconds to first token.

How do you handle freshness and stale documents?

With four deterministic mechanisms and no model judgement: recency decay tuned per source type, a canonical-source preference, explicit supersession links where they exist, and mandatory disclosure of how old the evidence is. The failure you are preventing is a well-written, confident, thoroughly abandoned document outranking the current one because it is longer and better structured.

Recency decay has to be per source type because half-lives differ enormously. A chat message about a process is nearly worthless after ninety days. A wiki page about the same process may be authoritative for two years. A signed policy document may be authoritative for five. Applying a single decay curve across all of them either discards useful wiki content or promotes stale chat, and both failures are invisible in aggregate retrieval metrics because they trade off against each other.

Canonical-source preference is the cheapest big win. Mark a small set of locations as authoritative for a topic — the handbook space, the policy drive folder, the architecture decision record directory — and give content from those locations a ranking boost that a well-written wiki page from a project space cannot overcome on relevance alone. This takes an afternoon, requires no machine learning, and removes most of the cases where the agent confidently describes a superseded process. It also creates a healthy incentive: teams that want the agent to reflect their process put it in the canonical location.

Disclosure is the mechanism that makes the remaining failures survivable. Every answer states the date of its newest cited source and, when the newest source is older than a threshold, says so prominently: the most recent source I found on this is fourteen months old, so this may be out of date. That single sentence converts a confidently wrong answer into a correctly hedged one, costs nothing, and is the thing employees consistently say they want when you ask them. It also produces a useful organisational signal — a question that repeatedly returns only stale evidence is a documentation gap with a measurable size.

The mechanism worth building in month two is supersession, borrowed from the same problem in meeting history described in the meeting intelligence build. Where a document explicitly replaces another — a version-two runbook, a new architecture decision record superseding an old one — that link should exist as an edge in the index so retrieval can follow it and answer with the current version while acknowledging the old one. Where the link does not exist, the recency and canonicity features are what you have, and they are enough for most cases.

Freshness features, by source type
 Half-life for rankingCanonical?Disclosure thresholdTypical failure
Handbook / policy space24 monthsYes — strong boost18 monthsNobody updated it after the process changed; still outranks everything
Architecture decision records36 monthsYes — with supersession edges24 monthsA superseded ADR retrieved without its replacement
Project wiki pages9 monthsNo9 monthsA finished project's page describes a process the company abandoned
Chat threads3 monthsNo3 monthsA confident answer from a colleague that was wrong at the time
Tickets and support cases6 monthsNo6 monthsA workaround for a bug that was fixed a year ago
Code and READMEs12 monthsRepo READMEs only12 monthsA README describing a build command that no longer exists
Shared drive documents12 monthsOnly in the policy folder12 monthsSix near-identical copies with different dates and no canonical one
Meeting notes6 monthsNo6 monthsA decision recorded accurately and reversed two meetings later
None of this is machine learning. It is eight rows of configuration, and it removes more confidently-wrong answers than any retrieval model upgrade available to you. The last column is worth reading on its own: every one of those failures produces a fluent, well-cited, completely wrong answer that passes every grounding check, because the document really does say that.
Which sources deserve to win a tie
Handbook / policy spaceArchitecture decision recordsRepo READMEProject wiki pageShared drive docSupport ticket resolutionChat threadMeeting notesRarely currentUsually currentHigh authorityLow authority
Chat sits bottom-right: almost always current, almost never authoritative. Handbooks sit top-left of the top-right quadrant: authoritative but frequently out of date. A ranker that only optimises recency promotes chat; one that only optimises authority promotes an abandoned handbook page. You need both features, weighted per source, which is exactly why this is configuration rather than a model.

How do you stop confident wrong answers?

With a verification gate outside the synthesiser combining four checks: every claim quotes a span that literally appears in a retrieved chunk, every cited document is still readable by the asker, the newest cited source is recent enough for the question asked, and a separate cheap model seeing the answer cold agrees it is grounded. Any failure produces a refusal with a specific reason.

Self-reported confidence is deliberately excluded, for the same reason it is excluded in the customer support agent build: a model asked how sure it is in the same call that produced the answer reports fluency, not correctness. The checks that work are external and mostly deterministic. Span validation is string matching. Permission re-checking is an authorisation call. Freshness thresholding is date arithmetic. Only the grounding verifier is a model call, and it sees the answer without the reasoning that produced it, because that reasoning is a persuasion artefact.

The refusal path deserves as much design as the answer path and usually gets none. A good refusal is specific: I found three documents about contractor invoicing but the most recent is from March 2024 and none of them mention the current approval tool, so I do not think I can answer this accurately — here are the documents and here is who last edited them. That is useful. A generic I could not find an answer is not, and it teaches employees that the agent does not work rather than that their documentation does not.

The subtlest failure in this domain is not hallucination but silent contradiction. Two documents disagree — an old policy says forty-eight hours, a newer thread says twenty-four — and the synthesiser, asked for one answer, produces one. It picks. It picks fluently, cites correctly, and passes every grounding check, because both spans genuinely exist. The mitigation is to make disagreement an explicit output type: when retrieved evidence conflicts on a material fact, the answer must surface both with their dates and sources rather than resolving them. Detecting the conflict is the hard part and it is worth a dedicated check on numeric and date claims, which is where most material contradictions live.

One last discipline that costs nothing: an answer must never cite a document the asker cannot open. This sounds obvious and is violated constantly, because a citation is rendered from index metadata while the permission check ran against the source. If the late-binding re-check dropped a document, its span must not appear anywhere in the output — not in the answer, not in a footnote, not in a suggested reading list. A citation to an inaccessible document tells the reader that it exists, which is often precisely the thing they were not supposed to know.

verify/answer-gate.ts
import { z } from "zod";

export const AnswerSchema = z.object({
  markdown: z.string().min(1),
  claims: z.array(z.object({
    text: z.string(),
    chunk_id: z.string(),
    quoted_span: z.string().min(15),
  })).min(1),
  conflicts: z.array(z.object({
    subject: z.string(),
    positions: z.array(z.object({ value: z.string(), chunk_id: z.string() })).min(2),
  })).default([]),
});

export type Answer = z.infer<typeof AnswerSchema>;

type Chunk = { id: string; text: string; updatedAt: string; sourceKind: string };

const DISCLOSE_AFTER_DAYS: Record<string, number> = {
  handbook: 540, adr: 730, wiki: 270, chat: 90, ticket: 180, code: 365, drive: 365,
};

export type GateResult =
  | { decision: "answer"; staleNoticeDays: number | null; conflicts: number }
  | { decision: "refuse"; reason: string; detail?: string };

export async function answerGate(
  answer: Answer,
  chunks: Map<string, Chunk>,
  allowedChunkIds: Set<string>,   // survivors of the late-binding ACL re-check
  verifyGrounded: (a: Answer, c: Map<string, Chunk>) => Promise<number>,
  threshold = 0.75,
): Promise<GateResult> {
  let newest = 0;
  let oldestKindOverThreshold: number | null = null;

  for (const claim of answer.claims) {
    const chunk = chunks.get(claim.chunk_id);

    // 1. free: the cited chunk must exist and the span must really be in it
    if (!chunk) return { decision: "refuse", reason: "citation_unknown_chunk" };
    if (!norm(chunk.text).includes(norm(claim.quoted_span))) {
      return { decision: "refuse", reason: "citation_span_not_found" };
    }

    // 2. free: never cite a document this person cannot open. A citation to an
    // inaccessible doc discloses its existence, which is usually the point.
    if (!allowedChunkIds.has(claim.chunk_id)) {
      return { decision: "refuse", reason: "cited_inaccessible_document" };
    }

    // 3. free: freshness is date arithmetic, per source kind
    const ageDays = Math.floor((Date.now() - Date.parse(chunk.updatedAt)) / 86_400_000);
    newest = Math.max(newest, Date.parse(chunk.updatedAt));
    const limit = DISCLOSE_AFTER_DAYS[chunk.sourceKind] ?? 365;
    if (ageDays > limit) {
      oldestKindOverThreshold = Math.max(oldestKindOverThreshold ?? 0, ageDays);
    }
  }

  // 4. the only model call: a cheap verifier, seeing the answer cold
  const score = await verifyGrounded(answer, chunks);
  if (score < threshold) {
    return { decision: "refuse", reason: "below_grounding_threshold" };
  }

  // Stale evidence is not a refusal. It is an answer with a mandatory notice.
  const staleNoticeDays =
    oldestKindOverThreshold !== null
      ? Math.floor((Date.now() - newest) / 86_400_000)
      : null;

  return { decision: "answer", staleNoticeDays, conflicts: answer.conflicts.length };
}

function norm(s: string): string {
  return s.toLowerCase().replace(/\s+/g, " ").trim();
}
The answer verification gate. Three of the four checks are deterministic and free; only verifyGrounded is a model call, on a cheap model, seeing the answer cold. The check most people omit is the third — a citation to a document the asker cannot open discloses its existence, which is frequently the exact fact that was protected. Note that a stale-evidence result is not a refusal: it is an answer with a mandatory disclosure attached.
What the agent should return, by evidence state
Given what retrieval found, what should the employee actually get?
Multiple current, canonical, agreeing sources
Answer with citations and dates

The clean case. Cite the canonical source first and the corroborating ones after, with each source's last-modified date rendered inline so the reader can judge for themselves.

Sources agree but the newest is past its disclosure threshold
Answer with a prominent staleness notice

Not a refusal. State the age of the newest source in the first line of the answer. This is the single cheapest thing you can do to convert a confidently wrong answer into a correctly hedged one.

Sources materially disagree on a number or a date
Surface both positions, do not resolve

The subtlest failure in the domain. The synthesiser will happily pick one and cite it correctly, because both spans genuinely exist. Make disagreement an explicit output type.

Only chat threads, all older than 90 days
Answer marked low-confidence, plus a documentation-gap signal

Answer if you must, but log it. A question that repeatedly returns only stale chat is a documentation gap with a measurable size, and that report is worth more to the company than the answer was.

No permitted evidence, or grounding below threshold
Specific refusal with what was found

Name the documents you found and could not use, or say plainly that nothing current exists. Crucially, phrase no-such-document and no-access identically, so a refusal never confirms that a document exists.

Four of these five branches are not answers in the sense the demo implies, and that is correct. An internal knowledge agent that answers everything is a liability; one that answers cleanly two thirds of the time, hedges properly on the rest, and produces a documentation-gap report as a by-product is an asset the company keeps.

What does one question actually cost?

About 4.3 cents for a single-hop question and roughly 5.2 cents blended once you account for the third of questions that need a second retrieval round. At 1,000 seats and twelve questions per person per month that is about $624 a month in query cost — which is a rounding error next to the connectors.

Work the line items. Query rewrite and scoping on a cheap model is $0.003. Hybrid retrieval is $0.0003 amortised across infrastructure rather than a per-call API charge. Reranking at a modelled $2 per thousand searches is $0.002. Answer synthesis over 14,000 input tokens with 4,000 of them cached, producing 600 output tokens on Sonnet-class pricing, is $0.0268 — 62% of the total. Verification on a cheap model is $0.011. Total $0.0431, and every number is a token count times a printed rate.

Now the numbers that actually decide the project. Indexing 1.3 billion tokens costs about $26 once and about $2.80 a month to keep current at 3% weekly change. Infrastructure — Postgres with pgvector, a search index, connector workers, observability — is modelled at $900 a month. The core build is roughly fourteen engineer-weeks amortised over 24 months, about $1,750 a month. And connector maintenance is roughly $900 per connector per month at a day and a half of engineering each. With six connectors that is $5,400, which is more than everything else in this paragraph combined.

So the build-versus-buy question is not about seats, it is about connectors. At 1,000 seats, a vendor at a reported $60 per user per month costs $60,000 a month regardless of how many systems it connects. Building costs roughly $3,274 in fixed monthly cost plus $900 per connector, which crosses $60,000 at around 63 connectors. Below that, building is dramatically cheaper on these inputs; above it, you are paying more to maintain integrations than to buy a product whose entire business is maintaining integrations.

Two honest caveats belong with that conclusion. First, Glean publishes no list pricing and every contract is custom-quoted, with reported figures around $45-65 per user per month for base search plus about $15 for advanced AI features and a reported minimum near 100 seats and $60,000 of annual contract value — so treat the comparison as a band, not a rate card. Second, a vendor sells more than connectors: ranking quality tuned over many customers, native clients, enterprise security review artefacts, and someone to call. The correct reading of the 63-connector crossover is not build always wins; it is that the value you are buying is integration breadth, so count your integrations before you sign anything.

Line itemModel / rateTokens or unitsPer questionPer month at 1,000 seatsNote
Query rewrite + scopeHaiku 4.5 · $1 / $5 per 1M2,000 in / 200 out$0.00300$36.00Expands nicknames, resolves now, picks plausible sources
Hybrid retrievalpgvector + BM25, amortised infra1 query over 2.4M chunks$0.00030$3.60Infrastructure amortised, not a per-call API charge
Late-binding ACL re-checkAuthorisation calls, amortised20 checks, 90ms$0.00000~$0Effectively free and closes the entire stale-permission window
RerankCross-encoder · $2 per 1,000 searches18 candidates$0.00200$24.00Runs after the permission check, never before
Answer synthesisSonnet 5 · $2 / $10 per 1M14.0k in (4k cached) / 600 out$0.02680$321.6062% of the per-question cost. Fewer, better chunks is the lever
Verification gateHaiku 4.59.0k in / 400 out$0.01100$132.00Three deterministic checks cost $0; only the grounding verifier is a model call
Single-hop subtotal$0.04310$517.20Modelled from Aug 2026 list prices
Multi-hop premium (30% of questions)Extra retrieval + synthesis turn$0.00870$104.40Blended cost per question: $0.0518
Incremental embeddingtext-embedding-3-small · $0.02 per 1M~140M tokens/month at 3% change$2.80Full initial index of 1.3B tokens is about $26, once
InfrastructurePostgres, search, workers, observability$900.00Modelled; scales with corpus size, not with question volume
Connector maintenance$75/hr · 1.5 engineer-days per connector6 connectors$5,400.00The only line that grows without bound. Everything else is flat
Amortised core build14 engineer-weeks over 24 months$1,750.00Excludes per-connector build, modelled at 1 week each
Total$8,674.60$8.67 per seat per month, of which 62% is connector maintenance
Where $8,675 a month goes at 1,000 seats and six connectors
$8,675per month
  • Connector maintenance (6 sources)$5,400 · 62%
  • Amortised core build$1,750 · 20%
  • Infrastructure$900 · 10%
  • Query cost (12,000 questions)$622 · 7%
  • Incremental embedding$2.80 · 0.03%
The smallest slice on this chart is the one most teams spend their design time on. Embeddings are three ten-thousandths of the bill. Every model call combined is 7%. Sixty-two per cent is engineers keeping six integrations alive, and that share rises linearly with every system you add — which is why the right first question for this project is not which embedding model but which three sources.
Build vs buy at 1,000 seats, by number of connectors
84,30763,23042,15321,07704816305080Monthly cost ($)Source systems connected
Crossover ~63 connectors
Build — infra + query + amortised build + $900 per connectorBuy — reported ~$60 per user per month at 1,000 seats
The vendor line is flat because per-seat pricing does not care how many systems it indexes. The build line has a slope of $900 per connector per month and crosses at roughly 63 sources. That is the honest shape of this decision: you are not choosing between search engines, you are choosing whether to own an integration surface. Count your must-have sources first, then read this chart.
The four numbers to put on the business case
$0.052
modelled blended cost of answering one employee question
$26
modelled one-time cost to embed a 1.2M-document corpus in full
62%
share of the monthly bill that is connector maintenance at six sources
~63
connectors at which a reported $60-per-seat vendor becomes the cheaper option
The second number is the one that surprises people and the third is the one that should change their plan. If you take one thing from this page: the index is free, the questions are pennies, and the integrations are the entire project.

What breaks in production, and how would you know?

Twelve things, and the three most serious produce a well-formed, cited, grammatical answer. A stale document outranking a current one, a permission that went stale between syncs, and a silent contradiction resolved in the model's favour all look like healthy successes on every dashboard you would build by default.

Read the table by its second column. The row that is a security incident rather than a quality problem is the stale permission — a departed employee or a moved team member retrieving content they should no longer see. Early binding alone guarantees a window of exposure equal to your slowest permission sync, and the late-binding re-check exists to close it. The row that quietly destroys trust is the confident stale answer, because the employee acts on it and discovers the error in front of a customer or a colleague.

Four signals catch most of it. Connector staleness — time since last event per source, against a per-source-kind threshold — is the primary operational metric, because a silent connector is indistinguishable from a quiet source and can go unnoticed for weeks. Refusal rate broken out by reason code tells you whether the gate is doing work or being annoying. Late-binding drop rate is your direct measurement of how stale permissions are, and it should be small and stable; a spike means a permission sync is failing. And answer-age distribution — the age of the newest cited source, per question — is the single best measurement of your documentation health.

Alert on rates and distributions, never on single questions. An agent that refuses one question in six is functioning correctly. An agent whose refusal rate moved from 16% to 34% over a week has almost certainly lost a connector, and the fastest way to confirm that is the staleness alarm you should already have. Similarly, a jump in late-binding drop rate almost always means the identity provider sync is behind rather than that many people lost access at once.

The last row is the standard one and it is what makes the rest operable. If you cannot answer, for a specific question asked six weeks ago, which chunks were retrieved, which were dropped by the permission re-check, how they were ranked, which spans were cited, what the grounding score was and why the gate let it through, then you cannot debug a complaint and you cannot tell a regression from a bad week. One journal table, one query, and it is the thing that turns an unexplainable system into an operable one.

Failure modeWhat the user seesWhere to fix itDetection signalCost of getting it wrong
Connector silently stops syncingAnswers that were right last month are now missing recent contextConnector: staleness alarm on time-since-last-event, per source kindTime since last event per source; refusal rate jumping for one sourceWeeks of missing content before anyone notices, and no way to tell how long
Permission stale between syncsA departed or moved employee retrieves content they should not seeRetrieval: late-binding re-check on final documents, failing closedLate-binding drop rate — a spike means the identity sync is behindA genuine access incident. The one row here that is not a quality problem
Post-filtering instead of predicate filteringEmpty answers to questions that had permitted answers ranked lowerRetrieval: put the ACL predicate inside the query, in the same storeAssertion in CI that no retrieval path applies access control after the factStructural leak through counts and timing, plus bad answers. Hard to detect
Deletions never tombstonedRemoved or unshared documents remain answerable indefinitelyConnector: explicit tombstone handling plus periodic reconciliationReconciliation pass removal count — a large number is a bug, not a successContent the company believes it deleted is still being quoted back at people
Stale document outranks the current oneA fluent, correctly cited answer describing an abandoned processRanker: per-source recency decay plus canonical-source boost plus disclosureAge distribution of newest cited source; user thumbs-down by source kindEmployees act on it. The failure that erodes trust fastest and quietest
Silent contradiction resolved by the modelOne confident number where two sources disagreedSynthesis: make conflict an explicit output type on numeric and date claimsConflict-detected rate; disagreement between top-ranked chunks on numbersThe subtlest failure in the domain. Both citations are genuinely real
Citation to an inaccessible documentA reference proving a document exists that the reader cannot openGate: refuse if any cited chunk is not in the allowed set after re-checkCount of citations outside the allowed set — should be exactly zeroDiscloses existence, which is frequently the exact fact that was protected
Uniform chunking across sourcesAnswers grounded in fragments that do not mean anything on their ownNormalisation: chunk on headings, threads, PR conversations, ticket pairsRetrieval precision on a hand-labelled question set, by source kindSystemic mediocrity that looks like the model being weak
Group expansion cache stale or failing openEither slow queries or unauthorised retrievalACL resolver: cache with membership-change invalidation, never fail openCache hit rate and expansion latency p99; any fail-open event, everFail-open here is an access breach dressed as a performance optimisation
Re-embedding everything on every changeNothing. A $26 job that somehow costs engineering weeksIndex: version the chunking scheme; re-embed only affected schemesMonthly embedding token volume against the 3%-change expectationNot money — engineering attention, and a re-index that blocks other work
Refusals that are genericI could not find an answer, with no explanationGate: refuse with the specific reason and the documents that were foundRefusal reason-code distribution; share of refusals with zero detailEmployees conclude the tool does not work rather than that the docs are stale
No query journalNobody can explain a wrong answer from six weeks agoJournal retrieved chunks, drops, ranks, spans, scores and cost in PostgresCan you answer why did it say that from one SELECT — yes or noYou cannot debug complaints, and complaints are how this product is judged
Checklist
The six signals to instrument before the first question
  • Connector staleness: time since last event, per sourceA silent connector looks exactly like a quiet source. This is the single most important operational metric in the system and it costs an afternoon.
  • Late-binding permission drop rateShould be small and stable. A spike means the identity provider sync is behind, not that many people simultaneously lost access.
  • Refusal rate, broken out by reason codeTells you whether the gate is protecting people or annoying them. A jump usually means a connector died rather than that the model got worse.
  • Age of newest cited source, distributedThe best available measurement of documentation health, and a by-product you get for free. Report it to the teams that own the stale spaces.
  • Retrieval precision on a hand-labelled question setFifty real questions with known-correct sources, re-run on every ranking change. Small, boring, and the only thing that catches a ranking regression.
  • Cost per question with a price snapshotStore as bigint micros against a rate-card id. Also track it per source, because one badly chunked source can quietly double average context size.
  • Questions that returned only stale evidence, grouped by topicThis is a documentation-gap report the company would pay for on its own, and it falls out of data you are already collecting. Almost nobody builds it.
The seventh item is the sleeper. A ranked list of the questions employees ask most often that have no current documented answer is arguably more valuable than the agent itself, and it requires no additional instrumentation — only the decision to aggregate what the gate is already recording.

What does it take to build, and what should you skip in v1?

About fourteen engineer-weeks for a two-person team to a production-grade v1 covering three connectors, plus roughly a week per additional connector and a day and a half per connector per month forever. Skip agentic multi-hop, custom embedding models, a dedicated vector database and a chat interface with memory until single-hop answers over three sources are actually trusted.

Week one is the document model, the index schema with its principal array, and the query journal. Weeks two and three are the first connector, done properly — cursor, backfill, tombstones, reconciliation, staleness alarm, rate-limit budget — because the first connector is the template for every subsequent one and shortcuts taken here get copied five times. Week four is the ACL resolver and identity provider integration, including group expansion and its cache invalidation.

Weeks five and six are connectors two and three, which go faster because the pattern exists. Week seven is normalisation and source-specific chunking, which is more work than it sounds and is the difference between retrieving meaning and retrieving fragments. Week eight is retrieval with the ACL predicate, the late-binding re-check and the freshness ranker. Weeks nine and ten are synthesis, the verification gate and the refusal paths. Weeks eleven and twelve are evals against a hand-labelled question set, dashboards and the documentation-gap report. Weeks thirteen and fourteen are rollout, which for this product means picking one department, watching what they ask, and fixing the retrieval failures you could not have predicted.

Skip agentic multi-hop in v1. Decomposing a question into sub-questions and running several retrieval rounds is genuinely useful for a minority of questions and it multiplies cost, latency and failure modes for all of them; Anthropic's own guidance reports multi-agent implementations typically consuming three to ten times the tokens of a single-agent approach for equivalent tasks. Ship single-hop with a good rewriter, measure which questions fail, and add a second hop for those specific shapes. The decision procedure is in multi-agent versus single agent.

Skip the dedicated vector database until Postgres with pgvector has demonstrably failed you, because keeping the ACL predicate in the same transaction as the data is worth far more here than marginal query performance. Skip custom embedding models entirely — the published rate for a commodity small embedding model is $0.02 per million tokens and the corpus costs $26 to embed, so there is no cost argument, and there is no quality argument until you have measured retrieval precision on a labelled set. What you must not skip: tombstones, the permission predicate, the late-binding re-check, freshness disclosure, the refusal path and the query journal. If you want this architected and built rather than described, that is what AI product development is for, and the broader stack these components sit in is in the AI product architecture guide.

Fourteen engineer-weeks, sequenced
  1. Week 1
    Document model, index schema, journal

    One document model across sources, the chunk table with its principal array and tombstone column, the query journal, cost attribution. Everything downstream assumes this shape.

  2. Weeks 2‑3
    Connector one, done properly

    Cursor with durable checkpoint, backfill, tombstones, reconciliation, staleness alarm, rate-limit budget. This is the template five more will be copied from, so shortcuts here compound.

  3. Week 4
    ACL resolver and identity integration

    Group expansion with cache invalidation on membership change, per-source sharing model projection, and the rule that it never fails open. The security core of the system.

  4. Weeks 5‑6
    Connectors two and three

    Faster because the pattern exists, but each source has its own permission quirks — link sharing, inherited folders, contractor groups nested inside team groups.

  5. Week 7
    Normalisation and chunking

    Headings for wikis, threads for chat, description-plus-review for pull requests, problem-plus-resolution for tickets. Uniform token chunking retrieves fragments, and fragments produce useless answers.

  6. Week 8
    Retrieval, re-check, freshness ranker

    ACL predicate inside the query, late-binding re-check on the final documents, per-source recency decay, canonical-source boost. Configuration, not machine learning.

  7. Weeks 9‑10
    Synthesis, verification, refusals

    Cited spans, the four-check gate, staleness disclosure, conflict as an explicit output type, and refusals specific enough to be useful rather than discouraging.

  8. Weeks 11‑14
    Evals, dashboards, rollout

    Fifty hand-labelled questions with known-correct sources, the six operational signals, the documentation-gap report, and one department for three weeks before anyone else sees it.

Four of the fourteen weeks are connectors and one more is the ACL resolver — five weeks on plumbing before a single question is answered well. That ordering feels wrong and is correct, because a knowledge agent over a stale, partially-synced, incorrectly-permissioned index is not a worse product than one over a good index. It is a different and more dangerous one.
Build now, or build when you have a retrieval number
pick
Build in v1
Six things, none of them impressive in a demo
  • Tombstones and reconciliation, so deleted content stops being answerable
  • The ACL predicate inside the query, never a post-filter
  • The late-binding permission re-check, failing closed
  • Per-source freshness decay and mandatory staleness disclosure
  • A refusal path specific enough that the user learns something from it
  • A query journal recording retrieved chunks, drops, ranks, spans and scores
Defer until measured
Four things you will be asked for in month one
  • Agentic multi-hop — 3-10x tokens for equivalent tasks on Anthropic's own numbers
  • A dedicated vector database — pgvector keeps ACLs in the same transaction as the data
  • Custom embedding models — the whole corpus embeds for $26 on a commodity model
  • Conversational memory — it multiplies permission surface for a small usability gain
The left column is roughly three of the fourteen weeks and produces nothing a stakeholder will applaud. The right column is where most enterprise-search budgets go in month two, before anyone has measured retrieval precision on a labelled set of the questions their own employees actually ask.

Building an internal knowledge agent: common questions

How much does an internal knowledge agent cost per question?

On the model in this post, about 4.3 cents for a single-hop question — three tenths of a cent for query rewriting, two tenths for reranking, 2.7 cents for synthesis over 14,000 input tokens with 4,000 cached, and 1.1 cents for verification — or roughly 5.2 cents blended once you include the third of questions that need a second retrieval round. At 1,000 seats and twelve questions per person per month that is about $624 a month, which is around 7% of the total. The dominant cost is connector maintenance at a modelled $900 per source per month. These are modelled figures from published list rates, not measurements of a system I have operated.

Is it cheaper to build an internal knowledge agent or buy one?

It depends almost entirely on how many source systems you need, not on how many people you have. At 1,000 seats, building costs roughly $3,274 a month in fixed cost — infrastructure, query spend, amortised build — plus about $900 per connector per month in engineering. A vendor at a reported $60 per user per month costs $60,000 a month regardless of connector count, so the crossover lands around 63 connectors. Glean publishes no list pricing and every contract is custom-quoted, with reported figures around $45-65 per user for base search plus about $15 for advanced AI features and a reported minimum near 100 seats, so treat that as a band. If you need six connectors, build. If you need sixty, you are buying integration maintenance and you should buy it.

How do you keep retrieval permission-aware without slowing it down?

Do it twice. Denormalise each chunk's principal set into the index so the access check is a predicate inside the search query rather than a filter over returned results — that keeps queries fast and means the database never returns a row the asker is not entitled to. Then re-check authorisation live, against the source of truth, on only the six to ten documents that will actually be cited, immediately before generation. That second check costs about 90 milliseconds, closes the window between permission syncs during which a revoked employee still matches the stored predicate, and must fail closed: an unresolvable check drops the document rather than allowing it.

How do you stop the agent answering from out-of-date documents?

Four deterministic mechanisms, none of them a model. Per-source recency decay, because a chat message is worthless after ninety days while a signed policy may be authoritative for five years. A canonical-source boost for a small set of locations marked authoritative, which takes an afternoon and removes most stale-process answers. Supersession edges where documents explicitly replace others. And mandatory disclosure — every answer states the date of its newest cited source, and says prominently when that source is past a per-source threshold. The disclosure line costs nothing and converts a confidently wrong answer into a correctly hedged one.

What stops it giving a confident wrong answer?

A verification gate outside the synthesiser with four checks. Every claim must quote a span that literally appears in a retrieved chunk, which is string matching rather than model judgement. Every cited document must still be readable by the asker after the late-binding re-check. The newest cited source must be recent enough for the question, or a staleness notice is attached. And a separate cheap model, seeing the answer cold without the reasoning that produced it, must score it above a threshold. Self-reported confidence is deliberately excluded because it tracks fluency rather than correctness. Any failure produces a specific refusal naming what was found and why it could not be used.

How expensive is it to index a large internal corpus?

Far less than people expect. A corpus of 1.2 million documents averaging about 900 tokens is roughly 1.3 billion tokens once chunked with overlap, and at the published $0.02 per million tokens for a commodity small embedding model that is about $26 to embed in full — around $13 through a batch API. Keeping it current at 3% weekly change is roughly $2.80 a month. The instinct that indexing is the expensive part of a knowledge system is a decade out of date, and it leads teams to optimise the one line in the budget that rounds to nothing while the connectors, which cost roughly $900 per source per month in engineering, go unexamined.

How long does it take to build one?

About fourteen engineer-weeks for a two-person team to a production-grade v1 covering three connectors, plus roughly a week per additional connector and a day and a half per connector per month of ongoing maintenance. Four of those weeks are connectors and one more is the ACL resolver, so five of fourteen are plumbing before a single question is answered well. That ordering feels wrong and is correct: a knowledge agent over a stale, partially-synced, incorrectly-permissioned index is not a worse product than one over a good index — it is a different and more dangerous one.

Ready to talk numbers?

Twenty minutes, straight to the engineer. No sales rep, no deck.