fix(retrieval): סף מכויל-קוסינוס סינן פלט RRF — דף אימות-הפסיקה הציג אפס תקדימים
`hybrid_search._merge_sem_lex` דורס את `score` בערך RRF (~0.008–0.02) ברגע
שה-leg הלקסיקלי מחזיר שורות. `score` הוא אות-דירוג לגיטימי, אבל הוא מפסיק
להיות קוסינוס — ואילו `case_citation_verification._SUGGEST_FLOOR = 0.45`
כויל לקוסינוס. התוצאה: **כל שאילתה במונחים משפטיים נפוצים סוננה עד
האחרונה**, והדף הציג "אין תקדים תומך" לכל טיעון בכל תיק.
אותה פונקציה החזירה שתי סקאלות, תלוי בשאילתה:
"המונח חזית הבניין יפורש..." leg לקסיקלי ריק → 0.63–0.71 (קוסינוס)
"סמכות ועדה מקומית לפי 62א" leg לקסיקלי מלא → 0.016 (RRF)
מה שונה
- **עוגן קוסינוס במקור (G1):** `db.search_precedent_library_semantic` מסמן
`relevance` לצד `score`. **לא** בפונקציה הלקסיקלית — שם `ts_rank_cd`,
ולסמן אותו כ-relevance היה חוזר על אותה טעות בדיוק.
- **הפיוז'ן משמר:** `_merge_sem_lex` מעביר את הקוסינוס הלאה. שורה
לקסיקלית-בלבד מקבלת `relevance = None` — "לא נמדד" אינו "נמדד כלא-רלוונטי".
- **הצרכן קורא `relevance`:** `_passes_floor()` במקום השוואה ל-`score`.
שורות לקסיקליות-בלבד **נשמרות במודע** — הן הגיעו לצמרת בדירוג BM25 בתוך
top-k זעיר, וסינונן היה מחביא בדיוק את התאמות-הביטוי ומספרי-התיק שהיו"ר
מחפש בשם.
הדירוג לא השתנה: RRF ממשיך לקבוע סדר. רק הסינון עבר לסקאלה יציבה.
אימות מול הקורפוס החי:
8124-09-24: 0 → **32 מתוך 32** טיעונים עם תקדים תומך (21.5 שנ', שלם)
1069-04-26: 0 → **29 מתוך 69** (24.1 שנ', חלקי — תקציב הזמן)
שורה סמנטית: score=0.0082 · relevance=0.7297
היומונים לא נפגעו ולא נגעתי בהם: `case_digest_radar` עובר דרך
`search_digests_semantic` — סמנטי טהור, בלי RRF, ולכן `min_score=0.45` שלו
מכויל נכון.
invariants: G1 (עוגן במקור, לא תיקון-סף בקריאה) · G2 (הגדרה אחת ל-relevance
לכל הצרכנים) · INV-AH (היעדר-מדידה אינו היעדר-רלוונטיות)
טסטים: 6 חדשים (tests/test_relevance_scale.py) — אחד מהם מוכיח את הבאג
ואת התיקון באותה שורה. 531 עוברים.
This commit is contained in:
@@ -48,6 +48,25 @@ _SUGGEST_FLOOR = 0.45
|
||||
#: deadlocks. Bounding the fan-out both fixes the failure and speeds it up.
|
||||
_MAX_CONCURRENT_LOOKUPS = 8
|
||||
|
||||
def _passes_floor(hit: dict) -> bool:
|
||||
"""Is this hit similar enough to the argument to suggest to the chair?
|
||||
|
||||
Reads ``relevance`` (always a cosine similarity), NOT ``score`` — ``score``
|
||||
becomes a rank-fusion value (~0.008-0.02) as soon as the lexical leg returns
|
||||
rows, and comparing that against a cosine-calibrated floor rejected every
|
||||
hit. That is why this tab showed no supporting precedent for any argument.
|
||||
|
||||
``relevance is None`` means the row came from the lexical leg only, so no
|
||||
cosine was ever computed. It is KEPT: it earned its place by BM25 rank
|
||||
inside an already-tiny top-k, and dropping it would silently hide exact
|
||||
phrase/docket matches — the very hits a chair searches for by name.
|
||||
"""
|
||||
rel = hit.get("relevance")
|
||||
if rel is None:
|
||||
return True
|
||||
return float(rel) >= _SUGGEST_FLOOR
|
||||
|
||||
|
||||
#: Wall-clock ceiling for the whole retrieval phase. The proxy gives up at 30s;
|
||||
#: cutting ourselves off earlier lets us return the suggestions that DID land
|
||||
#: instead of a 500 that shows the chair nothing. Partial results are labelled
|
||||
@@ -103,7 +122,7 @@ async def build_view(case_number: str) -> dict:
|
||||
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||
# Resolve the authority breakdown for the hit set in one batched query.
|
||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||
if h.get("case_law_id") and _passes_floor(h)]
|
||||
authority = await db.citation_authority(clids) if clids else {}
|
||||
return hits, authority
|
||||
|
||||
@@ -157,7 +176,7 @@ async def build_view(case_number: str) -> dict:
|
||||
clid = str(h.get("case_law_id") or "")
|
||||
if not clid or clid in seen:
|
||||
continue
|
||||
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
|
||||
if not _passes_floor(h):
|
||||
continue
|
||||
seen.add(clid)
|
||||
att = attached_by_arg.get(aid, {}).get(clid)
|
||||
|
||||
@@ -7979,6 +7979,14 @@ async def search_precedent_library_semantic(
|
||||
# Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
|
||||
_conf = float(d.get("confidence") or 0.0)
|
||||
d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
|
||||
# Stable cosine-scale relevance, carried alongside ``score``.
|
||||
# ``score`` is the RANKING signal and downstream fusion overwrites it
|
||||
# with an RRF value (~0.008-0.02) whenever the lexical leg returns
|
||||
# rows — a different scale entirely. Anything that THRESHOLDS must
|
||||
# read ``relevance`` instead, which always means "cosine similarity
|
||||
# to the query" no matter which fusion stages ran. See
|
||||
# hybrid_search._merge_sem_lex.
|
||||
d["relevance"] = d["score"]
|
||||
d["type"] = "halacha"
|
||||
# authority is DERIVED from the source, never stored (INV-DM7)
|
||||
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
|
||||
@@ -7990,6 +7998,7 @@ async def search_precedent_library_semantic(
|
||||
if d.get("decision_date") is not None:
|
||||
d["decision_date"] = d["decision_date"].isoformat()
|
||||
d["score"] = float(d["score"])
|
||||
d["relevance"] = d["score"] # cosine anchor — see the halacha branch above
|
||||
d["type"] = "passage"
|
||||
_maybe_swap_parent(d)
|
||||
results.append(d)
|
||||
|
||||
@@ -303,6 +303,21 @@ def _merge_sem_lex(
|
||||
if key in lex_row_by_key else 0.0
|
||||
d["lex_rank"] = lex_rank or 0
|
||||
d["score"] = sem_term + lex_term
|
||||
# ``score`` is now an RRF value (~0.008-0.02), NOT a cosine. Carry the
|
||||
# cosine forward under ``relevance`` so thresholding callers keep a
|
||||
# stable scale — without it, a caller comparing score >= 0.45 silently
|
||||
# drops every result the moment the lexical leg returns anything, which
|
||||
# is exactly how the citation-verification tab ended up showing no
|
||||
# supporting precedent for any argument.
|
||||
#
|
||||
# A lexical-only row has no cosine at all. It gets ``relevance = None``
|
||||
# rather than 0.0: "we did not measure this" is not "measured as
|
||||
# irrelevant", and the row earned its place by BM25 rank. Callers decide
|
||||
# (see case_citation_verification) — but they must decide knowingly.
|
||||
if key in sem_row_by_key:
|
||||
d["relevance"] = float(sem_row_by_key[key].get("relevance", d["sem_score"]))
|
||||
else:
|
||||
d["relevance"] = None
|
||||
merged.append(d)
|
||||
|
||||
merged.sort(key=lambda x: -float(x["score"]))
|
||||
|
||||
Reference in New Issue
Block a user