feat: add embeddings pipeline and semantic search endpoint

Wire the full embedding flow:
- Rewrite Embedding model to use pgvector Vector(512) with HNSW index
- Add embed_photo, vision_fanout, backfill_vision Celery tasks on
  dedicated `vision` queue
- Hook vision_fanout into generate_thumbnails completion
- Add POST /api/v1/photos/search with hybrid RRF ranking (semantic-only
  for now; FTS leg added in PR5)
- Stub ocr_photo, detect_objects, extract_faces tasks for later PRs

Migration 0003 drops/recreates the embeddings table (was never populated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:07:32 +02:00
parent b1c2bdf7f0
commit 649437dc85
8 changed files with 418 additions and 11 deletions

View File

@@ -0,0 +1,115 @@
"""
Unified search service — hybrid FTS + semantic (RRF) search.
Phase 1 (PR4): semantic-only via pgvector cosine similarity.
Phase 2 (PR5): adds FTS via tsvector, enables RRF fusion.
"""
import logging
from typing import Optional
import numpy as np
from sqlalchemy import select, text, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__)
async def hybrid_search(
db: AsyncSession,
q: Optional[str] = None,
tag_ids: Optional[list[str]] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""Run hybrid search (FTS + semantic) with RRF fusion.
Currently semantic-only; FTS leg added in PR5.
"""
model_name = settings.vision.embedder.name
results = {}
# ── Semantic search (CLIP text → pgvector cosine) ─────────────────
if q:
try:
from app.services.vision.registry import registry
embedder = registry.get_embedder()
query_vec = embedder.embed_text(q)
# pgvector cosine distance: <=> returns distance (lower = closer)
vec_str = "[" + ",".join(str(float(v)) for v in query_vec) + "]"
stmt = text("""
SELECT e.photo_id,
(e.vector <=> :qvec::vector) AS distance
FROM embeddings e
WHERE e.model = :model
ORDER BY e.vector <=> :qvec::vector
LIMIT 200
""")
rows = (await db.execute(stmt, {"qvec": vec_str, "model": model_name})).fetchall()
for rank, (photo_id, distance) in enumerate(rows):
if photo_id not in results:
results[photo_id] = {"semantic_rank": rank, "fts_rank": None}
else:
results[photo_id]["semantic_rank"] = rank
except Exception as e:
logger.warning("Semantic search failed (models may not be loaded): %s", e)
# ── FTS search (placeholder for PR5) ──────────────────────────────
# Will be: tsvector @@ plainto_tsquery(q), ranked by ts_rank.
# For now, skip.
# ── RRF fusion ────────────────────────────────────────────────────
k = 60
scored = []
for photo_id, ranks in results.items():
score = 0.0
if ranks["semantic_rank"] is not None:
score += 1.0 / (k + ranks["semantic_rank"])
if ranks.get("fts_rank") is not None:
score += 1.0 / (k + ranks["fts_rank"])
scored.append((photo_id, score))
scored.sort(key=lambda x: -x[1])
# If no text query, fall back to recent photos
if not q:
stmt = select(Photo.id).order_by(Photo.created_at.desc())
if tag_ids:
from app.models.tags import photo_tags
stmt = stmt.join(photo_tags, Photo.id == photo_tags.c.photo_id).where(
photo_tags.c.tag_id.in_(tag_ids)
).distinct()
if date_from:
stmt = stmt.where(Photo.taken_at >= date_from)
if date_to:
stmt = stmt.where(Photo.taken_at <= date_to)
stmt = stmt.offset(offset).limit(limit)
rows = (await db.execute(stmt)).fetchall()
return [{"photo_id": row[0], "score": 0.0} for row in rows]
# Apply filters to scored results
photo_ids = [pid for pid, _ in scored]
if not photo_ids:
return []
# Filter by tags if requested
if tag_ids:
from app.models.tags import photo_tags
stmt = select(photo_tags.c.photo_id).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
).distinct()
valid_ids = {row[0] for row in (await db.execute(stmt)).fetchall()}
scored = [(pid, s) for pid, s in scored if pid in valid_ids]
# Paginate
page = scored[offset : offset + limit]
return [{"photo_id": pid, "score": score} for pid, score in page]