# שדרוגי 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: השינויים במסמך הזה (לא מבוצעים עדיין).
