简介:面向计算机、人工智能、自动化等专业学生及从业者,这是一套基于OneKE模型构建知识图谱并搭建问答系统的完整毕设项目,可作为课程设计或毕业设计参考。压缩包共27个文件,约2.87MB,涵盖Python脚本(SPO三元组抽取与图谱转换)、JSON数据描述(图谱schema与处理语料)、CSV结果导出、Cypher导入语句、Shell部署脚本、执行流程截图及README文档等,便于理解从实体关系抽取到图谱落库、问答检索的完整链路。项目源于答辩评审98分的个人毕设,代码经过调试可运行,已有450人学习下载。资源提供OneKE模型处理文本生成SPO三元组的实现、知识图谱模式定义、Neo4j批量导入方案及问答系统关键代码,并附文档说明,具备较高借鉴价值,可在此基础上修改扩展以实现不同功能。
1. OneKE 模型选型:为什么知识图谱项目里要单独用抽取框架
很多 Python 知识图谱项目卡在第一步:语料有了、Neo4j 装好了,结果抽出来的实体七零八落,关系对不上号。传统 NER 管线要分别训练命名实体识别、关系分类、事件抽取三套模型,每一套都要标注数据、调参、维护。OneKE 走的是另一条路:用生成式模型把「schema 提示 + 输入文本」直接翻译成结构化抽取结果,一个框架同时覆盖实体、关系、事件三类任务,中文场景下零样本表现也可用。对本项目而言,选 OneKE 的意义不是「用了个新模型」,而是省掉一整套标注与训练流程,让知识图谱和问答系统的落地路径短了很多。适合想用中文语料快速构建领域知识图谱,又不想在抽取环节陷进去的开发者。
2. OneKE 环境搭建与 schema 驱动的抽取调用
2.1 为什么 OneKE 选 seq2seq 而不是序列标注
序列标注方案把抽取任务建模成 token 级分类,每个 token 打 BIESO 标签。这个范式对嵌套实体和重叠关系非常吃力:一个实体同时是另一个实体的组成部分时,标签体系要么爆炸,要么需要多层 CRF 叠加。OneKE 把抽取任务整体建模为序列到序列生成,输入是「任务描述 + schema + 文本」,输出是结构化的抽取文本片段。
生成式方案在推理时天然支持「先规划再抽取」:模型先在输出里声明要抽的实体类型,再逐一填充值,这让零样本迁移成为可能。同一个 checkpoint 可以今天抽人物关系,明天抽产品缺陷,只改输入里的 schema 即可。
常见的做法是参考官方发布的 checkpoint 和生成格式。我会在项目里固定一套输出约定,避免每次 parse 都跟着模型输出格式来回改。以下示例中模型名按你实际下载的 checkpooint 路径替换,代码以 HuggingFace transformers 接口为基准。
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM import torch model_path = "./models/oneke" # 替换为实际模型目录 tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSeq2SeqLM.from_pretrained(model_path) if torch.cuda.is_available(): model = model.half().cuda() def oneke_extract(sentence: str, schema: str, max_new_tokens: int = 512) -> str: prompt = f"任务:抽取文本中的实体、关系与事件。\nSchema:{schema}\n文本:{sentence}" inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=1024) if torch.cuda.is_available(): inputs = {k: v.cuda() for k, v in inputs.items()} with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=max_new_tokens, num_beams=4, repetition_penalty=2.0, ) return tokenizer.decode(outputs[0], skip_special_tokens=True)代码把「任务描述 + Schema + 文本」拼成 prompt 输入,让模型在同一套参数下完成三类抽取。max_new_tokens控制输出长度,实体多的长文本要调大到 768 否则结果会被截断。repetition_penalty是生成式抽取的关键参数,不设置时模型容易反复输出同一个实体名。
2.2 设计 schema:实体、关系与事件的定义方式
OneKE 的抽取边界完全由 schema 决定。schema 写得太粗,模型会把无关词也当成实体;写得太细,输出会被无效信息淹没。我一般在 schema 里直接抄领域词表的高频类别,再补一个「其他」兜底。
一个可用的 schema 设计长这样:
实体类型:人物、电影、导演、上映年份 关系类型: - 主演:人物 -> 电影 - 导演:导演 -> 电影 - 上映于:电影 -> 上映年份 事件类型:电影上映传入模型时按上面的格式拼成字符串。实际运行会发现,OneKE 对实体类型的名称很敏感:「导演」和「电影导演」抽出来的结果可能不同,项目里要固定一套命名并写进文档。
2.3 批量抽取时的批处理参数与内存控制
单条调用跑通后,下一步就是批量处理语料。逐条推理速度太慢,应该按 batch 组织输入,同时留意长度差异带来的显存浪费。
def batch_extract(sentences, schema, batch_size=8): prompts = [ f"任务:抽取文本中的实体、关系与事件。\nSchema:{schema}\n文本:{s}" for s in sentences ] results = [] for i in range(0, len(prompts), batch_size): batch = prompts[i:i+batch_size] inputs = tokenizer(batch, return_tensors="pt", padding=True, truncation=True, max_length=1024) if torch.cuda.is_available(): inputs = {k: v.cuda() for k, v in inputs.items()} with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=512, num_beams=4, repetition_penalty=2.0, ) results.extend(tokenizer.batch_decode(outputs, skip_special_tokens=True)) return resultspadding=True会把 batch 内句子补到等长,是常用的做法。显存紧张时优先调小batch_size而不是关掉 beam search,beam 从 4 降到 2 对抽取质量的影响比想象中大。如果语料里长句很多,max_length=1024会造成大量截断,此时记录一下被截断的样本 ID,单独用更长窗口重跑。
批处理后把原始句子和抽取结果一起落盘,用 JSON 逐行存储,后面解析和图入库都依赖这份中间产物。
3. 用 OneKE 输出构建 Neo4j 知识图谱:实体、关系与 Cypher 写入
3.1 从生成结果解析三元组的容错策略
OneKE 生成的是自由文本,不是 JSON。解析层要处理换行、全半角括号、实体名包含逗号等边界情况。我建议在项目里固定一套「实体声明 + 关系声明」的中间格式,解析器只认这套格式,模型输出先做规整再解析。
import re import json def normalize_output(raw: str) -> str: raw = raw.replace(",", ",").replace(":", ":").replace("(", "(").replace(")", ")") raw = re.sub(r"\n{2,}", "\n", raw) return raw.strip() def parse_oneke_output(raw: str): raw = normalize_output(raw) entities = [] relations = [] for line in raw.splitlines(): line = line.strip() if not line: continue m = re.match(r"\[实体\]\s*类型[::]\s*(.+?)\s*实体[::]\s*(.+)", line) if m: entities.append({"type": m.group(1).strip(), "name": m.group(2).strip()}) continue m = re.match(r"\[关系\]\s*类型[::]\s*(.+?)\s*头实体[::]\s*(.+?)\s*尾实体[::]\s*(.+)", line) if m: relations.append({ "type": m.group(1).strip(), "head": m.group(2).strip(), "tail": m.group(3).strip(), }) return {"entities": entities, "relations": relations}正则要求输出严格按约定格式,所以规范化的字符替换不能省。解析后立刻做一轮空值过滤:没有实体名、没有关系类型的三元组直接丢弃。抽样看 100 条解析结果,如果错误率高于 5%,优先检查 schema 命名是否和语料里的说法一致。
3.2 图模型设计:节点标签、关系类型与属性去重
图模型设计决定了 Cypher 查询的写法。实体类型映射为 Neo4j 的 Label,关系类型映射为 Relationship Type,原始文本作为实体节点的属性保留,方便溯源。同名字段以外的属性,例如人物简介、电影票房,先做成属性键值对,等后续数据变多了再评估是否拆节点。
| 实体类型 | 节点 Label | 必备属性 | 可选属性 |
|---|---|---|---|
| 人物 | Person | name, source_text | alias |
| 电影 | Movie | title, source_text | release_year |
| 导演 | Director | name, source_text | alias |
关系类型在 Neo4j 里直接用字符串表示,例如主演、导演、上映于。一个常见误用是给关系加太多属性,导致查询时属性名不统一。关系属性初期只保留source_text即可。
实体对齐放在入库前做。同一部电影在文本里可能是「流浪地球」和「《流浪地球》」,这一步不做,图里会出现两个孤立节点,问答系统的召回会被直接拉低。常见的做法是维护一份别名表,入库前统一做归一化替换。
3.3 用 UNWIND 批量写入 Neo4j
逐条创建节点和关系慢且容易触发事务超时,应该用 UNWIND 批量提交。以下代码用 neo4j 官方驱动实现批量写入。
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def write_triples(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(_upsert_batch, batch) def _upsert_batch(tx, batch): query = """ UNWIND $batch AS row MERGE (h:Entity {name: row.head}) ON CREATE SET h.source = row.source MERGE (t:Entity {name: row.tail}) ON CREATE SET t.source = row.source MERGE (h)-[r:REL {type: row.rel_type}]->(t) ON CREATE SET r.source = row.source """ tx.run(query, batch=batch)MERGE保证幂等写入,重复跑脚本不会产生重复节点。关系类型用属性type存储而不是直接写成[:主演],是为了代码里无需动态拼接关系类型,直接复用同一条 Cypher。批量大小batch_size=500在大多数配置上表现稳定,数据量大时逐步加大,观察 Neo4j 的dbms.transaction.timeout日志。
如果关系类型必须作为原生 Relationship Type 存储,Cypher 要用apoc.merge.relationship这类过程动态建关系,这依赖 APOC 插件,不是默认功能。
3.4 实体对齐与归一化
实体对齐是知识图谱构建环节里决定最终质量的一步。OneKE 抽出的实体名是纯字符串,同一个对象在语料里可能有别名、简称、中英文混写。项目里我按优先顺序做三层归一化:去除书名号、引号等装饰符;统一大小写与全半角;别名表替换。
import unicodedata alias_map = { "流浪地球": ["流浪地球2", "The Wandering Earth"], "张三丰": ["张君宝"], } def align_entity(name: str) -> str: name = unicodedata.normalize("NFKC", name).strip() name = name.replace("《", "").replace("》", "").replace("「", "").replace("」", "") name = name.lower() for standard, aliases in alias_map.items(): if name in aliases or name == standard: return standard return name别名表是人工维护的静态映射,领域固定时效果很好,换来的是零误替换。不要直接用词向量相似度做自动对齐,同义词替换的误伤率在中文语料里很高,对后续问答系统的答案置信度影响很大。
入库前对对齐后的三元组再跑一次去重统计,将重复实体的数量作为数据质量指标写进项目报告。
4. 问答系统两条链路:图谱查询与本地语义检索
4.1 整个问答系统的链路设计
知识图谱搭好后,问答系统要解决两类问题:一类是「周星驰主演了哪些电影」,答案必须来自图谱中的事实;另一类是「这部电影讲了什么故事」,这类问题图谱答不了,要从原始文档语义检索。生产级知识库问答系统通常两条链路并行:意图路由模块先判断问题类型,再决定走图查询还是文档召回。
意图路由不必上模型,用规则加实体识别足够。规则包含疑问词和动词的匹配表;实体识别复用 OneKE 抽取的实体名,在问题里做最长匹配。命中已知实体且满足图查询模式时走链路 A,否则走链路 B,链路 B 兜底所有问题。
4.2 图谱问答:模板化 Cypher 生成
图查询不直接让模型生成 Cypher,而是维护一组 Cypher 模板,根据意图和实体类型填充。这样避免大模型生成错误语法,也方便控制查询权限与超时。
CYPHER_TEMPLATES = { "主演作品": "MATCH (p:Person {name: $entity})-[:主演]->(m:Movie) RETURN m.title AS title", "导演作品": "MATCH (p:Person {name: $entity})-[:导演]->(m:Movie) RETURN m.title AS title", "上映年份": "MATCH (m:Movie {title: $entity})-[:上映于]->(y) RETURN y.name AS year", } def answer_graph(question: str, entity: str, intent: str) -> list: template = CYPHER_TEMPLATES.get(intent) if not template: return [] with driver.session() as session: result = session.run(template, entity=entity) return [record["title"] for record in result]模板的键是意图名,意图识别规则需要维护一份「问题模式 -> 意图」的映射,包含同义表达,例如「演过」「参演了」都映射到主演作品。模板匹配不到时返回空列表,接下来由语义检索链路兜底,不要让用户看到空白回答。
图谱链路的核心调优点在意图识别规则上。规则越多越精确,但维护成本走高,建议初期每个意图只覆盖三种以内的问法,把精力放在图谱质量和召回上,而不是穷举问法。
4.3 非结构化检索:本地向量召回与 BM25 融合
图谱查不到的问题,用原始语料召回答案片段。本地环境不依赖外部 API,用 sentence-transformers 做向量编码,配合 rank_bm25 做关键词召回,两种结果用 RRF(Reciprocal Rank Fusion)融合。
from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer import numpy as np bm25 = BM25Okapi([doc.split() for doc in corpus]) encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") doc_vecs = encoder.encode(corpus, normalize_embeddings=True) def hybrid_search(question: str, top_k: int = 10) -> list: q_vec = encoder.encode([question], normalize_embeddings=True)[0] vec_scores = doc_vecs @ q_vec bm25_scores = bm25.get_scores(question.split()) vec_rank = {i: r for r, i in enumerate(np.argsort(-vec_scores)[:top_k])} bm25_rank = {i: r for r, i in enumerate(np.argsort(-bm25_scores)[:top_k])} fused = {} for i in set(vec_rank) | set(bm25_rank): score = 0.0 if i in vec_rank: score += 1.0 / (60 + vec_rank[i]) if i in bm25_rank: score += 1.0 / (60 + bm25_rank[i]) fused[i] = score return sorted(fused.items(), key=lambda x: -x[1])[:top_k]融合排序把向量召回和关键词召回各自的头部结果顶到前面,对短问句和专有名词都有较好的容错。paraphrase-multilingual-MiniLM-L12-v2只有几百 MB,CPU 上也能跑,适合作为项目基线。语料量大时把文档向量用 faiss 建索引,直接用 numpy 点积只适合几千篇级别的 demo,这个边界要在文档里写清楚。
4.4 答案合成与可解释性
答案合成这步最容易做成表面功夫,但给分点往往也藏在这里。用户的问题在图谱里命中了五部电影,不能丢一个数组回去,而是要拼成自然语言答案,并附上来源片段。
对图查询结果,合成模板是「根据知识图谱,{entity} {intent_desc} 包括:{list}」。对语义检索结果,把命中文档的原文截取出来作为证据,在回答后标注「来源:{文档标题}」。这个设计让问答系统每一步都能回溯,用户看到的是答案,评审看到的是工程闭环。
5. 项目给分点:验证方法、评估脚本与文档组织
5.1 评测指标怎么设
一个「源码+文档说明」的高分项目,光能跑通不够,要证明系统的每一个环节都有可量化的表现。建议设三个层级:抽取层、图构建层、问答层。
抽取层用人工标注的 200 条语料做评测,计算实体 F1 与关系 F1。判断实体对齐是否准确;关系层只统计头尾实体均命中的关系对。图构建层统计节点数、关系数、孤立节点占比、别名归一化前后的实体去重率。问答层准备 50 条测试问题,逐条标注预期答案,评测指标用 Recall@k 和 MRR,不追求每一条都答对,但答对的必须能给出证据。
def evaluate_qa(test_set: list[dict], answer_func, k: int = 5) -> dict: recall_at_k, mrr = 0.0, 0.0 for item in test_set: preds = answer_func(item["question"])[:k] hit = set(item["expected"]) & set(preds) if hit: recall_at_k += 1 mrr += 1.0 / (preds.index(list(hit)[0]) + 1) return { "recall@k": recall_at_k / len(test_set), "mrr": mrr / len(test_set), }评测脚本要和源码放一起,README 里写清运行方式,这是「高分项目」和普通作业的分水岭。用固定随机种子跑结果,把评测输出落盘为 JSON 快照,方便和后续改动做对比。
5.2 错误归因:不要只报准确率
评测之后要做错误归类,不加这条,评测报告只是数字装饰。以下是知识图谱问答项目里最常见的四类错误,我把应对方案列成表。
| 错误类型 | 典型表现 | 修复方向 |
|---|---|---|
| 实体未召回 | 问题中的实体不在图里 | 扩充 OneKE schema、加强实体对齐 |
| 关系类型错误 | 头尾实体正确但关系不对 | 检查 schema 里关系定义、增加标注样本 |
| 意图路由误判 | 问题该走图查询却走了文档检索 | 补充问题模式、增加否定词过滤 |
| 答案片段截断 | 检索命中但返回内容不完整 | 调整文档切分粒度、增大答案窗口 |
错误归因不需要太多自动化,人工看 20 条错误回答就能定位到主要瓶颈。这个表写进项目文档,评审一眼能看出你对系统边界有清晰认知。
5.3 文档组织:把「能跑」变成「可复现」
最后是文档说明的组织方式。一个可复现的项目,目录结构和 README 要能回答三个问题:怎么装、怎么跑、跑完看什么。按下面组织源码目录,评审和协作者都能在十分钟内跑通:
├── README.md ├── requirements.txt ├── configs/schema.yaml ├── scripts/ │ ├── 01_extract.py │ ├── 02_parse_triples.py │ ├── 03_write_neo4j.py │ └── 04_run_qa.py ├── eval/ │ ├── test_questions.json │ └── evaluate.py └── docs/ ├── schema_design.md └── error_analysis.md运行脚本按编号顺序串联,configs 里放 schema 定义,eval 里放测试集和评测脚本,docs 里记录设计决策。若把 schema 直接写死在代码里,改一个实体类型要翻三个文件,这是最常见的坏味道。文档里最该写清楚的不是 API 注释,而是 schema 设计的取舍:为什么选这些实体类型、关系命名怎么统一、别名表维护流程是什么。这些决策记录下来,比大段函数注释有用得多。
本文还有配套的精品资源,点击获取