简介:这是一套从非结构化文本自动构造知识图的端到端管道,名为 KnowledgeGraph_Builder,面向 NLP 爱好者、算法工程师及知识图谱研究者。项目借助 spaCy、Stanford CoreNLP、Huggingface Neural Coref 和句子转换器等工具,实现了命名实体识别、共指消解、关系抽取和实体链接,最终输出 RDF 三元组,可用于自动本体构建与分类扩展,有效缓解手工知识库覆盖率低的问题。资源包共 106 个文件,压缩后约 3.12MB,其中包含 38 个 Python 脚本、20 个 CSV 数据文件、10 个 Jupyter Notebook,以及 C 源程序、Shell 脚本、配置文件和说明文档,覆盖从数据预处理、模型训练到结果评估的完整链路。包内还提供《星球大战》人物文本样例,便于对照理解算法产出。目前已有 301 人学习下载,适合希望快速上手知识图谱构建、研究无监督信息抽取方案的读者;代码结构清晰,可直接复现实验,也可作为二次开发的基础。
1. KnowledgeGraph_Builder:别急着上大模型,先让 HTML 文本变成可查询的三元组
之前做某领域资料梳理,手里是一堆网页存档的新闻稿和采访稿,目标是从里面理清「谁、在什么机构、做了什么、和谁有关」。我最开始试图直接上深度关系抽取,结果训练数据不够,效果像玄学。后来换了个思路:先用规则和词典把实体和关系从 HTML 原始文本里稳妥提取,再落到图结构里用浏览器可视化。这条路走通之后,处理一万篇文本只需要几小时,而且每一条三元组都能追溯回原文。这个资源包解决的就是这样一类问题:你手上有大量非结构化网页文本,想低成本得到一张可查询、可展示、可增量更新的知识图。适合做情报梳理、下游问答系统前置抽取、或者数据治理里实体关系盘点的人。后面我按自己实际拆包的过程,把清洗、抽取、建图、可视化的完整链路和一个一个坑位都写清楚。
2. 文本清洗与分句:把 HTML 还原成干净的句子,再谈实体识别
从网页文本构造知识图的第一步不是实体识别,而是把 HTML 里的噪声清掉。直接在原始网页上做抽取,你会匹配到一堆导航栏文字、脚本变量名和无意义的标签属性,这会让后续关系模板的准确率掉到没法看。这一章先解决「干净文本从哪来」的问题。
2.1 BeautifulSoup 拆 HTML:脚本、样式、标签一次清干净
HTML 里真正承载内容的往往只有<p>、<h1>~<h3>、<li>这类正文标签,其余大多是导航和脚本。常见做法是用 BeautifulSoup 先做一轮标签白名单过滤,再做一轮黑名单剔除。下面这段是我在资源包里默认使用的清洗脚本核心部分。
from bs4 import BeautifulSoup import re def clean_html(raw_html: str, keep_tags=None) -> str: # keep_tags 为 None 时默认保留段落、标题和列表项 if keep_tags is None: keep_tags = ['p', 'h1', 'h2', 'h3', 'li'] soup = BeautifulSoup(raw_html, 'html.parser') # 先去掉完全没用的节点:脚本、样式、注释 for node in soup(['script', 'style', 'noscript']): node.decompose() # 收集符合条件的节点文本,而不是对整篇做 get_text collected = [] for tag in soup.find_all(keep_tags): text = tag.get_text(separator=' ', strip=True) if len(text) >= 8: collected.append(text) text = '\n'.join(collected) # 压缩多余空白与换行 text = re.sub(r'\s+', ' ', text) return text.strip()逻辑上分三层:先decompose掉加载型标签,再按白名单收集正文节点,最后做空白归一。为什么要按标签收集,而不是直接soup.get_text()?因为后者会把表格、侧栏、版权行全部混进来,而白名单方式保留了网页的段落语义,之后按段切分句子时,段落边界可以直接当成句子边界。参数keep_tags可以根据页面类型调整:如果是论坛帖子,可以把div加上,但要小心div嵌套造成的重复收集,通常我建议用li和p起步。
2.2 jieba 分词 + 自定义词典:补齐领域词,再分句
英文文本有天然的空格分隔,中文没有。实体识别之前必须分词。我的做法是 jieba 加载一个自定义词典,把项目里确定的领域词先固化进去,再按句号、问号、感叹号做分句。分句这一步常被忽略,但它决定了关系模板的作用范围:关系抽取必须在一个句子内部进行,跨句关系需要额外的共指处理,前期完全可以不做。
import jieba import re jieba.load_userdict('domain_words.txt') # 每行一个词,可带词频和词性 def split_sentences(text: str) -> list: # 按中文结束标点切句,保留引号内的完整语义 parts = re.split(r'(?<=[。?!;])', text) sentences = [] for part in parts: part = part.strip() if len(part) >= 6: sentences.append(part) return sentences def tokenize_sentence(sentence: str) -> list: words = jieba.lcut(sentence) # 丢掉纯空白和单字虚词 return [w for w in words if w.strip() and len(w.strip()) > 1]这里的关键参数是domain_words.txt。每一行格式是「词 词频 词性」,词频不写时 jieba 会用默认值。领域词没有加进去之前,「某平台」会被切成「某 / 平台」,实体锚点直接失效。分句正则里的(?<=[。?!;])是零宽断言,意思是只在标点之后切,不把标点吃掉,这样句子结尾的标点可以留给后续模板做边界判断。len(part) >= 6这个阈值也不是随便定的,太短的分句大概率是列表项残留或页脚,过滤掉能减少不少假实体。
3. 实体识别与关系抽取:规则模板搭底,模型做兜底
文本干净了,下一步是抽实体和关系。资源包里的实现没有依赖重型预训练模型,而是采用「词典 + 正则 + 词性窗口」的做法。这样做的直接好处是可解释、可增量、离线可跑。下面按三个小节把这条管线的细节拆开。
3.1 词典实体锚点 + 正则扩展:低成本定位人名、机构与术语
实体识别的第一步是先锚定已知实体,再对未知实体做模式补充。拿人名来说,中文人名模式通常是「2~3 字姓氏 + 1~2 字名字」,但仅靠这个正则会把「主任」「经理」这种职位误伤成名字。我的处理办法是:先跑词典精确匹配,再用正则作为召回补充,最后用词性过滤掉明显不是名词的结果。
import re PERSON_PATTERN = re.compile(r'[\u4e00-\u9fa5]{2,3}(?:先生|女士|教授|博士)?') COMPANY_SUFFIX = ['公司', '集团', '实验室', '研究所', '委员会'] def extract_entities(sentence: str) -> list: entities = [] # 1. 词典锚点:命中即实体 for word in domain_entities: if word in sentence: entities.append({'text': word, 'type': 'domain'}) # 2. 正则扩展:人名候选 for m in PERSON_PATTERN.finditer(sentence): # 过滤:只有确定的人名候选且旁边没有中文冒号 candidate = m.group() if '说' in sentence[max(0, m.start()-3):m.end()+3]: entities.append({'text': candidate, 'type': 'person'}) # 3. 机构后缀匹配 for suffix in COMPANY_SUFFIX: idx = 0 while True: idx = sentence.find(suffix, idx) if idx == -1: break start = sentence.rfind(',', 0, idx) + 1 entity = sentence[start:idx+len(suffix)] if len(entity) <= 12: entities.append({'text': entity, 'type': 'org'}) idx += len(suffix) return entities参数说明集中在两个地方。PERSON_PATTERN里的(?:先生|女士|教授|博士)?是可选的称谓后缀,加了它之后,匹配结果更接近完整的人名指称。但在做关系抽取时,我一般会把称谓剥掉,只保留核心名字,否则同一实体多种称谓会让图里出现大量孤立节点。COMPANY_SUFFIX列表建议按你的数据源定制:如果你处理的是医疗领域,可以把「医院」「中心」「科室」加进去。抽取结果去重时,注意文本完全相同的实体可以直接合并,但含义不同的同名实体要留给 3.3 节的对齐逻辑处理。
3.2 关系模板:用词性窗口挖出「X 认为 Y」这类事实三元组
实体识别只解决了「图里有什么节点」,关系抽取解决「节点之间怎么连」。资源包里默认使用基于词性序列的模板匹配:先做词性标注,再在标注序列里找「实体—谓词—实体」的局部模式。
import jieba.posseg as pseg RELATION_TEMPLATES = [ {'pattern': ['n', 'v', 'n'], 'relation': '相关'}, {'pattern': ['n', 'v', 'n', 'v'], 'relation': '参与'}, ] def extract_relations(sentence: str, entities: list) -> list: # 对句子做词性标注 words = pseg.cut(sentence) tagged = [(w.word, w.flag) for w in words] relations = [] for template in RELATION_TEMPLATES: plen = len(template['pattern']) for i in range(len(tagged) - plen + 1): window = [flag for _, flag in tagged[i:i+plen]] if window == template['pattern']: head, tail = tagged[i][0], tagged[i+plen-1][0] # 两端必须命中实体列表 if head in entities and tail in entities: relations.append((head, tail, template['relation'])) return relations这里有一个容易翻车的点:['n', 'v', 'n']会匹配到「制度(n)完善(v)公司(n)」这种主谓宾齐全的普通句子,但它并不是我们想要的事实关系。所以关系模板的价值不在于全覆盖,而在于高精度召回。我实际使用的模板会更多约束动词本身,比如限定为「认为、表示、指出、任命、投资、合作」这类高频业务动词。你可以把这组动词放在一个列表里,模板匹配时同时检查tagged[i+1][0]是否在该列表中。词性序列匹配完之后的(head, tail, relation)三元组,才是接下来要进入图结构的数据。
3.3 实体对齐与消歧:别名表和编辑距离把同一实体合到一起
这是整个管线里最容易被低估的一步。实体识别得到的是文本指称,不是真正意义上的实体。同一个机构在不同文章里可能叫「某集团」「某集团公司」「该集团」;同一个人可能前面带职位、后面不带。如果不对齐,知识图里会出现大量重复节点,图结构散成一盘沙。
from difflib import SequenceMatcher ALIAS_TABLE = { '某集团': '某集团有限公司', '某所': '某研究所', } def align_entity(entity: str) -> str: # 先查别名表 if entity in ALIAS_TABLE: return ALIAS_TABLE[entity] # 再查已有实体库做相似度匹配 best_match, best_score = None, 0.0 for known in known_entities: score = SequenceMatcher(None, entity, known).ratio() if score > best_score: best_score, best_match = score, known if best_score >= 0.82: return best_match # 否则作为新实体加入 known_entities.add(entity) return entity阈值0.82是经验值,来自我对某领域数据集的试跑:低于 0.8 会把「某平台」和「某评价平台」误并,高于 0.9 则漏掉「有限公司/股份有限公司」这种常见后缀差异。 SequenceMatcher 计算的是编辑相似度,适合处理「名字局部缺失」的场景,但对于「同名不同实」无能为力,那是语义消歧的范畴,规则管线里我一般不做,而是用上下文的机构修饰词做简单区分。对齐结果要保留一个映射表,后续增量构建时可以直接复用。
4. 图构建与 HTML 可视化:把三元组变成浏览器里能点的关系网
抽取结果还是线性列表,离「图」还差一步。知识图的构建不只是把三元组塞进数据库,还要考虑节点属性、边方向、权重设计。这章讲清楚从三元组到可视化之间发生了什么,为什么我最终选择把它导出成 HTML 而不是直接用图数据库。
4.1 NetworkX 建模:节点属性、边方向和权重设计
图结构最常用的建模工具是 NetworkX。它不需要额外部署服务,跑完脚本直接得到内存中的图对象。建立节点时我会给每个节点加type、mention_count两个属性;建立边时,关系出现的次数累积为边的权重。方向由关系模板决定,主语指向宾语。
import networkx as nx G = nx.DiGraph() def add_relation(head: str, tail: str, relation: str): # 节点不存在则创建 if not G.has_node(head): G.add_node(head, type='entity', mention_count=0) if not G.has_node(tail): G.add_node(tail, type='entity', mention_count=0) # 累加出现次数作为边权重 if G.has_edge(head, tail, relation=relation): G[head][tail]['weight'] += 1 else: G.add_edge(head, tail, relation=relation, weight=1) G.nodes[head]['mention_count'] += 1 G.nodes[tail]['mention_count'] += 1这里值得说明的是边的键设计。NetworkX 的DiGraph默认用(head, tail)标识一条边,如果同一对实体之间存在多种关系,后写的关系会覆盖前一条。我在add_edge里把relation作为边的属性,但注意在一个DiGraph里(head, tail)只能有一条边。对于多关系场景,要么拆成多个图,要么把关系列表存进边的属性,我后面选择的是后者:边属性relations存一个列表,weight是列表长度。这样可视化时悬浮提示可以展示全部关系,而不仅仅是一条。
4.2 序列化为 JSON:给 ECharts 的 graph 类型喂数据
图数据库的部署成本对这个项目来说偏重。我的落地选择是:NetworkX 算完图,直接导出为 JSON,交给前端 HTML 页面里的 ECharts 渲染。这样做的好处是零服务依赖,文件生成后双击就能看,也方便发给别人做只读浏览。
def export_graph(G, output_path='graph_data.json'): nodes = [] for nid, data in G.nodes(data=True): nodes.append({ 'id': nid, 'name': nid, 'symbolSize': 8 + min(40, data['mention_count'] * 2), 'category': data.get('type', 'entity'), }) edges = [] for u, v, data in G.edges(data=True): edges.append({ 'source': u, 'target': v, 'value': data['weight'], 'relation': data.get('relations', data.get('relation', '')) }) with open(output_path, 'w', encoding='utf-8') as f: json.dump({'nodes': nodes, 'edges': edges}, f, ensure_ascii=False, indent=2)导出 JSON 时的关键参数是symbolSize,它直接决定可视化的可读性。8 + min(40, mention_count * 2)的意思是节点大小与出现次数线性相关,但封顶到 48,防止高频实体霸屏。category字段用来让前端给不同实体类型上不同颜色。ensure_ascii=False这里必须写,否则中文字符会变成\uXXXX转义序列,前端能正常显示,但排查数据时非常痛苦。
4.3 静态 HTML 交互:搜索、过滤、悬浮看详情
可视化部分我用 ECharts 的graph类型。整套页面只有一个 HTML 文件,内嵌样式和脚本,通过 fetch 或直接内联的 JSON 读取图数据。静态页面也能做到基本交互:缩放、拖拽、悬浮显示关系、搜索定位。
<script> fetch('graph_data.json') .then(res => res.json()) .then(data => { var chart = echarts.init(document.getElementById('graph')); var option = { tooltip: { formatter: (p) => p.data.relation || p.data.name }, series: [{ type: 'graph', layout: 'force', roam: true, data: data.nodes, edges: data.edges, force: { repulsion: 300, edgeLength: 100 }, label: { show: true, fontSize: 12 } }] }; chart.setOption(option); }); </script>layout: 'force'会让 ECharts 自动做力导向布局,节点数量在 500 以内时交互很流畅,超过 2000 就要开roam配合缩放。repulsion: 300控制节点之间的斥力,值越大图越松散;edgeLength: 100是边长基线,关系紧密的社群会自动聚在一起。formatter函数里读的是我们上一步写入的relation和value,悬浮时能看到「A ——相关—— B (出现 12 次)」。搜索框可以根据实际情况再加,用 ECharts 的data.find配合chart.dispatchAction实现高亮。
5. 避坑排查:我在这条管线上实打实趟过的五个坑
规则管线看着简单,真正跑起来坑不少。下面五条是资源包调试过程中最常遇到的问题,每条我都按「现象 → 原因 → 解决」记录下来。
5.1 HTML 清理过度导致句子黏连
现象:输出的文本里长句特别多,一个「句子」超过 200 字,后面关系模板在一个超长句子上反复匹配,跑出几百条关系。 原因:HTML 里两个独立的<p>段落被get_text(separator=' ')拼成了同一条文本,分句正则又只在句号问号感叹号处切开,如果段落末尾恰好没有标点,两段内容就黏在一起。 解决:在clean_html收集节点时,用separator='\n'而不是空格,并且分句前先把连续空白统一成\n,让每个<p>天然成为句子候选边界。之后我再检查了一下split_sentences,把包含两个及以上换行符的文本强制切开。
5.2 模板把假设关系抽成了「确定事实」
现象:抽取结果里出现大量「A —相关— B」,但回看原文,原文写的是「A 可能与 B 相关」「A 或与 B 相关」,语气是猜测,不是事实。 原因:关系模板只匹配了词性序列n v n,没有排除「可能、或许、或将」这类情态副词。 解决:在模板匹配前加一个否定词/情态词过滤列表,窗口内出现「可能、也许、或许、或、不、未」等词时直接跳过该窗口。这会让召回下降一些,但知识图的准确率比召回重要得多,错误关系一旦进图,下游使用的人会被误导。
5.3 全等匹配毁掉了半数实体对齐
现象:手工抽检时发现「某集团」出现了 30 多次,但其中真正指同一个主体的只有 20 次,其余指的是「某集团旗下公司」。 原因:实体对齐只做了字符串全等匹配,「某集团」和「某集团旗下公司」被当成两个实体;同时「某集团」和「某集团股份有限公司」也被当成两个实体。 解决:抽取实体时同时记录实体的上下文限定词,比如前一个词是「旗下、所属、控股」时,把限定词并入实体文本。另外对齐阶段用后缀归一:把「股份有限公司」「有限公司」「公司」做成一个归一映射,统一归一成最短形式。注意这会牺牲一部分精度,所以归一映射要做成可配置的。
5.4 图过密:没有度数过滤,图根本没法读
现象:第一批图导出后,一眼望过去是一个巨大的毛线团,中心节点连着几百个叶子节点,完全看不出社群结构。 原因:所有实体都进了图。「某某中心」这种高频词出现上百次,但几乎没有区分度。 解决:导出 JSON 前做两次过滤。第一次过滤mention_count小于 3 的节点,它们是低频噪声;第二次过滤度小于 2 的叶子节点,并同步删除它们连着的边。过滤后的节点规模能降到原来的两成,图的可读性立刻上来了。如果后续还需要查被过滤的细节节点,保留原始 JSON 备份即可。
5.5 Python 2 时代的库在 Python 3 下的编码翻车
现象:本地跑通所有步骤,部署到另一台机器后,分句正则突然失效,jieba 加载词典报 UnicodeDecodeError。 原因:domain_words.txt是 UTF-8 编码,但对方机器的默认编码是 GBK 或 latin-1,jieba 读取时没有显式指定编码。 解决:所有涉及文本读写的环节,都用open(..., encoding='utf-8')显式声明。另外在脚本开头加os.environ['PYTHONIOENCODING'] = 'utf-8',避免控制台输出打印中文字符时被编码截断。这不算严格意义的库 bug,但 Python 3 在部分 Linux 发行版上的默认 locale 仍然是 C,坑过很多人。
6. 进阶收尾:给三元组加置信度,做增量构建与局部更新
静态图文件有一个天然劣势:文本源更新后,整张图需要重新生成。与其全量重跑,不如在抽取阶段就给每条三元组打上置信度和时间戳,让增量构建变成可控的局部更新。
def add_relation_with_confidence(G, data, confidence, timestamp): key = (data['head'], data['tail'], data['relation']) if key not in G.graph.get('triplet_index', {}): G.add_edge(data['head'], data['tail'], relation=data['relation'], weight=1) G.graph['triplet_index'][key] = {'confidence': confidence, 'first_seen': timestamp} else: info = G.graph['triplet_index'][key] info['confidence'] = max(info['confidence'], confidence) info['last_seen'] = timestamp这里的triplet_index是整个图的全局索引,不是单条边的属性。增量构建时,新文本跑完一轮抽取,直接用这个函数合并进已有图;如果新文本与旧文本存在同一条三元组,只更新置信度和时间戳,不重复加边。导出可视化时,置信度低于 0.7 的边用虚线样式显示,这样浏览者一眼能区分可靠事实和待验证关系。
验证时我常做的一件事,是随机抽 30 条边回原文工厂做人工核对。规则管线的优势这时候就显出来了:每一条抽取结果都记得住来自哪一段文本,只要在导出 JSON 时给边加一个source_sentence字段,前端悬浮提示里就能直接弹出原文。这比纯黑盒模型好用的多,至少出错了你知道去哪儿改。
从那以后,我每次跑完看新一批文本的增量结果,都强制自己先过一遍置信度低于阈值的虚线段,再决定要不要调整动词列表或模板窗口。知识图这个东西,规则不是越复杂越好,而是越能解释、越能追溯越好。希望这一套拆解思路和踩坑记录,能帮你在构造自己的知识图时少走几趟弯路。
本文还有配套的精品资源,点击获取