ops: switch embeddings to voyage-3 + plan for context-3 + multimodal-3.5
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
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) <noreply@anthropic.com>
This commit is contained in:
338
docs/voyage-upgrades-plan.md
Normal file
338
docs/voyage-upgrades-plan.md
Normal file
@@ -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: השינויים במסמך הזה (לא מבוצעים עדיין).
|
||||||
170
scripts/reembed_voyage.py
Normal file
170
scripts/reembed_voyage.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user