"""Reranker that combines vector similarity, lexical overlap, and numeric overlap. The combined rerank score is: score = α·vector_score + β·lexical_score + γ·numeric_score Default weights: α=0.6, β=0.3, γ=0.1. """ import logging import re from app.config import settings from app.models.schemas import RerankedResult, SearchResult logger = logging.getLogger(__name__) # Scoring weights _ALPHA = 0.6 # vector similarity _BETA = 0.30 # lexical term overlap _GAMMA = 0.10 # numeric overlap # Regex for extracting number-like tokens (integers, decimals, percentages, areas) _NUM_PATTERN = re.compile(r"\b\d[\d,]*\.?\d*\b") def rerank( query: str, results: list[SearchResult], top_n: int | None = None, ) -> list[RerankedResult]: """Rerank retrieval results using a weighted combination of signals. Signals used: * **Vector score** — cosine similarity from the vector store (primary signal). * **Lexical overlap** — proportion of query tokens present in the chunk text. * **Numeric overlap** — proportion of numbers in the query also found in chunk. Args: query: The original query string (usually concatenated bullets). results: Candidates from the retriever, already ordered by vector score. top_n: Number of results to return. Defaults to ``settings.rerank_top_n``. Returns: Top-``top_n`` :class:`~app.models.schemas.RerankedResult` objects, ordered by ``rerank_score`` descending. Example:: reranked = rerank(query="95 sqm semi-detached", results=candidates, top_n=3) """ if top_n is None: top_n = settings.rerank_top_n query_tokens = _tokenise(query) query_numbers = set(_NUM_PATTERN.findall(query)) reranked: list[RerankedResult] = [] for r in results: chunk_tokens = _tokenise(r.text) chunk_numbers = set(_NUM_PATTERN.findall(r.text)) lex = _jaccard(query_tokens, chunk_tokens) num = _overlap(query_numbers, chunk_numbers) if query_numbers else 0.0 combined = _ALPHA * r.score + _BETA * lex + _GAMMA * num reranked.append( RerankedResult( **r.model_dump(), rerank_score=round(combined, 6), ) ) reranked.sort(key=lambda x: x.rerank_score, reverse=True) top = reranked[:top_n] logger.debug("Reranked %d → %d results", len(results), len(top)) return top def _tokenise(text: str) -> set[str]: """Lower-case and split ``text`` into a set of alpha/digit tokens. Args: text: Any plain-text string. Returns: Set of normalised tokens. """ return set(re.findall(r"[a-z0-9]+", text.lower())) def _jaccard(a: set[str], b: set[str]) -> float: """Compute Jaccard similarity between two token sets. Args: a: First token set. b: Second token set. Returns: Float in [0, 1]. """ if not a and not b: return 0.0 return len(a & b) / len(a | b) def _overlap(query_set: set[str], chunk_set: set[str]) -> float: """Fraction of ``query_set`` items found in ``chunk_set``. Args: query_set: Numbers extracted from the query. chunk_set: Numbers extracted from the chunk. Returns: Float in [0, 1]. """ if not query_set: return 0.0 return len(query_set & chunk_set) / len(query_set)