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:
115
backend/app/services/search.py
Normal file
115
backend/app/services/search.py
Normal 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]
|
||||
Reference in New Issue
Block a user