@@ -45,6 +45,27 @@ def _backends(pack: dict) -> set[str]:
4545 return {item ["backend" ] for item in pack ["items" ]}
4646
4747
48+ def _set_rerank (store : KBStore , * , enabled : bool , top_k : int | None = None ) -> None :
49+ cfg = yaml .safe_load (store .config_path .read_text ())
50+ rerank_cfg = cfg .setdefault ("retrieval" , {}).setdefault ("rerank" , {})
51+ rerank_cfg ["enabled" ] = enabled
52+ if top_k is not None :
53+ rerank_cfg ["top_k" ] = top_k
54+ store .config_path .write_text (yaml .safe_dump (cfg ))
55+
56+
57+ class _StubReranker :
58+ """Deterministic stand-in for the cross-encoder: longer snippet wins.
59+
60+ Exercises the real `embeddings.rerank.rerank` scoring/sort path without
61+ needing the optional sentence-transformers extra, so this runs under
62+ the base CI install like the rest of this file (#92-style pattern).
63+ """
64+
65+ def score (self , query : str , candidates : list [str ]) -> list [float ]:
66+ return [float (len (c )) for c in candidates ]
67+
68+
4869def test_backend_fts5_skips_embedding (
4970 store : KBStore , monkeypatch : pytest .MonkeyPatch
5071) -> None :
@@ -164,6 +185,90 @@ def test_dedupe_keeps_highest_scored_regardless_of_input_order() -> None:
164185 assert [i .id for i in out ] == ["hi" ]
165186
166187
188+ def test_rerank_disabled_by_default_ordering_unchanged (
189+ store : KBStore , monkeypatch : pytest .MonkeyPatch
190+ ) -> None :
191+ """No `retrieval.rerank` config at all (#429): the reranker must never
192+ even be constructed, and hybrid ordering is exactly the RRF-fused order."""
193+ def _boom () -> None :
194+ raise AssertionError ("reranker must not be constructed when rerank is off" )
195+
196+ monkeypatch .setattr ("vouch.embeddings.rerank.default_reranker" , _boom )
197+ src = store .put_source (b"e2" )
198+ store .put_claim (Claim (id = "c2" , text = "OAuth refresh flow" , evidence = [src .id ]))
199+ health .rebuild_index (store )
200+ monkeypatch .setattr (
201+ context .index_db , "search_semantic" ,
202+ lambda * a , ** k : [
203+ ("claim" , "c1" , "short" , 0.9 ),
204+ ("claim" , "c2" , "a much longer snippet of text" , 0.8 ),
205+ ],
206+ )
207+ monkeypatch .setattr (context .index_db , "search" , lambda * a , ** k : [])
208+ _set_backend (store , "hybrid" )
209+
210+ pack = context .build_context_pack (store , query = "auth" )
211+ assert [i ["id" ] for i in pack ["items" ]] == ["c1" , "c2" ]
212+
213+
214+ def test_rerank_enabled_reorders_by_cross_encoder_score (
215+ store : KBStore , monkeypatch : pytest .MonkeyPatch
216+ ) -> None :
217+ """With `retrieval.rerank.enabled: true`, hybrid hits are reordered by
218+ the reranker's scores instead of the RRF fusion order."""
219+ src = store .put_source (b"e2" )
220+ store .put_claim (Claim (id = "c2" , text = "OAuth refresh flow" , evidence = [src .id ]))
221+ health .rebuild_index (store )
222+ monkeypatch .setattr (
223+ context .index_db , "search_semantic" ,
224+ lambda * a , ** k : [
225+ ("claim" , "c1" , "short" , 0.9 ),
226+ ("claim" , "c2" , "a much longer snippet of text" , 0.8 ),
227+ ],
228+ )
229+ monkeypatch .setattr (context .index_db , "search" , lambda * a , ** k : [])
230+ _set_backend (store , "hybrid" )
231+
232+ # sanity: fused order (rerank off) is c1 then c2 by RRF score.
233+ baseline = context .build_context_pack (store , query = "auth" )
234+ assert [i ["id" ] for i in baseline ["items" ]] == ["c1" , "c2" ]
235+
236+ monkeypatch .setattr (
237+ "vouch.embeddings.rerank.default_reranker" , lambda : _StubReranker ()
238+ )
239+ _set_rerank (store , enabled = True )
240+ reranked = context .build_context_pack (store , query = "auth" )
241+ assert [i ["id" ] for i in reranked ["items" ]] == ["c2" , "c1" ]
242+ assert _backends (reranked ) == {"hybrid" }
243+
244+
245+ def test_rerank_missing_extra_degrades_to_fused_order (
246+ store : KBStore , monkeypatch : pytest .MonkeyPatch
247+ ) -> None :
248+ """`sentence-transformers` not installed must not break `kb.context` —
249+ it degrades to the unreranked fused order instead of raising."""
250+ def _raise () -> None :
251+ raise ImportError ("sentence-transformers not installed" )
252+
253+ src = store .put_source (b"e2" )
254+ store .put_claim (Claim (id = "c2" , text = "OAuth refresh flow" , evidence = [src .id ]))
255+ health .rebuild_index (store )
256+ monkeypatch .setattr (
257+ context .index_db , "search_semantic" ,
258+ lambda * a , ** k : [
259+ ("claim" , "c1" , "short" , 0.9 ),
260+ ("claim" , "c2" , "a much longer snippet of text" , 0.8 ),
261+ ],
262+ )
263+ monkeypatch .setattr (context .index_db , "search" , lambda * a , ** k : [])
264+ _set_backend (store , "hybrid" )
265+ monkeypatch .setattr ("vouch.embeddings.rerank.default_reranker" , _raise )
266+ _set_rerank (store , enabled = True )
267+
268+ pack = context .build_context_pack (store , query = "auth" )
269+ assert [i ["id" ] for i in pack ["items" ]] == ["c1" , "c2" ]
270+
271+
167272def test_dedupe_preserves_input_order_not_score_order () -> None :
168273 """Survivors keep the caller's order (ranked hits first, appended
169274 neighbours last) even when a later distinct item outscores an earlier one,
0 commit comments