3D Knowledge Graph of EU Regulation
Extract entities and relationships from EU legal documents and visualize them as an interactive 3D knowledge graph.
What you'll build
EU regulation forms a dense web of cross-references: directives cite regulations, CJEU rulings interpret treaty articles, and national courts apply preliminary rulings. Visualizing this structure as a knowledge graph reveals relationships that are invisible in linear document lists. This project uses the Moonlit API to collect EU legal documents, extract entities (courts, articles, directives, case numbers) and relationships (cites, interprets, amends, repeals), and build a graph database. The graph is then rendered as an interactive 3D visualization using Three.js where nodes are documents and edges are legal relationships. The result is a tool that lets researchers explore the regulatory landscape spatially -- seeing at a glance which directives are most cited, which CJEU rulings are central to a legal domain, and how different pieces of legislation interconnect.
Architecture
┌─────────────┐ ┌────────────────┐ ┌───────────────┐
│ Moonlit API │──▶│ Entity │──▶│ Graph DB │
│ search + │ │ Extraction │ │ (Neo4j) │
│ retrieve │ │ (LLM-powered) │ │ │
└─────────────┘ └────────────────┘ └───────┬───────┘
│
▼
┌───────────────┐
│ 3D Force │
│ Graph (Three) │
│ o---o │
│ / \ | │
│ o o-o │
└───────────────┘Prerequisites
- A Moonlit API key
- Python 3.10+ with requests, anthropic, neo4j-driver
- Neo4j database (local Docker or Aura cloud)
- Node.js project with three.js and 3d-force-graph
- An Anthropic API key for entity extraction
Step-by-step
Collect documents from a regulatory domain
Use the Moonlit search API to collect all EU documents in a specific regulatory area. Paginate through results to build a comprehensive corpus.
import requests
MOONLIT_KEY = "your-api-key"
BASE_URL = "https://api.moonlit.ai/v1.1"
def collect_corpus(query: str, max_docs: int = 200) -> list[dict]:
"""Collect documents by paginating through search results."""
docs = []
page = 1
while len(docs) < max_docs:
response = requests.post(
f"{BASE_URL}/search/hybrid_search",
headers={
"Ocp-Apim-Subscription-Key": MOONLIT_KEY,
"Content-Type": "application/json",
},
json={
"query": query,
"jurisdictions": ["European Union"],
"documentTypes": ["case_law", "legislation"],
"num_results": 50,
"page": page,
},
)
data = response.json()
results = data.get("result", {}).get("results", [])
if not results:
break
docs.extend(results)
page += 1
# Retrieve full text for each document
full_docs = []
for doc in docs[:max_docs]:
detail = requests.get(
f"{BASE_URL}/document/retrieve_document",
headers={"Ocp-Apim-Subscription-Key": MOONLIT_KEY},
params={"DocumentIdentifier": doc["identifier"]},
)
if detail.ok:
full_docs.append(detail.json())
return full_docs
corpus = collect_corpus("Digital Services Act platform liability", max_docs=100)
print(f"Collected {len(corpus)} documents")Extract entities and relationships with an LLM
Use Claude to extract structured entities (directives, regulations, articles, courts, case numbers) and relationships (cites, interprets, amends) from each document.
import anthropic
import json
client = anthropic.Anthropic()
EXTRACTION_PROMPT = """Extract legal entities and relationships from this document.
Return JSON with this structure:
{
"entities": [
{"id": "...", "type": "directive|regulation|case|article|court", "label": "..."}
],
"relationships": [
{"source": "...", "target": "...", "type": "cites|interprets|amends|repeals|applies"}
]
}
Document:
"""
def extract_graph(doc: dict) -> dict:
text = doc.get("text", doc.get("summary", ""))[:4000]
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
messages=[{
"role": "user",
"content": f"{EXTRACTION_PROMPT}\nID: {doc['identifier']}\nTitle: {doc['title']}\n\n{text}",
}],
)
try:
return json.loads(response.content[0].text)
except json.JSONDecodeError:
return {"entities": [], "relationships": []}Load the graph into Neo4j
Insert extracted entities as nodes and relationships as edges into a Neo4j graph database. Merge on entity ID to deduplicate.
from neo4j import GraphDatabase
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
def load_graph(extracted: dict):
with driver.session() as session:
for entity in extracted["entities"]:
session.run(
"MERGE (n:LegalEntity {id: $id}) "
"SET n.type = $type, n.label = $label",
id=entity["id"], type=entity["type"], label=entity["label"],
)
for rel in extracted["relationships"]:
session.run(
"MATCH (a:LegalEntity {id: $src}), (b:LegalEntity {id: $tgt}) "
"MERGE (a)-[r:LEGAL_REL {type: $type}]->(b)",
src=rel["source"], tgt=rel["target"], type=rel["type"],
)
for doc in corpus:
graph_data = extract_graph(doc)
load_graph(graph_data)
print(f"Processed: {doc['identifier']}")Visualize in 3D with Three.js
Export the graph from Neo4j and render it using the 3d-force-graph library. Color-code nodes by entity type and scale them by citation count.
import ForceGraph3D from "3d-force-graph";
const graphData = await fetch("/api/graph").then((r) => r.json());
const Graph = ForceGraph3D()(document.getElementById("graph-container"))
.graphData(graphData)
.nodeLabel("label")
.nodeColor((node) => {
const colors = {
directive: "#4F46E5",
regulation: "#059669",
case: "#DC2626",
article: "#D97706",
court: "#7C3AED",
};
return colors[node.type] || "#6B7280";
})
.nodeVal((node) => Math.max(node.citations || 1, 1))
.linkLabel("type")
.linkColor("#CBD5E1")
.linkWidth(1)
.onNodeClick((node) => {
window.open(node.url, "_blank");
});