Skip to content

Stack & decisions

Adapta is a standard web service augmented with three AI-specific systems. The web layer follows patterns any backend engineer will recognize; the AI layer follows the same engineering principles but manages different resource constraints — specifically, large stateful objects that are slow, memory-intensive, and not thread-safe.

This page covers every component: what it is, why it was chosen over alternatives, and where it lives in the codebase.


At a glance

Component Role Layer
FastAPI + Uvicorn Async HTTP service, OpenAI-compatible API Web
Pydantic v2 Request/response validation from the OpenAPI spec Web
PostgreSQL + SQLAlchemy 2.x async System of record — all metadata Data
Alembic Versioned schema migrations Data
Redis + BLPOP Background job queue (worker dispatch table is empty today) Jobs
Docker Compose + multi-stage Dockerfile Single-stack deployment, CPU-only Deploy
llama-cpp-python GGUF model inference (CPU) AI: Inference
sentence-transformers Text → dense embedding vectors; cross-encoder reranking AI: Retrieval
ChromaDB Per-project vector collections AI: Retrieval
Postgres FTS + RRF Keyword signal over the whole corpus fused with vector search — no extra service AI: Retrieval
bcrypt + JWT Password hashing, session auth, scoped API keys Auth

Web layer

FastAPI + Uvicorn

FastAPI is an ASGI web framework built on Pydantic and Starlette. It handles all HTTP routing, validation, and the OpenAI-compatible POST /v1/chat/completions surface. Uvicorn is the async server that runs it.

Why async matters here. A single inference call takes seconds. In a synchronous framework, that blocks a thread — and inference can only run one request at a time per model instance anyway. Async lets the event loop continue serving other requests (auth lookups, status checks, file uploads) while one inference call waits. The inference call itself runs in a bounded thread pool because it is CPU-bound and would otherwise block the event loop.

Why not Flask or Django?

  • Flask is synchronous by default. Bolting async onto it adds complexity without removing the fundamental threading model.
  • Django carries ORM, admin, and session middleware suitable for full-stack apps. For a headless API service with its own PostgreSQL layer, that overhead adds nothing.

FastAPI's Pydantic integration is also load-bearing here: request and response models are generated from the OpenAPI spec and validated automatically. There is no hand-written serialization code.

Key files: adapta/api/app.py, adapta/api/v1/


Pydantic v2 — contract-first validation

All request and response models in adapta/models/generated/models.py are generated from specs/openapi.yaml via make generate. They are never hand-written.

This matters operationally: make test-contracts runs schemathesis, which generates requests from the spec and fires them at a live server. Any drift between the spec and the implementation fails CI. The spec is the source of truth; the code follows it.

Pydantic v2 was chosen over v1 for its performance (Rust-backed core) and its first-class support in FastAPI's current release. The generated models use its model_validator and field_validator hooks where the spec includes constraints.

Key files: adapta/models/generated/models.py, specs/openapi.yaml


Data persistence

PostgreSQL + SQLAlchemy 2.x async

PostgreSQL stores all metadata: organizations, teams, users, projects, files, datasets, jobs, endpoints, API keys, and usage events. SQLAlchemy's async interface (AsyncSession, asyncpg driver) means database calls don't block the event loop.

Why relational? The data is genuinely relational — users belong to teams, teams own projects, projects own jobs and endpoints, and foreign-key integrity prevents orphaned artifacts. SQL joins are natural; document storage would be a workaround.

Why not a managed cloud database? The product is self-hosted. Customers run their own PostgreSQL container. This is a design constraint, not an oversight: data that never leaves the customer's infrastructure cannot be in a managed cloud service.

Commit before returning

SQLAlchemy does not auto-commit. Mutating handlers must call await db.commit() before returning — otherwise background tasks that start immediately after the HTTP response read stale data. This is a subtle ordering requirement that emerges specifically because background tasks can start before the request's session is flushed.

Key files: adapta/db/models.py, adapta/db/session.py


Alembic — versioned schema migrations

Alembic manages incremental schema changes through versioned migration files — the same model used by Rails Active Record, Django's manage.py migrate, and Laravel's artisan. Each migration applies a delta to the database and is recorded in a alembic_version table.

Migrations are mandatory here because customers upgrade a running deployment with an existing database. Without Alembic, upgrading would require a manual ALTER TABLE or a database dump-and-restore. With it, alembic upgrade head applies all pending deltas in order.

Migrations are autogenerated from ORM model diffs but reviewed before committing — the autogenerator can misread a column rename as a drop-and-add, which is destructive.

Key files: migrations/versions/


AI inference

This is the layer most unfamiliar to web developers. It has one job: given a text prompt (and optionally an image), run it through a model and return the generated text.

llama-cpp-python — the inference engine

llama-cpp-python is a Python binding for llama.cpp, a C++ inference engine for GGUF-format models. The shipped image runs it on CPU with no configuration.

llama.cpp is the choice for CPU portability and quantized GGUF support. Alternatives:

Alternative Notes
vLLM High-throughput multi-GPU serving for full-precision weights. Adapta had an optional vLLM backend; it was removed on 2026-10-10 with in-product training, since nothing in the stack needs a GPU.
HuggingFace transformers Research library; not a production serving runtime. Less battle-tested throughput management.
Ollama A good wrapper around llama.cpp, but adds an extra service and network hop with no benefit when the platform owns the process.

The decisive advantage is hardware flexibility: the same image runs on an old developer laptop and a production server. For a self-hosted product shipped to diverse hardware environments, that portability is worth more than the throughput optimizations vLLM offers.

Key files: adapta/core/inference.py, adapta/core/model_manager.py


GGUF — the model file format

GGUF is the format llama.cpp uses. Think of it as a compiled binary of the model: a single file that bundles the weights, the tokenizer vocabulary, and configuration metadata. llama.cpp is the runtime that loads and executes it — analogous to a JVM or a container runtime loading an artifact.

Quantization stores weights at reduced precision. A 7-billion-parameter model at full 16-bit precision requires ~14 GB of memory. At 4-bit precision (Q4_K_M) it requires ~4 GB, with a measured but small quality loss. This is what makes large models runnable on ordinary hardware: the platform's model catalog uses quantized GGUF files exclusively.

The quality trade-off is concrete: the eval gate (below) ensures that even on a quantized base, an adapter passes held-out quality checks before it can serve.

Key file: adapta/core/model_catalog.py


The per-model concurrency lock

llama.cpp model objects maintain mutable internal state (a KV cache — a scratchpad that records the conversation so far) during generation. They are not thread-safe. Two concurrent calls to the same instance corrupt each other's scratchpads, producing garbage output or a crash.

The implementation wraps each model instance in an asyncio.Lock. All callers serialize through it — identical in principle to a sync.Mutex in Go or a synchronized block in Java. This is not AI-specific engineering; it is ordinary concurrency control applied to a non-thread-safe resource.

Two other guards sit alongside the lock: generation runs on a bounded thread pool (so it doesn't block the event loop), and each call has a timeout (so a stalled model degrades to a 504, not an indefinite hang). The model cache is an LRU bounded by a configured size, so many endpoints don't exhaust memory by each pinning a full model.

Key files: adapta/core/model_manager.py, adapta/core/inference.py


Knowledge retrieval

The retrieval pipeline answers: "which passages in the project's documents are most relevant to this question?" It uses three components absent from ordinary web stacks: an embedding model, a vector store, and a hybrid ranking stage that combines semantic and keyword evidence.

sentence-transformers — text embeddings

A sentence-transformers model converts a piece of text into a dense vector (a list of ~384 numbers) such that semantically similar texts map to nearby vectors. This model is a small neural network — about 90 MB — loaded once at startup as a singleton.

It is self-hosted for the same reason the LLM is: data that doesn't leave the customer's infrastructure cannot pass through an external embedding API. All document indexing and query embedding happens locally.

Why sentence-transformers over building a custom embedding layer? It is a well-validated library with multiple pre-trained models for different languages and use cases. The default model (paraphrase-multilingual-MiniLM-L12-v2, 384 dimensions) is multilingual: a Spanish question finds an English document and vice versa (recall@5 0.978 on the bilingual golden set vs 0.844 for the English-only all-MiniLM-L6-v2). Replacing it is one config line plus a one-off scripts/reembed.py run for already-indexed projects.

Key file: adapta/services/embeddings.py


ChromaDB — the vector store

ChromaDB is a purpose-built vector database: it stores embedding vectors and retrieves the nearest neighbors to a query vector. The platform creates one Chroma collection per project — independent namespaces, not a shared index.

Semantically, it is a second database whose index is built on meaning rather than value. A conventional B-tree index on document_id retrieves the row whose id equals a value. A vector index retrieves the rows whose embeddings are closest to a query — "closest" measured by cosine similarity, not equality.

Why ChromaDB over pgvector?

pgvector adds a vector index extension to PostgreSQL. It is a reasonable choice when you want to avoid a second datastore and the collection size is modest. ChromaDB was chosen here for three reasons:

  1. It is purpose-built for embeddings and handles metadata filtering natively without raw SQL.
  2. It runs as its own container, keeping vector search workloads off the PostgreSQL connection pool during serving.
  3. One collection per project maps naturally to its API, with clean isolation between teams.

The trade-off is operational: one more container to run. The benefit is that PostgreSQL stays a pure metadata store with predictable load.

Why not Elasticsearch or Qdrant? Elasticsearch's vector support is mature but its JVM operational overhead is disproportionate for a single-tenant deployment. Qdrant is an excellent alternative; ChromaDB was chosen for its developer ergonomics and simplicity at the scale this product targets.

Key files: adapta/services/rag.py, adapta/services/embeddings.py


Hybrid ranking — Postgres FTS + Reciprocal Rank Fusion + cross-encoder

Vector search alone has a well-known blind spot: it matches meaning, so it can miss an exact token that carries all the signal — a part number, an error code, a person's surname, a rare acronym. Keyword search (BM25) has the mirror weakness: it matches strings, so it misses a passage that answers the question in different words.

Retrieval therefore runs both and merges them, in four stages:

Stage What it does Why
1. Vector search Fetch top_k × 4 nearest chunks from Chroma A wider candidate pool than is returned, so later stages have something to reorder
2. Full-text search Postgres FTS (ts_rank_cd over chunks.tsv, top_k × 4 hits) over the project's whole corpus, independent of stage 1 Recovers exact-term matches the embedding smoothed away, including chunks the vector stage never returned
3. Reciprocal Rank Fusion Merge the two independent rankings (union by chunk id) by Σ 1/(k + rank), k=60 Combines rank positions, so two incomparable score scales never have to be normalized against each other
4. Cross-encoder rerank Re-score the top top_k × 2 with a cross-encoder, sigmoid-normalized The most accurate stage, run last on the fewest candidates

Bi-encoder vs cross-encoder. The embedding model is a bi-encoder: it encodes the query and each passage independently, which is what makes the vectors precomputable and the search fast. A cross-encoder feeds the (query, passage) pair through the model together, so it can attend across both — markedly more accurate, and far too slow to run over a whole collection. Running it over a handful of already-shortlisted candidates buys most of the accuracy for a bounded cost.

Why Postgres full-text rather than a search engine? The chunk text already lives in Postgres (chunks, with a generated tsv column and a GIN index), so the keyword leg is one indexed query: no Elasticsearch, no second index to keep in sync, no new container. Because it searches the whole corpus rather than the vector candidates, a chunk the embedder missed can still be recalled by an exact term. Dials: ADAPTA_RAG_SPARSE_FETCH_MULTIPLIER (FTS list size) and ADAPTA_RAG_FTS_CONFIG (simple default; english/spanish only match once chunks.tsv is regenerated with that config). If Postgres is unreachable the request degrades to vector-only and logs a warning.

Off by default, opt-in. The reranker is a second small model (~90 MB, e.g. cross-encoder/ms-marco-MiniLM-L-6-v2), lazily loaded on first use in the app container. That model is English-only: on the bilingual golden set it lowered recall@5 from 0.978 to 0.889 and tripled latency, so since E3.6 stage 4 is skipped unless ADAPTA_RAG_RERANKER_MODEL names a model — enable it for English-only corpora. The whole path is bounded by ADAPTA_RAG_TIMEOUT_SECONDS.

Key file: adapta/services/rag.py (_fts_search, _rrf_fuse, RAGService.retrieve)


Model customization

Contextual retrieval (opt-in, E5.1). With contextual_retrieval on, the index job asks the project's base model for one short sentence situating each chunk in its document; that sentence is embedded and full-text indexed with the chunk (chunks.context), while citations still show the raw chunk. Measured on the 45-question golden set (30 short es/en documents, one chunk each): recall@5 0.978 → 0.956, MRR 0.840 → 0.850, indexing 2.2 s → 97 s (≈ 44×). On single-chunk documents the sentence adds little; the technique targets long documents whose chunks lose their context. It stays off by default — enable it per project only after measuring on your own documents.

Adapters are imported, not trained

In-product training (PEFT, TRL, QLoRA on a CUDA worker) was removed on 2026-10-10: it was the only reason the stack needed a GPU, and training is better done with dedicated tooling. The direction is bring your own adapter: import a GGUF or PEFT LoRA, convert and gate it on CPU, serve it with RAG (E4.2–E4.4, live 2026-10-10; see the import guide). Two fixture adapters live in tests/fixtures/adapters/ (classify-pass, garbage-control).


The evaluation gate — CI for a model

An adapter can silently be a worse model. Without a gate, a bad adapter would reach users. The eval gate is the hard stop.

The design: score the adapter on the project's held-out eval set and permit it to serve only if it clears an absolute quality threshold or demonstrably outperforms the base model on that same set. This is the model equivalent of a CI test suite. The eval-set format is specs/schemas/eval_dataset.schema.json (SFT rows) and adapta/services/datasets.py validates it; the gate itself is being rebuilt to run on CPU against imported adapters (E4.3) and is not present in the code today, so a finetune project cannot create an endpoint yet.

The "or clearly beats the base" path exists for a concrete reason: a small base model cannot always reach the absolute threshold even on an ideal task. An adapter that reliably outperforms its starting point has demonstrably learned the target behavior — refusing it because it didn't hit an arbitrary absolute number would be a false negative.


GGUF LoRA conversion

PEFT adapters are PyTorch tensors; the serving runtime (llama.cpp) loads GGUF LoRA adapters. The conversion helper is the explicit boundary between the HuggingFace world and the serving world — two ecosystems that don't share a file format. The import flow (E4.2) will use it.

Key file: adapta/core/adapter_conversion.py


Async job processing

Redis + BLPOP — the job queue

Long-running work (minutes or more) must not run inside a request handler: it would block the event loop and leave the caller waiting. Instead:

  1. A service pushes a job ID onto a Redis list (with meta["kind"]).
  2. The worker process blocks on BRPOPLPUSH — it wakes only when a job ID arrives.
  3. The worker looks the kind up in its HANDLERS dispatch table (adapta/worker/main.py). The table is empty today — an unknown kind fails the job with a typed error — until jobs such as ingestion (E3.7) are added.

This is the same pattern as Sidekiq (Ruby), Celery (Python), or BullMQ (Node.js) — a background queue with a persistent worker.

Why not Celery? Celery is mature and would work. Redis BLPOP was chosen for its simplicity: no broker configuration, no result backend, no serialization format to choose — a Redis list is enough for a single-queue, single-worker system. Celery adds value at scale (multiple queues, rate limiting, scheduling); BLPOP is the right tool here.

Why a dedicated process, not a background task in app? Heavy jobs would compete with inference for CPU and memory. Separating them means a long job cannot degrade serving latency. The worker is the same image as app, started with python -m adapta.worker.main.

Key files: adapta/services/jobs.py, adapta/worker/main.py


Authentication

bcrypt + JWT

User passwords are hashed with bcrypt. A SHA-256 pre-hash handles inputs longer than bcrypt's 72-byte input limit — a well-documented pattern when bcrypt is used directly. JWTs carry user identity for session auth (console and control plane). Scoped API keys (adp_…) use the same bcrypt mechanism: the full key is shown once at issuance, only its hash is stored, and verification is bcrypt.checkpw against the stored hash.

Keys are scoped to one endpoint. A adp_ key can only reach the endpoint it was issued for — a mismatched model slug in the request is rejected with 403. This is the product's access-control model: no key can reach data it wasn't explicitly granted.

Why not passlib?

passlib was removed because bcrypt 5.x broke its internal API. Direct bcrypt calls are simpler, have no extra dependency, and keep the auth path transparent.

Key file: adapta/services/auth.py


Deployment

Docker Compose + multi-stage Dockerfile

The full stack runs as five containers (app, worker, postgres, redis, chroma) via a single make up (docker compose up -d --build). There is one Dockerfile whose app target is the only runtime image: full toolchain, all [dev] extras, repo bind-mounted for hot reload. The worker service uses the same image with a different entrypoint. Nothing in the stack needs a GPU or the NVIDIA Container Toolkit.

There is no dev/production split. The app image ships the full development toolchain, and the repo is bind-mounted with hot reload. This is intentional: keeping one stack removes an entire class of "works on my machine" divergence.

Why not Kubernetes? Kubernetes is appropriate when you need horizontal scaling or multi-service cluster management. This product is deployed by individual teams on single servers they own. The operational overhead of Kubernetes doesn't fit a self-hosted appliance; Docker Compose is exactly the right complexity.

Key files: Dockerfile, docker-compose.yml, docker-compose.prod.yml, Makefile


How the pieces compose

The AI systems connect to the web layer through ordinary service calls:

HTTP request
  → FastAPI router
      → Service layer
          ├── PostgreSQL   (metadata: read / write)
          ├── ChromaDB     (vector retrieval, if project has documents)
          ├── llama-cpp    (inference — via asyncio.Lock + thread pool)
          └── Redis        (write-only: enqueue a job)

The worker is a separate OS process connected to the same PostgreSQL and Redis instances. It never touches the HTTP layer; it reads from the job queue and writes status back to PostgreSQL.


Further reading

  • Learning the system — how inference, RAG, and adapters work from first principles, using engineering analogies throughout.
  • Architecture — the container map, data model, and full request lifecycle for both serving modes.
  • Workflow & contracts — how to change any part of the stack safely.