简介:面向电力运检知识图谱构建的Python项目,整合知识抽取算法与管理系统前后端代码,适合算法工程师、图数据库开发者和电力系统信息化人员使用。算法部分包含AC属性抽取代码与数据、实体库、过滤器、命名实体识别最新代码及数据、关系抽取代码与数据;前端部分覆盖配置、路由、全局状态、通用组件、页面组件和工具包,构成完整图谱应用。压缩包共217个文件,以134个JavaScript文件和10个Python脚本为主体,辅以16个CSV和18个TXT数据文件、JSON配置、CSS样式和图片资源,整体大小仅5.68MB。目前已有269人学习下载。通过该工程可系统了解电力运检语料的实体识别、属性抽取与关系抽取实现方式,同时掌握知识图谱前端管理系统的模块划分与交互逻辑,便于在此基础上扩展自己的电力设备运检应用。
1. 电力运检知识图谱:从散乱文本到一张可查询的设备故障网
做电力运检的人最头疼的不是设备坏,是设备坏之前那些蛛丝马迹散在一堆运检工单、缺陷单、检修报告里。这份基于 Python 的电力运检知识图谱资源,包含知识抽取算法代码和一套前后端分离的管理系统源代码,核心思路是:把「220kV 1 号主变本体乙炔超标」这类非结构化文本,先用规则和 BiLSTM-CRF 抽成实体与关系,再存入 Neo4j 图数据库,最后在浏览器里把整张故障网拖拽出来。它解决的检索问题是:新员工查「某型号断路器拒动过几次、上次怎么处理的」,不用再翻三个月台账。适合正在做工业知识图谱、电力信息化项目,或者想拿一套完整 NLP+图数据库工程练手的人。
2. 技术选型与整体架构:Python 抽取、Neo4j 存储、前后端分离管理
2.1 为什么是「Python + Neo4j + 前后端分离」这套组合
工业场景下的知识图谱设计和通用知识图谱不一样,它的数据源、本体和查询模式都是固定的,选型必须围绕「够用、好维护」来。知识抽取环节选 Python 几乎没有争议:电力运检文本里有大量专业术语,要挂自定义词典、要做正则召回、要训练序列标注模型,Python 的 jieba、LAC、HanLP 和 transformers 生态都能直接接上;而且抽取代码后续要迭代,Python 脚本改起来比 Java 快得多。
存储环节,传统做法是建几张关系表存设备、缺陷、工单,但「某个主变出现过几次乙炔超标、每次关联了哪些检修措施、处理人是谁」这类问题,SQL 要 join 四五张表,递归查深度关系更是灾难。Neo4j 的用武之地是多跳关系查询,cypher 写起来像自然语言,工业图谱的实体种类有限,一般就十几类,Neo4j 完全扛得住,不需要上分布式图数据库。管理系统用前后端分离,是因为图谱查询接口不光要喂给网页端,还要给台账系统、报表系统复用,把接口独立出来,比塞在一个单体应用里干净得多。
2.2 资源目录与数据流转
这套资源的代码结构大致是这样,拿到手先按这个摸一遍:
power-grid-kg/ ├── algorithm/ # Python 知识抽取算法 │ ├── data/ # 原始运检文本、标注语料 │ ├── entity_recognition/ # 实体识别:规则 + BiLSTM-CRF │ ├── relation_extraction/ # 关系抽取:模板 + 位置约束 │ └── kg_writer/ # 三元组入库脚本(py2neo) ├── backend/ # 管理系统后端(Spring Boot) │ ├── controller/ # 图谱查询、台账管理接口 │ └── service/ # Neo4j 查询封装 ├── frontend/ # 管理系统前端(Vue + Element UI) │ ├── views/ # 图谱可视化、台账列表 │ └── components/ # ECharts 力导向图组件 └── docs/ # 本体设计文档、接口文档数据流转是:原始运检文本 → 预处理清洗 → 实体识别 → 关系抽取 → 三元组生成 → 去重合并 → 写入 Neo4j → Spring Boot 查询接口 → Vue 可视化展示。环境准备阶段,我一般会先用 VSCode 把 Python 环境配好,装好 requirements.txt 里的依赖,Neo4j 用 4.4 LTS 社区版,因为 py2neo 2021.2.3 版本和 4.x 配合最稳,后面第 5 章会专门讲这个坑。
3. 知识抽取算法的落地实现:规则召回 + BiLSTM-CRF 精排
3.1 先想清楚抽什么:本体设计与标签体系
知识抽取的第一步不是写代码,是定本体。电力运检场景里,一段典型的缺陷描述长这样:「220kV 某变电站 1 号主变本体油浸式套管渗油,计划 2024-03-12 停电处理,检修二班负责」。能抽出来的核心实体有四类:设备、部位、缺陷、日期,外加班组人员。这套资源里实体识别用的是 BIO 序列标注,B 表示实体开始,I 表示实体内部,O 表示非实体,标签表如下:
| 实体类型 | BIO 标签 | 示例 |
|---|---|---|
| 设备 | B-DEV / I-DEV | 主变、断路器、隔离开关、互感器 |
| 部位 | B-PART / I-PART | 本体、套管、油枕、触头 |
| 缺陷 | B-DEF / I-DEF | 渗油、发热、拒动、乙炔超标 |
| 日期 | B-DATE / I-DATE | 2024-03-12、计划检修时间 |
| 班组 | B-TEAM / I-TEAM | 检修二班、运维一班 |
关系类型比实体更精简,规则里重点抽两类:设备-缺陷(主变-渗油)和设备-部位(主变-本体)。关系抽太多会带来标注成本和误报率上升,工业场景宁可少抽几种关系,把准确率保住。本体设计文档里还会给每个实体类型配一个「同义词表」,比如「渗油」和「漏油」在缺陷识别时要归并成同一个标准名,这一步在入库前做。
3.2 规则召回:用词典和正则先把文本打薄
深度学习模型不是万能的,电力文本里有大量确定性较强的信息,比如电压等级、日期格式、设备型号,用规则可以零成本拿高分。这套资源的做法是先跑规则,把能被正则和词典稳定识别的实体标记出来,剩下的模糊片段才交给模型。规则层我一般会拆成两个 python 函数,一个处理日期和电压等级,一个处理设备型号:
import re VOLTAGE_PATTERN = r"(10|35|66|110|220|330|500|750|1000)\s*kV" DATE_PATTERN = r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?" DEVICE_PATTERN = r"([\u4e00-\u9fa5]{2,6}(?:主变|断路器|隔离开关|避雷器|互感器|开关柜))" def rule_recall(text: str) -> list: entities = [] for m in re.finditer(VOLTAGE_PATTERN, text): entities.append({"text": m.group(), "label": "VOLTAGE"}) for m in re.finditer(DATE_PATTERN, text): entities.append({"text": m.group(), "label": "DATE"}) for m in re.finditer(DEVICE_PATTERN, text): entities.append({"text": m.group(), "label": "DEV"}) return entities这段代码的核心思路是把能确定的信息先拿走:电压等级和日期用正则百发百中,设备名用「前缀词 + 设备类型词」的组合模式,比单纯维护几百个设备名词表省事。参数上要注意DEVICE_PATTERN里的{2,6}是设备名称的前缀长度,比如「油浸式套管」这类修饰语不能吞进设备名;如果语料里出现「主变」这种两个字的简称,需要把范围改小到{1,6},否则会漏召回。规则召回的结果会作为特征拼到序列标注模型的输入里,而不是直接当最终输出。
3.3 BiLSTM-CRF 精排:序列标注的核心管线
规则拿不定的片段交给 BiLSTM-CRF。选择这个模型的理由是:电力标注语料通常只有几千句,BERT 容易过拟合,BiLSTM-CRF 参数量小、收敛快,加上自定义词典特征后效果够用。训练数据格式是每句话一行,字和标签用空格分隔:
主 变 本 体 油 浸 式 套 管 渗 油 B-DEV I-DEV B-PART I-PART I-PART B-PART I-PART I-PART B-DEF I-DEF模型定义部分,用 PyTorch 写的话核心代码是这样,CRF 层我用 torchcrf 这个库,不用自己推维特比解码:
import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, emb_dim=128, hidden_dim=256): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_dim) self.lstm = nn.LSTM(emb_dim, hidden_dim // 2, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_dim, tag_size) self.crf = CRF(tag_size, batch_first=True) def forward(self, x, mask): emb = self.embedding(x) lstm_out, _ = self.lstm(emb) logits = self.fc(lstm_out) return logits def loss(self, x, tags, mask): logits = self.forward(x, mask) return -self.crf(logits, tags, mask=mask) def decode(self, x, mask): logits = self.forward(x, mask) return self.crf.decode(logits, mask=mask)代码里有几个点值得展开。hidden_dim // 2是因为用了双向 LSTM,两个方向的隐层拼接后正好等于hidden_dim,这是新手最容易写错的地方。CRF层的作用不是提高单点标签准确率,而是约束标签转移——比如B-DEF后面不可能接I-DEV,这种状态转移约束是普通 softmax 分类器学不到的。损失函数取负对数似然,所以是-crf(...)。训练时 batch 里每句话长度不同,mask 参数必须传,否则 padding 部分会参与 CRF 计算,导致标签序列错位,这是序列标注训练里最常见的隐性 bug。
推理阶段,decode 返回的是每个句子的最佳标签序列,拿到标签后按「B 开头,I 延续」的规则切回实体文本,再和规则层的结果合并。合并原则是:规则结果和模型结果冲突时,看谁的置信度高,规则的电压等级、日期直接信任,设备和缺陷以模型为主。
3.4 关系抽取:同句共现 + 模板约束
实体识别做完,关系抽取简单很多。工业文本里「设备-缺陷」关系绝大多数在一个句子内共现,所以用「同句共现 + 位置约束 + 触发词模板」就能拿到不错的效果,不需要上远程监督或联合抽取模型。做法是:把句子里的设备和缺陷实体找出来,如果设备出现在缺陷前面 15 个字符以内,且中间没有出现另一个设备的完整实体,就抽取一条关系。
import re TRIGGER_WORDS = ["发生", "存在", "出现", "发现", "报", "超标"] def extract_relations(sentence, entities): relations = [] devs = [e for e in entities if e["label"] == "DEV"] defs = [e for e in entities if e["label"] == "DEF"] for dev in devs: for defect in defs: if defect["start"] <= dev["start"]: continue gap = sentence[dev["end"]:defect["start"]] if len(gap) > 15: continue if any(w in gap for w in TRIGGER_WORDS): relations.append({ "source": dev["text"], "relation": "发生缺陷", "target": defect["text"] }) return relations这个函数的两个参数很关键:窗口宽度 15 个字符,以及触发词表。窗口太宽会把「主变正常,旁边断路器的间隔发热」这样的跨实体误配成关系;触发词表则保证「主变 渗油」这种纯名词堆叠不会误报,因为规范运检文本里缺陷描述基本都会带「发生、存在、超标」这类词。这套资源的语料里触发词表一共维护了 20 多个词,是从 500 条历史缺陷单里统计出来的高频词,不建议凭空拍脑袋写触发词。
4. 三元组入库与管理系统:从 py2neo 批量写入到 Vue 可视化
4.1 批量入库:约束、MERGE 与事务
抽取产出的三元组是 JSON 格式,入库这一步用 py2neo 批量写入 Neo4j。入库脚本里最关键的不是简单 CREATE,而是先建约束再 MERGE,否则重复跑脚本会把图谱撑爆。约束没建时,同一条「主变-渗油」关系每次跑都会新建一份节点和关系,图里会出现几百个一模一样的「主变」。
from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) graph.run("CREATE CONSTRAINT device_name IF NOT EXISTS FOR (d:Device) REQUIRE d.name IS UNIQUE") graph.run("CREATE CONSTRAINT defect_name IF NOT EXISTS FOR (d:Defect) REQUIRE d.name IS UNIQUE") def write_triples(triples, batch_size=200): with graph.begin() as tx: for i, t in enumerate(triples): tx.run( """ MERGE (d:Device {name: $source}) MERGE (f:Defect {name: $target}) MERGE (d)-[:HAS_DEFECT]->(f) """, source=t["source"], target=t["target"] ) if (i + 1) % batch_size == 0: tx.commit() tx = graph.begin() tx.commit()写这个脚本要注意的点:begin()开启事务后,commit 完了必须重新begin(),否则下一次循环还在已提交的事务里写,会报事务已关闭的错误。batch_size=200是实测比较稳的数值,太小事务开销大,太大单事务内存压力高,Neo4j 4.x 默认事务大小限制约 20000 条写操作。参数化查询用$source而不是字符串拼接,除了防注入,还能让 Cypher 执行计划缓存复用,批量导入速度明显快。
4.2 后端查询接口:Cypher 写在服务层,前端不碰数据库
管理系统后端这一侧,Spring Boot 负责把图谱数据以 JSON 形式吐给前端。后端服务层封装三类查询:按设备名查关联缺陷、查任意两个实体间最短路径、按缺陷类型聚合统计。前两类直接对应图谱管理的核心场景,第三类用于首页统计面板。
@Service public class GraphQueryService { private final Driver neo4jDriver; public List<Map<String, Object>> findDefectsByDevice(String deviceName) { String cypher = """ MATCH (d:Device {name: $name})-[:HAS_DEFECT]->(f:Defect) RETURN f.name AS defect, count(*) AS cnt ORDER BY cnt DESC """; try (Session session = neo4jDriver.session()) { Result result = session.run(cypher, Map.of("name", deviceName)); return result.stream().map(r -> Map.of( "defect", r.get("defect").asString(), "cnt", r.get("cnt").asLong() )).toList(); } } }这段代码把 Cypher 和 Java 的接口边界划得很清楚:前端永远只传设备名,不传 Cypher 语句;分组统计在数据库端完成,而不是把节点全捞到 Java 内存里数。count(*)配合ORDER BY cnt DESC能直接回答「这台设备历史缺陷排行」,前端拿到这份数据画柱状图即可。后端还需要配好 Spring Boot 的 CORS 配置,允许前端开发服务器的跨域访问,否则网页端请求会被浏览器拦截,这是前后端分离联调第一道坎。
4.3 前端可视化:ECharts 力导向图与节点分级
前端部分,图谱展示页用的是 Vue + ECharts 的 graph 类型。知识图谱可视化和普通图表不一样,节点多了之后布局和交互都要调参数,直接套 ECharts 默认配置会糊成一团。这套资源里对力导向图做了三处针对性调整:节点按设备类型分色、连接关系拖拽后自动回弹、悬浮显示设备台账摘要。
// views/GraphView.vue const chartOption = { series: [{ type: 'graph', layout: 'force', roam: true, draggable: true, force: { repulsion: 300, edgeLength: 80, gravity: 0.1 }, categories: [ { name: '设备', itemStyle: { color: '#409EFF' } }, { name: '缺陷', itemStyle: { color: '#F56C6C' } }, { name: '班组', itemStyle: { color: '#67C23A' } } ], label: { show: true, fontSize: 11 }, data: graphData.nodes, links: graphData.links, lineStyle: { curveness: 0.2 } }] };repulsion和edgeLength是力导向图两个最影响观感的参数。repulsion: 300表示节点间斥力半径,调太小节点叠在一起看不清标签,调太大图会散得看不到边;edgeLength: 80对应节点间期望连线长度,和斥力配合决定了整体密度。curveness: 0.2给关系线加了一点弧度,避免多条平行关系线重叠成一条黑线。如果图谱里超过 300 个节点,ECharts 力导向布局会明显掉帧,后面的避坑章节会讲替代方案。
5. 联调避坑:前后端联调中的五个高频翻车点
5.1 联调流程与接口约定
前后端联调的顺序我习惯固定为「后端接口自测 → 前端 mock 数据验证 → 真实联调」三步。第一步后端启动后先用 Postman 打一遍接口,确认 Cypher 能查到数、JSON 序列化没报错;第二步前端用假数据渲染图谱组件,把 ECharts 样式调好;最后一步才把前后端跑起来对接。直接跳到最后一步是联调翻车的最大原因,因为图谱前端渲染失败时,错误可能来自后端 500、跨域拦截、数据结构不符、ECharts 配置错误四个层面,叠在一起根本没法排查。接口约定的 JSON 结构统一成{ nodes: [{id, name, category}], links: [{source, target, relation}] },后端直接按这个结构组数据,前端拿到就能喂给 ECharts。
5.2 五个高频踩坑记录
坑一:py2neo 版本不适配,导入报错一片红。现象是from py2neo import Graph后运行就抛 AttributeError,或者 NodeMatcher 相关 API 找不到。原因是 py2neo 2021.2.3 用的是 4.x API,而 py2neo 5.x 对应 Neo4j 5.x,两者 API 改了类名和位置。解决:Neo4j 4.4 配 py2neo 2021.2.3,Neo4j 5.x 就用官方 neo4j 驱动而不是 py2neo,装依赖时把版本号写死进 requirements.txt,别用最新版。
坑二:后端返回中文乱码。现象是图谱里的「渗油」在前端显示成\u6e17\u6cb9。原因是 Spring Boot 默认 JSON 序列化对中文做 Unicode 转义,前端拿到的是转义后的字符串。解决:在后端配置文件里关掉 Unicode 转义,或者统一约定前端用 decodeURIComponent 处理。我更喜欢前者,因为转义会让接口调试时人眼没法读数据。
坑三:前端图谱节点一多,页面直接卡死。现象是展示 500 个节点时拖动图谱明显掉帧,800 个节点时浏览器假死。原因是 ECharts 力导向图布局在 CPU 上做迭代计算,节点多后计算量指数上涨。解决:先按设备类型过滤,前端只展示当前设备关联的两跳以内节点;需要看全量拓扑时切换到 Neo4j Bloom,不在业务系统里硬撑大图。
坑四:BiLSTM-CRF 训练完所有预测结果全是 O。现象是模型 loss 在降,但解码出来几乎全是非实体。原因是训练语料里 O 标签占比超过 90%,模型学会了「全预测 O 就能拿到低损失」。解决:计算每个 batch 的标签权重,把 B-DEF、I-DEF 这些稀有标签的权重调高 3 到 5 倍,或者在采样时对含实体的句子做过采样。这个坑在工业标注语料里几乎必现,因为正常设备记录里大部分句子都是无缺陷描述。
坑五:外键约束和 MERGE 冲突导致入库失败。现象是批量导入脚本在跑了几千条后抛 Unique constraint 异常,事务回滚。原因是脚本里先 CREATE 临时索引,数据里又存在同一设备名但标点全角半角不同的两条记录,MERGE 命中了同一条已存在记录却尝试再建唯一约束。解决:入库前先做实体名的统一清洗,全角转半角、去掉首尾空格、把「1号」和「1 号」归一化,再跑 MERGE。
6. 算法评估与进阶:用实体级指标验收知识抽取,再向 BERT 迭代
评估知识抽取效果不能只看准确率一个数,序列标注场景要用实体级指标,也就是预测的实体边界和类型完全一致才算对,字符级准确率虚高没有业务意义。我一般会把标注语料按 8:1:1 切分训练、验证、测试,测试集单独留出,用下面这段脚本算实体级 P/R/F1:
def evaluate_entities(gold_entities, pred_entities): correct = sum(1 for e in pred_entities if e in gold_entities) precision = correct / len(pred_entities) if pred_entities else 0 recall = correct / len(gold_entities) if gold_entities else 0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0 return {"precision": precision, "recall": recall, "f1": f1} gold = [("主变", "DEV", 0, 2), ("渗油", "DEF", 8, 10)] pred = [("主变", "DEV", 0, 2), ("渗油", "DEF", 8, 10)] print(evaluate_entities(gold, pred))评估结果出来后,一个非常实用的技巧是:把预测错误的实体按类型汇总打印出来,看模型把「套管」错标成了 DEV 还是 PART。如果大量将部位标签误标成设备,说明训练语料里部位的标注一致性有问题,而不是模型结构有问题。每隔几天就让标注人员复看一次错误集,比闷头调参有效得多。
进阶方向有三条路。语料扩充到 2 万句以上时,可以把 BiLSTM-CRF 换成 BERT-BiLSTM-CRF,用 transformers 加载中文预训练模型,只训练最后两层和 CRF 层;需要做跨文档聚合时,给设备实体加一层「台账对齐」,把「主变」「1 号主变」「#1 主变」统一链接到设备台账表的同一主键;查询复杂度上来后,可以用 APOC 的路径查找函数做「设备-缺陷-检修措施」的多跳溯源,把运行年限和缺陷类型关联起来,这是普通关系型数据库很难支撑的分析模式。我从那以后每次上线前都强制走一遍「规则召回样例检查 → 模型实体级评估 → 入库去重校验」三个环节,再小的改动也不跳过,这套流程帮我拦住了至少三次会污染图谱数据的低级错误,希望帮到你。
本文还有配套的精品资源,点击获取