Powrót do strony głównej

Legal RAG ARLC 2026: 17 iteracji do 0.791

Artykuł analizuje potok RAG dla prawnego wyzwania ARLC 2026: od pierwszej próby z grounding 0.05 do 0.791. Opisana architektura z indeksacją page-level, hybrydowym wyszukiwaniem RRF, cross-encoder reranking i fast-paths dla typizowanych odpowiedzi. Analiza spadku wydajności przy skalowaniu z 30 do 300 dokumentów.

RAG na prawnych PDF: od porażki do top wyniku ARLC
Advertisement 728x90

Legal RAG na ARLC 2026: od 0,034 do 0,791 w 17 iteracjach z analizą skalowania

Na wyzwaniu prawnym ARLC 2026 pipeline RAG rozpoczął się z groundingiem 0,05 i ogólnym wynikiem 0,034. Odpowiedzi deterministyczne (S_det) osiągały już 0,857, ale mnożnik G niszczył wynik. Problem został wykryty w formacie doc_id: plik submission.json wymagał nazwy pliku bez rozszerzenia '.pdf', podczas gdy kod przesyłał pełną nazwę. Naprawa jednej linii podniosła G do 0,55 i wynik do 0,438 — wzrost o 13 razy.

Lekcja dla deweloperów RAG: walidacja formatu submission musi poprzedzać optymalizację retrieval. Nawet doskonały reranking jest bezużyteczny bez poprawnego mapowania na (doc_id, page_number).

Architektura pipeline: inżynieria poziomu strony i hybrydowy wyszukiwarka

Pipeline indeksuje pełne strony dokumentów, unikając dzielenia na fragmenty. Upraszczają to grounding po poziomie strony, ale wymagają dystrybucji kontekstu przed LLM.

Google AdInline article slot

Parsowanie PDF z rezerwą OCR

def extract_pages(pdf_path, min_chars=50, ocr_dpi=300):
    pages = []
    doc = pdfplumber.open(pdf_path)
    for i, page in enumerate(doc.pages):
        text = page.extract_text() or ""
        if len(text.strip()) < min_chars:
            text = ocr_page(pdf_path, i + 1, dpi=ocr_dpi)
        pages.append({
            "doc_id": pdf_path.stem,      # bez .pdf!
            "page_number": i + 1,
            "text": text
        })
    return pages

Kod przetwarza skanowane PDF przez OCR przy małej ilości wyodrębnionego tekstu. doc_id to stem bez rozszerzenia, page_number to liczba zaczynająca się od 1.

Hybrydowe wyszukiwanie z RRF

Połączenie BM25 do dokładnych dopasowań i embeddings do semantyki przez Reciprocal Rank Fusion (k=60):

def hybrid_search(query, bm25_index, embedding_index, pages, top_k=20, rrf_k=60):
    bm25_scores = bm25_index.get_scores(tokenize(query))
    bm25_ranked = sorted(range(len(pages)), key=lambda i: -bm25_scores[i])

    q_emb = embed_model.encode([query[:512]], normalize_embeddings=True)
    sim_scores = (embedding_index @ q_emb.T).flatten()
    emb_ranked = sorted(range(len(pages)), key=lambda i: -sim_scores[i])

    combined = {}
    for rank, idx in enumerate(bm25_ranked[:top_k * 3]):
        combined[idx] = combined.get(idx, 0) + 1.0 / (rrf_k + rank)
    for rank, idx in enumerate(emb_ranked[:top_k * 3]):
        combined[idx] = combined.get(idx, 0) + 1.0 / (rrf_k + rank)

    return sorted(combined, key=lambda i: -combined[i])[:top_k]

Model embeddings: all-MiniLM-L6-v2 (22M parametrów). RRF nie wymaga normalizacji skal — działa na rangach.

Google AdInline article slot

Reranking i tagowanie priorytetów

Cross-encoder (ms-marco-MiniLM-L-6-v2) dorankuje top-30 stron. Maksymalna granica 30 chroni przed eksplozją TTFT typu O(N²).

def rerank(pages, query, top_k=5):
    HARD_CAP = 30
    priority = [p for p in pages if p.get("_priority")]
    non_priority = pages[:HARD_CAP - len(priority)]

    all_pages = priority + non_priority
    pairs = [(query, p["text"][:512]) for p in all_pages]
    scores = cross_encoder.predict(pairs)

    for i, p in enumerate(all_pages):
        if p.get("_priority"):
            scores[i] = 1000.0

    ranked = sorted(zip(scores, all_pages), key=lambda x: -x[0])
    return [p for _, p in ranked[:top_k]]

Strony priorytetowe (strony tytułowe, artykuły) otrzymują score=1000 — gwarantują obecność w kontekście.

Routing dokumentów dla zapytań porównawczych

Dla pytań typu «kto wcześniej został powołany w CFI 001/2020 vs CFI 002/2021» budowany jest indeks po stronach tytułowych:

Google AdInline article slot
  • Wzorce: CFI 010/2024 → doc_id
  • Zapytanie dualne z cyklicznym mieszaniem kontekstów

Deterministyczne ścieżki szybkie dla odpowiedzi typowanych

Typy: number (±1%), boolean, name (dokładne), date (YYYY-MM-DD), names (Jaccard), free_text (LLM-judge).

Przykłady extrakcji za pomocą regex:

  • Numer prawa: re.search(r"(?:DIFC\\s+)?Law\\s+No\\.\\s*(\\d+)")
  • Data wydania: parsowanie pierwszych stron według Date of Issue[:\\s]+(\\w+ \\d+,?\\s+\\d{4})
  • Sumy: wyodrębnianie dochodów pieniężnych według case_ref

Ścieżki szybkie oszczędzają 700 ms na wywołaniu Haiku, zwracając odpowiedź + grounding.

Formuła oceniania i optymalizacja TTFT

Total = (0.7 × S_det + 0.3 × S_asst) × G × T × F
  • G (F-beta, β=2.5): grounding po stronach — kluczowy mnożnik
  • F: <1000 ms → +5%, >3000 ms → -15%

| TTFT (ms) | Mnożnik |

|-----------|---------|

| <1000 | 1.05 |

| <2000 | 1.02 |

| <3000 | 1.00 |

| >3000 | 0.85–0.99 |

Ściana skalowania: warmup vs finał

Na 30 dokumentach (warmup) wynik to 0,791. Na 300 (finale) — spadek o 42%. Przyczyny:

  • Rozszerzenie korpusu rozcieńcza retrieval
  • TTFT rośnie liniowo z rozmiarem
  • Reranking na 300+ stronach wpływa negatywnie na F

Optymalizacje dla finale: cache embeddings, batching cross-encoder, wcześniejsze zatrzymanie hybrydowego wyszukiwania.

Co jest ważne

  • Indeksacja poziomu strony: upraszcza grounding, ale wymaga dystrybucji kontekstu.
  • RRF (k=60): optymalne scalanie BM25 + embeddings bez normalizacji.
  • Tagowanie priorytetów: gwarantuje obecność kluczowych stron w kontekście.
  • Ścieżki szybkie: regex do 80% pytań typowanych oszczędza TTFT.
  • TTFT-first: szybkość daje +5% gratis przy równym jakości.

— Editorial Team

Advertisement 728x90

Czytaj dalej