diff --git a/alembic/versions/0069_default_siglip2.py b/alembic/versions/0069_default_siglip2.py new file mode 100644 index 0000000..7bef8b1 --- /dev/null +++ b/alembic/versions/0069_default_siglip2.py @@ -0,0 +1,51 @@ +"""default the embedder to SigLIP 2 — for FRESH installs only (#1203) + +Make SigLIP 2 (so400m, 512px; a 1152-d drop-in) the default embedder. New +installs start on it. An EXISTING library is NOT touched: flipping its stored +embedder version would mark every embedding stale (the scorer is version-gated) +and kill suggestions until a full re-embed+retrain — so an existing instance +switches deliberately via Settings → GPU agent → Embedding model → Re-embed → +Retrain. We detect "fresh" by the absence of any embedded image. + +Revision ID: 0069 +Revises: 0068 +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0069" +down_revision: Union[str, None] = "0068" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NEW_NAME = "google/siglip2-so400m-patch16-512" +_NEW_VERSION = "siglip2-so400m-patch16-512" +_OLD_NAME = "google/siglip-so400m-patch14-384" +_OLD_VERSION = "siglip-so400m-patch14-384" + + +def upgrade() -> None: + # Fresh install (nothing embedded yet) → adopt SigLIP 2. + op.execute( + f""" + UPDATE ml_settings SET + embedder_model_name = '{_NEW_NAME}', + embedder_model_version = '{_NEW_VERSION}' + WHERE NOT EXISTS ( + SELECT 1 FROM image_record WHERE siglip_embedding IS NOT NULL + ) + """ + ) + op.alter_column("ml_settings", "embedder_model_name", server_default=_NEW_NAME) + op.alter_column( + "ml_settings", "embedder_model_version", server_default=_NEW_VERSION + ) + + +def downgrade() -> None: + op.alter_column("ml_settings", "embedder_model_name", server_default=_OLD_NAME) + op.alter_column( + "ml_settings", "embedder_model_version", server_default=_OLD_VERSION + ) diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index 054fa69..5afeee8 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -23,6 +23,36 @@ _EDITABLE = ( ) +# Supported embedders for the Settings dropdown — all 1152-d so a swap is a +# drop-in (re-embed + retrain, no schema change). Server-authoritative so the UI +# never free-types a model name. +SUPPORTED_EMBEDDERS = ( + { + "name": "google/siglip2-so400m-patch16-512", + "version": "siglip2-so400m-patch16-512", + "label": "SigLIP 2 · so400m · 512px (recommended)", + "dim": 1152, + }, + { + "name": "google/siglip2-so400m-patch16-384", + "version": "siglip2-so400m-patch16-384", + "label": "SigLIP 2 · so400m · 384px (faster)", + "dim": 1152, + }, + { + "name": "google/siglip-so400m-patch14-384", + "version": "siglip-so400m-patch14-384", + "label": "SigLIP 1 · so400m · 384px (original)", + "dim": 1152, + }, +) + + +@ml_admin_bp.route("/embedder-models", methods=["GET"]) +async def embedder_models(): + return jsonify({"models": list(SUPPORTED_EMBEDDERS)}) + + @ml_admin_bp.route("/settings", methods=["GET"]) async def get_settings(): from sqlalchemy import select diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 1d7c729..e415d9f 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -71,14 +71,16 @@ class MLSettings(Base): ccip_auto_apply_threshold: Mapped[float] = mapped_column( Float, nullable=False, default=0.92 ) + # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069); + # existing libraries keep their stored value until the operator re-embeds. embedder_model_version: Mapped[str] = mapped_column( - String(128), nullable=False, default="siglip-so400m-patch14-384" + String(128), nullable=False, default="siglip2-so400m-patch16-512" ) # The HF model NAME the embedder loads (server CPU embed + announced to the # GPU agent in the lease). Operator-settable so the embedder is a choice, not # a hardcode (#1190): set name + version together, then re-embed + retrain. embedder_model_name: Mapped[str] = mapped_column( - String(128), nullable=False, default="google/siglip-so400m-patch14-384" + String(128), nullable=False, default="google/siglip2-so400m-patch16-512" ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() diff --git a/frontend/src/components/settings/GpuAgentCard.vue b/frontend/src/components/settings/GpuAgentCard.vue index 45f1638..37432bb 100644 --- a/frontend/src/components/settings/GpuAgentCard.vue +++ b/frontend/src/components/settings/GpuAgentCard.vue @@ -117,15 +117,12 @@ the suggest cut; 0.92 recommended.

- -
Embedding model (advanced)
+ +
Embedding model
- -
@@ -139,12 +136,11 @@ >Re-embed library (GPU)

- Changing the model means a DIFFERENT embedding space. After saving a new - model + version, run Re-embed library (the GPU agent re-embeds - whole images + concept crops), then Retrain heads. Suggestions - degrade until both finish. SigLIP 2 (google/siglip2-so400m-patch16-512, - version siglip2-so400m-patch16-512) is a 1152-d drop-in at - 512px — no schema change. + Switching the model is a DIFFERENT embedding space. After Save model, + run Re-embed library (the GPU agent re-embeds whole images + concept + crops), then Retrain heads — suggestions degrade until both finish. + SigLIP 2 (512px) is a 1152-d drop-in over SigLIP 1; new installs default to + it. Your existing library stays on its current model until you re-embed.

@@ -173,8 +169,8 @@ const savingThreshold = ref(false) const autoApply = ref(true) const autoThreshold = ref(0.92) const savingAuto = ref(false) -const modelName = ref('') -const modelVersion = ref('') +const modelItems = ref([]) +const selectedModel = ref(null) const savingModel = ref(false) const reembedding = ref(false) const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 }) @@ -204,18 +200,26 @@ onMounted(async () => { autoThreshold.value = ml.settings.ccip_auto_apply_threshold } if (ml.settings?.embedder_model_name != null) { - modelName.value = ml.settings.embedder_model_name - modelVersion.value = ml.settings.embedder_model_version + const items = await ml.embedderModels() + // Make sure the current model is selectable even if it's not in the list. + const cur = ml.settings.embedder_model_name + if (!items.some((m) => m.name === cur)) { + items.push({ name: cur, version: ml.settings.embedder_model_version, label: `${cur} (current)` }) + } + modelItems.value = items + selectedModel.value = cur } } catch { /* non-fatal */ } }) async function onSaveModel() { + const opt = modelItems.value.find((m) => m.name === selectedModel.value) + if (!opt) return savingModel.value = true try { await ml.patchSettings({ - embedder_model_name: modelName.value.trim(), - embedder_model_version: modelVersion.value.trim(), + embedder_model_name: opt.name, + embedder_model_version: opt.version, }) toast({ text: 'Embedding model saved — now Re-embed library, then Retrain heads', type: 'success' }) } catch (e) { diff --git a/frontend/src/stores/ml.js b/frontend/src/stores/ml.js index c67e82e..8c01ba6 100644 --- a/frontend/src/stores/ml.js +++ b/frontend/src/stores/ml.js @@ -22,8 +22,14 @@ export const useMLStore = defineStore('ml', () => { await api.post('/api/ml/backfill') } + // Server-authoritative list of supported embedders for the model dropdown. + async function embedderModels() { + const r = await api.get('/api/ml/embedder-models') + return r.models || [] + } + return { settings, loading, error, - loadSettings, patchSettings, triggerBackfill + loadSettings, patchSettings, triggerBackfill, embedderModels } }) diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py index caa676a..5602f8c 100644 --- a/tests/test_api_ml_admin.py +++ b/tests/test_api_ml_admin.py @@ -34,25 +34,34 @@ async def test_get_and_patch_settings(client): @pytest.mark.asyncio -async def test_embedder_model_settable_and_empty_rejected(client): - # #1190: the embedder model name + version are operator-settable (a swap), - # and neither may be blanked. +async def test_embedder_model_default_settable_and_empty_rejected(client): + # #1203: fresh installs default to SigLIP 2 (migration 0069, no embeddings). + # The model is operator-settable (a swap) and neither field may be blanked. body = await (await client.get("/api/ml/settings")).get_json() - assert body["embedder_model_name"] == "google/siglip-so400m-patch14-384" + assert body["embedder_model_name"] == "google/siglip2-so400m-patch16-512" ok = await client.patch("/api/ml/settings", json={ - "embedder_model_name": "google/siglip2-so400m-patch16-512", - "embedder_model_version": "siglip2-so400m-patch16-512", + "embedder_model_name": "google/siglip-so400m-patch14-384", + "embedder_model_version": "siglip-so400m-patch14-384", }) assert ok.status_code == 200 out = await ok.get_json() - assert out["embedder_model_name"] == "google/siglip2-so400m-patch16-512" - assert out["embedder_model_version"] == "siglip2-so400m-patch16-512" + assert out["embedder_model_name"] == "google/siglip-so400m-patch14-384" bad = await client.patch("/api/ml/settings", json={"embedder_model_name": " "}) assert bad.status_code == 400 +@pytest.mark.asyncio +async def test_embedder_models_list(client): + # #1203: the dropdown reads the supported-model list from the server. + body = await (await client.get("/api/ml/embedder-models")).get_json() + assert any( + m["name"] == "google/siglip2-so400m-patch16-512" for m in body["models"] + ) + assert all({"name", "version", "label"} <= set(m) for m in body["models"]) + + @pytest.mark.asyncio async def test_video_settings_default_and_patch(client): """#747: video frame-sampling knobs are exposed + patchable."""