fix(explore): diversify "more like this" so it stops getting stuck (#1188)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m32s

Pure nearest-cosine piled near-identical images into the neighbour grid — a
reposted banner filled all 24 slots, and once you wandered into a B&W /
comic-panel cluster every neighbour was more of the same with no way back to
colour without the Random button (operator-reported, with screenshot).

similar() now over-fetches a wide candidate pool (5x the requested limit, cap
200), then diversifies down to `limit`:
- pHash near-duplicate collapse: drop candidates within 6 Hamming bits of the
  anchor or an already-kept candidate, so a repost (and the anchor's own clones)
  appears at most once.
- MMR re-rank: greedily pick for closeness-to-anchor minus similarity-to-already
  -picked (lambda 0.55), so the result SPANS clusters instead of returning 40
  variations of one image. Falls back to nearest-order on any failure / small
  pool, so existing nearest-first behaviour is unchanged when there's nothing to
  diversify.

Frontend forwardTarget drops the now-redundant skip-nearest-third hack (the list
is already diversified server-side) — plain random-over-unvisited gives the
variance now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-30 09:01:01 -04:00
parent 715e276c03
commit 0f472b2f9e
3 changed files with 115 additions and 15 deletions
+87 -8
View File
@@ -289,6 +289,75 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
]
def _diversify_similar(src, rows, limit, *, dup_threshold=6, lam=0.55):
"""Trim a nearest-cosine candidate pool down to `limit` diverse picks.
1. pHash collapse: drop any candidate whose perceptual hash is within
`dup_threshold` Hamming bits of the anchor or an already-kept candidate —
so a reposted banner (and the anchor's own clones) appears at most once.
2. MMR (Maximal Marginal Relevance): greedily pick the candidate maximising
`lam * sim_to_anchor - (1 - lam) * max_sim_to_already_picked`. This keeps
the most relevant up top but pushes the selection to SPAN clusters
instead of returning 40 variations of one image.
Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool.
"""
if len(rows) <= 1:
return rows[:limit]
try:
import imagehash
import numpy as np
except Exception:
return rows[:limit]
# --- 1. pHash near-duplicate collapse (videos/NULL phash pass through) ---
kept = []
seen = []
if src.phash:
try:
seen.append(imagehash.hex_to_hash(src.phash))
except Exception:
pass
for row in rows:
ph = row[0].phash
if ph:
try:
h = imagehash.hex_to_hash(ph)
if any((h - k) <= dup_threshold for k in seen):
continue
seen.append(h)
except Exception:
pass
kept.append(row)
if len(kept) <= limit:
return kept
# --- 2. MMR re-rank on the L2-normalised SigLIP embeddings ---
try:
a = np.asarray(src.siglip_embedding, dtype=np.float32)
a = a / (np.linalg.norm(a) or 1.0)
V = np.vstack([
np.asarray(row[0].siglip_embedding, dtype=np.float32) for row in kept
])
V = V / np.clip(np.linalg.norm(V, axis=1, keepdims=True), 1e-8, None)
except Exception:
return kept[:limit]
rel = V @ a # (N,) cosine to the anchor
n = len(kept)
picked_mask = np.zeros(n, dtype=bool)
max_sim = np.zeros(n, dtype=np.float32) # max sim to anything picked yet
order = []
for _ in range(min(limit, n)):
scores = lam * rel - (1.0 - lam) * max_sim
scores[picked_mask] = -np.inf
i = int(np.argmax(scores))
order.append(i)
picked_mask[i] = True
max_sim = np.maximum(max_sim, V @ V[i])
return [kept[i] for i in order]
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -565,14 +634,20 @@ class GalleryService:
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
) -> list[GalleryImage] | None:
"""Visual "more like this": images ranked by cosine distance to
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
No ML inference here; the embedding was computed at import.
"""Visual "more like this": images near `image_id`'s SigLIP embedding
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
doesn't collapse into one cluster. No ML inference here.
Returns None if the source image doesn't exist (→ 404), [] if it has
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
scope filters (AND) but REPLACES the date sort — always nearest-first,
bounded to `limit` (no cursor; distance-ranking has no date cursor).
Pure nearest-cosine piles up near-identical images — a reposted banner
fills the whole grid, and once you wander into a B&W / comic-panel
cluster every neighbour is more of the same with no way back to colour
(operator-reported 2026-06-30). So we pull a WIDER candidate pool, then:
1. collapse near-duplicate pHashes (and drop clones of the anchor),
2. MMR re-rank — pick for closeness-to-anchor but penalise similarity
to what's already picked, so the result SPANS clusters.
Returns None if the source doesn't exist (→ 404), [] if it has no
embedding. Composes with the scope filters (AND); REPLACES the date sort.
"""
if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200")
@@ -582,6 +657,9 @@ class GalleryService:
if src.siglip_embedding is None:
return []
# Over-fetch so diversification has clusters to spread across — without a
# wide pool there's nothing but the near-dupes to choose from.
pool_n = min(200, max(limit * 5, 60))
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
@@ -597,8 +675,9 @@ class GalleryService:
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
stmt = stmt.order_by(distance.asc()).limit(limit)
stmt = stmt.order_by(distance.asc()).limit(pool_n)
rows = (await self.session.execute(stmt)).all()
rows = _diversify_similar(src, rows, limit)
artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists)