Files
nexa/docs/09-deployment.md
T
Claude 17c7f5033f Stage embeddings: ship Path A (text) now, prepare Path C (text+visual) additively
Decision (Q15 resolved): start with text-only via TEI + bge-m3 in Phase 3.1,
prepare data shapes so Phase 3.2 (visual collection via infinity + jina-clip-v2)
is a pure additive operation — no rename, no schema migration, no n8n rewiring.

Concretely:
- Qdrant collection renamed nexa_knowledge → nexa_knowledge_text (1024-dim
  for bge-m3) with modality-aware payload (modality, source_type, media_uri,
  graph_iri, content_hash, context). Visual placeholder schema committed
  alongside (qdrant_schema_visual.json, 768-dim, jina-clip-v2).
- Image attachments captured in 3.1 are recorded in GraphDB as nexa:Note with
  nexa:modality "image" + nexa:pendingVisualIndex true; the 3.2 backfill
  workflow picks them up and embeds. No data lost between phases — the queue
  is the GraphDB itself.
- RDF schema (docs/08) gains nexa:modality, nexa:mediaUri,
  nexa:vectorCollection, nexa:pendingVisualIndex from day one.
- docs/02 roadmap split: 3.1 = text RAG (Path A), 3.2 = visual collection
  (Path C), 3.4 = Ontotext GraphDB.
- docs/09 grows a "Phase add-on: visual collection (Phase 3.2)" section with
  the TEI→infinity swap, second collection create, LiteLLM second model
  registration, and the SPARQL-driven backfill query.
- New open questions: Q16 (queue ergonomics + does SAIA already proxy an
  embed model?), Q17 (reuse Immich's CLIP for photo-library queries?).
- docs/03 + CLAUDE.md updated so future runs use the new collection names
  and don't re-decide the staging.
2026-05-04 21:33:59 +00:00

12 KiB
Raw Blame History

09 — Deployment

Pragmatic deployment guide that assumes the existing homelab and adds only what's missing.

What's already running (no action required)

Surveyed from Homepage / Dozzle / Proxmox / Zoraxy:

Service Host / port URL
Memos docker LXC 104 → :5230 https://memos.nuclide.systems
n8n docker LXC 104 → :5678 https://n8n.nuclide.systems
LiteLLM (SAIA gateway) docker LXC 104 → :4000 https://ai.nuclide.systems (proxies LobeHub UI :3210; API on :4000)
Nextcloud LXC 105 https://nc.nuclide.systems
ntfy docker LXC 104 → :7998 https://ntfy.nuclide.systems
Karakeep (Hoarder) docker LXC 104 → :3090 https://hoarder.nuclide.systems
Home Assistant VM 100 (HAOS) https://ha.nuclide.systems
Pocket-ID (OAuth/SSO) docker LXC 104 → :1411 https://id.nuclide.systems
Vaultwarden docker LXC 104 → :11001 https://vault.nuclide.systems
Backrest LXC 103 (internal)
AdGuard DNS LXC 102 (internal)
Zoraxy reverse proxy LXC 108 → 192.168.1.4:8000 TLS for *.nuclide.systems
qdrant_scientific (existing) docker LXC 104 reused — Nexa uses nexa_* collections in this instance

The deployment task is not "spin up the stack" — most of the stack is already up. It is wire Nexa across these services + add the small bits that are missing.

What's missing for Nexa

  1. Qdrant collection for Nexa (nexa_knowledge) inside the existing qdrant_scientific instance — vector dim follows Q15 (1024 for bge-m3, 768 for nomic-embed-text).
  2. TEI (HF text-embeddings-inference) on the docker host for self-hosted embeddings (LiteLLM key is not authorised for OpenAI embeddings — see 11/Q3+Q15). Lighter than Ollama: single Rust binary, ~500 MB image, no LLM runtime.
  3. n8n workflows (./nexa-core/n8n-workflows/) imported into the running n8n.
  4. Nextcloud lists & calendars for Work / Personal / Shopping / Wishes (auto-discovered via #nexa:config).
  5. Memos webhook → n8n wired through the Memos config.
  6. LiteLLM virtual key for the nexa user with chat-only access (no embeddings — handled by Ollama).
  7. A Zoraxy host entry is not needed — Memos / n8n / LiteLLM are already proxied.
  8. (Phase 3.4) Ontotext GraphDB for the SPARQL pillar — see add-on at the bottom of this doc.

Step 1 — Secrets

Copy nexa-core/.env.examplenexa-core/.env and fill only the secrets:

cd nexa-core
cp .env.example .env
$EDITOR .env       # MEMOS_API_KEY, SAIA_API_KEY, NC_APP_PASSWORD, QDRANT_API_KEY

The .env is only used at bootstrap time. Everything else (list IDs, calendar IDs, collection sizes) is discovered at runtime via #nexa:config (see 05). No secrets should ever live in n8n workflow JSON — use n8n credentials instead.

Step 2 — Qdrant collection (nexa_knowledge_text)

Phase 3.1 ships Path A (text-only) but the schema and naming already make room for Path C (text + visual) so adding a nexa_knowledge_visual collection later is a pure additive operation — no rename, no migration, no n8n rewiring.

# adjust QDRANT_HOST in .env first
source nexa-core/.env

# create the text collection from the schema file
curl -X PUT "$QDRANT_HOST/collections/nexa_knowledge_text" \
  -H "Content-Type: application/json" \
  -H "api-key: $QDRANT_API_KEY" \
  -d @nexa-core/config/qdrant_schema.json

The collection name is always suffixed with the modality (_text, _visual) so logic in n8n and SPARQL stays modality-aware from day one. Indexed rows carry these payload fields (source):

Field Why it's there now
modality Always "text" in _text, "image" in _visual. Future-proofs cross-modality filters.
source_type memo / mail / obsidian / screenshot / image — used by classification and digest workflows.
media_uri memos://…, nextcloud://…, obsidian://…. Empty for text-only rows; populated when Path C ships.
graph_iri IRI of the corresponding nexa:Note in GraphDB. The same value is stored on the GraphDB side as nexa:vectorId — this is the cross-pillar bridge.
content_hash de-dup.
context work / personal.

Targets the existing qdrant_scientific instance — just an extra collection, no new container. The vectors.size field follows Q15: 1024 for bge-m3, 768 for nomic-embed-text-v1.5.

Image attachments today (queue them)

Memos can already attach images. Until Phase 3.2 the indexer does not embed them, but it does record them so they can be replayed later:

  • Memo with an image → text body still goes into nexa_knowledge_text.
  • The image attachment(s) are written as nexa:Note triples in GraphDB with nexa:modality "image" and nexa:vectorId left empty (nexa:pendingVisualIndex true).
  • A Phase-3.2 backfill workflow will pick up everything where ?n nexa:pendingVisualIndex true and embed it through the visual collection.

This means no data is lost between 3.1 and 3.2 — the queue is the GraphDB itself.

Step 3 — Self-hosted embeddings (TEI)

Use HuggingFace text-embeddings-inference — single Rust binary, ~500 MB image, OpenAI-compatible API, loads exactly one model. Lighter than Ollama because there's no LLM runtime, no GGUF loader, no model registry.

# on the docker host (LXC 104)
docker run -d --name nexa-embed \
  --restart unless-stopped \
  -p 127.0.0.1:8080:80 \
  -v tei-data:/data \
  ghcr.io/huggingface/text-embeddings-inference:cpu-1.5 \
  --model-id BAAI/bge-m3

Memory budget: ~1.1 GB resident (bge-m3 is ~1 GB + ~100 MB overhead). First start downloads the model into the named volume; subsequent restarts are instant.

Register it inside LiteLLM (admin UI → Models) with the OpenAI-compatible adapter:

  • model name: nexa-embed
  • provider: openai
  • model: bge-m3
  • api_base: http://nexa-embed:80/v1
  • api_key: any non-empty string (TEI ignores it)

Now n8n only ever talks to LiteLLM and the model is swappable without touching workflows.

Step 4 — LiteLLM virtual key

In the LiteLLM admin UI (ai.nuclide.systems):

  1. Create user nexa.
  2. Issue a virtual key with access to:
    • one chat model (already-available model from your SAIA gateway).
    • the nexa-embed model from Step 3.
  3. Paste the key into SAIA_API_KEY in .env.

Step 4 — n8n workflows

Import the JSON exports — credentials are filled inside n8n, not in the JSON:

# n8n personal access token from the n8n UI: Settings → API
N8N_URL=https://n8n.nuclide.systems
N8N_TOKEN=...    # from the n8n UI

for f in nexa-core/n8n-workflows/phase-1/*.json \
         nexa-core/n8n-workflows/phase-2/*.json; do
  curl -X POST "$N8N_URL/api/v1/workflows" \
    -H "X-N8N-API-KEY: $N8N_TOKEN" \
    -H "Content-Type: application/json" \
    --data-binary "@$f"
done

Inside n8n, attach credentials to the imported nodes:

  • Memos → HTTP header Authorization: Bearer $MEMOS_API_KEY
  • LiteLLM → header Authorization: Bearer $SAIA_API_KEY
  • Nextcloud → app password
  • Qdrant → header api-key: $QDRANT_API_KEY

Activate each workflow individually after smoke-test.

Step 5 — Memos webhook

In the Memos admin UI, set the webhook URL to the production address of the discovery workflow:

https://n8n.nuclide.systems/webhook/memos

The same URL is the one the workflow exposes; verify with:

curl -i https://n8n.nuclide.systems/webhook/memos
# expect 200 / 405, never 404

Step 6 — Bootstrap commands via Memos

Create a memo with body #nexa:config — the discovery workflow:

  1. Lists Nextcloud Tasks lists, picks Work / Personal / Shopping / Wishes by name.
  2. Counts existing Qdrant points in nexa_knowledge.
  3. Verifies LiteLLM reachability + lists available models.
  4. Replies as a comment with a runtime-config snapshot that's stored as Qdrant metadata (_config namespace) and as nexa-core/config/runtime_config.json (gitignored).

After this point, .env is read-once. Subsequent runs read config from Qdrant.

Step 7 — Smoke tests

# (1) Memos round-trip — should produce a comment within ~5 s
curl -X POST https://memos.nuclide.systems/api/v1/memos \
  -H "Authorization: Bearer $MEMOS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content":"- [ ] testing the router #nexa"}'

# (2) Classification dry-run
curl -X POST https://memos.nuclide.systems/api/v1/memos \
  -H "Authorization: Bearer $MEMOS_API_KEY" \
  -d '{"content":"#nexa:route-test buy milk"}'

# (3) RAG test (requires at least one indexed memo/note)
curl -X POST https://memos.nuclide.systems/api/v1/memos \
  -H "Authorization: Bearer $MEMOS_API_KEY" \
  -d '{"content":"#nexa:ask what is the goal of nexa?"}'

Step 8 — Reverse proxy

Already done — Zoraxy at 192.168.1.4:8000 terminates TLS for *.nuclide.systems and forwards to docker LXC 104 (192.168.1.40). No new entry is required for Nexa: every service Nexa talks to already has a host entry.

Step 9 — Backups

Already covered by Backrest (LXC 103). Add:

  • n8n workflowsnexa-core/scripts/backup_workflows.sh (already present) into a Backrest schedule.
  • Qdrant snapshots → schedule a daily POST /collections/nexa_knowledge/snapshots and rsync to S3 (s3.nuclide.systems). Add as a Backrest pre-hook on the docker host.

For deeper detail: 10 — Operations.


Phase add-on: Ontotext GraphDB (Phase 3.4)

Defer until 3.13.3 ship.

# nexa-core/docker-compose.graph.yml
services:
  graphdb:
    image: ontotext/graphdb:10.7.0
    container_name: nexa-graphdb
    ports: ["127.0.0.1:7200:7200"]
    environment:
      GDB_JAVA_OPTS: "-Xmx4g -Xms1g"
    volumes:
      - ./data/graphdb:/opt/graphdb/home
    restart: unless-stopped

After first start, create the repository (one-time):

curl -X POST http://localhost:7200/rest/repositories \
  -H 'Content-Type: application/json' \
  -d '{
    "id": "nexa_knowledge",
    "title": "Nexa Knowledge Graph",
    "type": "graphdb",
    "params": {
      "ruleset":     {"value": "rdfsplus-optimized"},
      "baseURL":     {"value": "https://nuclide.systems/nexa/"}
    }
  }'

Optional Zoraxy entry graph.nuclide.systems192.168.1.40:7200 if you want the SPARQL Workbench in a browser; otherwise n8n talks to it on the docker network at http://nexa-graphdb:7200.

For schema and example queries: 08-graphrag-architecture.


Phase add-on: visual collection (Phase 3.2)

Adds Path C — image embeddings without disturbing the text path. Schema is already in nexa-core/config/qdrant_schema_visual.json.

# (1) replace TEI with infinity (or run alongside) for CLIP-family support
docker rm -f nexa-embed
docker run -d --name nexa-embed \
  --restart unless-stopped \
  -p 127.0.0.1:8080:80 \
  -v infinity-data:/app/.cache \
  michaelf34/infinity:latest \
  v2 \
  --model-id BAAI/bge-m3 \
  --model-id jinaai/jina-clip-v2 \
  --port 80

# (2) create the visual collection
curl -X PUT "$QDRANT_HOST/collections/nexa_knowledge_visual" \
  -H "Content-Type: application/json" \
  -H "api-key: $QDRANT_API_KEY" \
  -d @nexa-core/config/qdrant_schema_visual.json

# (3) register the second model in LiteLLM as `nexa-embed-visual`
#     (same OpenAI-compatible route, different model id)

# (4) backfill queued images:
#     SPARQL: SELECT ?note ?uri WHERE { ?note nexa:pendingVisualIndex true ; nexa:mediaUri ?uri }
#     For each row: fetch the bytes, embed via nexa-embed-visual, upsert into the visual collection,
#     UPDATE GraphDB to set nexa:vectorId and DELETE nexa:pendingVisualIndex.

n8n RAG workflow gains a parallel branch: text-query → both nexa-embed-text and nexa-embed-visual text encoders → kNN against both collections → merge by score before SAIA prompt.


Step-back / rollback

  • Disable any Nexa workflow in n8n — deactivates the side effect immediately, Memos webhooks become no-ops.
  • Drop a Qdrant collectioncurl -X DELETE $QDRANT_HOST/collections/nexa_knowledge_text -H "api-key: $QDRANT_API_KEY".
  • Re-discover#nexa:reset-config then #nexa:config.