""" Reciprocal Rank Fusion (RRF) utilities for combining search results. RRF is a method to combine multiple ranked lists by computing the reciprocal of each item's rank in each list, then summing these reciprocal ranks. """ import re from typing import Any, TypeVar from sqlalchemy import Select, and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from src import models from src.config import settings from src.dependencies import tracked_db from src.embedding_client import embedding_client from src.exceptions import ValidationException from src.models import session_peers_table from src.utils.filter import apply_filter from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern from src.vector_store import get_external_vector_store T = TypeVar("T") def _uses_pgvector_message_search() -> bool: """Return True when semantic message search can stay entirely in Postgres.""" return ( settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED ) def reciprocal_rank_fusion(*ranked_lists: list[T], k: int = 60, limit: int) -> list[T]: """ Combine multiple ranked lists using Reciprocal Rank Fusion (RRF). RRF assigns a score to each item based on the formula: RRF_score = sum(1 / (k + rank_i)) for all lists where the item appears Where: - k is a constant (typically 60) that controls the impact of high-ranked items - rank_i is the rank of the item in list i (1-indexed) Args: *ranked_lists: Variable number of ranked lists to combine k: RRF constant parameter (default: 60) limit: Maximum number of results to return Returns: list of items ranked by RRF score (highest score first) """ if not ranked_lists: return [] # dictionary to store RRF scores for each item rrf_scores: dict[T, float] = {} # Process each ranked list for ranked_list in ranked_lists: for rank, item in enumerate(ranked_list, 1): # 1-indexed ranking if item not in rrf_scores: rrf_scores[item] = 0.0 # Add reciprocal rank contribution from this list rrf_scores[item] += 1.0 / (k + rank) # Sort items by RRF score (descending order) sorted_items = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True) # Extract just the items (not the scores) result = [item for item, _ in sorted_items] return result[:limit] async def query_external_vector_message_ids( workspace_name: str, embedding_query: list[float], limit: int, filters: dict[str, Any] | None = None, ) -> list[str]: """Query the external vector store and return ordered message IDs.""" external_vector_store = get_external_vector_store() if external_vector_store is None: return [] namespace = external_vector_store.get_vector_namespace("message", workspace_name) vector_filters: dict[str, Any] = {} if filters: if "session_id" in filters: vector_filters["session_name"] = filters["session_id"] if "peer_id" in filters: vector_filters["peer_name"] = filters["peer_id"] # Oversample: multiple chunk-level hits can map to the same message, # so fetch extra to ensure enough unique messages after deduplication. vector_results = await external_vector_store.query( namespace, embedding_query, top_k=limit * 3, filters=vector_filters if vector_filters else None, ) if not vector_results: return [] seen_message_ids: dict[str, None] = {} for result in vector_results: message_id = result.metadata.get("message_id") if message_id and message_id not in seen_message_ids: seen_message_ids[message_id] = None return list(seen_message_ids.keys()) async def fetch_messages_by_ids( db: AsyncSession, message_ids: list[str], filters: dict[str, Any] | None = None, ) -> list[models.Message]: """Fetch messages by ID and preserve the input ordering.""" if not message_ids: return [] stmt = select(models.Message).where(models.Message.public_id.in_(message_ids)) stmt = apply_filter(stmt, models.Message, filters) result = await db.execute(stmt) messages = {msg.public_id: msg for msg in result.scalars().all()} return [messages[msg_id] for msg_id in message_ids if msg_id in messages] async def _semantic_search_pgvector( db: AsyncSession, workspace_name: str, embedding_query: list[float], limit: int, filters: dict[str, Any] | None = None, ) -> list[models.Message]: """ Perform semantic message search using pgvector in Postgres. Args: db: Database session workspace_name: Name of the workspace to search in embedding_query: Pre-computed embedding for the search query limit: Maximum number of results to return filters: Optional filters to apply to the message query Returns: list of messages ordered by semantic similarity """ distance_expr = models.MessageEmbedding.embedding.cosine_distance(embedding_query) stmt = ( select(models.Message) .join( models.MessageEmbedding, models.Message.public_id == models.MessageEmbedding.message_id, ) .where(models.MessageEmbedding.embedding.isnot(None)) .where(models.MessageEmbedding.workspace_name == workspace_name) ) if filters: internal_filters = filters.copy() internal_filters["workspace_id"] = workspace_name stmt = apply_filter(stmt, models.Message, internal_filters) # Oversample because a message with multiple embedding chunks can # produce duplicate rows; we deduplicate in Python to preserve HNSW # index usage (a DISTINCT ON subquery would prevent the index scan). stmt = stmt.order_by(distance_expr).limit(limit * 2) result = await db.execute(stmt) seen: set[str] = set() deduped: list[models.Message] = [] for msg in result.scalars().all(): if msg.public_id not in seen: seen.add(msg.public_id) deduped.append(msg) return deduped[:limit] async def _filter_by_peer_perspective( db: AsyncSession, messages: list[models.Message], workspace_name: str, peer_name: str, ) -> list[models.Message]: """ Filter messages by peer perspective (temporal session membership). Only keeps messages from sessions where the peer was a member at the time the message was created (between joined_at and left_at). Args: db: Database session messages: List of messages to filter workspace_name: Name of the workspace peer_name: Name of the peer whose perspective to use Returns: Filtered list of messages """ if not messages: return [] # Get all session memberships for this peer in this workspace session_memberships_query = ( select(session_peers_table) .where(session_peers_table.c.workspace_name == workspace_name) .where(session_peers_table.c.peer_name == peer_name) ) result = await db.execute(session_memberships_query) memberships = result.all() # Build a lookup of session -> time windows session_windows: dict[str, list[tuple[Any, Any]]] = {} for membership in memberships: session_name = membership.session_name if session_name not in session_windows: session_windows[session_name] = [] session_windows[session_name].append((membership.joined_at, membership.left_at)) # Filter messages filtered_messages: list[models.Message] = [] for msg in messages: if msg.session_name not in session_windows: continue # Check if message was created during any of the peer's active windows in this session for joined_at, left_at in session_windows[msg.session_name]: if msg.created_at >= joined_at and ( left_at is None or msg.created_at <= left_at ): filtered_messages.append(msg) break # Don't add the same message twice return filtered_messages async def _fulltext_search( db: AsyncSession, query: str, stmt: Select[tuple[models.Message]], limit: int, ) -> list[models.Message]: """ Perform full-text search using PostgreSQL FTS and ILIKE fallback. Args: db: Database session query: Search query stmt: Base SQL query conditions limit: Maximum number of results to return Returns: list of messages ordered by text search relevance """ # Check if query contains special characters that FTS might not handle well has_special_chars = bool( re.search(r'[~`!@#$%^&*()_+=\[\]{};\':"\\|,.<>/?-]', query) ) # Escape ILIKE pattern characters to treat user input literally escaped_query = escape_ilike_pattern(query) if has_special_chars: # For queries with special characters, use exact string matching (ILIKE) search_condition = models.Message.content.ilike( f"%{escaped_query}%", escape=ILIKE_ESCAPE_CHAR ) fulltext_query = stmt.where(search_condition).order_by( models.Message.created_at.desc() ) else: # For natural language queries, use full text search with ranking fts_condition = func.to_tsvector("english", models.Message.content).op("@@")( func.plainto_tsquery("english", query) ) # Combine FTS with ILIKE as fallback for better coverage combined_condition = or_( fts_condition, models.Message.content.ilike( f"%{escaped_query}%", escape=ILIKE_ESCAPE_CHAR ), ) fulltext_query = stmt.where(combined_condition).order_by( # Order by FTS relevance first, then by creation time func.coalesce( func.ts_rank( func.to_tsvector("english", models.Message.content), func.plainto_tsquery("english", query), ), 0, ).desc(), models.Message.created_at.desc(), ) fulltext_query = fulltext_query.limit(limit) result = await db.execute(fulltext_query) return list(result.scalars().all()) async def search( query: str, *, filters: dict[str, Any] | None = None, limit: int = 10, ) -> list[models.Message]: """ Search across message content using a hybrid approach with Reciprocal Rank Fusion (RRF). This function combines semantic search and full-text search results using RRF when both are available, providing better search results than either method alone. Args: query: Search query to match against message content filters: Optional filters to scope search (must include workspace_id for semantic search). Special filter 'peer_perspective' will search across all messages from sessions that the peer is/was a member of, filtered by the time window when they were actually in the session. limit: Maximum number of results to return Returns: list of messages that match the search query, ordered by RRF relevance or individual search relevance Raises: ValidationException: If query exceeds maximum token limit for embeddings """ # Base query conditions stmt = select(models.Message) # Handle special peer_perspective filter peer_perspective_name: str | None = None if filters and "peer_perspective" in filters: peer_perspective_name = filters["peer_perspective"] # Remove from filters dict so apply_filter doesn't try to handle it filters = {k: v for k, v in filters.items() if k != "peer_perspective"} # Safety: peer_perspective must be scoped to a workspace if not filters or ( "workspace_id" not in filters and "workspace_name" not in filters ): raise ValidationException( "peer_perspective requires a workspace scope (workspace_id or workspace_name)." ) # Join with session_peers_table to get messages from sessions the peer was in # Only include messages created during the time window the peer was active stmt = stmt.join( session_peers_table, and_( models.Message.session_name == session_peers_table.c.session_name, models.Message.workspace_name == session_peers_table.c.workspace_name, models.Message.created_at >= session_peers_table.c.joined_at, or_( session_peers_table.c.left_at.is_(None), models.Message.created_at <= session_peers_table.c.left_at, ), ), ).where(session_peers_table.c.peer_name == peer_perspective_name) stmt = apply_filter(stmt, models.Message, filters) workspace_name: str | None = None if filters: workspace_value = filters.get("workspace_id") or filters.get("workspace_name") if isinstance(workspace_value, str): workspace_name = workspace_value semantic_limit = limit * 4 if peer_perspective_name else limit * 2 query_embedding: list[float] | None = None semantic_message_ids: list[str] | None = None if settings.EMBED_MESSAGES and isinstance(workspace_name, str): try: query_embedding = await embedding_client.embed(query) except ValueError as e: raise ValidationException( f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}." ) from e if not _uses_pgvector_message_search(): semantic_message_ids = await query_external_vector_message_ids( workspace_name=workspace_name, embedding_query=query_embedding, limit=semantic_limit, filters=filters, ) async def _run_search(active_db: AsyncSession) -> list[models.Message]: search_results: list[list[models.Message]] = [] if ( settings.EMBED_MESSAGES and isinstance(workspace_name, str) and query_embedding is not None ): if _uses_pgvector_message_search(): semantic_results = await _semantic_search_pgvector( db=active_db, workspace_name=workspace_name, embedding_query=query_embedding, limit=semantic_limit, filters=filters, ) else: semantic_results = await fetch_messages_by_ids( db=active_db, message_ids=semantic_message_ids or [], filters=filters, ) if peer_perspective_name: semantic_results = await _filter_by_peer_perspective( active_db, semantic_results, workspace_name, peer_perspective_name, ) search_results.append(semantic_results) fulltext_results = await _fulltext_search( db=active_db, query=query, stmt=stmt, limit=limit * 2, ) search_results.append(fulltext_results) if len(search_results) > 1: return reciprocal_rank_fusion(*search_results, limit=limit) if len(search_results) == 1: return search_results[0][:limit] return [] async with tracked_db("search.messages") as managed_db: combined_results = await _run_search(managed_db) for message in combined_results: managed_db.expunge(message) return combined_results