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

17 KiB
Raw Blame History

שדרוגי 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

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

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, 242f6688a815ec):

  • 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 חדש

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:

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:

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 (תיק):

# אחרי 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.

חדש ב-db.py:search_precedent_library_hybrid:

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