☰
Python文本关系抽取实战:HanLP实体识别与三元组组装
2026/10/1 5:28:45 网站建设 项目流程

简介:这是一套面向自然语言处理初学者与关系抽取实践者的Python工具源码,基于HanLP完成实体识别、语义角色标注与依存句法分析,最终输出关系三元组。工具支持多种抽取模式,包括施事者—谓语—受事者三元组、主谓宾三元组、关键词、高频词、实体词、实体共现词以及实体与关键词关联词,可满足文本结构化与知识图谱构建的基础需求。资源包共66个文件,以35个py源码和16个md说明文档为主,另含txt、png、json、yaml、js、css等配置与示例文件,压缩包约1.76MB,目录涵盖utils、tests、examples、docs等模块,结构清晰便于按功能查阅。目前已有431人学习下载。读者可从中获取完整的实体识别与关系抽取实现代码、测试用例、示例脚本及配套文档,适合用于课程设计、科研原型或工程二次开发,快速理解三元组抽取流程与HanLP调用方式。

1. 从一段合同文本里抠出三元组:这套 Python 关系抽取工具到底解决什么问题

手里拿到一份 3000 字的合同、一段医疗病历或者一批新闻稿,老板要你「把里面的人、公司、金额、时间、地点之间的关系全部结构化出来」,你第一反应可能是上大模型。但真到落地环节,成本、延迟、私有化部署、结果可解释性这几座大山压下来,很多团队最后还是回到传统 NLP 流水线:先做命名实体识别(NER),再做关系分类,最后拼成(头实体, 关系, 尾实体)这样的三元组。这套 Python 实现的文本关系抽取工具,走的就是这条路线,实体识别这一层交给 HanLP,关系判定这一层自己写规则或接分类模型,输出统一的三元组结构。

它适合谁?适合需要把非结构化中文文本转成知识图谱边、做合同要素抽取、做舆情主体关系梳理的工程师;也适合刚学完 python 基础语法、想找一个能跑通全流程的 NLP 小项目练手的人。不适合指望开箱即用、零配置就能达到 GPT 级别抽取精度的场景——传统流水线的天花板就在那里,但它的可控性和可调试性,恰恰是很多生产环境更看重的。下面我把这套方案从环境搭建、实体识别、关系判定到三元组组装,按我自己踩过的顺序讲一遍。

2. 环境与 HanLP 落地:从 python 安装到第一次跑出实体

2.1 为什么实体识别这一层选 HanLP 而不是自己训 BERT

关系抽取的上游是实体识别,实体识别的质量直接决定三元组能不能拼对。选 HanLP 的理由很实际:它开箱就带中文预训练模型,hanlp这个 pip 包封装了分词、词性标注、命名实体识别、依存句法一整条流水线,调用接口统一,不需要你先去 huggingface 下载权重再写 tokenizer。对于「文本关系抽取」这种以工程落地为目标的场景,HanLP 的性价比很高——你花在环境上的时间少,花在关系逻辑上的时间就多。

当然它也有边界。HanLP 的默认 NER 模型覆盖的是通用领域实体类型(人名、地名、机构名等),如果你做的是医疗、法律、金融这种垂直领域,通用模型对专有实体召回会明显下降。这时候常见做法是:用 HanLP 做基础分词和词性标注,实体识别换成自己微调的模型,或者用 HanLP 的自定义词典功能把领域词先兜住。我一般会先用默认模型跑一遍看效果,再决定要不要换。

2.2 从零把环境跑起来:python 安装与依赖配置

不管你用的是 python 官网下载的安装包,还是 ubuntu 下 apt 装的,第一步都是确认版本。HanLP 对 python 版本有要求,太老的 3.6 以下会出问题,建议 3.8 及以上。装完 python 之后,vscode python 环境配置或者 pycharm 配置 python 环境都行,选一个顺手的。下面这套命令是我在 linux 和 windows 上都验证过的:

# 建议先建虚拟环境,避免和系统里的包打架 python -m venv hanlp_env # linux / mac 激活 source hanlp_env/bin/activate # windows 激活 # hanlp_env\Scripts\activate # 安装核心依赖,hanlp 会自动拉取 torch 等 pip install hanlp # 如果网络慢,可以指定国内镜像 pip install hanlp -i https://pypi.tuna.tsinghua.edu.cn/simple

装完之后验证一下,HanLP 第一次调用会自动下载预训练模型,这一步会占几百 MB 磁盘,网络不好容易卡住:

import hanlp # 加载多任务模型,包含分词、词性、NER # 第一次运行会自动下载,耐心等 tagger = hanlp.load(hanlp.pretrained.mtl.CLOSE_TOK_POS_NER_SRL_DEP_SDP_CON_ELECTRA_SMALL_ZH) text = "张三于2023年5月加入了北京字节跳动科技有限公司,担任算法工程师。" result = tagger(text) # 打印命名实体识别结果 print(result['ner/ontonotes'])

这段代码的逻辑是:hanlp.load加载一个多任务预训练模型,tagger(text)一次性返回分词、词性、NER、依存等多个结果,我们只取ner/ontonotes这一项。参数说明:CLOSE_TOK_POS_NER_SRL_DEP_SDP_CON_ELECTRA_SMALL_ZH是模型标识,SMALL表示轻量版,速度快但精度略低;如果对精度要求高,可以换成LARGE版本,代价是显存和推理时间上升。跑通这一步,你就拿到了实体列表,这是三元组的原料。

2.3 实体识别结果长什么样,怎么读

上面那段代码的输出大致是这样:

[('张三', 'PERSON'), ('2023年5月', 'DATE'), ('北京字节跳动科技有限公司', 'ORG'), ('算法工程师', 'TITLE')]

每个元素是(实体文本, 实体类型)。注意 HanLP 的实体类型标签是英文的,PERSON是人名,ORG是机构,DATE是日期,TITLE是职位。这些标签就是你后面写关系规则时的锚点。比如「加入」这个动作,通常连接的是PERSON和ORG;「担任」连接的是PERSON和TITLE。把实体类型和触发词对上,关系抽取的骨架就出来了。

这里有个容易翻车的点:HanLP 返回的实体位置信息默认不在这个简化输出里,如果你需要知道实体在原文中的起止位置(做关系触发词定位时会用到),得用result['ner/ontonotes']配合result['tok/fine']自己对齐,或者直接调底层 API 拿 offset。我一开始没注意这个,写关系规则时全靠字符串匹配,遇到重复实体就抓瞎。

3. 关系判定与三元组组装:规则、模型两条路怎么选

3.1 关系抽取的两种主流做法:触发词规则 vs 关系分类模型

实体识别出来之后,下一步是判断两个实体之间到底有没有关系、是什么关系。这里分两条路。

第一条是触发词规则法。核心思想是:句子里的某些动词或关键词是关系的「信号」。比如「就职于」「加入」「担任」指向任职关系,「收购」「并购」指向资本关系,「出生于」指向籍贯关系。做法是先在句子里找到触发词,再看触发词左右最近的实体,按预设的模板拼成三元组。这条路实现简单、可解释性强、调试直观,缺点是覆盖不了触发词不明显的句子,召回率有限。

第二条是关系分类模型法。把「实体1 + 句子 + 实体2」拼成一个分类样本,训练一个模型输出关系类别。这条路召回高,但需要标注数据,训练和部署成本都上去了。对于大多数刚起步的项目,我的建议是先用规则跑通闭环,把三元组结构、存储、下游消费都打通,等规则覆盖率不够了再上模型。下面重点讲规则法,因为它才是这套工具能快速落地的关键。

3.2 用触发词规则把实体拼成三元组

先定义关系模板。每个模板包含:关系名、触发词列表、头实体类型、尾实体类型。然后遍历句子,找到触发词,再在触发词附近找符合类型的实体。

# 关系模板定义 # 每个关系:关系名 -> (触发词列表, 头实体类型, 尾实体类型) RELATION_PATTERNS = { "任职于": (["加入", "就职于", "任职于", "入职"], "PERSON", "ORG"), "担任": (["担任", "出任", "就任"], "PERSON", "TITLE"), "成立于": (["成立于", "创立于", "创办于"], "ORG", "DATE"), "收购": (["收购", "并购", "兼并"], "ORG", "ORG"), } def extract_triples(text, entities, tokens): """ text: 原始句子 entities: [(实体文本, 类型), ...] tokens: 分词结果,用于定位触发词位置 """ triples = [] for rel_name, (triggers, head_type, tail_type) in RELATION_PATTERNS.items(): for trigger in triggers: if trigger not in text: continue # 找到触发词在句中的位置 trigger_pos = text.find(trigger) # 在触发词左侧找头实体,右侧找尾实体 head = None tail = None for ent_text, ent_type in entities: ent_pos = text.find(ent_text) if ent_pos < trigger_pos and ent_type == head_type and head is None: head = ent_text if ent_pos > trigger_pos and ent_type == tail_type and tail is None: tail = ent_text if head and tail: triples.append((head, rel_name, tail)) return triples # 测试 text = "张三于2023年5月加入了北京字节跳动科技有限公司,担任算法工程师。" entities = [('张三', 'PERSON'), ('2023年5月', 'DATE'), ('北京字节跳动科技有限公司', 'ORG'), ('算法工程师', 'TITLE')] tokens = ['张三', '于', '2023年5月', '加入', '了', '北京字节跳动科技有限公司', ',', '担任', '算法工程师', '。'] print(extract_triples(text, entities, tokens)) # 输出: [('张三', '任职于', '北京字节跳动科技有限公司'), ('张三', '担任', '算法工程师')]

逻辑说明:外层遍历每个关系模板,内层遍历触发词。找到触发词位置后,在它左边找类型匹配的头实体,右边找类型匹配的尾实体,都找到就产出一个三元组。参数说明:RELATION_PATTERNS是核心配置,你可以按业务往里面加关系;head_type和tail_type决定了实体类型约束,防止把「日期」错配成「机构」。这段代码故意写得朴素,是为了让你看清逻辑,实际用的时候要处理一个实体被多个关系复用、触发词重叠、否定句(「没有加入」)这些情况。

3.3 三元组去重、过滤与结构化输出

规则法跑出来的三元组会有重复和噪声。比如同一句话里「加入」和「入职」都命中,会产出两条一样的。再比如「张三没有加入字节」这种否定句,规则法会误抽。所以组装完必须过一遍清洗。

def clean_triples(triples): # 去重 triples = list(set(triples)) # 过滤头尾实体相同的(自环) triples = [t for t in triples if t[0] != t[2]] # 过滤空值 triples = [t for t in triples if all(t)] return triples # 否定句简单处理:触发词前 3 个字内有否定词就丢弃 NEG_WORDS = ["不", "没", "未", "无", "非"] def filter_negation(text, triples): result = [] for h, r, t in triples: # 找到关系对应触发词的位置做判断,这里简化处理 neg = False for w in NEG_WORDS: if w in text: neg = True break if not neg: result.append((h, r, t)) return result

去重和自环过滤是基本操作,否定句处理是规则法的老大难,上面这段只是最粗的版本,实际项目里我一般会结合依存句法,看触发词和否定词是不是在同一个依存子树里,这样准确率高很多。输出格式上,三元组可以直接存成 JSON Lines,一行一条,方便下游灌进图数据库或者做数据分析:

import json triples = [('张三', '任职于', '北京字节跳动科技有限公司'), ('张三', '担任', '算法工程师')] with open('triples.jsonl', 'w', encoding='utf-8') as f: for h, r, t in triples: f.write(json.dumps({"head": h, "relation": r, "tail": t}, ensure_ascii=False) + "\n")

这种一行一个 JSON 的格式,比一次性 dump 一个大 JSON 数组更友好,追加写入方便,读取时也能流式处理,文本量大时不会爆内存。

4. 避坑与排查:关系抽取流水线里最容易翻车的 5 个地方

4.1 实体边界切错导致三元组头尾对不上

现象:抽出来的三元组里,头实体是「北京字节跳动」,尾实体是「科技有限公司」,明显被切断了。原因:HanLP 默认模型对长机构名的边界识别不稳定,尤其是带地名前缀的公司名。解决:给 HanLP 加自定义词典,把业务里高频的机构全称、简称都塞进去,或者在后处理阶段用规则把相邻的同类实体合并。我一般会维护一个领域词典文件,启动时加载。

4.2 触发词误命中,把无关句子抽成关系

现象:「他加入了讨论」被抽成「他 任职于 讨论」。原因:触发词「加入」太泛,不区分宾语类型。解决:在规则里加实体类型约束只是第一层,还要加宾语语义约束,比如尾实体必须是 ORG 类型,且不能是动词短语。更稳的做法是结合依存句法,要求触发词和尾实体之间存在动宾关系。

4.3 一个实体被多个关系重复消费

现象:同一个「张三」在「加入字节并担任工程师」里,被「加入」和「担任」两个模板各抽一次,如果模板设计不当,可能产出「张三 任职于 工程师」这种错误三元组。原因:头实体查找时没有做关系隔离,多个模板共享了实体列表。解决:每个模板独立查找,且尾实体类型严格匹配,TITLE 类型不能被 ORG 模板消费。

4.4 长文本里实体位置漂移

现象:处理超过 500 字的段落时,三元组头尾实体张冠李戴。原因:HanLP 对超长文本会截断或分块,实体 offset 和原文对不上。解决:先按句号、分号切句,逐句做实体识别和关系抽取,再在句子级别合并结果。切句这一步别偷懒,它是长文本抽取稳定的前提。

4.5 模型首次加载慢被误判为卡死

现象:第一次运行hanlp.load时程序几分钟没反应,以为挂了。原因:HanLP 在后台下载预训练模型,没有进度条。解决:提前手动下载模型放到缓存目录,或者用hanlp.pretrained里的下载函数单独拉。生产环境一定要把模型文件打进镜像,别让线上服务第一次请求时去下载。

5. 进阶技巧:把规则法和模型法串起来,让三元组召回再上一个台阶

规则法跑通之后,你会发现有些句子明明有关系,但就是没有触发词,比如「张三,字节跳动算法工程师」这种同位语结构,规则法完全抓不到。这时候可以引入一个轻量的关系分类模型做补充。我的做法是:规则法先跑一遍,产出高置信度三元组;剩下的句子用「实体对 + 句子」构造样本,喂给一个基于 HanLP 或 transformers 微调的分类模型,输出关系类别。两路结果合并去重,召回率能明显提升。

验证这套流水线好不好,别只看准确率。我一般会准备一个小规模的人工标注测试集,至少 200 条句子,覆盖各种句式和否定情况,然后算三个指标:精确率(抽出来的三元组有多少是对的)、召回率(人工标注的三元组有多少被抽出来了)、以及三元组级别的 F1。规则法的精确率通常高但召回低,加了模型之后召回上去,精确率会掉一点,这个权衡要看业务能不能接受。

还有一个实用技巧:把关系模板配置化。别把RELATION_PATTERNS写死在代码里,抽成 YAML 或 JSON 文件,业务方改关系不用动代码,重启服务就生效。我吃过这个亏,早期模板写死在 py 文件里,运营要加一个「投资」关系,还得找我改代码发版,后来全部外置,省了大量沟通成本。

最后说个我自己的习惯:每加一条新规则,先拿历史数据回跑一遍,看新规则有没有把老结果带偏。规则之间会互相干扰,尤其是触发词有重叠的时候。这个回跑机制帮我拦下过好几次线上事故。希望帮到你。

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

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

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

立即咨询