4. 图谱构建:使用 Neo4j 构建药物知识图谱

好,咱们进入实战阶段了。用 Neo4j 建图谱,说白了就是把数据塞进图数据库,让药物、靶点、疾病这些实体连成一张网。别急着写代码,先搞清楚你想存什么、怎么连。

4.1 节点构建:实体怎么存?

节点就是图谱里的「点」。每个点代表一个实体,比如一个药、一个基因。我个人习惯用 CREATE 语句直接插入,但生产环境更推荐用 LOAD CSV 批量导入。

先看一个简单的例子:创建一个药物节点和它对应的靶点。

CREATE (d:Drug {
    drugbank_id: "DB00001",
    name: "伊马替尼",
    molecular_weight: 493.6,
    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"
})
CREATE (t:Target {
    gene_symbol: "ABL1",
    uniprot_id: "P00519",
    name: "酪氨酸蛋白激酶ABL1"
})

嗯,这里要注意::Drug 和 :Target 是节点的标签(Label),相当于关系型数据库里的表名。属性名用下划线还是驼峰?我个人偏好下划线,跟 Python 变量名保持一致,团队协作时少吵架。

属性设计原则

  • 扁平化:别搞嵌套属性,Neo4j 不支持。把 target_info: {id: "P00519", name: "ABL1"} 拆成多个属性。
  • 避免冗余:计算值别存,比如 logP 可以从 SMILES 实时算。
  • 索引前置:准备做查询条件的字段,一律建索引。
经验谈:我在做 FDA 药物库导入时,把所有 drugbank_id 都设成 :ID 约束。这样后面匹配关系时,能用 MERGE 避免重复节点,比 MATCH + CREATE 快 3 倍以上。

4.2 关系构建:实体怎么连?

光有节点不够,得用关系把它们串起来。关系有方向、有类型、还能带属性。

举个例子:药物「作用于」靶点,这是个有向关系。

MATCH (d:Drug {drugbank_id: "DB00001"})
MATCH (t:Target {uniprot_id: "P00519"})
CREATE (d)-[:TARGETS {
    action: "抑制剂",
    affinity_nM: 0.03,
    confidence: "validated"
}]->(t)

你想想看,为什么我写成 (d)-[:TARGETS]->(t) 而不是 (t)-[:TARGETED_BY]->(d)?一来语义更自然,二来查询时从药物出发找靶点,减少遍历深度。方向选对,查询性能差一个数量级。

常见关系类型

关系类型 示例 方向 典型属性
TARGETS 药物 -> 靶点 单向 action, affinity, phase
INDICATES 药物 -> 疾病 单向 evidence, approval_status
INTERACTS_WITH 药物 <-> 药物 双向 interaction_type, severity
ASSOCIATED_WITH 靶点 <-> 疾病 双向 p_value, odds_ratio
避坑指南:我曾经图省事,把关系属性都堆在节点上,比如在 Drug 节点里用列表存 targets: ["ABL1", "KIT", "PDGFRB"]。结果要查「哪些药物作用于 KIT 靶点」时,只能用 WHERE 'KIT' IN d.targets,慢得离谱。后来老老实实拆成独立关系,配合索引,查询从秒级降到毫秒级。

4.3 属性优化:别把图谱当表格用

很多人刚开始用 Neo4j 时,习惯把关系型数据库那套搬过来。其实图谱有它自己的脾气。

什么时候用属性?什么时候用关系?

一个简单的判断标准:如果这个信息可能被单独查询或过滤,就做成关系。比如「药物 A 和药物 B 有相互作用」,如果把相互作用描述写成 :INTERACTS_WITH {description: "增加QT间期风险"} 的关系属性没问题;但如果你想查「所有增加QT间期风险的药物对」,这个 description 字段就值得单独建标签,甚至拆成一个 :InteractionEffect 节点。

我见过有人把某个药物的所有别名都堆在一个属性里:aliases: "Gleevec, STI571, CGP57148B"。然后查「STI571 是什么药」时,得做字符串匹配。正确的做法是把别名做成 :Synonym 节点,通过 :HAS_SYNONYM 关系连到药物。这样查询变成匹配一个节点,一步到位。

属性类型选择

  • 数值型:分子量、亲和力——用 float 或 int,别存字符串。
  • 布尔型:是否已批准、是否为天然产物——用 boolean。
  • 时间型:批准日期——用 date,别存时间戳。
  • 大文本:SMILES、InChI——除非要全文搜索,否则存字符串即可,别建索引。

4.4 索引与约束:查询快不快,就看这步

没索引的 Neo4j 就像没目录的书。查一个节点,全库扫描,几百万节点下去直接卡死。

我个人习惯在导入数据前就把索引建好,边导入边建索引也行,但第一次写入会慢一些。

// 单属性索引
CREATE INDEX drugbank_id_index FOR (d:Drug) ON (d.drugbank_id);

// 复合索引
CREATE INDEX drug_name_smiles_index FOR (d:Drug) ON (d.name, d.smiles);

// 唯一性约束(同时创建索引)
CREATE CONSTRAINT unique_drug_id FOR (d:Drug) REQUIRE d.drugbank_id IS UNIQUE;

你可能会问:建这么多索引,写入会不会变慢?会,但值得。在我的经验里,80% 的操作是查询,只有 20% 是写入。稍微牺牲一点写入性能,换来查询效率的大幅提升,划算。

技巧:我建议设置索引前先查一下现有索引,避免重复创建:CALL db.indexes() YIELD name, labelsOrTypes, properties。

4.5 数据导入策略:小批量 vs 全量

数据量不一样,策略不一样。

小批量(几万节点)

用 LOAD CSV 最省事。假设你有个 CSV 文件 drugs.csv:

LOAD CSV WITH HEADERS FROM 'file:///drugs.csv' AS row
CREATE (d:Drug {
    drugbank_id: row.drugbank_id,
    name: row.name,
    smiles: row.smiles
})

全量(百万级以上)

用 neo4j-admin import 工具,或者 APOC 库的 apoc.periodic.iterate 分批提交。我曾在百万节点的药物相互作用图上踩过坑——直接用 LOAD CSV 一次性提交,事务日志撑爆了磁盘。后来改成每 5000 条提交一次,问题解决。

// 用 APOC 分批导入
CALL apoc.periodic.iterate(
    "LOAD CSV WITH HEADERS FROM 'file:///interactions.csv' AS row RETURN row",
    "MATCH (d1:Drug {drugbank_id: row.drug1_id})
     MATCH (d2:Drug {drugbank_id: row.drug2_id})
     CREATE (d1)-[:INTERACTS_WITH {
         severity: row.severity,
         description: row.description
     }]->(d2)",
    {batchSize: 5000, parallel: true}
)
重点:batchSize 不是越大越好。默认 10000,但我一般设 3000-5000。太大容易内存溢出,太小事务开销高。你得根据服务器内存和数据的复杂程度调参。

4.6 图谱构建流程全景

下面这张图展示了我常用的构建流程,从原始数据到最终的图实例化。

药物知识图谱构建流程 原始数据源 CSV / JSON / API 知识定义 标签 / 关系 / 属性 数据清洗 去重 / 归一化 节点创建 Drug / Target / Disease 关系创建 TARGETS / INDICATES 索引与约束 CREATE INDEX / CONSTRAINT 属性优化 扁平化 / 类型选择 导入策略 LOAD CSV / periodic.iterate 药物知识图谱实例 数据质量决定图谱质量 清洗不彻底,分析全是坑

从图上你能看到,整个流程不是线性的。节点创建和关系创建经常交叉进行——建完药节点,马上连靶点;遇到新靶点,又回头建靶点节点。迭代着来,别想着一次完美。

4.7 一个小案例:从 0 到 1 的图谱构建

最后用个真实案例串一下。假设我们有三个药的数据要建图:伊马替尼、舒尼替尼、索拉非尼。它们都作用于酪氨酸激酶靶点,但具体的靶点谱不一样。

第一步:定义标签和关系。 确定我们需要的节点类型::Drug、:Target、:Disease。关系类型::TARGETS、:INDICATES。

第二步:清洗数据。 检查 ID 是否唯一,SMILES 格式是否准确,靶点名称是否标准化(比如 ABL1 和 c-Abl 是同一个东西)。

第三步:写 Cypher 脚本。

CREATE CONSTRAINT drug_id_unique FOR (d:Drug) REQUIRE d.drugbank_id IS UNIQUE;
CREATE CONSTRAINT target_id_unique FOR (t:Target) REQUIRE t.uniprot_id IS UNIQUE;

CREATE INDEX target_gene_index FOR (t:Target) ON (t.gene_symbol);

// 创建节点
LOAD CSV WITH HEADERS FROM 'file:///drugs.csv' AS row
CREATE (d:Drug {
    drugbank_id: row.drugbank_id,
    name: row.name,
    smiles: row.smiles
});

LOAD CSV WITH HEADERS FROM 'file:///targets.csv' AS row
CREATE (t:Target {
    uniprot_id: row.uniprot_id,
    gene_symbol: row.gene_symbol,
    name: row.name
});

// 创建关系
LOAD CSV WITH HEADERS FROM 'file:///drug_target.csv' AS row
MATCH (d:Drug {drugbank_id: row.drugbank_id})
MATCH (t:Target {uniprot_id: row.target_id})
CREATE (d)-[:TARGETS {
    action: row.action,
    affinity_nM: toFloat(row.affinity_nM)
}]->(t);

第四步:验证。 跑一条查询看图谱对不对:

MATCH (d:Drug)-[:TARGETS]->(t:Target)
RETURN d.name AS Drug, collect(t.gene_symbol) AS Targets
LIMIT 5;

我记得第一次跑这个查询时,看到结果里伊马替尼的靶点列表包含 ABL1、KIT、PDGFRB,舒尼替尼的列表是 FLT3、KIT、PDGFRB、VEGFR2——这些跟文献里完全对得上,那种感觉就像拼图最后一块落到位了。

总结一下: 图谱构建不是一次性的体力活,更像反复打磨的工艺。节点、关系、属性、索引——每一环都要想清楚再动手。建好的图谱是你的数据资产,它决定了后续所有分析的深度和速度。