Source code for axiom.retrieval.reranker

"""Optional cross-encoder reranking.

The retrieval arms (semantic embeddings + BM25) score a document *independently*
of the query's other terms. A **cross-encoder** instead reads the (query,
document) pair *jointly* and judges their relevance directly — the most accurate
ranking signal available, used as the final re-sort after fusion.

It is **OFF by default** and **degrades to a no-op** when unavailable:

- needs a torch model (~90 MB, ``cross-encoder/ms-marco-MiniLM-L-6-v2``), the
  same native runtime that fails on Windows without VC++ (TICKET-070);
- the heavy import / model load is lazy and guarded, so a missing or broken
  runtime simply means "no reranking", never a crash.

Algorithm reference: Hindsight (MIT, ``engine/search/reranking.py``). Local
sentence-transformers cross-encoders return logits → squashed to [0, 1] with a
sigmoid; already-calibrated [0, 1] scores are passed through.
"""

from __future__ import annotations

import math

_DEFAULT_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"

_warned = False


def _warn_once(exc: BaseException) -> None:
    global _warned
    if _warned:
        return
    _warned = True
    try:
        from axiom.logger import logger

        logger.warning(
            "Cross-encoder reranking disabled: the model could not load (%s). "
            "Search falls back to fused semantic+lexical ranking. On Windows this "
            "usually means the Microsoft Visual C++ Redistributable (x64) is missing.",
            exc,
        )
    except Exception:
        pass


def _stable_sigmoid(x: float) -> float:
    """Numerically stable logistic sigmoid → (0, 1)."""
    if x >= 0.0:
        return 1.0 / (1.0 + math.exp(-x))
    e = math.exp(x)
    return e / (1.0 + e)


[docs] class CrossEncoderReranker: """Lazy wrapper around a sentence-transformers ``CrossEncoder``. Args: model_name: HuggingFace cross-encoder id (default ms-marco MiniLM). model: Pre-built model with a ``predict(pairs)`` method. Injecting one bypasses loading entirely — used by tests to stay deterministic and offline. """ def __init__(self, model_name: str = _DEFAULT_MODEL, model: object | None = None) -> None: self._model_name = model_name self._model = model self._disabled = False def _ensure_loaded(self) -> None: if self._model is not None or self._disabled: return try: from sentence_transformers import CrossEncoder except Exception as exc: # torch / sentence-transformers unavailable self._disabled = True _warn_once(exc) return # Prefer the offline fast path (cached model, no HF Hub HEAD request that # stalls ~90s on hosts with broken IPv6 — same root cause as the # embedding singleton). Fall back to a one-time online download. try: try: self._model = CrossEncoder(self._model_name, local_files_only=True) except TypeError: # Older sentence-transformers without the kwarg. self._model = CrossEncoder(self._model_name) except Exception: try: self._model = CrossEncoder(self._model_name) except Exception as exc: self._disabled = True _warn_once(exc)
[docs] def rerank(self, query_text: str, documents: list[str]) -> list[float] | None: """Score each document's relevance to the query, in [0, 1]. Returns scores aligned 1:1 with ``documents``, or ``None`` when the reranker is unavailable / fails (the caller then keeps the fused order). """ self._ensure_loaded() if self._disabled or self._model is None or not documents: return None pairs = [[query_text, doc] for doc in documents] try: raw = self._model.predict(pairs) except Exception as exc: self._disabled = True _warn_once(exc) return None scores = [float(s) for s in raw] if not scores: return None # Calibrated rerankers already emit [0, 1] — keep their absolute # confidence. Local models emit logits — sigmoid maps them to (0, 1). already_unit = min(scores) >= 0.0 and max(scores) <= 1.0 out: list[float] = [] for s in scores: v = 0.0 if math.isnan(s) else (s if already_unit else _stable_sigmoid(s)) out.append(v) return out