Aissugur Kurganbekov Claude Opus 4.8 commited on
Commit
1b671d1
·
1 Parent(s): ba9f2db

Harden FlashRank reranking (review-driven) and wire it into the image

Browse files

Adversarial multi-agent review follow-up to the second-stage reranker:
- answering.py: pool_k = max(TOP_K, RERANK_CANDIDATES) so a mis-set
RERANK_CANDIDATES can never shrink the abstention gate's top-K (the
abstention decision stays provably on the TF-IDF top-K).
- reranking.py: guard the one-time, network-bound ranker construction with a
double-checked threading.Lock — concurrent first /ask requests no longer
race into two parallel model downloads; bounds-check the result->chunk
remap so a changed flashrank result schema can't IndexError.
- Dockerfile: flashrank ships via requirements.txt; keep the optional LLM
SDKs (openai/anthropic) explicit.

Verified: 28 tests pass; golden eval unchanged (7/7, 100% citation coverage,
0 unsupported, evidence-match 1.0); proved enabled-without-flashrank cites the
identical set as a no-rerank build, so abstention and eval are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. Dockerfile +2 -1
  2. backend/app/answering.py +13 -5
  3. backend/app/reranking.py +30 -11
Dockerfile CHANGED
@@ -16,7 +16,8 @@ FROM python:3.12-slim
16
  WORKDIR /app
17
 
18
  COPY backend/requirements.txt ./requirements.txt
19
- # Include the optional LLM SDKs so "Thinking" mode works when a key is provided.
 
20
  RUN pip install --no-cache-dir -r requirements.txt openai anthropic
21
 
22
  COPY backend/app ./app
 
16
  WORKDIR /app
17
 
18
  COPY backend/requirements.txt ./requirements.txt
19
+ # requirements.txt already pins flashrank (the reranker). Add the optional LLM
20
+ # SDKs too so "Thinking" mode works when a key is provided.
21
  RUN pip install --no-cache-dir -r requirements.txt openai anthropic
22
 
23
  COPY backend/app ./app
backend/app/answering.py CHANGED
@@ -107,11 +107,19 @@ def answer_question(question: str, chunks: List[Chunk], mode: str = "fast"):
107
  or the call fails. The returned mode reflects what actually
108
  produced the answer ("fast" | "thinking").
109
  """
110
- # Stage 1: TF-IDF retrieval. Pull a larger candidate pool when reranking is
111
- # on, but the abstention DECISION below always uses the original top-K, so the
112
- # behaviour (and the golden eval) is identical whether or not the reranker is
113
- # present.
114
- pool_k = config.RERANK_CANDIDATES if reranking.is_enabled() else config.TOP_K
 
 
 
 
 
 
 
 
115
  scored = rank_chunks(question, chunks, top_k=pool_k)
116
  gate_chunks = scored[: config.TOP_K]
117
  top_score = gate_chunks[0].score if gate_chunks else 0.0
 
107
  or the call fails. The returned mode reflects what actually
108
  produced the answer ("fast" | "thinking").
109
  """
110
+ # Stage 1: TF-IDF retrieval. Pull a larger candidate pool when reranking is on.
111
+ # The abstention DECISION (below) always runs on the original top-K TF-IDF
112
+ # scores, so it is identical with or without reranking — as are citation
113
+ # coverage and the unsupported-answer count. Reranking only reorders/selects
114
+ # WHICH evidence is cited (by design the cross-encoder may promote a chunk
115
+ # ranked outside the top-K); when flashrank is absent the cited set is the same
116
+ # top-K TF-IDF slice as a no-rerank build. max() keeps the gate's top-K intact
117
+ # even if RERANK_CANDIDATES is mis-set below TOP_K.
118
+ pool_k = (
119
+ max(config.TOP_K, config.RERANK_CANDIDATES)
120
+ if reranking.is_enabled()
121
+ else config.TOP_K
122
+ )
123
  scored = rank_chunks(question, chunks, top_k=pool_k)
124
  gate_chunks = scored[: config.TOP_K]
125
  top_score = gate_chunks[0].score if gate_chunks else 0.0
backend/app/reranking.py CHANGED
@@ -13,6 +13,7 @@ only reorders the evidence that gets cited and fed to the LLM; the abstention
13
  decision still runs on the TF-IDF scores in answering.py.
14
  """
15
  import logging
 
16
  from typing import List
17
 
18
  from . import config
@@ -22,6 +23,7 @@ logger = logging.getLogger(__name__)
22
 
23
  _ranker = None # cached FlashRank Ranker instance
24
  _unavailable = False # latch: once we know it's unusable, stop retrying
 
25
 
26
 
27
  def is_enabled() -> bool:
@@ -29,22 +31,31 @@ def is_enabled() -> bool:
29
 
30
 
31
  def _get_ranker():
32
- """Lazily build and cache the FlashRank ranker, or return None if unusable."""
 
 
 
 
 
33
  global _ranker, _unavailable
34
  if _ranker is not None:
35
  return _ranker
36
  if _unavailable:
37
  return None
38
- try:
39
- from flashrank import Ranker # optional dependency, imported lazily
 
 
 
 
 
40
 
41
- _ranker = Ranker(model_name=config.RERANK_MODEL)
42
- logger.info("Reranker ready (%s).", config.RERANK_MODEL)
43
- return _ranker
44
- except Exception as exc: # not installed / download failed / offline
45
- logger.info("Reranker unavailable (%s); using TF-IDF order.", exc)
46
- _unavailable = True
47
- return None
48
 
49
 
50
  def rerank(question: str, scored: List[ScoredChunk], top_k: int) -> List[ScoredChunk]:
@@ -62,9 +73,17 @@ def rerank(question: str, scored: List[ScoredChunk], top_k: int) -> List[ScoredC
62
  try:
63
  from flashrank import RerankRequest
64
 
 
 
65
  passages = [{"id": i, "text": s.chunk.text} for i, s in enumerate(scored)]
66
  ranked = ranker.rerank(RerankRequest(query=question, passages=passages))
67
- return [scored[r["id"]] for r in ranked][:top_k]
 
 
 
 
 
 
68
  except Exception as exc: # pragma: no cover - defensive
69
  logger.warning("Rerank failed (%s); using TF-IDF order.", exc)
70
  return head
 
13
  decision still runs on the TF-IDF scores in answering.py.
14
  """
15
  import logging
16
+ import threading
17
  from typing import List
18
 
19
  from . import config
 
23
 
24
  _ranker = None # cached FlashRank Ranker instance
25
  _unavailable = False # latch: once we know it's unusable, stop retrying
26
+ _lock = threading.Lock() # guards the one-time, network-bound ranker construction
27
 
28
 
29
  def is_enabled() -> bool:
 
31
 
32
 
33
  def _get_ranker():
34
+ """Lazily build and cache the FlashRank ranker, or return None if unusable.
35
+
36
+ Construction downloads a small model on first use; FastAPI serves /ask from a
37
+ threadpool, so the check-then-set is guarded by a lock (double-checked) to stop
38
+ concurrent first requests from racing into two parallel downloads.
39
+ """
40
  global _ranker, _unavailable
41
  if _ranker is not None:
42
  return _ranker
43
  if _unavailable:
44
  return None
45
+ with _lock:
46
+ if _ranker is not None:
47
+ return _ranker
48
+ if _unavailable:
49
+ return None
50
+ try:
51
+ from flashrank import Ranker # optional dependency, imported lazily
52
 
53
+ _ranker = Ranker(model_name=config.RERANK_MODEL)
54
+ logger.info("Reranker ready (%s).", config.RERANK_MODEL)
55
+ except Exception as exc: # not installed / download failed / offline
56
+ logger.info("Reranker unavailable (%s); using TF-IDF order.", exc)
57
+ _unavailable = True
58
+ return _ranker
 
59
 
60
 
61
  def rerank(question: str, scored: List[ScoredChunk], top_k: int) -> List[ScoredChunk]:
 
73
  try:
74
  from flashrank import RerankRequest
75
 
76
+ # flashrank scores then sorts the passages list IN PLACE, so build a fresh
77
+ # list every call (never cache it). Each result keeps the "id" we set here.
78
  passages = [{"id": i, "text": s.chunk.text} for i, s in enumerate(scored)]
79
  ranked = ranker.rerank(RerankRequest(query=question, passages=passages))
80
+ # Bounds-checked remap so a changed result schema can't IndexError/KeyError.
81
+ out = [
82
+ scored[i]
83
+ for r in ranked
84
+ if isinstance((i := r.get("id")), int) and 0 <= i < len(scored)
85
+ ]
86
+ return (out or head)[:top_k]
87
  except Exception as exc: # pragma: no cover - defensive
88
  logger.warning("Rerank failed (%s); using TF-IDF order.", exc)
89
  return head