167 lines
6.2 KiB
Python
167 lines
6.2 KiB
Python
"""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
|