Definition
An entity graph is a directed, labeled graph data structure in which nodes represent real-world entities (people, organizations, assets, addresses, digital identifiers) and edges represent typed relationships between those entities (ownership, employment, directorship, transaction, communication). Unlike flat relational tables, entity graphs natively model the multi-hop, many-to-many, and recursive relationships that characterize real-world intelligence data -- making them the natural substrate for OSINT analysis, due diligence investigations, anti-money laundering (AML) compliance, and beneficial ownership mapping.
In the Prismatic Platform, entity graphs are stored and queried through KuzuDB, an embedded graph database that provides Cypher-compatible query language, ACID transactions, and columnar storage optimized for analytical workloads. The platform's entity graph layer bridges OSINT data collection (120+ adapters across Czech, EU, and global sources) with analytical capabilities like entity resolution, ownership chain analysis, and risk scoring, creating a unified intelligence fabric that transforms raw data into actionable knowledge.
Overview
The fundamental insight behind entity graphs is that relationships are data, not just foreign keys. In a traditional relational database, the relationship between a person and a company is a row in a junction table -- a structural artifact that requires JOINs to traverse. In an entity graph, that relationship is a first-class citizen with its own properties (role, start date, ownership percentage, confidence score), traversable in constant time per hop.
This distinction becomes critical at scale. Consider a beneficial ownership investigation that must trace ownership through a chain of shell companies across multiple jurisdictions. In SQL, this requires recursive CTEs with depth limits and exponential JOIN complexity. In a graph database, it is a simple variable-length path traversal:
MATCH (person:Person)-[:OWNS*1..5]->(target:Company {name: 'TargetCorp'})
RETURN person.name, length(path) AS chain_depth
Entity graphs in the Prismatic Platform serve three primary functions:
-
Intelligence Aggregation: Raw data from 120+ OSINT sources is ingested, normalized, and linked into a unified entity graph. A single company entity might have nodes from ARES (Czech business register), Censys (digital footprint), certificate transparency logs, and Shodan (exposed services), all connected through resolved entity relationships.
-
Analytical Traversal: Investigators can traverse the graph to discover non-obvious connections, ownership chains, and risk indicators. Graph algorithms (centrality, community detection, shortest path) surface patterns invisible in tabular data.
-
Temporal Modeling: Entity relationships change over time. A person may serve as director of a company from 2020-2023, then resign. The entity graph maintains temporal edges with validity periods, enabling point-in-time analysis and change detection.
Technical Details
Node Types
The Prismatic entity graph defines a taxonomy of node types based on real-world entity categories:
| Node Type | Description | Key Properties | Source Examples |
|---|---|---|---|
| Person | Individual human entity | name, date_of_birth, nationality, identifiers | ARES, Justice, LinkedIn |
| Organization | Company, NGO, government body | name, registration_number, jurisdiction, status | ARES, Commercial Register, SEC EDGAR |
| Address | Physical or postal address | street, city, country, coordinates | ARES, Google Maps, postal databases |
| Asset | Digital or physical asset | type, value, identifiers | Certificate transparency, DNS, Shodan |
| Domain | Internet domain name | name, registrar, creation_date, nameservers | DNS enumeration, WHOIS |
| IPAddress | Network address | address, asn, geolocation, services | Censys, Shodan, VirusTotal |
| Certificate | TLS/SSL certificate | subject, issuer, validity, SANs | Certificate transparency logs |
| Document | Legal or regulatory document | type, filing_date, reference_number | Justice, ISIR, Commercial Register |
Edge Types
Relationships are typed and carry metadata:
| Edge Type | From | To | Properties |
|---|---|---|---|
| OWNS | Person/Org | Organization | percentage, start_date, end_date, source |
| DIRECTS | Person | Organization | role, start_date, end_date, source |
| EMPLOYS | Organization | Person | position, department, start_date |
| LOCATED_AT | Person/Org | Address | type (registered, operational), since |
| HOSTS | IPAddress | Domain | first_seen, last_seen, protocol |
| ISSUED_TO | Certificate | Domain | valid_from, valid_to, issuer |
| RELATED_TO | Any | Any | relationship_type, confidence, source |
| SANCTIONED_BY | Person/Org | Document | list_name, designation_date, reason |
Graph Schema in KuzuDB
CREATE NODE TABLE Person(
id STRING PRIMARY KEY,
name STRING,
date_of_birth DATE,
nationality STRING,
identifiers MAP(STRING, STRING),
confidence FLOAT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
CREATE NODE TABLE Organization(
id STRING PRIMARY KEY,
name STRING,
registration_number STRING,
jurisdiction STRING,
status STRING,
founded_date DATE,
confidence FLOAT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
CREATE REL TABLE OWNS(
FROM Person TO Organization,
FROM Organization TO Organization,
percentage FLOAT,
start_date DATE,
end_date DATE,
source STRING,
confidence FLOAT
);
CREATE REL TABLE DIRECTS(
FROM Person TO Organization,
role STRING,
start_date DATE,
end_date DATE,
source STRING,
confidence FLOAT
);Interactive Entity Graph Visualization
Relationship Types
Implementation in Prismatic Platform
Entity Graph Manager
The platform's entity graph operations are coordinated through a dedicated OTP process:
defmodule PrismaticGraph.EntityManager do
@moduledoc """
Manages entity graph operations including node creation,
relationship linking, entity resolution, and graph traversal.
Uses KuzuDB as the underlying graph storage engine with
Cypher-compatible query execution.
"""
use GenServer
alias PrismaticGraph.{KuzuClient, EntityResolver, SchemaRegistry}
@type entity_type :: :person | :organization | :address | :asset | :domain | :ip_address
@type relationship_type :: :owns | :directs | :employs | :located_at | :hosts | :related_to
@type entity_id :: String.t()
@type confidence :: float()
@type entity :: %{
id: entity_id(),
type: entity_type(),
properties: map(),
confidence: confidence(),
sources: list(String.t())
}
@type relationship :: %{
from_id: entity_id(),
to_id: entity_id(),
type: relationship_type(),
properties: map(),
confidence: confidence()
}
@type traversal_result :: %{
paths: list(list(entity())),
depth: non_neg_integer(),
total_nodes: non_neg_integer()
}
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@spec create_entity(entity_type(), map()) :: {:ok, entity()} | {:error, String.t()}
def create_entity(type, properties) do
GenServer.call(__MODULE__, {:create_entity, type, properties})
end
@spec create_relationship(entity_id(), entity_id(), relationship_type(), map()) ::
{:ok, relationship()} | {:error, String.t()}
def create_relationship(from_id, to_id, type, properties \\ %{}) do
GenServer.call(__MODULE__, {:create_relationship, from_id, to_id, type, properties})
end
@spec find_entity(entity_id()) :: {:ok, entity()} | {:error, :not_found}
def find_entity(entity_id) do
GenServer.call(__MODULE__, {:find_entity, entity_id})
end
@spec traverse_ownership(entity_id(), non_neg_integer()) :: {:ok, traversal_result()}
def traverse_ownership(entity_id, max_depth \\ 5) do
GenServer.call(__MODULE__, {:traverse_ownership, entity_id, max_depth})
end
@spec resolve_entities(list(map())) :: {:ok, list(entity())}
def resolve_entities(candidate_entities) do
GenServer.call(__MODULE__, {:resolve_entities, candidate_entities})
end
@spec graph_statistics() :: map()
def graph_statistics do
GenServer.call(__MODULE__, :graph_statistics)
end
@impl true
@spec init(keyword()) :: {:ok, map()}
def init(opts) do
kuzu_path = Keyword.get(opts, :database_path, "priv/power_graph_kuzu")
state = %{
kuzu_path: kuzu_path,
schema_version: SchemaRegistry.current_version(),
entity_count: 0,
relationship_count: 0,
started_at: DateTime.utc_now()
}
{:ok, state}
end
@impl true
def handle_call({:create_entity, type, properties}, _from, state) do
entity_id = generate_entity_id(type, properties)
entity = %{
id: entity_id,
type: type,
properties: sanitize_properties(properties),
confidence: calculate_initial_confidence(properties),
sources: extract_sources(properties),
created_at: DateTime.utc_now()
}
case execute_create_node(type, entity, state) do
:ok ->
emit_entity_event(:created, entity)
{:reply, {:ok, entity}, %{state | entity_count: state.entity_count + 1}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call({:create_relationship, from_id, to_id, type, properties}, _from, state) do
relationship = %{
from_id: from_id,
to_id: to_id,
type: type,
properties: sanitize_properties(properties),
confidence: Map.get(properties, :confidence, 0.8),
created_at: DateTime.utc_now()
}
case execute_create_relationship(relationship, state) do
:ok ->
emit_relationship_event(:created, relationship)
new_state = %{state | relationship_count: state.relationship_count + 1}
{:reply, {:ok, relationship}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call({:find_entity, entity_id}, _from, state) do
result = execute_find_node(entity_id, state)
{:reply, result, state}
end
@impl true
def handle_call({:traverse_ownership, entity_id, max_depth}, _from, state) do
query = """
MATCH path = (start)-[:OWNS*1..#{}]->(target {id: $target_id})
RETURN path, length(path) AS depth
ORDER BY depth ASC
"""
case KuzuClient.execute(state.kuzu_path, query, %{target_id: entity_id}) do
{:ok, results} ->
traversal = %{
paths: format_paths(results),
depth: max_result_depth(results),
total_nodes: count_unique_nodes(results)
}
{:reply, {:ok, traversal}, state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call({:resolve_entities, candidates}, _from, state) do
resolved = EntityResolver.resolve(candidates)
{:reply, {:ok, resolved}, state}
end
@impl true
def handle_call(:graph_statistics, _from, state) do
stats = %{
entity_count: state.entity_count,
relationship_count: state.relationship_count,
schema_version: state.schema_version,
uptime_seconds: DateTime.diff(DateTime.utc_now(), state.started_at, :second)
}
{:reply, stats, state}
end
@spec generate_entity_id(entity_type(), map()) :: entity_id()
defp generate_entity_id(type, properties) do
fingerprint =
properties
|> Map.take([:name, :registration_number, :identifier])
|> :erlang.term_to_binary()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
"#{}_#{}"
end
@spec sanitize_properties(map()) :: map()
defp sanitize_properties(properties) do
properties
|> Map.drop([:__struct__, :__meta__])
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
@spec calculate_initial_confidence(map()) :: confidence()
defp calculate_initial_confidence(properties) do
source_count = properties |> Map.get(:sources, []) |> length()
cond do
source_count >= 3 -> 0.95
source_count == 2 -> 0.80
source_count == 1 -> 0.60
true -> 0.40
end
end
@spec extract_sources(map()) :: list(String.t())
defp extract_sources(properties) do
Map.get(properties, :sources, ["manual"])
end
@spec execute_create_node(entity_type(), entity(), map()) :: :ok | {:error, String.t()}
defp execute_create_node(type, entity, state) do
table_name = entity_type_to_table(type)
query = """
CREATE (n:#{} {id: $id, confidence: $confidence, created_at: $created_at})
"""
case KuzuClient.execute(state.kuzu_path, query, entity) do
{:ok, _} -> :ok
{:error, reason} -> {:error, "Failed to create node: #{}"}
end
end
@spec execute_create_relationship(relationship(), map()) :: :ok | {:error, String.t()}
defp execute_create_relationship(relationship, state) do
table_name = relationship_type_to_table(relationship.type)
query = """
MATCH (from {id: $from_id}), (to {id: $to_id})
CREATE (from)-[:#{} {confidence: $confidence}]->(to)
"""
case KuzuClient.execute(state.kuzu_path, query, relationship) do
{:ok, _} -> :ok
{:error, reason} -> {:error, "Failed to create relationship: #{}"}
end
end
@spec execute_find_node(entity_id(), map()) :: {:ok, entity()} | {:error, :not_found}
defp execute_find_node(entity_id, state) do
query = "MATCH (n {id: $id}) RETURN n"
case KuzuClient.execute(state.kuzu_path, query, %{id: entity_id}) do
{:ok, [result | _]} -> {:ok, result}
{:ok, []} -> {:error, :not_found}
{:error, _} -> {:error, :not_found}
end
end
@spec entity_type_to_table(entity_type()) :: String.t()
defp entity_type_to_table(:person), do: "Person"
defp entity_type_to_table(:organization), do: "Organization"
defp entity_type_to_table(:address), do: "Address"
defp entity_type_to_table(:asset), do: "Asset"
defp entity_type_to_table(:domain), do: "Domain"
defp entity_type_to_table(:ip_address), do: "IPAddress"
@spec relationship_type_to_table(relationship_type()) :: String.t()
defp relationship_type_to_table(:owns), do: "OWNS"
defp relationship_type_to_table(:directs), do: "DIRECTS"
defp relationship_type_to_table(:employs), do: "EMPLOYS"
defp relationship_type_to_table(:located_at), do: "LOCATED_AT"
defp relationship_type_to_table(:hosts), do: "HOSTS"
defp relationship_type_to_table(:related_to), do: "RELATED_TO"
@spec format_paths(list()) :: list(list(entity()))
defp format_paths(results), do: Enum.map(results, & &1[:path])
@spec max_result_depth(list()) :: non_neg_integer()
defp max_result_depth([]), do: 0
defp max_result_depth(results), do: results |> Enum.map(& &1[:depth]) |> Enum.max()
@spec count_unique_nodes(list()) :: non_neg_integer()
defp count_unique_nodes(results) do
results
|> Enum.flat_map(& &1[:nodes])
|> Enum.uniq_by(& &1[:id])
|> length()
end
@spec emit_entity_event(atom(), entity()) :: :ok
defp emit_entity_event(event, entity) do
:telemetry.execute(
[:prismatic_graph, :entity, event],
%{timestamp: System.monotonic_time()},
%{type: entity.type, id: entity.id, confidence: entity.confidence}
)
end
@spec emit_relationship_event(atom(), relationship()) :: :ok
defp emit_relationship_event(event, relationship) do
:telemetry.execute(
[:prismatic_graph, :relationship, event],
%{timestamp: System.monotonic_time()},
%{type: relationship.type, from: relationship.from_id, to: relationship.to_id}
)
end
endBeneficial Ownership Analyzer
A specialized module for tracing ownership through complex corporate structures:
defmodule PrismaticGraph.BeneficialOwnership do
@moduledoc """
Analyzes entity graphs to determine ultimate beneficial
ownership (UBO) through multi-hop corporate structures.
Implements EU Anti-Money Laundering Directive thresholds
(25% direct or indirect ownership).
"""
alias PrismaticGraph.EntityManager
@ubo_threshold 0.25
@type ownership_chain :: %{
ultimate_owner: EntityManager.entity(),
chain: list(ownership_hop()),
effective_percentage: float(),
confidence: float()
}
@type ownership_hop :: %{
entity: EntityManager.entity(),
percentage: float(),
relationship_type: atom()
}
@spec find_beneficial_owners(EntityManager.entity_id()) :: {:ok, list(ownership_chain())}
def find_beneficial_owners(company_id) do
with {:ok, traversal} <- EntityManager.traverse_ownership(company_id, 10) do
chains =
traversal.paths
|> Enum.map(&calculate_effective_ownership/1)
|> Enum.filter(&(&1.effective_percentage >= @ubo_threshold))
|> Enum.sort_by(& &1.effective_percentage, :desc)
{:ok, chains}
end
end
@spec calculate_effective_ownership(list(map())) :: ownership_chain()
defp calculate_effective_ownership(path) do
hops = Enum.map(path, fn node ->
%{
entity: node,
percentage: Map.get(node, :ownership_percentage, 1.0),
relationship_type: Map.get(node, :relationship_type, :owns)
}
end)
effective = Enum.reduce(hops, 1.0, fn hop, acc -> acc * hop.percentage end)
confidence = Enum.reduce(hops, 1.0, fn hop, acc -> acc * Map.get(hop.entity, :confidence, 0.8) end)
%{
ultimate_owner: List.first(hops).entity,
chain: hops,
effective_percentage: effective,
confidence: confidence
}
end
endComparison with Alternatives
Entity Graph vs. Relational Database
Relational databases excel at structured, tabular data with fixed schemas. Entity graphs excel at relationship-heavy data with variable schemas. For a simple CRM (customer has orders), a relational database is more efficient. For an OSINT investigation (person owns company A, which owns company B, which owns company C, which transacts with sanctioned entity D), an entity graph is orders of magnitude more efficient due to index-free adjacency.
Entity Graph vs. Knowledge Graph
A knowledge graph is a broader concept that includes semantic meaning (ontologies, RDF triples, reasoning). An entity graph is a pragmatic subset focused on real-world entities and typed relationships without the full semantic overhead. The Prismatic Platform uses entity graphs for operational intelligence and belief graphs for epistemic reasoning.
Entity Graph vs. Document Store
Document stores (MongoDB, Elasticsearch) embed relationships within documents. This works for shallow nesting but fails for deep, recursive relationships. Entity graphs maintain relationships as external edges, enabling unlimited traversal depth without document bloat.
Entity Graph vs. Property Graph (Neo4j)
Neo4j's property graph model is conceptually similar to entity graphs. The distinction is architectural: Neo4j is a client-server database requiring network communication, while KuzuDB is an embedded database running in-process with the Elixir application. This eliminates network latency and simplifies deployment.
| Aspect | Entity Graph (KuzuDB) | Relational (PostgreSQL) | Knowledge Graph (RDF) | Property Graph (Neo4j) |
|---|---|---|---|---|
| Relationship traversal | O(1) per hop | O(n) JOIN | O(1) per hop | O(1) per hop |
| Schema flexibility | High | Low | Very high | High |
| Deployment | Embedded | Client-server | Client-server | Client-server |
| Analytical queries | Columnar-optimized | Good (with indexes) | Variable | Moderate |
| Semantic reasoning | No | No | Yes (OWL/RDFS) | No |
| ACID transactions | Yes | Yes | Varies | Yes |
Best Practices
-
Model entities by real-world identity, not data source: A person should have one node in the graph regardless of how many OSINT sources mention them. Use entity resolution to merge duplicate mentions into canonical entities.
-
Carry confidence scores on every node and edge: Intelligence data varies in reliability. A government registry entry (confidence 0.95) should be weighted differently from an unverified web scrape (confidence 0.40). Propagate confidence through traversals to produce calibrated result confidence.
-
Model time explicitly on relationships: Ownership, directorship, and employment relationships have start and end dates. Always include temporal properties to enable point-in-time analysis and change detection.
-
Use typed edges, not generic relationships: Prefer
OWNS,DIRECTS,EMPLOYSover genericRELATED_TO. Typed edges enable precise queries and reduce false positives in traversals. -
Index frequently traversed properties: Entity IDs, names, and registration numbers should be indexed in KuzuDB for fast lookups. Graph traversal is fast, but the initial node lookup benefits from indexing.
-
Validate graph consistency periodically: Run consistency checks to ensure all relationships reference existing nodes, ownership percentages are valid (0-100%), and temporal properties are logically consistent (start_date < end_date).
-
Separate ingestion from analysis: Use a data pipeline to ingest and normalize OSINT data into the entity graph, and a separate query layer for analytical traversals. This prevents long-running analysis from blocking ingestion.
Common Pitfalls
-
Entity duplication: Failing to resolve entities across sources, creating multiple nodes for the same real-world entity. "ACME Corp" from ARES and "ACME Corporation" from SEC EDGAR should resolve to one node, not two. Invest in robust entity resolution.
-
Ignoring confidence propagation: Treating all relationships as equally reliable. A multi-hop ownership chain where one hop has low confidence should not produce a high-confidence conclusion. Multiply confidence scores through the chain.
-
Unbounded traversals: Executing variable-length path queries without depth limits in production. A query like
MATCH path = (a)-[*]->(b)can explode combinatorially on dense graphs. Always set explicit depth limits. -
Schema over-engineering: Creating too many fine-grained node and edge types that fragment the graph and complicate queries. Start with a minimal schema (Person, Organization, Address, Domain) and extend only when specific analytical needs demand it.
-
Neglecting temporal consistency: Querying the graph without time context, mixing current and historical relationships. An ownership chain valid in 2020 may not be valid in 2025. Always scope queries to a specific temporal window.
-
Treating the graph as a data lake: Dumping raw OSINT data into graph nodes without normalization. Entity graphs should contain clean, resolved, structured data. Raw data belongs in the ETL staging layer.
Use Cases
OSINT Intelligence Aggregation
The platform's 120+ OSINT adapters produce raw data about entities across Czech registries (ARES, Justice, ISIR), global sources (Shodan, Censys, VirusTotal), and sanctions lists (EU, OFAC, UN). The entity graph aggregates this data into a unified intelligence picture, enabling analysts to see connections that are invisible in isolated data sources.
Beneficial Ownership Mapping
For compliance with EU Anti-Money Laundering Directives, the entity graph traces ownership chains through complex corporate structures to identify ultimate beneficial owners (UBOs). This is essential for KYC (Know Your Customer) processes and sanctions screening.
Attack Surface Discovery
In EASM operations, the entity graph maps the relationship between domains, IP addresses, certificates, and services to build a complete picture of an organization's external attack surface. Graph traversal reveals infrastructure dependencies and hidden exposure points.
Due Diligence Investigation
For M&A transactions and business partnerships, the entity graph provides comprehensive background information on target entities, including corporate structure, key personnel, litigation history, sanctions exposure, and financial indicators.
Risk Scoring
The entity graph enables graph-based risk scoring where an entity's risk is influenced by its relationships. A company directed by a sanctioned individual inherits risk through the graph, even if the company itself is not directly sanctioned.
Related Concepts
- Knowledge Graph -- broader concept encompassing semantic reasoning and ontologies beyond entity relationships
- Entity Resolution -- the process of determining when two entity references denote the same real-world entity
- Due Diligence -- investigative process that heavily relies on entity graph traversal
- Data Pipeline -- the ingestion and normalization layer that feeds data into the entity graph
- EASM -- external attack surface management that uses entity graphs to map infrastructure
- Belief Graph -- epistemic graph used for reasoning about the reliability of entity graph data
- Ecto -- the relational data layer that complements graph storage for structured entity metadata
- Embedding -- vector representations used for entity similarity and fuzzy matching
- Cosine Similarity -- similarity metric used in entity resolution within the graph
- Epistemic Pipeline -- the processing chain that transforms raw OSINT data into graph-ready entities
See Also
- Distributed System -- architecture patterns for scaling entity graphs across nodes
- ETS -- in-memory storage used for caching frequently accessed graph query results
- Event Sourcing -- pattern for tracking all changes to the entity graph over time
- GraphQL -- query language that can expose entity graph data to external consumers
- Confidence Scoring -- the scoring system applied to entity graph nodes and edges
Connect & Contribute
Created by Tomas Korcak (korczis) | Open Source under GHL