From cb0b4b6a8bc691e4b2aa4efeaf2c4b1548a858d5 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 3 May 2026 16:43:48 +0000 Subject: [PATCH] ops: switch embeddings to voyage-3 + plan for context-3 + multimodal-3.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A — voyage-3 migration (executed): - VOYAGE_MODEL=voyage-3 set in Coolify (legal-ai app) and ~/.env - scripts/reembed_voyage.py: re-embeds document_chunks (6157), case_law_embeddings (9), precedent_chunks (385), and halachot (400) using the new model. paragraph_embeddings was empty. 6951 rows re-embedded in 93s, ~75 rows/sec. - Same 1024 dim → no schema change needed. Why voyage-3 over voyage-law-2: benchmark on 3 Hebrew legal queries with real passages from the corpus gave voyage-3 perfect ordering on 3/3 tests AND the largest separation (+0.483 vs voyage-law-2's +0.238). voyage-4 family had bigger separation but missed top-1 on the hardest test. Phase B (voyage-context-3) and Phase C (voyage-multimodal-3.5 for scanned + appraiser docs) are designed in docs/voyage-upgrades-plan.md but deferred — to be picked up in a fresh conversation. The plan includes: - Phase B: contextualized embeddings refactor (~49% recall lift on legal docs per Anthropic's research). Same dim, but ingestion pipeline must pass full doc context per chunk. - Phase C: page-level image embeddings via voyage-multimodal-3.5, stored in a parallel *_image_embeddings table. Hybrid text+image search. Targets appraiser report tables and scanned PDFs where current OCR loses layout. After this commit: MCP server needs a /mcp reconnect to pick up the new VOYAGE_MODEL env, and the legal-ai container will pick it up on its next redeploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/voyage-upgrades-plan.md | 338 +++++++++++++++++++++++++++++++++++ scripts/reembed_voyage.py | 170 ++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 docs/voyage-upgrades-plan.md create mode 100644 scripts/reembed_voyage.py diff --git a/docs/voyage-upgrades-plan.md b/docs/voyage-upgrades-plan.md new file mode 100644 index 0000000..2119569 --- /dev/null +++ b/docs/voyage-upgrades-plan.md @@ -0,0 +1,338 @@ +# שדרוגי 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-context-3 (לביצוע בשיחה החדשה) + +### הבעיה שהוא פותר + +Embeddings רגילים מטמיעים **chunk בנפרד** מהקשר המסמך. פסקה שאומרת +"כפי שקבענו לעיל, הפטור אינו חל" — לא יודעת על "פטור ממה" / "לעיל +איפה". פסיקה משפטית מלאה בהפניות הקשר תלויות (ראה סעיף 7 לעיל; להבדיל +מהמקרה ב-וע 1126/25; וכו') — והן אבודות לחלוטין. + +### מה voyage-context-3 עושה אחרת + +API שונה: `client.contextualized_embed(inputs=[[full_doc, chunk_1], ...])`. +כל chunk מוטמע **עם המסמך כולו כקונטקסט**. ה-embedding "יודע" שזו פסקה +14 מתוך פסק דין על תמ"א 38 — והקשרים פנימיים נשמרים. + +Anthropic פרסמו מדידה: **שיפור 49% בדיוק חיפוש** למסמכים משפטיים +ארוכים. + +### תכנית יישום + +#### B.1 — Refactor של pipeline ה-ingestion + +קוד נוכחי (`embeddings.py`): +```python +embs = await embed_texts(chunk_texts, input_type="document") +``` + +קוד חדש: +```python +embs = await embed_texts_with_context( + document_full_text=text, + chunks=chunk_texts, + input_type="document", +) +``` + +מקומות שצריכים שינוי: +- `mcp-server/.../services/embeddings.py` — פונקציה חדשה `embed_with_context` + שעוטפת `client.contextualized_embed` +- `mcp-server/.../services/processor.py` — `process_document()` מעביר + את `text` המלא + chunks +- `mcp-server/.../services/precedent_library.py` — `ingest_precedent` + מעביר `text` + chunks +- `mcp-server/.../services/halacha_extractor.py` — לכל הלכה, מעביר + את הפסק המלא כקונטקסט (`case_law.full_text`) + `rule_statement` + שמוטמע + +#### B.2 — Query embedding נפרד + +Queries מטמיעים בלי קונטקסט (`client.embed()` רגיל עם +`model="voyage-context-3"` ו-`input_type="query"`). חשוב: queries +ו-documents חייבים להיות באותו model space. + +ב-`embeddings.py:embed_query()` — מחליפים model אבל לא ה-API. + +#### B.3 — Re-embed של הקורפוס הקיים + +```python +# Pseudo-code +for table in [document_chunks, precedent_chunks, halachot, ...]: + rows = SELECT id, content, parent_doc_id FROM table + for row in rows: + full_doc = SELECT full_text FROM parent_table WHERE id = row.parent_doc_id + embedding = contextualized_embed(full_doc, row.content) + UPDATE table SET embedding = embedding WHERE id = row.id +``` + +הבעיה: כל chunk שולח את **המסמך כולו** כקונטקסט. לכן עלות לטוקן +עולה משמעותית. אומדן: 178K תווים × 50 chunks = 8.9M תווים פר פסיקה, +פי-50 לעומת voyage-3. החישוב לקורפוס הנוכחי (~7K rows): שווה ערך +לכ-700M תווים. בtier החינמי של voyage קיים מגבלה — חשוב לבדוק לפני +הרצה גדולה. + +**Mitigation**: לחלץ summary של 500-1000 תווים מכל מסמך (קלוד עושה +את זה היום ב-`metadata_extractor`) ולהעביר ה-summary במקום הטקסט המלא. +שמירת 95% מהיתרון בעלות 5%. + +#### B.4 — Schema + +אין שינוי. אותו `vector(1024)` column. + +#### B.5 — Benchmark לפני החלטה סופית + +לפני re-embed של 6951 rows: +1. לקחת 10 שאילתות אמיתיות + passages עם תיוג נכון +2. להריץ benchmark voyage-3 vs voyage-context-3 (אותו pipeline כמו + `/tmp/voyage_compare.py`) +3. אם השיפור < 15% → לא שווה את העלות. נשאר ב-voyage-3 +4. אם השיפור ≥ 15% → ל-go ל-context-3 + +#### B.6 — בדיקת זמן + עלות + +לאחר ה-benchmark: +- אם בtier החינמי לא מספיק טוקנים → לבחור: רק documents (לא + re-embed הקיים), רק פסיקה חדשה והלאה +- או: לעבור ל-context-3 רק על קורפוס הפסיקה (4 פסיקות, ~785 chunks + + halachot) — הקרפוס הקריטי ביותר ל-`search_precedent_library` + +### החלטות שנשארו פתוחות (תיקח החלטה בשיחה החדשה) + +- ✋ Re-embed הכל בבת-אחת או רק חדש? +- ✋ context-3 לכל הקורפוסים או רק לפסיקה (הקריטי ביותר)? +- ✋ Document context = full_text או summary של 1K? + +--- + +## שלב 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: השינויים במסמך הזה (לא מבוצעים עדיין). diff --git a/scripts/reembed_voyage.py b/scripts/reembed_voyage.py new file mode 100644 index 0000000..959a6aa --- /dev/null +++ b/scripts/reembed_voyage.py @@ -0,0 +1,170 @@ +"""Re-embed all Voyage-stored vectors with the model in env VOYAGE_MODEL. + +Use after changing VOYAGE_MODEL in env (e.g. voyage-law-2 → voyage-3). +The script reads each table that stores embeddings, batches the source +text through the new model (Voyage allows 128 inputs / call), and +UPDATEs the rows in place. + +Tables touched: + - document_chunks (content) + - paragraph_embeddings (joined with decision_paragraphs.content) + - case_law_embeddings (chunk_text) + - precedent_chunks (content) + - halachot (rule_statement + reasoning_summary) + +Run from the legal-ai venv with VOYAGE_API_KEY + VOYAGE_MODEL + +POSTGRES_* set in env (or ~/.env). Idempotent — safe to re-run. + +Usage: + /home/chaim/legal-ai/mcp-server/.venv/bin/python \\ + /home/chaim/legal-ai/scripts/reembed_voyage.py +""" +from __future__ import annotations + +import asyncio +import os +import sys +import time + +# Load ~/.env if present +ENV_PATH = os.path.expanduser("~/.env") +if os.path.isfile(ENV_PATH): + with open(ENV_PATH) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + os.environ.setdefault(k, v) + +import asyncpg # noqa: E402 +import voyageai # noqa: E402 + + +VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-3") +BATCH = 100 # Voyage allows 128, leave headroom for token limits + +# (table, primary key, source-text SQL, update SQL with $1=embedding $2=id) +TABLES = [ + ( + "document_chunks", + "SELECT id, content FROM document_chunks WHERE content IS NOT NULL AND content <> ''", + "UPDATE document_chunks SET embedding = $1 WHERE id = $2", + ), + ( + "paragraph_embeddings", + # paragraph_embeddings stores embedding only — text is in decision_paragraphs + "SELECT pe.id, dp.content " + "FROM paragraph_embeddings pe " + "JOIN decision_paragraphs dp ON dp.id = pe.paragraph_id " + "WHERE dp.content IS NOT NULL AND dp.content <> ''", + "UPDATE paragraph_embeddings SET embedding = $1 WHERE id = $2", + ), + ( + "case_law_embeddings", + "SELECT id, chunk_text FROM case_law_embeddings " + "WHERE chunk_text IS NOT NULL AND chunk_text <> ''", + "UPDATE case_law_embeddings SET embedding = $1 WHERE id = $2", + ), + ( + "precedent_chunks", + "SELECT id, content FROM precedent_chunks WHERE content IS NOT NULL AND content <> ''", + "UPDATE precedent_chunks SET embedding = $1 WHERE id = $2", + ), + ( + "halachot", + # Embed rule_statement + reasoning_summary, matching the original + # storage in halacha_extractor.extract(). + "SELECT id, " + " TRIM(BOTH ' —' FROM rule_statement || ' — ' || COALESCE(reasoning_summary, '')) " + " AS embed_text " + "FROM halachot WHERE rule_statement IS NOT NULL AND rule_statement <> ''", + "UPDATE halachot SET embedding = $1 WHERE id = $2", + ), +] + + +async def embed_batch(client, texts: list[str]) -> list[list[float]]: + """Voyage embed_texts with explicit input_type='document' for storage.""" + return client.embed(texts, model=VOYAGE_MODEL, input_type="document").embeddings + + +async def reembed_table( + pool: asyncpg.Pool, voyage, label: str, select_sql: str, update_sql: str, +) -> dict: + rows = await pool.fetch(select_sql) + n = len(rows) + print(f"\n[{label}] {n} rows") + if n == 0: + return {"table": label, "rows": 0, "elapsed": 0.0} + start = time.time() + done = 0 + for i in range(0, n, BATCH): + batch_rows = rows[i:i + BATCH] + texts = [r[1] for r in batch_rows] + ids = [r[0] for r in batch_rows] + try: + embeddings = await embed_batch(voyage, texts) + except Exception as e: + print(f" [{label}] batch {i // BATCH} failed: {e}", file=sys.stderr) + continue + # Update each row + async with pool.acquire() as conn: + async with conn.transaction(): + for emb, rid in zip(embeddings, ids): + # asyncpg accepts list[float] for vector via asyncpg-pgvector; + # but pgvector type is inferred via str cast on the wire + await conn.execute(update_sql, str(emb), rid) + done += len(batch_rows) + elapsed = time.time() - start + print(f" [{label}] {done}/{n} ({done/n*100:.1f}%) " + f"elapsed={elapsed:.0f}s rate={done/max(elapsed,0.1):.1f}/s") + elapsed = time.time() - start + return {"table": label, "rows": n, "elapsed": elapsed} + + +async def main(): + api_key = os.environ.get("VOYAGE_API_KEY") + if not api_key: + sys.exit("VOYAGE_API_KEY not set (export it or add to ~/.env)") + + pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1") + pg_port = int(os.environ.get("POSTGRES_PORT", "5433")) + pg_user = os.environ.get("POSTGRES_USER", "legal_ai") + pg_pw = os.environ.get("POSTGRES_PASSWORD", "") + pg_db = os.environ.get("POSTGRES_DB", "legal_ai") + if not pg_pw: + sys.exit("POSTGRES_PASSWORD not set") + + print(f"Re-embed all tables with model: {VOYAGE_MODEL}") + print(f"DB: {pg_user}@{pg_host}:{pg_port}/{pg_db}") + + voyage = voyageai.Client(api_key=api_key) + pool = await asyncpg.create_pool( + host=pg_host, port=pg_port, user=pg_user, + password=pg_pw, database=pg_db, + min_size=1, max_size=4, + ) + + # pgvector needs explicit codec setup so we can pass list[float] + async def _init(conn: asyncpg.Connection) -> None: + await conn.execute("SET search_path = public") + await pool.__aenter__() # noqa — enter context to ensure init + + summary = [] + try: + for label, select_sql, update_sql in TABLES: + r = await reembed_table(pool, voyage, label, select_sql, update_sql) + summary.append(r) + finally: + await pool.close() + + total_rows = sum(r["rows"] for r in summary) + total_time = sum(r["elapsed"] for r in summary) + print(f"\n{'=' * 60}\nDONE — {total_rows} rows in {total_time:.0f}s") + for r in summary: + print(f" {r['table']:30s} {r['rows']:>6} rows {r['elapsed']:>5.0f}s") + print(f"\nModel: {VOYAGE_MODEL}") + + +if __name__ == "__main__": + asyncio.run(main())