Merge pull request 'Release: SigLIP 2 default (new installs) + embedder model dropdown' (#157) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m26s

This commit was merged in pull request #157.
This commit is contained in:
2026-06-30 18:01:01 -04:00
6 changed files with 133 additions and 31 deletions
+51
View File
@@ -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
)
+30
View File
@@ -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
+4 -2
View File
@@ -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()
@@ -117,15 +117,12 @@
the suggest cut; 0.92 recommended.
</p>
<!-- Embedding model (advanced) -->
<div v-if="ml.settings" class="fc-section-h mt-5 mb-1">Embedding model (advanced)</div>
<!-- Embedding model -->
<div v-if="ml.settings" class="fc-section-h mt-5 mb-1">Embedding model</div>
<div v-if="ml.settings">
<v-text-field
v-model="modelName" label="HF model name" density="compact" hide-details
variant="outlined" class="mb-2"
/>
<v-text-field
v-model="modelVersion" label="Version stamp" density="compact" hide-details
<v-select
v-model="selectedModel" :items="modelItems" item-title="label"
item-value="name" label="Model" density="compact" hide-details
variant="outlined"
/>
<div class="d-flex mt-3" style="gap:8px">
@@ -139,12 +136,11 @@
>Re-embed library (GPU)</v-btn>
</div>
<p class="fc-muted text-caption mt-2 mb-0">
Changing the model means a DIFFERENT embedding space. After saving a new
model + version, run <b>Re-embed library</b> (the GPU agent re-embeds
whole images + concept crops), then <b>Retrain heads</b>. Suggestions
degrade until both finish. SigLIP 2 (<code>google/siglip2-so400m-patch16-512</code>,
version <code>siglip2-so400m-patch16-512</code>) is a 1152-d drop-in at
512px no schema change.
Switching the model is a DIFFERENT embedding space. After <b>Save model</b>,
run <b>Re-embed library</b> (the GPU agent re-embeds whole images + concept
crops), then <b>Retrain heads</b> 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.
</p>
</div>
</MaintenanceTile>
@@ -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) {
+7 -1
View File
@@ -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
}
})
+17 -8
View File
@@ -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."""