Scale

Production Target

The enterprise-scale target for both backends: what the always-on RAG path needs to handle massive throughput and isolation, and what the ephemeral EKS cluster would need before it could carry production traffic.

Both backends also keep a server-side Ask transcript today, viewable while a session is live and after it ends: EKS reuses the per-run store it already had, and the Bedrock console gets its own durable chat store in this app. In Chat mode, that durable transcript IS the model's conversational context now (Ops' Chat / Single question toggle makes this real and switchable per question) - the RAG API Lambda's own short-term session memory is skipped entirely rather than combined with it, which is what keeps a resent history from being billed twice. Single question mode reads and writes neither store, for a genuinely memoryless turn.

A retention tension worth naming: chat turns are kept for a year, while uploaded documents expire after 24 hours (ADR-005's "don't leave data lying around" reasoning does not automatically extend to a year of visitor questions). See ADR-015.

The live quota panel is deployed and verified: the browser subscribes directly to AWS IoT Core over MQTT/WSS for the remaining-questions count, so no Vercel function is held open for the life of a tab. Proven by subscribing as a browser would, a retained message arrived immediately on connect showing 15 remaining, and a push arrived showing 14 while a query ran. One AWS-specific detail was worth the trouble it caused: IoT Core rejects a SigV4 signature computed over the session token, so the token is appended after signing. Until that was found the URL looked correct in every respect and the socket simply closed. See ADR-016.

Sets from fields, stories from retrieval

A visitor asked this demo "what are the different projects Arup has contributed in?" and it refused, over a corpus that is almost entirely his projects. Three causes stacked behind that one refusal, and each was hiding the next. The corpus survey (ADR-017) exists to answer exactly that shape of question by enumerating every document rather than searching, and it never fired: the detector matched a noun list that stopped at "skills", so "projects" fell through to ordinary top-k. Asked with the word "skills" instead, the same system answered correctly. One absent noun separated a full corpus survey from a refusal.

With that fixed the survey fired, and exposed the deeper problem. It handed the model all fourteen document summaries as prose and let it work out what each one was. The answer listed "AI Certifications and Formal Training" and "AI Architecture Patterns" as projects, and omitted three production wins. Nothing in the payload distinguished work delivered from material about the practice, so the model guessed, and guessed badly.

Typed fields were the obvious repair: docType, domains and techniques derived at ingestion and written into the vector payload as indexed, filterable keys. It was also not enough, twice over, and both discoveries were worth more than the repair.

The corpus was the wrong size. /work rendered 22 projects at the time (the list has since grown). The corpus had documents for five. The demo was answering from roughly a quarter of the real work, and no retrieval strategy fixes an index that does not contain the answer. It had been written by hand alongside content the site already held as typed data, and the two drifted: the corpus technology list for the migration omitted Rails, PostgreSQL and Redis that /work had all along. So the corpus is now derived from /work rather than maintained beside it: the site publishes its projects as JSON, the seed reads it, and projects with a hand-written narrative keep it while the rest get a generated spine. Fourteen documents became 27. Presence on /work is also what decides whether something is a project, which retired a rule that had been guessing from filename prefixes.

Naming a type is not enforcing it. With every document typed and in the prompt, the model still answered with reference material listed as projects. A field the model is asked to respect is a suggestion. So membership stopped being its job: a question asking which projects, technologies or domains is now answered by computing the set from the indexed fields in code, and the model only describes what it is handed. Sets come from fields, stories come from retrieval. The trace says enumerate rather than survey when that happens, because a field read is not a search and should not be dressed as one.

Then the guardrails ate the answer. With all 24 computed and handed over, the reply still named four or five. The prompt was not the problem. A grounding guard exists to catch an answer produced without retrieving, and an enumerate calls no tools, so it fired: it discarded the answer and rebuilt one from the top-scoring citations. Enumerated members carry a score of zero on purpose, because they were read from a field and never ranked, so they sorted last and were cut every time. A complete list of 24 was being thrown away and regenerated from six retrieved chunks. The coverage check that appended the missing nineteen was not a safety net doing its job; it was patching damage inflicted three steps earlier.

The grounding self-check made the same mistake one step later, flagging every enumerated answer for "case studies not mentioned in the provided context" (the projects it had just asked for). Each member is stored carrying the whole list as its text, and the checker built its context one entry per citation, truncated at 500 characters, so it saw the same list two dozen times with every copy cut off around the fourth project. Its verdict was correct about its own input and wrong about the world. Both guards were applying retrieval logic to an enumeration; both now know the difference between a field read and a search, and the answer went from four or five to 21 of 24 named directly.

One honesty guard fell out of it. Technology answers keep "used in these projects" separate from "certified in", because SageMaker and Comprehend appear only in the certifications document. Merging them would have the system claim Arup used tools the corpus only supports him being trained in.

Derived, never authored is the thread running through all of it. The tempting fix at every stage was to write the answer down: an index document listing the projects. This repo disproves it against itself. corpus/README.mdstill says "4 production wins" when there are six; it drifted the moment documents were added. A client is worse again, because you cannot hand them a heading convention and ask them to restructure documents they already have. Derivation puts the burden on the pipeline, where it can be validated. Seeding reports documents that yield no metadata rather than indexing them silently, because a document that vanishes from breadth answers is invisible in a success count.

Held to the usual bar: 39 of 44 eval cases passed at the time, average top score 0.5077, unchanged across the re-index and improved slightly by citing documents with their real titles instead of their filenames; the title feeds the BM25 sparse vector, so that was measured rather than assumed. The suite has since grown to 60 cases with 57 passing at 0.5476, and the failures are kept red on purpose: they measure plain similarity search, which cannot enumerate a corpus, which is the entire reason the enumerate route exists. What this does not claim: some runs still saw a few projects missed and appended by the coverage check. That is a model declining to finish a list it was handed, which is a different and smaller problem than the pipeline destroying its own correct work, and closing it means either splitting the answer across calls or accepting a more mechanical format, both of which cost more than three appended lines do. The corpus is also small enough to read in one pass; at a few thousand documents it would not be, and hierarchical summarisation is the next step rather than a bigger prompt.

Production Target

Here is the enterprise production architecture targeted for massive scale, demonstrating what is required when handling millions of documents and strict tenant isolation. Two backends serve this portfolio and they hit different walls at scale, so each one gets its own comparison.

Backend 1

Always-on Bedrock RAG

The hosted path: API Gateway, Lambda, Bedrock, and Qdrant Cloud. It scales to zero between visitors and is available the moment one arrives.

1. Scale-to-Zero vs. Provisioned Throughput

Demo: Uses AWS Lambda, DynamoDB on-demand, and Qdrant Cloud.
Production Target: Amazon EKS/ECS, replicated Qdrant clusters, and regional cache layers.
Enterprise Scale:Designed to handle 10,000+ Requests Per Minute (RPM) with guaranteed < 50ms p99 latency by avoiding cold starts entirely. Provisioned capacity amortizes costs at massive query volume.
DEMOAPI Gatewayon-demandLambdascales to zerocold startQdrantcloudPRODLoad BalancerprovisionedECS Clusterwarm pool< 10msQdrant Clusterreplicated

2. Retrieval: Single-Stage Hybrid vs. Hybrid + Re-ranker

Demo: Qdrant hybrid retrieval with Titan dense vectors, BM25 sparse vectors, and RRF fusion.
Production Target: Hybrid retrieval plus a Cross-Encoder re-ranker for the final candidate set.
Enterprise Scale:Hybrid retrieval recovers exact acronyms, entity names, and SKUs that dense-only search drops. At larger scale, a re-ranker improves final ordering without giving up lexical recall.
DEMOQueryTitan Embeddense vectorQdrantRRF hybridTop KPRODQueryDenseembeddingsSparseBM25Qdranthybrid + rerank inputrerankTop K

3. Guardrails & Agentic Bounding

Demo: A bounded 3-iteration self-correction loop where Nova Pro acts as both generator and judge.
Production Target: An adversarial judge using a distinct model. Hard boundary semantic guardrails (like NeMo) intercepting prompt injections.
Enterprise Scale:Required for public-facing deployments serving millions of distinct users. A dual-model adversarial setup mathematically eliminates self-preference bias, guaranteeing that hallucinated or toxic outputs never reach the user.
DEMOUserpromptNova Progenerator & judgeself-evaluatesOutputPRODUserpromptInput GuardNeMo guardrailsLLMgenerationOutput Guarddistinct judge model

4. Identity & Multi-Tenancy

Demo: IP-based rate-limiting. Uploaded documents isolated by sessionId payload filters in Qdrant.
Production Target: OIDC/OAuth2 authentication (e.g., Auth0, Cognito). Strict authz-scoped payload filters or separate collections per tenant within private VPCs.
Enterprise Scale:Securely isolates thousands of distinct enterprise tenants (B2B). Mathematically guarantees that Tenant A's private PII/PHI data can never leak into Tenant B's context window.
DEMOPublic IPbrowserRate Limitsliding windowQdrantsingle collectionsessionId filterPRODAuth0 / IDPauthenticatedPrivate VPCAPI GatewayauthorizerTenant Aisolated indexTenant Bisolated index

5. Ingestion Pipeline & Observability

Demo: Synchronous API ingestion of ephemeral chunks during the request lifecycle.
Production Target: Asynchronous, event-driven pipelines (S3 → EventBridge → SQS → Lambda) with Dead Letter Queues (DLQs).
Enterprise Scale:Capable of ingesting and OCR-parsing TB-scale document backlogs asynchronously. DLQs ensure that out of 100,000 documents, zero chunks are silently dropped due to transient API timeouts.
DEMOUploaddocumentLambdasync apitimeout / errorDroppedsilently failedPRODUploaddocumentS3bucketSQSevent queueLambdaasync workerDLQ / RetriesIndexpersisted

Backend 2

Ephemeral EKS LangGraph

The path where I provision and pay for the infrastructure myself: a real EKS cluster created on approval and destroyed after the run. It is the demo with the most production-shaped failure modes, so the rows below state the gaps as they actually are, including the ones still open.

1. Cluster Lifetime: Ephemeral Per-Demo vs. Long-Lived Platform

Demo: An EKS cluster, a single t3.medium node group, a Classic ELB, and a public IPv4 exist only for an approved ~30-minute session. The foundation pieces (ECR images, Terraform state, IAM/OIDC trust, the Qdrant collection) stay warm between runs.
Production Target: A long-lived multi-AZ cluster with Karpenter, scale-to-zero node pools, Spot capacity for stateless workers, and blue/green control-plane upgrades.
Enterprise Scale:The demo trades roughly 13 minutes of cold start for near-zero idle cost, which is the right trade when nobody is watching most hours of the day. At sustained traffic the arithmetic inverts: an autoscaler absorbs burst against a warm control plane, and the idle savings stop being worth minutes of provisioning latency on every session.
DEMOApprovaladmin approves the runTFTerraform applyephemeral workspace~13 min cold startEKSEKS + node groupone t3.medium workerTTL 30 minhard stop at 90teardownDestroyedidle cost back to zeroPRODRequestno approval gateEKSWarm control planemulti-AZ · always onseconds, not minutesKarpenterprovisions on demandSpot + On-Demandburst absorbed in-placeidleScale to zeronode pools drain, cluster stays

2. Exposure: Public Classic ELB vs. Authenticated Private Ingress

Demo: Service type=LoadBalancer with no AWS Load Balancer Controller installed, so the in-tree cloud provider creates a Classic ELB. The agent's /v1/ask on this path is unauthenticated on a public IP.
Production Target: An ALB via the Load Balancer Controller with ACM TLS and WAF in front, workloads in private subnets, a private EKS API endpoint, and OIDC-authenticated ingress.
Enterprise Scale:This is the gap I would close first, and it is real today rather than hypothetical. An unauthenticated inference endpoint on a public IP is a denial-of-wallet surface. The always-on Bedrock path already has layered spend caps and a WAF rate rule in front of it; this backend has not inherited any of them.
DEMOInternetany client, no identitypublic IPv4ELBClassic ELBService type=LoadBalancer/v1/askagent podno WAF · no rate limit · no auth: open gap todayPRODInternetuntrustedWAFrate rules · IP reputationALBALB + ACM TLSLB Controller · HTTPSPrivate subnets · private EKS API endpointOIDC authorizercaller is known/v1/askno public IP

3. Workload Identity: Per-Run Pod Identity vs. Supply-Chain Policy

Demo: The pod assumes a per-run role scoped to three Bedrock models via EKS Pod Identity. No AWS credentials are injected into the cluster at all.
Production Target: Per-workload roles under permission boundaries and SCPs, secrets delivered by the Secrets Manager CSI driver, signed images pinned by digest, and admission policy enforcing restricted Pod Security Standards.
Enterprise Scale:Worth stating how it got here: the pod originally ran with the provisioner's own credentials (eks:*, ec2:*, iam:CreateRole) injected as a Kubernetes Secret, behind that same public load balancer. Least privilege was not the original design. It was the fix, and a smoke test run after the provisioner's Bedrock grant was removed is the only reason I can claim it works.
DEMOAgent podone per runassumesPod Identityper-run role3 models, nothing elseBedrockscoped invoke onlywas: provisioner creds as a K8s Secret (eks:* ec2:* iam:CreateRole)behind that same public load balancer, until it was found and fixedPRODSigned imagecosign attestationAdmission policyKyverno · restricted PSSPer-workload rolepermission boundary · SCPSecrets ManagerCSI driver · no K8s SecretsBedrockscoped per workload

4. Teardown: App-Driven Timer vs. Provider-Enforced Guarantees

Demo: An EventBridge one-time hard expiry plus a reconciler sweep every 10–15 minutes. A run reports Destroyed only after a tag-based cleanup audit confirms the expensive resources are actually gone.
Production Target: The same primitives plus Config drift detection, Organizations tag policies so untagged spend cannot be created, and Cost Anomaly Detection wired to paging rather than to a dashboard.
Enterprise Scale:The sharp lesson is already baked into this design: the kill switch called delete_cluster before the node groups were gone, hit ResourceInUseException, swallowed it, and returned a value EventBridge read as success, so clusters leaked indefinitely while the UI showed green. Teardown paths need the same failure testing as request paths, because their failure mode is silent and it bills by the hour.
DEMOEventBridge TTLone-time hard expiryReconciler10–15 min sweepTag-based auditEKS · ELB · EIP · EBSDestroyedreported only if audit passesResourceInUseExceptionswallowed · returned ok=falseEventBridge read that as success; clusters leaked, UI stayed greenPRODConfig driftfinds orphaned stacksTag policyOrganizations · untagged blockedCost anomalyspend delta, not a thresholdPaginga human, not a dashboard

5. Cost Visibility: Rate-Card Estimate vs. Tag-Driven Showback

Demo: A live per-hour readout computed from ap-south-1 rate cards multiplied by wall time, with Cost Explorer tag filters published for every meter.
Production Target: Cost and Usage Report data with tag-based allocation, budgets with anomaly alerts, Savings Plans for the committed base and Spot for burst, tracked as unit economics rather than as a monthly total.
Enterprise Scale:A rate card is a model, and models drift from reality. Running EKS 1.31 past its standard-support date billed at $0.60/hr against a card that still read $0.10, a 6× error, for weeks, with no signal anywhere in the UI. Estimated cost has to be reconciled against actual billing on a schedule, or it quietly becomes confident fiction.
DEMORate card$/hr per meter, ap-south-1× wall timeLive cost metershown in the Ops panelnever reconciledcard: $0.10/hrbilled: $0.60/hrEKS 1.31 past standard support (6× for weeks, no signal anywhere)PRODCUR exporthourly, resource-levelTag allocationRunId · env · ownerShowback$ per 1,000 queriesBudget + anomalyalerts before the invoiceSavings Plansbase committed · burst on Spot

6. Delivery & Resilience: Single-Run Apply vs. GitOps

Demo: A GitHub Actions workflow runs terraform apply into an ephemeral workspace and installs the Helm chart. One replica, no autoscaling, no PodDisruptionBudgets.
Production Target: Argo CD reconciling from Git, canary rollouts, HPA or KEDA on queue depth, PDBs and topology spread across AZs, and Terraform state with a DynamoDB lock on both stacks.
Enterprise Scale:Ephemeral state moves today with aws s3 cp … || true and no lock, and foundation state lives on a single laptop. That is survivable for a one-operator demo and disqualifying for a team: two concurrent applies would corrupt state with no error and no way to tell which run won.
DEMOGHworkflow_dispatchmanual triggerTFterraform applystate moved by s3 cp || trueHelmhelm installimperative, in-workflow1 replicano HPA · no PDBno state lock, so two concurrent applies corrupt state silentlyPRODGitGit committhe desired stateArgo CDreconcile loopdrift correctedCanary rolloutArgo RolloutsHPA / KEDAscales on queue depthPDB + AZ spreadsurvives a node lossTerraform state on S3 with a DynamoDB lock (on both stacks, not just the foundation)

7. Agent Autonomy: Visitor-Gated Interrupts vs. Policy-Driven Approval

Demo: Two LangGraph interrupt() checkpoints pause the compiled graph against an in-memory checkpointer: clarify, before any model call, when an enumeration can be read two ways; and a priced extend-the-loop offer at the iteration cap with thin evidence. The human in the loop is the visitor in the Ask tab, and their decision lands in the trace as a visible 'human' step.
Production Target: A durable checkpointer (Postgres or DynamoDB) so pauses survive restarts and scale past one replica, approval queues with SLAs for decisions that outlive a session, a policy engine deciding what escalates versus what auto-runs, and an audit trail of who approved what.
Enterprise Scale:The in-memory checkpointer is the deliberate trade: one replica, thirty-minute runs, and a durable store would widen a pod IAM role that is Bedrock-only on purpose, for state that outlives a cluster built to burn. The honest failure mode (a pod restart forgets the pause and the UI says ask again) costs less than the infrastructure that would prevent it. What does not change at scale: the iteration cap never moves without a human, and the extension stays bounded and priced.
DEMOVisitor asksinteractive · Ops Ask tabinterrupt() · clarifybefore any model callLoop cap hit≤ 3 turns · thin evidenceVisitor decidesextend (priced) · or answerIn-memory checkpointersingle replica · 30-min runa pod restart forgets the pause; the UI says ask again, never retriesPRODDurable checkpointerPostgres · DynamoDBApproval queueSLA'd human reviewPolicy enginewhat escalates · what auto-runsAudit trailwho approved what, when
AboutEMpathWritingProductionConnect