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

ProcessRunsHandles
APIuvicorn (async FastAPI)HTTP + streaming chat, all synchronous request/response work
WorkerCelery, priority queues over RedisDocument 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

ServiceRole
PostgresSystem of record: tenancy, documents metadata, chat history, encrypted secrets, audit log, usage ledger
QdrantVector store — hybrid (dense + sparse) retrieval, one tenant-aware filter path
RedisCelery broker/queues, quota counters, rate-limit counters
MinIOObject storage — original document files + extracted blocks/chunks
LiteLLMUnified 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
DexOptional OIDC identity provider for SSO

The module map

Every piece of domain logic lives in exactly one module under backend/src/ragz/modules/:

ModuleOwns
authIdentity, sessions, API keys, SSO
tenancyOrganizations, workspaces, groups, membership, TenantContext
documentsUpload, ingestion jobs, metadata, versioning, deletion propagation
retrievalVector store client, hybrid search, rerank, the ACL filter (one code path)
chatConversations, streaming, citations, the agent loop
modelsModel registry, LiteLLM sync, capability probes
quotasAllocations, usage ledger, enforcement
secretsEnvelope encryption, KEK handling
auditAppend-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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.