Build an AI Knowledge Agent Over Scattered Internal Docs: Connectors, Permissions, Freshness and Cost Per Question (2026)
- 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.
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.
| Component | What it does | Implementation | Cost at 1,000 seats | Primary failure mode | Skip in v1? |
|---|---|---|---|---|---|
| Connectors | Incremental content and permission sync per source system, with backfill and tombstones | Cursor-based sync plus webhooks where available, one worker per source | ~$900/month each in engineering | A vendor changes an API or a rate limit and the source silently stops syncing | No — but start with three, not twelve |
| Normalise + chunk | One document model across every source; chunks aligned to sections, threads or turns | Deterministic parsers per source type, chunk metadata carried through | In infra | Fixed-token chunking splits a table or a thread mid-argument and both halves become useless | No |
| ACL resolver | Turns each source's sharing model into a principal set stored alongside the chunk | Group expansion from the identity provider, cached, refreshed on membership change | In infra | A group is expanded stale and a departed employee still matches the predicate | Absolutely not |
| Embed + index | Vectors plus BM25 plus metadata, with the permission predicate as a queryable column | pgvector and BM25 in Postgres until a measured limit forces otherwise | ~$26 full index, ~$2.80/month incremental | Re-embedding everything on every schema change, turning a $26 job into a weekly ritual | No |
| Query planner | Rewrites, decomposes and scopes the question; refuses out-of-scope questions early | Haiku-class, 2k in / 200 out, structured output | $0.003 per question | Decomposes a simple question into four retrievals and triples the cost for no gain | Planner: month two. Rewrite: v1 |
| Permission-aware retrieval | Hybrid search with the ACL predicate inside the query, plus a late-binding re-check | Predicate in the SQL, then a live authorisation check on the final top-k | $0.0003 amortised | Post-filtering instead of predicate filtering — leaks through counts, timing and eventually a bug | No |
| Freshness ranker | Recency decay per source type, canonical-source preference, supersession awareness | Deterministic scoring features, not a model | $0.002 with rerank | A confident, well-written, abandoned wiki page from 2022 outranks the current one | No |
| Answer synthesis | Grounded answer with cited spans, source dates and an explicit coverage statement | Sonnet-class, ~14k in (4k cached) / 600 out | $0.0268 per question | Fluent synthesis across contradictory sources, silently picking one | No |
| Verification gate | Span validation, freshness disclosure, permission re-check, refusal path | Deterministic checks plus a cheap-model grounding verifier | $0.011 per question | Confident wrong answer reaches an employee who acts on it | Absolutely 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.
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.
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 });
}
}- 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.
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.
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 };
}- 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
- 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
- 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
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.
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.
| Half-life for ranking | Canonical? | Disclosure threshold | Typical failure | |
|---|---|---|---|---|
| Handbook / policy space | 24 months | Yes — strong boost | 18 months | Nobody updated it after the process changed; still outranks everything |
| Architecture decision records | 36 months | Yes — with supersession edges | 24 months | A superseded ADR retrieved without its replacement |
| Project wiki pages | 9 months | No | 9 months | A finished project's page describes a process the company abandoned |
| Chat threads | 3 months | No | 3 months | A confident answer from a colleague that was wrong at the time |
| Tickets and support cases | 6 months | No | 6 months | A workaround for a bug that was fixed a year ago |
| Code and READMEs | 12 months | Repo READMEs only | 12 months | A README describing a build command that no longer exists |
| Shared drive documents | 12 months | Only in the policy folder | 12 months | Six near-identical copies with different dates and no canonical one |
| Meeting notes | 6 months | No | 6 months | A decision recorded accurately and reversed two meetings later |
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.
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 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.
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.
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.
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.
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.
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 item | Model / rate | Tokens or units | Per question | Per month at 1,000 seats | Note |
|---|---|---|---|---|---|
| Query rewrite + scope | Haiku 4.5 · $1 / $5 per 1M | 2,000 in / 200 out | $0.00300 | $36.00 | Expands nicknames, resolves now, picks plausible sources |
| Hybrid retrieval | pgvector + BM25, amortised infra | 1 query over 2.4M chunks | $0.00030 | $3.60 | Infrastructure amortised, not a per-call API charge |
| Late-binding ACL re-check | Authorisation calls, amortised | 20 checks, 90ms | $0.00000 | ~$0 | Effectively free and closes the entire stale-permission window |
| Rerank | Cross-encoder · $2 per 1,000 searches | 18 candidates | $0.00200 | $24.00 | Runs after the permission check, never before |
| Answer synthesis | Sonnet 5 · $2 / $10 per 1M | 14.0k in (4k cached) / 600 out | $0.02680 | $321.60 | 62% of the per-question cost. Fewer, better chunks is the lever |
| Verification gate | Haiku 4.5 | 9.0k in / 400 out | $0.01100 | $132.00 | Three deterministic checks cost $0; only the grounding verifier is a model call |
| Single-hop subtotal | — | — | $0.04310 | $517.20 | Modelled from Aug 2026 list prices |
| Multi-hop premium (30% of questions) | Extra retrieval + synthesis turn | — | $0.00870 | $104.40 | Blended cost per question: $0.0518 |
| Incremental embedding | text-embedding-3-small · $0.02 per 1M | ~140M tokens/month at 3% change | — | $2.80 | Full initial index of 1.3B tokens is about $26, once |
| Infrastructure | Postgres, search, workers, observability | — | — | $900.00 | Modelled; scales with corpus size, not with question volume |
| Connector maintenance | $75/hr · 1.5 engineer-days per connector | 6 connectors | — | $5,400.00 | The only line that grows without bound. Everything else is flat |
| Amortised core build | 14 engineer-weeks over 24 months | — | — | $1,750.00 | Excludes per-connector build, modelled at 1 week each |
| Total | — | — | — | $8,674.60 | $8.67 per seat per month, of which 62% is connector maintenance |
- 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%
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 mode | What the user sees | Where to fix it | Detection signal | Cost of getting it wrong |
|---|---|---|---|---|
| Connector silently stops syncing | Answers that were right last month are now missing recent context | Connector: staleness alarm on time-since-last-event, per source kind | Time since last event per source; refusal rate jumping for one source | Weeks of missing content before anyone notices, and no way to tell how long |
| Permission stale between syncs | A departed or moved employee retrieves content they should not see | Retrieval: late-binding re-check on final documents, failing closed | Late-binding drop rate — a spike means the identity sync is behind | A genuine access incident. The one row here that is not a quality problem |
| Post-filtering instead of predicate filtering | Empty answers to questions that had permitted answers ranked lower | Retrieval: put the ACL predicate inside the query, in the same store | Assertion in CI that no retrieval path applies access control after the fact | Structural leak through counts and timing, plus bad answers. Hard to detect |
| Deletions never tombstoned | Removed or unshared documents remain answerable indefinitely | Connector: explicit tombstone handling plus periodic reconciliation | Reconciliation pass removal count — a large number is a bug, not a success | Content the company believes it deleted is still being quoted back at people |
| Stale document outranks the current one | A fluent, correctly cited answer describing an abandoned process | Ranker: per-source recency decay plus canonical-source boost plus disclosure | Age distribution of newest cited source; user thumbs-down by source kind | Employees act on it. The failure that erodes trust fastest and quietest |
| Silent contradiction resolved by the model | One confident number where two sources disagreed | Synthesis: make conflict an explicit output type on numeric and date claims | Conflict-detected rate; disagreement between top-ranked chunks on numbers | The subtlest failure in the domain. Both citations are genuinely real |
| Citation to an inaccessible document | A reference proving a document exists that the reader cannot open | Gate: refuse if any cited chunk is not in the allowed set after re-check | Count of citations outside the allowed set — should be exactly zero | Discloses existence, which is frequently the exact fact that was protected |
| Uniform chunking across sources | Answers grounded in fragments that do not mean anything on their own | Normalisation: chunk on headings, threads, PR conversations, ticket pairs | Retrieval precision on a hand-labelled question set, by source kind | Systemic mediocrity that looks like the model being weak |
| Group expansion cache stale or failing open | Either slow queries or unauthorised retrieval | ACL resolver: cache with membership-change invalidation, never fail open | Cache hit rate and expansion latency p99; any fail-open event, ever | Fail-open here is an access breach dressed as a performance optimisation |
| Re-embedding everything on every change | Nothing. A $26 job that somehow costs engineering weeks | Index: version the chunking scheme; re-embed only affected schemes | Monthly embedding token volume against the 3%-change expectation | Not money — engineering attention, and a re-index that blocks other work |
| Refusals that are generic | I could not find an answer, with no explanation | Gate: refuse with the specific reason and the documents that were found | Refusal reason-code distribution; share of refusals with zero detail | Employees conclude the tool does not work rather than that the docs are stale |
| No query journal | Nobody can explain a wrong answer from six weeks ago | Journal retrieved chunks, drops, ranks, spans, scores and cost in Postgres | Can you answer why did it say that from one SELECT — yes or no | You cannot debug complaints, and complaints are how this product is judged |
- 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.
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.
- Week 1Document 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.
- Weeks 2‑3Connector 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.
- Week 4ACL 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.
- Weeks 5‑6Connectors 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.
- Week 7Normalisation 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.
- Week 8Retrieval, 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.
- Weeks 9‑10Synthesis, 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.
- Weeks 11‑14Evals, 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.
- 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
- 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
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.