Zpět na domů

Legal RAG ARLC 2026: 17 iterací do 0.791

Článek rozebírá RAG-pipeline pro právní výzvu ARLC 2026: od prvního podání s grounding 0.05 do 0.791. Popsána architektura s page-level indexací, hybridním vyhledáváním RRF, cross-encoder reranking a fast-paths pro typizované odpovědi. Analýza poklesu výkonu při škálování z 30 na 300 dokumentů.

RAG na právních PDF: od selhání do top-skóre ARLC
Advertisement 728x90

Právní RAG na ARLC 2026: z 0,034 na 0,791 za 17 iterací s analýzou škálovatelnosti

Na právním výzvě ARLC 2026 začal RAG-pipelin s groundingem 0,05 a celkovým skóre 0,034. Deterministické odpovědi (S_det) již dosahovaly 0,857, ale násobitel G rušil výsledek. Problém byl zjištěn ve formátu doc_id: submission.json vyžadoval jméno souboru bez přípony '.pdf', zatímco kód posílal plné jméno. Oprava jediného řádku zvýšila G na 0,55 a skóre na 0,438 — nárůst o 13×.

Učení pro RAG-vývojáře: ověření formátu submission předchází optimalizaci retrievalu. I dokonalé reranking je bezcenné bez správného mapování na (doc_id, page_number).

Architektura pipeliny: indexace na úrovni stránky a hybridní hledání

Pipelinu indexuje kompletní stránky dokumentů, vyhýbá se částečnému rozdělení textu. To zjednodušuje grounding na úrovni stránky, ale vyžaduje distilaci kontextu před LLM.

Google AdInline article slot

Parsování PDF s OCR-fallback

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

Kód zpracovává skenované PDF prostřednictvím OCR při malém objemu extrahovaného textu. doc_id je stem bez přípony, page_number je 1-založené.

Hybridní retrieval s RRF

Kombinace BM25 pro přesné shody a embeddings pro smysl prostřednictvím 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ů). RRF nevyžaduje normalizaci škál — pracuje na rankech.

Google AdInline article slot

Reranking a priority tagging

Cross-encoder (ms-marco-MiniLM-L-6-v2) dorazuje top-30 stránek. Hard cap 30 chrání před O(N²)-výbuchem TTFT.

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

Priority-stránky (titulní, články) dostávají score=1000 — garantují se v kontextu.

Document routing pro porovnávací dotazy

Pro dotazy typu «kdo byl jmenován dřív v CFI 001/2020 vs CFI 002/2021» se staví index na titulních stránkách:

Google AdInline article slot
  • Vzory: CFI 010/2024 → doc_id
  • Dual query s round-robin interleavingem kontextů

Deterministické fast-paths pro typizované odpovědi

Typy: number (±1%), boolean, name (přesné), date (YYYY-MM-DD), names (Jaccard), free_text (LLM-judge).

Příklady regex-extrakce:

  • Číslo zákona: re.search(r"(?:DIFC\s+)?Law\s+No\.\s*(\d+)")
  • Datum vydání: parsování prvních stránek podle Date of Issue[:\s]+(\w+ \d+,?\s+\d{4})
  • Částky: extrakce finančních nároků podle case_ref

Fast-paths ušetří 700 ms na Haiku-volání, vracejí odpověď + grounding.

Formule scoringu a optimalizace TTFT

Total = (0.7 × S_det + 0.3 × S_asst) × G × T × F
  • G (F-beta, β=2.5): grounding na stránkách — klíčový násobitel
  • F: <1000 ms → +5%, >3000 ms → -15%

| TTFT (ms) | Násobitel |

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

| <1000 | 1.05 |

| <2000 | 1.02 |

| <3000 | 1.00 |

| >3000 | 0.85–0.99 |

Stěna škálovatelnosti: warmup vs finale

Na 30 dokumentech (warmup) skóre 0,791. Na 300 (finale) – minus 42 %. Příčiny:

  • Zvětšení korpusu zřídí retrieval
  • TTFT roste lineárně s velikostí
  • Reranking na 300+ stránkách zasahuje do F

Optimalizace pro finale: kešování embeddings, batching cross-encoder, early stopping hybridního hledání.

Co je důležité

  • Indexace na úrovni stránky: zjednodušuje grounding, ale vyžaduje distilaci kontextu.
  • RRF (k=60): optimální slučování BM25 + embeddings bez normalizace.
  • Priority tagging: zaručuje, že klíčové stránky jsou v kontextu.
  • Fast-paths: regex pro 80 % typizovaných dotazů ušetří TTFT.
  • TTFT-first: rychlost dává +5 % zdarma při stejné kvalitě.

— Editorial Team

Advertisement 728x90

Číst dál