Files
legal-ai/docs/voyage-upgrades-plan.md
Chaim d12cdb1fad
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
docs(voyage): mark stage C complete + record empirical fixes
Stage C of the voyage-upgrades-plan shipped to production on
2026-05-03. The doc now leads with the final state and the two
empirical corrections vs the original plan:

1. Reciprocal Rank Fusion replaces weighted-sum hybrid merge.
   voyage-3 cosines (~0.4-0.5) systematically outscale
   voyage-multimodal-3 cosines (~0.20-0.25); a weighted sum lets
   text dominate even when image is the better signal. RRF is
   rank-based and robust to scale differences.

2. Chunker now propagates page_number end-to-end (extractor returns
   per-page offsets, chunker tags each chunk by its first character's
   page). A retrofit script backfills page_number on existing
   document_chunks without re-OCR — uses the stored
   documents.extracted_text plus PyMuPDF direct text reads as page
   anchors (linear interpolation for OCR-only pages).

Production state on cases 8174-24 + 8137-24: 419 page-image
embeddings, 819 chunks tagged with page_number, MULTIMODAL_ENABLED=true
in Coolify env, hybrid search verified A/B against text-only baseline.

The original stage C plan section is retained below for reference.

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

410 lines
17 KiB
Markdown
Raw Permalink 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 (✅ בוצע 2026-05-03)
> **תיקון שם המודל מהתכנית המקורית**: השם הסופי הוא
> `voyage-multimodal-3` (לא 3.5). הוצמד לזה ש-POC #3 הריץ.
### מצב סופי בייצור
- `MULTIMODAL_ENABLED=true` ב-Coolify env
- Schema V9 ב-DB (document_image_embeddings + precedent_image_embeddings)
- 419 page-image embeddings על 8174-24 (146) + 8137-24 (273)
- 819 text chunks קיבלו page_number (100% retrofit)
- RRF hybrid merge עם boost text+image פעיל
### שינויים מהתכנית המקורית — שני תיקונים אמפיריים
1. **Score scaling — Reciprocal Rank Fusion במקום weighted sum.**
ה-cosine של voyage-3 (~0.4-0.5) שיטתית גבוה מ-voyage-multimodal-3
(~0.20-0.25). A/B ראשון על 7 שאילתות הראה: עם 0.65/0.35 weighted
sum ו-MULTIMODAL_ENABLED=true, **0** image rows הופיעו ב-top-5,
image side פשוט הוצף. עברנו ל-RRF (`rrf_score = w / (k + rank)`)
שעמיד לסקיילים שונים. תוצאה: 5/5 results עם image contribution
בכל שאילתה.
2. **Page tracking — chunker חדש + retrofit ל-819 chunks קיימים.**
ה-chunker הישן זרק את ה-page_number של chunks. בלעדיו ה-boost
text+image (join על `(document_id, page_number)`) לא יכול לפעול.
נוסף `page_offsets` ל-`extractor.extract_text` (משלשה במקום זוג —
מעודכן ב-6 callers); chunker מקבל אותו ומסמן page לכל chunk לפי
offset של התווים הראשונים שלו. retrofit ל-chunks קיימים
(`scripts/backfill_chunk_pages.py`) עובד **בלי re-OCR**
משתמש ב-stored extracted_text כמקור (matches existing chunk
content verbatim) ו-PyMuPDF direct text reads כעיגוני page
boundaries; pages סרוקים ללא טקסט ישיר עוברים אינטרפולציה.
### למה NOT לעשות re-OCR ב-retrofit
ניסיון ראשון השתמש ב-`extractor.extract_text` להפיק page_offsets
חדשים. תוצאה: 1/29 chunks נמצאו (28 not found), כי OCR של Google
Vision לא דטרמיניסטי — ה-OCR החדש שונה מה-OCR שהפיק את ה-chunks
המקוריים. הגרסה החדשה משתמשת ב-stored `documents.extracted_text`
שמתאים לחלוטין לתוכן ה-chunks. עלות: $0 (לעומת ~$0.0015/page).
### Files שהשתנו (יחסית למה שהמסמך הזה תיכנן)
קוד שנכתב/שונה (5 commits, 242f668 → 8a815ec):
- `mcp-server/src/legal_mcp/config.py` — flags MULTIMODAL_*
- `mcp-server/src/legal_mcp/services/extractor.py` — render + page_offsets
- `mcp-server/src/legal_mcp/services/embeddings.py` — embed_images
- `mcp-server/src/legal_mcp/services/db.py` — schema V9 + 4 store/search funcs
- `mcp-server/src/legal_mcp/services/chunker.py` — page tracking
- `mcp-server/src/legal_mcp/services/processor.py` — ingest integration
- `mcp-server/src/legal_mcp/services/precedent_library.py` — same
- `mcp-server/src/legal_mcp/services/hybrid_search.py` — חדש, RRF orchestrator
- `mcp-server/src/legal_mcp/tools/search.py` — wired to hybrid
- `mcp-server/src/legal_mcp/tools/documents.py` + `tools/workflow.py` + `web/app.py` — extract_text triple unpack
- `scripts/multimodal_backfill.py` + `scripts/backfill_chunk_pages.py` — חדשים
### מה נשאר (deferred)
- UI thumbnails בתוצאות חיפוש (לא חוסם — דפנה מקבלת page numbers)
- Backfill על שאר הקורפוס (מעבר ל-2 התיקים): לא דחוף, אפשר per-case
- `text_weight` תיאום: כרגע 0.5 (vanilla RRF). אם דפנה תגיד שהיא רואה
יותר מדי image-influence, מעלים ל-0.55-0.6 דרך env בלי deploy.
---
## שלב C המקורי (תכנון, לרפרנס)
### הבעיה שהוא פותר
תיקים סרוקים ודוחות שמאי מאבדים מידע ב-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: השינויים במסמך הזה (לא מבוצעים עדיין).