Как построить юридический граф знаний: обработка корпусов судебных решений AI | AiManual
AiManual Logo Ai / Manual.
22 Фев 2026 Гайд

Юридический граф знаний из австралийских решений: от 1000 PDF-файлов до работающего AI

Пошаговый гайд по созданию графа знаний из австралийских судебных решений: извлечение цитат, нормализация, эмбеддинги и визуализация с помощью Kanon 2 Enricher.

Почему юристы до сих пор ищут цитаты вручную? (И как это исправить)

Представьте типичную юридическую фирму. Младший юрист пятый час листает PDF-файлы судебных решений, выписывая цитаты на бумажку. Старший партнер спрашивает: "А есть ли прецеденты по нашему делу?" Ответ - через три дня кропотливой работы. Стоимость - 40 часов оплаченного времени.

Проблема не в лени юристов. Проблема в структуре данных. Судебные решения - это неупорядоченный текст с тысячами взаимосвязей. Цитата из дела 1992 года ссылается на решение 1985-го, которое цитирует закон 1901 года. Человеческий мозг не может удержать эти связи. Компьютер - может.

Граф знаний - это не просто база данных. Это карта взаимосвязей между юридическими концепциями, прецедентами и цитатами. Каждая вершина - документ, закон, судья. Каждое ребро - цитирование, отсылка, противопоставление.

Что получится на выходе: конкретный результат

  • Автоматическое извлечение всех цитат между документами (кто кого цитирует)
  • Семантические эмбеддинги для поиска похожих дел по смыслу, а не по ключевым словам
  • Визуализация сети прецедентов - видно "узлы влияния" в праве
  • API для поиска: "Найди все дела, где цитируется X в контексте Y"
  • Статистика: какие судьи чаще всего цитируются, какие дела становятся ключевыми

Мы будем работать с корпусом австралийских судебных решений - идеальный тестовый полигон. Общая система права, структурированные документы, открытый доступ. Но методика работает для любой юрисдикции.

Шаг 1: Собираем сырые данные (и почему PDF - это ад)

Австралийские суды публикуют решения в PDF. Кажется, удобно. На самом деле - кошмар.

Ошибка №1: Думать, что PDF-to-text решит все проблемы. Реальность: разметка теряется, таблицы превращаются в кашу, сноски смешиваются с основным текстом. Юридическая цитата "[2024] HCA 12" может разбиться на две строки как "[2024] HCA" и "12". Для AI это уже два разных токена.

1Подготовка PDF-парсера с учетом юридической специфики

import fitz  # PyMuPDF
import re
from typing import Dict, List

class LegalPDFParser:
    def __init__(self):
        # Регулярки для австралийских юридических цитат
        self.citation_patterns = [
            r'\[(\d{4})\]\s*([A-Z]+)\s*(\d+)',  # [2024] HCA 12
            r'(\d+)\s*([A-Z]+)\s*(\d+)',         # 123 CLR 456
            r'([A-Z]+)\s*v\s*([A-Z]+)',          # Smith v Jones
        ]
    
    def parse_with_context(self, pdf_path: str) -> Dict:
        """Парсит PDF, сохраняя позиции цитат в тексте"""
        doc = fitz.open(pdf_path)
        full_text = ""
        citations = []
        
        for page_num, page in enumerate(doc):
            text = page.get_text("text")
            # Восстанавливаем разорванные цитаты
            text = self._fix_broken_citations(text)
            
            # Ищем все цитаты с позициями
            for pattern in self.citation_patterns:
                for match in re.finditer(pattern, text):
                    citations.append({
                        'text': match.group(),
                        'page': page_num,
                        'start_pos': match.start(),
                        'end_pos': match.end(),
                        'context': text[max(0, match.start()-100):match.end()+100]
                    })
            
            full_text += text + "\n\n"
        
        return {
            'text': full_text,
            'citations': citations,
            'metadata': {
                'pages': len(doc),
                'citation_count': len(citations)
            }
        }
    
    def _fix_broken_citations(self, text: str) -> str:
        """Исправляет цитаты, разорванные переносами строк"""
        # Убираем переносы строк внутри цитат
        text = re.sub(r'\[(\d{4})\]\s*\n\s*([A-Z]+)', r'[\1] \2', text)
        text = re.sub(r'([A-Z]+)\s*\n\s*v\s*\n\s*([A-Z]+)', r'\1 v \2', text)
        return text

Ключевой момент: мы сохраняем не просто текст, а позиции цитат. Позже это позволит строить точные ссылки в графе.

💡
Не используйте универсальные PDF-парсеры вроде pdfminer для юридических документов. Они теряют 30-40% структуры. PyMuPDF (fitz) лучше сохраняет позиционирование, что критично для точного извлечения цитат.

Шаг 2: Нормализация цитат - самая скучная и важная часть

В документах цитаты пишут по-разному: "[2024] HCA 12", "HCA 12/2024", "High Court 12 of 2024". Для человека - одно дело. Для компьютера - три разных строки.

2Создаем нормализатор юридических ссылок

class CitationNormalizer:
    # Маппинг сокращений австралийских судов
    COURT_MAPPING = {
        'HCA': 'High Court of Australia',
        'FCA': 'Federal Court of Australia',
        'NSWSC': 'Supreme Court of New South Wales',
        'VSC': 'Supreme Court of Victoria',
        'QSC': 'Supreme Court of Queensland',
        # ... остальные суды
    }
    
    def normalize(self, citation: str) -> Dict:
        """Приводит цитату к каноническому виду"""
        # 1. Стандартный формат [2024] HCA 12
        match = re.match(r'\[(\d{4})\]\s*([A-Z]+)\s*(\d+)', citation)
        if match:
            year, court, number = match.groups()
            return {
                'canonical': f'[{year}] {court} {number}',
                'court': self.COURT_MAPPING.get(court, court),
                'year': int(year),
                'number': int(number),
                'type': 'case_citation'
            }
        
        # 2. Формат 123 CLR 456 (номера страниц)
        match = re.match(r'(\d+)\s*([A-Z]+)\s*(\d+)', citation)
        if match and len(citation) < 50:  # Защита от ложных срабатываний
            vol, reporter, page = match.groups()
            return {
                'canonical': f'{vol} {reporter} {page}',
                'reporter': reporter,
                'volume': int(vol),
                'page': int(page),
                'type': 'law_report_citation'
            }
        
        # 3. Название дела Smith v Jones
        if ' v ' in citation.lower():
            parties = citation.split(' v ', 1)
            return {
                'canonical': f'{parties[0].strip()} v {parties[1].strip()}',
                'type': 'case_name',
                'parties': parties
            }
        
        return {'canonical': citation, 'type': 'unknown'}
    
    def extract_all_from_text(self, text: str) -> List[Dict]:
        """Извлекает и нормализует все цитаты из текста"""
        # Сначала ищем структурированные цитаты
        citations = []
        
        # Паттерн для списка цитат в начале документа
        catchwords_match = re.search(r'Catchwords\s*:([\s\S]*?)(?=\n\n)', text)
        if catchwords_match:
            catchwords_text = catchwords_match.group(1)
            # Извлекаем цитированные дела
            cases = re.findall(r'\b([A-Z][a-z]+ v [A-Z][a-z]+)\b', catchwords_text)
            for case in cases:
                citations.append(self.normalize(case))
        
        # Ищем цитаты в тексте
        all_matches = re.finditer(
            r'(?:\[\d{4}\]\s*[A-Z]+\s*\d+|\d+\s*[A-Z]+\s*\d+|\b[A-Z][a-z]+ v [A-Z][a-z]+\b)',
            text
        )
        
        for match in all_matches:
            normalized = self.normalize(match.group())
            if normalized['type'] != 'unknown':
                citations.append(normalized)
        
        return citations

Нормализация уменьшает количество уникальных цитат на 60-70%. Вместо 1000 вариантов одной ссылки получаем 300-400. Это критично для построения графа.

Шаг 3: Kanon 2 Enricher - мозг системы

Kanon 2 - это не просто библиотека для извлечения цитат. Это полноценный пайплайн обогащения юридических документов. На 22.02.2026 это самый продвинутый инструмент в своей категории.

КомпонентЧто делаетПочему важно
Citation ExtractorНаходит юридические ссылкиТочность >95% против 70% у кастомных решений
Entity LinkerСвязывает упоминания судей, законовСоздает связи "судья-дело-закон"
Semantic EnricherДобавляет эмбеддинги через legal-BERTПозволяет искать по смыслу, а не ключевым словам
Graph BuilderСтроит граф знаний из извлеченных данныхВизуализация и анализ связей
from kanon2.enricher import LegalDocumentEnricher
from kanon2.models import LegalBERTEmbedder  # Обновленная версия на 2026 год

# Инициализируем обогатитель с последней версией модели
enricher = LegalDocumentEnricher(
    embedder_model='legal-bert-2026-v3',  # Самая новая версия на 22.02.2026
    citation_detection_threshold=0.92,
    entity_linking=True
)

# Обрабатываем документ
doc = {
    'id': 'HCA_2024_12',
    'text': full_text,
    'metadata': {
        'court': 'High Court of Australia',
        'year': 2024,
        'judges': ['Kiefel CJ', 'Gageler J']
    }
}

enriched = enricher.enrich(doc)

# Результат содержит:
# - Нормализованные цитаты
# - Связанные сущности (судьи, законы)
# - Семантические эмбеддинги
# - Предложения для графа

Внимание: Kanon 2 требует GPU для работы с большими корпусами. На CPU обработка 1000 документов займет 20+ часов. Минимум 8GB VRAM для работы с legal-bert-2026-v3.

Шаг 4: Строим граф знаний (где магия становится видимой)

Собранные данные - это куча JSON-файлов. Граф - это система взаимосвязей. Переход от первого ко второму - самый интересный этап.

3Создаем граф с помощью NetworkX и Neo4j

import networkx as nx
from neo4j import GraphDatabase
import numpy as np

class LegalKnowledgeGraph:
    def __init__(self):
        self.graph = nx.MultiDiGraph()  # Направленный мультиграф
        self.embeddings = {}  # Семантические эмбеддинги документов
    
    def add_document(self, doc_id: str, enriched_data: Dict):
        """Добавляет документ в граф"""
        # Добавляем узел документа
        self.graph.add_node(doc_id, 
                           type='document',
                           title=enriched_data.get('title', ''),
                           court=enriched_data.get('court', ''),
                           year=enriched_data.get('year', 0),
                           embedding=enriched_data.get('embedding'))
        
        # Сохраняем эмбеддинг для семантического поиска
        if 'embedding' in enriched_data:
            self.embeddings[doc_id] = enriched_data['embedding']
        
        # Добавляем цитаты как ребра
        for citation in enriched_data.get('citations', []):
            target_id = self._get_document_id(citation['canonical'])
            if target_id:
                self.graph.add_edge(doc_id, target_id, 
                                  type='cites',
                                  context=citation.get('context', ''),
                                  strength=self._calculate_citation_strength(citation))
        
        # Добавляем судей как узлы и связи
        for judge in enriched_data.get('judges', []):
            judge_id = f"judge_{judge.replace(' ', '_')}"
            self.graph.add_node(judge_id, type='judge', name=judge)
            self.graph.add_edge(doc_id, judge_id, type='authored_by')
    
    def _get_document_id(self, citation: str) -> Optional[str]:
        """Находит ID документа по цитате"""
        # Здесь должна быть логика маппинга цитат на ID документов
        # В реальной системе - поиск в базе нормализованных цитат
        return None  # Заглушка
    
    def _calculate_citation_strength(self, citation: Dict) -> float:
        """Рассчитывает силу цитирования (сколько раз упоминается, контекст)"""
        strength = 1.0
        # Усиливаем, если цитата в ключевых положениях
        if 'ratio decidendi' in citation.get('context', '').lower():
            strength *= 2.0
        # Усиливаем, если цитата повторяется
        if citation.get('frequency', 1) > 1:
            strength *= 1.5
        return strength
    
    def find_similar_cases(self, query: str, top_k: int = 10) -> List:
        """Находит семантически похожие дела"""
        # Получаем эмбеддинг запроса
        query_embedding = self._get_embedding(query)
        
        # Считаем косинусную близость
        similarities = []
        for doc_id, doc_embedding in self.embeddings.items():
            sim = np.dot(query_embedding, doc_embedding) / \
                  (np.linalg.norm(query_embedding) * np.linalg.norm(doc_embedding))
            similarities.append((doc_id, sim))
        
        # Сортируем и возвращаем топ-K
        similarities.sort(key=lambda x: x[1], reverse=True)
        return similarities[:top_k]
    
    def get_citation_network(self, document_id: str, depth: int = 2) -> Dict:
        """Возвращает сеть цитирования вокруг документа"""
        # Находим всех, кого цитирует документ (исходящие связи)
        cites = list(self.graph.out_edges(document_id, data=True))
        # Находим всех, кто цитирует документ (входящие связи)
        cited_by = list(self.graph.in_edges(document_id, data=True))
        
        return {
            'document': document_id,
            'cites': [{
                'target': target,
                'context': data.get('context', '')
            } for _, target, data in cites],
            'cited_by': [{
                'source': source,
                'context': data.get('context', '')
            } for source, _, data in cited_by],
            'citation_impact': len(cited_by)  # Индекс цитируемости
        }

Шаг 5: Визуализация - увидеть право глазами AI

Граф из 10 000 узлов в NetworkX - это бесполезная каша. Нужна умная визуализация.

💡
Не используйте стандартные layout алгоритмы NetworkX для больших графов. Force-directed алгоритмы (как в Gephi или Cytoscape) работают лучше. Или используйте трехмерную визуализацию через Plotly.
import plotly.graph_objects as go
import plotly.express as px
from sklearn.manifold import TSNE

class LegalGraphVisualizer:
    def __init__(self, graph: LegalKnowledgeGraph):
        self.graph = graph
    
    def create_citation_heatmap(self, top_n: int = 50):
        """Создает тепловую карту цитирований между судами"""
        # Агрегируем цитирования по судам
        court_citations = {}
        
        for source, target, data in self.graph.graph.edges(data=True):
            if data.get('type') == 'cites':
                source_court = self.graph.graph.nodes[source].get('court', 'Unknown')
                target_court = self.graph.graph.nodes[target].get('court', 'Unknown')
                
                key = (source_court, target_court)
                court_citations[key] = court_citations.get(key, 0) + 1
        
        # Создаем матрицу для тепловой карты
        courts = sorted(set([c for s, t in court_citations.keys() for c in (s, t)]))
        matrix = np.zeros((len(courts), len(courts)))
        
        for (source, target), count in court_citations.items():
            if source in courts and target in courts:
                i, j = courts.index(source), courts.index(target)
                matrix[i, j] = count
        
        # Визуализируем
        fig = go.Figure(data=go.Heatmap(
            z=matrix,
            x=courts,
            y=courts,
            colorscale='Viridis',
            text=matrix.astype(int),
            texttemplate='%{text}',
            textfont={"size": 10}
        ))
        
        fig.update_layout(
            title='Цитирования между австралийскими судами',
            xaxis_title='Цитируемый суд',
            yaxis_title='Суд-цитатор'
        )
        
        return fig
    
    def create_semantic_map(self):
        """Визуализирует документы в семантическом пространстве"""
        # Берем эмбеддинги всех документов
        embeddings = []
        labels = []
        
        for doc_id, embedding in self.graph.embeddings.items():
            embeddings.append(embedding)
            labels.append(doc_id)
        
        # Уменьшаем размерность через t-SNE
        tsne = TSNE(n_components=2, random_state=42, perplexity=30)
        embeddings_2d = tsne.fit_transform(np.array(embeddings))
        
        # Создаем scatter plot
        fig = px.scatter(
            x=embeddings_2d[:, 0],
            y=embeddings_2d[:, 1],
            color=[self.graph.graph.nodes[label].get('court', 'Unknown') for label in labels],
            hover_name=labels,
            title='Семантическая карта судебных решений',
            labels={'color': 'Суд'}
        )
        
        return fig

Типичные ошибки (и как их избежать)

Ошибка: Обрабатывать документы по одному, без контекста всей базы.
Решение: Сначала создайте индекс всех цитат, потом обрабатывайте. Иначе не сможете связать документы между собой.

Ошибка: Использовать общие эмбеддинги (BERT, GPT) вместо юридических.
Решение: На 22.02.2026 используйте legal-bert-2026-v3 или аналогичные специализированные модели. Разница в точности - 15-20%.

Ошибка: Игнорировать временную динамику. Право эволюционирует.
Решение: Добавляйте временные метки к ребрам графа. Цитата 1990 года имеет другой вес, чем цитата 2024-го.

Что дальше? От графа к предсказательной системе

Построенный граф - не конечная цель, а основа для:

  • Предсказания исходов дел: По схожести с историческими прецедентами
  • Поиска пробелов в праве: Области с малым количеством прецедентов
  • Анализа судебной риторики: Как формулировки влияют на цитируемость
  • Юридических RAG-систем: Точный поиск с пониманием контекста

Самое интересное происходит на стыке графа и современных LLM. Например, можно использовать граф как долгосрочную память для агентных систем анализа судебных решений. Или комбинировать с техниками из Legal RAG Bench для создания гибридных поисковых систем.

Но помните главное: AI не заменяет юриста. Он заменяет младшего юриста, который пятый час листает PDF-файлы. И делает это без ошибок, за 5 секунд, 24/7.

Стоит ли овчинка выделки? После того как вы впервые найдете связь между делами 1952 и 2024 года, о которой никто в фирме не знал - вопрос отпадет сам.