File size: 7,610 Bytes
30e6218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""Reranking modes.

- ``heuristic``: term-overlap scoring; cheap, no model, weak.
- ``llm``: prompt-based relevance scoring through the generation provider.
  Responses are parsed strictly; a response that is not a number is a failure,
  never a default score. Concurrency and candidate count are bounded.
- ``cross_encoder``: the transformer cross-encoder in
  ``app/reranking/cross_encoder.py`` (optional ML dependencies, local model
  prepared in advance; no download at request time).

Whatever the mode, a failed rerank keeps the original candidate order and is
reported with ``RerankStatus.FAILED`` instead of fabricating scores.
"""

from __future__ import annotations

import asyncio
import logging
import os
import re
from dataclasses import dataclass
from typing import Any, List, Optional, Protocol, Sequence

from ..core.config import Settings
from ..core.lexical import tokenize
from ..core.types import RerankStatus, SearchHit

logger = logging.getLogger(__name__)


class RerankError(Exception):
    pass


class Reranker(Protocol):
    name: str

    async def score(self, query: str, hits: Sequence[SearchHit]) -> List[float]:
        ...


class HeuristicReranker:
    """Query-term coverage plus exact-phrase bonus. Not a relevance model."""

    name = "heuristic:term-overlap"

    async def score(self, query: str, hits: Sequence[SearchHit]) -> List[float]:
        terms = set(tokenize(query))
        phrase = query.strip().lower()
        scores: List[float] = []
        for hit in hits:
            if not terms:
                scores.append(0.0)
                continue
            doc_terms = set(tokenize(hit.text))
            coverage = len(terms & doc_terms) / len(terms)
            bonus = 0.25 if phrase and phrase in hit.text.lower() else 0.0
            scores.append(min(1.0, coverage + bonus))
        return scores


_SCORE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(?:/\s*10)?\s*\.?\s*$")


class LLMReranker:
    """Scores each candidate 0-10 with the chat model. Paid per candidate."""

    def __init__(self, chat_client, model_name: str, max_candidates: int, concurrency: int):
        self._client = chat_client
        self.name = f"llm:{model_name}"
        self.max_candidates = max(1, max_candidates)
        self._semaphore = asyncio.Semaphore(max(1, concurrency))

    async def _score_one(self, query: str, text: str) -> float:
        system = (
            "You rate how well a passage answers a query. Reply with a single "
            "number from 0 (irrelevant) to 10 (fully answers). Reply with the "
            "number only."
        )
        user = f"Query:\n{query}\n\nPassage:\n<passage>\n{text[:2000]}\n</passage>"
        async with self._semaphore:
            raw = await self._client.complete(system, user, max_tokens=8, temperature=0.0)
        match = _SCORE_RE.match(raw or "")
        if not match:
            raise RerankError(f"Unparseable relevance score: {raw!r}")
        value = float(match.group(1))
        if value < 0.0 or value > 10.0:
            raise RerankError(f"Relevance score out of range: {value}")
        return value / 10.0

    async def score(self, query: str, hits: Sequence[SearchHit]) -> List[float]:
        if len(hits) > self.max_candidates:
            raise RerankError(
                f"{len(hits)} candidates exceed llm_rerank_max_candidates=" f"{self.max_candidates}"
            )
        return list(await asyncio.gather(*(self._score_one(query, h.text) for h in hits)))


class CrossEncoderAdapter:
    """Lazy adapter around ``app.reranking.cross_encoder.CrossEncoderReranker``."""

    def __init__(self, model_name: str, device: str, allow_download: bool):
        self.name = f"cross_encoder:{model_name}"
        self._model_name = model_name
        self._device = device
        self._allow_download = allow_download
        self._impl: Any = None
        self._lock = asyncio.Lock()

    async def _load(self) -> Any:
        if self._impl is not None:
            return self._impl
        async with self._lock:
            if self._impl is None:
                if not self._allow_download:
                    os.environ.setdefault("HF_HUB_OFFLINE", "1")
                    os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
                try:
                    from app.reranking.cross_encoder import CrossEncoderConfig, CrossEncoderReranker
                except ImportError as exc:
                    raise RerankError(
                        "cross_encoder mode needs the optional ML dependencies "
                        "(pip install -r requirements-ml.txt)"
                    ) from exc
                config = CrossEncoderConfig(model_name=self._model_name, device=self._device)

                def construct() -> Any:
                    return CrossEncoderReranker(config)

                try:
                    self._impl = await asyncio.to_thread(construct)
                except Exception as exc:
                    raise RerankError(
                        f"Could not load cross-encoder '{self._model_name}': {exc}. "
                        "Prepare it with `python scripts/setup/init_models.py --reranker`."
                    ) from exc
        return self._impl

    async def score(self, query: str, hits: Sequence[SearchHit]) -> List[float]:
        impl = await self._load()
        docs = [{"id": h.chunk_id, "content": h.text, "score": 0.0} for h in hits]
        results = await asyncio.to_thread(impl.rerank, query, docs, None)
        by_id = {r.doc_id: float(r.rerank_score) for r in results}
        return [by_id[h.chunk_id] for h in hits]


def build_reranker(settings: Settings, chat_client=None) -> Optional[Reranker]:
    mode = settings.reranker_mode
    if mode == "none":
        return None
    if mode == "heuristic":
        return HeuristicReranker()
    if mode == "llm":
        if chat_client is None:
            logger.warning("reranker_mode=llm but no generation provider; reranking unavailable")
            return None
        return LLMReranker(
            chat_client,
            settings.openai_model,
            settings.llm_rerank_max_candidates,
            settings.llm_rerank_concurrency,
        )
    if mode == "cross_encoder":
        return CrossEncoderAdapter(
            settings.cross_encoder_model,
            settings.cross_encoder_device,
            settings.allow_model_download,
        )
    raise ValueError(f"Unknown reranker mode: {mode}")


@dataclass
class RerankOutcome:
    hits: List[SearchHit]
    status: RerankStatus
    reranker: Optional[str]
    error: Optional[str] = None


async def apply_reranker(
    reranker: Optional[Reranker], query: str, hits: Sequence[SearchHit]
) -> RerankOutcome:
    """Rerank the full candidate pool; never drop candidates on failure."""
    hits = list(hits)
    if reranker is None:
        return RerankOutcome(hits, RerankStatus.UNAVAILABLE, None)
    if not hits:
        return RerankOutcome(hits, RerankStatus.APPLIED, reranker.name)
    try:
        scores = await reranker.score(query, hits)
        if len(scores) != len(hits):
            raise RerankError("reranker returned a different number of scores")
    except Exception as exc:
        logger.warning("Reranking failed (%s); keeping original order", exc)
        return RerankOutcome(hits, RerankStatus.FAILED, reranker.name, str(exc))
    for hit, score in zip(hits, scores):
        hit.rerank_score = float(score)
    ordered = sorted(hits, key=lambda h: (-(h.rerank_score or 0.0), h.chunk_id))
    return RerankOutcome(ordered, RerankStatus.APPLIED, reranker.name)