Files
forge/apps/api/forge/knowledge/rerank.py
T
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

140 lines
5.8 KiB
Python

"""Two-stage retrieval: a local cross-encoder re-ranker (stage 2).
Vector/hybrid search (stage 1) is fast but coarse - it scores every candidate against the
query independently. A cross-encoder reads the query AND a candidate *together*, so it judges
relevance far more accurately - at a cost, which is why it only ever runs over a small
shortlist (top-N) that stage 1 already narrowed down.
Fully local + offline, no new dependency: it reuses ``fastembed`` (already the default
embedder backend) via its ``TextCrossEncoder`` - ONNX on CPU, model files download once to the
same HuggingFace cache the embedders use. Like ``bm25_rank`` in hybrid.py, this NEVER hard-fails:
if the model can't load (knowledge extra absent / offline first run / bad model id) the input
order is returned unchanged and a warning is logged once. Re-ranking is opt-in per retrieval
node (or the search debugger); default retrieval is unchanged.
"""
from __future__ import annotations
import logging
import math
from dataclasses import replace
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from forge.knowledge.store import Hit
log = logging.getLogger("forge.rerank")
# Small, CPU-fast cross-encoder (~80 MB). BAAI/bge-reranker-base (~1 GB) is a heavier, higher-
# quality alternative a project can opt into via rag_defaults.reranker_model.
DEFAULT_RERANKER = "Xenova/ms-marco-MiniLM-L-6-v2"
# Warn once per model when a reranker can't be built, so a missing model degrades quietly to
# stage-1 order instead of spamming logs on every query.
_FALLBACK_WARNED: set[str] = set()
class Reranker(Protocol):
name: str
def scores(self, query: str, docs: list[str]) -> list[float]: ...
def _sigmoid(x: float) -> float:
# Cross-encoder outputs are unbounded logits; squash to (0,1) so a re-ranked Hit.score
# keeps the same 0..1 scale as cosine/fused scores and a downstream min_score still filters.
if x >= 0:
return 1.0 / (1.0 + math.exp(-x))
ex = math.exp(x)
return ex / (1.0 + ex)
class _FastEmbedReranker:
"""Adapter over fastembed's TextCrossEncoder (ONNX cross-encoder, CPU, offline)."""
def __init__(self, model_name: str) -> None:
from fastembed.rerank.cross_encoder import TextCrossEncoder
from forge.config import settings
# Reuse the same baked/offline model cache the embedders use (falls back to fastembed's
# own default temp cache when unset).
cache_dir = settings.fastembed_cache_dir or None
self._model = TextCrossEncoder(model_name=model_name, cache_dir=cache_dir)
self.name = model_name
def scores(self, query: str, docs: list[str]) -> list[float]:
# TextCrossEncoder.rerank yields one raw score per doc, in input order.
return [float(s) for s in self._model.rerank(query, list(docs))]
# Rerankers are expensive to construct (ONNX model load); cache one per model id process-wide,
# mirroring the embedder cache in embeddings.py.
_RERANKER_CACHE: dict[str, Reranker] = {}
def _normalize_model(model: str | None) -> str:
"""Accept a bare id ('Xenova/...') or a 'fastembed:<id>' ref (matching the embedding_model
convention); '' / None / ':'-only -> the default reranker."""
if not model:
return DEFAULT_RERANKER
m = model.strip()
if m.startswith("fastembed:"):
m = m.split(":", 1)[1].strip()
return m or DEFAULT_RERANKER
def resolve_reranker(model: str | None = None) -> Reranker | None:
"""Build (or reuse) a local cross-encoder re-ranker. Returns None (never raises) if the
model can't be constructed, so callers fall back to stage-1 order."""
name = _normalize_model(model)
hit = _RERANKER_CACHE.get(name)
if hit is not None:
return hit
try:
rr = _FastEmbedReranker(name)
except Exception as e: # noqa: BLE001 - fastembed/model unavailable -> no rerank
if name not in _FALLBACK_WARNED:
_FALLBACK_WARNED.add(name)
log.warning("resolve_reranker: %r unavailable (%s); using stage-1 order", name, e)
return None
_RERANKER_CACHE[name] = rr
return rr
def rerank_hits(query: str, hits: list[Hit], *, top_k: int, model: str | None = None) -> list[Hit]:
"""Re-order ``hits`` by cross-encoder relevance to ``query`` and keep the top ``top_k``.
Each returned Hit's ``score`` is the sigmoid of the cross-encoder logit (0..1), so
min_score filtering downstream still works. Degrades to ``hits[:top_k]`` (unchanged order)
when there is nothing to do or the reranker can't be built - never hard-fails.
"""
if not hits or not (query or "").strip():
return hits[:top_k]
reranker = resolve_reranker(model)
if reranker is None:
return hits[:top_k]
try:
raw = reranker.scores(query, [h.text for h in hits])
except Exception: # noqa: BLE001 - inference failure -> stage-1 order
log.warning("rerank_hits: cross-encoder inference failed; using stage-1 order", exc_info=True)
return hits[:top_k]
# A score-count mismatch would let zip() silently drop the unpaired tail hits; treat it as a
# failure and fall back to stage-1 order rather than returning a truncated/misaligned set.
if len(raw) != len(hits):
log.warning("rerank_hits: got %d scores for %d hits; using stage-1 order", len(raw), len(hits))
return hits[:top_k]
scored = sorted(
(replace(h, score=round(_sigmoid(s), 4)) for h, s in zip(hits, raw, strict=True)),
key=lambda h: h.score,
reverse=True,
)
return scored[:top_k]
async def arerank_hits(query: str, hits: list[Hit], *, top_k: int, model: str | None = None) -> list[Hit]:
"""Async wrapper: run the (CPU-bound) cross-encoder off the event loop."""
import asyncio
return await asyncio.to_thread(rerank_hits, query, hits, top_k=top_k, model=model)