-
Notifications
You must be signed in to change notification settings - Fork 114
week_4: Module C (The Librarian) — C.2 cross-encoder reranker #957
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d1ea019
week_4: Module C (The Librarian) — C.2 cross-encoder reranker
PRAteek-singHWY d745a91
week_4: address CodeRabbit findings on #957
PRAteek-singHWY 5c52d87
week_4: black-format run_librarian semantic guard (fix Lint CI)
PRAteek-singHWY 0b48c6d
week_4: black-format cross_encoder_test (fix Lint CI)
PRAteek-singHWY ea53658
week_4: fix live-eval id-space (UUID->external_id) for recall + reran…
PRAteek-singHWY File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| """Tests for the C.2 cross-encoder reranker (Week 4). | ||
|
|
||
| Hermetic: the cross-encoder score function and the CRE text map are injected as | ||
| controlled values, so re-ordering, top-N truncation, tie stability, the audit | ||
| shape, and the failure modes are all assertable without torch, an LLM, or a DB. | ||
| """ | ||
|
|
||
| import unittest | ||
|
|
||
| from application.utils.librarian.cross_encoder import ( | ||
| CrossEncoderReranker, | ||
| MissingCandidateTextError, | ||
| RerankerError, | ||
| ) | ||
| from application.utils.librarian.schemas import CreCandidate, RetrievalAudit | ||
|
|
||
| # The text each candidate CRE is scored against (its embeddings_content). | ||
| CRE_TEXTS = { | ||
| "111-111": "text-a", | ||
| "222-222": "text-b", | ||
| "333-333": "text-c", | ||
| } | ||
|
|
||
|
|
||
| def make_audit(threshold=0.8): | ||
| """A C.1 shortlist in cosine order: a (0.90) > b (0.80) > c (0.70).""" | ||
| return RetrievalAudit( | ||
| retriever="in-memory-cosine/0.1.0", | ||
| candidates=[ | ||
| CreCandidate(cre_id="111-111", score_vector=0.90), | ||
| CreCandidate(cre_id="222-222", score_vector=0.80), | ||
| CreCandidate(cre_id="333-333", score_vector=0.70), | ||
| ], | ||
| reranked=[], | ||
| threshold=threshold, | ||
| ) | ||
|
|
||
|
|
||
| # The cross-encoder disagrees with cosine: it likes b most, then c, then a. | ||
| _PAIR_SCORES = { | ||
| ("q", "text-a"): 0.10, | ||
| ("q", "text-b"): 0.90, | ||
| ("q", "text-c"): 0.50, | ||
| } | ||
|
|
||
|
|
||
| def fake_score(pairs): | ||
| return [_PAIR_SCORES[(q, cand)] for q, cand in pairs] | ||
|
|
||
|
|
||
| def make_reranker(top_n=5, cre_texts=None, score_fn=fake_score): | ||
| return CrossEncoderReranker( | ||
| score_fn=score_fn, | ||
| top_n=top_n, | ||
| cre_texts=CRE_TEXTS if cre_texts is None else cre_texts, | ||
| ) | ||
|
|
||
|
|
||
| class RerankTest(unittest.TestCase): | ||
| def test_reranks_by_cross_encoder_score_not_cosine(self) -> None: | ||
| audit = make_reranker().rerank("q", make_audit()) | ||
| # Cosine order was a,b,c; the cross-encoder reorders to b,c,a. | ||
| self.assertEqual( | ||
| [c.cre_id for c in audit.reranked], ["222-222", "333-333", "111-111"] | ||
| ) | ||
| scores = [c.score_rerank for c in audit.reranked] | ||
| self.assertEqual(scores, sorted(scores, reverse=True)) | ||
| self.assertAlmostEqual(audit.reranked[0].score_rerank, 0.90) | ||
|
|
||
| def test_candidates_preserved_and_audit_shape(self) -> None: | ||
| audit = make_reranker().rerank("q", make_audit(threshold=0.8)) | ||
| # candidates[] (the pre-rerank shortlist) is untouched, still cosine order. | ||
| self.assertEqual( | ||
| [c.cre_id for c in audit.candidates], ["111-111", "222-222", "333-333"] | ||
| ) | ||
| # score_rerank is only set on reranked[], never on candidates[]. | ||
| self.assertTrue(all(c.score_rerank is None for c in audit.candidates)) | ||
| self.assertEqual(audit.retriever, "in-memory-cosine/0.1.0") | ||
| self.assertEqual(audit.threshold, 0.8) | ||
|
|
||
| def test_top_n_truncates_to_best(self) -> None: | ||
| audit = make_reranker(top_n=2).rerank("q", make_audit()) | ||
| self.assertEqual([c.cre_id for c in audit.reranked], ["222-222", "333-333"]) | ||
|
|
||
| def test_top_n_larger_than_shortlist_keeps_all(self) -> None: | ||
| audit = make_reranker(top_n=20).rerank("q", make_audit()) | ||
| self.assertEqual(len(audit.reranked), 3) | ||
|
|
||
| def test_ties_preserve_cosine_order(self) -> None: | ||
| # Equal cross-encoder scores -> the stable sort keeps C.1's cosine order. | ||
| audit = make_reranker(score_fn=lambda pairs: [1.0] * len(pairs)).rerank( | ||
| "q", make_audit() | ||
| ) | ||
| self.assertEqual( | ||
| [c.cre_id for c in audit.reranked], ["111-111", "222-222", "333-333"] | ||
| ) | ||
|
|
||
| def test_empty_candidates_yields_empty_reranked(self) -> None: | ||
| empty = RetrievalAudit(retriever="r", candidates=[], reranked=[], threshold=0.8) | ||
| out = make_reranker().rerank("q", empty) | ||
| self.assertEqual(out.reranked, []) | ||
|
|
||
|
|
||
| class FailureModeTest(unittest.TestCase): | ||
| def test_missing_cre_text_raises(self) -> None: | ||
| with self.assertRaises(MissingCandidateTextError): | ||
| make_reranker(cre_texts={"111-111": "text-a"}).rerank("q", make_audit()) | ||
|
|
||
| def test_non_positive_top_n_rejected(self) -> None: | ||
| with self.assertRaises(RerankerError): | ||
| make_reranker(top_n=0) | ||
|
|
||
| def test_score_count_mismatch_rejected(self) -> None: | ||
| with self.assertRaises(RerankerError): | ||
| make_reranker(score_fn=lambda pairs: [0.5]).rerank("q", make_audit()) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| """Module C.2 — cross-encoder reranker (Week 4). The careful re-reader. | ||
|
|
||
| C.1 (the bi-encoder, W3) fingerprints the section and each CRE *separately* and | ||
| cosine-ranks the whole hub — fast enough to scan every CRE, but it never reads a | ||
| section and a candidate *together*, so the ordering inside the top-K shortlist is | ||
| rough: the right CRE can sit at #7, not #1. C.2 fixes that ordering. It reads | ||
| each ``(section text, candidate CRE text)`` *pair* together as one input, scores | ||
| "do these two actually match?", re-sorts the shortlist by that score, and keeps | ||
| the best N. Slow per pair, so it runs only over the K candidates C.1 already | ||
| narrowed to — never the whole hub. | ||
|
|
||
| Like C.1, the reranker is a thin dependency-injected seam over its model: | ||
|
|
||
| - ``score_fn(pairs) -> Sequence[float]`` — scores a batch of | ||
| ``(query_text, candidate_text)`` pairs, higher = better match. Prod wires a | ||
| pinned cross-encoder (``ms-marco-MiniLM-L-6-v2``); the harness and tests | ||
| inject a deterministic stub. C.2 never imports the model directly, so it | ||
| stays import-light and hermetically testable (mirrors C.1's ``embed_fn``). | ||
|
|
||
| The text a candidate CRE is scored against is its ``embeddings_content`` — the | ||
| same signal the hub vectors were built from — supplied as a ``{cre_id -> text}`` | ||
| map. The RFC is silent on ranking tech; it mandates only the | ||
| ``candidates[]``/``reranked[]`` audit trail. C.2 fills ``reranked[]`` (the slot | ||
| C.1 deliberately left empty), populating ``score_rerank`` and re-ordering, while | ||
| leaving ``candidates[]`` untouched so the pre-rerank shortlist stays auditable. | ||
| """ | ||
|
|
||
| from typing import Callable, List, Mapping, Sequence, Tuple | ||
|
|
||
| from application.utils.librarian.schemas import RetrievalAudit | ||
|
|
||
| # A function that scores a batch of (query_text, candidate_text) pairs. | ||
| RerankFn = Callable[[Sequence[Tuple[str, str]]], Sequence[float]] | ||
|
|
||
| # Identify the reranker in the RFC audit trail. Bumped when the model or the | ||
| # scoring changes so a stored proposal is traceable to the code that ranked it. | ||
| RERANKER_NAME = "cross-encoder-ms-marco-MiniLM-L-6-v2/0.1.0" | ||
| DEFAULT_CROSSENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" | ||
|
|
||
|
|
||
| class RerankerError(ValueError): | ||
| """Base class for reranker construction/usage failures.""" | ||
|
|
||
|
|
||
| class MissingCandidateTextError(RerankerError): | ||
| """A shortlisted CRE has no text to score against — cannot rerank fairly.""" | ||
|
|
||
|
|
||
| class CrossEncoderReranker: | ||
| """Re-score a C.1 shortlist by reading each (section, CRE) pair together. | ||
|
|
||
| ``top_n`` is ``CRE_LIBRARIAN_TOP_K_RERANK`` (default 5). ``rerank`` reads | ||
| ``audit.candidates`` (C.1's shortlist), scores every pair, re-sorts by the | ||
| cross-encoder score, keeps the best ``top_n``, and returns a copy of the | ||
| audit with ``reranked`` filled and ``candidates`` preserved. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| score_fn: RerankFn, | ||
| top_n: int, | ||
| *, | ||
| cre_texts: Mapping[str, str], | ||
| ) -> None: | ||
| if top_n <= 0: | ||
| raise RerankerError(f"top_n must be > 0, got {top_n}") | ||
| self._score_fn = score_fn | ||
| self._top_n = top_n | ||
| self._cre_texts = dict(cre_texts) | ||
|
|
||
| def rerank(self, text: str, audit: RetrievalAudit) -> RetrievalAudit: | ||
| """Return a copy of ``audit`` with ``reranked`` filled from ``candidates``. | ||
|
|
||
| Raises ``MissingCandidateTextError`` if a shortlisted CRE has no text to | ||
| score (a silent-quality trap otherwise) and ``RerankerError`` if the | ||
| model returns the wrong number of scores. | ||
| """ | ||
| candidates = audit.candidates | ||
| if not candidates: | ||
| # Nothing to rerank (e.g. an empty hub upstream); keep the audit | ||
| # shape consistent — an explicit, empty reranked list. | ||
| return audit.model_copy(update={"reranked": []}) | ||
|
|
||
| pairs: List[Tuple[str, str]] = [] | ||
| for c in candidates: | ||
| cre_text = self._cre_texts.get(c.cre_id) | ||
| if not cre_text: | ||
| raise MissingCandidateTextError( | ||
| f"no text for candidate CRE {c.cre_id!r}; the reranker needs " | ||
| "the CRE's embeddings_content to score the pair" | ||
| ) | ||
| pairs.append((text, cre_text)) | ||
|
|
||
| scores = list(self._score_fn(pairs)) | ||
| if len(scores) != len(candidates): | ||
| raise RerankerError( | ||
| f"score_fn returned {len(scores)} scores for {len(candidates)} " | ||
| "candidates; the reranker expects exactly one score per pair" | ||
| ) | ||
|
|
||
| reranked = [ | ||
| c.model_copy(update={"score_rerank": float(s)}) | ||
| for c, s in zip(candidates, scores, strict=True) | ||
| ] | ||
| # Highest cross-encoder score first, then keep only the best top_n. | ||
| # Python's sort is stable, so ties preserve C.1's cosine order. | ||
| reranked.sort(key=lambda c: c.score_rerank, reverse=True) | ||
| return audit.model_copy(update={"reranked": reranked[: self._top_n]}) | ||
|
|
||
|
|
||
| def build_cross_encoder_score_fn( | ||
| model_name: str = DEFAULT_CROSSENCODER_MODEL, | ||
| ) -> RerankFn: | ||
| """Load a sentence-transformers CrossEncoder and adapt it to ``RerankFn``. | ||
|
|
||
| ``sentence_transformers`` (and torch) is imported lazily so this module — | ||
| and CI/tests that inject a stub score_fn — never need the heavy ML stack | ||
| loaded (mirrors C.1 keeping the embedding model out of module import). | ||
| """ | ||
| from sentence_transformers import CrossEncoder # lazy, heavy | ||
|
|
||
| model = CrossEncoder(model_name) | ||
|
|
||
| def score_fn(pairs: Sequence[Tuple[str, str]]) -> List[float]: | ||
| # CrossEncoder.predict takes a list of [a, b] pairs, returns an ndarray. | ||
| return [float(s) for s in model.predict([list(p) for p in pairs])] | ||
|
|
||
| return score_fn |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.