All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
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>
344 lines
14 KiB
Markdown
344 lines
14 KiB
Markdown
# שדרוגי 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: השינויים במסמך הזה (לא מבוצעים עדיין).
|