Merge pull request #529 from ggozad/fix/cross-encoder-activation
Keep cross-encoder rerank scores apart when they saturate
This commit is contained in:
commit
432c81149c
6 changed files with 64 additions and 12 deletions
|
|
@ -1,6 +1,10 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `cross-encoder` reranking no longer ties the scores of strongly-relevant candidates, which left their order to the sort. Scores remain 0-1.
|
||||||
|
|
||||||
## [0.73.0] - 2026-08-06
|
## [0.73.0] - 2026-08-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pyright>=1.1.407", "ruff>=0.14.10"]
|
dev = ["ty>=0.0.28", "ruff>=0.14.10"]
|
||||||
|
|
||||||
[tool.hatch.metadata]
|
[tool.hatch.metadata]
|
||||||
allow-direct-references = true
|
allow-direct-references = true
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import math
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from sentence_transformers import (
|
import torch
|
||||||
CrossEncoder, # pyright: ignore[reportMissingImports]
|
from sentence_transformers import CrossEncoder
|
||||||
)
|
|
||||||
except ImportError as e: # pragma: no cover
|
except ImportError as e: # pragma: no cover
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"sentence-transformers is not installed. Install it with "
|
"sentence-transformers is not installed. Install it with "
|
||||||
|
|
@ -30,7 +30,15 @@ class CrossEncoderReranker(RerankerBase):
|
||||||
self, query: str, chunks: list[Chunk], top_n: int = 10
|
self, query: str, chunks: list[Chunk], top_n: int = 10
|
||||||
) -> list[tuple[Chunk, float]]:
|
) -> list[tuple[Chunk, float]]:
|
||||||
documents = [chunk.content for chunk in chunks]
|
documents = [chunk.content for chunk in chunks]
|
||||||
|
# Ask for logits and squash them here: the model's own sigmoid runs in
|
||||||
|
# bf16, where saturated scores round onto identical values and leave the
|
||||||
|
# order of the top candidates to the sort.
|
||||||
rankings = await asyncio.to_thread(
|
rankings = await asyncio.to_thread(
|
||||||
lambda: self._reranker.rank(query, documents, top_k=top_n)
|
lambda: self._reranker.rank(
|
||||||
|
query, documents, top_k=top_n, activation_fn=torch.nn.Identity()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return [(chunks[r["corpus_id"]], float(r["score"])) for r in rankings]
|
return [
|
||||||
|
(chunks[r["corpus_id"]], 1.0 / (1.0 + math.exp(-r["score"])))
|
||||||
|
for r in rankings
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from transformers import (
|
from transformers import AutoModel
|
||||||
AutoModel, # pyright: ignore[reportMissingImports]
|
|
||||||
)
|
|
||||||
except ImportError as e: # pragma: no cover
|
except ImportError as e: # pragma: no cover
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"transformers is not installed. Please install it with `pip install transformers torch` "
|
"transformers is not installed. Please install it with `pip install transformers torch` "
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
# Adapted from pydantic-ai: https://github.com/pydantic/pydantic-ai/blob/main/tests/json_body_serializer.py
|
# Adapted from pydantic-ai: https://github.com/pydantic/pydantic-ai/blob/main/tests/json_body_serializer.py
|
||||||
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
|
||||||
|
|
@ -481,16 +481,56 @@ async def test_cross_encoder_reranker():
|
||||||
pytest.skip("sentence-transformers not installed")
|
pytest.skip("sentence-transformers not installed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_cross_encoder_separates_saturated_scores():
|
||||||
|
"""`mxbai-rerank-base-v2` ships a Sigmoid it evaluates in bf16, where every
|
||||||
|
strongly-relevant candidate rounds to exactly 1.0. Squashing the logits
|
||||||
|
ourselves keeps them apart."""
|
||||||
|
try:
|
||||||
|
from haiku.rag.reranking.cross_encoder import CrossEncoderReranker
|
||||||
|
|
||||||
|
saturating = [
|
||||||
|
Chunk(content=content, document_id=str(i))
|
||||||
|
for i, content in enumerate(
|
||||||
|
[
|
||||||
|
"To Kill a Mockingbird is a novel by Harper Lee published in 1960.",
|
||||||
|
"Harper Lee wrote To Kill a Mockingbird, published in 1960.",
|
||||||
|
"The author of To Kill a Mockingbird is Harper Lee.",
|
||||||
|
"To Kill a Mockingbird, written by Harper Lee, appeared in 1960.",
|
||||||
|
"Harper Lee, author of To Kill a Mockingbird, won a Pulitzer Prize.",
|
||||||
|
"Harper Lee is the novelist who wrote To Kill a Mockingbird.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
reranker = CrossEncoderReranker("mixedbread-ai/mxbai-rerank-base-v2")
|
||||||
|
reranked = await reranker.rerank(
|
||||||
|
"Who wrote 'To Kill a Mockingbird'?", saturating, top_n=len(saturating)
|
||||||
|
)
|
||||||
|
|
||||||
|
scores = [score for chunk, score in reranked]
|
||||||
|
assert len(set(scores)) == len(scores)
|
||||||
|
assert all(0.0 < score < 1.0 for score in scores)
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("sentence-transformers not installed")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cross_encoder_reranks_via_model_ranking(monkeypatch):
|
async def test_cross_encoder_reranks_via_model_ranking(monkeypatch):
|
||||||
"""The rank() results map back onto the input chunks by corpus_id."""
|
"""The rank() results map back onto the input chunks by corpus_id."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
from haiku.rag.reranking import cross_encoder as ce_module
|
from haiku.rag.reranking import cross_encoder as ce_module
|
||||||
|
|
||||||
class _StubCrossEncoder:
|
class _StubCrossEncoder:
|
||||||
def __init__(self, model):
|
def __init__(self, model):
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
def rank(self, query, documents, top_k=10):
|
def rank(self, query, documents, top_k=10, activation_fn=None):
|
||||||
|
self.activation_fn = activation_fn
|
||||||
# Reverse order so the mapping back to chunks is observable.
|
# Reverse order so the mapping back to chunks is observable.
|
||||||
return [
|
return [
|
||||||
{"corpus_id": i, "score": 1.0 - (i / 10)}
|
{"corpus_id": i, "score": 1.0 - (i / 10)}
|
||||||
|
|
@ -505,4 +545,7 @@ async def test_cross_encoder_reranks_via_model_ranking(monkeypatch):
|
||||||
assert len(reranked) == 2
|
assert len(reranked) == 2
|
||||||
last_index = len(chunks) - 1
|
last_index = len(chunks) - 1
|
||||||
assert reranked[0][0] is chunks[last_index]
|
assert reranked[0][0] is chunks[last_index]
|
||||||
assert reranked[0][1] == pytest.approx(1.0 - last_index / 10)
|
# The stub returns logits; the reranker squashes them itself.
|
||||||
|
stub_logit = 1.0 - last_index / 10
|
||||||
|
assert reranked[0][1] == pytest.approx(1.0 / (1.0 + math.exp(-stub_logit)))
|
||||||
|
assert isinstance(reranker._reranker.activation_fn, torch.nn.Identity)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue