Scaling

Ragz is a modular monolith: one FastAPI codebase serving both the synchronous API and the Celery workers. That means scaling is mostly about giving the right process more of the right resource — there's no service mesh to reason about.

The levers

SymptomLever
Ingestion queue backs up (documents stuck in queued/processing)Add Celery workers
Chat feels slow under concurrent usersAdd uvicorn worker processes
OCR-heavy corpora (scanned PDFs) stall the ingestion queueDedicated ingestion worker pool, separate from interactive
Corpus keeps growingMore disk — Postgres, Qdrant, and MinIO volumes all grow with document count
You need local/air-gapped LLM inferenceGPU tier — a host with a 16 GB+ GPU running Ollama/vLLM
Everything is fine except one org's spiky usagePer-org/per-user quotas (modules/quotas), not more hardware

Corpus size → disk

Every document lands in three places: MinIO (original file + extracted blocks), Postgres (metadata, chunks references, versions), and Qdrant (embeddings). Budget disk against the corpus, not against traffic — a 100-page-PDF-heavy workspace with light chat traffic needs more disk than a high-traffic workspace with a small corpus. The reference sizing in the README suggests 80–100 GB SSD for a typical single-node install (≈25–30 GB fixed for images and model weights, the rest grows with your corpus).

Concurrent users → RAM / vCPU

Two independent process pools consume CPU under load:

  • uvicorn (the FastAPI process) — scales with concurrent chat/API requests. Each streaming chat response holds a connection open for the duration of the answer.
  • Celery workers — scale with ingestion throughput (parsing, chunking, embedding, upserting to Qdrant).

They don't compete for the same resource pool if you run them as separate processes/containers, which is the compose default — so you can scale one without touching the other.

Adding Celery workers

Run more worker processes against the same queues. On Linux, use the prefork pool (the Celery default) to get real multi-core parallelism per worker process:

# From backend/ — add a second worker process on the interactive + default queues
uv run celery -A ragz.worker.celery_app:celery_app worker \
  -Q interactive,default -l info --concurrency=4

Each additional celery worker invocation is an independent consumer on the same Redis-backed queues — start as many as your host (or additional hosts) can support. On macOS, add --pool=solo for local dev; it doesn't parallelize within a single worker, so for real concurrency run multiple --pool=solo processes instead of relying on one.

Dedicated ingestion workers

If OCR (Docling on scanned PDFs) or large-batch ingestion is competing with interactive chat latency, split them onto separate queues and give OCR its own worker pool:

uv run celery -A ragz.worker.celery_app:celery_app worker \
  -Q ingestion -l info --concurrency=2

Route heavy ingestion tasks to the ingestion queue and keep interactive responsive for chat-blocking work.

Adding uvicorn processes

Run multiple uvicorn workers (or multiple container replicas) behind your reverse proxy, load-balanced across them:

uv run uvicorn --factory ragz.api.app:create_app --workers 4 --port 8000

Session state lives in Postgres/Redis, not in-process, so any worker can serve any request — scale horizontally without sticky sessions.

The GPU tier

Local/air-gapped LLM inference (Ollama or vLLM) needs its own host with a 16 GB+ GPU — this is a separate concern from the CPU-bound API/worker scaling above. If you're also running local embeddings and rerank (the tei/tei-rerank services), those can share the CPU tier or move to GPU depending on throughput needs; see the model-placement table in the README for the RAM cost of each placement choice.

When to stop scaling a single node

Once a single host's uvicorn + Celery scaling isn't enough — sustained high concurrent chat load alongside heavy ingestion — the next step is running the API and workers as separate deployable units (still the same codebase, per the modular-monolith architecture) across multiple hosts, with Postgres moved to a managed instance as described in Production.