법률 AI 도우미 구축: RAG, 가드레일 및 전문 인터페이스
2025년 초부터 AI 생성 콘텐츠 518건이 문서화됐다. 환각과 함께 미국 법원 소송에서 발표되었습니다. 같은 기간 동안 독립적인 평가에 따르면 Westlaw AI와 LexisNexis는 법률계에서 가장 널리 사용되는 두 가지 시스템인 Lexis+는 정확한 답변을 제공합니다. 특정 법률 문의에 관한 사건의 65~83%에서만 발생합니다. 문제는 AI가 아니라 방법이다 그것은 사용되며 어떻게 구축되는지.
이 기사에서 우리는 법률 AI 보조원(Legal Copilot) 환각 문제를 직접적으로 다루는 전문가: RAG on Corpus 지원되지 않는 응답을 포착하기 위한 독점적인 법적 다단계 가드레일 출처, 검증 가능한 인용, 흐름에 최적화된 Angular 인터페이스 변호사의 업무.
무엇을 배울 것인가
- 법적 도메인을 위한 검색 증강 생성(RAG) 아키텍처
- 법적인 말뭉치의 구축: 규정, 문장, 교리
- 다단계 가드레일: 인용 근거, 신뢰도 점수, 거부 논리
- 정확하고 오해의 소지가 없는 법적 답변을 위한 신속한 엔지니어링
- 응답 스트리밍을 갖춘 변호사 친화적인 각도 인터페이스
- 시스템 품질 측정을 위한 평가 프레임워크
법률 도메인을 위한 RAG 아키텍처
일반 챗봇과 전문 Legal Copilot의 차이점은 다음과 같습니다. RAG 아키텍처에서는 모든 응답이 다음과 같아야 합니다. 특정 문서를 기반으로 모델의 매개변수적 메모리에 의해 생성되지 않은 법적 자료에서 복구되었습니다. 이는 문제로 인한 환각을 줄이는 기본 메커니즘입니다. 관리 가능한 위험이 있는 체계적.
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
@dataclass
class LegalSource:
"""Documento sorgente recuperato per fondare la risposta."""
doc_id: str
doc_type: str # "sentenza", "legge", "regolamento", "dottrina"
title: str
citation: str # citazione formale (es. "Cass. civ. n. 12345/2024")
content_chunk: str # estratto rilevante
relevance_score: float # score di rilevanza [0, 1]
source_url: Optional[str] = None
@dataclass
class LegalQueryResult:
"""
Risultato strutturato di una query al Legal Copilot.
Ogni affermazione deve essere tracciabile a una fonte specifica.
"""
query: str
answer: str
sources: List[LegalSource]
confidence: float # confidence score complessivo [0, 1]
grounding_ratio: float # % di affermazioni supportate da fonti
uncertainty_disclaimer: str # disclaimer quando la confidenza e bassa
generated_at: datetime
model_version: str
warnings: List[str] = field(default_factory=list)
법률자료 구축
말뭉치의 품질은 법률 부조종사에게 가장 중요한 요소입니다. 법적 말뭉치 잘 구성된 이탈리아어에는 다음이 포함되어야 합니다.
- 기본 입법: 민법, 형법, 재판법 민사, 특별법 - 현재(통합) 버전
- 법률학: 대법원, 헌법재판소의 판결, TAR 및 국가 평의회, EU 사법 재판소(ECHR)
- 규정 및 회보: 이탈리아 은행, Consob, 개인 정보 보호 보증인, AGCM, INPS
- 업데이트된 교리: 권위 있는 법률 저널의 기사
import asyncio
import aiohttp
from bs4 import BeautifulSoup
from dataclasses import dataclass
from typing import List, AsyncIterator
import re
@dataclass
class RawLegalDocument:
source_id: str
doc_type: str
raw_text: str
metadata: dict
class LegalCorpusBuilder:
"""
Builder per il corpus giuridico.
Scarica e normalizza documenti da fonti ufficiali.
"""
# Normativa italiana via Normattiva (fonte ufficiale)
NORMATTIVA_API = "https://www.normattiva.it/uri-res/N2Ls"
async def fetch_normativa(self, uri: str) -> Optional[RawLegalDocument]:
"""
Scarica una norma da Normattiva (il portale ufficiale delle leggi italiane).
URI format: urn:nir:stato:legge:2023-12-31;234
"""
async with aiohttp.ClientSession() as session:
try:
async with session.get(
f"{self.NORMATTIVA_API}?urn={uri}&mimetype=text/plain",
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
text = await resp.text()
return RawLegalDocument(
source_id=uri,
doc_type="legge",
raw_text=self._clean_normativa_text(text),
metadata={'source': 'normattiva', 'uri': uri}
)
except Exception as e:
print(f"Errore fetch {uri}: {e}")
return None
def _clean_normativa_text(self, text: str) -> str:
"""Rimuove markup e normalizza il testo normativo."""
# Rimuovi intestazioni burocratiche
text = re.sub(r'^.*?CAPO I', 'CAPO I', text, flags=re.DOTALL)
# Normalizza a capo
text = re.sub(r'\n{3,}', '\n\n', text)
# Rimuovi numeri di pagina
text = re.sub(r'\n\d+\n', '\n', text)
return text.strip()
def chunk_legal_text(
self,
doc: RawLegalDocument,
max_chars: int = 1500,
overlap_chars: int = 200
) -> List[dict]:
"""
Chunking strutturato per testi normativi.
Divide per articoli mantenendo la struttura legislativa.
"""
chunks = []
# Pattern per articoli: "Art. 1" o "Articolo 1" con varianti
article_pattern = re.compile(
r'(?:Art(?:icolo)?\.?\s+(\d+(?:\s*-\s*(?:bis|ter|quater|quinquies))?)'
r'|(\d+\.\s+))',
re.IGNORECASE
)
articles = list(article_pattern.finditer(doc.raw_text))
if not articles:
# Nessuna struttura: chunk fisso con overlap
for i in range(0, len(doc.raw_text), max_chars - overlap_chars):
chunk_text = doc.raw_text[i:i + max_chars]
chunks.append({
'content': chunk_text,
'doc_id': doc.source_id,
'doc_type': doc.doc_type,
'metadata': doc.metadata
})
else:
for idx, match in enumerate(articles):
start = match.start()
end = articles[idx + 1].start() if idx + 1 < len(articles) else len(doc.raw_text)
chunk_text = doc.raw_text[start:end].strip()
if len(chunk_text) > max_chars:
# Articolo molto lungo: suddividi ulteriormente
for j in range(0, len(chunk_text), max_chars - overlap_chars):
chunks.append({
'content': chunk_text[j:j + max_chars],
'doc_id': doc.source_id,
'article_ref': match.group(0),
'doc_type': doc.doc_type,
'metadata': doc.metadata
})
else:
chunks.append({
'content': chunk_text,
'doc_id': doc.source_id,
'article_ref': match.group(0),
'doc_type': doc.doc_type,
'metadata': doc.metadata
})
return chunks
다단계 가드레일을 갖춘 RAG 시스템
Legal Copilot 및 가드레일을 갖춘 RAG 시스템의 핵심: 모든 질문이 반드시 필요한 것은 아닙니다. 응답을 받습니다. 검색된 출처가 질문을 충분히 다루지 못하는 경우, 시스템은 추측적인 응답을 생성하는 대신 이를 명시적으로 명시해야 합니다.
from langchain_openai import ChatOpenAI
from langchain.schema import SystemMessage, HumanMessage
from sentence_transformers import SentenceTransformer, util
import json
import re
from typing import List, Tuple
class LegalGuardrailSystem:
"""
Sistema di guardrail multi-livello per il Legal Copilot.
Impedisce la generazione di risposte non supportate dalle fonti.
"""
# Prompt di sistema con istruzioni esplicite anti-hallucination
SYSTEM_PROMPT = """Sei un assistente legale AI altamente specializzato.
REGOLE ASSOLUTE:
1. Rispondi SOLO basandoti sui documenti forniti nel contesto.
2. Se le fonti non coprono adeguatamente la domanda, di' esplicitamente
"Le fonti disponibili non sono sufficienti per rispondere a questa domanda."
3. Cita sempre la fonte specifica per ogni affermazione (art. X, sentenza Y).
4. Non interpretare o speculare oltre quanto dichiarato nelle fonti.
5. Usa linguaggio giuridico preciso, non parafrasare formule legali standard.
6. Segnala quando una norma potrebbe essere stata modificata di recente.
FORMATO RISPOSTA:
- Risposta strutturata con paragrafi
- Ogni affermazione seguita da [Fonte: ...]
- Conclusione con disclaimer se appropriato"""
def __init__(self, llm_model: str = "gpt-4o", embedding_model: str = "nlpaueb/legal-bert-base-uncased"):
self.llm = ChatOpenAI(model=llm_model, temperature=0.1, max_tokens=2000)
self.embedding_model = SentenceTransformer(embedding_model)
def _compute_grounding_score(
self,
answer: str,
sources: List[LegalSource]
) -> Tuple[float, List[str]]:
"""
Calcola quanto la risposta e 'fondata' nelle sorgenti.
Usa similarity semantica tra frasi della risposta e chunk delle fonti.
"""
if not sources:
return 0.0, ["Nessuna fonte disponibile"]
# Estrai frasi dalla risposta
sentences = [s.strip() for s in re.split(r'[.!?]', answer) if len(s.strip()) > 20]
if not sentences:
return 0.0, []
source_texts = [s.content_chunk for s in sources]
ungrounded = []
sentence_embeds = self.embedding_model.encode(sentences, convert_to_tensor=True)
source_embeds = self.embedding_model.encode(source_texts, convert_to_tensor=True)
grounded_count = 0
for i, sent_embed in enumerate(sentence_embeds):
max_sim = float(util.cos_sim(sent_embed, source_embeds).max())
if max_sim >= 0.65:
grounded_count += 1
else:
ungrounded.append(sentences[i])
grounding_ratio = grounded_count / len(sentences) if sentences else 0.0
return grounding_ratio, ungrounded
def _check_refusal_conditions(self, query: str, sources: List[LegalSource]) -> Optional[str]:
"""
Verifica se il sistema deve rifiutare di rispondere.
Restituisce il motivo del rifiuto o None se si può procedere.
"""
# Nessuna fonte trovata
if not sources:
return "Non ho trovato documenti rilevanti nel corpus per rispondere a questa domanda."
# Tutte le fonti hanno rilevanza molto bassa
max_relevance = max(s.relevance_score for s in sources)
if max_relevance < 0.4:
return (
f"Le fonti disponibili hanno una rilevanza troppo bassa (max: {max_relevance:.2f}) "
"per rispondere con sufficiente affidabilità."
)
# Query su consulenza legale personale specifica
advice_patterns = [
r'cosa devo fare (io|noi)', r'ho torto o ragione',
r'posso vincere la causa', r'devo (firmare|accettare|rifiutare)'
]
for pattern in advice_patterns:
if re.search(pattern, query, re.IGNORECASE):
return (
"Non posso fornire consulenza legale personalizzata. "
"Rivolgiti a un avvocato abilitato per il tuo caso specifico."
)
return None # Nessun rifiuto: procedi
async def generate_legal_answer(
self,
query: str,
retrieved_sources: List[LegalSource]
) -> LegalQueryResult:
"""
Genera una risposta legale fondata sulle sorgenti.
"""
from datetime import datetime
# Step 1: Verifica condizioni di rifiuto
refusal_reason = self._check_refusal_conditions(query, retrieved_sources)
if refusal_reason:
return LegalQueryResult(
query=query,
answer=refusal_reason,
sources=[],
confidence=0.0,
grounding_ratio=0.0,
uncertainty_disclaimer=refusal_reason,
generated_at=datetime.utcnow(),
model_version="gpt-4o-guardrailed-v1",
warnings=["RIFIUTO: " + refusal_reason]
)
# Step 2: Costruisci contesto dalle fonti
context = "\n\n---\n\n".join([
f"[{s.doc_type.upper()}] {s.citation}\n{s.content_chunk}"
for s in retrieved_sources
])
messages = [
SystemMessage(content=self.SYSTEM_PROMPT),
HumanMessage(content=f"CONTESTO NORMATIVO:\n{context}\n\nDOMANDA: {query}")
]
# Step 3: Genera risposta con LLM
response = await self.llm.ainvoke(messages)
answer = response.content
# Step 4: Calcola grounding score
grounding_ratio, ungrounded = self._compute_grounding_score(answer, retrieved_sources)
# Step 5: Genera disclaimer se grounding insufficiente
disclaimer = ""
warnings = []
if grounding_ratio < 0.7:
disclaimer = (
f"ATTENZIONE: Il {(1-grounding_ratio)*100:.0f}% delle affermazioni potrebbe "
"non essere direttamente supportato dalle fonti citate. Verificare sempre "
"con il testo normativo originale."
)
warnings.append(f"Grounding score basso: {grounding_ratio:.2%}")
confidence = (
grounding_ratio * 0.6 +
(max(s.relevance_score for s in retrieved_sources) if retrieved_sources else 0) * 0.4
)
return LegalQueryResult(
query=query,
answer=answer,
sources=retrieved_sources,
confidence=confidence,
grounding_ratio=grounding_ratio,
uncertainty_disclaimer=disclaimer,
generated_at=datetime.utcnow(),
model_version="gpt-4o-guardrailed-v1",
warnings=warnings
)
스트리밍을 사용한 각도 인터페이스
전문 Legal Copilot은 변호사에게 최적의 사용자 경험을 제공해야 합니다. 그리고 법률 보조원. 인터페이스는 소스를 실시간으로 표시해야 하며 확장이 가능해야 합니다. 각 규범을 추출하고 응답의 신뢰 수준을 명확하게 만듭니다.
// legal-copilot.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subject } from 'rxjs';
export interface LegalSource {
docId: string;
docType: string;
citation: string;
contentChunk: string;
relevanceScore: number;
sourceUrl?: string;
}
export interface CopilotResponse {
query: string;
answer: string;
sources: LegalSource[];
confidence: number;
groundingRatio: number;
uncertaintyDisclaimer: string;
warnings: string[];
generatedAt: string;
}
@Injectable({ providedIn: 'root' })
export class LegalCopilotService {
private http = inject(HttpClient);
private readonly API_BASE = '/api/v1/legal-copilot';
isLoading = signal(false);
currentResponse = signal<CopilotResponse | null>(null);
streamingText = signal('');
ask(query: string): Observable<CopilotResponse> {
this.isLoading.set(true);
this.streamingText.set('');
return new Observable(observer => {
// SSE per streaming della risposta
const eventSource = new EventSource(
`{this.API_BASE}/stream?query={encodeURIComponent(query)}`
);
eventSource.addEventListener('token', (e: MessageEvent) => {
this.streamingText.update(prev => prev + e.data);
});
eventSource.addEventListener('complete', (e: MessageEvent) => {
const result = JSON.parse(e.data) as CopilotResponse;
this.currentResponse.set(result);
this.isLoading.set(false);
observer.next(result);
observer.complete();
eventSource.close();
});
eventSource.addEventListener('error', () => {
this.isLoading.set(false);
observer.error(new Error('Errore nella comunicazione con il Legal Copilot'));
eventSource.close();
});
return () => eventSource.close();
});
}
}
평가 프레임워크
Legal Copilot의 품질을 측정하려면 법적 영역에 특정한 측정 기준이 필요합니다. 사용자 만족도를 측정하는 것만으로는 충분하지 않습니다. 법적 정확성을 평가해야 합니다.
from dataclasses import dataclass
from typing import List
import statistics
@dataclass
class EvaluationCase:
"""Un caso di valutazione con risposta attesa verificata da un esperto legale."""
query: str
reference_answer: str # risposta attesa da avvocato senior
required_citations: List[str] # citazioni che devono comparire nella risposta
forbidden_claims: List[str] # affermazioni false che non devono apparire
class LegalCopilotEvaluator:
"""
Framework di evaluation per il Legal Copilot.
Combina metriche automatiche e expert review.
"""
def evaluate_response(
self,
response: LegalQueryResult,
eval_case: EvaluationCase
) -> dict:
"""Valuta una singola risposta su più dimensioni."""
# 1. Citation recall: % delle citazioni attese presenti nella risposta
answer_lower = response.answer.lower()
found_citations = sum(
1 for cit in eval_case.required_citations
if cit.lower() in answer_lower
)
citation_recall = found_citations / len(eval_case.required_citations) if eval_case.required_citations else 1.0
# 2. Hallucination rate: % delle affermazioni false rilevate
hallucinations_found = sum(
1 for claim in eval_case.forbidden_claims
if claim.lower() in answer_lower
)
hallucination_rate = hallucinations_found / len(eval_case.forbidden_claims) if eval_case.forbidden_claims else 0.0
# 3. Refusal appropriateness (solo per query senza copertura nel corpus)
# Valutazione manuale: 1 se il rifiuto era corretto, 0 se errato
return {
'citation_recall': citation_recall,
'hallucination_rate': hallucination_rate,
'grounding_ratio': response.grounding_ratio,
'confidence': response.confidence,
'answer_length': len(response.answer),
'sources_count': len(response.sources)
}
def aggregate_evaluation(self, results: List[dict]) -> dict:
"""Aggrega i risultati di più casi di valutazione."""
return {
'avg_citation_recall': statistics.mean(r['citation_recall'] for r in results),
'avg_hallucination_rate': statistics.mean(r['hallucination_rate'] for r in results),
'avg_grounding_ratio': statistics.mean(r['grounding_ratio'] for r in results),
'avg_confidence': statistics.mean(r['confidence'] for r in results),
'total_cases': len(results)
}
배포 및 규정 준수 고려 사항
필수 면책조항 및 시스템 제한사항
- 이는 법적 조언이 아닙니다. 각 답변을 동반해야 합니다. 시스템이 법적 정보를 제공하는 것이 아니라 법적 정보를 제공한다는 명시적인 면책 조항을 통해 맞춤형 법률 자문. 변호사 업무는 비밀로 유지됩니다. 자격을 갖춘 변호사에게.
- 코퍼스 업데이트: 규정이 변경됩니다. 코퍼스는 반드시 마지막 날짜 표시와 함께 적어도 매주 업데이트됩니다. 업데이트가 사용자에게 표시됩니다.
- 감사를 위한 로깅: 모든 쿼리와 응답은 다음과 같아야 합니다. 법률감사 및 지속적인 시스템 개선을 위해 로그인하였습니다.
- 사용자 책임: 계약상으로 다음과 같이 정의합니다. 시스템 응답을 기반으로 내린 결정에 대한 책임 사용자의 변호사에게 남습니다.
결론
법률 AI 도우미는 단순히 "법률 문서에 연결된 ChatGPT"가 아닙니다. 전문적인 RAG 아키텍처, 가드레일이 필요한 복잡한 시스템입니다. 환각을 위한 다단계, 업데이트된 법률 자료 및 인터페이스 법적 작업 흐름을 위해 특별히 설계되었습니다.
업계 수치는 명확합니다. 적절한 가드레일 없이 구축된 시스템입니다. 특정 법적 질문에 대한 사례의 17~33%에서 환각을 일으킵니다. 와 이 기사에 제시된 아키텍처 — RAG + 인용 접지 + 거부 논리 - 이 비율을 크게 줄이고 시스템을 구축하는 것이 가능합니다. 변호사들이 연구 및 분석 도구로 자신있게 사용할 수 있습니다.
LegalTech 및 AI 시리즈
- 계약 분석을 위한 NLP: OCR에서 이해까지
- e-Discovery 플랫폼 아키텍처
- 동적 규칙 엔진을 통한 규정 준수 자동화
- 법적 계약을 위한 스마트 계약: Solidity 및 Vyper
- Generative AI를 사용한 법률 문서 요약
- 검색 엔진 법칙: 벡터 임베딩
- Scala의 디지털 서명 및 문서 인증
- 데이터 개인정보 보호 및 GDPR 규정 준수 시스템
- 법률 AI 도우미 구축 - 법률 부조종사(이 문서)
- LegalTech 데이터 통합 패턴







