
简介这是一份面向自然语言处理初学者与关系抽取实践者的Python工具源码围绕HanLP实现实体识别、语义角色标注与依存句法分析最终输出关系三元组。资源覆盖event施事者—谓语—受事者、svo主谓宾、keyword关键词、freq高频词、ner实体词、coexist实体共现及ner_keyword实体与关键词关联等多种抽取维度适合用于信息抽取课程设计、知识图谱构建入门或文本分析项目参考。压缩包共66个文件约1.76MB其中35个py源码构成核心抽取与评估逻辑16个md文档记录模型说明与数据格式另有txt、png、json、yaml、js、css及论文PDF等辅助材料目录按utils、uie、examples、tests、docs等模块划分结构清晰。目前已有431人学习下载。读者可据此快速理解三元组抽取流程复用实体识别与关系抽取脚本并借助示例与测试代码完成二次开发与效果验证。1. 从一段合同文本里抽出三元组这个工具到底在解决什么假设你手头有一批合同、公告或工单文本业务方要的不是分词结果也不是一堆实体列表而是「谁和谁之间发生了什么」——比如「甲方 与 乙方 签订 采购协议」「某公司 收购 某子公司」。这类结构化输出最通用的表达形式就是三元组主体、关系、客体。Python 实现的文本关系抽取工具核心目标就是把一段自然语言转成一组这样的三元组而实体识别这一层交给 HanLP 来做关系判定这一层由你自己控制规则或模型。这个方向适合两类人一类是手上已经有大量中文文本、想快速跑通一条抽取流水线的工程师另一类是正在做知识图谱、问答或风控系统需要把非结构化文本变成可入库结构化数据的开发者。它不适合指望开箱即用就能达到人工标注精度的人因为关系抽取的准确率高度依赖你的关系定义和触发词设计。下面按「先跑通、再调优、最后避坑」的顺序讲清楚。2. 用 HanLP 做实体识别从安装到拿到带类型的实体列表2.1 为什么实体识别这一层选 HanLP中文实体识别常见方案有 LTP、StanfordNLP、spaCy 加中文模型以及 HanLP。选 HanLP 的理由很实际它对中文的预训练模型覆盖比较全命名实体识别、分词、词性标注可以一次调用拿到而且 Python 接口封装得比较直接不需要你单独去处理分词边界和实体边界对齐的问题。对于关系抽取来说实体识别输出的每个实体必须带类型和起止位置HanLP 的recognize_entities返回的正是这种结构后续做关系判定时可以直接用实体对去匹配触发词。需要提前说清楚一个边界HanLP 的通用模型识别的是人名、地名、机构名这类通用实体。如果你的业务实体是「合同编号」「设备型号」这种领域实体通用模型大概率识别不出来这时候要么自己训练领域模型要么在 HanLP 结果之上加一层正则补充。我一般会先用通用模型跑一遍看召回再决定要不要补规则。2.2 安装 HanLP 并跑通第一个实体识别示例安装这一步最容易翻车的地方是版本和依赖。HanLP 1.x 和 2.x 的 API 完全不同网上很多老教程是 1.x 的写法直接抄会报错。下面按 2.x 的接口来写。# 建议单独建虚拟环境避免和系统里的其他包冲突 python -m venv hanlp_env source hanlp_env/bin/activate # Windows 用 hanlp_env\Scripts\activate # 安装 HanLP 2.x它会自动拉取 torch 等依赖 pip install hanlp # 首次运行会自动下载预训练模型国内网络建议先设置镜像 pip install hanlp -i https://pypi.tuna.tsinghua.edu.cn/simpleimport hanlp # 加载多任务模型包含分词、词性、命名实体识别 # 首次调用会下载模型之后走本地缓存 tagger hanlp.load(hanlp.pretrained.mtl.CLOSE_TOK_POS_NER_SRL_DEP_SDP_CON_ELECTRA_SMALL_ZH) text 张三于2023年加入了北京智源人工智能研究院担任研究员。 # 只取命名实体识别结果 result tagger(text) entities result[ner/ontonotes] for ent in entities: # ent 结构为 (实体文本, 实体类型, 起始位置, 结束位置) print(ent)这段代码的逻辑是先加载一个多任务模型一次前向计算同时得到分词、词性和实体结果然后只取ner/ontonotes这一路输出。参数上CLOSE_TOK_POS_NER_SRL_DEP_SDP_CON_ELECTRA_SMALL_ZH是模型名SMALL 版本速度快、显存占用低适合先跑通如果精度不够可以换成对应的 BASE 或 LARGE 版本代价是推理变慢。实体类型常见的有 PERSON、ORG、GPE、DATE 等这些类型名在后续关系判定里会用到。提示如果加载模型时报显存不足先把模型换成 SMALL 版本或者用tagger hanlp.load(..., devices-1)强制走 CPU。2.3 把实体结果整理成关系抽取可用的结构HanLP 返回的实体列表是扁平的关系抽取需要的是「实体对 它们在原文中的位置」。所以中间要做一步整理把实体按起止位置排序同时保留实体类型方便后面按类型组合去限定关系搜索范围。比如「人 加入 机构」这种关系主体类型限定 PERSON客体类型限定 ORG就能过滤掉大量无关实体对。def normalize_entities(entities): 把 HanLP 的实体输出整理成统一结构按位置排序 normalized [] for text, label, start, end in entities: normalized.append({ text: text, label: label, start: start, end: end }) # 按起始位置排序保证实体对在原文中的先后顺序正确 normalized.sort(keylambda x: x[start]) return normalized ents normalize_entities(entities) for e in ents: print(e[text], e[label], e[start], e[end])这里的关键参数是start和end它们是字符级偏移不是 token 偏移。后面做关系触发词匹配时判断触发词是否落在两个实体之间用的就是这两个值。如果偏移量对不上说明模型输出的偏移和原文编码不一致常见原因是文本里有全角字符或换行需要先做一次文本清洗。3. 关系判定从实体对到三元组的规则与触发词设计3.1 关系抽取的两种路线和选型理由拿到实体之后关系判定有两条路一是基于规则和触发词二是基于分类模型。规则路线的好处是可解释、可调试、不需要标注数据缺点是覆盖有限、维护成本随关系种类增加而上升。模型路线精度上限更高但需要标注数据而且中文关系抽取的公开标注数据质量参差不齐。我的建议是关系种类少于 20 种、且每种关系有比较固定的触发词时先用规则跑通把流水线和数据格式定下来等规则覆盖率到瓶颈了再把规则产出的结果当作弱标注数据去训练分类模型。这样不会一上来就卡在标注上。3.2 用触发词加实体类型约束生成三元组规则路线的核心是对每一对实体检查它们之间是否存在触发词同时检查实体类型是否符合关系定义。下面是一个可运行的实现。# 关系定义关系名 - (主体类型, 客体类型, 触发词列表) RELATION_RULES { 任职于: ([PERSON], [ORG], [加入, 任职, 担任, 就职]), 收购: ([ORG], [ORG], [收购, 并购, 兼并]), 成立于: ([ORG], [DATE], [成立于, 创建于, 建立于]), } def extract_triples(text, entities, rules): 基于触发词和类型约束抽取三元组 triples [] for i in range(len(entities)): for j in range(len(entities)): if i j: continue subj, obj entities[i], entities[j] # 只考虑主体在客体之前的情况避免重复 if subj[start] obj[start]: continue # 两个实体之间的文本片段 between text[subj[end]:obj[start]] for rel_name, (subj_types, obj_types, triggers) in rules.items(): if subj[label] not in subj_types: continue if obj[label] not in obj_types: continue # 检查触发词是否出现在实体之间 if any(t in between for t in triggers): triples.append((subj[text], rel_name, obj[text])) return triples triples extract_triples(text, ents, RELATION_RULES) for t in triples: print(t)逻辑说明外层双重循环遍历所有实体对用subj[start] obj[start]保证只处理主体在前的情况避免同一对实体生成两条方向相反的三元组。between取的是两个实体之间的原文片段触发词只要出现在这个片段里就算命中。参数上RELATION_RULES是唯一需要你按业务改的地方主体类型、客体类型、触发词三个字段决定了抽取的精度和召回。触发词列表越长召回越高但也越容易误触发比如「加入」既可能表示任职也可能表示加入某个组织成为会员这时候就要靠实体类型约束来兜底。3.3 触发词匹配的边界处理上面这段代码有一个明显问题any(t in between for t in triggers)是子串匹配「加入」会匹配到「加入我们」这种无关片段。改进方式是加距离约束和否定词过滤。def extract_triples_v2(text, entities, rules, max_distance30): 增加距离约束和否定词过滤的版本 NEGATIONS [没有, 未, 不再, 拒绝] triples [] for i in range(len(entities)): for j in range(len(entities)): if i j or entities[i][start] entities[j][start]: continue subj, obj entities[i], entities[j] between text[subj[end]:obj[start]] # 距离约束实体间隔太远关系可信度低 if len(between) max_distance: continue # 否定词过滤实体间出现否定词则跳过 if any(neg in between for neg in NEGATIONS): continue for rel_name, (subj_types, obj_types, triggers) in rules.items(): if subj[label] in subj_types and obj[label] in obj_types: if any(t in between for t in triggers): triples.append((subj[text], rel_name, obj[text])) return triplesmax_distance这个参数需要按你的文本类型调合同条款句子长可以放到 40 到 50新闻标题短20 就够。设太小会漏掉跨小句的关系设太大会引入大量噪声。否定词过滤是血泪经验不加的话「张三没有加入该公司」会被抽成「张三 任职于 该公司」方向完全反了。4. 把流水线串起来文本清洗、批量处理和结果落库4.1 文本清洗为什么不能省HanLP 的实体偏移是基于输入字符串的如果输入里混了 HTML 标签、多余空格或全角半角混用偏移就会错位导致触发词匹配落在错误位置。常见做法是先做一轮清洗去掉 HTML 标签、统一全角半角、把连续空白压成一个空格。这一步不做后面所有基于位置的逻辑都不可靠。import re def clean_text(text): 清洗文本保证实体偏移和原文一致 # 去掉 HTML 标签 text re.sub(r[^], , text) # 全角转半角只处理常见标点和数字字母 text text.replace( , ) # 压缩连续空白 text re.sub(r\s, , text) return text.strip()清洗之后要重新跑实体识别不能用清洗前的实体结果因为偏移已经变了。这个顺序不能反。4.2 批量处理时的性能取舍单条文本跑 HanLP 没问题但批量处理时如果逐条调用模型加载和释放的开销会拖慢整体速度。正确做法是模型只加载一次然后循环调用。如果文本量很大可以把文本攒成 batch 一起送进模型HanLP 的tagger支持传入列表。def batch_extract(texts, tagger, rules, batch_size16): 批量抽取三元组 all_results [] for i in range(0, len(texts), batch_size): batch [clean_text(t) for t in texts[i:ibatch_size]] # 批量推理返回结果列表 batch_results tagger(batch) for text, res in zip(batch, batch_results): ents normalize_entities(res[ner/ontonotes]) triples extract_triples_v2(text, ents, rules) all_results.append({text: text, triples: triples}) return all_resultsbatch_size取决于你的显存SMALL 模型 16 一般没问题LARGE 模型可能要降到 4 或 8。批量推理的收益在文本量大时才明显如果只有几十条逐条跑反而更省心。4.3 结果落库和去重三元组抽出来之后通常要入库。入库前必须去重因为同一对实体可能在文本里多次出现触发词命中多次就会生成重复三元组。去重键用「主体 关系 客体」的组合即可。def dedup_triples(triples): 按主体、关系、客体去重保持原有顺序 seen set() result [] for t in triples: key (t[0], t[1], t[2]) if key not in seen: seen.add(key) result.append(t) return result如果后续要入图数据库主体和客体建议再做一次实体对齐把「北京智源人工智能研究院」和「智源研究院」这种同一实体的不同表述合并否则图谱里会出现大量重复节点。实体对齐本身是个独立话题简单做法是用别名表复杂做法用向量相似度。5. 避坑与排查关系抽取流水线最常见的五个翻车点5.1 实体偏移错位导致触发词匹配全空现象实体识别结果看起来正常但三元组一条都抽不出来。原因输入文本在实体识别前做过清洗但偏移量用的是清洗前的或者文本里有不可见字符导致偏移整体偏移。解决确保清洗和实体识别用的是同一个字符串对象清洗后重新跑实体识别排查时打印text[start:end]看是否等于实体文本不等就说明偏移错了。5.2 触发词子串匹配造成大量误抽现象「加入」把「加入我们」「加入收藏」都匹配上了生成一堆无意义三元组。原因子串匹配没有词边界概念。解决把触发词匹配改成基于分词结果的匹配或者给触发词加前后字符约束比如要求触发词前面是实体或标点。更彻底的做法是维护一个停用触发词表把高频误触发词排除。5.3 实体类型不匹配导致关系漏抽现象明明文本里有「收购」触发词但三元组没抽出来。原因HanLP 把某个公司名识别成了 GPE 而不是 ORG而规则里客体类型只写了 ORG。解决打印实体类型分布看通用模型的类型标注和你的规则定义差在哪对领域文本通用模型的类型体系经常对不上需要在规则里放宽类型约束或者加一层类型映射表。5.4 长文本里实体对爆炸现象一篇几千字的文档实体有上百个双重循环产生上万对组合跑得极慢且结果噪声大。原因没有做实体对筛选所有实体两两组合。解决加距离约束只是第一步更有效的是按句子切分只在句子内部做实体对组合跨句关系单独处理。句子切分可以用 HanLP 的分句功能也可以简单按句号、分号切。5.5 模型首次加载卡住或报网络错误现象第一次运行hanlp.load长时间无响应或报连接错误。原因模型文件需要从远端下载网络不通或镜像没配。解决提前配好 pip 镜像或者手动下载模型文件放到 HanLP 的缓存目录。缓存目录一般在~/.hanlp下具体路径可以在报错信息里看到。如果是在内网环境需要提前把模型文件拷贝进去。6. 进阶用规则产出做弱标注再训一个关系分类模型规则跑通之后你会发现两个瓶颈触发词覆盖不全以及同一触发词在不同语境下关系不同。这时候可以往模型方向走一步但不必从零标注。做法是用规则在高置信度文本上抽一批三元组把实体对之间的文本片段作为特征关系名作为标签训练一个文本分类模型。这样规则产出的结果就成了弱标注数据成本比人工标注低得多。具体操作上把每个三元组对应的实体间文本片段抽出来加上实体类型特征构成训练样本。模型可以用简单的 TextCNN 或 BERT 微调输入是「主体 间隔文本 客体」的拼接。训练完之后用模型去预测规则没覆盖到的实体对把高置信度的结果回流到规则里形成迭代。def build_training_samples(text, entities, triples, rules): 用规则产出构建弱标注训练样本 samples [] triple_set set(triples) for i in range(len(entities)): for j in range(len(entities)): if i j or entities[i][start] entities[j][start]: continue subj, obj entities[i], entities[j] between text[subj[end]:obj[start]] if len(between) 50: continue # 特征主体类型 间隔文本 客体类型 feature f{subj[label]}|{between}|{obj[label]} # 标签规则命中的关系名未命中的标为 None label None for t in triples: if t[0] subj[text] and t[2] obj[text]: label t[1] break samples.append({feature: feature, label: label}) return samples这个函数产出的样本里正样本是规则命中的负样本是未命中的。训练时只拿正样本和一部分负样本比例控制在 1:3 左右负样本太多会让模型偏向预测无关系。验证方法上留出一部分规则没处理过的文本做测试集看模型能不能抽出规则漏掉的关系如果能说明模型学到了触发词之外的模式。我自己的习惯是规则和模型不是替代关系而是互补。规则负责高精度、可解释的部分模型负责召回和泛化。上线时两条路并行规则结果直接入库模型结果超过阈值才入库低于阈值的进人工复核队列。这样既保证了准确率又不会因为规则覆盖不足而漏掉重要关系。希望帮到你。本文还有配套的精品资源点击获取