"""Optional local Cross-Encoder candidate reranking. The first-stage vector/FTS recall remains the default. A Cross-Encoder is only enabled when ``RAG_RERANKER_MODEL`` points at a local model directory (or ``RAG_RERANKER_ALLOW_DOWNLOAD=1`` is explicitly set). This keeps production search deterministic and offline when the optional model is absent, while allowing operators to add a Chinese model such as ``BAAI/bge-reranker-base`` without changing the retrieval contract. """ from __future__ import annotations import logging import os from pathlib import Path import threading import time logger = logging.getLogger("yawen.rag.rerank") _lock = threading.Lock() _metric_lock = threading.Lock() _model = None _model_name = "" _model_error = "" _last_metric: dict = { "enabled": False, "model": "", "model_loaded": False, "model_load_ms": 0.0, "inference_ms": 0.0, "candidate_count": 0, "reranked_count": 0, "fallback": True, } def _configured_model() -> str: return str(os.environ.get("RAG_RERANKER_MODEL", "")).strip() def _allow_download() -> bool: return str(os.environ.get("RAG_RERANKER_ALLOW_DOWNLOAD", "0")).lower() in { "1", "true", "yes", "on" } def _set_metric(**values) -> None: global _last_metric with _metric_lock: row = dict(_last_metric) row.update(values) for key in ("model_load_ms", "inference_ms", "total_ms"): if key in row: try: row[key] = round(float(row[key]), 2) except (TypeError, ValueError): row[key] = 0.0 _last_metric = row def last_metric() -> dict: with _metric_lock: return dict(_last_metric) def _local_model_path(name: str) -> str | None: """Resolve a configured model without triggering a network download.""" candidate = Path(name).expanduser() if candidate.is_dir() and (candidate / "config.json").exists(): return str(candidate) try: from ..config import MODELS_DIR cached = MODELS_DIR / name.replace("/", "--") if cached.is_dir() and (cached / "config.json").exists(): return str(cached) except Exception: pass return None def _get_model(): """Lazily load a local sentence-transformers CrossEncoder.""" global _model, _model_name, _model_error name = _configured_model() if not name: return None with _lock: if _model is not None and _model_name == name: return _model if _model_error and _model_name == name: return None started = time.perf_counter() local = _local_model_path(name) if local is None and not _allow_download(): _model_name = name _model_error = "模型目录不存在;为避免请求线程下载,已回退基础排序" _set_metric(enabled=False, model=name, model_loaded=False, fallback=True, error=_model_error) logger.warning("RAG 重排模型不可用:%s(%s)", name, _model_error) return None try: from sentence_transformers import CrossEncoder _model = CrossEncoder(local or name) _model_name = name _model_error = "" _set_metric(enabled=True, model=name, model_loaded=True, model_load_ms=(time.perf_counter() - started) * 1000, fallback=False) return _model except Exception as exc: # optional feature must never break RAG _model = None _model_name = name _model_error = f"{type(exc).__name__}: {exc}" _set_metric(enabled=False, model=name, model_loaded=False, model_load_ms=(time.perf_counter() - started) * 1000, fallback=True, error=_model_error) logger.exception("RAG Cross-Encoder 加载失败,回退基础排序:%s", name) return None def rerank(candidates: list[dict], query: str, *, limit: int | None = None) -> list[dict]: """Rerank candidates in place-compatible copies and return stable results. Only the first ``limit`` candidates are scored; the tail keeps its fused order. If the model is not configured/available, candidates are returned unchanged and no exception escapes the retrieval path. """ configured = _configured_model() if not candidates or not configured: _set_metric(enabled=False, model=configured, candidate_count=len(candidates), reranked_count=0, fallback=True, inference_ms=0.0) return candidates count = min(len(candidates), max(1, min(int(limit or os.environ.get("RAG_RERANKER_TOP_K", 32)), 64))) model = _get_model() if model is None: _set_metric(candidate_count=len(candidates), reranked_count=0, fallback=True) return candidates pairs = [(str(query), str(row.get("text") or "")) for row in candidates[:count]] started = time.perf_counter() try: scores = model.predict(pairs, batch_size=min(32, count), show_progress_bar=False) scored = [] for index, (row, score) in enumerate(zip(candidates[:count], scores)): item = dict(row) item["rerank_score"] = float(score) scored.append((item, float(score), index)) scored.sort(key=lambda value: (-value[1], value[2])) result = [item for item, _, _ in scored] + [dict(row) for row in candidates[count:]] _set_metric(candidate_count=len(candidates), reranked_count=count, inference_ms=(time.perf_counter() - started) * 1000, fallback=False) return result except Exception as exc: logger.exception("RAG Cross-Encoder 推理失败,回退基础排序") _set_metric(candidate_count=len(candidates), reranked_count=0, inference_ms=(time.perf_counter() - started) * 1000, fallback=True, error=f"{type(exc).__name__}: {exc}") return candidates def prewarm() -> bool: """Load the optional reranker in the resident RAG worker at startup.""" if not _configured_model(): return False return _get_model() is not None