简介:基于故障诊断知识图谱的问答系统项目,是面向知识图谱、人工智能、自动化等方向的在校学生与研发人员的完整毕设/课设参考,主要解决故障诊断场景下知识如何结构化、图谱如何查询以及问答如何自动匹配等问题。整套资料共76个文件、约1.57MB,除源码外还包含配套文档与数据,以Python后端代码、前端页面、CSV数据集、字体和图片资源为主,其中CSV保存实体关系数据,前端脚本与样式支撑Web交互,代码量紧凑,结构清晰,便于快速理解和运行。项目已经过完整测试,并获导师认可,答辩评审分达到95分;内容包含知识图谱建模、基于余弦相似度的问句匹配、模板页面和静态资源模块,能够帮助读者理解从数据层、算法层到展示层的系统实现路径。目前已有93人学习下载,特别适合直接用于毕业设计、课程设计,也可以在此基础上替换业务数据、调整匹配逻辑,扩展为其他领域的智能问答应用。
1. 故障诊断问答系统:不是所有答案都该搜百度
车间里最值钱的不是设备,是老师傅脑子里的那套“一听声音就知道哪坏了”的经验。这套经验一旦人走了,就断了。基于故障诊断知识图谱的问答系统,解决的正是经验结构化问题:把历史故障记录、维修手册、专家判断拆成“现象—原因—维修方案”的图结构,用户用日常话术提问,系统自动把问句映射成图查询,返回可操作的结论。
这个项目的核心难点不在 Cypher 语法,而在“用户问的是‘轴承温度偏高咋办’,图谱里存的是‘温度过高—润滑不足—更换润滑脂’,你怎么让这两句话对上”。本文拆解这个开源项目的完整链路:Neo4j 数据建模、余弦相似度问句匹配、Flask 请求响应、以及最终如何调优到能真的给维修师傅用。适合做设备运维、PHM 预测性维护、以及人工智能方向课程设计的人参考,新手能照着把流程跑通,熟手可以关注我在第五章给出的阈值策略和多轮检索思路。
2. 知识图谱建模:故障实体关系设计与 Cypher 导入
2.1 故障诊断领域的实体、关系与属性设计
知识图谱的本质是“用节点和边表示实体及其联系”。在故障诊断场景里,我们至少需要四类实体:FaultPhenomenon(故障现象)、FaultCause(故障原因)、DevicePart(设备部件)、RepairMethod(维修方案)。它们之间的关系比通用知识图谱更直白,但有明确的业务语义:
| 关系类型 | 头节点 | 尾节点 | 业务含义 | 典型例子 |
|---|---|---|---|---|
OCCURS_ON | FaultPhenomenon | DevicePart | 现象发生在哪个部件上 | 轴承温度过高 → 发生在 → 轴承 |
CAUSED_BY | FaultPhenomenon | FaultCause | 现象由什么原因导致 | 轴承温度过高 → 由…导致 → 润滑不足 |
FIXED_BY | FaultCause | RepairMethod | 该原因用什么方法修复 | 润滑不足 → 用…修复 → 更换润滑脂 |
RELATES_TO | FaultCause | FaultCause | 原因之间的关联 | 润滑不足 → 关联 → 密封失效 |
属性方面,每个实体建议至少带一个name字段作为规范化名称,另外可以加description、frequency(发生频次)、cost(维修成本)等辅助排序字段。项目里喂给图谱的数据来源一般是 Excel 或 CSV,列名直接对应属性名,后续导入时不需要做复杂映射。
2.2 用 Neo4j Cypher 建节点和关系
建图前先确认 Neo4j 版本。常见做法是用 Neo4j 5.x 的默认数据库,连接地址是bolt://localhost:7687,用户名密码默认neo4j/neo4j(首次登录会要求改)。下面这段 Cypher 可以直接在 Browser 里执行,创建一批故障知识种子数据:
CREATE CONSTRAINT fault_phenomenon_name IF NOT EXISTS FOR (p:FaultPhenomenon) REQUIRE p.name IS UNIQUE; CREATE (p1:FaultPhenomenon {name:'轴承温度过高', description:'轴承座表面温度超过70°C'}) CREATE (p2:FaultCause {name:'润滑不足', frequency:23}) CREATE (p3:DevicePart {name:'驱动端轴承'}) CREATE (p4:RepairMethod {name:'更换润滑脂', cost:150}) CREATE (p1)-[:OCCURS_ON]->(p3) CREATE (p1)-[:CAUSED_BY]->(p2) CREATE (p2)-[:FIXED_BY]->(p4);这段代码先为FaultPhenomenon的name字段建立唯一约束,避免重复导入产生冗余节点。然后创建四个节点和三条关系。frequency属性会在后续问答排序中派上用场——当多个原因都有可能时,优先返回频次高的那个。
实际项目中数据量大时不会手写 Cypher,而是用 Python 脚本批量执行。常见做法是先用 pandas 读取 CSV,再通过 py2neo 的Graph.run()逐行写入。
2.3 从结构化表格批量导入图谱
先看项目里可能存在的data目录结构,一般会包含phenomenon.csv、cause.csv、repair.csv和一张relations.csv。批量导入脚本的简化版如下:
import pandas as pd from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) df = pd.read_csv("data/fault_data.csv") for _, row in df.iterrows(): phenom = Node("FaultPhenomenon", name=row["phenomenon"], description=row["desc"]) cause = Node("FaultCause", name=row["cause"], frequency=int(row["freq"])) part = Node("DevicePart", name=row["part"]) method = Node("RepairMethod", name=row["method"], cost=float(row["cost"])) graph.merge(phenom, "FaultPhenomenon", "name") graph.merge(cause, "FaultCause", "name") graph.merge(part, "DevicePart", "name") graph.merge(method, "RepairMethod", "name") graph.merge(Relationship(phenom, "OCCURS_ON", part)) graph.merge(Relationship(phenom, "CAUSED_BY", cause)) graph.merge(Relationship(cause, "FIXED_BY", method))代码逻辑说明:graph.merge()是幂等操作,如果已存在同name的节点,不会新建而是更新属性,这比用create安全得多。Relationship()创建关系,merge关系时同样会检查是否已存在,避免重复边。
参数说明:auth元组按(用户名, 密码)传;merge的第一个节点是目标,第二个参数是节点标签,第三个参数是唯一属性。如果 CSV 里没有frequency字段,就把int(row["freq"])换成0或直接删掉这行代码,图数据库不要求所有节点属性一致。
3. 问句解析:余弦相似度与候选路径生成
3.1 为什么不用编辑距离和正则
用户问“轴承过热怎么办”,如果用正则匹配“温度过高”,永远匹配不上。编辑距离只能处理错别字,无法处理同义表达:“过热”和“温度过高”之间编辑距离很大,但语义很近。这里采用更工程化的方案:把问句分词后,和图谱里所有实体名称做 Text 相似度计算,取分数最高的实体作为“查询锚点”。
选定余弦相似度的原因有两个。第一,向量化之后的余弦相似度对文本长度不敏感,问句里附带“我想问一下”“麻烦看看”这类噪声词时干扰较小;第二,工程上用 TF-IDF 向量,不需要额外训练词向量模型,离线资源少、部署轻。缺点是 TF-IDF 对同义词识别能力弱,这个问题可以在实体名称标准化时解决,比如把“过热”统一成“温度过高”。
3.2 基于 TF-IDF 和余弦相似度的候选实体匹配
项目里的cosin.py核心就是做这件事。先看一个可运行的实现:
import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) def fetch_all_entity_names(): query = """ MATCH (n) WHERE n: FaultPhenomenon OR n: FaultCause OR n: DevicePart OR n: RepairMethod RETURN labels(n)[0] AS type, n.name AS name """ results = graph.run(query).data() return [(r["type"], r["name"]) for r in results] def match_entity(question, top_k=3): entities = fetch_all_entity_names() entity_names = [e[1] for e in entities] question_words = " ".join(jieba.cut(question)) corpus = [question_words] + entity_names vectorizer = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b") tfidf_matrix = vectorizer.fit_transform(corpus) sim_scores = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:]).flatten() candidates = [] for idx in sim_scores.argsort()[-top_k:][::-1]: candidates.append({ "entity": entity_names[idx], "type": entities[idx][0], "score": round(float(sim_scores[idx]), 4) }) return candidates if __name__ == "__main__": print(match_entity("轴承温度过高什么原因"))逻辑说明:先把问题用 jieba 分词并用空格连起来,再和图谱里所有实体名称放在一起组成语料库。随后使用TfidfVectorizer把整个语料转成 TF-IDF 矩阵,第一行是问题,后面行是实体名。cosine_similarity计算第一行与后面每一行的相似度,最后按分数从高到低取前top_k个实体作为候选。
参数说明:token_pattern要匹配中文字符,直接用默认的正则对中文不友好,这里通过 jieba 预分词后,再让 vectorizer 按词之间空格切分,所以 token_pattern 用普通单词模式即可。top_k=3表示至少保留三个候选实体,防止第一个匹配错误导致整条查询失败。
3.3 从实体匹配到查询路径生成
匹配到实体后,不能直接把实体名拼进 Cypher,因为用户问的是“是什么原因”,还是“怎么修”,意图不同,要走的路径也不同。因此项目里常见做法是在QA.py中维护一个意图词典:
INTENT_CYPHER = { "cause": """ MATCH (p:FaultPhenomenon {name: $entity})-[r:CAUSED_BY]->(c:FaultCause) RETURN c.name AS cause, c.frequency AS freq ORDER BY c.frequency DESC """, "repair": """ MATCH (p:FaultPhenomenon {name: $entity})-[:CAUSED_BY]->(c:FaultCause)-[:FIXED_BY]->(m:RepairMethod) RETURN m.name AS method, m.cost AS cost ORDER BY m.cost ASC """, "part": """ MATCH (p:FaultPhenomenon {name: $entity})-[:OCCURS_ON]->(d:DevicePart) RETURN d.name AS part """ }判定意图的方式可以简单粗暴:问句包含“原因”“为什么”“咋回事”就走cause分支;包含“怎么修”“方法”“咋办”走repair分支;包含“哪个部件”“在哪”走part分支。如果没有命中任何关键词,默认返回cause路径。
这里的关键是用参数化查询$entity,而不是字符串拼接。既防止 Cypher 注入,又避免特殊字符破坏语法。查询结果按照frequency排序,体现故障知识图谱中“按频次归因”的思想。
4. Flask 问答链路:从 HTTP 请求到 Cypher 查询再到答案渲染
4.1 后端架构与路由设计
系统采用 Flask 提供 Web 服务,项目里的templates存放 HTML 页面,static存放 CSS/JS 文件。整体请求链路是:用户在浏览器输入问题 → 前端通过 AJAX POST 到后端/ask接口 → 后端调用问句解析和意图识别模块 → 得到实体和意图后执行 Cypher → 将结果渲染为 JSON 返回前端。
简化版的后端核心代码如下:
from flask import Flask, request, jsonify, render_template from cosin import match_entity from QA import INTENT_CYPHER from py2neo import Graph import jieba app = Flask(__name__) graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) def detect_intent(question): if any(word in question for word in ["怎么修", "方法", "咋办", "修复"]): return "repair" if any(word in question for word in ["哪个部件", "在哪", "位置"]): return "part" return "cause" @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/ask", methods=["POST"]) def ask(): data = request.get_json() question = data.get("question", "") if not question or not question.strip(): return jsonify({"code": 400, "msg": "问题不能为空"}), 400 candidates = match_entity(question) if not candidates or candidates[0]["score"] < 0.5: return jsonify({"code": 404, "msg": "无法匹配到相关故障实体"}), 404 entity = candidates[0]["entity"] entity_type = candidates[0]["type"] intent = detect_intent(question) cypher = INTENT_CYPHER[intent] records = graph.run(cypher, entity=entity).data() if not records: return jsonify({"code": 200, "msg": "图谱中暂无该故障的详细知识"}), 200 return jsonify({"code": 200, "entity": entity, "intent": intent, "answer": records}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)逻辑说明:detect_intent用关键词快速判断用户意图,虽然简单但对工业场景足够稳定。match_entity里如果最高得分低于 0.5,说明用户的问题和图谱实体语义差距过大,此时不要硬查,而是直接返回提示。graph.run()第二个参数entity会自动绑定到 Cypher 中的$entity上。
参数说明:score < 0.5的阈值来自反复测试。如果设成 0.3,很多无关问题会被错误映射;设成 0.7,用户稍微换种说法就匹配不上。0.5 是个平衡点。host="0.0.0.0"允许局域网访问,方便后续推到车间 PAD 上测试。
4.2 前端怎样把答案组织成易读卡片
后端返回 JSON 后,前端不能只是把JSON.stringify结果直接怼到页面上,否则用户看到的是带大括号和引号的原生对象。更好的做法是在前端把结果分类渲染成卡片。项目里templates/index.html中常见的处理方式如下:
<div id="answer-box" style="display: none;"> <h3 id="entity-name"></h3> <div id="result-list"></div> </div> <script> async function askQuestion() { const question = document.getElementById('question').value; const resp = await fetch('/ask', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({question: question}) }); const data = await resp.json(); if (data.code !== 200) { document.getElementById('result-list').innerText = data.msg; return; } document.getElementById('entity-name').innerText = '识别实体:' + data.entity + '(意图:' + data.intent + ')'; const html = data.answer.map(item => { if (item.cause) return `<p>可能原因:${item.cause}(频次:${item.freq})</p>`; if (item.method) return `<p>维修方法:${item.method}(成本:${item.cost}元)</p>`; return `<p>相关部件:${item.part}</p>`; }).join(''); document.getElementById('result-list').innerHTML = html; document.getElementById('answer-box').style.display = 'block'; } </script>说明:展示时把识别到的实体和意图显式标出来,既让用户确认系统没有理解错,也方便后端调试。如果用户发现实体识别错了,可以换一种说法再问,这实际上是变相的用户反馈机制。
4.3 测试脚本与回归验证
项目里还有test.py,它承担自动化测试问答准确率的工作。常见做法是准备一份问答测试集,每一行是一组“问题—期望实体—期望意图—期望答案关键词”,然后循环调用问答案链路,统计命中率:
import json from QA import detect_intent from cosin import match_entity test_cases = [ {"q": "轴承温度过高什么原因", "entity": "轴承温度过高", "intent": "cause"}, {"q": "轴承过热怎么修", "entity": "轴承温度过高", "intent": "repair"}, ] match_count = 0 for case in test_cases: entity_candidates = match_entity(case["q"]) entity_match = entity_candidates and entity_candidates[0]["entity"] == case["entity"] intent_match = detect_intent(case["q"]) == case["intent"] if entity_match and intent_match: match_count += 1 print(f"PASS: {case['q']}") else: print(f"FAIL: {case['q']}, expected={case['entity']}, got={entity_candidates}") print(f"Accuracy: {match_count / len(test_cases):.2%}")这段测试不是为了追求 100% 准确率,而是为了每次修改cosin.py或意图词典时,能快速知道有没有破坏已有功能。
5. 进阶:阈值调优、多轮检索与可视化扩展
5.1 相似度阈值是动态的,不是拍脑袋定的
前面把阈值固定成 0.5,但实际部署时,不同实体名称的文本长度、图数据库里实体的拥挤程度都会影响分数。更稳妥的做法是把阈值做成可配置项,同时针对不同类型实体单独设置阈值。例如故障现象名称往往较长,与问句的相似度天然低于短实体名,这时可以放宽阈值;而维修方案名称和问句的相似度通常不高,直接通过CAUSED_BY关系二次过滤,而不是依赖实体匹配分数。
实操上,可以把测试集按实体类型分组,分别统计分数分布,用 P90 作为阈值基准。如果某个类型 P90 以下仍然有大量正确命中,就降到 P80。这个调参过程和模型调参一样,需要看具体数据。
5.2 多轮追问:先从图谱找原因,再用对话引导细化
单轮问答的局限在于用户的问题经常不完整。比如用户说“电机抖动”,系统返回了 5 个可能原因。此时用户可能想继续问“那怎么查”。在现有架构下,可以在 Flask 的 session 中记录上次命中的实体和候选原因,下一轮如果用户问题里没有新的实体名词,则默认沿用上一轮实体:
from flask import session app.secret_key = "your-secret-key" @app.route("/ask", methods=["POST"]) def ask_with_session(): question = request.get_json().get("question", "") candidates = match_entity(question) if candidates and candidates[0]["score"] >= 0.5: session["entity"] = candidates[0]["entity"] else: # 当前问题没有命中实体,尝试沿用上一轮 if "entity" in session: session_entity = session["entity"] # 重新计算低分匹配的实体,但保留上一轮的实体 intent = detect_intent(question) cypher = INTENT_CYPHER[intent] records = graph.run(cypher, entity=session_entity).data() session["entity"] = session_entity return jsonify({"code": 200, "entity": session_entity, "intent": intent, "answer": records}) else: return jsonify({"code": 404, "msg": "没找到对应故障知识"}), 404这里的关键是让多轮追问的逻辑落在「低分匹配时回退到 session」这一分支上。注意要给 Flask 设置secret_key,否则 session 无法使用。这种方案比引入 Rasa 之类对话框架轻量得多,适合故障诊断这种领域封闭的场景。
5.3 答案可视化:用 ECharts 把层级关系画出来
文本卡片只适合展示单层答案,如果用户提问“列出这个故障所有可能的原因和对应维修方法”,用表格呈现更直观。项目里可以新增一个/graph接口,返回某个故障实体周围一跳或两跳的所有节点和关系,前端用 ECharts 的力导向图渲染。Cypher 查询:
MATCH (p:FaultPhenomenon {name: $entity})-[r1]-(n) OPTIONAL MATCH (n)-[r2]-(m) WHERE n:FaultCause OR n:DevicePart OR n:RepairMethod RETURN p, r1, n, r2, m返回的数据中,节点去重后映射成{id, name, category},关系映射成{source, target, label}。ECharts 的graph系列可以直接消费这种格式。可视化的价值不只是炫技,它能让维修人员一眼看出某个原因关联到哪些维修方案,比线性文字列表更适合故障排查时的发散思维。
5.4 把知识图谱问答变成检索增强生成的底座
另一个进阶方向是把这套问答系统作为检索模块,接入大语言模型做生成式回答。当图谱返回了候选原因和维修方案后,不直接渲染,而是将这些结构化作参照文本,让本地部署的大模型重新组织语言。比如问“轴承温度过高怎么办”,图谱先查出[润滑不足, 更换润滑脂],大模型再生成一段“建议检查润滑油位,若油位正常则需要停机后更换润滑脂”的更自然的答案。这里要注意,必须限制大模型的输出不能脱离检索结果,否则模型可能胡编维修步骤,在工业场景这是安全问题。
这种架构比单独用大模型更可靠,因为知识图谱保证了事实来源,大模型负责润色。项目已有的match_entity和INTENT_CYPHER可以直接复用,不需要改图谱结构。
5.5 快速部署到局域网的验证方法
在车间环境通常没有公网 IP,直接python QA.py启动 Flask,注意关闭 debug 模式,避免代码泄露。用生产级服务方式部署时,可以将 Flask 应用挂到 gunicorn 后面:
pip install gunicorn gunicorn -w 4 -b 0.0.0.0:5000 QA:app启动后,在同一局域网内的终端浏览器访问http://<服务器IP>:5000即可测试。验证时重点关注三件事:第一,连续发 10 个故障现象问句,看实体识别准确率是否稳定;第二,故意输入一个图谱里不存在的故障描述,看系统是否正确地返回“无法匹配”而不是报错;第三,同时开 10 个浏览器窗口并发提问,观察是否有查询超时,如果超时就在 Neo4j 配置里增大dbms.connector.bolt.max_connections。
本文还有配套的精品资源,点击获取