For finance, government, and healthcare — where the model may not call home, and every access has to be provable years later.
- The problem Aegis solves
- What makes it different
- How a query flows
- The trust contract
- Access control, enforced at retrieval
- The tamper-evident audit ledger
- The interface
- See it work
- Quickstart
- Configuration
- Tech stack
- API reference
- Evaluation
- Development & testing
- Security & compliance
- Repository layout
Most RAG stacks are built for convenience, not custody. They call a hosted model, index every document in one flat namespace, and keep no defensible record of what was asked or answered. In finance, government, and healthcare, all three are disqualifying: data cannot leave the perimeter, one analyst must never retrieve another team's restricted files, and every access has to be provable years later.
Aegis RAG is built the other way around. Inference, embeddings, reranking, and the vector index all run on your own hardware. Access is enforced where documents are selected, not bolted on at the API. Every answer carries its evidence, and every sensitive action is written to a cryptographic chain you can independently verify.
Compliance-mapped to HIPAA · GDPR · EU AI Act · DORA · Swiss FADP / FINMA · UAE PDPL / DIFC Reg 10 — see
docs/compliance-mapping.md.
| Capability | Why it matters |
|---|---|
| 100% on-premise / air-gapped — Qwen2.5 32B (Ollama), BGE-M3 embeddings, bge-reranker-v2-m3, and FAISS all run locally. Weights are pre-staged once; then the cluster runs offline. | No third party ever sees a query or a document. Data residency is a property of the deployment, not a promise. |
| RBAC at the retrieval layer — the caller's roles filter the candidate set before ranking. | A user can't receive a forbidden document in an answer, a citation, a rerank candidate, or a log — even by accident. |
| Inspectable LangGraph pipeline — hybrid retrieval → rerank → CRAG self-correction → generation → faithfulness grading → citations. | The reasoning path is an explicit state machine you can read, test, and audit — not a black box. |
Citations are mandatory — every claim maps to a chunk with document_id, chunk_id, and character offsets. |
Answers are traceable to the source span. No grounding ⇒ the answer is flagged, never faked. |
| No silent hallucination — if evidence is insufficient, Aegis says so. Correction is internal query rewrite + re-retrieval, never a web search. | Refusal is a first-class outcome. The air gap is never broken to "find an answer." |
Tamper-evident audit log — hash-chained, HMAC-signed, append-only (enforced by a DB trigger and a least-privilege role), with a /audit/verify endpoint. |
You can prove the record wasn't altered, and pinpoint the exact entry if it was. |
| CI-gated evaluation — RAGAS + a built-in local evaluator score faithfulness / hallucination / relevancy against local models and fail the build below thresholds. | Quality is a gate, not a vibe — and the eval itself never phones home. |
flowchart LR
U([Authorized user]) --> N[nginx]
N --> API["FastAPI · JWT + RBAC"]
API --> RB{{RBAC gate}}
RB --> R["Hybrid retrieve<br/>FAISS + BM25 → RRF"]
R --> RK[Cross-encoder rerank]
RK --> CR{CRAG relevance grade}
CR -- low relevance --> TQ[Rewrite query] --> R
CR -- ok --> GEN["Grounded generation<br/>Qwen2.5 via Ollama"]
GEN --> FA{Faithfulness grade}
FA -- below threshold --> GEN
FA -- ok --> FIN[Build citations + finalize]
FIN --> U
API -. every sensitive action .-> AUD[(Hash-chained audit_log)]
Each stage does one job, and hands a typed state object to the next:
- Retrieve — dense vectors (FAISS) and lexical matches (
rank_bm25) are searched in parallel, both already filtered to the caller's permitted documents, then fused with Reciprocal Rank Fusion (rank-based, so incomparable score scales don't matter). - Rerank — a cross-encoder rescoring pass keeps the top few most relevant chunks for generation.
- Grade documents (CRAG) — each candidate is graded for relevance; if too few are relevant, the query is rewritten and retrieval runs again (bounded — no infinite loops, no web search).
- Generate — the model answers only from the numbered contexts and cites them inline as
[n]. - Grade faithfulness — the answer is checked against its sources; below threshold it regenerates once, then finalizes honestly rather than confidently.
- Finalize — inline markers become structured citations with offsets; the response,
query_logrow, and audit record are written.
System of record: PostgreSQL (users/roles, docs/chunks,
query_log,audit_log). Live vector index: FAISS. LLM service: Ollama. All on a private Docker network.
Aegis never returns a bare paragraph you have to trust on faith. Every answer arrives with its evidence and one of three honest states:
| State | In the response | What you see |
|---|---|---|
| Verified · grounded | faithful: true |
The answer, citation chips (click for the source snippet + page + offsets), and the retrieved spans. Faithfulness cleared the gate and every claim is cited. |
| Unverified · low faithfulness | faithful: false |
The answer, clearly flagged — not presented as trustworthy. The model answered, but the sources don't sufficiently support it. |
| Insufficient evidence | insufficient_evidence: true |
An explicit refusal. Retrieval and correction couldn't ground an answer; no fabrication, no outside knowledge. |
Faithfulness is a real number (0.00–1.00) gated at FAITHFULNESS_THRESHOLD (default 0.70), surfaced to the user — not hidden.
The defining architectural choice: authorization gates the candidate set, not just the endpoint. A document carries a classification and an allowed_roles set; the caller's roles are resolved to a set of permitted faiss_ids that is passed into both the vector and lexical search before anything is ranked — then re-asserted when chunks are hydrated from Postgres (defense in depth). Any error computing visibility fails closed (empty set → "insufficient evidence").
flowchart TD
Q[Query + caller roles] --> A["allowed_document_ids(user)"]
A --> F[permitted faiss_ids]
F --> D[FAISS dense search]
F --> B[BM25 lexical search]
D --> RRF[RRF fusion]
B --> RRF
RRF --> H["Hydrate chunks from Postgres<br/>re-assert visibility"]
H --> OUT[Only-authorized candidates]
Visibility rule: a document is visible to a user iff the user has at least one role in allowed_roles or the user is admin.
| Role | Sees |
|---|---|
admin |
Everything. |
compliance_auditor |
Audit trail + metadata; document content follows the same visibility rule unless explicitly granted. |
analyst |
Documents whose allowed_roles include a role they hold. Can upload/ingest. |
viewer |
Read-only, within their permitted classifications. |
Classifications (default ramp of exposure): public · internal · confidential · restricted.
Every sensitive action — login, query, ingest, delete, role change, config change — is appended to a hash chain. Each entry links to the previous one:
entry_hash = HMAC-SHA256( canonical(payload) + prev_hash )
flowchart LR
G[genesis hash] --> E1["#1 · auth.login"]
E1 --> E2["#2 · document.ingested"]
E2 --> E3["#3 · query.executed"]
E3 --> E4["#4 · …"]
- Append-only at the database layer — a
BEFORE UPDATE/DELETEtrigger raises for every role, including the table owner, so no application bug or compromised app credential can rewrite history. A least-privilegeaegis_auditrole (granted onlyINSERT, SELECT) is provisioned for out-of-band audit access; the app itself writes on its normal session so each record commits atomically with the operation that caused it. - Fail-closed — if the audit write fails, the triggering operation fails with it. There is no un-audited path.
- Independently verifiable —
GET /api/v1/audit/verifyrecomputes every link and reports{ ok, total, first_broken_id, broken_field }, so a single altered byte is located precisely.
A dark "Sovereign Instrument" console — designed to read as instrumentation for an operator, not a generic dashboard. Monospace carries the data (hashes, IDs, scores); color is earned, appearing only where it encodes classification or trust.
The whole loop — sign in → ask → graded, cited answer + evidence → corpus → verify the ledger → run the eval gate.
Interrogate — the answer never arrives alone. The trust seal, the citation chips, and the evidence rail are all part of the response.
How these were made — read this before trusting the numbers. The images above are captures of the real shipped frontend (real components, CSS, routing and state), driven by
infra/scripts/capture_demo.mjsagainst a stubbed API. A live answer needs Qwen2.5 32B (~20 GB) plus staged BGE-M3 weights, so the pipeline cannot run in CI or on a typical laptop — the UI is genuine, the data in it is illustrative, not a real model's output. To capture from an actual deployment instead, usemake screenshots(below), which drives a real browser against a real stack and overwrites the same files. A hand-drawn schematic of the same screen is also kept for reference.
| Screen | What it does |
|---|---|
| Terminal (login) | Operator sign-in; a live "air-gapped · zero egress" status readout. |
| Interrogate (chat) | Ask the corpus; answers arrive with a trust seal, citation chips, and an evidence rail showing the faithfulness gauge, CRAG document grades, and retrieved spans. |
| Corpus (documents) | RBAC-filtered document list; upload with a classification + allowed-roles selector. |
| Ledger (audit) | The hash chain as a visual spine — linked entries with truncated hashes and a one-click Verify chain. |
| Evaluation | Trigger RAGAS + local-evaluator runs and read faithfulness / hallucination metric cards, live while a run is still grading. |
Built to a quality floor: strict TypeScript, keyboard focus visible, reduced motion respected, responsive to mobile.
Regenerate the screenshots and the GIF
Two harnesses write the same filenames to docs/screenshots/, both at 2× device scale with reduced motion forced so captures are deterministic. Pick by what you have available:
A · From a real deployment — authoritative, needs the full stack (models staged, operator seeded):
npm i -D playwright && npx playwright install chromium # one-time, online
AEGIS_USER=admin AEGIS_PASSWORD='…' make screenshotsScreens your roles don't permit are skipped with a note rather than failing the run. Point it elsewhere with AEGIS_URL, and set the demo question with AEGIS_QUERY.
B · From the built frontend + a stubbed API — no backend, database, models, or network; this is what the committed images come from, and the only path that also produces demo.gif:
npm i -D playwright ffmpeg-static # ffmpeg-static only needed for the GIF
make demoPlaywright's bundled ffmpeg is a minimal VP8 build with no GIF encoder, so GIF encoding needs a full ffmpeg — point
AEGIS_FFMPEGat one. Without it the stills still succeed and the GIF step warns and keeps the.webm.
| File | Screen |
|---|---|
01-login.png |
Terminal / sign-in |
02-interrogate-empty.png |
Chat, empty state |
03-interrogate-answered.png |
Chat with a graded, cited answer + evidence rail |
04-corpus.png |
RBAC-filtered document list |
05-ledger.png |
Audit chain + verification |
06-evaluation.png |
Eval runs and metric cards |
07-mobile-nav.png |
Mobile navigation drawer |
demo.gif |
The full loop (harness B only) |
Both navigate by clicking the in-app nav, never page.goto(): the auth store is deliberately in-memory (no localStorage, §6.18), so a full document navigation drops the session and <Protected> bounces to /login.
Overrides: AEGIS_OUT (output dir), AEGIS_PLAYWRIGHT (path to a playwright install outside the repo — ESM ignores NODE_PATH), AEGIS_CHROMIUM, AEGIS_FFMPEG, AEGIS_SKIP_GIF.
The whole product is one loop: restricted ingest → role-scoped query → verifiable audit. Here it is against the API.
# 1 · Authenticate
TOKEN=$(curl -s localhost:8000/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"username":"analyst","password":"…"}' | jq -r .access_token)
# 2 · Ingest a document that only analysts may retrieve
curl -s localhost:8000/api/v1/documents \
-H "authorization: Bearer $TOKEN" \
-F file=@hipaa-controls.pdf \
-F classification=confidential \
-F 'allowed_roles=analyst'
# 3 · Ask
curl -s localhost:8000/api/v1/query \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"query":"What controls satisfy HIPAA audit requirements?"}'The response carries its own evidence — field shapes are exact; values are illustrative:
When the corpus can't support an answer, that is the answer. No fabrication, no reaching outside the perimeter:
{
"answer": "I don't have enough information in the provided sources to answer that.",
"insufficient_evidence": true,
"faithful": false,
"citations": []
}Then prove the trail. Every step above appended a link to the chain:
curl -s localhost:8000/api/v1/audit/verify -H "authorization: Bearer $ADMIN_TOKEN"{ "ok": true, "total": 1284, "first_broken_id": null, "broken_field": null }Alter a single byte of any details payload and the same call pins the exact entry:
{ "ok": false, "total": 1284, "first_broken_id": 907, "broken_field": "entry_hash" }The RBAC proof. Repeat step 3 as a
viewerwho lacks theanalystrole and the restricted document is not merely hidden from the answer — it was never a retrieval candidate. It cannot appear in a citation, a rerank candidate, or a log line.
Prerequisites: Docker + Docker Compose. Qwen2.5 32B (~20 GB at Q4) wants a 24 GB+ GPU or a large-RAM CPU box. Only steps 1–2 touch the network; everything after runs offline.
cp .env.example .env
# Generate real secrets (the app refuses to boot in production with CHANGE_ME_*):
python -c "import secrets; print('JWT_SECRET_KEY=' + secrets.token_hex(32))"
python -c "import secrets; print('AUDIT_HMAC_KEY=' + secrets.token_hex(32))"
# → set JWT_SECRET_KEY, AUDIT_HMAC_KEY, POSTGRES_PASSWORD, AUDIT_DB_PASSWORD in .envmake models # BGE-M3 + bge-reranker-v2-m3 → ./models
make pull-llm # qwen2.5:32b → the ollama volumemake build
make up # full stack on a private bridge network
make migrate # alembic upgrade head
# First admin (credentials from the environment, never hardcoded):
SEED_ADMIN_USERNAME=admin SEED_ADMIN_PASSWORD='change-this' \
docker compose exec -e SEED_ADMIN_USERNAME -e SEED_ADMIN_PASSWORD \
backend python scripts/seed_admin.py
make up-airgap # zero-egress mode: only the frontend is exposed| Surface | URL |
|---|---|
| Frontend | http://localhost:8080 |
| API docs (OpenAPI) | http://localhost:8000/docs |
| Liveness | GET /api/v1/health |
| Readiness | GET /api/v1/health/ready — 503 until models finish loading |
End-to-end smoke test (after provisioning)
- Seed an admin, sign in at
:8080. - Ingest a document with a classification (e.g.
confidential, allowed rolesanalyst). - Sign in as a lesser role and ask a question about it.
- Confirm you get either a cited, faithful answer or an explicit insufficient-evidence response.
- Open Ledger → Verify chain and confirm the trail is intact.
That path — restricted ingest → role-scoped query → verifiable audit — is the whole product in one loop.
Everything is read through a single settings ingress (backend/app/core/config.py); nothing reads os.environ directly. In production, the app refuses to boot on missing or placeholder (CHANGE_ME_*) secrets, and builds DATABASE_URL from parts. Full list with defaults in .env.example.
Key settings by group
| Group | Keys (selected) | Notes |
|---|---|---|
| App | APP_ENV, LOG_FORMAT, CORS_ORIGINS, RATE_LIMIT_PER_MINUTE |
production enforces secret validation. |
| Secrets | JWT_SECRET_KEY, AUDIT_HMAC_KEY, POSTGRES_PASSWORD, AUDIT_DB_PASSWORD |
Must be real (not CHANGE_ME_*) in production. |
| LLM | OLLAMA_MODEL, OLLAMA_NUM_CTX, OLLAMA_TEMPERATURE_GEN, OLLAMA_TEMPERATURE_GRADE |
Grading/faithfulness run at temperature=0.0. |
| Embeddings / rerank | EMBEDDING_MODEL_PATH, EMBEDDING_DIM=1024, RERANKER_MODEL_PATH |
Weights pre-staged under ./models. |
| Retrieval / CRAG | RETRIEVAL_TOP_K=40, RRF_K=60, RERANK_TOP_N=8, CRAG_RELEVANCE_THRESHOLD=0.5, FAITHFULNESS_THRESHOLD=0.7, MAX_CORRECTION_ATTEMPTS=1, MAX_REGEN_ATTEMPTS=1 |
The correction/regeneration loops are always bounded. |
| Offline enforcement | HF_HUB_OFFLINE=1, TRANSFORMERS_OFFLINE=1, HF_DATASETS_OFFLINE=1 |
Do not change — these keep model libs from reaching the Hub. |
| Layer | Choices |
|---|---|
| Backend | FastAPI · Pydantic v2 · SQLAlchemy 2 (async) + asyncpg · Alembic · LangGraph |
| Retrieval | FAISS (dense) · rank_bm25 (lexical) · Reciprocal Rank Fusion · BGE-M3 · bge-reranker-v2-m3 |
| Generation | Qwen2.5 32B via Ollama (temperature 0 for all grading) |
| Eval | RAGAS + built-in local evaluator — wired to local Ollama + embeddings |
| Frontend | React 18 · TypeScript (strict) · Vite · TanStack Query · Zustand · Tailwind |
| Infra | Docker Compose (+ airgap overlay) · nginx · GitHub Actions CI |
Base path /api/v1. All non-auth routes require a bearer token; errors use a uniform envelope { "error": { "code", "message", "request_id" } }; every sensitive call is audited.
POST /api/v1/auth/login → access + refresh tokens
POST /api/v1/documents → upload + ingest (analyst, admin)
POST /api/v1/query → cited, faithfulness-graded answer
POST /api/v1/query/stream → SSE token stream + final QueryResponse
GET /api/v1/audit/verify → tamper-evidence attestation (auditor, admin)
POST /api/v1/eval/run → RAGAS + local evaluator run (admin)
POST /api/v1/admin/users → user + role management (audited, admin)
Full endpoint list
Auth — POST /auth/login · POST /auth/refresh · POST /auth/logout · GET /auth/me
Documents — POST /documents · GET /documents · GET /documents/{id} · DELETE /documents/{id} · POST /documents/{id}/reindex
Query — POST /query · POST /query/stream
Audit — GET /audit · GET /audit/verify · GET /audit/export
Eval — POST /eval/run · GET /eval/runs · GET /eval/runs/{id}
Admin — POST /admin/users · GET /admin/users · POST /admin/users/{id}/roles · POST /admin/roles
Health — GET /health · GET /health/ready
A QueryResponse carries answer, faithful, faithfulness_score, insufficient_evidence, citations[], retrieved_chunks[], doc_grades[], correction_applied, and latency_ms.
Answer quality is gated in CI, using local models only — the evaluator never falls back to a hosted API.
- RAGAS — faithfulness, answer relevancy, context precision/recall, wired to local Ollama + BGE-M3 embeddings.
- Built-in local evaluator — faithfulness / hallucination / answer-relevancy driven by the local model. (The third-party
deepevalpackage is intentionally not a dependency — it ships telemetry and eager cloud-model imports that would break the air gap.)
python eval/ci_gate.py --suite both # exits non-zero below thresholdsA preflight checks the eval dependencies, the staged embedding weights, and that OLLAMA_HOST is reachable and serving OLLAMA_MODEL. If that environment isn't there the gate skips cleanly (exit 0) naming the specific blocker; --require turns the skip into a failure. Once preflight passes, an evaluation error is a real failure (exit 2) and is never reported as a skip — so a broken suite can't hide behind "no model provisioned".
# Backend — the full local gate (mirrors CI)
cd backend
pip install -r requirements.txt && pip install -e ".[dev]"
ruff check . && ruff format --check . && mypy app && pytest
# Frontend — strict typecheck + build
cd ../frontend
npm install && npm run typecheck && npm run build| Suite | How to run | Notes |
|---|---|---|
| Unit | pytest tests/unit |
Runs anywhere; model-dependent tests self-skip when weights are absent. |
| Integration | TEST_DATABASE_URL=postgresql+asyncpg://… pytest tests/integration |
Skips unless a reachable asyncpg DSN is set (use a throwaway DB — tables are dropped/recreated). |
| Eval gate | python eval/ci_gate.py --suite both |
Local models only. |
CI (.github/workflows/ci.yml) runs two jobs on every push:
- Backend — ruff lint → ruff format check →
mypy --strict→alembic upgrade head→ pytest (against a real Postgres service) → the local-model eval gate. - Frontend —
npm ci→tsc --noEmit→vite build.
Both run with HF_HUB_OFFLINE=1 / TRANSFORMERS_OFFLINE=1 / HF_DATASETS_OFFLINE=1 set, so an accidental Hub or telemetry call fails the build rather than passing quietly.
- Secrets live only in
.env(git-ignored); production refuses to boot on placeholder values. - Tokens stay in memory on the client; refresh rotates on use.
- Audit log is append-only & hash-chained — a DB trigger blocks
UPDATE/DELETEfor every role, andverify_chainrecomputes each link so tampering by anyone who bypasses it is still detectable. - Fail closed — any auth / RBAC / audit error denies and logs; it never default-allows.
- Zero egress is enforced by
docker-compose.airgap.yml: every service on aninternal: truenetwork with no gateway, DNS pinned off, capabilities dropped, and no published ports except the frontend. Asserted — not assumed — bytests/integration/test_airgap_compose.py, which resolves the realdocker compose configand fails if any service is reachable beyond the internal network.
Deeper docs: architecture · compliance mapping · threat model · air-gap runbook.
aegis-rag/
├── backend/ FastAPI app · LangGraph pipeline · retrieval · audit · eval · tests
├── frontend/ React + TypeScript (Vite) — the Sovereign Instrument UI
├── infra/ nginx · postgres init (roles, triggers) · provisioning scripts
├── eval/ CI eval gate + golden datasets
├── docs/ architecture · compliance · threat model · runbook
└── models/ pre-staged BGE-M3 + bge-reranker-v2-m3 weights
CLAUDE.mdis the single source of truth — golden rules, module contracts, DB schema, the full API surface, and the per-module definition of done.
Air-gapped, auditable, deterministic — in that order.


{ "answer": "Audit controls require hardware, software, and procedural mechanisms that record and examine activity in systems containing ePHI [1]. Aegis satisfies this with an append-only, hash-chained audit_log covering authentication, query, and document events [2].", "insufficient_evidence": false, "faithful": true, // ← graded before you ever see the answer "faithfulness_score": 0.86, // ← gated at FAITHFULNESS_THRESHOLD (0.70) "citations": [ { "marker": 1, "document_id": "a3f91c04-…", "chunk_id": "7b20de55-…", "page_number": 4, "char_start": 1203, "char_end": 1487, "snippet": "§164.312(b) Audit controls. Implement hardware, software…" } ], "doc_grades": [ // ← CRAG relevance grading, per candidate { "chunk_id": "7b20de55-…", "relevant": true, "score": 0.94 }, { "chunk_id": "c118a0b9-…", "relevant": false, "score": 0.31 } ], "correction_applied": false, // ← true when the query was rewritten and re-retrieved "latency_ms": 4210, "request_id": "b7c1…" }