`search_precedent_library` — הכלי שסוכני הכתיבה קוראים לו בפועל — לא חשף `source_kind` כלל, וכל שכבה מתחתיו נפלה לברירת-מחדל `external_upload`. התוצאה: `WHERE cl.source_kind = 'external_upload'` חתך 104 החלטות ועדות-ערר (29% מהקורפוס) עוד לפני הדירוג. ערר (חיפה) 83/16 — האסמכתה הישירה ביותר בקורפוס לטענת-סף ס-2 ב-1069-04-26 — לא הוחזרה גם בשאילתה כמעט-מילולית. ההיפותזה שנרשמה ב-#232 (הטיית verified/cite_count מדירה החלטות חדשות) **נבדקה ונפסלה**: כיסוי ה-verified כמעט זהה בשני הקורפוסים (55% מול 49%), ותקרת-ההטיה 0.22 לא הסבירה פער מול התאמה כמעט-מילולית. הסיבה הייתה סינום קשיח, לא דירוג. מה שונה - `_source_kind_clause()` — הגדרה אחת לאוצר-המילים של הסלקטור (G2), במקום שכפולו בכל אתר חיפוש/רשימה. `""`/`"all"` = כל הקורפוס; סלקטור לא-מוכר מרים ValueError במקום להגיע ל-SQL (הערך מוזרק ב-f-string, אז ה-whitelist הוא גם מה ששומר על זה בטוח). - ברירת-המחדל בכל שרשרת-החיפוש והרשימה: `""` = הקורפוס כולו. - `search_precedent_library` + `precedent_library_list` חושפים `source_kind` לסינון מפורש; `/api/precedent-library/search` מקבל אותו גם הוא כדי ש-UI ו-MCP לא יתפצלו. - נרמול מפריד במספר-תיק: `83/16` ו-`83-16` מחזירים את אותה שורה. אימות מול ה-DB החי — 4/4 החלטות ועדה חוזרות במקום **1** בשאילתה בלשון הלכה מאושרת (83-16, 1029-18, 1085-23, 1094-09-19 פדילה). 83/16 עלתה ל-0.711 מול 0.663 של 3213/97 שחסמה אותה קודם. רשימת-הקורפוס: 259 → 386. invariants: G1 (נרמול במקור, לא תיקון-תסמין בקריאה) · G2 (הגדרה אחת לסלקטור; UI ו-MCP על אותו מסלול) · §6 (סלקטור שגוי מתפוצץ, לא נבלע) טסטים: 5 חדשים (tests/test_source_kind_selector.py), אחד מהם נועל את ברירות-המחדל של 8 נקודות-הכניסה — זה בדיוק הבאג. 514 עוברים.
391 lines
14 KiB
Python
391 lines
14 KiB
Python
"""Hybrid (text + image) search wrappers.
|
||
|
||
Layered on top of ``rerank.maybe_rerank``. When ``MULTIMODAL_ENABLED`` is
|
||
true the result comes from a weighted merge of:
|
||
|
||
• text side: cosine on chunks → optional rerank-2 cross-encoder
|
||
(precedent search additionally fuses ``ts_rank_cd`` lexical results
|
||
via RRF before this step — see ``BM25_HYBRID_ENABLED``)
|
||
• image side: cosine on per-page voyage-multimodal-3 embeddings
|
||
|
||
rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed
|
||
through it; they keep their cosine score and merge alongside the
|
||
(possibly reranked) text rows. Image-only pages with no overlapping
|
||
text chunk are surfaced as ``match_type='image'`` so scanned-only or
|
||
visual-heavy content still appears in results.
|
||
|
||
When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain
|
||
``rerank.maybe_rerank`` — callers can wrap unconditionally and let env
|
||
control behaviour.
|
||
|
||
BM25/lexical leg (V12 + ``BM25_HYBRID_ENABLED``):
|
||
``search_precedent_library_hybrid`` runs ``search_precedent_library_lexical``
|
||
in parallel with the semantic side and fuses the two by rank via RRF.
|
||
This recovers exact-string recall (case-number citations like "1461/20",
|
||
rare planning terms) that voyage embeddings blur. The fused list is
|
||
then handed to rerank-2 (if enabled) and to the image RRF (if
|
||
multimodal is enabled) exactly as before.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from legal_mcp import config
|
||
from legal_mcp.services import db, embeddings, rerank
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def search_documents_hybrid(
|
||
query: str,
|
||
query_text_embedding: list[float],
|
||
*,
|
||
limit: int,
|
||
case_id: UUID | None = None,
|
||
section_type: str | None = None,
|
||
practice_area: str | None = None,
|
||
appeal_subtype: str | None = None,
|
||
) -> list[dict]:
|
||
"""Hybrid wrapper for document-chunk search (search_decisions /
|
||
search_case_documents / find_similar_cases)."""
|
||
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
|
||
text_results = await rerank.maybe_rerank(
|
||
query=query,
|
||
base_search=lambda **kw: db.search_similar(
|
||
query_embedding=query_text_embedding, **kw,
|
||
),
|
||
limit=fetch_k,
|
||
case_id=case_id,
|
||
section_type=section_type,
|
||
practice_area=practice_area,
|
||
appeal_subtype=appeal_subtype,
|
||
)
|
||
if not config.MULTIMODAL_ENABLED:
|
||
return text_results[:limit]
|
||
|
||
try:
|
||
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
||
img_rows = await db.search_document_images_similar(
|
||
query_img_emb,
|
||
limit=fetch_k,
|
||
case_id=case_id,
|
||
practice_area=practice_area,
|
||
appeal_subtype=appeal_subtype,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Hybrid: image side failed, returning text only: %s", e)
|
||
return text_results[:limit]
|
||
|
||
merged = _merge(
|
||
text_results, img_rows,
|
||
id_field="document_id",
|
||
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
|
||
)
|
||
return merged[:limit]
|
||
|
||
|
||
async def search_precedent_library_hybrid(
|
||
query: str,
|
||
query_text_embedding: list[float],
|
||
*,
|
||
limit: int,
|
||
practice_area: str = "",
|
||
court: str = "",
|
||
precedent_level: str = "",
|
||
appeal_subtype: str = "",
|
||
is_binding: bool | None = None,
|
||
subject_tag: str = "",
|
||
include_halachot: bool = True,
|
||
source_kind: str = "",
|
||
district: str = "",
|
||
chair_name: str = "",
|
||
max_per_case_law: int = 2,
|
||
) -> list[dict]:
|
||
"""Hybrid wrapper for precedent-library search.
|
||
|
||
source_kind='' → the whole corpus (default, #232)
|
||
source_kind='external_upload' → court rulings only
|
||
source_kind='internal_committee' → appeals-committee decisions only
|
||
max_per_case_law: MMR-style diversity cap — at most N hits per
|
||
case_law_id in the final ranked list (default 2). Prevents a
|
||
single precedent from monopolizing the result list when many of
|
||
its chunks/halachot are individually relevant.
|
||
|
||
When ``config.BM25_HYBRID_ENABLED`` is true (default) ``_base`` fuses
|
||
semantic cosine + lexical ``ts_rank_cd`` via RRF before handing the
|
||
candidates to rerank-2 (if enabled) and the image merge (if
|
||
multimodal is enabled).
|
||
"""
|
||
# Fetch deeper so diversity dedup still leaves enough candidates.
|
||
fetch_k = max(limit * max(max_per_case_law, 1), config.VOYAGE_RERANK_FETCH_K) \
|
||
if config.MULTIMODAL_ENABLED else max(limit * max(max_per_case_law, 1), limit)
|
||
|
||
async def _base(limit: int) -> list[dict]:
|
||
sem_rows = await db.search_precedent_library_semantic(
|
||
query_embedding=query_text_embedding,
|
||
practice_area=practice_area,
|
||
court=court,
|
||
precedent_level=precedent_level,
|
||
appeal_subtype=appeal_subtype,
|
||
is_binding=is_binding,
|
||
subject_tag=subject_tag,
|
||
limit=limit,
|
||
include_halachot=include_halachot,
|
||
source_kind=source_kind,
|
||
district=district,
|
||
chair_name=chair_name,
|
||
)
|
||
if not config.BM25_HYBRID_ENABLED:
|
||
return sem_rows
|
||
# Fetch lexical with ≥ 2× depth so RRF has reserves at the tail.
|
||
lex_limit = max(limit * 2, limit)
|
||
try:
|
||
lex_rows = await db.search_precedent_library_lexical(
|
||
query=query,
|
||
practice_area=practice_area,
|
||
court=court,
|
||
precedent_level=precedent_level,
|
||
appeal_subtype=appeal_subtype,
|
||
is_binding=is_binding,
|
||
subject_tag=subject_tag,
|
||
source_kind=source_kind,
|
||
district=district,
|
||
chair_name=chair_name,
|
||
limit=lex_limit,
|
||
include_halachot=include_halachot,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"Hybrid precedent: lexical side failed, semantic only: %s", e,
|
||
)
|
||
return sem_rows
|
||
if not lex_rows:
|
||
return sem_rows
|
||
return _merge_sem_lex(sem_rows, lex_rows, limit=limit)
|
||
|
||
text_results = await rerank.maybe_rerank(
|
||
query=query, base_search=_base, limit=fetch_k,
|
||
)
|
||
if not config.MULTIMODAL_ENABLED:
|
||
return _diversify_by_case_law(text_results, limit, max_per_case_law)
|
||
|
||
try:
|
||
query_img_emb = await embeddings.embed_query_for_multimodal(query)
|
||
img_rows = await db.search_precedent_images_similar(
|
||
query_img_emb,
|
||
limit=fetch_k,
|
||
practice_area=practice_area,
|
||
court=court,
|
||
precedent_level=precedent_level,
|
||
appeal_subtype=appeal_subtype,
|
||
is_binding=is_binding,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Hybrid: image side failed, returning text only: %s", e)
|
||
return _diversify_by_case_law(text_results, limit, max_per_case_law)
|
||
|
||
merged = _merge(
|
||
text_results, img_rows,
|
||
id_field="case_law_id",
|
||
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
|
||
)
|
||
return _diversify_by_case_law(merged, limit, max_per_case_law)
|
||
|
||
|
||
def _diversify_by_case_law(
|
||
rows: list[dict],
|
||
limit: int,
|
||
max_per_case_law: int,
|
||
) -> list[dict]:
|
||
"""MMR-style diversity cap: at most ``max_per_case_law`` rows per
|
||
case_law_id in the final list. Preserves input order (which is the
|
||
relevance ranking) — for each row, include it only if we haven't
|
||
reached the cap for its case_law_id yet.
|
||
|
||
Set max_per_case_law<=0 to disable (returns rows[:limit] unchanged).
|
||
"""
|
||
if max_per_case_law <= 0 or not rows:
|
||
return rows[:limit]
|
||
counts: dict[str, int] = {}
|
||
out: list[dict] = []
|
||
for r in rows:
|
||
clid = str(r.get("case_law_id") or "")
|
||
if not clid:
|
||
out.append(r)
|
||
if len(out) >= limit:
|
||
break
|
||
continue
|
||
n = counts.get(clid, 0)
|
||
if n < max_per_case_law:
|
||
out.append(r)
|
||
counts[clid] = n + 1
|
||
if len(out) >= limit:
|
||
break
|
||
return out
|
||
|
||
|
||
def _row_key(r: dict) -> tuple[str, str]:
|
||
"""Stable identity for sem/lex RRF.
|
||
|
||
Halachot rows have ``halacha_id``; chunk rows have ``chunk_id``.
|
||
Returns ``(type, id)`` so a halacha and a chunk with the same UUID
|
||
(extremely unlikely, but distinct namespaces) don't collide.
|
||
"""
|
||
typ = str(r.get("type") or "")
|
||
rid = r.get("halacha_id") if typ == "halacha" else r.get("chunk_id")
|
||
return (typ, str(rid or ""))
|
||
|
||
|
||
def _merge_sem_lex(
|
||
sem_rows: list[dict],
|
||
lex_rows: list[dict],
|
||
*,
|
||
limit: int,
|
||
) -> list[dict]:
|
||
"""RRF fusion of semantic + lexical precedent results.
|
||
|
||
Why RRF (and not weighted score sum): cosine similarities (~0.4-0.7)
|
||
and ``ts_rank_cd`` values (often 0.001-0.5, query-length-dependent)
|
||
live on completely different scales — a weighted sum would let one
|
||
side dominate by accident. RRF combines by *rank*, so a row that
|
||
tops one list and is mid-pack in the other gets a robust boost.
|
||
|
||
Per row::
|
||
|
||
rrf_score = 1 / (k + sem_rank) + 1 / (k + lex_rank)
|
||
|
||
A row that appears in only one list contributes that list's term
|
||
only. Output is sorted by combined score, with extra debug fields
|
||
(``sem_score``, ``sem_rank``, ``lex_score``, ``lex_rank``) attached
|
||
so callers and tests can inspect why a row ranked where it did.
|
||
|
||
The row payload (``content``, ``rule_statement``, ``case_*`` joins,
|
||
etc.) is taken from the semantic-side row when available — the two
|
||
sources return identical column shapes, but semantic rows carry the
|
||
confidence-boosted ``score`` that the rest of the pipeline expects.
|
||
"""
|
||
k = config.MULTIMODAL_RRF_K
|
||
sem_rank_by_key: dict[tuple, int] = {}
|
||
sem_row_by_key: dict[tuple, dict] = {}
|
||
for rank, r in enumerate(sem_rows, 1):
|
||
key = _row_key(r)
|
||
if not key[1]:
|
||
continue
|
||
sem_rank_by_key[key] = rank
|
||
sem_row_by_key[key] = r
|
||
|
||
lex_rank_by_key: dict[tuple, int] = {}
|
||
lex_row_by_key: dict[tuple, dict] = {}
|
||
for rank, r in enumerate(lex_rows, 1):
|
||
key = _row_key(r)
|
||
if not key[1]:
|
||
continue
|
||
lex_rank_by_key[key] = rank
|
||
lex_row_by_key[key] = r
|
||
|
||
all_keys = set(sem_rank_by_key) | set(lex_rank_by_key)
|
||
merged: list[dict] = []
|
||
for key in all_keys:
|
||
sem_rank = sem_rank_by_key.get(key)
|
||
lex_rank = lex_rank_by_key.get(key)
|
||
base = sem_row_by_key.get(key) or lex_row_by_key.get(key)
|
||
if base is None:
|
||
continue
|
||
d = dict(base)
|
||
sem_term = 1.0 / (k + sem_rank) if sem_rank else 0.0
|
||
lex_term = 1.0 / (k + lex_rank) if lex_rank else 0.0
|
||
d["sem_score"] = float(sem_row_by_key[key]["score"]) \
|
||
if key in sem_row_by_key else 0.0
|
||
d["sem_rank"] = sem_rank or 0
|
||
d["lex_score"] = float(lex_row_by_key[key]["score"]) \
|
||
if key in lex_row_by_key else 0.0
|
||
d["lex_rank"] = lex_rank or 0
|
||
d["score"] = sem_term + lex_term
|
||
merged.append(d)
|
||
|
||
merged.sort(key=lambda x: -float(x["score"]))
|
||
return merged[:limit]
|
||
|
||
|
||
def _merge(
|
||
text_rows: list[dict],
|
||
img_rows: list[dict],
|
||
id_field: str,
|
||
text_weight: float,
|
||
) -> list[dict]:
|
||
"""Reciprocal Rank Fusion of text + image rows.
|
||
|
||
Why RRF: voyage-3 cosine scores (~0.4-0.5) and voyage-multimodal-3
|
||
scores (~0.2-0.25) live on different scales — a direct weighted
|
||
sum lets text always dominate. RRF combines by *rank* in each list,
|
||
making the merge robust to score-scale differences.
|
||
|
||
Per item::
|
||
|
||
rrf_score = text_weight / (k + text_rank)
|
||
+ image_weight / (k + image_rank)
|
||
|
||
A row that appears in only one list contributes that list's term
|
||
only. Rows joined at ``(id_field, page_number)`` get both terms —
|
||
surfaced as ``match_type='text+image'`` with the thumbnail attached.
|
||
|
||
Halachot in precedent rows have no page_number; they remain
|
||
text-only under RRF (the case-level image boost is dropped — RRF
|
||
works on rank, not raw scores).
|
||
"""
|
||
from legal_mcp import config as _cfg
|
||
img_weight = 1.0 - text_weight
|
||
k = _cfg.MULTIMODAL_RRF_K
|
||
|
||
# Index image rows by their join key for boost detection.
|
||
img_rank_by_key: dict[tuple, int] = {}
|
||
img_row_by_key: dict[tuple, dict] = {}
|
||
for rank, r in enumerate(img_rows, 1):
|
||
key = (str(r[id_field]), r.get("page_number"))
|
||
img_rank_by_key[key] = rank
|
||
img_row_by_key[key] = r
|
||
|
||
seen_image_keys: set = set()
|
||
merged: list[dict] = []
|
||
for rank, r in enumerate(text_rows, 1):
|
||
rid = str(r[id_field])
|
||
page = r.get("page_number")
|
||
key = (rid, page) if page is not None else None
|
||
img_rank = img_rank_by_key.get(key) if key else None
|
||
text_term = text_weight / (k + rank)
|
||
image_term = img_weight / (k + img_rank) if img_rank else 0.0
|
||
d = dict(r)
|
||
d["text_score"] = float(r.get("score", 0.0))
|
||
d["text_rank"] = rank
|
||
if img_rank:
|
||
img_hit = img_row_by_key[key]
|
||
d["image_score"] = float(img_hit.get("score", 0.0))
|
||
d["image_rank"] = img_rank
|
||
d["image_thumbnail_path"] = img_hit.get("image_thumbnail_path")
|
||
d["match_type"] = "text+image"
|
||
seen_image_keys.add(key)
|
||
else:
|
||
d["image_score"] = 0.0
|
||
d["match_type"] = "text"
|
||
d["score"] = text_term + image_term
|
||
merged.append(d)
|
||
|
||
for rank, r in enumerate(img_rows, 1):
|
||
key = (str(r[id_field]), r.get("page_number"))
|
||
if key in seen_image_keys:
|
||
continue
|
||
d = dict(r)
|
||
d["text_score"] = 0.0
|
||
d["image_score"] = float(r.get("score", 0.0))
|
||
d["image_rank"] = rank
|
||
d["score"] = img_weight / (k + rank)
|
||
d["match_type"] = "image"
|
||
d["content"] = ""
|
||
d["section_type"] = "image"
|
||
merged.append(d)
|
||
|
||
merged.sort(key=lambda x: -float(x["score"]))
|
||
return merged
|