A document platform for humans and agents: the architecture

A corpus of unstructured documents that people and agents query through one index. Six stages, six stores.

Six stages top to bottom, each with its own store: sources, ingest, enrich, index, access, products; synthesis from products loops back into ingest Pipeline: every document takes the same path, each stage has its own store store stage what flows Sources raw store raw bytes Ingest document store text, versions Enrich tag store text + tags Index search index ranked chunks Access budget store results Products user state synthesis written in Products re-enters Ingest as a new document
Six stages, six stores. The only loop: synthesis made in Products re-enters Ingest as a new document.

Stages

StageDoesStore
Sourcespulls feeds, files, APIs and internal notes on a schedule or by push; each source has an ownerraw store, byte-exact, keyed by content hash
Ingestparses to text, strips boilerplate, dedupes by hash, keeps every version under one doc_keydocument store
Enrichtags each document against the ontology: entities, countries, concepts, markets, and the period it discussestag store
Indexsplits into chunks, embeds them, writes text, vector and filter fieldssearch index
Accessone query API with two profiles (human, agent), entitlements and per-agent budgetsbudget store (sessions, quotas)
Productssaved searches, collections, feeds, cards; writes synthesis backuser state

Every document gets a schema, an owning source, ontology tags and versions instead of overwrites.

Schema

Six core tables: document, chunk, tag, entitlement, saved_search, card; the chunk has a stored part and a part copied from tag, entitlement and document rows Schema: six core tables; the chunk carries copies of its filter fields documentidpkdoc_keysource + external idsource_idfk sourceurltextpublished_attimeingested_attime, set by ingestversionintcontent_hashsha256texttext chunkidpkdocument_idfkordinalinttext~1k tokensembeddingvector[d]copied when the chunk is written:tags← tag.value[]acl← entitlement.group_id[]published_at← documentvalid_from← this ingested_atvalid_to← next ingested_at tagdocument_idfkkindentity|country|…valueontology idperiod_fromdateperiod_todateconfidence0–1 entitlementdocument_idfkgroup_idfk group saved_searchidpkownerfk userquerytextfiltersjsonactivebool cardidpksaved_search_idfksummarytextevidencechunk idsstatusnew|read|kept card.evidence holds chunk ids (n:m, no fk) 1:n foreign key reference by ids ← value copied from another table
The chunk is what the index returns. Its lower part is copied from tag, entitlement and document rows when it is written, so no filter needs a join at query time.
Three filtering strategies with k = 10: post-filter returns 3 of 10, pre-filter returns 10 after an exact scan of the subset, native filtering returns 10 by checking the filter on every hop of the graph walk Filtering: where the filter runs decides recall and speed (k = 10) post-filter vector top-10 then filter7 of 10 fail it 3 returned fast loses recall pre-filter filter firstsubset of corpus exact scanof the subset 10 returned full recall slow on big subsets native graph walkover the index filter per hopskip what fails 10 returned full recall, fast tiny subset: exact scan passes the filter fails the filter
Post-filter asks the index for k and throws away what fails the filter: when 7 of 10 fail, the caller gets 3. Native filtering checks the filter on each hop of the vector graph walk; when a filter leaves very few candidates the walk loses its way, so the engine switches to an exact scan of that subset.

Almost every query carries a date, source, entity or ACL condition, so filters run inside the vector search.

Each query runs a keyword search and a vector search over the same filtered chunks. The two rankings are merged with reciprocal rank fusion; the human profile reranks the top 50 with a cross-encoder.

Point-in-time

Timeline of one document ingested three times; a query with as_of between the second and third ingest sees version 2 only Point-in-time: one document, three versions, a query with as_of = Oct 3 Sep 1 Sep 15 Oct 1 Oct 15 ingested_at → version 1 Sep 1 → Sep 15 hidden: valid_to ≤ as_of version 2 Sep 15 → Oct 8 visible version 3 Oct 8 → hidden: valid_from > as_of as_of visible when valid_from ≤ as_of < valid_to; every query carries an as_of
Each ingest of the same doc_key is a new version; valid_from is its ingested_at, valid_to the next version's. A query sees the one version valid at its as_of, so dated questions and backtests are reproducible.

Every query takes an as_of. A chunk is visible when valid_from ≤ as_of < valid_to, so a dated question or a backtest sees each document as it was then and nothing ingested later. ingested_at is set by ingest and never backdated; that keeps as_of results reproducible. The tag period (period_from / period_to) is a separate filter on what the document is about, not on when it was ingested.

Two query profiles, one index

Human profileAgent profile
Reads~300 words/mintens of thousands of words/min
Optimised forprecisionrecall
Results per query10, reranked50–200 chunks
Returned texttitle + short snippetfull chunk text + ids for citation
Plan for~100 queries/hour in total~10 queries/s per agent, 1,000+ for a swarm
Limitsnonerate limit and budget per agent

Same index, filters and ACL; only k, reranking and payload differ. The agent profile sets the scale:

Quality

200 labelled queries run through both search profiles, scored as precision@10 and recall@100, gated before deploy; tuning loops back to a new run Quality loop: both profiles are scored on every change labelled set200 queries, eachwith relevant doc ids run searchboth profiles,production config scoreprecision@10 humanrecall@100 agent gatebelow target:deploy blocked below target: tune chunking, text/vector fusion or reranker, run again
Labels are document ids, so they survive re-chunking. The score runs in CI next to the unit tests.

Products

A saved search is matched against each new chunk on ingest; matches become cards; the user's synthesis is ingested as a document and matched by the watchers again Products: saved search → watcher → card → user → synthesis saved searchquery + filters,owned by a user watchermatches each newchunk on ingest cardsummary +evidence chunk ids useropens, digs in,keeps or dismisses synthesisnote + evidence,acl = ∩ of evidence ingested as a new document: the watchers match it like any other chunk
Saved searches run in reverse: each new chunk is matched against all active saved searches as it is indexed, so cards appear without re-running searches. A synthesis is a document like any other, so the watchers see it too.

Collections are fixed document sets a user hands to an agent as its corpus. A synthesis note gets the intersection of its evidence chunks’ ACLs, so a summary never reaches someone who could not read its sources.

Access

Six clients call one query API, which adds the caller's groups as an acl filter and a per-agent budget, then queries the index Access: one query API; entitlements and budgets are added there web app phone team chat IDE CLI agents (tool API) q query APIprofile: human | agentacl ← caller's groupsbudget per agent q + filters + acl search indexacl is one morefilter in the samegraph walk
Every client, agents included, goes through the same API. The caller's groups become an acl filter inside the index query; agent budgets are checked here too.

Clients never query the index directly; budgets and the acl filter live in the API only.

Build order

  1. Raw store, ingest, document store with doc_key, versions and hashes.
  2. Ontology and tagger; tag store.
  3. Chunking, embeddings, index with native filtering and the copied filter fields.
  4. Query API with both profiles, as_of and entitlements.
  5. Labelled set and the quality gate in CI.
  6. Saved searches, watchers, cards, write-back.

Questions? Let's Chat :)

architecture