Files
legal-ai/docs/voyage-upgrades-plan.md
Chaim 26c3fddf41
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
feat(retrieval): add voyage rerank-2 cross-encoder stage (feature flag)
Stage B of voyage-upgrades-plan rewritten: instead of context-3 (which
4 POCs showed inconsistent improvement), add a cross-encoder rerank
layer on top of voyage-3. Default off (VOYAGE_RERANK_ENABLED=false).

POC validation (785-doc corpus, 12 queries, claude-haiku-4-5 judge):
- mean@3 +4.5% (4.306 → 4.500)
- practical-category queries +11.6% (3.78 → 4.22)
- latency +702ms per query
- no schema change, no re-embed, no double storage

Plumbing:
- config: VOYAGE_RERANK_ENABLED / _MODEL / _FETCH_K env vars
- embeddings.voyage_rerank() wraps voyageai client.rerank
- services/rerank.py: maybe_rerank() helper — fetches FETCH_K candidates
  via the bi-encoder then reranks to top-K. Fail-open if Voyage rerank is
  unavailable.
- tools/search.py: search_decisions, search_case_documents,
  find_similar_cases all wrapped
- services/precedent_library.search_library wrapped

Smoke-tested locally with flag on/off — produces expected behaviour and
latency profile. Ready for production rollout via Coolify env flip after
deploy.

POCs (kept under scripts/ for reference):
- voyage_context3_poc{_long}.py — context-3 evaluation (rejected)
- voyage_multimodal_poc.py — multimodal-3 (stage C, deferred)
- voyage_rerank_judge_poc.py — single-case rerank benchmark
- voyage_rerank_corpus_poc.py — full-corpus rerank validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:43:41 +00:00

344 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# שדרוגי Voyage — תכנית מפורטת
תכנית 3-שלבית לשדרוג שכבת ה-retrieval של עוזר משפטי. שלב A מבוצע
בתאריך התכנית; שלבים B ו-C ממתינים לשיחה החדשה.
**הקשר**: Voyage = חיפוש (find), Claude = הבנה+כתיבה (read+write). שני
המנועים מנותקים ארכיטקטונית — שינוי שכבת ה-retrieval לא משפיע על קלוד
עצמו, רק על איזה chunks מגיעים אליו לקריאה.
---
## שלב A — מעבר ל-voyage-3 (✅ מבוצע)
### למה voyage-3 ולא voyage-law-2?
Benchmark על 3 שאילתות עברית-משפטית עם passages אמיתיים מהקורפוס:
| מודל | Perfect orderings | Total Separation |
|---|---|---|
| **voyage-3** | **3/3** | **+0.483** |
| voyage-3.5 | 3/3 | +0.278 |
| voyage-law-2 *(היה)* | 3/3 | +0.238 |
| voyage-4 | 2/3 | +0.423 |
| voyage-4-large | 2/3 | +0.353 |
voyage-3 **מנצח כפול** — דירוג מושלם + מרווחים גדולים פי-2 מ-voyage-law-2.
מימד נשאר 1024 → אין שינוי schema.
### מה בוצע
1. **Coolify env**: `VOYAGE_MODEL=voyage-3` בקונטיינר
2. **Local env (`~/.env`)**: `VOYAGE_MODEL=voyage-3`
3. **Re-embed של 5 טבלאות** באמצעות `scripts/reembed_voyage.py`:
- `document_chunks` — מסמכי תיקים (~6K rows)
- `paragraph_embeddings` — קורפוס סגנון (כעת ריק)
- `case_law_embeddings` — stubs מצוטטים אוטו'
- `precedent_chunks` — פסיקה שהועלתה (~385)
- `halachot.embedding` — 400 הלכות (rule_statement + reasoning)
4. **MCP server restart** — טעינה מחדש של `embeddings.py` עם המודל החדש
### Verification
- `search_precedent_library` על "תכנית רחביה" → 403/17 holding ראשון
- `search_decisions` על "השבחה" → תוצאות עקביות
- ה-counts בטבלאות לא ירדו (כל row עודכן, לא נמחק)
### Rollback אם משהו נשבר
- `VOYAGE_MODEL=voyage-law-2` ב-Coolify + `~/.env`
- הרצה מחדש של `scripts/reembed_voyage.py` (חוזרים לקודם)
- 10 דקות סך-הכל
---
## שלב B — voyage-rerank-2 (Cross-encoder reranking)
> **שינוי מהותי מהתכנית המקורית.** המקור היה ל-context-3. POC רחב
> (4 בנצ'מרקים) הראה ש-context-3 לא משפר עקבית, ובחלק מהמקרים מציג
> רגרסיה. במקום זאת, **rerank-2** (cross-encoder) הצליח לתת שיפור של
> +4.5% mean@3 על קורפוס מלא של 785 docs, **+11.6% על שאילתות
> מעשיות** (P-category — בדיוק התרחיש של legal-writer/legal-researcher),
> בלי שינוי schema, בלי re-embed, ובלי double storage.
### למה rerank-2 ולא context-3?
POC #4 (אהרון ברק, 18 שאילתות, claude-haiku-4-5 כ-judge):
| Retriever | mean@3 | mean@5 | MRR |
|---|---|---|---|
| voyage-3 (baseline) | 3.278 | 3.300 | 0.741 |
| **voyage-3 + rerank-2** | **3.574** | **3.467** | **0.769** |
| voyage-context-3 (windowed) | 3.481 | 3.378 | 0.685 |
POC #5 (קורפוס מלא 785 docs, 12 שאילתות):
| Retriever | mean@3 | קטגוריה P (practical) |
|---|---|---|
| voyage-3 | 4.306 | 3.78 |
| **voyage-3 + rerank-2** | **4.500 (+4.5%)** | **4.22 (+11.6%)** |
context-3 גם נכשל בקטגוריות keyword שהן 60%+ מהשאילתות בפועל אצל דפנה.
### איך rerank-2 עובד
Two-stage retrieval:
1. **שלב bi-encoder (כמו היום)**: voyage-3 מטמיע את ה-query, מחזיר
top-50 chunks דרך cosine similarity על `pgvector` (מהיר, ~390ms).
2. **שלב cross-encoder (חדש)**: rerank-2 מקבל `(query, document)` עבור
כל אחד מ-50 הdocuments, ומחזיר ציון רלוונטיות מדויק יותר.
הreranker רואה את ה-query ואת ה-doc ביחד דרך attention מלא,
לעומת bi-encoder שרק מחשב cosine בין שני embeddings בלתי-תלויים.
3. החזרה: top-K (10) המדורגים מחדש.
**עלות**: +702ms latency (bi-encoder=393ms → +rerank=1095ms).
**עלות tokens**: zero לאחסון (רק חישוב per-query).
### תכנית יישום
#### B.1 — `voyage_rerank()` ב-`embeddings.py`
```python
async def voyage_rerank(
query: str, documents: list[str], top_k: int = 10,
) -> list[tuple[int, float]]:
"""Cross-encoder rerank via Voyage. Returns [(orig_index, score), ...]."""
if not documents:
return []
client = _get_client()
result = client.rerank(
query=query, documents=documents,
model=config.VOYAGE_RERANK_MODEL, # "rerank-2"
top_k=top_k,
)
return [(r.index, r.relevance_score) for r in result.results]
```
#### B.2 — Feature flag ב-`config.py`
```python
VOYAGE_RERANK_MODEL = os.environ.get("VOYAGE_RERANK_MODEL", "rerank-2")
VOYAGE_RERANK_ENABLED = (
os.environ.get("VOYAGE_RERANK_ENABLED", "false").lower() == "true"
)
VOYAGE_RERANK_FETCH_K = int(os.environ.get("VOYAGE_RERANK_FETCH_K", "50"))
```
הdefault הוא `false` — הקוד יישמר אך לא יורץ עד שיופעל ידנית.
#### B.3 — אינטגרציה ב-3 search functions
ב-`db.py`:
- `search_similar` (document_chunks) — נוסיף פרמטר `rerank: bool = False`.
אם True: שולפים top-`VOYAGE_RERANK_FETCH_K` במקום `limit`,
מעבירים דרך rerank, מחזירים top-`limit`.
- `search_precedent_library_semantic` — אותו דבר. הuance: היום יש
boost של +0.05 ל-halachot. כש-rerank פעיל, ה-boost מתבטל ו-rerank
מוחל על המאוחד (chunks + halachot ביחד) — cross-encoder יבחר נכון
בלי boost מלאכותי.
- `search_similar_paragraphs` / `search_similar_case_law` (ב-style
corpus) — אותו דבר.
ב-`tools/search.py` — כל הtools (`search_decisions`, `search_case_documents`,
`find_similar_cases`, `precedent_search_library`) יעבירו
`rerank=config.VOYAGE_RERANK_ENABLED` לקריאות ה-DB.
#### B.4 — Schema
אין שינוי. אותם vectors, אותו pgvector.
#### B.5 — Rollout
1. שינוי קוד + push + deploy עם feature flag = `false`
2. אימות ש-baseline ממשיך לעבוד (לא רגרסיה)
3. הפעלה ידנית: `VOYAGE_RERANK_ENABLED=true` ב-Coolify env
4. שאילתות אמיתיות מדפנה / סוכנים — observation
5. אם רגרסיה — kill switch בשניות (`false` בחזרה)
6. אם כל מתעקפם — להגדיר `true` כdefault (in-code) אחרי שבוע יציב
#### B.6 — Tier check
Voyage Tier 1: 2M TPM, 2000 RPM ל-rerank-2. עומס שלנו (~עשרות
queries בשעה במקרה רגיל) — מתחת ל-1% מהמכסה.
---
## שלב C — voyage-multimodal-3.5 (לביצוע בשיחה החדשה)
### הבעיה שהוא פותר
תיקים סרוקים ודוחות שמאי מאבדים מידע ב-OCR:
- ✗ פריסת טבלאות (שורות נתונים מתבלגנות)
- ✗ חתימות וחותמות
- ✗ דיאגרמות, מפות, תרשימים אדריכליים
- ✗ נוסחאות מתמטיות
OCR קיים (Google Cloud Vision) ממיר תמונות לטקסט אבל מטפל בעמוד כשורה-
אחר-שורה. תוצאה: בדוח שמאי "שווי לפני | שווי אחרי | ≈ 1.5M ש"ח" הופך
ל-"שווי לפני שווי אחרי 1.5M ש"ח" — חיפוש "שומה ל-1.5M" לא תמיד מוצא.
### מה voyage-multimodal-3.5 עושה
API: `client.multimodal_embed(inputs=[[image, text?], ...])`. מקבל
תמונה (PIL Image או URL) ומחזיר embedding שכולל:
- את הטקסט שעל העמוד
- את **המבנה הוויזואלי** (טבלה, חתימה, מיקומי גוש)
- תרשימים ודיאגרמות
Searchable יחד עם text embeddings — query טקסטואלית רגילה מוצאת גם
פסקאות עם טבלה רלוונטית.
### תכנית יישום
#### C.1 — Schema חדש
```sql
CREATE TABLE document_image_embeddings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
image_thumbnail_path TEXT, -- לסרגל תוצאות חיפוש
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_doc_img_emb_vec
ON document_image_embeddings USING ivfflat (embedding vector_cosine_ops);
CREATE TABLE precedent_image_embeddings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
image_thumbnail_path TEXT,
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_prec_img_emb_vec
ON precedent_image_embeddings USING ivfflat (embedding vector_cosine_ops);
```
#### C.2 — Pipeline שינוי
חדש ב-`extractor.py`:
```python
async def render_pages_as_images(pdf_path: str) -> list[bytes]:
"""PyMuPDF render of each page → PNG bytes for multimodal embedding."""
import fitz
doc = fitz.open(pdf_path)
images = []
for page in doc:
pix = page.get_pixmap(dpi=144) # decent resolution for embeddings
images.append(pix.tobytes("png"))
return images
```
חדש ב-`embeddings.py`:
```python
async def embed_images(images: list[bytes], input_type: str = "document") -> list[list[float]]:
"""Embed page images via voyage-multimodal-3.5."""
from PIL import Image
import io
pil_images = [Image.open(io.BytesIO(img)) for img in images]
response = _get_client().multimodal_embed(
inputs=[[img] for img in pil_images],
model="voyage-multimodal-3.5",
input_type=input_type,
)
return response.embeddings
```
#### C.3 — Integration ב-ingest pipelines
`processor.py:process_document` (תיק):
```python
# אחרי extract+chunk+embed הטקסטואלי:
images = await extractor.render_pages_as_images(file_path)
img_embs = await embeddings.embed_images(images)
await db.store_document_image_embeddings(document_id, img_embs, thumbnails)
```
`precedent_library.py:ingest_precedent`: אותו pattern, על
`precedent_image_embeddings`.
#### C.4 — Hybrid search
חדש ב-`db.py:search_precedent_library_hybrid`:
```python
async def search_precedent_library_hybrid(query, limit=10):
query_emb = await embeddings.embed_query(query)
query_img_emb = await embeddings.embed_query_for_multimodal(query)
text_results = ... # cosine on precedent_chunks (top 30)
image_results = ... # cosine on precedent_image_embeddings (top 30)
# Merge: weighted score (text 0.6, image 0.4 — tunable)
merged = {}
for r in text_results: merged[r.case_law_id] = r.score * 0.6
for r in image_results:
merged[r.case_law_id] = merged.get(r.case_law_id, 0) + r.score * 0.4
return sorted(merged.items(), key=lambda x: -x[1])[:limit]
```
#### C.5 — UI: thumbnails בתוצאות חיפוש
ב-`/precedents` חיפוש סמנטי, התוצאות עם רכיב image יציגו thumbnail
קטן של העמוד. לחיצה תפתח את ה-PDF במקום הרלוונטי.
#### C.6 — סדר עדיפויות לדיגום
1. **דוחות שמאי** — הזכייה הגדולה (טבלאות = ערכים מספריים שכרגע
הולכים לאיבוד ב-OCR)
2. **תיקים סרוקים ישנים** — שיפור ה-recall של חיפוש
3. **פסיקה עם דיאגרמות** (תרשימי גוש/חלקה) — minor
#### C.7 — עלות + tier
voyage-multimodal-3.5 הוא מוצר נפרד. בdoc'ים פר-עמוד:
- תיק ממוצע: 50-200 עמודים
- 100 תיקים = 5,000-20,000 עמודים
- Free tier: 200M tokens/month — אבל multimodal נמדד ב-tokens שונה
(התמונה צורכת ~1000-2000 tokens לעמוד)
הערכה: 100 תיקים × 100 עמודים × 1500 tokens = 15M tokens. בthe
free tier בקלות. צריך לבדוק תקרת שימוש בפועל בdocs של voyage.
#### C.8 — שלבים מומלצים
1. **POC** — תיק אחד עם דו"ח שמאי. embed → search → השוואה לתוצאות
טקסט-בלבד.
2. **A/B test** — חצי מהתיקים החדשים עם multimodal, חצי בלי. 4
שבועות בדיקה — האם דפנה מוצאת תוצאות מדויקות יותר?
3. **Rollout** — אם המבחן חיובי, לעבד את הקורפוס הקיים ברקע
### החלטות שנשארו פתוחות
- ✋ DPI לרינדור: 144 (סביר), 200 (איכות), 96 (מהיר)?
- ✋ נשמור thumbnails ב-disk או רק את ה-embeddings?
- ✋ משקלות hybrid search: 0.6/0.4 או יותר נטוי לטקסט?
---
## רצף עבודה בשיחה החדשה
> 1. פתחי `docs/voyage-upgrades-plan.md` (זה המסמך)
> 2. אם A הצליח (verify ב-Coolify env), נמשיך ל-B (context-3)
> 3. **B.5 קודם** — benchmark לפני re-embed גדול
> 4. אם B מצליח, רץ ל-C — אבל ב-2 צעדים זהירים (POC → A/B → rollout)
---
## נספח: רשימה של קבצים שנגעו ב-Voyage היום
קוד שנכתב/שונה:
- `scripts/reembed_voyage.py` — חדש, סקריפט re-embed
- `~/.env``VOYAGE_MODEL=voyage-3`
- Coolify env (legal-ai app) — `VOYAGE_MODEL=voyage-3`
קבצים שלא צריכים שינוי (CONFIRM):
- `mcp-server/src/legal_mcp/services/embeddings.py` — קורא ל-config.VOYAGE_MODEL
- `mcp-server/src/legal_mcp/config.py` — default ל-voyage-law-2 אבל env
בקוולפיי + מקומית מנצח
- כל הסוכנים (legal-writer, etc.) — לא קוראים ל-Voyage ישירות
עבור B + C: השינויים במסמך הזה (לא מבוצעים עדיין).