fix(retrieval): סף מכויל-קוסינוס סינן פלט RRF — דף אימות-הפסיקה הציג אפס תקדימים
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

`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:
2026-08-05 13:31:12 +00:00
parent df60636876
commit 5a2a989e9b
4 changed files with 113 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]))