Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions backend/app/api/v1/endpoints/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
113 changes: 113 additions & 0 deletions backend/app/services/hybrid_search.py
Original file line number Diff line number Diff line change
@@ -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]
Loading