book search engine #18
@@ -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)
|
||||
Reference in New Issue
Block a user