Architecture¶
This page is the map: the moving parts, how they're separated, and how a request travels through them.
Companion pages
Stack & decisions explains why each component was chosen and covers the alternatives. Learning the system explains how the ML-specific parts work from first principles. Read those alongside this page for the full picture.
The containers¶
The whole product runs as a handful of containers via Docker Compose, on hardware the customer owns:
Company's own server (docker compose up)
┌─────────────────────────────────────────────────────────┐
│ browser → /console/ ←── Svelte operator UI (static) │
│ │
│ app (FastAPI) ──────────────────────────► PostgreSQL │
│ │ control plane + RAG data plane + serving │
│ ├─── RAG pipeline ──► ChromaDB (per-project vectors) │
│ └─── Job queue ──► Redis ──► worker (app image) │
│ │
│ Inference (llama-cpp, CPU): base GGUF + GGUF LoRA │
└─────────────────────────────────────────────────────────┘
| Container | Role | Analogy |
|---|---|---|
app |
Control plane + RAG + serving + console | Your usual web service |
worker |
Same image as app; consumes the Redis queue and dispatches on the job's meta["kind"] through HANDLERS (empty today) |
A background job runner (Sidekiq/Celery) |
postgres |
All metadata (system of record) | Your usual relational DB |
redis |
Job queue (BLPOP) | A job queue / message broker |
chroma |
Per-project vector collections | A second database, but indexed by meaning |
In-product training was removed on 2026-10-10: nothing in the stack needs a GPU, and
the worker is the app image started with python -m adapta.worker.main. Its loop
(heartbeat, healthcheck, requeue on SIGTERM) is kept; an unknown job kind fails with a
typed error. Adapters are imported rather than trained.
The index job (E3.7). POST /v1/projects/{id}/files saves the bytes, rejects an identical file with 409
(sha256), commits the row as pending and enqueues {kind: "index", id} — it never parses in the request. The
worker (one job at a time, so indexing load is bounded by the worker, not by how many uploads arrive) parses with
Docling, chunks, writes the chunks rows, upserts vectors and marks the file indexed or failed; a transient
failure (DB, Chroma, I/O, embedding) is retried once. The worker loads the parser and embedder once at startup and
sweeps files left processing by a dead worker; the app's startup sweep fails rows whose job is gone.
The adapter job (E4.2–E4.4). POST /v1/projects/{id}/adapters stores the upload as uploaded and enqueues
{kind: "adapter", id} on the Redis queue. The worker's adapter handler (services/adapters.py, process_adapter)
moves the row to processing, converts a PEFT upload to GGUF with llama.cpp's vendored converter (--base = the base's
hf_config/), probe-loads base + adapter with a one-token generation, scores base and adapter with
services/eval_gate.py on the eval set, and writes eval_metrics and gguf_path. The row ends passed or failed
(failure_reason set on failure). A startup sweep fails any adapter still processing after a restart. Only a passed
adapter can back an endpoint.
Two data planes¶
There are two distinct flows that share the control plane but otherwise don't overlap:
- Control plane — auth, teams, projects, files, datasets, endpoints, keys, usage. Plain CRUD over Postgres. Nothing exotic.
- Data plane (serving) —
POST /v1/chat/completions. For RAG this retrieves from Chroma and calls the inference engine; for a finetune project it loads the base model with a passed, imported adapter. This is the only path a customer's application hits.
Layers (and the import rule)¶
The codebase is layered, and the layering is enforced (make lint-imports):
adapta/api/v1/* HTTP routers — translate HTTP ⇄ services, no business logic
│
adapta/services/* business logic — the real work; raises DomainError
│
adapta/core/* engine wrappers — inference, model cache, health
adapta/domain/* errors + pure types — imports nothing from adapta
adapta/db/* SQLAlchemy ORM + session
adapta.domainmay not importadapta.api/adapta.services/adapta.core/fastapi. It's the dependency-free core.adapta.servicesmay not importadapta.api.
If you've worked with hexagonal/clean architecture, this is the same idea: dependencies point inward, the domain is pure.
The data model (Postgres)¶
Org ─< Team ─< User
Team ─< Project (type: rag|finetune, base_model, status)
Project ─< ProjectFile (upload, parse status) # both modes
ProjectFile ─< ProjectChunk (text, content_hash, page, tsv) # rag; table `chunks`
Project ─1 Collection (chroma_collection_name) # rag
Project ─< Dataset (JSONL, num_samples, status) # finetune (eval set)
Project ─< Adapter (format gguf|peft, sha256, status, eval_metrics)
Project ─1 Endpoint (slug, adapter_id → Adapter, status)
Endpoint ─< ApiKey (key_prefix, key_hash, is_active)
Endpoint ─< UsageEvent (day, prompt/completion/request counts)
Postgres is the system of record — including every indexed chunk's text (chunks, written before
the Chroma upsert; chunk counts are SELECT count(*) over it). Chroma holds vectors (derived, rebuildable
from the source files). Redis holds the transient job queue. The filesystem
volume holds uploaded files and adapter artifacts.
Request lifecycle: a RAG chat completion¶
This is the path worth tracing end to end, because it touches the most pieces:
- Auth —
POST /v1/chat/completionsarrives with anadp_key. The handler (adapta/api/v1/chat.py) looks up the key by its prefix (indexed) and bcrypt-verifies it, resolving it to its endpoint. The client-suppliedmodelslug must match — otherwise403. - Retrieve —
ChatServiceruns hybrid retrieval: a Chroma vector search fortop_k × 4candidates and an independent Postgres full-text search over the project's wholechunkstable (OR over the query's lexemes, IDF-ranked, very common lexemes dropped), Reciprocal Rank Fusion of the two rankings, then an optional cross-encoder rerank of the toptop_k × 2(run off the event loop, under a timeout). See Hybrid ranking. - Assemble — the retrieved chunks are injected as context ahead of the question, and the prompt is checked against the model's context window; lowest-relevance chunks are dropped first if it won't fit.
- Infer — the inference engine generates a completion. Access to a given model instance is serialized by a per-model lock (llama-cpp isn't thread-safe per instance — see Learning the system).
- Meter & respond — token usage is recorded off the response path; the answer returns with citations and usage.
Every error along the way is a typed DomainError that the
single boundary handler in adapta/api/app.py turns into the safe
{error:{code,message,correlation_id}} envelope. A raw exception never reaches
the client.
Next: Learning the system for the concepts behind these pieces, or Workflow for how to change them safely.