← Back to list

How We Built a Financial Intelligence Engine That Thinks in Relationships

Turning raw SEC filings, ownership records, and ontologies into a living graph — where every question about money, power, and corporate…

Akash Goyal · 2026-04-22 18:35 · 79 claps · 13.0 min read paywalled
#finance #knowledge-graph #neo4j #ontology #graphml
Open on Medium ↗
Wiki topics: ECO · Economy · General PHI · Philosophy 💑 · Relationships

How We Built a Financial Intelligence Engine That Thinks in Relationships

Turning raw SEC filings, ownership records, and ontologies into a living graph — where every question about money, power, and corporate control becomes a simple traversal.

Financial Intelligence Engine

Financial Intelligence Engine

The Problem with Financial Data

Picture a financial analyst trying to answer a deceptively simple question: “What is BlackRock’s full exposure to Apple — through every fund, subsidiary, and holding company they control?”

If you’re not a Medium member, you can read the full story using the MEDIUM FRIEND LINK

In a traditional database, this means joining a dozen tables, resolving name variants, writing painful SQL, and probably giving up halfway. The data is there, but it’s buried under layers of disconnected silos.

Now picture the same question answered in a single graph query — one that follows every ownership thread, surfaces every regulatory filing, and returns the complete picture in milliseconds.

That’s the promise of a Financial Knowledge Graph (FKG): a system where the relationships between companies, instruments, filings, and events are as important as the entities themselves. This post walks through how we built one from scratch — pulling real data from GLEIF, SEC EDGAR, OpenFIGI, and FIBO — and wired it up to LLMs to create something closer to a financial investigator than a search engine.

What Makes a Knowledge Graph Different

Before diving in, it’s worth understanding why a graph database changes the game here.

Financial data is fundamentally relational: a company issues instruments, which are listed on exchanges. Funds ownstakes in companies. Filings report on entities. Events affect stock prices. In a property graph like Neo4j, these aren’t just columns in a table — they’re first-class citizens with their own properties and traversal semantics.

The same ownership query that requires painful multi-table joins in SQL becomes:

MATCH path = (holder:LegalEntity {name: 'BLACKROCK INC.'})-
[:OWNS*1..4]->(target:LegalEntity)
WHERE toLower(target.name) CONTAINS 'apple'
RETURN path

Four hops, three lines. The graph finds every intermediate entity automatically.

The Architecture: Six Layers, One Coherent System

The full system is organised in a layered stack where each layer builds directly on the one below:

The architecture starts with the platform and builds upward.

The architecture starts with the platform and builds upward.

We’ll walk through each of these — not as abstract concepts, but as working, instrumented code.

Layer 0: The Platform Foundation

Every production system fails at scale if configuration and connections aren’t abstracted cleanly. Rather than scattering database credentials and API keys across the codebase, we built a single GraphProvider and LLMProvider that every downstream component uses.

python

gp = GraphProvider()   # reads bolt://localhost:7687 from config
llm = LLMProvider()    # switches between Ollama, OpenAI, Azure, or mock

The GraphProvider is deliberately thin — just a session wrapper:

class GraphProvider:
    def run(self, cypher: str, params: dict | None = None) -> list[dict]:
        with self.session() as s:
            return s.run(cypher, params or {}).data()

Configuration lives in one YAML file:

llm:
  default_provider: ollama
  ollama:
    model: deepseek-v3.2:cloud
graph:
  uri: bolt://localhost:7687
  database: neo4j

This design decision pays off immediately: when we swap from a local Ollama model to OpenAI in production, nothing else changes.

Layer 2: Defining the Graph Schema

Before ingesting any data, we define the shape of the graph — the “contract” that every importer must follow.

Think of it like designing a database schema, except instead of tables and foreign keys, you’re defining node labels (the nouns) and relationship types (the verbs):

We enforce uniqueness constraints immediately — a company’s LEI, an instrument’s FIGI, an exchange’s MIC code. These aren’t just metadata; they’re the glue that makes entity resolution reliable across all data sources:

constraints = [
    'CREATE CONSTRAINT legal_entity_lei IF NOT EXISTS FOR (le:LegalEntity) REQUIRE le.lei IS UNIQUE',
    'CREATE CONSTRAINT instrument_figi  IF NOT EXISTS FOR (i:Instrument)   REQUIRE i.figi IS UNIQUE',
    'CREATE CONSTRAINT exchange_mic     IF NOT EXISTS FOR (e:Exchange)      REQUIRE e.mic  IS UNIQUE',
]

Why this matters: Three different data sources might each know Apple as “Apple Inc.”, “APPLE INC”, and “CIK0000320193”. The LEI is the one identifier that ties them all together deterministically.

Ingesting the Real World: Four Data Sources

The knowledge graph is only as good as the data that feeds it. We pull from four authoritative sources.

GLEIF — Who the Legal Entities Are

The GLEIF API is the global registry of Legal Entity Identifiers (LEIs) — the closest thing finance has to a universal company ID. We pull 20 entities per jurisdiction across the US, UK, Germany, Japan, and Switzerland:

resp = httpx.get(
    'https://api.gleif.org/api/v1/lei-records',
    params={'filter[entity.legalAddress.country]': 'US', 'page[size]': '20'}
)
gp.run("""
    UNWIND $batch AS row
    MERGE (le:LegalEntity {lei: row.lei})
    SET le.name = row.name, le.jurisdiction = row.jurisdiction, le.legalForm = row.legalForm
""", {'batch': rows})

Result: 1,313 legal entities from real registrations across five jurisdictions.

ISO 10383 — Where Instruments Trade

The ISO MIC (Market Identifier Code) registry covers every recognized exchange globally. We import 2,287 active venues, giving the graph a complete picture of where instruments can be listed:

MERGE (ex:Exchange {mic: row.mic})
SET ex.name = row.name, ex.country=row.country, ex.operatingMIC=row.operatingMIC

OpenFIGI — What the Instruments Are

Every ticker symbol maps to a FIGI (Financial Instrument Global Identifier). We use the OpenFIGI API to resolve common tickers to their canonical FIGI identifiers, then link each Instrument to the Exchange it trades on via LISTED_ONrelationships.

SEC EDGAR — The Financial Facts

This is where the numbers come in. EDGAR’s XBRL API exposes company fundamentals in a structured format. We pull real data for Apple, Microsoft, and Alphabet:

resp = httpx.get(
    'https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json',
    headers={'User-Agent': 'KG-LLM-INACTION/1.0'}
)
us_gaap = resp.json()['facts']['us-gaap']
for concept in ['Revenues', 'NetIncomeLoss', 'Assets', 'EarningsPerShareBasic']:
    entries = us_gaap[concept]['units']['USD'][-5:]  # last 5 periods
    # → create StatementItem nodes linked to Filing nodes

Result: 45 statement items — real revenue, net income, assets, and EPS figures from actual SEC filings, not hand-crafted test data.

Adding Meaning: The FIBO Ontology

Raw data tells you what exists. An ontology tells you what things mean.

FIBO (the Financial Industry Business Ontology) is a formal W3C-standard vocabulary for finance. It defines classes like Corporation, LimitedLiabilityCompany, Fund, and Bank in a machine-readable hierarchy. We import FIBO directly into Neo4j using the Neosemantics (n10s) plugin, which translates RDF/OWL into property graph nodes:

python

gp.run("""
    CALL n10s.graphconfig.init({
        handleVocabUris: 'MAP',
        handleMultival: 'ARRAY',
        handleRDFTypes: 'LABELS_AND_NODES'
    })
""")
gp.run("""
    CALL n10s.rdf.import.fetch(
        'https://spec.edmcouncil.org/fibo/ontology/BE/MetadataBE/BEDomain',
        'RDF/XML'
    )
""")

After import, we classify each legal entity by its legal form:

LEGAL_FORM_TO_FIBO = {
    'CORP': 'https://spec.edmcouncil.org/fibo/.../Corporation',
    'LLC':  'https://spec.edmcouncil.org/fibo/.../LimitedLiabilityCompany',
    'FUND': 'https://spec.edmcouncil.org/fibo/.../Fund',
}
gp.run("""
    MATCH (le:LegalEntity {legalForm: $form})
    MATCH (oc:OntologyClass {iri: $iri})
    MERGE (le)-[:CLASSIFIED_AS]->(oc)
""", {'form': form, 'iri': fibo_iri})

Result: 264 OntologyClass nodes from FIBO’s Business Entities, FBC, Securities, and Indicators modules — each LegalEntity now carries a formal semantic type.

The Entity Resolution Problem

Here’s a dirty secret about financial data: the same company appears under dozens of different names and IDs across different systems. One source has the LEI, another has the SEC CIK, a third has the Bloomberg FIGI. None of them agree on a canonical name.

We solve this with Crosswalk nodes — bridge nodes that explicitly represent the mapping between identifiers, rather than trying to merge conflicting data:

[LEI: 549300AJTMQ...] ──── [Crosswalk: DETERMINISTIC, confidence: 1.0] 
──── [CIK: 0000320193]
      └── [FIGI: BBG000B9XRY4]

Deterministic matches use exact identifier pairs (confidence = 1.0). Probabilistic matches use Jaro-Winkler string similarity for name variants:

MATCH (a:LegalEntity), (b:LegalEntity)
WHERE id(a) < id(b)
  AND a.jurisdiction = b.jurisdiction
  AND apoc.text.jaroWinklerDistance(
        apoc.text.clean(a.name),
        apoc.text.clean(b.name)
      ) > 0.92
RETURN a.name AS nameA, b.name AS nameB

The Crosswalk pattern is elegant because it preserves the original source data — you can always trace why two records were linked, and with what confidence.

Making the Graph Searchable: Graph Algorithms

With all the data loaded, we run two graph algorithms to compute global properties.

Louvain Community Detection groups entities into clusters based on how densely they’re connected to each other. Companies in the same industry sector, owned by the same parent, or frequently co-appearing in filings end up in the same community.

PageRank identifies the most central entities — those connected to many other important entities. In the ownership graph, this surfaces the institutional investors and holding companies that sit at the center of the network:

gp.run("CALL gds.pageRank.write('fin-pagerank', {writeProperty: 'pagerank'})")
top = gp.run("""
    MATCH (le:LegalEntity)
    RETURN le.name AS name, le.pagerank AS pagerank
    ORDER BY le.pagerank DESC LIMIT 5
""")

Think of PageRank as answering: “Who is the most well-connected entity at a global financial conference?” — not just who knows many people, but who knows people who themselves know many people.

Extracting Knowledge from Unstructured Text

So far, we’ve loaded structured data. But the most valuable financial intelligence lives in unstructured text — SEC press releases, Fed announcements, earnings call transcripts.

The extraction pipeline works in three stages:

1. Chunking — Documents are split into overlapping 400-character windows, stored as Chunk nodes linked to their parent Document.

2. NER (Named Entity Recognition) — Each chunk is processed by spaCy plus custom financial regex patterns:

ISIN_RE   = re.compile(r'\b[A-Z]{2}[A-Z0-9]{9}[0-9]\b')   # ISIN identifiers
TICKER_RE = re.compile(r'\$[A-Z]{1,5}\b')                   # $AAPL, $MSFT
MONEY_RE  = re.compile(r'\$[\d,]+(?:\.\d{1,2})?\s*(?:million|billion|M|B)?')

3. Entity LinkingMention nodes are linked back to canonical LegalEntity nodes using fuzzy matching. The confidence score is stored on the relationship:

MERGE (m)-[r:RESOLVED_TO]->(le)
SET r.confidence = 0.91

LLM-Powered Extraction goes further — we send raw filing text to an LLM and ask it to extract structured entities and events in JSON format:

result = llm.complete_json("""
    Extract all financial entities from this text as JSON.
    Return {"entities": [{"name": ..., "type": "ORG"|"PERSON"|"INSTRUMENT", "confidence": 0-1}]}
    Text: Apple Inc. reported Q3 2024 revenue of $85.8 billion...
""")

Crucially, every LLM call is wrapped in safety guardrails that reject prompts asking for price predictions or investment recommendations:

FORBIDDEN_PATTERNS = [
    'predict.*price', 'stock.*tip', 'buy.*sell.*recommendation',
    'guaranteed.*return', 'insider.*information'
]

Teaching the Graph to Understand Structure: Embeddings

There are two fundamentally different kinds of “similarity” for financial entities — and we compute both.

Text-based embeddings capture what an entity is. We build a profile string for each entity (“Apple Inc. Jurisdiction: US. Form: CORP”) and embed it using nomic-embed-text (768 dimensions). Entities with similar characteristics end up close together in vector space:

profiles = [f"{e['name']}. Jurisdiction: {e['jurisdiction']}. Form: {e['legalForm']}" for e in entities]
embeddings = llm.embed(texts)  # nomic-embed-text, 768 dim
# Store on nodes
gp.run('MATCH (le:LegalEntity {lei: $lei}) SET le.profileEmbedding = $emb',
       {'lei': lei, 'emb': emb})

Graph embeddings (Node2Vec) capture how an entity connects. Node2Vec runs random walks across the ownership graph — 10 walks × 20 steps per node — and treats the resulting sequences like sentences, learning which entities appear in similar structural “contexts”:

for node in all_nodes:
    for _ in range(10):
        walk = [node]
        current = node
        for _ in range(20):
            neighbors = adj.get(current, [])
            if not neighbors: break
            current = random.choice(neighbors)
            walk.append(current)
        walks.append(walk)
# Co-occurrence matrix → SVD → 32-dim embeddings
U, S, _ = np.linalg.svd(cooc, full_matrices=False)
embeddings = U[:, :32] * np.sqrt(S[:32])

The intuition: “Tell me who your neighbors are, and I’ll tell you who you are.” Two companies that don’t know each other directly but sit in similar ownership neighborhoods will have similar Node2Vec embeddings — revealing structural patterns invisible in the raw data.

Graph Neural Networks: Learning from the Topology

Graph Neural Networks take this further by learning task-specific representations. Instead of treating node features and graph structure separately, GNNs process both simultaneously.

We train three architectures and compare them:

  • GCN (Graph Convolutional Network) — averages neighbor features at each layer
  • GAT (Graph Attention Network) — learns which neighbors matter more
  • GraphSAGE — samples and aggregates from local neighborhoods, scales to large graphs
class GCN(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = GCNConv(3, 16)   # 3 input features → 16 hidden
        self.conv2 = GCNConv(16, 2)    # 16 hidden → 2 output classes
def forward(self, x, edge_index):
        x = F.relu(self.conv1(x, edge_index))
        return F.log_softmax(self.conv2(x, edge_index), dim=1)

Node classification predicts the type or risk category of an entity from its structural position and features. Link prediction goes further — it predicts which ownership relationships probably exist but aren’t yet in the graph:

class LinkPredictor(torch.nn.Module):
    def decode(self, z, edge_index):
        # If two node embeddings point in the same direction → predict a link
        return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=1)

The elegance here is the decoder: a high dot-product between two node embeddings means the model thinks those two entities should be connected. In a financial context, this surfaces probable ownership relationships that may be obscured or not yet officially registered.

Feature engineering on 1,313 entities produced strong baselines: Random Forest achieved a ROC-AUC of 0.995 on degree-based classification, while the GNN models achieved accuracy above 0.97 on the available graph.

The Intelligence Layer: Graph RAG

Traditional RAG (Retrieval-Augmented Generation) retrieves text chunks and feeds them to an LLM. Graph RAG does something richer — it combines vector search over document embeddings with structured Cypher traversals over the knowledge graph, giving the LLM both unstructured context and authoritative facts simultaneously.

The architecture:

User Question
      │
      ├── Vector Search  →  relevant Chunk text from filings/news
      │
      └── KG Lookup      →  structured facts (ownership, financials, classifications)
                │
                └── Safety Validator (no DELETE/CREATE/SET/DROP allowed)

            Combined Context → LLM → Answer + Citations + Confidence Score

The Cypher safety layer is non-negotiable — any LLM-generated query must pass validation before executing:

_FORBIDDEN_CLAUSES = {"DELETE", "DETACH", "CREATE", "SET", "REMOVE", "DROP", "CALL"}
def validate_cypher(cypher: str) -> tuple[bool, str]:
    upper = cypher.upper()
    for clause in _FORBIDDEN_CLAUSES:
        if re.search(rf"\b{clause}\b", upper):
            return False, f"Forbidden clause: {clause}"
    return True, "OK"

Contradiction detection compares LLM-generated claims against authoritative XBRL values from SEC filings. If the LLM says “Apple reported $85B revenue” but the EDGAR data shows a different figure, the system flags it as a potential contradiction — preventing confidently wrong answers from reaching analysts.

Production Governance: Contracts and Migrations

A demo that works once isn’t a production system. We implement two governance mechanisms.

Schema migrations are versioned, idempotent, and tracked in the graph itself:

MIGRATIONS = [
    ('20260418_001_init',      'Initial schema constraints'),
    ('20260418_002_indexes',   'Performance indexes'),
    ('20260418_003_crosswalk', 'Crosswalk constraint'),
]
# Only apply migrations not yet recorded in Migration nodes
applied = {r['migrationId'] for r in gp.run('MATCH (m:Migration) RETURN m.migrationId')}
for mid, desc in MIGRATIONS:
    if mid not in applied:
        gp.run('CREATE (m:Migration {migrationId: $id, ...})', ...)

Data contracts are automated quality checks that run before any analysis:

checks = [
    ('LegalEntity missing lei', 'MATCH (le:LegalEntity) WHERE le.lei IS NULL RETURN count(le)'),
    ('Orphan chunks',           'MATCH (c:Chunk) WHERE NOT (c)-[:OF_DOC]->(:Document) RETURN count(c)'),
    ('Orphan mentions',         'MATCH (m:Mention) WHERE NOT (m)-[:IN_CHUNK]->(:Chunk) RETURN count(m)'),
]

These aren’t afterthoughts — they’re baked into the pipeline from day one. A node count anomaly or orphaned entity triggers a warning before it can silently corrupt downstream analysis.

The Investigative Copilot

All of this comes together in a Streamlit application where an analyst types a company name and receives a comprehensive investigation in seconds.

The investigate_entity() function combines four Cypher traversals:

def investigate_entity(name: str):
    # 1. Entity profile — LEI, jurisdiction, PageRank, ticker list, filing count
    profile = gp.run("""
        MATCH (le:LegalEntity)
        WHERE toLower(le.name) CONTAINS toLower($name)
        OPTIONAL MATCH (le)-[:ISSUES]->(i:Instrument)
        OPTIONAL MATCH (f:Filing)-[:REPORTS_ON]->(le)
        RETURN le.lei, le.name, le.jurisdiction, le.pagerank,
               collect(DISTINCT i.ticker) AS tickers,
               count(DISTINCT f) AS filings
    """, {'name': name})
# 2. Ownership network - OWNS, CONTROLS, PARENT_OF relationships
    # 3. Financial data - Revenue, NetIncome, Assets from XBRL
    # 4. Exposure paths - shortest paths through the ownership graph

The Exposure Path Explorer is particularly powerful — it finds the shortest ownership path between any two entities in the graph:

OPTIONAL MATCH path = shortestPath((a)-[:OWNS|CONTROLS|PARENT_OF*..4]-(b))
RETURN a.name AS from, b.name AS to,
       [n IN nodes(path) | n.name] AS pathNodes,
       length(path) AS hops

Type “Goldman” and get: its LEI, jurisdiction, PageRank centrality score, every instrument it issues, every filing associated with it, its ownership connections, and a path trace to the five most central entities in the graph. Then ask the Graph RAG layer a natural language question about it.

Seven Things That Would Have Saved Me Time

Looking back at building this system, here are the lessons that matter most:

1. Define the schema before writing a single importer. Node labels, relationship types, and uniqueness constraints need to be locked in as a shared contract. Everything built on top breaks if the schema shifts.

2. Use real identifiers everywhere. LEI, FIGI, MIC, ISIN, CIK — these aren’t metadata decorations. They’re what makes entity resolution deterministic rather than a probability game.

3. Crosswalk nodes beat merging conflicting records. When the same company has different names in different sources, preserve the original data and link it explicitly. You can always trace the lineage.

4. Ontologies are worth the upfront complexity. FIBO gives you a formal vocabulary that doesn’t rot over time. When your LLM output says “Corporation”, it means exactly what the W3C definition says — not what someone decided last Tuesday.

5. Layer your embeddings. Text embeddings tell you what an entity is. Node2Vec tells you how it connects. Use both. One without the other misses half the picture.

6. The Cypher safety validator is not optional. An LLM with write access to your graph is a disaster waiting to happen. Validate every generated query before execution, every time.

7. Graph RAG is categorically different from vector-only RAG. Structured relationships let the LLM reason about why two entities are connected — not just that text about them appeared near each other. The difference in answer quality for multi-hop financial questions is substantial.

Getting Started

The full implementation runs end-to-end in a single notebook, from Neo4j connection to investigative queries over a real financial graph:

# Install dependencies
pip install neo4j httpx pyyaml python-dotenv numpy scikit-learn torch torch-geometric spacy pycountry
# Pull a local embedding model
ollama pull nomic-embed-text
# Start Neo4j with the n10s, APOC, and GDS plugins
# Then launch the tutorial:
jupyter notebook tutorial_ch01_ch17_fin.ipynb

The notebook is self-contained — each section builds on the previous, and the final cell prints a complete inventory of every node and relationship in the graph you’ve built.

What This Enables

A financial analyst using this system can:

  • Ask “Show me every company Goldman Sachs owns, directly or through subsidiaries” and get a graph traversal answer in under a second.
  • Query “Which legal entities have the highest PageRank centrality in the US ownership network?” and get ranked results from structured data.
  • Ask a natural language question like “What are the key financial metrics for the largest entities?” and receive an LLM-synthesized answer grounded in actual XBRL filings, with automatic contradiction checking.
  • Predict where new ownership relationships might exist using the GNN link predictor, surfacing undisclosed connections worth investigating.

This is the difference between a database that stores financial data and an intelligence system that reasons over it.

Built with Neo4j, Neosemantics (n10s), APOC, GDS, FIBO, GLEIF, SEC EDGAR, OpenFIGI, spaCy, PyTorch Geometric, and Ollama. The full implementation is available at https://github.com/lavishlyinspired/finOntoKG/tree/main/ChaptersFinancial


메타데이터
post_id
25cf2fda47b5
slug
how-we-built-a-financial-intelligence-engine-that-thinks-in-relationships-25cf2fda47b5
url
https://medium.com/@aiwithakashgoyal/how-we-built-a-financial-intelligence-engine-that-thinks-in-relationships-25cf2fda47b5
canonical_url
https://medium.com/@aiwithakashgoyal/how-we-built-a-financial-intelligence-engine-that-thinks-in-relationships-25cf2fda47b5
author_url
https://medium.com/@aiwithakashgoyal
status
ok
fetched_at
2026-07-24 06:32:49