ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python文本关系抽取实战:基于HanLP从实体到三元组

Python文本关系抽取实战:基于HanLP从实体到三元组 简介这是一套面向自然语言处理研究者和信息抽取开发者的 Python 文本关系抽取工具源码借助 HanLP 完成命名实体识别、语义角色标注与依存句法分析可输出事件三元组、主谓宾三元组、关键词、高频词、实体词、实体共现词以及实体与关键词关联词等多维关系结果。压缩包共 66 个文件约 1.76MB35 个 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、依存句法分析等能力。而标题里说的“文本关系抽取”是在实体识别之上额外搭建的一层逻辑。把这条流水线展开大概是这样一个顺序输入一篇原始文本先做分句保证每一句是关系抽取的基本单位。对单句做分词和词性标注交给 HanLP 的 NER 模块拿到句子里的命名实体列表每条实体带实体类型比如“张三 / 人名”“华为 / 机构名”“深圳 / 地名”。把句子里的实体两两配对形成候选实体对同时剪掉明显不合理的配对比如“深圳 / 地名”和“北京 / 地名”这种同类实体对。对每一对候选实体借助触发词规则或关系分类模型判定这对实体之间是否存在业务关系以及具体是哪一种关系。把通过判定的实体对和关系打包成三元组输出成 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_wordsNone): 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_asciiFalse, indent2))输出是一个 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误报多时下调到 10NER 模型常驻内存加载一次禁止在循环内重复加载内存占用方面要注意一个隐藏点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 和规则维护得当它就能稳定地成为你知识抽取管线里的生产组件。希望帮到你。本文还有配套的精品资源点击获取
返回列表