diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 8e588fa2..09b0a2ba 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -111,24 +111,81 @@ def follow_user( @router.get("/search", response_model=schemas.UnifiedSearchResponse) def search_unified( - q: str = "", + q: str = "", + mode: str = "hybrid", db: Session = Depends(get_db) ): if not q: - return {"videos": [], "users": []} - - # Search for users + return {"videos": [], "users": [], "posts": []} + + # ── User search (always runs, unaffected by mode) ── users = db.query(User).filter( or_( User.username.ilike(f"%{q}%"), User.full_name.ilike(f"%{q}%") ) ).limit(10).all() - + from app.crud import video as crud_video - videos = crud_video.search_videos(db, query_str=q) - posts = crud_video.search_posts(db, query_str=q) - + + # ── Keyword search (runs for both 'keyword' and 'hybrid') ── + keyword_videos = [] + keyword_video_ids = [] + posts = [] + + if mode in ("keyword", "hybrid"): + keyword_videos = crud_video.search_videos(db, query_str=q) + keyword_video_ids = [v.id for v in keyword_videos] + posts = crud_video.search_posts(db, query_str=q) + + # ── Semantic search (runs for both 'semantic' and 'hybrid') ── + semantic_results = [] + if mode in ("semantic", "hybrid"): + try: + from app.services.hybrid_search import semantic_search + semantic_results = semantic_search(db, q, limit=30) + except Exception as e: + # Semantic search is best-effort; keyword results still stand + import logging + logging.getLogger(__name__).warning("Semantic search failed: %s", e) + + # ── Merge & rank (hybrid mode only — otherwise pass through) ── + if mode == "hybrid" and semantic_results: + from app.services.hybrid_search import merge_search_results + ranked = merge_search_results(keyword_video_ids, semantic_results, limit=50) + + # Build an id→Video lookup from keyword results (already has owner loaded) + kw_lookup = {v.id: v for v in keyword_videos} + + # Fetch any semantic-only videos not in keyword results + semantic_only_ids = [vid for vid, _ in ranked if vid not in kw_lookup] + if semantic_only_ids: + from sqlalchemy.orm import joinedload + extra_videos = ( + db.query(Video) + .options(joinedload(Video.owner)) + .filter(Video.id.in_(semantic_only_ids), Video.status == "approved") + .all() + ) + for v in extra_videos: + kw_lookup[v.id] = v + + # Build final ordered list, preserving rank order + videos = [kw_lookup[vid] for vid, _ in ranked if vid in kw_lookup] + + elif mode == "semantic" and semantic_results: + # Pure semantic: fetch full Video objects for the ranked IDs + from sqlalchemy.orm import joinedload + sem_ids = [vid for vid, _ in semantic_results] + videos = ( + db.query(Video) + .options(joinedload(Video.owner)) + .filter(Video.id.in_(sem_ids), Video.status == "approved") + .all() + ) + else: + videos = keyword_videos + return {"videos": videos, "users": users, "posts": posts} @router.post("/upload-avatar") diff --git a/backend/app/services/hybrid_search.py b/backend/app/services/hybrid_search.py new file mode 100644 index 00000000..a394e1d8 --- /dev/null +++ b/backend/app/services/hybrid_search.py @@ -0,0 +1,113 @@ +""" +Hybrid search — merges keyword (ILIKE) and semantic (pgvector) search results. + +The keyword path lives in app.crud.video.search_videos / search_posts (untouched). +This module adds the semantic path and the merge/ranking logic. +""" +import logging +from typing import List, Optional, Tuple + +import numpy as np +from sqlalchemy.orm import Session +from sqlalchemy import text + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Model singleton — loaded once, reused across requests +# --------------------------------------------------------------------------- +_encoder = None + + +def _get_encoder(): + global _encoder + if _encoder is None: + from sentence_transformers import SentenceTransformer + _encoder = SentenceTransformer("all-MiniLM-L6-v2") + logger.info("Loaded sentence-transformers model all-MiniLM-L6-v2") + return _encoder + + +# --------------------------------------------------------------------------- +# Semantic search via pgvector +# --------------------------------------------------------------------------- + +def semantic_search( + db: Session, + query: str, + limit: int = 20, + status: str = "approved", +) -> List[Tuple[int, float]]: + """Return [(video_id, similarity_score), ...] ranked by cosine distance. + + Uses the <=> (cosine distance) operator from pgvector. + similarity = 1 - cosine_distance, so higher is better. + """ + if not query or not query.strip(): + return [] + + encoder = _get_encoder() + embedding = encoder.encode(query.strip(), normalize_embeddings=True) + # pgvector expects a literal string like '[0.1, 0.2, ...]' + embedding_str = "[" + ",".join(str(float(v)) for v in embedding) + "]" + + # cosine_distance <=> returns 0 for identical vectors, 2 for opposite + # similarity = 1 - distance, so range is [-1, 1] + rows = db.execute( + text( + """ + SELECT ma.video_id, (1 - (ma.caption_embedding <=> :embedding::vector)) AS similarity + FROM media_analysis ma + JOIN videos v ON v.id = ma.video_id + WHERE ma.caption_embedding IS NOT NULL + AND v.status = :status + AND ma.status = 'done' + ORDER BY ma.caption_embedding <=> :embedding::vector + LIMIT :limit + """ + ), + {"embedding": embedding_str, "status": status, "limit": limit}, + ).fetchall() + + return [(row[0], float(row[1])) for row in rows] + + +# --------------------------------------------------------------------------- +# Pure merge / ranking function +# --------------------------------------------------------------------------- + +KEYWORD_BOOST = 1.0 # Added to raw similarity so keyword matches always rank above semantic-only + + +def merge_search_results( + keyword_video_ids: List[int], + semantic_results: List[Tuple[int, float]], + limit: int = 50, +) -> List[Tuple[int, float]]: + """Merge keyword and semantic video results into a single ranked list. + + - keyword_video_ids: list of video IDs from ILIKE search (order = relevance) + - semantic_results: list of (video_id, similarity_score) from pgvector + - Returns [(video_id, final_score), ...] sorted descending, capped to *limit*. + + A video matching both gets KEYWORD_BOOST + its semantic score (not double-counted). + Keyword-only matches get KEYWORD_BOOST + 0. + Semantic-only matches get their raw similarity score. + """ + scores = {} + + # Keyword results — assign boost + recency tiebreaker from position + for idx, vid in enumerate(keyword_video_ids): + recency = max(0.0, 1.0 - idx * 0.01) # slight tiebreak for earlier results + scores[vid] = KEYWORD_BOOST + recency + + # Semantic results — merge or add + for vid, sim in semantic_results: + if vid in scores: + # Already a keyword hit — use keyword-boosted score (higher) + pass + else: + scores[vid] = sim + + ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True) + return ranked[:limit]