From 8e6a2809b0631271fd972092e55d598867b6d3f2 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Fri, 12 Jun 2026 03:06:27 -0400 Subject: [PATCH] added rerank --- python/ebook_search/rerank.py | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 python/ebook_search/rerank.py diff --git a/python/ebook_search/rerank.py b/python/ebook_search/rerank.py new file mode 100644 index 0000000..abb2ee0 --- /dev/null +++ b/python/ebook_search/rerank.py @@ -0,0 +1,127 @@ +"""vLLM-backed optional reranking.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +from python.ebook_search.llm_interface import request_rerank + +if TYPE_CHECKING: + from python.ebook_search.config import RerankConfig + from python.ebook_search.search import SearchResult + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class RerankResult: + """A relevance score for one candidate chunk.""" + + chunk_id: int + score: float + + +def rerank_chunks(query: str, candidates: list[SearchResult], config: RerankConfig) -> list[SearchResult]: + """Rerank candidates with a vLLM rerank endpoint.""" + if not candidates: + return [] + + logger.info( + "ebook_rerank_request_start base_url=%s model=%s candidates=%s", + config.base_url, + config.model, + len(candidates), + ) + scores = score_candidates(query, candidates, config) + results = sorted( + ( + replace( + result, + score=final_rerank_score(result, scores[result.chunk_id].score, candidates), + rerank_score=scores[result.chunk_id].score, + ) + for result in candidates + ), + key=lambda result: result.score, + reverse=True, + ) + logger.info( + "ebook_rerank_request_complete base_url=%s model=%s candidates=%s", + config.base_url, + config.model, + len(results), + ) + return results + + +def score_candidates( + query: str, + candidates: list[SearchResult], + config: RerankConfig, +) -> dict[int, RerankResult]: + """Score candidate chunks with the configured rerank API.""" + body = request_rerank(query, [candidate.text for candidate in candidates], config) + if body is None: + return zero_rerank_scores(candidates) + + scores = parse_vllm_scores(body, candidates) + for result in scores.values(): + logger.debug("ebook_rerank_candidate_scored chunk_id=%s score=%s", result.chunk_id, result.score) + return scores + + +def parse_vllm_scores(body: object, candidates: list[SearchResult]) -> dict[int, RerankResult]: + """Parse vLLM rerank scores into chunk-id keyed results.""" + if not isinstance(body, dict): + logger.debug("ebook_rerank_response_not_object", extra={"response": body}) + return zero_rerank_scores(candidates) + + results = body.get("results") or body.get("data") + if not isinstance(results, list): + logger.debug("ebook_rerank_response_missing_results", extra={"response": body}) + return zero_rerank_scores(candidates) + + scores = zero_rerank_scores(candidates) + for item in results: + if not isinstance(item, dict): + continue + index = item.get("index") + score = item.get("relevance_score", item.get("score")) + if not isinstance(index, int) or index < 0 or index >= len(candidates): + continue + if not isinstance(score, int | float): + continue + chunk_id = candidates[index].chunk_id + scores[chunk_id] = RerankResult(chunk_id=chunk_id, score=clamp_score(float(score))) + return scores + + +def zero_rerank_scores(candidates: list[SearchResult]) -> dict[int, RerankResult]: + """Return zero relevance scores for all candidate chunks.""" + return {candidate.chunk_id: RerankResult(chunk_id=candidate.chunk_id, score=0.0) for candidate in candidates} + + +def clamp_score(score: float) -> float: + """Clamp a rerank score into the supported 0.0 to 1.0 range.""" + return min(max(score, 0.0), 1.0) + + +def final_rerank_score(result: SearchResult, rerank_score: float, candidates: list[SearchResult]) -> float: + """Combine rerank relevance with normalized hybrid retrieval evidence.""" + return rerank_score * normalized_hybrid_score(result, candidates) + + +def normalized_hybrid_score(result: SearchResult, candidates: list[SearchResult]) -> float: + """Normalize a candidate hybrid score against the rerank candidate set.""" + hybrid_scores = [ + candidate.fused_score if candidate.fused_score is not None else candidate.score for candidate in candidates + ] + low = min(hybrid_scores) + high = max(hybrid_scores) + if high == low: + return 1.0 + + score = result.fused_score if result.fused_score is not None else result.score + return (score - low) / (high - low)