简介:项目使用Python语言,基于OneKE模型构建知识图谱并搭建问答系统,完整覆盖实体/关系抽取、知识图谱存储与检索问答流程,面向计算机、人工智能、自动化等相关专业的学生、教师和从业者,适用于课程设计、毕业设计及项目进阶学习。压缩包内共27个文件、2.87MB,包含三个Python脚本(数据处理与图谱转换)、两个Cypher脚本(Neo4j图数据库导入)、九个JSON文件(Schema定义、中间输出与测试数据)、两个CSV结果表、七张PNG流程图与效果示例,以及README说明文档、shell一键运行脚本和若干辅助配置文件,目录结构清晰,便于按模块查阅与复用。目前已有450人学习。本项目为个人高分毕设,答辩评审98分,所有代码均经过调试测试,可直接运行;随包提供的文档说明、流程图片、数据样例和导入脚本,有助于理解OneKE模型到知识图谱的完整构建链路,并能在此基础上进一步实现RAG问答系统。初学者可对照学习,有基础的读者也可修改扩展,满足不同应用需求。
1. 用 OneKE 抽三元组,别再把知识图谱当成遥不可及的工程活
接到一张知识图谱需求表时,最容易让人退缩的不是技术选型,而是从文本里抽三元组这一步。以前我习惯用正则加 BERT 序列标注,规则写了三周,换个领域就废。换成基于大模型的知识抽取框架 OneKE 后,一条生成式指令就能同时输出实体、关系和事件,Python 端调用起来非常直接。这个“Python基于OneKE模型构建知识图谱并搭建问答系统”的完整项目,就是从非结构化文本出发,用 OneKE 抽取三元组,存入 Neo4j,再挂一个智能问答系统的过程。适合课设、毕设,也适合想用最小成本验证知识图谱价值的技术团队。
2. 环境准备与 OneKE 本地推理:从 Python 安装到模型跑通
2.1 环境依赖与 Python 安装要点
开始动手前先把环境理清,否则后面每个报错都会耗掉半天。常见做法是用 Python 3.10 或 3.11,配一个独立的虚拟环境,避免把系统 Python 弄乱。在 Windows 上安装 Python 时要记得勾选“Add to PATH”,在 macOS/Linux 上建议直接用 pyenv 或 conda 管理版本。
确认 Python 可用的第一步是跑通版本号:
python --version # 确认版本号,建议 3.10 或 3.11 python -m venv oneke_env source oneke_env/bin/activate # Windows 下用 oneke_env\Scripts\activate pip install --upgrade pip pip install torch transformers accelerate bitsandbytes pip install neo4j pandas tqdm说明:pip install默认会装 CPU 版 PyTorch,如果你有 NVIDIA 显卡,建议先按 PyTorch 官网命令装对应 CUDA 的版本,再装 transformers,不然后面跑 OneKE 会慢得让人怀疑人生。VSCode 用户按 Ctrl+Shift+P 选择 Python 解释器时,要指向oneke_env/bin/python或oneke_env\Scripts\python.exe,这一步是“vscode python环境配置”最常见的卡点。
2.2 加载 OneKE 模型并封装抽取函数
OneKE 是一个生成式知识抽取模型,通常基于 LLaMA 或 Qwen 架构,因此在 transformers 里需要用AutoModelForCausalLM加载。如果机器显存不大,建议用bitsandbytes做 4bit 量化。下面是我常用的一段加载代码:
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig # 显存不够时使用 4bit 量化,能把 7B 模型的显存占用降到 5GB 左右 quant_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype="float16" ) model_path = "./models/OneKE" # 换成你实际下载后的模型路径 tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained( model_path, device_map="auto", trust_remote_code=True, quantization_config=quant_config ) model.eval() print("模型加载完成")说明:device_map="auto"可以自动把模型层分配到 GPU 和内存,8GB 显存的卡也能跑 7B 模型;trust_remote_code=True必须加,因为 OneKE 仓库里有自定义模型代码。如果你的机器内存也小,可以把load_in_4bit换成load_in_8bit,速度更快但占用稍高一点。
加载模型后,封装一个统一的抽取函数,方便后面多处调用:
def extract_knowledge(text, schema, max_new_tokens=256): prompt = { "instruction": "你是知识抽取专家,请从输入中抽取符合schema的实体和关系,并输出JSON格式三元组列表,每个三元组为 [头实体, 关系, 尾实体]。", "schema": schema, "text": text } inputs = tokenizer.apply_chat_template(prompt, return_tensors="pt").to(model.device) outputs = model.generate( inputs, max_new_tokens=max_new_tokens, do_sample=False, # 关闭采样,保证答案可复现 pad_token_id=tokenizer.eos_token_id ) answer = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True) return answer说明:apply_chat_template是模型仓库里定义好的模板方法,不同版本格式可能有差异,如果你用的版本不支持,可以自己拼 prompt 字符串。max_new_tokens控制生成长度,默认 256 对大部分文本够用;schema 越复杂,需要的输出长度越长,我通常先试 256,报错或截断就调到 512。
3. 让文本变成三元组:知识图谱构建的完整流程
3.1 数据准备与文本切分:为什么必须做滑动窗口
OneKE 这类生成式模型对输入长度有上限,常见的是 2048 或 4096 个 token。如果一篇文档有几千字,直接塞进去会被截断,导致关系残缺。我的做法是先按字符做滑动窗口切分,并且让相邻窗口保留重叠区域,防止一个实体恰好被切成两半。
def split_text(text, max_len=1500, overlap=200): """按字符长度切分,保留 overlap 防止实体被切断""" chunks = [] start = 0 while start < len(text): end = start + max_len chunks.append(text[start:end]) if end >= len(text): break start = end - overlap return chunks说明:max_len指字符数,不是 token 数。中文字符在 BPE 编码下大约 1 个字符对应 0.6~1 个 token,所以 1500 字符通常不会超限。overlap=200是经验值,太小会漏掉跨窗口的关键信息,太大会造成大量重复计算。实际使用时要根据模型的最大序列长度估算,比如模型支持 4096 token,字符数上限可以放宽到 2000。
3.2 用 OneKE 生成三元组并解析成结构化 JSON
模型输出不是纯 JSON,经常带“好的”“下面是我抽取的结果”这类前缀。直接json.loads大概率会炸。我习惯用正则先把 JSON 数组找出来,再做解析:
import json, re def parse_triples(raw): # 截取第一个 [ 到最后一个 ] 之间的内容 m = re.search(r'\[.*\]', raw, re.DOTALL) if not m: return [] json_str = m.group() try: data = json.loads(json_str) except json.JSONDecodeError: # 模型有时会把双引号写成单引号,做一次简单替换 data = json.loads(json_str.replace("'", '"')) triples = [] for item in data: if isinstance(item, list) and len(item) == 3: subj, rel, obj = item[0].strip(), item[1].strip(), item[2].strip() if subj and rel and obj: # 过滤空字符串 triples.append((subj, rel, obj)) return triples说明:replace("'", '"')只处理最浅层的错误,如果模型输出里有嵌套引号或转义符号,这个方法会失效。更可靠的方案是安装json_repair库,但会多一个依赖。在解析前还要对实体做strip(),否则“张三 ”和“张三”会变成两个节点。过滤空字符串也很重要,模型偶尔会输出空内容占位。
接下来对每个文本块调用抽取和解析,收集所有三元组:
all_triples = [] schema = ["人物", "组织", "地点", "时间", "作品"] for chunk in split_text(long_text): raw = extract_knowledge(chunk, schema, max_new_tokens=256) triples = parse_triples(raw) all_triples.extend(triples) print(f"chunk 长度 {len(chunk)},抽到 {len(triples)} 条") # 去重,并过滤实体长度小于 2 的残缺内容 unique_triples = set() for t in all_triples: if len(t[0]) >= 2 and len(t[2]) >= 2: unique_triples.add(t) print(f"共抽取到 {len(unique_triples)} 条干净三元组")说明:schema 列表决定了 OneKE 抽取时关注哪些类型。schema 越窄,抽取结果越准;schema 太宽,模型会连“的”“了”这种虚词也当成实体。长度过滤是第一个防线,后续还要配合词典或词性过滤。
3.3 存入 Neo4j:构建知识图谱的节点与关系
图谱存储我一般用 Neo4j,因为它的 Cypher 查询天然适合多跳关系,而知识图谱问答几乎都是多跳问题。写入时统一使用MERGE而不是CREATE,否则重复运行脚本会生成大量重复节点。
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password")) def write_triple(tx, subj, rel, obj): tx.run( """ MERGE (h:Entity {name: $subj}) MERGE (t:Entity {name: $obj}) MERGE (h)-[r:REL {type: $rel}]->(t) """, subj=subj, rel=rel, obj=obj ) with driver.session() as session: for s, r, o in unique_triples: session.execute_write(write_triple, s, r, o) driver.close()说明:MERGE是幂等操作,同一对实体和关系不会重复创建;关系类型REL固定,真正的语义存在属性type里。这样做的好处是不需要为每个关系动态创建 label,也方便在问答里统一用type匹配。写入前建议先启动本地 Neo4j,并在浏览器或命令行里执行一条唯一约束:
CREATE CONSTRAINT FOR (n:Entity) REQUIRE n.name IS UNIQUE;有了约束,MERGE的性能会明显提升,因为图数据库会为name建索引。如果不加约束,规模大了之后每一次MERGE都要全库扫描,几十万节点时写入会卡到无法忍受。
4. 搭一个能对话的智能问答系统:从问题到 Cypher
4.1 问句解析:规则匹配还是意图分类
问答系统有很多实现方式。实际项目中,如果只面对“X 的 Y 是谁/是什么”这类固定句式,规则匹配比训练意图分类更快、更好维护。我开始时会写一个非常简单的正则解析器,把问句拆成头实体和关系:
import re def parse_question(question): # 匹配 “张三的出生地是哪里” “北京大学的校长是谁” m = re.match(r'(.+?)的(.+?)是(什么|谁|哪里|哪一年)', question) if m: subj = m.group(1).strip() rel = m.group(2).strip() return subj, rel return None, None说明:这个正则假设用户按“实体 + 的 + 关系 + 是 + 疑问词”的句式提问。实际用户可能说“张三哪里出生的”,这时候就需要换一种匹配规则。不要指望一个正则覆盖所有问题,成熟的方案是先用少量规则,再对未命中的问题提供兜底回复。
如果问题包含“谁/什么”在主语位置,比如“谁创建了阿里”,其实就是反向查询。我习惯把这类问题也归并到同一套解析里,只不过查询方向不同。项目文档里通常会把“智能问答系统”的意图识别模块写得很复杂,但你实际落地时建议从规则起步。
4.2 执行查询并生成答案
解析出subj和rel后,需要把它们拼成 Cypher。切记使用参数化查询,不要直接用 f-string 拼接,否则实体名里一旦出现引号或特殊字符就会语法报错,甚至产生注入风险。
def answer_question(question): subj, rel = parse_question(question) if not subj or not rel: return "我没理解这个问题,换个说法试试。" with driver.session() as session: result = session.run( """ MATCH (h:Entity {name: $subj})-[r:REL {type: $rel}]->(t:Entity) RETURN t.name AS ans """, subj=subj, rel=rel ) answers = [record["ans"] for record in result] if answers: return f"{subj}的{rel}是:" + "、".join(answers) else: return f"图谱里没有找到 {subj} 的 {rel} 信息"说明:$subj和$rel是参数占位符,与 Python 驱动里的参数映射严格对应。t.name AS ans把结果取出来,如果有多个答案就合并在一起输出。这里没有处理同义词关系,比如“出生地”和“出生地点”在图谱中可能是两条不同的关系,导致查不到答案。解决方式是第 5 章会提到的关系归一化。
4.3 兜底策略与答案优化
现实问题不可能都被规则覆盖。我通常会再补一层模糊匹配:如果精确查询没有结果,就把name匹配改成contains,同时尝试反向关系:
def answer_fuzzy(question): # 反向问句:谁创建了阿里 -> 尾实体是公司,头实体是人 m = re.match(r'谁(.+?)了(.+)', question) if m: rel = m.group(1).strip() obj = m.group(2).strip() with driver.session() as session: result = session.run( """ MATCH (h:Entity)-[r:REL {type: $rel}]->(t:Entity {name: $obj}) RETURN h.name AS ans """, rel=rel, obj=obj ) answers = [record["ans"] for record in result] if answers: return f"{obj}的{rel}是:" + "、".join(answers) return "我还不会回答这个问题,请用“X的Y是什么”的句式提问。"说明:反向查询依赖关系方向,如果建图时没有统一方向,可能需要用无方向的MATCH (h)-[r]->(t),代价是性能变差。另外,模糊匹配contains会导致匹配到“北京大学”时把“北京大学人民医院”也捞出来,所以只适合做兜底。
问答系统做到这里已经能跑通“张三的出生地是哪里”这类问题。如果想让答案更自然,可以在返回前加一段生成式语言模型润色,或者直接拼接模板句子。项目文档里“智能问答系统”的高分亮点往往在兜底策略和答案覆盖率上,而不是一个孤零零的查询函数。
5. 避坑指南:OneKE 与知识图谱项目里的五个血泪教训
5.1 模型加载与推理的坑
现象一:加载模型直接报 CUDA out of memory。
原因很直接:没有做量化,7B 模型以 fp16 加载需要约 14GB 显存,消费级显卡根本扛不住。解决方法是给from_pretrained传quantization_config,用 4bit 加载,显存占用能压到 5GB 左右。如果还是超,就改用更小的 OneKE 变体,或者把device_map改成"cpu"做纯 CPU 推理,但速度会慢到没法批量跑。
现象二:模型推理慢到一条文本要跑几十秒。
原因通常是max_new_tokens设得太大,或者被强制在 CPU 上跑。我在项目里把max_new_tokens从 512 降到 128,速度直接翻倍。抽取任务真正需要的 token 数量并不多,三元组列表通常 50 个 token 就能写完。另外记得用model.eval(),并关掉梯度计算:
with torch.no_grad(): outputs = model.generate(...)这能省不少显存和计算时间。
5.2 三元组与图谱构建的坑
现象三:同一关系被表达成多种写法,“出生于”和“出生地”在图谱里成了两个关系。
原因是没有做关系归一化。OneKE 生成时受 prompt 影响,会把意思相近的关系写成不同词。解决方案是在写入前维护一张同义词映射表:
relation_map = { "出生于": "出生地", "出生地点": "出生地", "创立于": "创立时间", "成立于": "创立时间", } def normalize_rel(rel): return relation_map.get(rel, rel)然后在write_triple之前调用它。这一步虽小,但对问答准确率影响极大,否则用户问“出生地”时匹配不到“出生于”。
现象四:Neo4j 节点数量暴涨,几篇文档就出来几十万个节点。
原因有两个:实体没有做清洗,比如带空格、标点、换行符;或者用了CREATE而不是MERGE,重复运行脚本导致节点翻倍。解决思路:写入前对subj和obj做strip()、去标点、过滤纯数字和虚词;写入时用MERGE;最后建唯一约束。如果项目中已经出现大量重复节点,可以用一条 Cypher 合并:
MATCH (n:Entity) WITH n.name AS name, collect(n) AS nodes WHERE size(nodes) > 1 FOREACH (x IN tail(nodes) | DETACH DELETE x)现象五:OneKE 输出无法解析成 JSON,程序直接崩溃。
原因在于生成式模型总会带点“废话”。解决方法是先提取[...]片段,再做解析;如果还是失败,就在外面包一层try/except,跳过这条 chunk,不要因为一条坏数据中断整个项目。更稳妥的做法是加入max_retries:失败的 chunk 重新调用一次,模型第二次生成的结果可能就正常了。
6. 效果验证与调优:让抽取结果从“能看”到“能用”
项目做完之后,最容易被导师或同事挑战的一句话是“你的抽取准确率到底多少”。不要等别人问,自己先抽 50 条三元组做人工校验。我会从最终入库的结果里随机挑 50 条,打印成表格,逐条判断“头实体是否准确、关系是否合理、尾实体是否完整”,然后算一个简单精确率:正确条数除以 50。我习惯把目标定在 0.8 以上,低于这个值就说明 schema 给得太宽或 prompt 描述不精确。
调优最有效的三个旋钮:一是把 schema 从“人物、组织、地点”改成更细的“政治家、公司、城市”,OneKE 对细粒度 schema 的抽取会更准;二是把temperature降到 0.1 甚至 0,保证每次运行结果一致;三是给 prompt 里加示例,比如“关系用‘出生于’表示,不要用‘出生’”。这些都不需要改模型,只改字符串就能明显提升输出质量。
批量抽取时建议用tqdm显示进度,并且每处理 100 条 chunk 存一次临时结果,防止进程中断后全部重跑。我一般存成 JSONL:
import json, tqdm with open("triples.jsonl", "w", encoding="utf-8") as f: for chunk in tqdm.tqdm(chunks): raw = extract_knowledge(chunk, schema) for t in parse_triples(raw): f.write(json.dumps(t, ensure_ascii=False) + "\n")最后还有一个容易被忽略的点:关系归一化映射表不是一次写死的,而是随着数据增加不断补充。我第一版只写了 20 条映射,跑到第 5000 条 chunk 时又发现十几种新写法。这个迭代过程很像调参,耐心比技巧更重要。
做完整套链路后我最大的教训是:不要拿到 OneKE 输出就直接入库,一定要加实体长度过滤和关系归一化。第一次跑完图谱,里面有大量“的”“了”这种节点,问答系统一查全是一片 “None”。后来把过滤规则加严,图谱清爽了很多,问答效果也终于能拿出来演示了。希望帮到你。
本文还有配套的精品资源,点击获取