第4章 图数据库基础:Neo4j与Cypher
好,开始正题。图数据库这块,我最初入行时也绕了不少弯。市面上图数据库不少,但Neo4j真正让我觉得「嗯,这东西靠谱」。它背后的图存储引擎和查询语言设计,对药物知识图谱这种复杂关系网络来说,再合适不过。
实际上,Neo4j的核心就是一个属性图模型。节点(Node)代表实体,关系(Relationship)代表连接,两者都能挂载键值对属性。想想药物分子与靶点蛋白的作用关系?用节点表示化合物和蛋白,用「作用于」关系连接,再用属性存储结合能Ki值——这比关系型里铺三张外键表自然太多了。
4.1 Neo4j 架构与安装
Neo4j分社区版和企业版。做课程项目,社区版完全够用。我记得早期项目里,用社区版跑了百万节点级别的DrugBank数据,性能依然可接受。
下面是Docker部署方式,我个人最推荐这个,清理环境也方便:
docker run \
--name neo4j-kg \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/kg2024 \
-e NEO4J_PLUGINS='["apoc"]' \
-e NEO4J_dbms_memory_pagecache_size=2G \
-e NEO4J_dbms_memory_heap_max__size=4G \
-v /data/neo4j:/data \
neo4j:5-community
apoc 插件强烈建议装上。后续做图算法和批量导入,你一定会用到它。
4.2 Cypher 查询语言
Cypher的设计哲学很简单:用图案匹配图案。你看过它的语法就会懂——节点用(),关系用[],箭头表示方向。它不像SQL那样需要定义SELECT/ FROM/ JOIN的编排顺序,而是直接描出你要找的子图结构。
4.2.1 基本模式匹配
// 查找与"阿司匹林"有直接相互作用的所有蛋白
MATCH (d:Drug {name: '阿司匹林'})-[r:INTERACTS_WITH]->(p:Protein)
RETURN d.name, p.name, r.affinity_kd
// 查找通过"作用于"关系相连的三元组
MATCH (d:Drug)-[:TARGETS]->(p:Protein)
WHERE d.class = 'kinase_inhibitor'
RETURN d, p
这里要注意:Cypher用MATCH描述图模式,用WHERE做条件过滤。RETURN返回的不一定是标量,可以返回整条路径。
为何说Cypher对药物知识图谱特别友好?看个例子:你要查某个化合物是否通过某个中间蛋白间接影响另一条代谢通路。在SQL里需要递归CTE加三层JOIN。在Cypher里一行可变长路径匹配:
// 查找连接两类药物的长度2~5的间接路径
MATCH path = (d1:Drug)-[*2..5]-(d2:Drug)
WHERE d1.id = 'DB0001' AND d2.id = 'DB0002'
RETURN path LIMIT 10
4.2.2 数据写入与更新
// 创建节点
CREATE (d:Drug {id: 'DB01001', name: '伊马替尼', smiles: 'CC1=C(C=C(C=C1)NC(=O)C2=CC=C(C=C2)CN3CCN(CC3)C)NC4=NC=CC(=N4)C5=CN=CC=C5'})
// 创建关系
MATCH (d:Drug {id: 'DB01001'}), (p:Protein {id: 'P00519'})
CREATE (d)-[:TARGETS {evidence: 'FDA_label', affinity: 0.5_nM}]->(p)
// MERGE防重复,我个人习惯用它代替CREATE
MERGE (d:Drug {id: 'DB01001'})
ON CREATE SET d.created_at = timestamp()
4.2.3 聚合与统计
// 统计每个靶点被多少种药物靶向
MATCH (d:Drug)-[:TARGETS]->(p:Protein)
RETURN p.name, count(d) AS drug_count
ORDER BY drug_count DESC
// 找出至少被5种药物靶向的蛋白
MATCH (d:Drug)-[:TARGETS]->(p:Protein)
WITH p, count(d) AS cnt
WHERE cnt >= 5
RETURN p.name, cnt
我曾经查一个靶点的药物覆盖度时,用WITH做中间聚合,然后WHERE过滤低于阈值的节点——这个模式在药物重定位分析里几乎每次都用。
| Cypher 关键字 | 作用 | 药物KG典型用法 |
|---|---|---|
MATCH |
定义图模式 | 描出药物-靶点-通路子图 |
OPTIONAL MATCH |
左外连接式匹配 | 找出那些没有已知靶点的孤儿药物 |
WITH |
管道传递结果 | 先聚合再过滤,比如找多靶点药物 |
UNWIND |
展开列表为行 | 批量导入药物A TC分类代码 |
CALL { ... } |
子查询 | 跨分片做嵌套聚合 |
4.3 图谱数据导入与导出
真实项目里不可能一条一条用CREATE去插入数据。我处理过一批36万条的药物-靶点作用对,要是纯Cypher插入,跑了一小时都没完。正确做法是批量导入。
4.3.1 LOAD CSV 命令
// 准备CSV:drug_id,drug_name,smiles
// 然后加载
LOAD CSV WITH HEADERS FROM 'file:///drugs.csv' AS row
MERGE (d:Drug {id: row.drug_id})
ON CREATE SET d.name = row.drug_name,
d.smiles = row.smiles
file:///目录默认是Neo4j的import文件夹。我以前没注意,直接写绝对路径,结果上报错"Couldn't load the external resource"——记得把数据丢进 $NEO4J_HOME/import。
4.3.2 neo4j-admin import 工具
对于千万级节点以上的数据,LOAD CSV太慢。这时候要用neo4j-admin离线导入:
# 停库
neo4j stop
# 结构化导入:节点文件、关系文件
neo4j-admin database import full \
--nodes=Drug=drug-header.csv,drugs.csv \
--nodes=Protein=protein-header.csv,proteins.csv \
--relationships=INTERACTS_WITH=interacts-header.csv,interacts.csv \
--trim-strings=true \
--ignore-empty-strings=true
# 重启
neo4j start
header文件示例:
drug-header.csv:
drug_id:ID(Drug),name:string,smiles:string,:LABEL
protein-header.csv:
protein_id:ID(Protein),name:string,uniprot:string,:LABEL
interacts-header.csv:
:START_ID(Drug),affinity:float,:END_ID(Protein),:TYPE
这样导入50万节点+200万关系,我实测大约2分半钟完成,比LOAD CSV快了不止一个数量级。
4.3.3 APOC 和导出
// 导出全库为JSON(备份或迁移用)
CALL apoc.export.json.all('/data/export/dump.json', {useTypes: true})
// 导出指定子图
MATCH (d:Drug)-[r:TARGETS]->(p:Protein)
CALL apoc.export.json.data(collect(d), collect(r), collect(p),
'/data/export/drug_target_subgraph.json', {})
4.4 索引与性能优化
好,到了最关键的部分——索引。没有索引,Neo4j匹配节点时会走全局扫描,万级以上数据瞬间卡死。我在第一个项目中就踩过这个坑:写了一个看似简单的MATCH (d:Drug {name: 'xxx'}),结果跑了8秒才出来。加个索引,变成2毫秒。
4.4.1 创建索引
// 单属性索引(最常用)
CREATE INDEX drug_name_idx FOR (d:Drug) ON (d.name)
// 复合索引:多属性过滤时显著提升
CREATE INDEX drug_class_target_idx FOR (d:Drug) ON (d.class, d.target_count)
// 全文索引:用于SMILES子结构或描述文本的模糊搜索
CREATE FULLTEXT INDEX drug_fulltext FOR (d:Drug) ON EACH [d.name, d.description]
// 范围索引:适合数值过滤,比如分子量>500
CREATE RANGE INDEX drug_mw_idx FOR (d:Drug) ON (d.molecular_weight)
4.4.2 查询计划分析
性能遇到瓶颈时,用EXPLAIN或PROFILE看执行计划:
EXPLAIN MATCH (d:Drug {name: '地西泮'})-[r:TARGETS]->(p:Protein)
RETURN d, r, p
// PROFILE会真实执行并返回统计
PROFILE MATCH (d:Drug {name: '地西泮'})-[r:TARGETS]->(p:Protein)
RETURN d, r, p
看输出中的db hits和rows行数。如果出现NodeByLabelScan而不是NodeIndexSeek,说明没走索引——赶紧回去建索引。
4.4.3 内存与缓存调优
Neo4j性能核心在页面缓存(page cache)。估算公式很简单:你的全库大小是多少?尽量把热数据全塞进page cache:
# neo4j.conf 调优参数
dbms.memory.pagecache.size=8G
dbms.memory.heap.initial_size=4G
dbms.memory.heap.max_size=8G
# 如果做大量写操作,可以增加事务内存
dbms.tx_state.max_size=512M
MATCH (n) RETURN count(n),再配合dbms.listConfig看实际内存使用。我一般保持page cache是数据库文件大小的1.2~1.5倍。
4.5 本章核心知识脉络
下面的SVG图总结了本章的四块知识结构,以及它们如何支撑药物知识图谱的存储与查询。
4.6 本章小结
图数据库选型与基础操作是后续一切特征提取工作的地基。记住三条线:
- 存储线: 节点、关系、属性,对应药物KG中的药物、靶点、通路和它们的相互作用度量。
- 查询线: Cypher用图案配图案,写起来像画图,性能靠索引和page cache。
- 导入线: 数据量决定工具选择——千级用LOAD CSV,百万级用neo4j-admin,备份用APOC导出。
我在实际的生产级药物KG项目中,就是用这套组合拳完成了从数据清洗到图加载的全流程。工具本身不复杂,复杂的是从一开始就把存储结构和查询模式想清楚。