第4章 图数据库基础:Neo4j与Cypher

好,开始正题。图数据库这块,我最初入行时也绕了不少弯。市面上图数据库不少,但Neo4j真正让我觉得「嗯,这东西靠谱」。它背后的图存储引擎和查询语言设计,对药物知识图谱这种复杂关系网络来说,再合适不过。

实际上,Neo4j的核心就是一个属性图模型。节点(Node)代表实体,关系(Relationship)代表连接,两者都能挂载键值对属性。想想药物分子与靶点蛋白的作用关系?用节点表示化合物和蛋白,用「作用于」关系连接,再用属性存储结合能Ki值——这比关系型里铺三张外键表自然太多了。

一句话总结: Neo4j把关系当第一公民存储,查询时无需大表JOIN。

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)
我建议: 任何频繁出现在WHERE子句中的属性都该建索引。包括药物ID、蛋白UniProt ID、ATC分类码、分子量范围。

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章 知识体系 —— 图数据库基础 Neo4j 架构 属性图模型 Cypher 查询 模式匹配·聚合 数据导入/导出 LOAD CSV · admin 索引与调优 page cache · profile 药物知识图谱存储 节点·关系·属性·索引 实践要点 · 优先用MERGE防止重复节点 · 百万级导入用neo4j-admin · 总在WHERE属性上建索引 避坑指南 · 别忽略page cache配置 · 避免返回大量非必要属性 · 联合查询用WITH控制中间数

4.6 本章小结

图数据库选型与基础操作是后续一切特征提取工作的地基。记住三条线:

  • 存储线: 节点、关系、属性,对应药物KG中的药物、靶点、通路和它们的相互作用度量。
  • 查询线: Cypher用图案配图案,写起来像画图,性能靠索引和page cache。
  • 导入线: 数据量决定工具选择——千级用LOAD CSV,百万级用neo4j-admin,备份用APOC导出。

我在实际的生产级药物KG项目中,就是用这套组合拳完成了从数据清洗到图加载的全流程。工具本身不复杂,复杂的是从一开始就把存储结构和查询模式想清楚。