v1 of this plan was a single-container FastAPI + Neo4j demo. The user wants something closer to how recommendation engines are actually built in production, sized to still be learnable/buildable solo, and deliberately chosen so every piece is a real, resume-recognizable technology: a managed vector DB, a self-hosted stateful graph DB under real k8s constraints, an API/AI gateway fronting both client and LLM traffic, a multi-node k3s cluster on real cloud VMs, full observability, and a load-test benchmark with results to show.
Decisions locked in with the user so far:
- Vector DB: Qdrant Cloud (managed, free tier)
- Graph DB: Neo4j, self-hosted inside k3s (StatefulSet + PersistentVolumeClaim) — deliberately more k8s surface area (storage, stateful sets) as a learning goal
- Cluster: k3s, multi-node, on Hetzner Cloud VMs (not local, not physical hardware) — provisioned as real infrastructure
- Observability: Prometheus + Grafana (
kube-prometheus-stack) - Carried over from v1: Python, MovieLens (
ml-latest-small) + TMDb enrichment for director/composer data, OpenAI for embeddings + generation
New decisions made in this plan (decisive, not asked as open questions, but flagged for visibility):
- API/AI gateway: APISIX, used for two distinct purposes — (1) public ingress in front of the recommendation API with rate-limiting + key-auth, and (2) an internal AI gateway route that
recommend-apicalls instead of hitting OpenAI directly, using APISIX'sai-proxyplugin. This centralizes the OpenAI API key (the app never holds it) and centralizes LLM traffic control (rate limiting, logging, retry) — this is the actual real-world pattern "AI gateway" refers to, not just a generic reverse proxy. - Infra as code: Terraform provisions the Hetzner VMs, private network, and firewall; cloud-init installs k3s on boot (1 server + 2 agents). This is itself a strong resume line ("Terraform-provisioned k3s cluster on Hetzner Cloud") and keeps the cluster reproducible/destroyable.
- Package management for third-party services: Helm for anything with an official chart (Neo4j, APISIX, kube-prometheus-stack) — nobody hand-writes Neo4j's YAML in the real world. Plain Kubernetes manifests via Kustomize for the app's own pieces (
recommend-apiDeployment/Service,ingestionJob, ConfigMaps/Secrets) — small enough to hand-write, and shows you can do both. - Storage: the Hetzner CSI driver, so Neo4j's PVC is backed by a real networked Hetzner Volume (not
local-path), so the pod is not glued to one node. - Node sizing: 1× CX32 (4 vCPU/8GB, runs the control plane + is pinned via nodeAffinity to also run Neo4j, since it needs the most RAM) + 2× CX22 (2 vCPU/4GB, run
recommend-api, APISIX, and the observability stack). Estimated cost:€14-15/month ($16/month) total while the cluster is up — flagging this as real recurring spend, destroy withterraform destroywhen not in use. - Benchmark tool: k6 (Grafana's load-testing tool) — industry-standard, and its Prometheus remote-write output means a live load test can be watched in the same Grafana dashboard the observability stack builds, which is a nice concrete demo.
- Container registry: GitHub Container Registry (
ghcr.io) — free, and images pushed there are directly pullable from k3s via an image-pull secret. - Not in v1 (explicitly out of scope, to bound complexity): CI/CD pipeline (GitHub Actions build/push), GitOps (ArgoCD/Flux), HA/multi-server etcd, autoscaling (HPA). These are natural "what's next" talking points but not built now.
Hetzner Cloud (Terraform-provisioned, private network)
┌───────────────────────────────────────────────────────────────────┐
│ k3s cluster (1 server + 2 agents) │
│ │
│ Node A (CX32, control-plane + pinned Neo4j) │
│ ├── k3s server │
│ └── Neo4j StatefulSet (Hetzner CSI PVC) │
│ │
│ Node B/C (CX22, agents) │
│ ├── APISIX (Helm) ── public ingress + AI-gateway routes │
│ ├── recommend-api Deployment (FastAPI, 2-3 replicas) │
│ ├── ingestion Job (run on-demand, not always-on) │
│ └── kube-prometheus-stack (Prometheus + Grafana) │
└───────────────────────────────────────────────────────────────────┘
│ public route (rate-limited, key-authed) │ internal ai-proxy route
▼ ▼
[ client / k6 benchmark ] [ OpenAI API ]
External managed service: Qdrant Cloud (free tier) — vector index, queried by recommend-api
Request flow for POST /api/recommend:
- Client → APISIX public route (key-auth + rate-limit) →
recommend-apiService recommend-apiembeds the query (OpenAI embeddings, called directly — cheap/low-risk enough not to need gatewaying) → queries Qdrant for seed movie(s) by vector similarityrecommend-apiruns multi-hop Cypher traversal against self-hosted Neo4j from the seed (director/composer/genre/co-rating overlap), ranks candidatesrecommend-apisends the generation prompt to APISIX'sai-proxyroute, which forwards to OpenAI chat completions (APISIX holds the OpenAI key as a plugin secret) → response flows back through APISIX →recommend-api→ client, with the subgraph path included for auditability
vecto-graph/
├── infra/ # Terraform
│ ├── main.tf # hcloud provider, network, firewall
│ ├── servers.tf # 3 hcloud_server resources + cloud-init
│ ├── cloud-init/
│ │ ├── k3s-server.yaml.tpl # installs k3s server, writes join token
│ │ └── k3s-agent.yaml.tpl # installs k3s agent, joins via private IP
│ └── outputs.tf # kubeconfig retrieval instructions, node IPs
├── k8s/
│ ├── helm-values/
│ │ ├── neo4j-values.yaml # StatefulSet sizing, PVC storageClass=hcloud-volumes
│ │ ├── apisix-values.yaml
│ │ └── kube-prometheus-stack-values.yaml
│ ├── apisix/
│ │ ├── route-public-recommend.yaml # APISIX Route CRD: public -> recommend-api
│ │ └── route-ai-proxy-openai.yaml # APISIX Route CRD: ai-proxy -> OpenAI
│ └── app/ # Kustomize base for hand-written app manifests
│ ├── kustomization.yaml
│ ├── recommend-api-deployment.yaml
│ ├── recommend-api-service.yaml
│ ├── ingestion-job.yaml
│ ├── configmap.yaml
│ └── secret.yaml.example # not committed with real values
├── src/vecto_graph/ # same core Python package structure as v1:
│ ├── config.py
│ ├── db/{neo4j_client.py, qdrant_client.py}
│ ├── ingestion/ (download.py, tmdb_enrich.py, embed.py, schema.py, load.py, run_ingestion.py)
│ ├── retrieval/ (vector_search.py [now queries Qdrant], graph_traversal.py, subgraph.py)
│ ├── generation/ (prompt.py, llm.py [calls the APISIX ai-proxy URL, not OpenAI directly])
│ ├── pipeline.py
│ └── api/ (main.py with /recommend, /health, /metrics)
├── Dockerfile.api # recommend-api image
├── Dockerfile.ingestion # ingestion Job image
├── benchmarks/
│ ├── recommend_load_test.js # k6 script
│ └── report.md # generated after a benchmark run
└── scripts/sanity_check.py
Qdrant replaces Neo4j's vector index for the embedding step:
- Collection
movies, vector size 1536, distanceCosine. - Each point's
id=movieId(int),payload = {title, year}for quick display without a Neo4j round-trip. db/qdrant_client.pywrapsqdrant-client(official Python SDK):upsert()during ingestion,query_points()during retrieval.
Neo4j keeps the same schema as v1 (Movie/User/Genre/Person nodes; RATED/TAGGED/IN_GENRE/DIRECTED/COMPOSED/ACTED_IN relationships) minus the embedding vector property and the vector index — Neo4j is now graph-structure-only, Qdrant is vector-only, joined by movieId. This split (dedicated vector DB + dedicated graph DB) is itself the realistic "hybrid retrieval" pattern, more so than v1's single-DB shortcut.
Deploy via the official Neo4j Helm chart (neo4j/neo4j), k8s/helm-values/neo4j-values.yaml setting: single instance (no causal cluster — that's a further HA topic, out of scope), volumes.data.mode: dynamic with storageClassName: hcloud-volumes (installed via the Hetzner CSI driver's own Helm chart, deployed first), memory config sized to the CX32 node's RAM, and a nodeSelector/nodeAffinity pinning the pod to Node A.
Same logical steps as v1 (download MovieLens → TMDb enrich, cached → build embedding text → OpenAI embeddings → write graph to Neo4j → write vectors to Qdrant), packaged as Dockerfile.ingestion, run as a Kubernetes Job (k8s/app/ingestion-job.yaml) rather than a script run from a laptop — this is what makes ingestion itself part of "the distributed system" instead of a side channel. TMDb credit cache (data/cache/tmdb_credits.json) is baked into a ConfigMap or, more realistically, persisted to a small PVC so re-running the Job after a code fix doesn't re-spend the ~30-40 min TMDb pass.
Same pipeline logic as v1 (pipeline.recommend(query)), with two changes:
retrieval/vector_search.pynow calls Qdrant instead of Neo4j's vector index.generation/llm.pysends the chat completion request to the APISIX ai-proxy route's URL (an internal cluster address, e.g.http://apisix-gateway.apisix.svc.cluster.local/ai/openai/chat/completions) instead ofapi.openai.comdirectly —recommend-apidoesn't needOPENAI_API_KEYin its own env at all for generation (only for embeddings, unless we also proxy those — for v1, proxy only the chat completion calls through APISIX since that's the higher-value, more auditable traffic; embeddings stay direct to keep the ai-proxy config simple).- Adds a
/metricsendpoint viaprometheus-fastapi-instrumentatorfor Prometheus scraping. - Deployed via
k8s/app/recommend-api-deployment.yaml(2-3 replicas, resource requests/limits set, readiness/liveness probes on/health) + matching Service.
Installed via the official apisix Helm chart (bundles etcd) into an apisix namespace.
Route 1 — public ingress (k8s/apisix/route-public-recommend.yaml, applied as an APISIX ApiSixRoute/Admin-API object or via the Ingress controller CRDs): POST /api/recommend → upstream recommend-api.default.svc.cluster.local:8000, plugins: key-auth (client must send apikey header), limit-req (rate limiting).
Route 2 — AI gateway (k8s/apisix/route-ai-proxy-openai.yaml): upstream is api.openai.com, plugin ai-proxy configured with the model (gpt-4o-mini) and the OpenAI key stored as an APISIX secret (Secret CRD backed by the bundled etcd, referenced by the plugin config — not as a k8s Secret mounted into recommend-api). This route is only reachable from inside the cluster (no public listener), so it functions purely as an internal AI gateway.
Install kube-prometheus-stack via Helm (bundles Prometheus Operator, Grafana, Alertmanager, node-exporter, kube-state-metrics) into a monitoring namespace.
ServiceMonitorforrecommend-api(/metrics).- APISIX's built-in
prometheusplugin exposes gateway-level metrics (request rate, latency, status codes per route) — add aServiceMonitorfor it too. - One Grafana dashboard (JSON checked into
k8s/helm-values/or agrafana/folder) showing: request rate & p50/p95/p99 latency for/api/recommend(APISIX view),recommend-api's internal breakdown (Qdrant query time, Neo4j query time, OpenAI call time — instrument these as histograms inpipeline.py), and node CPU/RAM.
benchmarks/recommend_load_test.js — k6 script: ramps virtual users (e.g. 1→50 over 2 minutes) hitting the public APISIX route with a rotating set of MovieLens-style queries, asserting p95 latency and error-rate thresholds. Run with k6's Prometheus remote-write output pointed at the cluster's Prometheus, so the run is visible live on the Grafana dashboard from step 6. After the run, export k6's summary (--summary-export) and write benchmarks/report.md with p50/p95/p99 latency, throughput (req/s), and error rate — this is the concrete "benchmarking and seeing the result" artifact.
- Phase 0 — Infra. Terraform provisions 3 Hetzner VMs + private network + firewall; cloud-init installs k3s (1 server, 2 agents joined over the private network). Verify:
kubectl get nodesshows 3Readynodes;terraform destroycleanly tears it all down (confirm this works before building further, since it's real spend). - Phase 1 — Storage + data layer. Install Hetzner CSI driver (Helm). Create Qdrant Cloud collection. Deploy Neo4j via Helm with a Hetzner-backed PVC, pinned to Node A. Verify:
kubectl get pvcbound, Neo4j Browser reachable viakubectl port-forward, a test point upserts/queries successfully against the Qdrant collection via its REST API. - Phase 2 — Ingestion. Build/push
Dockerfile.ingestion, run as a k8s Job. Verify: Neo4j node/relationship counts + Interstellar's DIRECTED/COMPOSED edges (same Cypher checks as v1); Qdrant collection point count matches movie count. - Phase 3 — App layer. Build/push
Dockerfile.api, deployrecommend-api(2-3 replicas). Verify viakubectl port-forward+ curl, reproducing the Interstellar → Inception example, calling OpenAI directly (APISIX ai-proxy wiring comes next phase — verify the pipeline logic works before adding gateway indirection). - Phase 4 — Gateway. Deploy APISIX via Helm. Wire the AI-gateway route first, repoint
generation/llm.pyat it, re-verify the same Interstellar/Inception curl test still works (now via APISIX for the LLM call). Then wire the public ingress route with key-auth + rate-limit. Verify: curl the public route with a valid key succeeds, without a key gets 401, and hammering it past the rate limit gets 429. - Phase 5 — Observability. Install
kube-prometheus-stack, wire ServiceMonitors, build the Grafana dashboard. Verify: dashboard shows live request metrics while curling the endpoint manually. - Phase 6 — Benchmark. Run the k6 script against the public route. Verify:
benchmarks/report.mdproduced with real latency/throughput numbers, and the load spike is visible on the Grafana dashboard during the run.
Same Cypher sanity checks and the Interstellar/Inception functional test as v1 (see scripts/sanity_check.py), plus:
# public route, authed
curl -X POST http://<cluster-public-ip>/api/recommend -H "apikey: <key>" -H "Content-Type: application/json" \
-d '{"query": "Recommend me sci-fi thrillers like Interstellar"}'
# public route, no key -> expect 401
curl -X POST http://<cluster-public-ip>/api/recommend -H "Content-Type: application/json" -d '{"query": "..."}'
# k6 benchmark
k6 run --out experimental-prometheus-rw benchmarks/recommend_load_test.jsinfra/servers.tf,infra/cloud-init/k3s-server.yaml.tplk8s/helm-values/neo4j-values.yamlk8s/apisix/route-ai-proxy-openai.yaml,k8s/apisix/route-public-recommend.yamlsrc/vecto_graph/generation/llm.py(APISIX-routed OpenAI call)src/vecto_graph/retrieval/vector_search.py(Qdrant client)benchmarks/recommend_load_test.js