feat(retrieval): add voyage rerank-2 cross-encoder stage (feature flag)
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:
2026-05-03 18:43:41 +00:00
parent 688ba37d9c
commit 26c3fddf41
13 changed files with 1578 additions and 100 deletions

View File

@@ -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

View File

@@ -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.2Query 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% מהמכסה.
---