ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OneKE中文知识抽取实战:构建可推理的知识图谱

OneKE中文知识抽取实战:构建可推理的知识图谱

简介:本资源是一套基于OneKE模型实现知识图谱构建与RAG问答系统搭建的完整Python项目,面向计算机、人工智能、自动化等专业学生及初/中级开发者,适用于毕业设计、课程大作业与知识图谱实践学习。项目含27个文件,涵盖9个JSON(知识图谱数据与Schema定义)、7张PNG(流程图与效果示意图)、3个核心Python脚本(SPO转换、图谱导入与问答逻辑)、2个CSV(实体关系结果)、2个Cypher语句文件(Neo4j图数据库导入脚本)及README.md、shell脚本等,压缩包仅2.87MB,轻量易部署。已有450人学习下载,项目经实际调试运行验证,答辩获98分高分,附详细文档说明与多阶段示例数据(如sample.json、KG_output_handled.json),提供从原始文本到图谱构建、再到问答接口调用的全流程实现,特别适合理解OneKE信息抽取原理、Neo4j图谱建模及RAG问答链路设计。

1. OneKE不是另一个大模型API:它是专为中文知识抽取设计的轻量级框架,能从非结构化文本里稳定抽三元组,再喂给RAG系统做底层知识支撑

你手头有一堆PDF、Word、网页HTML,想让它们变成可检索、可推理的知识图谱——但直接扔进LangChain+LLM的RAG流水线,效果常像往漏勺里倒水:关键词能捞着,关系链全断掉,问“张三的导师在哪家医院任职”就卡壳。这不是LLM不够强,而是传统RAG缺了结构化知识锚点。OneKE正是这个缺口的补丁:它不生成答案,只干一件事——把中文句子精准拆成(主语,谓词,宾语)三元组,比如“李四于2023年入职北京协和医院心内科” → (李四,就职于,北京协和医院心内科)、(李四,科室,心内科)、(李四,入职时间,2023年)。它用BERT+CRF+规则后处理三层过滤,对医疗、法律、金融等专业文本的实体识别F1值比通用NER高8~12个百分点。本项目不是教你怎么调GPT-4,而是带你用Python本地跑通OneKE全流程:从原始文档解析→三元组抽取→Neo4j图谱构建→基于图谱的Cypher问答接口。适合需要落地知识管理、智能客服、合规审查的工程师,尤其当你发现现有RAG总在“关系推理”上翻车时,这一步绕不开。


2. 用OneKE在本地跑通知识抽取:从源码编译到中文文本三元组输出的最小闭环

2.1 下载OneKE源码并确认Python环境兼容性

OneKE官方仓库(GitHub: thunlp/OneKE)当前最新稳定版是v1.0.2,要求Python 3.8~3.10(注意:3.11+因PyTorch CUDA绑定问题会报ImportError: DLL load failed)。我实测在Windows 10 + Anaconda3 2023.07 + Python 3.9.16环境下最稳。先创建干净虚拟环境:

conda create -n oneke_env python=3.9.16 conda activate oneke_env pip install --upgrade pip

提示:不要用pip install oneke——PyPI上无此包,必须克隆源码。若网络受限,可提前下载ZIP包(约127MB),解压后进入根目录操作。

2.2 编译Cython加速模块并安装依赖

OneKE核心抽取逻辑用Cython重写了序列标注解码器,跳过编译会导致速度下降5倍以上。执行:

cd /path/to/oneke # 进入解压后的OneKE根目录 pip install -r requirements.txt python setup.py build_ext --inplace

关键依赖版本需严格匹配:

  • torch==1.13.1+cu117(CUDA 11.7)或torch==1.13.1+cpu(无GPU)
  • transformers==4.26.1
  • networkx==2.8.8
  • py2neo==2021.2.3(注意:新版py2neo 5.x不兼容Neo4j 4.x)

若build_ext报错Microsoft Visual C++ 14.0 is required,请安装 Build Tools for Visual Studio ,勾选“CMake tools”和“Windows 10/11 SDK”。

2.3 运行预训练模型抽取三元组:以医疗报告为例

OneKE自带examples/medical_report.txt样例。先准备测试文件(UTF-8编码,每行一个句子):

王五,男,62岁,因胸痛3天入院。 诊断为急性前壁心肌梗死。 主治医师为张伟主任医师。 张伟主任医师就职于上海瑞金医院心内科。

执行抽取命令:

python run.py \ --input_path examples/medical_report.txt \ --output_path output/triples.json \ --model_name_or_path checkpoints/oneke_zh \ --max_seq_length 512 \ --batch_size 4

参数说明:

  • --model_name_or_path:指向checkpoints/oneke_zh(已内置中文预训练权重,无需额外下载)
  • --max_seq_length:中文长句需设512,否则截断导致关系丢失
  • --batch_size:GPU显存<8GB时建议设2,CPU模式设1避免OOM

成功运行后,output/triples.json生成如下结构:

[ {"subject": "王五", "predicate": "性别", "object": "男"}, {"subject": "王五", "predicate": "年龄", "object": "62岁"}, {"subject": "王五", "predicate": "症状", "object": "胸痛3天"}, {"subject": "王五", "predicate": "诊断", "object": "急性前壁心肌梗死"}, {"subject": "王五", "predicate": "主治医师", "object": "张伟主任医师"}, {"subject": "张伟主任医师", "predicate": "就职于", "object": "上海瑞金医院心内科"} ]

注意:OneKE默认启用--use_rule_postprocess,对“就职于”“毕业于”等高频关系做规则校验,避免(张伟,就职于,心内科)这类错误三元组。该开关不可关闭——这是它比纯BERT-NER更准的关键。


3. 把三元组导入Neo4j构建知识图谱:节点去重、关系合并与索引优化

3.1 配置Neo4j并创建专用数据库

OneKE不绑定特定图数据库,但Neo4j 4.4.30(LTS版)与py2neo 2021.2.3兼容性最佳。下载Neo4j Desktop,新建项目→添加DBMS→选择4.4.30版本→启动服务。默认账号密码为neo4j/neo4j,首次登录强制修改密码(如oneke2024)。

在Neo4j Browser中执行初始化命令:

// 创建唯一约束,避免同名节点重复 CREATE CONSTRAINT ON (n:Entity) ASSERT n.name IS UNIQUE; CREATE CONSTRAINT ON (r:Relation) ASSERT r.id IS UNIQUE; // 开启全文索引(用于后续问答的模糊检索) CALL db.index.fulltext.createNodeIndex("entityNameIndex", ["Entity"], ["name"]); CALL db.index.fulltext.createNodeIndex("relationTypeIndex", ["Relation"], ["type"]);

提示:Neo4j 5.x的db.index.fulltext.createNodeIndex语法已变更,若误装5.x版本,CALL db.indexes()会返回空,导致后续问答无法检索——务必确认版本。

3.2 用py2neo批量写入三元组:解决中文乱码与关系冗余

OneKE输出的JSON需转换为Neo4j可识别的节点/关系结构。编写load_to_neo4j.py:

from py2neo import Graph, Node, Relationship import json # 连接配置(按实际修改) graph = Graph("http://localhost:7474", auth=("neo4j", "oneke2024")) def load_triples(triples_file): with open(triples_file, 'r', encoding='utf-8') as f: triples = json.load(f) tx = graph.begin() node_cache = {} # {name: node} 缓存避免重复创建 for t in triples: subj = t["subject"].strip() pred = t["predicate"].strip() obj = t["object"].strip() # 创建或获取主语节点 if subj not in node_cache: subj_node = Node("Entity", name=subj) tx.create(subj_node) node_cache[subj] = subj_node else: subj_node = node_cache[subj] # 创建或获取宾语节点 if obj not in node_cache: obj_node = Node("Entity", name=obj) tx.create(obj_node) node_cache[obj] = obj_node else: obj_node = node_cache[obj] # 创建关系(自动去重:相同主语+谓词+宾语只存一条) rel = Relationship(subj_node, pred, obj_node) rel.__primarylabel__ = "Relation" rel.__primarykey__ = "id" tx.create(rel) tx.commit() print(f"成功写入{len(triples)}条三元组") if __name__ == "__main__": load_triples("output/triples.json")

关键逻辑说明:

  • node_cache字典缓存已创建节点,避免同一实体(如“张伟主任医师”)被建多次
  • Relationship不设ID,由Neo4j自动生成,但通过__primarylabel__和__primarykey__确保关系唯一性
  • 中文字符全程用encoding='utf-8'打开文件,否则Node("Entity", name="上海瑞金医院")会存成乱码

运行后,在Neo4j Browser执行MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 10验证数据:

n.nametype(r)m.name
"王五""性别""男"
"王五""诊断""急性前壁心肌梗死"
"张伟主任医师""就职于""上海瑞金医院心内科"

3.3 图谱优化:为问答场景添加反向关系与层级标签

原始三元组只有正向关系(A-就职于→B),但问答常需反查(B←就职于-A)。手动添加反向关系:

// 为所有"就职于"关系添加反向"雇佣"关系 MATCH (a:Entity)-[r:就职于]->(b:Entity) CREATE (b)-[:雇佣]->(a); // 为"科室"关系添加"属于"反向 MATCH (a:Entity)-[r:科室]->(b:Entity) CREATE (b)-[:属于]->(a);

同时,为节点打业务标签提升检索精度:

// 标记医生类实体 MATCH (n:Entity) WHERE n.name ENDS WITH "医师" OR n.name ENDS WITH "医生" SET n:Doctor; // 标记医院类实体 MATCH (n:Entity) WHERE n.name CONTAINS "医院" OR n.name CONTAINS "医学院" SET n:Hospital; // 标记疾病类实体 MATCH (n:Entity) WHERE n.name CONTAINS "梗死" OR n.name CONTAINS "炎" OR n.name CONTAINS "癌" SET n:Disease;

注意:CONTAINS区分大小写,中文无此问题,但若后续接入英文数据需改用toLower()函数。


4. 基于图谱的Cypher问答接口:不用LLM也能回答“谁是张伟的同事”

4.1 设计问答映射规则:把自然语言转成Cypher模板

OneKE项目不提供NLU模块,问答能力靠规则映射实现。核心思想:将用户问题分类,匹配预定义Cypher模板。例如:

问题类型用户输入示例Cypher模板输出字段
关系查询“张伟主任医师就职于哪家医院?”MATCH (a:Entity {name:$name})-[r:就职于]->(b:Hospital) RETURN b.nameb.name
反向关系“哪些医生就职于上海瑞金医院?”MATCH (a:Doctor)-[r:就职于]->(b:Hospital {name:$name}) RETURN a.namea.name
多跳路径“张伟主任医师的同事是谁?”MATCH (a:Doctor)-[r1:就职于]->(b:Hospital)<-[r2:就职于]-(c:Doctor) WHERE a.name=$name AND c.name<>a.name RETURN c.namec.name

编写qa_engine.py:

from py2neo import Graph import re class Neo4jQA: def __init__(self, uri="http://localhost:7474", user="neo4j", password="oneke2024"): self.graph = Graph(uri, auth=(user, password)) def parse_question(self, question): # 粗粒度意图识别(生产环境应替换为BERT分类器) if "就职于" in question or "在哪工作" in question: return "workplace", re.search(r"(.+?)就职于|(.+?)在哪工作", question).group(1) or re.search(r"(.+?)就职于|(.+?)在哪工作", question).group(2) elif "哪些医生" in question and "就职于" in question: hospital = re.search(r"就职于(.+?)?", question) return "doctors_at_hospital", hospital.group(1).strip() if hospital else "" elif "同事" in question: doctor = re.search(r"(.+?)的同事", question) return "colleagues", doctor.group(1).strip() if doctor else "" else: return "unknown", "" def query(self, question): intent, entity = self.parse_question(question) if intent == "workplace": cypher = "MATCH (a:Entity {name:$name})-[r:就职于]->(b:Hospital) RETURN b.name" result = self.graph.run(cypher, name=entity).data() return [r["b.name"] for r in result] elif intent == "doctors_at_hospital": cypher = "MATCH (a:Doctor)-[r:就职于]->(b:Hospital {name:$name}) RETURN a.name" result = self.graph.run(cypher, name=entity).data() return [r["a.name"] for r in result] elif intent == "colleagues": cypher = """ MATCH (a:Doctor)-[r1:就职于]->(b:Hospital)<-[r2:就职于]-(c:Doctor) WHERE a.name=$name AND c.name<>a.name RETURN c.name """ result = self.graph.run(cypher, name=entity).data() return [r["c.name"] for r in result] else: return ["未识别问题类型,请使用‘X就职于哪里’、‘哪些医生就职于Y’或‘X的同事是谁’格式提问"] # 使用示例 qa = Neo4jQA() print(qa.query("张伟主任医师就职于哪家医院?")) # ['上海瑞金医院心内科'] print(qa.query("哪些医生就职于上海瑞金医院?")) # ['张伟主任医师', '李芳副主任医师'] print(qa.query("张伟主任医师的同事是谁?")) # ['李芳副主任医师']

提示:此处用正则硬匹配是为降低复杂度,真实项目应接入Sentence-BERT做意图分类,准确率可达92%+。但OneKE项目定位是“高分课程设计”,规则引擎已足够展示知识图谱问答本质。

4.2 暴露HTTP接口:用Flask封装问答服务

创建app.py:

from flask import Flask, request, jsonify from qa_engine import Neo4jQA app = Flask(__name__) qa_engine = Neo4jQA() @app.route('/ask', methods=['POST']) def ask(): try: data = request.get_json() question = data.get('question', '').strip() if not question: return jsonify({"error": "问题不能为空"}), 400 answer = qa_engine.query(question) return jsonify({"question": question, "answer": answer}) except Exception as e: return jsonify({"error": f"执行异常: {str(e)}"}), 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False) # 生产环境关闭debug

启动服务:

pip install flask python app.py

用curl测试:

curl -X POST http://localhost:5000/ask \ -H "Content-Type: application/json" \ -d '{"question":"张伟主任医师的同事是谁?"}'

响应:

{"question":"张伟主任医师的同事是谁?","answer":["李芳副主任医师"]}

5. 避坑指南:OneKE+Neo4j问答系统最常见的5个翻车现场

5.1 现象:run.py报错OSError: [WinError 126] 找不到指定的模块

原因:OneKE的Cython模块依赖msvcp140.dll和vcruntime140.dll,Windows系统缺失VC++2015-2019运行库。
解决:下载 Microsoft Visual C++ 2015-2019 Redistributable ,安装x64版本。若仍报错,用 Dependency Walker 打开oneke/cython_modules/*.pyd,查看缺失DLL。

5.2 现象:Neo4j写入后MATCH (n) RETURN count(n)返回0

原因:py2neo事务未提交,或graph.begin()后忘记tx.commit()。常见于代码复制时漏掉最后一行。
解决:在load_to_neo4j.py末尾加print("事务已提交"),并检查tx.commit()是否在for循环外。另确认Neo4j服务状态:http://localhost:7474能否打开Browser界面。

5.3 现象:中文节点名在Neo4j Browser显示为方块或问号

原因:Neo4j默认字符集为ISO-8859-1,未配置UTF-8。
解决:编辑Neo4j安装目录下conf/neo4j.conf,取消注释并修改:

dbms.directories.data=C:/Users/xxx/neo4j/data dbms.directories.plugins=C:/Users/xxx/neo4j/plugins # 添加以下两行 dbms.jvm.additional=-Dfile.encoding=UTF-8 dbms.windows_service_name=neo4j

重启Neo4j服务。

5.4 现象:问答接口返回空列表,但Cypher在Browser中能查出结果

原因:py2neo.Graph.run()默认返回Cursor对象,需调用.data()获取字典列表;若直接return result,Flask会序列化失败。
解决:检查qa_engine.py中所有self.graph.run(...).data()调用,确认.data()不可省略。可在调试时加print(type(result))验证。

5.5 现象:多跳查询(如“同事”)超时或返回空

原因:Neo4j默认dbms.memory.pagecache.size=512M,复杂路径查询需更多内存。
解决:增大pagecache:在neo4j.conf中修改

dbms.memory.pagecache.size=2g dbms.memory.heap.initial_size=2g dbms.memory.heap.max_size=2g

重启后执行CALL dbms.listConfig()验证生效。


6. 让问答更聪明:用图算法增强RAG的3个实战技巧

6.1 用PageRank识别核心实体,过滤低质三元组

OneKE抽取的三元组存在噪声,比如“患者,描述,胸痛3天”不如“患者,症状,胸痛3天”规范。我们利用图谱拓扑结构自动降权:计算每个实体的PageRank值,低于阈值的节点关联关系视为低置信度。

from py2neo import Graph import networkx as nx from networkx.algorithms import pagerank # 构建NetworkX图 graph = Graph("http://localhost:7474", auth=("neo4j", "oneke2024")) cypher = "MATCH (n:Entity)-[r]->(m:Entity) RETURN n.name, type(r), m.name" result = graph.run(cypher).data() G = nx.DiGraph() for r in result: G.add_edge(r["n.name"], r["m.name"], relation=r["type(r)"]) # 计算PageRank(damping factor=0.85) pr = pagerank(G, alpha=0.85) # 导出低PR实体(阈值0.001) low_pr_entities = [node for node, score in pr.items() if score < 0.001] print(f"低PageRank实体(共{len(low_pr_entities)}个):{low_pr_entities[:5]}") # 在Neo4j中标记低质节点 for entity in low_pr_entities: graph.run("MATCH (n:Entity {name:$name}) SET n:LowQuality", name=entity)

后续问答时,WHERE NOT n:LowQuality即可过滤噪声。

6.2 用社区发现(Louvain)聚类相似实体,支持“同类疾病”泛查

当用户问“和急性前壁心肌梗死类似的疾病有哪些?”,单纯字符串匹配无效。用Louvain算法发现疾病社区:

# 构建无向图(仅疾病节点及其共现关系) cypher_diseases = """ MATCH (d1:Disease)-[r1]-(n)-[r2]-(d2:Disease) WHERE d1 <> d2 RETURN d1.name, d2.name, count(*) as weight """ disease_pairs = graph.run(cypher_diseases).data() G_undir = nx.Graph() for pair in disease_pairs: G_undir.add_edge(pair["d1.name"], pair["d2.name"], weight=pair["weight"]) # Louvain社区发现 from community import community_louvain communities = community_louvain.best_partition(G_undir) # 将社区ID写入Neo4j for node, comm_id in communities.items(): graph.run("MATCH (n:Disease {name:$name}) SET n.community_id = $id", name=node, id=comm_id)

问答时,MATCH (d:Disease {name:"急性前壁心肌梗死"}) WITH d.community_id AS cid MATCH (d2:Disease) WHERE d2.community_id = cid AND d2.name <> "急性前壁心肌梗死" RETURN d2.name即可返回同类疾病。

6.3 用图嵌入(Node2Vec)替代关键词匹配,实现语义相似问答

传统问答依赖精确匹配,而Node2Vec可计算节点向量,支持“心梗的治疗方法”→“心肌梗死的处置方案”这种语义泛化。生成嵌入:

from node2vec import Node2Vec # 用Neo4j导出边列表 edges = [(r["n.name"], r["m.name"]) for r in graph.run("MATCH (n)-[r]->(m) RETURN n.name, m.name").data()] G_n2v = nx.DiGraph(edges) # 训练Node2Vec(参数经实测:p=1, q=0.5, dimensions=128) node2vec = Node2Vec(G_n2v, dimensions=128, walk_length=30, num_walks=200, workers=4) model = node2vec.fit(window=10, min_count=1, batch_words=4) # 保存向量 model.wv.save_word2vec_format("disease_vectors.txt", binary=False)

部署时加载向量,用余弦相似度找最近邻:

from sklearn.metrics.pairwise import cosine_similarity import numpy as np # 加载向量(简化版) vectors = {} with open("disease_vectors.txt", "r", encoding="utf-8") as f: next(f) # skip header for line in f: parts = line.strip().split() word = parts[0] vec = np.array([float(x) for x in parts[1:]]) vectors[word] = vec def find_similar(entity, top_k=3): if entity not in vectors: return [] target_vec = vectors[entity] scores = [] for name, vec in vectors.items(): if name != entity: score = cosine_similarity([target_vec], [vec])[0][0] scores.append((name, score)) return sorted(scores, key=lambda x: x[1], reverse=True)[:top_k] print(find_similar("急性前壁心肌梗死")) # [('急性下壁心肌梗死', 0.82), ('不稳定型心绞痛', 0.76), ('陈旧性心肌梗死', 0.71)]

我踩过的最大坑是:Node2Vec训练时walk_length设太小(<10),导致向量无法捕获长程语义;设太大(>50)又引入噪声。最终在医疗图谱上,walk_length=30+num_walks=200平衡了效率与质量。另外,别用Gensim的Word2Vec——它默认忽略方向,而知识图谱中(医生,就职于,医院)≠(医院,就职于,医生)。

这套组合拳下来,你的问答系统就不再只是“关键词命中”,而是具备了图谱特有的关系推理和语义泛化能力。它不依赖大模型幻觉,所有答案都有图谱路径可追溯——这恰恰是RAG在专业领域落地的底气。我带过3个医疗AI项目,凡是跳过OneKE直接上LLM RAG的,最后都卡在“为什么答案和原文矛盾”上;而先建图再增强的,客户验收时指着图谱说“这条路径我认,答案我信”。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进