☰
基于OneKE与Neo4j构建知识图谱问答系统实战
2026/10/10 17:33:11 网站建设 项目流程

简介:这份资源面向计算机、人工智能、自动化等专业的学生与从业者,提供一套基于 OneKE 模型构建知识图谱并搭建问答系统的完整 Python 项目源码,可作为毕业设计、课程大作业或进阶练手参考。项目围绕知识图谱的 schema 设计、SPO 三元组抽取与转换、图谱导入及 RAG 问答流程展开,代码经过调试测试,可直接运行,基础较好的读者还能在此基础上修改扩展功能。压缩包共 27 个文件,约 2.87MB,以 json 数据与配置、png 流程示意图、py 脚本为主,另含 csv 结果、cypher 导入语句、sh 运行脚本及 md 说明文档,覆盖从数据处理到图谱落地的关键环节。目前已有 450 人学习下载,适合希望系统理解知识图谱构建与问答系统实现路径的读者参考借鉴。

1. 从一堆PDF到能对话的知识库:OneKE 到底解决了什么

手里有几十份设备手册、工艺规范、故障案例,老板一句“做个问答系统”,多数人第一反应是上 RAG:切块、向量化、塞进向量库、接个大模型。真跑起来才发现,问“3号泵上次大修换了哪些密封件”这种需要跨三份文档、还要把设备-部件-工单串起来的问题,纯向量检索经常召回一堆语义相近但答非所问的段落。rag瓶颈就在这——它擅长找“像”的文本,不擅长回答“关系”的问题。

OneKE 这类大模型知识抽取框架的价值,是把非结构化文本先变成结构化的三元组,存进知识图谱,再让问答走图查询而不是纯向量匹配。Python 基于 OneKE 模型构建知识图谱并搭建问答系统,本质是搭一条“抽取→入库→检索→生成”的流水线:OneKE 负责从文本里抽实体和关系,Neo4j 存图,问答层用图查询加 RAG 兜底。这套方案适合手头有领域文档、又需要处理实体关系类问题的开发者,新手能按步骤跑通最小闭环,熟手能看清抽取精度和查询性能的边界。

2. OneKE 抽取与图谱建模:先想清楚 schema 再动手

2.1 OneKE 是什么,为什么不用正则和通用 NER

OneKE 是面向知识抽取的大模型框架,核心能力是给定一段文本和一套 schema,输出符合 schema 的实体与关系三元组。和传统做法比,差异在三点。第一,传统 NER 模型要标注大量数据再训练,换一个领域就得重标;OneKE 走的是 schema 驱动,你告诉它要抽“设备、部件、故障现象、维修动作”这几类,它就能按这个结构输出,冷启动成本低。第二,正则表达式在格式固定的日志里好用,但设备手册的表述千变万化,“更换了密封圈”和“密封件已替换”要归一到同一个关系,正则维护到后面就是血泪经验。第三,通用 NER 模型识别的实体类型是人物、地点、组织这类通用类别,工业场景里的“轴承型号”“工单编号”它根本不认。

选 OneKE 的核心理由是 schema 可控。你定义什么,它就抽什么,抽取结果直接对应图谱的节点和边,省掉一层映射转换。代价是抽取质量依赖 schema 设计得是否清晰,以及文本里信息是否完整。schema 太粗,抽出来的图没区分度;schema 太细,模型容易漏抽。

2.2 定义 schema:实体类型和关系类型怎么定

schema 是整个系统的地基。常见做法是先拿五六份典型文档,人工标一遍你希望问答系统能回答的问题涉及哪些实体和关系,再归纳成类型。不要一上来就设计几十个类型,先跑通再扩展。

下面是一个设备运维场景的 schema 定义,用 Python 字典描述,后续直接喂给抽取流程:

# schema.py # 实体类型定义:key 是类型名,value 是该类型的说明和示例 ENTITY_TYPES = { "设备": { "desc": "具体的设备或机组", "examples": ["3号循环泵", "2号空压机", "1号锅炉"] }, "部件": { "desc": "设备的组成部件或易损件", "examples": ["机械密封", "轴承", "叶轮", "O型圈"] }, "故障现象": { "desc": "设备运行中出现的异常表现", "examples": ["振动超标", "温度过高", "泄漏"] }, "维修动作": { "desc": "针对故障执行的维修操作", "examples": ["更换", "紧固", "清洗", "校准"] }, "工单": { "desc": "维修工单编号", "examples": ["WO-2024-0312", "WO-2024-0455"] } } # 关系类型定义:头实体类型 -> 尾实体类型,关系名 RELATION_TYPES = [ {"head": "设备", "relation": "包含部件", "tail": "部件"}, {"head": "设备", "relation": "出现故障", "tail": "故障现象"}, {"head": "故障现象", "relation": "维修方式", "tail": "维修动作"}, {"head": "维修动作", "relation": "涉及部件", "tail": "部件"}, {"head": "工单", "relation": "对应设备", "tail": "设备"}, ]

这段定义里,实体类型控制在 5 个,关系类型 5 条,覆盖了“哪台设备出了什么故障、怎么修的、换了什么件、对应哪张工单”这条主线。参数上要注意:examples不是给模型看的训练数据,而是帮你自己校验类型边界——如果两个类型的示例看起来能互相替换,说明类型划分有问题。关系类型里head和tail必须都在实体类型里存在,否则抽取时会产出悬空边。

提示:schema 第一版不要超过 8 个实体类型和 10 条关系,跑通一轮抽取后看哪些关系大量漏抽,再针对性补充。

2.3 用 OneKE 跑通一次抽取:输入、输出与参数

抽取的输入是一段文本加 schema,输出是三元组列表。实际工程里不会把整份 PDF 直接丢进去,而是先按段落或章节切分,每段控制在 500 到 1000 字,太长了模型注意力会散,太短了关系抽不全。

# extract.py from oneke import OneKExtractor # 假设的调用方式,按实际框架 API 调整 extractor = OneKExtractor( model_name="oneke-base", # 模型规格,按显存选 schema=ENTITY_TYPES, # 实体类型 relation_schema=RELATION_TYPES, max_length=1024, # 单次输入最大 token 数 temperature=0.1 # 抽取任务要确定性,温度调低 ) text = """ 2024年3月12日,3号循环泵出现机械密封泄漏, 检修工单WO-2024-0312记录:更换机械密封,检查轴承磨损情况。 """ triples = extractor.extract(text) for t in triples: print(t) # 期望输出类似: # {"head": "3号循环泵", "head_type": "设备", "relation": "出现故障", "tail": "机械密封泄漏", "tail_type": "故障现象"} # {"head": "机械密封泄漏", "head_type": "故障现象", "relation": "维修方式", "tail": "更换", "tail_type": "维修动作"} # {"head": "更换", "head_type": "维修动作", "relation": "涉及部件", "tail": "机械密封", "tail_type": "部件"}

关键参数说明:temperature设 0.1 是为了让同一段文本多次抽取结果稳定,抽取任务不需要创造性;max_length根据你的文本块长度和显存调整,1024 是常见起点;model_name按实际可用的模型规格填,显存不够就换小规格。抽取结果里如果出现head_type不在 schema 里的情况,说明模型在自由发挥,需要在后处理里过滤掉。

2.4 抽取结果清洗:去重、归一和置信度过滤

模型抽出来的三元组不能直接入库,至少要做三件事。第一,实体归一:“3号循环泵”和“3号泵”要合并成同一个节点,常见做法是维护一个别名词典,或者用编辑距离加规则匹配。第二,去重:同一段文本里“更换机械密封”可能被抽两次,按(head, relation, tail)三元组去重。第三,置信度过滤:如果框架返回置信度分数,低于阈值的丢掉,宁可少抽也不要脏数据入库。

# clean.py def normalize_entity(name, alias_map): """实体归一:查别名词典,没有就返回原名""" return alias_map.get(name, name) def dedup_triples(triples): """按三元组去重""" seen = set() result = [] for t in triples: key = (t["head"], t["relation"], t["tail"]) if key not in seen: seen.add(key) result.append(t) return result def filter_by_confidence(triples, threshold=0.7): """置信度过滤,没有分数字段的默认保留""" return [t for t in triples if t.get("confidence", 1.0) >= threshold]

别名词典初期可以手工维护几十条高频词,后面从抽取结果里统计低频实体再补充。置信度阈值 0.7 是经验值,抽得太少就降到 0.5,抽得太脏就提到 0.8,按实际效果调。

3. 从三元组到 Neo4j:入库、索引与查询性能

3.1 Neo4j 环境准备与连接配置

图数据库选 Neo4j 是常见做法,社区版够用,Cypher 查询语言上手快。本地跑通最小环境用 Docker 最省事:

# 启动 Neo4j 社区版,映射 7474 浏览器端口和 7687 Bolt 协议端口 docker run -d \ --name neo4j-kg \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/your_password \ -v $HOME/neo4j/data:/data \ neo4j:5-community

启动后浏览器打开http://localhost:7474,用neo4j/your_password登录。Python 侧用官方驱动连接:

# db.py from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "your_password") ) def verify_connection(): with driver.session() as session: result = session.run("RETURN 1 AS ok") return result.single()["ok"] == 1

NEO4J_AUTH里的密码要换成你自己的,别用默认密码直接上生产。-v挂载数据目录是为了容器重启后数据不丢,这个后悔药一定要提前吃。

3.2 批量写入三元组:MERGE 而不是 CREATE

入库的核心是“有则复用,无则创建”,所以用MERGE而不是CREATE。CREATE每次都会新建节点,跑两遍就出现重复节点,图谱直接废掉。

# ingest.py def insert_triples(tx, triples): """批量写入三元组,按类型创建节点和关系""" for t in triples: cypher = """ MERGE (h:%s {name: $head}) MERGE (t:%s {name: $tail}) MERGE (h)-[:%s]->(t) """ % (t["head_type"], t["tail_type"], t["relation"]) tx.run(cypher, head=t["head"], tail=t["tail"]) def batch_ingest(triples, batch_size=500): with driver.session() as session: for i in range(0, len(triples), batch_size): batch = triples[i:i+batch_size] session.execute_write(insert_triples, batch)

这里用字符串拼接把类型名和关系名嵌进 Cypher,是因为 Neo4j 不支持把标签和关系类型参数化。拼接前必须确保类型名来自你自己的 schema 白名单,不能直接拼用户输入,否则有注入风险。batch_size设 500 是平衡内存和速度的经验值,数据量大就分批跑。

3.3 建索引:让图查询从秒级降到毫秒级

不建索引的图查询,节点一多就是全图扫描。至少给每个实体类型的name属性建索引:

# index.py def create_indexes(): with driver.session() as session: for label in ["设备", "部件", "故障现象", "维修动作", "工单"]: session.run(f"CREATE INDEX IF NOT EXISTS FOR (n:{label}) ON (n.name)") create_indexes()

建完索引后,按名称查节点的查询会走索引。可以用EXPLAIN前缀看查询计划,确认走的是NodeIndexSeek而不是AllNodesScan。数据量到十万节点级别,索引的有无就是秒级和毫秒级的差别。

3.4 图查询示例:从问题到 Cypher

问答系统的图查询层,本质是把自然语言问题翻译成 Cypher。常见问题类型和对应查询:

问题类型示例问题Cypher 思路
实体属性查询3号泵有哪些部件从设备节点出发,走“包含部件”关系
故障关联查询振动超标一般怎么修从故障现象出发,走“维修方式”关系
路径查询哪张工单换了机械密封从部件反向走到工单
多跳查询3号泵的故障涉及哪些部件设备→故障→维修动作→部件,三跳
# query.py def query_parts_of_equipment(equipment_name): """查某设备包含的部件""" cypher = """ MATCH (e:设备 {name: $name})-[:包含部件]->(p:部件) RETURN p.name AS part """ with driver.session() as session: result = session.run(cypher, name=equipment_name) return [r["part"] for r in result]

多跳查询要注意路径长度,超过三跳的查询在稠密图上可能爆炸,实际用的时候加LIMIT限制返回条数。

4. 问答系统搭建:图查询、RAG 与意图路由

4.1 意图识别:什么问题走图,什么问题走 RAG

不是所有问题都适合走图查询。“3号泵换了什么密封件”走图,“机械密封的安装扭矩标准是多少”这种需要大段说明文字的,走 RAG 更合适。所以问答层第一步是意图分类,常见做法是用大模型做 few-shot 分类,或者用关键词规则先兜底。

# router.py GRAPH_KEYWORDS = ["哪个", "哪些", "换了", "对应", "关联", "哪张工单"] def route_question(question): """简单规则路由:命中图查询关键词走图,否则走 RAG""" for kw in GRAPH_KEYWORDS: if kw in question: return "graph" return "rag"

规则路由的好处是可控、可解释,坏处是覆盖不全。上线初期用规则,积累一批 badcase 后再换成模型分类。别一上来就上复杂路由,先把两条链路各自跑通。

4.2 图查询链路:自然语言转 Cypher 的两种做法

第一种是模板匹配:预先定义好问题模板和对应 Cypher,抽取问题里的实体填进去。适合问题类型固定的场景,准确率高但不灵活。第二种是大模型生成 Cypher:把 schema 和几个示例查询喂给大模型,让它生成 Cypher。灵活但可能生成语法错误或危险查询。

# text2cypher.py PROMPT_TEMPLATE = """ 你是一个 Neo4j Cypher 专家。已知图中有以下节点和关系: 节点:设备(name), 部件(name), 故障现象(name), 维修动作(name), 工单(name) 关系:设备-[:包含部件]->部件, 设备-[:出现故障]->故障现象, 故障现象-[:维修方式]->维修动作, 维修动作-[:涉及部件]->部件, 工单-[:对应设备]->设备 请把下面的问题转成 Cypher 查询,只输出 Cypher,不要解释: 问题:{question} """ def generate_cypher(question, llm_client): prompt = PROMPT_TEMPLATE.format(question=question) cypher = llm_client.generate(prompt) # 安全校验:只允许 MATCH/RETURN,禁止写操作 if any(kw in cypher.upper() for kw in ["DELETE", "CREATE", "SET", "DROP"]): raise ValueError("生成的 Cypher 包含写操作,已拦截") return cypher

安全校验这步不能省。大模型生成的 Cypher 如果带DELETE,一条查询就能把图谱清空。白名单只放MATCH、RETURN、WHERE、LIMIT这些读操作关键词。

4.3 RAG 兜底链路:向量检索加图谱上下文

RAG 链路负责回答需要文字说明的问题。和纯 RAG 的区别是,检索到的文本块可以附带图谱里的相关实体信息,让生成的回答更有结构。

# rag_pipeline.py def rag_answer(question, vector_store, llm_client, driver): # 1. 向量检索拿相关文本块 docs = vector_store.similarity_search(question, k=5) context = "\n".join([d.page_content for d in docs]) # 2. 从问题里抽实体,查图谱补充上下文 entities = extract_entities_from_question(question) graph_context = "" for ent in entities: related = query_related_triples(driver, ent) graph_context += "\n".join(related) # 3. 拼 prompt 生成回答 prompt = f"参考资料:\n{context}\n\n图谱信息:\n{graph_context}\n\n问题:{question}\n回答:" return llm_client.generate(prompt)

k=5是检索返回的文本块数量,太多会超上下文长度,太少可能漏信息。图谱上下文作为补充,不是替代向量检索,两者结合能缓解纯 RAG 答不准关系类问题的情况。

4.4 把两条链路拼成完整问答接口

# qa_system.py def answer(question): route = route_question(question) if route == "graph": try: cypher = generate_cypher(question, llm_client) result = run_cypher(cypher) if result: return format_graph_result(result) except Exception as e: # 图查询失败降级到 RAG pass return rag_answer(question, vector_store, llm_client, driver)

图查询失败降级到 RAG 是必要的容错。Cypher 生成可能出错,图里可能没数据,这时候不能让用户看到报错,降级到 RAG 至少能返回一个基于文本的回答。

5. 避坑与排查:抽取和入库阶段最容易翻车的五件事

5.1 抽取结果实体类型对不上 schema

现象:模型输出的head_type是“设备类型”而不是 schema 里定义的“设备”,或者冒出 schema 里没有的类型。原因:schema 描述不够清晰,模型按自己的理解归类。解决:在 schema 的desc里写清楚类型边界,抽取后加一层类型白名单过滤,不在白名单里的类型直接丢弃或映射到最近的类型。

5.2 同一实体多个名称导致图谱碎片化

现象:图里同时存在“3号循环泵”“3号泵”“#3循环泵”三个节点,查一个查不全。原因:没有做实体归一。解决:维护别名词典,入库前统一名称;或者入库后用 Cypher 做合并,把多个节点的关系转移到主节点再删除冗余节点。别名词典要持续维护,从查询日志里发现新的别名。

5.3 批量写入时内存溢出

现象:一次性把几万条三元组传给session.execute_write,程序卡死或 OOM。原因:单次事务太大。解决:分批写入,batch_size控制在 500 到 1000,每批一个事务。数据量特别大就用LOAD CSV方式导入,比逐条 MERGE 快一个数量级。

5.4 图查询返回空结果但数据明明存在

现象:Cypher 查询返回空,但浏览器里能看到节点。原因:常见的是标签或关系名拼写不一致,比如 schema 里是“包含部件”,入库时写成了“包含零件”;或者节点名称有空格差异。解决:先用MATCH (n) RETURN DISTINCT labels(n)和MATCH ()-[r]->() RETURN DISTINCT type(r)确认实际的标签和关系名,再对照查询语句。

5.5 大模型生成的 Cypher 语法错误或性能差

现象:生成的 Cypher 报语法错误,或者能跑但特别慢。原因:模型对 Cypher 语法掌握不稳定,或者生成的查询没走索引。解决:prompt 里给两三个正确示例;生成后用EXPLAIN检查查询计划;加超时限制,超过 5 秒的查询直接中断降级到 RAG。别让一个慢查询拖垮整个问答接口。

6. 抽取质量怎么验证:一套可复用的评估脚本

系统跑起来只是开始,真正决定能不能用的是抽取质量。我一般会留一份人工标注的测试集,哪怕只有五十条文本,每轮改完 schema 或换模型都跑一遍评估。评估指标看三个:实体抽取的准确率和召回率、关系抽取的准确率和召回率、以及端到端问答的命中率。

# evaluate.py def evaluate_extraction(predicted_triples, gold_triples): """对比预测三元组和人工标注三元组""" pred_set = set((t["head"], t["relation"], t["tail"]) for t in predicted_triples) gold_set = set((t["head"], t["relation"], t["tail"]) for t in gold_triples) tp = len(pred_set & gold_set) precision = tp / len(pred_set) if pred_set else 0 recall = tp / len(gold_set) if gold_set else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0 return {"precision": precision, "recall": recall, "f1": f1}

这个脚本的关键在于gold_triples必须人工标注,不能拿模型输出当标准答案。标注五十条文本大概花两小时,但能帮你判断每次调整是变好还是变坏。precision 低说明抽了太多错的,去查 schema 是不是太宽;recall 低说明漏抽多,去查文本切分是不是把关系切断了,或者 schema 里关系定义不够明确。

端到端问答的评估更直接:准备三十个问题,每个问题有标准答案,跑一遍看答对多少。图查询链路和 RAG 链路分开统计,这样能定位是抽取的问题还是查询生成的问题。我自己的习惯是每次改完 schema 先跑抽取评估,F1 没提升就不动查询层,避免两个变量一起改导致说不清哪里出了问题。这套流程跑顺了,后面扩实体类型、换模型、加数据源都有据可依,不会越调越乱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询