Architecture
Ragz is a modular monolith: one FastAPI codebase, running as two process types (an async API and Celery workers), backed by a handful of well-known infrastructure services. There is no service mesh, no per-module deployment, and no network hop between modules — module boundaries are enforced in code (import-linter in CI), not by putting a network between them.
Why a monolith, not microservices
RAG has a small number of true dependencies — a relational store, a vector
store, a queue, object storage, and an LLM gateway — and one dominant
request path (retrieve → generate → cite). Splitting that path into
services buys you independent deployability you don't need yet, at the
cost of latency, distributed transactions across tenancy/documents/chat,
and duplicated auth checks at every hop. A modular monolith gets the same
separation of concerns (enforced by import-linter, not a network) while
keeping a single transaction boundary and a single place to enforce tenant
isolation. Scale is handled by running more processes — see
Scaling — not by splitting the codebase.
Processes
| Process | Runs | Handles |
|---|---|---|
| API | uvicorn (async FastAPI) | HTTP + streaming chat, all synchronous request/response work |
| Worker | Celery, priority queues over Redis | Document ingestion (parse/chunk/embed/upsert), OCR, scheduled model-catalog sync |
Both processes import from the same ragz package. api/ and worker/ are
thin entrypoints — routes and Celery tasks call into module service.py
functions and nothing else. The dependency direction is fixed and enforced:
api/, worker/ → modules/* → core/Modules call other modules' public service.py functions — never another
module's ORM models or internals directly.
Infrastructure
| Service | Role |
|---|---|
| Postgres | System of record: tenancy, documents metadata, chat history, encrypted secrets, audit log, usage ledger |
| Qdrant | Vector store — hybrid (dense + sparse) retrieval, one tenant-aware filter path |
| Redis | Celery broker/queues, quota counters, rate-limit counters |
| MinIO | Object storage — original document files + extracted blocks/chunks |
| LiteLLM | Unified gateway to model providers (OpenAI, Anthropic, Gemini, local Ollama/vLLM, ...) — one integration point instead of one per provider |
| TEI (embed/rerank) | Optional local model servers (bge-m3 embeddings, bge-reranker-v2-m3 cross-encoder) for fully offline/air-gapped installs |
| Dex | Optional OIDC identity provider for SSO |
The module map
Every piece of domain logic lives in exactly one module under
backend/src/ragz/modules/:
| Module | Owns |
|---|---|
auth | Identity, sessions, API keys, SSO |
tenancy | Organizations, workspaces, groups, membership, TenantContext |
documents | Upload, ingestion jobs, metadata, versioning, deletion propagation |
retrieval | Vector store client, hybrid search, rerank, the ACL filter (one code path) |
chat | Conversations, streaming, citations, the agent loop |
models | Model registry, LiteLLM sync, capability probes |
quotas | Allocations, usage ledger, enforcement |
secrets | Envelope encryption, KEK handling |
audit | Append-only event log |
Two rules keep these boundaries real instead of aspirational: Postgres
queries on org-owned tables go through the TenantContext dependency
(modules/tenancy/), and Qdrant filters are built in exactly one function
inside modules/retrieval/. See Security Model for
why those two rules carry the whole tenant-isolation guarantee.
The RAG flow
A chat turn moves through four stages:
- Retrieve. The query is embedded and searched against Qdrant through the single retrieval filter (tenant + workspace + ACL-group intersection — see Security Model), then optionally reranked.
- Agent loop. The model decides whether it has enough grounding, can call read-only tools (web search, if enabled for the workspace), and iterates until it's ready to answer or determines it can't.
- Synthesize with citations. The answer streams back with citations carrying document name, version, section, and page — built from the actual retrieved chunks, not inferred after the fact. If grounding is insufficient, the workspace's fallback policy decides between a general-knowledge answer (clearly labeled) or a no-answer response — never a hallucinated citation.
- Optional generative UI. When enabled for the workspace, a second pass can render the answer as structured blocks (cards, charts, tables, source cards, follow-up chips) instead of — or in addition to — plain markdown.
Retrieved chunks are treated as data, not instructions, throughout this pipeline: they're wrapped in delimited blocks before reaching the model, and model output is rendered as sanitized markdown only. See Security Model for the full LLM-boundary posture.
Where to look next
- Security Model — the five iron rules that make tenant isolation and ACL enforcement actually hold.
- Data Model — the entities this architecture persists, and how RBAC roles attach to them.
- Scaling — how the two processes scale independently under load.