Skip to content

Score floors per encoder family, not one number for all CLIPs - #373

Open
lstein wants to merge 1 commit into
masterfrom
lstein/fix/lower-clip-score-floor
Open

Score floors per encoder family, not one number for all CLIPs#373
lstein wants to merge 1 commit into
masterfrom
lstein/fix/lower-clip-score-floor

Conversation

@lstein

@lstein lstein commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Text search filters out results below the album's min_search_score, resolved once at album creation as "0.005 for SigLIP, else 0.2". The 0.2 was calibrated for OpenAI CLIP — and it sits above the entire match band of the encoder PhotoMapAI recommends by default.

The measurement

Same 400 images, same eight queries, three encoders:

encoder mean top-1 median results at a 0.2 floor
openai-clip:ViT-B/32 0.267 +0.17 13–170 of 400
open-clip:ViT-L-14/dfn2b_s39b (default) 0.151 −0.07 0 or 1 per query
siglip2-large (calibrated) 0.015 0.00 0 (uses 0.005)

Repeated against a real 38,000-image photo library indexed with the OpenCLIP default, a 0.2 floor returns zero results for five of eight ordinary queries — "a dog", "a birthday cake", "a snowy mountain landscape", "a plate of food", "a city street at night" — and fewer than five for two more. The matches are in the index; they score 0.15–0.26.

What changed

The floor is a per-backend table: 0.1 for OpenCLIP, 0.2 for OpenAI CLIP, 0.005 for SigLIP, 0.1 for anything unrecognized. One number cannot serve all three — the same 0.1 applied to OpenAI CLIP returns the whole album ranked, because its median image scores 0.17 against an arbitrary query. On the 38k library, 0.1 yields 56–2953 hits per query: at most ~8% of the album for a very broad one.

Because the floor is stored per album, changing the default alone would only help albums created after the upgrade. A one-time migration re-resolves albums still carrying the machine-chosen 0.2 whose encoder now resolves lower. A floor the user typed is left alone, and the migration is gated on the config version, so 0.2 remains a value they can choose afterwards — without the gate it would be permanently unsettable.

Loading a config stays a read. The migration applies in memory and rides along with the next save the user actually causes; rewriting config.yaml on every first start after upgrading would drop its comments and any key this build does not know, unprompted.

While in there, atomic_write_text now preserves the target's mode and follows symlinks. It replaces the file rather than writing through it, so a config.yaml tightened to 0600 — it holds an InvokeAI password and a LocationIQ key — was being widened to the process umask by any album edit, and a symlinked config (dotfiles) was being replaced by a regular file while the real one went stale.

Finally, search resolves an omitted floor through the album first and the encoder second, so an API client that omits the field gets the user's tuned value rather than a default.

Tests

Thirteen new cases: the per-family table (including unrecognized and colon-less specs), album resolution, the migration and its three "leave it alone" cases, repeat-load idempotency, a newer config being left alone, the encoder-aware search default, and the two atomic_write_text properties. Each was checked by reverting its production hunk and confirming it fails.

Backend 680 passed, frontend 578 passed, ruff and prettier clean.

Notes

Three adversarial review rounds ran against this change. The first found that the migration ran on every load rather than once (type 0.2 into an OpenCLIP album, save it, and the next read silently reverted it), that a config stamped by a newer build was downgraded with its unknown keys stripped, and that a plain read rewrote the file — which is where the mode-widening and symlink-replacement came to light. All are fixed here.

SigLIP's floor is untouched, but the same measurement suggests it is marginal too: top-1 for "a dog" over those 400 images was 0.004, just under its own 0.005. That wants its own measurement rather than a guess bundled into this change.

🤖 Generated with Claude Code

Text search filters results below the album's ``min_search_score``, which
was resolved once at album creation as "0.005 for SigLIP, else 0.2". The
0.2 was calibrated for OpenAI CLIP, and it is wrong by roughly the width
of the match band for the encoder PhotoMapAI actually recommends.

Measured on the same 400 images with the same eight queries:

    encoder                        mean top-1   median
    openai-clip:ViT-B/32              0.267      +0.17
    open-clip:ViT-L-14/dfn2b_s39b     0.151      -0.07
    siglip (calibrated probability)   0.015       0.00

and again on a 38,000-image photo library indexed with the OpenCLIP
default, where a 0.2 floor returns *zero* results for five of eight
ordinary queries ("a dog", "a birthday cake", "a snowy mountain
landscape", "a plate of food", "a city street at night") and fewer than
five for two more. The matches are there; they score 0.15 to 0.26.

So the floor is now a per-backend table: 0.1 for OpenCLIP, 0.2 for
OpenAI CLIP, 0.005 for SigLIP. A single number cannot serve them — the
same 0.1 applied to OpenAI CLIP returns the entire album ranked, since
its *median* image scores 0.17 against an arbitrary query. On the 38k
library 0.1 yields 56 to 2953 hits per query, at most ~8% of the album
for a very broad one.

The floor is stored per album, so the default alone would only help
albums created after the upgrade. A one-time migration re-resolves
albums still carrying the machine-chosen 0.2 whose encoder now resolves
lower; a floor the user typed is left alone, and the migration is gated
on the config version so 0.2 stays a value they can choose afterwards.

Loading a config stays a read: the migration applies in memory and rides
along with the next save the user causes. Rewriting config.yaml on every
first start would drop its comments and any key this build does not
know, unprompted. ``atomic_write_text`` now also preserves the file's
mode and follows symlinks — it replaces rather than writes through, so a
config tightened to 0600 (it holds an InvokeAI password and a LocationIQ
key) was being widened to the umask by any album edit, and a symlinked
config was being replaced by a regular file.

Search resolves an omitted floor through the album first and the encoder
second, so an API client that omits it gets the user's tuned value
rather than a default.

Backend 680 passed, frontend 578 passed, ruff and prettier clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant