feat(llm): recency-blended re-ranking of retrieval hits
This commit is contained in:
66
src/llm/rerank.py
Normal file
66
src/llm/rerank.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Similarity × recency re-ranking over merged retrieval hits (pure).
|
||||
|
||||
pgvector returns cosine *distance* (0 = identical). We map it to a
|
||||
similarity in [0, 1], blend with an exponential recency decay, and keep
|
||||
the best chunk per item so one long comment can't fill the context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hit:
|
||||
text: str
|
||||
metadata: dict[str, str]
|
||||
distance: float
|
||||
score: float = 0.0
|
||||
|
||||
|
||||
def recency(when: str, *, now: date, half_life_days: float) -> float:
|
||||
"""1.0 for today (or future), halving every ``half_life_days``; 0.0
|
||||
when ``when`` is empty or unparsable."""
|
||||
try:
|
||||
d = date.fromisoformat(when[:10])
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
age = (now - d).days
|
||||
if age <= 0:
|
||||
return 1.0
|
||||
return 0.5 ** (age / half_life_days)
|
||||
|
||||
|
||||
def _similarity(distance: float) -> float:
|
||||
return min(1.0, max(0.0, 1.0 - distance))
|
||||
|
||||
|
||||
def blend(
|
||||
hits: list[Hit],
|
||||
*,
|
||||
weight: float,
|
||||
half_life_days: float,
|
||||
now: date,
|
||||
top_n: int,
|
||||
) -> list[Hit]:
|
||||
"""Score, dedupe per ``item_key`` (best chunk wins), sort desc, cut."""
|
||||
best: dict[str, Hit] = {}
|
||||
for h in hits:
|
||||
score = (1.0 - weight) * _similarity(h.distance) + weight * recency(
|
||||
h.metadata.get("date", ""), now=now, half_life_days=half_life_days
|
||||
)
|
||||
scored = replace(h, score=score)
|
||||
key = h.metadata.get("item_key", "") or str(id(h))
|
||||
if key not in best or scored.score > best[key].score:
|
||||
best[key] = scored
|
||||
ranked = sorted(best.values(), key=lambda h: h.score, reverse=True)
|
||||
return ranked[:top_n]
|
||||
|
||||
|
||||
def filter_since(hits: list[Hit], since: str) -> list[Hit]:
|
||||
"""Keep hits dated on/after ``since`` (ISO date); undated hits are
|
||||
dropped when a cutoff is active. Empty ``since`` is a no-op."""
|
||||
if not since:
|
||||
return hits
|
||||
return [h for h in hits if h.metadata.get("date", "")[:10] >= since[:10]]
|
||||
85
tests/llm/test_rerank.py
Normal file
85
tests/llm/test_rerank.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""llm.rerank — similarity × recency blend, pure."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from llm.rerank import Hit, blend, filter_since, recency
|
||||
|
||||
NOW = date(2026, 9, 3)
|
||||
|
||||
|
||||
def _hit(key, distance, when, kind="comment"):
|
||||
return Hit(
|
||||
text=f"text {key}",
|
||||
metadata={"item_key": key, "date": when, "kind": kind},
|
||||
distance=distance,
|
||||
)
|
||||
|
||||
|
||||
class TestRecency:
|
||||
def test_today_is_one(self):
|
||||
assert recency("2026-09-03", now=NOW, half_life_days=365) == pytest.approx(1.0)
|
||||
|
||||
def test_one_half_life_is_half(self):
|
||||
assert recency("2025-09-03", now=NOW, half_life_days=365) == pytest.approx(0.5)
|
||||
|
||||
def test_future_dates_clamp_to_one(self):
|
||||
assert recency("2027-01-01", now=NOW, half_life_days=365) == 1.0
|
||||
|
||||
def test_undated_or_garbage_is_zero(self):
|
||||
assert recency("", now=NOW, half_life_days=365) == 0.0
|
||||
assert recency("not a date", now=NOW, half_life_days=365) == 0.0
|
||||
|
||||
def test_datetime_prefix_accepted(self):
|
||||
assert recency("2026-09-03T12:00:00Z", now=NOW, half_life_days=365) == 1.0
|
||||
|
||||
|
||||
class TestBlend:
|
||||
def test_recent_beats_slightly_closer_old_hit(self):
|
||||
old = _hit("OLD", distance=0.20, when="2019-01-01")
|
||||
new = _hit("NEW", distance=0.25, when="2026-08-19")
|
||||
out = blend([old, new], weight=0.3, half_life_days=365, now=NOW, top_n=8)
|
||||
assert [h.metadata["item_key"] for h in out] == ["NEW", "OLD"]
|
||||
assert out[0].score > out[1].score
|
||||
|
||||
def test_weight_zero_is_pure_similarity(self):
|
||||
old = _hit("OLD", distance=0.20, when="2019-01-01")
|
||||
new = _hit("NEW", distance=0.25, when="2026-08-19")
|
||||
out = blend([old, new], weight=0.0, half_life_days=365, now=NOW, top_n=8)
|
||||
assert [h.metadata["item_key"] for h in out] == ["OLD", "NEW"]
|
||||
|
||||
def test_dedupes_per_item_keeping_best_chunk(self):
|
||||
a1 = _hit("A", distance=0.30, when="2026-01-01")
|
||||
a2 = _hit("A", distance=0.10, when="2026-01-01")
|
||||
b = _hit("B", distance=0.20, when="2026-01-01")
|
||||
out = blend([a1, a2, b], weight=0.3, half_life_days=365, now=NOW, top_n=8)
|
||||
assert [(h.metadata["item_key"], h.distance) for h in out] == [
|
||||
("A", 0.10),
|
||||
("B", 0.20),
|
||||
]
|
||||
|
||||
def test_top_n_truncates(self):
|
||||
hits = [_hit(f"K{i}", 0.1 * i, "2026-01-01") for i in range(5)]
|
||||
assert len(blend(hits, weight=0.3, half_life_days=365, now=NOW, top_n=2)) == 2
|
||||
|
||||
def test_distance_clamped_into_unit_range(self):
|
||||
far = _hit("F", distance=1.7, when="")
|
||||
(out,) = blend([far], weight=0.0, half_life_days=365, now=NOW, top_n=1)
|
||||
assert out.score == 0.0
|
||||
|
||||
|
||||
class TestFilterSince:
|
||||
def test_keeps_on_or_after_and_drops_undated(self):
|
||||
hits = [
|
||||
_hit("A", 0.1, "2025-12-31"),
|
||||
_hit("B", 0.1, "2026-01-01"),
|
||||
_hit("C", 0.1, ""),
|
||||
]
|
||||
assert [h.metadata["item_key"] for h in filter_since(hits, "2026-01-01")] == [
|
||||
"B"
|
||||
]
|
||||
|
||||
def test_empty_since_is_noop(self):
|
||||
hits = [_hit("A", 0.1, ""), _hit("B", 0.1, "2020-01-01")]
|
||||
assert filter_since(hits, "") == hits
|
||||
Reference in New Issue
Block a user