基于知识图谱的金融智能引擎
想象一位金融分析师试图回答一个看似简单的问题:"贝莱德(BlackRock)对苹果的全面敞口是什么——通过他们控制的每一个基金、子公司和控股公司?"
在传统数据库中,这意味着关联十几个表,解析名称变体,编写痛苦的SQL,很可能在中途就放弃了。数据就在那里,但它被埋在层层孤立的系统之下。
现在想象同样的问题通过一个图谱查询就能回答——一个跟踪每条所有权线索、调出每份监管文件、并在毫秒内返回完整结果的查询。
这就是 金融知识图谱(FKG)的承诺:一个公司、工具、文件和事件之间的关系与实体本身同等重要的系统。本文将介绍我们如何从零开始构建这样一个系统——从GLEIF、SEC EDGAR、OpenFIGI和FIBO拉取真实数据——并将其连接到LLM,创建一个更接近金融调查员而非搜索引擎的东西。
1、知识图谱有何不同
在深入之前,值得理解为什么图数据库在这里能改变游戏规则。
金融数据本质上是关系型的:一家公司发行工具,这些工具上市于交易所。基金持有公司的股份。文件报告实体。事件影响股价。在像Neo4j这样的属性图中,这些不仅仅是表中的列——它们是具有自身属性和遍历语义的一等公民。
在SQL中需要痛苦的多表关联的所有权查询变成了:
MATCH path = (holder:LegalEntity {name: 'BLACKROCK INC.'})-
[:OWNS*1..4]->(target:LegalEntity)
WHERE toLower(target.name) CONTAINS 'apple'
RETURN path
四跳,三行。图谱自动找到每个中间实体。
2、架构:六层,一个连贯的系统
整个系统被组织为一个分层堆栈,每一层直接建立在下面一层之上:
架构从平台开始并向上构建。
我们将逐一介绍这些层——不是作为抽象概念,而是作为可运行的、有工具支持的代码。
2.1 第0层:平台基础
如果配置和连接没有干净地抽象出来,每个生产系统在规模化时都会失败。我们没有在代码库中分散数据库凭据和API密钥,而是构建了一个单一的GraphProvider和LLMProvider,每个下游组件都使用它们。
gp = GraphProvider() # 从配置中读取 bolt://localhost:7687
llm = LLMProvider() # 在 Ollama、OpenAI、Azure 或 mock 之间切换
GraphProvider被故意设计得很薄——只是一个会话包装器:
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()
配置放在一个YAML文件中:
llm:
default_provider: ollama
ollama:
model: deepseek-v3.2:cloud
graph:
uri: bolt://localhost:7687
database: neo4j
这个设计决策立竿见影:当我们在生产中从本地Ollama模型切换到OpenAI时,其他一切都不需要改变。
2.2 第2层:定义图谱Schema
在摄入任何数据之前,我们定义了图谱的形状——每个导入器必须遵循的"契约"。
把它想象成设计数据库模式,只不过不是表和外键,而是定义节点标签(名词)和关系类型(动词):
我们立即强制执行唯一性约束——公司的LEI、工具的FIGI、交易所的MIC代码。这些不仅仅是元数据;它们是使实体解析在所有数据源中可靠的粘合剂:
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',
]
为什么这很重要:三个不同的数据源可能各自将Apple称为"Apple Inc."、"APPLE INC"和"CIK0000320193"。LEI是将它们全部确定性连接在一起的那个标识符。
3、摄入真实世界:四个数据源
知识图谱的质量取决于喂给它的数据。我们从四个权威来源拉取数据。
3.1 GLEIF — 法律实体是谁
GLEIF API是法律实体标识符(LEI)的全球注册表——金融界最接近通用公司ID的东西。我们从美国、英国、德国、日本和瑞士每个司法管辖区拉取20个实体:
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})
结果:来自五个司法管辖区真实注册的1,313个法律实体。
3.2 ISO 10383 — 工具在哪里交易
ISO MIC(市场标识代码)注册表覆盖全球每个认可的交易所。我们导入了2,287个活跃场所,为图谱提供了工具可以在哪里上市的完整图景:
MERGE (ex:Exchange {mic: row.mic})
SET ex.name = row.name, ex.country=row.country, ex.operatingMIC=row.operatingMIC
3.3 OpenFIGI — 工具是什么
每个股票代码映射到一个FIGI(金融工具全球标识符)。我们使用OpenFIGI API将常见股票代码解析为其规范FIGI标识符,然后通过LISTED_ON关系将每个Instrument链接到它交易的Exchange。
3.4 SEC EDGAR — 金融事实
这是数字来源的地方。EDGAR的XBRL API以结构化格式公开公司基本面数据。我们拉取Apple、Microsoft和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:] # 最近5个期间
# → 创建链接到 Filing 节点的 StatementItem 节点
结果:45个报表项目——来自实际SEC文件的真实收入、净收入、资产和EPS数据,而非手工制作的测试数据。
4、添加语义:FIBO本体
原始数据告诉你什么存在。本体告诉你事物意味着什么。
FIBO(金融行业业务本体)是金融领域的正式W3C标准词汇表。它以机器可读的层次结构定义了Corporation、LimitedLiabilityCompany、Fund和Bank等类。我们使用**Neosemantics(n10s)**插件将FIBO直接导入Neo4j,该插件将RDF/OWL转换为属性图节点:
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'
)
""")
导入后,我们按法律形式对每个法律实体进行分类:
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})
结果:来自FIBO的商业实体、FBC、证券和指标模块的264个OntologyClass节点——每个LegalEntity现在都带有一个正式的语义类型。
4.1 实体解析问题
关于金融数据有一个肮脏的秘密:同一家公司在不同系统中以几十种不同的名称和ID出现。一个来源有LEI,另一个有SEC CIK,第三个有Bloomberg FIGI。它们中没有哪个在规范名称上达成一致。
我们使用Crosswalk节点来解决这个问题——桥接节点明确表示标识符之间的映射,而不是试图合并不兼容的数据:
[LEI: 549300AJTMQ...] ──── [Crosswalk: DETERMINISTIC, confidence: 1.0]
──── [CIK: 0000320193]
└── [FIGI: BBG000B9XRY4]
确定性匹配使用精确的标识符对(置信度 = 1.0)。概率性匹配使用Jaro-Winkler字符串相似度来处理名称变体:
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
Crosswalk模式很优雅,因为它保留了原始源数据——你总是可以追溯为什么两条记录被链接,以及链接的置信度是多少。
5、让图谱可搜索:图谱算法
加载完所有数据后,我们运行两个图谱算法来计算全局属性。
Louvain社区检测根据实体之间连接的密度将实体分组为集群。同一行业部门的公司、由同一母公司拥有的公司、或频繁在文件中共同出现的公司最终会被分到同一个社区。
PageRank识别最核心的实体——那些与许多其他重要实体相连的实体。在所有权图谱中,这暴露了处于网络中心的机构投资者和控股公司:
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
""")
把PageRank想象成回答:"在全球金融会议上,谁是最有人脉的实体?"——不仅仅是认识很多人的人,而是认识那些本身就认识很多人的人的人。
6、从非结构化文本中提取知识
到目前为止,我们已经加载了结构化数据。但最有价值的金融智能存在于非结构化文本中——SEC新闻稿、美联储公告、财报电话会议记录。
提取管道分三个阶段工作:
1. 分块——文档被分割成400字符的重叠窗口,存储为Chunk节点,链接到其父Document。
2. NER(命名实体识别)——每个块由spaCy加上自定义金融正则表达式模式处理:
ISIN_RE = re.compile(r'\b[A-Z]{2}[A-Z0-9]{9}[0-9]\b') # ISIN 标识符
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. 实体链接——Mention节点通过模糊匹配链接回规范的LegalEntity节点。置信度分数存储在关系上:
MERGE (m)-[r:RESOLVED_TO]->(le)
SET r.confidence = 0.91
LLM驱动的提取走得更远——我们将原始文件文本发送给LLM,要求它以JSON格式提取结构化实体和事件:
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...
""")
关键的是,每个LLM调用都包裹在安全护栏中,拒绝要求价格预测或投资推荐的提示:
FORBIDDEN_PATTERNS = [
'predict.*price', 'stock.*tip', 'buy.*sell.*recommendation',
'guaranteed.*return', 'insider.*information'
]
7、教图谱理解结构:嵌入
金融实体有两种根本不同的"相似性"——我们计算了两种。
基于文本的嵌入捕获实体是什么。我们为每个实体构建一个配置文件字符串("Apple Inc. Jurisdiction: US. Form: CORP"),并使用nomic-embed-text(768维)对其进行嵌入。具有相似特征的实体在向量空间中最终会靠得很近:
profiles = [f"{e['name']}. Jurisdiction: {e['jurisdiction']}. Form: {e['legalForm']}" for e in entities]
embeddings = llm.embed(texts) # nomic-embed-text, 768 维
# 存储在节点上
gp.run('MATCH (le:LegalEntity {lei: $lei}) SET le.profileEmbedding = $emb',
{'lei': lei, 'emb': emb})
**图谱嵌入(Node2Vec)**捕获实体如何连接。Node2Vec在所有权图谱上运行随机游走——每个节点10次游走 × 20步——并将结果序列视为句子,学习哪些实体出现在相似的结构"上下文"中:
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)
# 共现矩阵 → SVD → 32维嵌入
U, S, _ = np.linalg.svd(cooc, full_matrices=False)
embeddings = U[:, :32] * np.sqrt(S[:32])
直觉是: "告诉我你的邻居是谁,我就能告诉你你是谁。"两家不直接认识但处于相似所有权邻域的公司会有相似的Node2Vec嵌入——揭示原始数据中不可见的结构模式。
8、图神经网络:从拓扑中学习
图神经网络通过学习任务特定的表示将此推进一步。GNN不是分别处理节点特征和图谱结构,而是同时处理两者。
我们训练了三种架构并进行比较:
- GCN(图卷积网络)——在每层平均邻居特征
- GAT(图注意力网络)——学习哪些邻居更重要
- GraphSAGE——从局部邻域采样和聚合,可扩展到大型图谱
class GCN(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = GCNConv(3, 16) # 3个输入特征 → 16个隐藏
self.conv2 = GCNConv(16, 2) # 16个隐藏 → 2个输出类
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)
节点分类从实体的结构位置和特征预测其类型或风险类别。链接预测走得更远——它预测哪些所有权关系可能存在但尚未在图谱中:
class LinkPredictor(torch.nn.Module):
def decode(self, z, edge_index):
# 如果两个节点嵌入指向同一方向 → 预测一个链接
return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=1)
这里的优雅之处在于解码器:两个节点嵌入之间的高点积意味着模型认为这两个实体应该被连接。在金融语境中,这暴露了可能被隐藏或尚未正式注册的可能所有权关系。
在1,313个实体上的特征工程产生了强大的基线:随机森林在基于度的分类上达到了0.995的ROC-AUC,而GNN模型在可用图谱上的准确率超过0.97。
9、智能层:Graph RAG
传统RAG(检索增强生成)检索文本块并将其喂给LLM。Graph RAG做了更丰富的事情——它结合了文档嵌入上的向量搜索和知识图谱上的结构化Cypher遍历,同时为LLM提供非结构化上下文和权威事实。
架构:
用户问题
│
├── 向量搜索 → 来自文件/新闻的相关 Chunk 文本
│
└── KG 查找 → 结构化事实(所有权、财务数据、分类)
│
└── 安全验证器(不允许 DELETE/CREATE/SET/DROP)
组合上下文 → LLM → 答案 + 引用 + 置信度分数
Cypher安全层是不可协商的——任何LLM生成的查询必须在执行前通过验证:
_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"
矛盾检测将LLM生成的声明与SEC文件中的权威XBRL值进行比较。如果LLM说"Apple报告了850亿美元收入"但EDGAR数据显示了不同的数字,系统会将其标记为潜在矛盾——防止自信的错误答案到达分析师手中。
10、生产治理:契约和迁移
只运行一次的演示不是生产系统。我们实施了两种治理机制。
Schema迁移是版本化的、幂等的,并在图谱本身中跟踪:
MIGRATIONS = [
('20260418_001_init', '初始 schema 约束'),
('20260418_002_indexes', '性能索引'),
('20260418_003_crosswalk', 'Crosswalk 约束'),
]
# 仅应用尚未记录在 Migration 节点中的迁移
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, ...})', ...)
数据契约是在任何分析之前运行的自动化质量检查:
checks = [
('LegalEntity 缺少 lei', 'MATCH (le:LegalEntity) WHERE le.lei IS NULL RETURN count(le)'),
('孤立 chunk', 'MATCH (c:Chunk) WHERE NOT (c)-[:OF_DOC]->(:Document) RETURN count(c)'),
('孤立 mention', 'MATCH (m:Mention) WHERE NOT (m)-[:IN_CHUNK]->(:Chunk) RETURN count(m)'),
]
这些不是事后补充——它们从第一天起就被纳入了管道。节点计数异常或孤立实体会触发警告,在它悄悄腐蚀下游分析之前。
11、调查副驾驶
所有这些在Streamlit应用中汇聚在一起,分析师输入公司名称,几秒钟内就能获得全面的调查报告。
investigate_entity()函数结合了四次Cypher遍历:
def investigate_entity(name: str):
# 1. 实体概要 — LEI、司法管辖区、PageRank、股票代码列表、文件数量
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. 所有权网络 - OWNS, CONTROLS, PARENT_OF 关系
# 3. 财务数据 - 来自 XBRL 的 Revenue, NetIncome, Assets
# 4. 敞口路径 - 通过所有权图谱的最短路径
敞口路径探索器特别强大——它找到图谱中任意两个实体之间的最短所有权路径:
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
输入"Goldman"就能得到:它的LEI、司法管辖区、PageRank中心性分数、它发行的每个工具、与之关联的每份文件、所有权连接、以及到图谱中五个最核心实体的路径追踪。然后可以向Graph RAG层提出关于它的自然语言问题。
12、七件会节省我时间的事情
回顾构建这个系统的过程,以下是最重要的经验教训:
1. 在编写任何导入器之前定义schema。节点标签、关系类型和唯一性约束需要作为共享契约锁定。如果schema发生变化,建立在它之上的一切都会崩溃。
2. 到处使用真实标识符。LEI、FIGI、MIC、ISIN、CIK——这些不是元数据装饰。它们使实体解析变成确定性的,而不是一场概率游戏。
3. Crosswalk节点胜过合并不兼容的记录。当同一家公司在不同来源中有不同名称时,保留原始数据并明确链接它们。你总是可以追溯血缘。
4. 本体值得前期投入的复杂性。FIBO为你提供了一个不会随时间腐化的正式词汇表。当你的LLM输出说"Corporation"时,它的意思是完全符合W3C定义的——而不是某个人上周二决定的。
5. 分层你的嵌入。文本嵌入告诉你实体是什么。Node2Vec告诉你它如何连接。两者都用。只用一个会错过一半的画面。
6. Cypher安全验证器不是可选的。一个有写权限访问你图谱的LLM是一场等待发生的灾难。在执行前验证每个生成的查询,每次都是如此。
7. Graph RAG与仅向量的RAG在类别上是不同的。结构化关系让LLM推理为什么两个实体被连接——而不仅仅是关于它们的文本在彼此附近出现。对于多跳金融问题,答案质量的差异是显著的。
13、入门指南
完整的实现在一个notebook中端到端运行,从Neo4j连接到在真实金融图谱上进行调查查询:
# 安装依赖
pip install neo4j httpx pyyaml python-dotenv numpy scikit-learn torch torch-geometric spacy pycountry
# 拉取本地嵌入模型
ollama pull nomic-embed-text
# 使用 n10s、APOC 和 GDS 插件启动 Neo4j
# 然后启动教程:
jupyter notebook tutorial_ch01_ch17_fin.ipynb
这个notebook是自包含的——每个部分都建立在前一个之上,最后一个单元格会打印出你构建的图谱中每个节点和关系的完整清单。
14、这能做什么
使用这个系统的金融分析师可以:
- 询问*"展示高盛直接或通过子公司拥有的每一家公司"*,并在一秒内获得图谱遍历答案。
- 查询*"在美国所有权网络中,哪些法律实体具有最高的PageRank中心性?"*,并从结构化数据中获得排名结果。
- 提出一个自然语言问题,如*"最大实体的关键财务指标是什么?"*,并收到一个基于实际XBRL文件、带有自动矛盾检查的LLM合成答案。
- 使用GNN链接预测器预测哪里可能存在新的所有权关系,暴露值得调查的未披露连接。
这就是存储金融数据的数据库和对金融数据进行推理的智能系统之间的区别。
原文链接: How We Built a Financial Intelligence Engine That Thinks in Relationships
汇智网翻译整理,转载请标明出处