简介:这是一份基于Python生态的Neo4j知识图谱构建入门资源,面向希望用pandas处理Excel数据、并以三元组形式批量写入图数据库的数据分析或开发人员。资源围绕发票数据场景,完整演示了从Excel数据抽取、节点与关系构建到Neo4j入库的流程,适合初学知识图谱或需要快速落地图数据导入的读者参考。包体共15个文件,其中8个Python脚本为核心,包含数据抽取、关系构建、图谱写入、矩阵转换等模块;另有2个txt依赖清单、1个Excel演示数据和1个Markdown说明文档,压缩包整体约30KB,结构紧凑,便于直接对照运行。当前已有2125人学习浏览,具有一定的实用参考价值。作者给出了清晰的实现路径:先用pandas将Excel转换为DataFrame,再通过函数分别抽取实体与关系,形成三元组后使用py2neo写入Neo4j。同时提供requirements.txt依赖文件,降低环境配置成本;neo4j_matrix模块可用于进一步分析图数据关系。这份资源体积虽小,但浓缩了知识图谱构建中常见的数据处理与入库思路,适合作为快速上手的代码模板。
1. 知识图谱听起来很大,但如果你手里的实体和关系都散落在 Excel 里,pandas 配合 py2neo v3 就是 Python 生态里最省事的一条路
这个项目的核心流程并不复杂:用 pandas 把 Excel 读进来,清洗成标准的“头实体—关系—尾实体”三元组,再通过 py2neo v3 批量写进 Neo4j,最终得到一个能用 Cypher 查询的知识图谱。适合做数据治理、数据中台样例、或者给某个业务系统做图谱 POC 的开发者。你不需要对图数据库内核有多深的理解,只要会基础 pandas 操作,就能在半小时内把一张张结构化的表变成真正的图结构。整个链路里最考验人的不是写库,而是数据准备阶段那些不起眼的小坑。
2. 数据准备阶段:pandas 从 Excel 抽取三元组的三个关键动作
导入之前的功夫,决定了图谱一半以上的质量。Excel 数据往往带着列名空格、表头合并、单元格空值、字符串前后的隐形空格,这些在图里都会表现为脏节点、重复实体、甚至写入失败。所以真正写库之前,有三件事值得多花时间:锁定 py2neo 版本边界、规范读取字段、把各种宽表变成标准三元组。下面按顺序拆开讲。
2.1 依赖选型与版本边界:py2neo v3 为什么还值得用
py2neo 从 v4 开始把 API 翻了个底朝天,Graph、Node、Relationship这些核心类型还在,但很多方法的位置和签名变了,网上大量旧教程直接跑不通。v3 系列在功能上反而更稳:Node("Entity", name="张三")这种直观的构造方式、graph.run()执行 Cypher、graph.merge()做幂等写入,都能用。更重要的是,v3 对接 Neo4j 3.x 和 4.x 都没问题,对硬件要求也不高,跑在普通办公笔记本上完全够用。
我一般这样锁版本:
pip install pandas openpyxl pip install py2neo==3.1.2这里有个隐藏的版本依赖:读取.xlsx需要openpyxl,读取旧版.xls需要xlrd。如果之前装的是xlrd2.x,读.xls时会直接报错,因为 2.x 只支持.xlsx。处理混合格式文件时,我的习惯是统一转成.xlsx,省掉这个麻烦。py2neo不要装成 3.1.1 之前的小版本,那几个版本在处理参数化查询时偶尔会把 int 类型解析成 float,虽然不致命,但会在图里留下类型混乱的属性。
装好后先做一次连接测试,这一步能过滤掉一半的“写了半天才发现连不上库”的尴尬:
from py2neo import Graph g = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) print(g.run("RETURN 1").evaluate())逻辑说明:bolt://localhost:7687是 Neo4j 的 Bolt 协议地址,不是 HTTP 的 7474 端口。Graph构造函数第二个参数用auth传用户名和密码,不要拼到 URL 里。.evaluate()是取查询结果第一行第一列的最简方式,适合做连通性测试。
2.2 读取与清洗:对列名、空值、类型做一次规整
读取 Excel 本身不难,难在读取之后。我见过太多人直接把列名包含空格、中文括号的 DataFrame 拿去写库,后续脚本里到处是df[' 头实体 ']这种写法。我一般在read_excel之后立即做三件事:列名统一、整行空值清理、实体字段类型与空格处理。
import pandas as pd df = pd.read_excel("raw_data.xlsx", engine="openpyxl", sheet_name=0) df.columns = [str(c).strip().replace(" ", "_") for c in df.columns] df = df.dropna(how="all") df["head"] = df["head"].astype(str).str.strip() df["relation"] = df["relation"].astype(str).str.strip() df["tail"] = df["tail"].astype(str).str.strip()这段代码逐行解释:read_excel的engine参数指定解析引擎,.xlsx固定用openpyxl;df.columns的列表推导式把所有列名做了strip并把空格替换成下划线,避免后续用点号访问属性时踩空格坑;dropna(how="all")只删除整行全为空的行,不会误伤单列缺失;最后对三个核心字段做astype(str),防止身份证号、编号这类列被 pandas 读成数字后丢精度,str.strip()则去掉单元格里肉眼看不见的首尾空格。
下一步处理空值。三元组里最怕的不是整行缺失,而是head和tail一个有一个没有。我的处理习惯是,实体字段的空值给默认值,关系字段的空值直接过滤掉:
df = df.fillna({"head": "未知", "tail": "未知"}) df = df[df["relation"].notna() & (df["relation"] != "")]参数说明:fillna按列给默认值,不要用空字符串去填充实体字段,因为空字符串放进图库后会变成一个“真实”存在的属性,查询时难以过滤。关系字段如果为空,这行数据本身没有语义价值,过滤掉最干净。下面的清洗规则表可以作为后续项目的固定模板:
| 数据问题 | 处理方式 | 使用场景 |
|---|---|---|
| 列名含空格或中文括号 | strip+replace | 统一字段引用方式 |
| 实体字段为 NaN | fillna("未知") | 保持连接不中断 |
| 实体首尾带空格 | .str.strip() | 避免同名实体 |
| 关系为空 | dropna或条件过滤 | 语义不完整数据直接丢弃 |
| 数值型编号被读成浮点 | astype(str)+ 按需补零 | 保证实体名唯一 |
2.3 把宽表转成三元组:三种常规结构,两种转换写法
Excel 里的数据不会天然按“头实体—关系—尾实体”三列排好,常见的原始形态有三种。第一种最省事,表里已经有head / relation / tail三列,只需要做过滤:
triples = df[["head", "relation", "tail"]].copy() triples = triples[(triples["head"].str.len() > 0) & (triples["tail"].str.len() > 0)]逻辑说明:这种形态下只保留头和尾都不为空的行。.str.len()依赖前面的astype(str),否则遇到 NaN 会直接报错。copy()是必须的,避免后续切分时触发SettingWithCopyWarning。
第二种是宽表形态:左边是一列实体,右边若干列是不同类型的关联实体。比如一列“员工”,后面跟着“所属部门”“直属上司”“参与项目”三列,这三列其实是三种不同的关系。用melt把宽表拉长:
triples_wide = df.melt( id_vars=["head"], value_vars=["所属部门", "直属上司", "参与项目"], var_name="relation", value_name="tail" ) triples_wide = triples_wide[triples_wide["tail"].notna()]melt的参数含义:id_vars是保持不变的实体列,value_vars是要转成行的关系列,var_name是转换后关系类型列的列名,value_name是尾部实体列的列名。转换后,原本三列的数据会变成三行,每行对应一条三元组。注意最后一步必须过滤tail为空的组合,因为宽表里很多单元格是空的。
第三种是单元格内一对多:一个单元格里用竖线或逗号分隔了多个实体,比如“参与项目”格里写的是“A项目|B项目|C项目”。处理思路是先把分隔符拆成列表,再explode成多行:
df["tail_list"] = df["tail_list"].apply( lambda x: [] if pd.isna(x) else str(x).split("|") ) triples_multi = df.explode("tail_list").rename(columns={"tail_list": "tail"})参数说明:split("|")按竖线切分,如果原始数据用的是中文逗号,改split(",")即可。pd.isna(x)判断空单元格,空值给空列表而不是['nan'],这是最容易忽略的细节。explode会把列表展开成多行,其他列自动复制。到这里,三种原始形态都归一成了标准三元组,可以进入图模型设计阶段了。
3. 写入阶段:三元组如何映射成图,以及如何做到不重复
数据准备好了,下一步是让三元组落进 Neo4j。这一章从节点设计、唯一约束、批量提交三个层面展开,重点说清楚对象 API 和 Cypher 两种写法的取舍。
3.1 节点与关系设计:实体标签怎么定,关系类型怎么取
同一批数据里,一个实体可能同时出现在三元组的头部和尾部。比如“张三 属于 技术部”和“技术部 负责 项目A”里,“技术部”既是尾实体又是头实体。设计图模型的时候,最省心的做法是给所有实体统一打一个标签,比如Entity,再用属性区分类型。如果一开始就按“员工”“部门”“项目”打三个标签,后期会出现同一实体跨标签的麻烦,查询时还得处理标签交集。
from py2neo import Node, Relationship head_node = Node("Entity", name=row["head"], source="excel") tail_node = Node("Entity", name=row["tail"], source="excel") rel = Relationship(head_node, row["relation"], tail_node)代码说明:Node的第一个参数是标签,后面接属性键值对。Relationship(head_node, row["relation"], tail_node)的第二个参数是关系类型。这里有个我必须提前踩下的坑:关系类型不能写中文,不能带空格。Neo4j 的关系类型在 Cypher 里如果是中文,查询必须带反引号,写起来非常难受。我一般会把原始关系映射成英文大写下划线风格:
relation_map = { "属于": "BELONG_TO", "包含": "CONTAINS", "关联": "RELATES_TO", "参与": "PARTICIPATES_IN" } rel_type = relation_map.get(str(row["relation"]), "DEFAULT_RELATION")说明:映射表单独放在一个配置模块里,不要散落在业务代码中。str(row["relation"])是为了防止某些 Excel 单元格读出来是数字或者 float 类型。如果遇到未映射的关系,给它一个默认类型DEFAULT_RELATION,保证写入不中断,后续再回来看哪些数据没映射。
3.2 唯一约束与 MERGE 语义:给重复和并发上保险
在批量写入之前,必须先给实体表加上唯一约束。没有约束的情况下,两个同名实体在并发写入时可能同时被创建两次,后面清理起来非常痛苦。建约束的代码很简单:
graph.schema.create_uniqueness_constraint("Entity", "name")这行代码的含义是:对Entity标签下的name属性建立唯一约束。约束建好后,再用graph.merge写节点,就能保证同名实体不会重复创建:
node = graph.merge(Node("Entity", name=name), "Entity", "name")graph.merge有三个参数:第一个是待写入的节点,第二个是主标签,第三个是合并键。py2neo v3 的merge语义是“存在就返回原节点,不存在就创建”,函数返回值是合并或创建后的节点对象,可以直接拿来建关系。
注意,create_uniqueness_constraint要求现有数据里不能有重复值,否则会报错。如果你是从一个已经有重复数据的库上补约束,需要先去重。我的做法是先用 Cypher 做一次分组统计,确认没有重复再建约束,不要盲目执行。
3.3 批量写入:单条 create 是性能陷阱,UNWIND 才是正解
用 py2neo 对象 API 写节点很方便,但用对象 API 逐条建关系就是灾难。每调用一次graph.create()都会开启一个独立事务,逐条提交一万条关系,耗时可能到分钟级别甚至更久。我最终会选择用 Cypher 的UNWIND做批量写入,把数据先组装成字典列表,再一次性发给服务端。
rows = triples.to_dict("records") cypher = """ UNWIND $rows AS row MATCH (h:Entity {name: row.head}) MATCH (t:Entity {name: row.tail}) MERGE (h)-[r:BELONG_TO]->(t) ON CREATE SET r.name = row.relation """ graph.run(cypher, rows=rows[:500])这段 Cypher 的逻辑:UNWIND $rows AS row把参数数组展开成一行行记录;MATCH两句分别定位头尾实体;MERGE建关系,这样同一个关系不会被重复创建;ON CREATE SET在关系首次创建时写入属性,避免覆盖已有关系的属性。
每次提交的行数我一般控制在 500 到 1000 条之间。提交太大,服务端内存压力大;太小,往返次数多,性能反而下降。整个批次循环可以这样写:
from math import ceil batch_size = 500 for i in range(0, len(rows), batch_size): batch = rows[i:i + batch_size] graph.run(cypher, rows=batch)参数说明:range(0, len(rows), batch_size)以步长滑动切片,rows[i:i + batch_size]取当前批次。graph.run的第二个参数是 Cypher 参数,这里传入的是字典列表,注意 Cypher 里$rows的类型是LIST OF MAP,不能用单个 dict 替换。下面是一个简单的参数速查表:
| 参数 | 建议值 | 含义 |
|---|---|---|
| batch_size | 500 | 每次事务处理的行数 |
| 唯一约束 | name | 合并节点的判断依据 |
| 关系类型映射 | 英文大写 | 避免中文反引号问题 |
| UNWIND 参数 | list of dict | 驱动参数化查询 |
4. 避坑与排查:py2neo v3 落地时最常见的五个坑
再顺利的流程,也会在细节上翻车。这一章从真实操作里挑出五个高频问题,按“现象 → 原因 → 解决”的顺序写,方便你在出问题时直接照着处理。
4.1 连接失败:密码正确却连不上库
现象:Graph()对象创建成功,但一执行graph.run()就抛ConnectionRefused或Unauthorized。有时候用http://localhost:7474能通,用bolt://localhost:7687就不通。
原因:Neo4j 服务端可能没启动 Bolt 协议监听,或者防火墙拦了 7687 端口。另一个常见原因是 py2neo v3 在创建连接时默认走 HTTP 的 7474 端口,如果你用Graph("http://localhost:7474")去连一个只开了 Bolt 的实例,也会失败。还有一部分情况是密码里带了特殊字符,写在 URL 里被解析出了问题。
解决:先确认 Neo4j 的配置文件里dbms.connectors.default_listen_address和 Bolt 端口是否启用,再用cypher-shell手工测试一次连接。代码层面统一写成Graph("bolt://localhost:7687", auth=("neo4j", "password")),密码作为auth参数传入,绝不拼 URL。这样能把网络层和认证层的错误分开排查。
4.2 实体名称被塞进了 NaN 和空串
现象:图谱里出现一个名为"nan"的实体节点,或者“张三”和“张三 ”(带空格)两个节点同时存在,查询永远只能查到一半数据。
原因:pandas 里空单元格读出来是NaN,它是一个 float 类型,转成字符串后就是"nan"。Excel 里前端录入时如果带了个看不见的空格,strip之前这个空格会原样进入图库,name属性看起来一样,实际不相等。
解决:在 2.2 节的清洗阶段就做掉,口诀是“实体字段先astype(str)再str.strip(),空值必须fillna给默认值”。如果错误数据已经写入,用一条 Cypher 清掉:
MATCH (n:Entity {name: "nan"}) DETACH DELETE n;清完后重新跑一遍写入,覆盖正常实体。对于带空格的旧脏数据,可以先用正则匹配出来确认数量,再决定是清理还是归一化。
4.3 MERGE 之后居然还有重复实体
现象:明明用了graph.merge(),但MATCH (n:Entity {name:"某实体"}) RETURN count(n)返回的结果大于 1。
原因:唯一约束没有提前建立。没有唯一约束时,两个请求如果同时发现“这个实体不存在”,就会同时创建。py2neo 的merge和 Cypher 的MERGE行为类似,它不是绝对安全的并发操作,唯一约束才是那个真正的保险。另一个原因是你可能在不同批次的脚本里改了主键名,一部分数据用的是name,另一部分用的是title。
解决:进入写入流程前,永远先执行create_uniqueness_constraint("Entity", "name")。如果重复已经存在,先做一次聚合清理,把同一name的多余节点合并到第一个节点上:
MATCH (n:Entity) WITH n.name AS name, collect(n) AS nodes WHERE size(nodes) > 1 WITH nodes[0] AS keep, nodes[1..] AS dups UNWIND dups AS dup MATCH (dup)-[r]->(target) MERGE (keep)-[nr:BELONG_TO]->(target) ON CREATE SET nr = properties(r) DELETE dup;执行前记得先备份数据,这种清理脚本一旦跑错,会把关系指向合并后的节点,属性不会自动迁移。
4.4 写入慢如蜗牛:事务没复用
现象:三千行三元组,写成对象 API 逐条graph.create(),跑了十几分钟还没结束。检查 Neo4j 服务端日志,发现事务开启关闭的频率高得吓人。
原因:每条graph.create()默认就是一个独立事务。事务的开启和提交开销远大于执行本身,逐条写入等于把九个步骤压缩成一个步骤还重复三千次。
解决:改成 3.3 节里的 UNWIND 批量方式。同样三千条数据,分 6 批提交,每批 500 条,整体耗时能从分钟级降到秒级。如果数据量上了百万级别,就换 Neo4j 官方的neo4j-admin import离线导入,py2neo 这种在线写入方式不适合超大图的初始化。最直观的替换方式是先计算出批次数量,让事务数量从 3000 降到 6。
4.5 中文、引号、换行引发的 Cypher 破坏
现象:写入时报Invalid input语法错误,或者关系类型里出现中文,查询时必须到处加反引号。
原因:如果你用 Python 的字符串拼接去构造 Cypher,而不是用参数化查询,那任何包含单引号、双引号、换行符的实体名,都会直接破坏整条语句的结构。比如三元组的尾部实体“某公司(甲方)”里带的全角括号问题不大,但半角单引号足以让 Cypher 解析器当场报错。中文关系类型则是 Neo4j 的标识符规范问题,它允许 Unicode,但查询时默认要加反引号。
解决:第一条铁律是“所有值都必须走参数化”,写graph.run(cypher, rows=rows),不要写graph.run(f"MATCH ... WHERE n.name = '{name}'")。第二条铁律是关系类型统一走映射表,把中文转成英文大写下划线。补一条保险做法:在清洗阶段把实体名里的单引号、双引号、换行符统一替换成空格或下划线,这个操作放在str.strip()之后。
5. 回读验证与扩展:写完图谱后我必做的三件事
数据写入完成只算走完了前一半,真正交付之前我会强制自己跑一遍回读验证。第一件事是基础统计。先看节点数和关系数是否和预期一致,如果还不确定,用一条语句同时查出两端:
MATCH (n:Entity) RETURN count(n) AS node_count; MATCH ()-[r]->() RETURN count(r) AS rel_count;第二件事是孤立节点检查。三元组数据里如果有些实体只有名称没有关系,它会变成悬空节点,占用空间却不参与任何查询。排查方式比较简单,找出所有没有关系连接的节点,确认它们是本来就没有语义记录,还是数据清洗时丢了关系:
MATCH (n:Entity) WHERE NOT (n)--() RETURN n.name LIMIT 50;第三件事是验证最大连通结构。知识图谱的价值在于关联路径,如果数据缺失严重,图谱会被拆成大量互不相通的小团块。我会用两跳路径去反推核心实体是否存在,比如查某个关联最多的节点:
MATCH (n:Entity)-[r]->() RETURN n.name, count(r) AS out_degree ORDER BY out_degree DESC LIMIT 10;这个结果能直接告诉你哪些实体是业务的中枢,方便反过来检查数据源里这些实体对应的行有没有被误过滤。跑完这三个验证,图谱才真正能在业务上被调用。
数据更新场景下,我会把清洗脚本、映射表、写入脚本拆成三个独立模块,只在写入这一层做变更。如果只是 Excel 内容变化,其余两个模块原封不动;如果关系类型新增,只需改映射表。从那以后,每次我跑完导入脚本,都会强制把这组查询完整过一遍,再交给下游使用,而不是盯着终端输出里的“Created N nodes”就算完事。希望这些经验和避坑记录帮到你,少走几趟弯路。
本文还有配套的精品资源,点击获取