← cd ~/lab

./run wiki-portabile-bundle-openwebui-docker

Ho congelato una wiki intera in un bundle Docker — e un URL dentro SQLite ha quasi rovinato tutto

Come ho reso portabile un'intera knowledge base OpenWebUI: bundle self-contained, vettori pre-calcolati, tool server OpenAPI e il port-pinning che tiene in…

status
poc
project
wiki-openwebui
updated
2026-07-08
tags
#openwebui#docker#portabilità#lancedb#tool-server#rag
Diagramma di un bundle Docker portabile con OpenWebUI, un tool server wiki e un indice vettoriale LanceDB congelato

Volevo una cosa apparentemente banale: prendere la mia knowledge base interna — chat OpenWebUI, tool, wiki e vettori — e farla viaggiare tra host Docker senza ricostruire nulla. Copio una cartella su un’altra macchina, lancio uno script, e ho lo stesso identico assistente che risponde solo sul mio corpus. Niente reindicizzazione, niente riconfigurazione a mano dei tool, niente “ah ma qui manca la knowledge”.

Il piano ingenuo era: “faccio un tar del volume dati di OpenWebUI e via”. Ha funzionato per il 90%. Il restante 10% mi ha fatto perdere un pomeriggio, e riguarda una cosa che non ti aspetti: un URL assoluto sepolto dentro lo SQLite di OpenWebUI.

L’idea: un bundle, tre pezzi, zero segreti

Il corpus in sé è un progetto riservato — qui non c’entra e non lo tocco. Parlo solo della tecnica di impacchettamento. Il bundle è una cartella con tre ingredienti:

  1. Il wikipack — l’albero markdown della wiki + l’indice vettoriale LanceDB già calcolato + un manifest.json con la config (non-segreta) dell’embedding. Vettori congelati: all’import non si ri-embedda niente.
  2. Il tool server wiki-tools — un piccolo servizio FastAPI che espone la wiki come tool server OpenAPI (search, lettura pagina, pagine collegate, elenco pagine), così la chat naviga la wiki invece di fare RAG cieco.
  3. Lo stato di OpenWebUI — uno snapshot del volume dati, cioè webui.db (SQLite) con dentro tutto: utenti, chat, modelli registrati, knowledge e — soprattutto — la registrazione del tool server.

Lo snapshot è tre righe di export-bundle.sh, e la parte importante è che gira dentro un container alpine usando il volume, senza toccare i file sotto:

VOLUME="wiki-openwebui_openwebui-data"
docker run --rm -v "$VOLUME":/data -v "$OUT":/backup alpine \
  tar czf /backup/openwebui-data.tgz -C /data .

# .env template col segreto STRAPPATO via sed
sed 's/^GATEWAY_API_KEY=.*/GATEWAY_API_KEY=/' "$HERE/.env" > "$OUT/.env.template"

Regola d’oro: la chiave del gateway non entra mai nel bundle. Viene rimossa dal template con sed, e l’import-bundle.sh la chiede in modo interattivo alla prima esecuzione. Stessa disciplina nel wikipack: il make_wikipack.py legge endpoint e modello dell’embedding dallo stato dell’app ma non scrive mai una API key nel manifest.

Il manifest: il contratto dei vettori

Ecco il cuore del “vettori portabili”. Il manifest.json (sanitizzato) è minuscolo:

{
  "wikipackVersion": 1,
  "title": "<wiki-interna>",
  "createdAt": null,
  "embedding": {
    "endpoint": "https://<GATEWAY>/v1/embeddings",
    "model": "Qwen3-Embedding-8B",
    "outputDimensionality": 4096
  },
  "chunkCount": 2468,
  "pageCount": 244,
  "markdownFiles": 249
}

Perché è il contratto? Perché l’indice viaggia già vettorizzato a 4096 dimensioni con un modello preciso. Al momento della query, il tool server deve embeddare la domanda con lo stesso identico modello e la stessa dimensione, altrimenti il confronto tra vettori è pura spazzatura. Ho reso questa invariante esplicita nel codice — se non torna, esplode subito con un errore comprensibile invece di dare risposte silenziosamente sbagliate:

if self.expected_dim is not None and len(vec) != self.expected_dim:
    raise EmbeddingError(
        f"embedding dim mismatch: got {len(vec)}, index expects {self.expected_dim}. "
        f"Query embedder must use the SAME model+dim as the shipped vectors."
    )

Nota sul createdAt: null: è voluto. Non lo timbro a build-time per non introdurre nondeterminismo — due export dello stesso stato producono lo stesso file. Lo può timbrare la CI se serve.

Prima di spedire, il make_wikipack chiama table.optimize() di LanceDB sulla copia (mai sull’originale): compatta i frammenti e pota le versioni vecchie, così il bundle è snello. Dettaglio da nerd che ho scoperto guardando i file: il nome del manifest di versione è 18446744073709551428.manifest — è 2^64 − 188, LanceDB numera le versioni a ritroso da UINT64_MAX. Innocuo, ma la prima volta ti fa dubitare di aver corrotto qualcosa.

La spina: l’URL che non voleva viaggiare

Ecco dove il piano “basta il tar” si è rotto. OpenWebUI, quando registri un tool server, salva l’URL così com’è dentro webui.db. Nel mio caso http://localhost:8100. Quando importi il bundle su un altro host e OpenWebUI riparte, riprova a chiamare esattamente quell’URL. Se sulla macchina nuova il tool server è finito su un’altra porta, la registrazione è morta: la chat non ha più tool, e lo capisci solo perché le risposte peggiorano.

La cura non è elegante ma è solida: inchiodare le porte. Il compose generato nel bundle non lascia scegliere a caso — pinna HOST_PORT e TOOLS_PORT, e sono gli stessi valori con cui l’URL è stato salvato nello SQLite:

  wiki-tools:
    ports:
      - "${TOOLS_PORT:-8100}:8000"   # deve combaciare con l'URL salvato in webui.db
  open-webui:
    ports:
      - "${HOST_PORT:-8300}:8080"

È questo il prezzo della portabilità in questo design: la registrazione del tool è un URL assoluto congelato nel DB, quindi le porte diventano parte del contratto. Tienile uguali e il bundle “si ricollega da solo”; cambiale e ti tocca ri-registrare il tool a mano. Sul mio cluster k8s ho scelto la strada opposta — lì inietto la stessa registrazione via variabile d’ambiente (TOOL_SERVER_CONNECTIONS) invece che via snapshot SQLite, così l’URL è configurabile. Due mondi, due strategie, stessa lezione: lo stato che rende comodo l’import è anche quello che ti incastra.

Gotcha onesti

  • Lo snapshot “impostazioni” pesa 272 MB, il wikipack 13. Sorpresa contro-intuitiva: la parte pesante non è il corpus con i suoi vettori (13 MB), ma lo stato di OpenWebUI (openwebui-data.tgz, 272 MB). Le “impostazioni” sono più grandi della conoscenza. Bundle totale ~286 MB in versione “light” (ricostruisce l’immagine wiki-tools e pulla OpenWebUI all’import); con --with-images diventa molto più grosso ma gira offline.
  • Il compose del bundle perde l’auth del tool server. Nel compose di sviluppo passo TOOLS_API_KEY; l’heredoc che genera il compose del bundle non lo include. Risultato: nel bundle il tool server parte con auth disabilitata (_load_keys() vuoto → nessun controllo). Comodo per una demo interna, ma è drift reale tra i due file, e su una rete non fidata è un buco. Da sistemare.
  • Il nome del volume è hardcoded. Sia export che import assumono wiki-openwebui_openwebui-data e --project-name wiki-openwebui. Rinomina il progetto e il restore punta al volume sbagliato in silenzio.
  • Il path sorgente dell’export è assoluto. Lo script di export ha il percorso della macchina di build inchiodato dentro: si esporta solo da lì. Il bundle è portabile; lo script che lo crea no.
  • README/realtà divergono sulle porte di default. Il compose “dev” del repo usa 3000/8000, quello generato nel bundle 8300/8100. Se leggi il README sbagliato colleghi la porta sbagliata.

Come va

È shipped e usato come knowledge base interna. Il ciclo export → copia → import-bundle.sh → chiave → su gira, e la chat risponde sul corpus con i tool pre-registrati. C’è pure un endpoint /reload key-protected per ricaricare il wikipack aggiornato senza riavviare il container. I punti ruvidi (auth persa nel bundle, path hardcoded, nome volume fisso) sono note nel taccuino, non bloccanti per l’uso interno.

Cosa ho imparato

Che la portabilità non è “copiare i file”, è capire dove lo stato tiene i riferimenti assoluti. Un vettore a 4096 dimensioni viaggia benissimo — è un array di float, non ha opinioni. È l’http://localhost:8100 sepolto in uno SQLite che ti tradisce, perché presume un mondo che sull’host di destinazione non esiste più. La soluzione vera non è stata scrivere più codice: è stata rendere le porte parte del contratto e scriverlo nero su bianco nel README. A volte “portabile” significa semplicemente essere onesti su cosa deve restare uguale.