feat(retrieval): add voyage rerank-2 cross-encoder stage (feature flag)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
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>
This commit is contained in:
@@ -40,7 +40,7 @@ Local (developer machine, pm2):
|
||||
|
||||
External:
|
||||
← Claude API (Opus 4.7 for agents)
|
||||
← Voyage AI (voyage-3-large, 1024-dim embeddings)
|
||||
← Voyage AI (voyage-3, 1024-dim embeddings)
|
||||
← Infisical (secret management)
|
||||
← Gmail SMTP (agent notifications)
|
||||
```
|
||||
@@ -59,7 +59,7 @@ External:
|
||||
- מפעיל OCR (Google Vision) אם PDF ללא טקסט
|
||||
- מריץ proofreader להסרת artifacts מ-Nevo
|
||||
- מחלץ טקסט ל-`documents.extracted_text`
|
||||
- מפצל ל-chunks של ~500 מילים, מחשב embeddings (voyage-3-large, 1024D), שומר ב-`document_chunks`
|
||||
- מפצל ל-chunks של ~500 מילים, מחשב embeddings (voyage-3, 1024D), שומר ב-`document_chunks`
|
||||
4. סטטוס תיק: `new` → `proofread`
|
||||
|
||||
### שלב 2 — ניתוח משפטי (legal-researcher + analyst)
|
||||
@@ -223,7 +223,7 @@ legal-qa מריץ 6 בדיקות איכות:
|
||||
`case_law`, `statutory_provisions`, `transition_phrases`, `lessons_learned`, `style_corpus`, `style_patterns`
|
||||
|
||||
### Layer 4: Semantic Search (RAG)
|
||||
`document_embeddings`, `paragraph_embeddings`, `case_law_embeddings` (pgvector 1024-dim, voyage-3-large)
|
||||
`document_embeddings`, `paragraph_embeddings`, `case_law_embeddings` (pgvector 1024-dim, voyage-3)
|
||||
|
||||
### Layer 5 — Multi-tenancy
|
||||
`companies`, `tag_company_mappings` (appeal_subtype → company_id)
|
||||
@@ -283,7 +283,9 @@ legal-qa מריץ 6 בדיקות איכות:
|
||||
## טכנולוגיות עיקריות
|
||||
|
||||
- **Database**: PostgreSQL 15 + pgvector 0.8.1
|
||||
- **Embeddings**: Voyage AI (`voyage-3-large`, 1024-dim)
|
||||
- **Embeddings**: Voyage AI (`voyage-3`, 1024-dim) + cross-encoder rerank (`rerank-2`)
|
||||
- bi-encoder: voyage-3 לכל chunk (חד-פעמי בעת ingestion)
|
||||
- cross-encoder: rerank-2 לכל query (top-50 → top-K), feature flag `VOYAGE_RERANK_ENABLED`
|
||||
- **Agents**: Claude Opus 4.7 (via Paperclip pm2)
|
||||
- **DOCX manipulation**: `python-docx` 1.2+ ו-`lxml` 5.2+ (XML surgery)
|
||||
- **Frontend**: Next.js + TanStack Query + Tailwind
|
||||
|
||||
@@ -52,109 +52,114 @@ voyage-3 **מנצח כפול** — דירוג מושלם + מרווחים גדו
|
||||
|
||||
---
|
||||
|
||||
## שלב B — voyage-context-3 (לביצוע בשיחה החדשה)
|
||||
## שלב 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.
|
||||
|
||||
Embeddings רגילים מטמיעים **chunk בנפרד** מהקשר המסמך. פסקה שאומרת
|
||||
"כפי שקבענו לעיל, הפטור אינו חל" — לא יודעת על "פטור ממה" / "לעיל
|
||||
איפה". פסיקה משפטית מלאה בהפניות הקשר תלויות (ראה סעיף 7 לעיל; להבדיל
|
||||
מהמקרה ב-וע 1126/25; וכו') — והן אבודות לחלוטין.
|
||||
### למה rerank-2 ולא context-3?
|
||||
|
||||
### מה voyage-context-3 עושה אחרת
|
||||
POC #4 (אהרון ברק, 18 שאילתות, claude-haiku-4-5 כ-judge):
|
||||
|
||||
API שונה: `client.contextualized_embed(inputs=[[full_doc, chunk_1], ...])`.
|
||||
כל chunk מוטמע **עם המסמך כולו כקונטקסט**. ה-embedding "יודע" שזו פסקה
|
||||
14 מתוך פסק דין על תמ"א 38 — והקשרים פנימיים נשמרים.
|
||||
| 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 |
|
||||
|
||||
Anthropic פרסמו מדידה: **שיפור 49% בדיוק חיפוש** למסמכים משפטיים
|
||||
ארוכים.
|
||||
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 — Refactor של pipeline ה-ingestion
|
||||
#### B.1 — `voyage_rerank()` ב-`embeddings.py`
|
||||
|
||||
קוד נוכחי (`embeddings.py`):
|
||||
```python
|
||||
embs = await embed_texts(chunk_texts, input_type="document")
|
||||
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
|
||||
embs = await embed_texts_with_context(
|
||||
document_full_text=text,
|
||||
chunks=chunk_texts,
|
||||
input_type="document",
|
||||
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"))
|
||||
```
|
||||
|
||||
מקומות שצריכים שינוי:
|
||||
- `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`
|
||||
שמוטמע
|
||||
הdefault הוא `false` — הקוד יישמר אך לא יורץ עד שיופעל ידנית.
|
||||
|
||||
#### B.2 — Query embedding נפרד
|
||||
#### B.3 — אינטגרציה ב-3 search functions
|
||||
|
||||
Queries מטמיעים בלי קונטקסט (`client.embed()` רגיל עם
|
||||
`model="voyage-context-3"` ו-`input_type="query"`). חשוב: queries
|
||||
ו-documents חייבים להיות באותו model space.
|
||||
ב-`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) — אותו דבר.
|
||||
|
||||
ב-`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%.
|
||||
ב-`tools/search.py` — כל הtools (`search_decisions`, `search_case_documents`,
|
||||
`find_similar_cases`, `precedent_search_library`) יעבירו
|
||||
`rerank=config.VOYAGE_RERANK_ENABLED` לקריאות ה-DB.
|
||||
|
||||
#### B.4 — Schema
|
||||
|
||||
אין שינוי. אותו `vector(1024)` column.
|
||||
אין שינוי. אותם vectors, אותו pgvector.
|
||||
|
||||
#### B.5 — Benchmark לפני החלטה סופית
|
||||
#### B.5 — Rollout
|
||||
|
||||
לפני 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
|
||||
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 — בדיקת זמן + עלות
|
||||
#### B.6 — Tier check
|
||||
|
||||
לאחר ה-benchmark:
|
||||
- אם בtier החינמי לא מספיק טוקנים → לבחור: רק documents (לא
|
||||
re-embed הקיים), רק פסיקה חדשה והלאה
|
||||
- או: לעבור ל-context-3 רק על קורפוס הפסיקה (4 פסיקות, ~785 chunks
|
||||
+ halachot) — הקרפוס הקריטי ביותר ל-`search_precedent_library`
|
||||
|
||||
### החלטות שנשארו פתוחות (תיקח החלטה בשיחה החדשה)
|
||||
|
||||
- ✋ Re-embed הכל בבת-אחת או רק חדש?
|
||||
- ✋ context-3 לכל הקורפוסים או רק לפסיקה (הקריטי ביותר)?
|
||||
- ✋ Document context = full_text או summary של 1K?
|
||||
Voyage Tier 1: 2M TPM, 2000 RPM ל-rerank-2. עומס שלנו (~עשרות
|
||||
queries בשעה במקרה רגיל) — מתחת ל-1% מהמכסה.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user