"""把 _002_ 抽好的中药图谱 / 方剂图谱导入 Neo4j。
读 _002__extract_information/{中药图谱,方剂图谱}/*.json 里的 entities 和 relations,
在 Python 里拼 Cypher,走 common/neo4j_manager.py 的 Neo4jClient 写库。
几个关键取舍:
- **先在内存里去重,再写库。** 49226 条 entity 里只有 18870 个不同节点,50627 条
relation 里只有 48346 条不同边 —— 同一个「咳嗽」在几百个文件里都出现。逐条 MERGE
等于把同样的活干几百遍,所以先聚合成唯一集合再写。
- **节点主键是 (label, name),不是 name。** 有 356 个名字同时挂在两种类型下
(「热咳」既是 Disease 又是 Symptom),只按 name 去重会把两类实体粘成一个节点。
- **label 和关系类型只能拼进语句,不能当参数**,Cypher 不支持参数化它们。所以这两个
位置都先对白名单做校验再拼字符串,值一律走 $ 参数,不做字符串插值。
- 属性以「实体自己那个文件」为准:一贯煎.json 里的「当归」只是配料,没有性味剂量,
当归.json 里才是全的。同名属性冲突时后者覆盖前者,其余只补空缺。
用法(在项目根目录执行):
python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --limit 5
python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --dry-run
python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j
python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --only formula
"""
import argparse
import json
import os
from collections import Counter
from tqdm import tqdm
from common.config import Config
from common.neo4j_manager import Neo4jClient
from common.path_utils import get_file_path
# 白名单 = 唯一允许拼进 Cypher 的 label / 关系类型,和 _002_ 的 Pydantic schema 一致。
# 抽取结果里出现过 TREATS、TREATS_DISE 这种模型笔误,不在名单里就丢掉并记账。
ENTITY_TYPES = {"Symptom", "Disease", "Formula", "Herb", "Effect", "Source"}
RELATION_TYPES = {
"TREATS_DISEASE",
"ALLEVIATES_SYMPTOM",
"HAS_EFFECT",
"HAS_INGREDIENT",
"HAS_SYMPTOM",
"FROM_SOURCE",
}
# FormulaAttributes + HerbAttributes 的全部字段。属性名同样会拼进 SET,所以也过白名单,
# 免得模型多吐一个键就在库里长出一个野属性。
ATTR_KEYS = {
"alias",
"dosage",
"effect",
"indication",
"meridian",
"origin",
"place",
"processing",
"property_flavor",
"taboo",
"traits",
"usage",
}
GRAPH_DIRS = {
"herb": "_002__extract_information/中药图谱",
"formula": "_002__extract_information/方剂图谱",
}
# 一个事务里塞多少条语句。太小则往返次数多,太大则事务占内存、失败回滚的代价高。
BATCH_SIZE = 500
# ======================
# 读盘 + 聚合
# ======================
def iter_graph_files(only=None, limit=0):
"""按 (来源, 路径) 产出要导入的图谱 json 路径。"""
paths = []
for kind, rel_dir in GRAPH_DIRS.items():
if only and kind != only:
continue
directory = get_file_path(rel_dir)
if not os.path.isdir(directory):
print(f"图谱目录不存在,跳过: {directory}")
continue
names = sorted(f for f in os.listdir(directory) if f.endswith(".json"))
if limit:
names = names[:limit]
paths.extend((kind, os.path.join(directory, f)) for f in names)
return paths
def clean_attributes(raw):
"""过滤属性字典:只留白名单里的键,丢掉空值,值统一成字符串。"""
if not isinstance(raw, dict):
return {}
out = {}
for key, value in raw.items():
if key not in ATTR_KEYS or value is None:
continue
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
text = text.strip()
if text:
out[key] = text
return out
def collect(paths, stats):
"""把所有图谱 json 聚合成「唯一节点」和「唯一关系」两个集合。
nodes: {(label, name): {属性}}
rels: {(主label, 主name, 关系, 宾label, 宾name)}
关系两端的节点也顺手补进 nodes —— 有 177 处 relation 的端点没在同文件的 entities
里列出来,不补的话 MATCH 匹配不上,那条边就凭空丢了。
"""
nodes = {}
rels = set()
for kind, path in tqdm(paths, desc="读取图谱", unit="个", ncols=90):
try:
with open(path, encoding="utf-8") as f:
graph = json.load(f)
except (OSError, json.JSONDecodeError):
stats["json 读取失败"] += 1
continue
# 主体实体的属性最全(同名的配料条目往往只有名字),最后合并时优先它
main_name = graph.get("name")
for entity in graph.get("entities") or []:
label = entity.get("type")
name = (entity.get("name") or "").strip()
if label not in ENTITY_TYPES or not name:
stats["实体不合法丢弃"] += 1
continue
key = (label, name)
attrs = clean_attributes(entity.get("attributes"))
existing = nodes.setdefault(key, {})
if name == main_name:
# 自己那个文件里的定义最权威,直接盖掉别处带来的零碎属性
existing.update(attrs)
else:
# 只补空缺,不覆盖已有的
for k, v in attrs.items():
existing.setdefault(k, v)
for relation in graph.get("relations") or []:
rel_type = relation.get("relation")
s_label, o_label = relation.get("subject_type"), relation.get("object_type")
s_name = (relation.get("subject") or "").strip()
o_name = (relation.get("object") or "").strip()
if rel_type not in RELATION_TYPES:
stats["关系类型不合法丢弃"] += 1
continue
if s_label not in ENTITY_TYPES or o_label not in ENTITY_TYPES:
stats["关系端点类型不合法丢弃"] += 1
continue
if not s_name or not o_name:
stats["关系端点为空丢弃"] += 1
continue
nodes.setdefault((s_label, s_name), {})
nodes.setdefault((o_label, o_name), {})
rels.add((s_label, s_name, rel_type, o_label, o_name))
return nodes, rels
# ======================
# 拼 Cypher
# ======================
def build_constraint_cypher():
"""每类实体按 name 建唯一约束。
顺带把 MERGE 需要的索引也建好了(约束自带索引),不然 18870 次 MERGE 都是全表扫。
IF NOT EXISTS 让脚本可以重复跑。
"""
return [
(
f"CREATE CONSTRAINT {label.lower()}_name IF NOT EXISTS "
f"FOR (n:{label}) REQUIRE n.name IS UNIQUE",
None,
)
for label in sorted(ENTITY_TYPES)
]
def build_node_cypher(nodes):
"""一个节点一条 MERGE。
label 只能拼进语句(Cypher 不支持参数化 label),name 和属性全走 $ 参数。
label 已经过了 ENTITY_TYPES 白名单,不存在注入面。
属性用 SET n += $attrs 而不是 MERGE (n {name:..., effect:...}):属性写进 MERGE
的话,属性一变就 MERGE 不上、变成新建一个节点。ON CREATE SET 也不合适 —— 重跑或
补数据时已存在的节点就永远补不上属性了。
"""
queries = []
for (label, name), attrs in sorted(nodes.items()):
if attrs:
query = f"MERGE (n:{label} {{name: $name}}) SET n += $attrs"
params = {"name": name, "attrs": attrs}
else:
query = f"MERGE (n:{label} {{name: $name}})"
params = {"name": name}
queries.append((query, params))
return queries
def build_relation_cypher(rels):
"""一条关系一条语句:两端 MATCH(节点已经在上一步 MERGE 好了)再 MERGE 边。
关系类型和两端 label 都拼进语句,同样先过了白名单。MERGE 而不是 CREATE,
保证重复跑不会长出平行边。
两个 MATCH 中间夹一个 WITH,而不是写成 `MATCH (s), (o)`:后者是一条语句里两个不
相连的模式,Neo4j 每条都会回一个 cartesian product 警告,5 万条刷得日志没法看。
这里两端都是唯一约束上的等值查找,笛卡尔积本来就是 1×1,换个写法只是让驱动闭嘴。
"""
queries = []
for s_label, s_name, rel_type, o_label, o_name in sorted(rels):
query = (
f"MATCH (s:{s_label} {{name: $s_name}}) "
f"WITH s MATCH (o:{o_label} {{name: $o_name}}) "
f"MERGE (s)-[:{rel_type}]->(o)"
)
queries.append((query, {"s_name": s_name, "o_name": o_name}))
return queries
# ======================
# 写库
# ======================
def run_in_batches(client, queries, desc):
"""分批提交。
run_multiple_cypher 整批是一个事务,5 万条一次性提交内存吃不消、失败也全回滚,
所以切成 BATCH_SIZE 一批。批与批之间不是原子的,但所有语句都是 MERGE,
中断后重跑是幂等的,无所谓。
"""
total = len(queries)
done = 0
for start in range(0, total, BATCH_SIZE):
batch = queries[start : start + BATCH_SIZE]
client.run_multiple_cypher(batch)
done += len(batch)
print(f" {desc}: {done}/{total}")
return done
def run(only=None, limit=0, dry_run=False, wipe=False):
paths = iter_graph_files(only=only, limit=limit)
if not paths:
print("没有找到图谱 json,先跑 _002_ 的抽取脚本。")
return
stats = Counter()
nodes, rels = collect(paths, stats)
label_count = Counter(label for label, _ in nodes)
rel_count = Counter(r[2] for r in rels)
print(f"\n图谱文件 {len(paths)} 个")
print(f"唯一节点 {len(nodes)} 个: {dict(label_count.most_common())}")
print(f"唯一关系 {len(rels)} 条: {dict(rel_count.most_common())}")
for key, value in stats.most_common():
print(f" {key}: {value}")
node_queries = build_node_cypher(nodes)
rel_queries = build_relation_cypher(rels)
if dry_run:
print("\n--dry-run,不写库。示例语句:")
for query, params in node_queries[:2] + rel_queries[:2]:
print(f" {query}\n {params}")
return
conf = Config()
client = Neo4jClient(conf.NEO4J_URI, conf.NEO4J_USER, conf.NEO4J_PASSWORD)
if wipe:
# 只删这个图谱的六类节点,不动库里别的东西。DETACH 连边一起删。
print("\n清空旧数据...")
for label in sorted(ENTITY_TYPES):
client.run_cypher(f"MATCH (n:{label}) DETACH DELETE n")
print("\n建唯一约束...")
client.run_multiple_cypher(build_constraint_cypher())
print("写入节点...")
run_in_batches(client, node_queries, "节点")
print("写入关系...")
run_in_batches(client, rel_queries, "关系")
# 从库里回读一遍计数,别只信自己发了多少条语句
print("\n库内实际计数:")
for label in sorted(ENTITY_TYPES):
rows = client.run_cypher(f"MATCH (n:{label}) RETURN count(n) AS c")
print(f" {label}: {rows[0]['c']}")
for rel_type in sorted(RELATION_TYPES):
rows = client.run_cypher(f"MATCH ()-[r:{rel_type}]->() RETURN count(r) AS c")
print(f" {rel_type}: {rows[0]['c']}")
def main():
parser = argparse.ArgumentParser(description="把 _002_ 抽取的图谱导入 Neo4j")
parser.add_argument(
"--only",
choices=sorted(GRAPH_DIRS),
help="只导入一边:herb=中药图谱,formula=方剂图谱,默认两边都导",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="每个目录只取前 N 个 json 试跑,默认 0 表示全量",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只统计并打印示例 Cypher,不连库不写入",
)
parser.add_argument(
"--wipe",
action="store_true",
help="导入前先 DETACH DELETE 这六类节点(会删掉已有图谱数据)",
)
args = parser.parse_args()
run(only=args.only, limit=args.limit, dry_run=args.dry_run, wipe=args.wipe)
if __name__ == "__main__":
main()