4.7 KiB
14 — Data stores
Detail on schema and indexing is in 07-indexing-and-storage.md. This page covers operational shape: what is deployed, who touches it, and what is missing.
Deployed stores
| Store | Image | Deployed in | Volume | Host port |
|---|---|---|---|---|
| Qdrant | qdrant/qdrant:latest |
docker-compose.prod.yml |
qdrant-data |
none in prod; 6333/6334 in local dev |
| PostgreSQL 16 | postgres:16-alpine |
docker-compose.prod.yml |
postgres-data |
none in prod; 5432 in local dev |
| Prometheus TSDB | prom/prometheus:v3.3.0 |
observability overlay | prometheus-data |
127.0.0.1:9090 |
| Tempo | grafana/tempo:2.7.2 |
observability overlay | tempo-data |
none |
| Grafana | grafana/grafana:11.5.2 |
observability overlay | grafana-data |
127.0.0.1:3002 |
| Caddy | caddy:2-alpine |
prod | caddy-data, caddy-config |
80, 443 |
qdrant/qdrant:latest is an unpinned tag — a rebuild can silently move the
Qdrant version underneath a loaded collection. Every other image is pinned.
Redis — declared, never used
Redis appears in three places and is used by none of them:
infra/docker/docker-compose.yml(local dev) startsredis:7-alpine.infra/k8s/base/redis/is an empty directory.- The pre-existing
docs/architecture.mdreserves it for session cache, rate-limit counters and a future job queue.
No source file in the repository imports a Redis client, and it is absent
from docker-compose.prod.yml and from the Helm chart. middleware.ts names
Redis as where its in-memory rate limiter should move when web scales past
one replica.
Who touches what
| Component | Qdrant | PostgreSQL | Local disk |
|---|---|---|---|
ai-service startup |
read (manifest, collection list) | — | reads ENTITIES_PATH JSON |
ai-service query path |
read (scroll + query_points) | write trace, read/write conversation turns | — |
ai-service /v1/rag/feedback |
— | upsert feedback | — |
ingestion load |
create collection, create indexes, upsert, count | — | reads chunks.jsonl, reads/writes embedding cache |
web |
— | — | reads the source PDF for /api/pdf |
Consistency and idempotency
- Qdrant writes are idempotent. Point ids are
uuid5(namespace, chunk_id), so re-loading the same corpus converges. - Migrations are idempotent. All four are
CREATE TABLE IF NOT EXISTS/ADD COLUMN IF NOT EXISTS/CREATE INDEX IF NOT EXISTS. There is no migration-version table and no down-migration;migrate.pysimply replays all four every deploy. - No transactions span stores. A trace row and a Qdrant read are unrelated; a failed trace write leaves the answer already returned.
- No cache layer. The only cache in the system is the offline embedding cache on disk. Query embeddings, retrieval results and generations are not cached — every identical question re-pays for every model call.
Connection handling
adapters/postgres.py opens a new connection per call with
connect_timeout=5 and no pool. Both classes document this as a known
simplification (F-09: "a real pool, with startup-time lifecycle, is a further
improvement not made here"). The timeout is load-bearing: an unreachable but
non-refusing host otherwise hangs on the OS TCP timeout, which defeats the
caller's fail-open try/except just as completely as no try/except at all.
The Qdrant client is a single long-lived QdrantClient(timeout=30) built in
bootstrap.py.
Backup, restore, retention
| Concern | State |
|---|---|
| PostgreSQL backup | Not found — no dump job, no cron, no snapshot automation |
| Qdrant backup | Not found in code; ingestion/README.md recommends snapshot + restore for moving a corpus, done manually |
| EBS snapshots | Unverifiable from the repository |
rag_conversation_turn retention |
None — append-only, grows without bound |
rag_retrieval_trace retention |
None |
| Prometheus retention | 7d in Helm values; the Compose overlay sets no --storage.tsdb.retention flag, so the Prometheus default applies |
| Tempo retention | 24h in Helm values; Compose uses whatever infra/docker/tempo/tempo.yml specifies |
Data classification
rag_retrieval_trace.query_text and rag_conversation_turn.line store the raw
user turn. Because the product asks clinicians to supply patient context — age,
weight, comorbidities, allergies, current medications, eGFR/CrCl, Child-Pugh,
pregnancy status, lab values (rag/clinical.py::PatientContext) — those columns
can contain clinical detail about a third party. There is no redaction, no
encryption at rest beyond whatever the host volume provides, no access control
on the database, and no retention limit. See
16-security.md.