79cd1234e2
GalleryService.similar() ranks images by pgvector cosine distance to a source image's precomputed SigLIP embedding — no query-time ML inference. Composes with the Phase-1/2 scope filters (AND) but replaces the date sort (always nearest-first, bounded top-N, no cursor). Returns None for a missing source (→404), [] for a source with no embedding (video / pending ML); excludes self and NULL-embedding rows. New GET /api/gallery/similar?similar_to=<id>&limit=N. Image-detail payload gains has_embedding so the UI can hide the surface. Alembic 0036 adds an HNSW vector_cosine_ops index on siglip_embedding (1152<2000 dims) so the search is sub-50ms ANN instead of a full scan; one-time ~30-60s build over existing embeddings on deploy. Shared _gallery_images/_image_json helpers de-dup the scroll/similar builders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
42 lines
1.6 KiB
Python
42 lines
1.6 KiB
Python
"""image_record.siglip_embedding: HNSW cosine index for "more like this"
|
|
|
|
Revision ID: 0036
|
|
Revises: 0035
|
|
Create Date: 2026-06-04
|
|
|
|
Gallery Phase 3 (visual similarity search) ranks images by
|
|
`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's
|
|
a sequential scan computing a 1152-dim distance for every row — fine at small
|
|
scale, but it grows linearly with the library. Add an HNSW index with
|
|
`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN.
|
|
|
|
1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training,
|
|
better recall than IVFFlat) is the right choice. ONE-TIME COST: building the
|
|
index over the existing embeddings (~57k vectors on the operator's library)
|
|
locks image_record for ~30-60s during this migration on deploy — acceptable
|
|
for a single-operator homelab. NULL embeddings (videos / not-yet-embedded
|
|
rows) are simply not indexed.
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "0036"
|
|
down_revision: Union[str, None] = "0035"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Raw SQL: alembic's create_index doesn't express the `USING hnsw (...
|
|
# vector_cosine_ops)` access-method + opclass cleanly. Must match the
|
|
# query's cosine_distance operator class to be usable by the planner.
|
|
op.execute(
|
|
"CREATE INDEX ix_image_record_siglip_hnsw "
|
|
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record")
|