☰
Python文本关系抽取实战:基于HanLP从实体到三元组
2026/10/7 18:21:20 网站建设 项目流程

简介:这是一套面向自然语言处理研究者和信息抽取开发者的 Python 文本关系抽取工具源码,借助 HanLP 完成命名实体识别、语义角色标注与依存句法分析,可输出事件三元组、主谓宾三元组、关键词、高频词、实体词、实体共现词以及实体与关键词关联词等多维关系结果。压缩包共 66 个文件,约 1.76MB;35 个 py 脚本覆盖 UIE 模型预测、关系抽取、句子解析、Doccano 标注、图展示与微调训练,16 个 md 文档梳理 README、SRL、NER 数据格式与模型说明,另有 4 个 txt 示例、配置文件、可视化页面、论文 PDF 等,配合 setup.py 与 requirements.txt 可快速搭建环境。已有 432 人学习使用。relext-main 目录结构清晰,examples 下演示脚本可直接运行,能复现从原始文本到三元组的完整流程;还可结合 doccano 标注与 finetune 代码,把抽取能力迁移到自有业务数据,并在 evaluate.py 的辅助下评估关系抽取效果。

1. 文本关系抽取工具到底帮你省了什么事:从命名实体到三元组的一次性落地

做知识图谱、做舆情结构化、做合同条款拆解的人,迟早都会撞上同一个需求:给一段中文文本,程序要自己读出“谁,做了什么,对谁做”这类事实,也就是输出(主语,关系,宾语)三元组。这套用 Python 实现的文本关系抽取工具,正是把这条链路完整串起来的参考实现——它先用 HanLP 做实体识别,把文本里的“人名、地名、机构名”捞出来,再通过候选实体配对和关系分类,把实体之间的联系抽成三元组结果。对新手来说,它最大的价值不是模型有多花哨,而是让你少走一遍从零搭管线的弯路;对熟手来说,它的价值在于暴露了那批最容易被忽略的工程坑:实体配对怎么剪枝、否定句怎么过滤、HanLP 版本接口不兼容时去哪儿排查。下面我把这套方案的架构、实现步骤和踩坑记录完整拆开。

2. 三层管线拆解:实体识别、候选配对、关系分类是怎么协作的

2.1 一次抽取请求的完整旅程:从原始文本到三元组

先明确一个事实:HanLP 本身只干活到“实体识别”这一步,它是一个 NLP 预训练工具包,提供分词、词性标注、命名实体识别(NER)、依存句法分析等能力。而标题里说的“文本关系抽取”,是在实体识别之上额外搭建的一层逻辑。把这条流水线展开,大概是这样一个顺序:

  1. 输入一篇原始文本,先做分句,保证每一句是关系抽取的基本单位。
  2. 对单句做分词和词性标注,交给 HanLP 的 NER 模块,拿到句子里的命名实体列表,每条实体带实体类型,比如“张三 / 人名”“华为 / 机构名”“深圳 / 地名”。
  3. 把句子里的实体两两配对,形成候选实体对,同时剪掉明显不合理的配对,比如“深圳 / 地名”和“北京 / 地名”这种同类实体对。
  4. 对每一对候选实体,借助触发词规则或关系分类模型,判定这对实体之间是否存在业务关系,以及具体是哪一种关系。
  5. 把通过判定的实体对和关系打包成三元组,输出成 JSON 或 CSV。

这套流程里,最容易让新手误判的环节是第 3 步。很多人以为实体识别完,直接把句子丢给关系分类器就行,但分类器是逐个实体对判别的,候选实体对不剪枝,分类请求量会爆炸——一句话里出现 5 个实体,全配对就是 10 对,放到一段 20 个实体的长文本里就是 190 对。所以工程上必须在进入关系分类之前,先把可靠性低的配对过滤掉。

HanLP 在这个方案里的角色是“实体识别的底座”。为什么选中它而不是自己用规则写实体识别?因为规则写出来的实体识别器只对封闭域有效——你见过“深圳市”这个写法,不等于你见过“深圳经济特区”这种变体;而 HanLP 这类预训练模型基于大规模标注语料训练,对开放文本里常见的人名、地名、机构名有不错的召回率。代价是模型文件需要加载到内存,首次调用有耗时,这会在后面的性能章节展开说。

2.2 关系类型不是拍脑袋:先定 Schema 再写代码

关系抽取最容易被低估的前置工作,是定义关系类型体系。三元组的“关系”到底有哪几种,必须在下游消费方那里先对齐——是给人看的业务报表,还是要灌进图数据库?不同的目标决定了关系集合的粗细粒度。一个常见做法是先收集领域内的语料,人工看 200 条到 500 条句子,把高频出现的谓词和介词短语聚成类,再做归一化。比如“位于”“坐落于”“地处”归一成一种关系“地理位于”;“收购了”“买下”“并购”归一成“收购”。

以企业公告和新闻语料为例,一套最小可用的关系类型可以设计成这样:

关系名触发词示例主语实体类型宾语实体类型
位于位于、坐落在、地处机构/地名地名
收购收购、并购、买下机构机构
发布发布、宣布推出、上线机构产品/作品
任职担任、出任、就任人名机构
毕业于毕业于、硕士毕业于人名机构

这张表的作用有两个:一是规定了候选实体配对的裁剪方向,二是给触发词规则引擎提供了配置文件。你会发现,如果主语和宾语都限定实体类型,很多无意义的候选对在上一阶段就会被过滤掉,既省了计算,也提了准确率。站在实现者的角度,我一般会把这张表做成独立的 JSON 配置,而不是写死在代码里,因为业务需求调整关系类型时,改配置比改代码安全得多。

2.3 规则引擎还是分类模型:中小项目怎么选

承接上面那一步,关系分类本身有两条路线。第一条是触发词加模式匹配的规则路线:在候选实体对之间的窗口文本里,寻找关系表中的触发词,命中就判定为该关系。优点是冷启动快,不需要标注数据,结果可解释,出错了能直接看着规则修;缺点是无法处理触发词没出现、但语义上确实是该关系的句子,比如“华为正式成为比亚迪的供应商”,这句里“供应商”不是动词,触发词表很难覆盖全。第二条是训练一个文本分类模型,把候选实体对和它们所在的句子拼成一个序列,输入给模型做多分类,类别就是关系表里的那些关系。优点是泛化能力强,能处理同义改写;缺点是需要人工标注数据,而且标注标准本身要非常稳定,否则模型学到的只是标注者的不一致。

对标题描述的这种“工具源代码”项目,我的判断是:核心骨架大概率是规则路线,因为它的输出是确定性的、可审计的,而且不需要在项目附带的说明文档里解释“标注规模 5000 条”这种重资产细节。规则路线更适合作为第一版,跑通之后如果准确率不够,再把分类模型挂在规则的后面作为兜底或候选重排序。切换点在于:当你的触发词表已经膨胀到 200 个以上,而且新增触发词带来的准确率收益趋近于零时,说明规则的短板到头了,该上模型了。不过在那之前,先用规则把地基打扎实,是性价比最高的选择。

3. 用 HanLP 搭出实体识别与关系抽取的核心代码

3.1 用 HanLP 跑通实体识别:最小可运行的 NER 代码

先准备好 Python 运行环境,这一步涉及 Python 的安装和 HanLP 依赖引入。主流的做法是创建独立虚拟环境,避免把依赖装进系统级 Python,然后在环境里用 pip 安装 HanLP 的官方 Python 包。需要说明的是,HanLP 存在两个世代:1.x 时代用 pyhanlp 这个包封装 Java 类库,2.x 时代提供了原生 Python 包。如果你拿到的源码里 import 的是 hanlp,那它就依赖 2.x;如果 import 的是 pyhanlp,则是 1.x。下面的代码按 2.x 写法演示,老版本源码的适配方式会在避坑章节单独讲。

import hanlp # 加载 HanLP 官方预训练命名实体识别模型 # 首次运行会下载模型权重到本地缓存目录,建议提前在网络通畅的环境执行一次 ner_model = hanlp.load(hanlp.pretrained.ner.MSRA_NER_BERT_CHAR) text = "华为技术有限公司在深圳发布了最新的Mate 60手机,余承东出席了发布会。" # 直接预测命名实体 entities = ner_model.predict([text]) print(entities)

这段代码的意图分三层。先看模型加载:hanlp.pretrained.ner.MSRA_NER_BERT_CHAR是 HanLP 内置的预训练模型入口,基于中文 MSRA 语料训练,对机构名、地名、人名这类通用实体类型有较好的识别质量。再看预测方式,HanLP 的 NER 接口接的是句子列表,所以即使只预测一句话也要传列表,返回结果是一个列表嵌套的结构,每个命名实体是一个元组,依次是实体文本、实体类型、在句子里的起止位置。最后是输出结构,你可以把它理解成“实体被捞出来,带着边界和类型标签”,这是后续关系抽取的原料。

这里有一个必须强调的参数细节:模型加载是一次性动作,它会把权重文件和分词器的词表读进内存,第一次调用会比较慢。实际工程里应该把模型加载放到模块初始化阶段,只加载一次,而不是每次请求都加载,后面批量处理章节会看到这个动作对性能的影响有多大。

3.2 把实体列表拼成候选实体对:剪枝与类型过滤

拿到 NER 结果后,先做实体配对。下面是候选实体对的生成逻辑,同时做了三件事:遍历配对、按类型过滤、按句内位置约束过滤。

def build_candidate_pairs(entities, relation_schema): """ entities: NER 返回的实体列表,格式为 [(text, type, begin, end), ...] relation_schema: 关系表,格式为 [{"relation": "收购", "subject_type": "机构", "object_type": "机构"}, ...] 返回候选实体对列表,每个元素是一对实体及其允许的关系类型集合 """ candidates = [] for i in range(len(entities)): for j in range(len(entities)): if i == j: continue subj = entities[i] obj = entities[j] if subj[1] == obj[1]: # 同一类型的实体之间,默认不做关系判断,避免出现“地名-地名”配对 continue allowed_relations = [] for rule in relation_schema: if rule["subject_type"] == subj[1] and rule["object_type"] == obj[1]: allowed_relations.append(rule["relation"]) if allowed_relations: candidates.append({ "subject": subj, "object": obj, "allowed_relations": allowed_relations, "gap": obj[2] - subj[3] }) return candidates

这段剪枝逻辑的关键在于类型约束。一个“机构名-机构名”配对能进入下一步,前提是关系表里必须存在主语类型为机构、宾语类型也为机构的关系规则;而“地名-地名”这种配对,在业务上往往是无关事实,直接跳过。gap字段记录宾语起始位置和主语结束位置的间隔,用于后续触发词匹配时限定窗口范围。

参数选择的注意事项:建议把关系 schema 从外部配置读入,这样业务上新增一种关系类型时,不需要改代码;在句子特别长、实体特别多时,可以考虑引入最大候选对数量限制,比如每个句子最多保留 30 对,防止下游分类器被慢请求拖垮。

3.3 触发词规则引擎:产出并清洗三元组

候选实体对确定后,进入关系判定。一个实用的规则引擎写法是:从候选对的 subject 结束位置到 object 开始位置之间截取窗口文本,去触发词表里查有没有命中,同时检查窗口内是否存在否定词,存在则跳过,避免把“华为没有收购 O 公司”抽成正面三元组。

import json NEGATION_WORDS = {"不", "未", "没有", "并未", "尚未", "否认"} def extract_triples(sentence, candidates, trigger_words, negation_words=None): negation_words = negation_words or NEGATION_WORDS triples = [] for cand in candidates: subj_text = cand["subject"][0] obj_text = cand["object"][0] window = sentence[cand["subject"][3]:cand["object"][2]] matched_relation = None for rel in cand["allowed_relations"]: for trigger in trigger_words.get(rel, []): if trigger in window: matched_relation = rel break if matched_relation: break if matched_relation: # 否定词出现在窗口内,视为否定语义,不输出三元组 if any(word in window for word in negation_words): continue triples.append({ "subject": subj_text, "relation": matched_relation, "object": obj_text, "confidence": 0.9 }) return triples # 调用示例 trigger_words = { "收购": ["收购", "并购", "买下", "入股"], "发布": ["发布", "宣布推出", "上线"], "位于": ["位于", "坐落", "地处"] } text = "华为技术有限公司在深圳发布了最新的Mate 60手机,余承东出席了发布会。" res = extract_triples(text, candidates, trigger_words) print(json.dumps(res, ensure_ascii=False, indent=2))

输出是一个 JSON 数组,每个元素就是标题所说的三元组:主语、关系、宾语,外加一个置信度字段。trigger_words是关系表里触发词部分的直接映射,它在真实项目中通常存在外部 JSON 文件里,代码只负责加载。这里的置信度是规则命中的固定保守值,如果你在后面接了关系分类模型,这个位置应该换成模型输出的概率值。窗口截取用的是字符索引,而 HanLP 返回的实体起止位置正好是字符级索引,二者可以直接对齐,这是选择 HanLP 而不是某些只给词级别标注的工具的另一个原因。

4. 从脚本到工程:分句、批量处理与性能参数调优

4.1 长文本处理:分句、截断与实体跨句合并

关系抽取的最小单位是句子,但真实语料里一句就是一个句号结尾的完整描述,而是成段的文本。因此第一步就是分句。中文分句不能只按句号切,还要兼顾感叹号、问号、分号,同时要防止把“3.14”“某某有限公司”里的点误判成句尾。一个简单可用的做法是把文本按标点拆开,再用一个短句长度过滤掉空串和纯标点片段。

import re SENTENCE_SPLIT_PATTERN = re.compile(r"[。!?;]") def split_sentences(text): raw_sentences = SENTENCE_SPLIT_PATTERN.split(text) sentences = [] for s in raw_sentences: s = s.strip() if len(s) < 2: continue sentences.append(s) return sentences

这里有一个工程上常见的取舍:如果一个长句被切分成多个短句,那么跨短句的实体对就不会被配对,这会漏掉部分关系事实。比如“华为技术有限公司在深圳发布新品。(随后)余承东出席发布会。”如果拆成两句,第一句的“华为”和第二句的“余承东”之间的任职关系就丢了。所以更精细的做法是:分句时保留句号前的原文偏移量,实体识别后对相邻句子的首尾实体做一次跨句合并配对。具体参数上,合并窗口建议限制在前后相邻两三个句子之内,跨句越远,实体对之间存在事实关系的概率越低,合并窗口太大只会引入噪声。

4.2 批量运行与性能参数:控制 CPU 占用和内存上限

整个工具跑起来之后,你迟早会面对一个实际问题:处理速度不够。HanLP 的预训练模型加载后常驻内存,单条短文本的推理耗时在 CPU 机器上处于可用的量级,但如果你把几千条文本一条一条循环调用,内存里反复创建临时对象,速度会肉眼可见地下降。推荐的优化手段有三个:批次推理、长度截断、缓存去重。

批次推理是说把多条句子拼成一个列表,一次性传给 NER 模型,让模型内部向量化计算吃满 CPU 的 SIMD 能力,而不是循环里一条条调用。在 HanLP 接口里,predict本身支持列表输入,你只需要在业务层做好批量聚合。

长度截断则是针对超长文本的风险控制:命名实体识别模型对特别长的句子容易丢失尾部信息,而且推理时间随序列长度呈平方级增长。我一般会把单句长度限制在 256 或 512 个字符以内,超出的部分先按标点切成子句再进模型。下面是一组在真实项目中常用的起始参数表:

参数项推荐起始值调整方向
单句最大长度256 字符长度过大导致耗时激增时下调到 128
批处理大小32 句/批内存占用过高时下调到 16
候选实体对上限30 对/句准确率偏低时下调到 15,召回偏低时上调
触发词窗口长度25 字符关系跨度大时上调到 40,误报多时下调到 10
NER 模型常驻内存加载一次禁止在循环内重复加载

内存占用方面要注意一个隐藏点:NER 模型正常情况下会占用几百 MB 到 1GB 级别的内存,如果你在同一个进程里既跑关系抽取又跑其他重型服务,建议用独立的 Python 子进程承载抽取服务,或者干脆把 HanLP 封装成一个独立后端服务来隔离内存。

4.3 从脚本到服务:HanLP 被上层系统调用的两种常见形态

很多读者是在公司系统集成场景里接触这个标题的,于是会遇到一个现实问题:抽取工具写好了,怎么被业务系统调用。HanLP 本身脱胎于 Java 生态,不少后端项目是基于 Java 或 Spring Boot 的,行业内常做的方案是把抽取逻辑封装成独立服务对外提供 HTTP 接口,而 Spring Boot 项目里通过 HTTP 客户端调用它。我见过的两种落地形态分别是:一是把整个抽取管线用 Python FastAPI 包一层接口,返回 JSON 三元组列表;二是直接用 pyhanlp 依赖在 Java 进程内嵌入 HanLP,规则引擎用 Java 重写。对多数团队,形态一更省事,因为 Python 侧改规则和迭代模型都不需要重新编译部署,而且标题描述的这套工具本身就是 Python 实现,保持同构最简单。

5. 关系抽取常见问题与避坑:现象、原因、解决

5.1 罪名一:模型首次加载卡在下载环节

这是最让新手崩溃的一步,现象是代码停在hanlp.load附近长时间不动,或者报网络连接超时。原因在于 HanLP 首次加载预训练模型时需要联网下载权重文件,权重体积不小,而公司的办公网络往往对直接下载做了限制。解决方法是:在一台网络畅通的机器上把模型先跑通一次,让权重缓存到本地目录,然后把这个缓存目录连同源码一起拷贝到离线环境。再不行,就在 HanLP 的配置入口显式指定本地模型路径,绕开在线下载逻辑。

5.2 罪名二:实体边界错位,同一个机构被拆成两截

现象是“华为技术有限公司”被识别成“华为技术”和“有限公司”两个碎片,或者反过来,把相邻的两个实体并成了一个。原因有两个层面:一是分词粒度与 NER 模型的标签体系不一致,二是训练语料里没见过你这个领域的特有实体写法。解决方法是加一层规则后处理:维护一个领域别名表,在 NER 结果上做字符串匹配合并;命中别名表时,直接以别名表的实体边界覆盖模型输出。

5.3 罪名三:否定句和条件句被抽成正向三元组

现象是“华为并未收购 O 公司”这句被抽成“华为 收购 O 公司”。原因很直白:触发词“收购”命中了窗口文本,规则引擎没有检查否定语境。解决方法是像前面规则引擎代码里写的那样,把“不、未、没有、并未、尚未、否认”纳入否定词表,在窗口文本里做一次扫描;更进一步,还可以把“计划收购”“正在洽谈收购”这类模态词拆出来,给三元组加一个“确定性”字段,而不是直接丢弃。

5.4 罪名四:HanLP 1.x 和 2.x 的接口签名完全不同

现象是拿到的源码里import hanlp之后,调用代码和网上的教程对不上。原因是 HanLP 2.x 做了 Python 接口重写,1.x 时代流行的pyhanlp包是 Java 类库的 PDF 包装,调用方式差异巨大。解决方法是先看一眼源码的依赖声明和 import 语句:如果是from pyhanlp import HanLP,那依赖的是 1.x 世代,可以直接按着 1.x API 文档补依赖;如果import hanlp且使用hanlp.load,那就是 2.x 原生 Python 包,别想着混用,装错依赖直接 import 都会抛异常。

5.5 罪名五:三元组出现大规模重复,同一个事实被抽了十几次

现象是同一篇长文里有多个句子都在描述同一件事,比如公告里先后出现“华为收购了该公司”“华为买下该公司”,规则引擎把它们抽成两条不同的三元组,下游灌入库时产生冗余。原因是缺少归一化和去重步骤。解决方法是加一个三元组聚合层:以主语文本、关系名、宾语文本作为联合主键,优先保留置信度最高的那条,同时把来源句子索引记录在附件字段里,方便人工审计。

6. 验证三元组质量与后续增强:一次抽样对比就够了

与其在这里列一堆理论指标,不如给你一个直接能用的抽样验证方法。从你要处理的语料里随机抽 50 到 100 个句子,人工标注标准三元组,然后把你这个 Python 工具跑出来的结果和人工标注做对比。对比时不要只看精确率或召回率单一指标,因为规则引擎的短板通常集中在召回上,你更需要看到“哪几类是漏掉的、哪几类是抽错的”。具体做法是准备一张简单的记录表,一列写句子原文,一列写人工标注的三元组,一列写工具输出的三元组,一列写备注“漏抽/错抽/正确”。刷完 50 条,你就能判断出问题集中在触发词表覆盖不足,还是实体配对剪枝剪得太狠。

后续增强的方向也是清晰的:先扩大触发词表,把高频漏抽句子的核心动词加进去,观察收益;收益饱和后,再把规则引擎的输出作为伪标注数据,做一次远程监督式的模型训练,让模型接手规则引擎覆盖不了的同义改写场景。我自己在实际项目里最大的教训是:关系类型定义得越细,标注和规则就越难对齐,比如“任职”和“曾任职”如果分成两类,人工标注都会吵起来。这个工具的方向本身是可靠的——用 HanLP 兜住实体识别,把关系和三元组留给自己掌控,只要 schema 和规则维护得当,它就能稳定地成为你知识抽取管线里的生产组件。希望帮到你。

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

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

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

立即咨询