简介这是一套从非结构化文本自动构造知识图的端到端管道名为 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、h1h3、li这类正文标签其余大多是导航和脚本。常见做法是用 BeautifulSoup 先做一轮标签白名单过滤再做一轮黑名单剔除。下面这段是我在资源包里默认使用的清洗脚本核心部分。from bs4 import BeautifulSoup import re def clean_html(raw_html: str, keep_tagsNone) - 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 , stripTrue) 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:idxlen(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:iplen]] if window template[pattern]: head, tail tagged[i][0], tagged[iplen-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[i1][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, typeentity, mention_count0) if not G.has_node(tail): G.add_node(tail, typeentity, mention_count0) # 累加出现次数作为边权重 if G.has_edge(head, tail, relationrelation): G[head][tail][weight] 1 else: G.add_edge(head, tail, relationrelation, weight1) 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_pathgraph_data.json): nodes [] for nid, data in G.nodes(dataTrue): 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(dataTrue): edges.append({ source: u, target: v, value: data[weight], relation: data.get(relations, data.get(relation, )) }) with open(output_path, w, encodingutf-8) as f: json.dump({nodes: nodes, edges: edges}, f, ensure_asciiFalse, indent2)导出 JSON 时的关键参数是symbolSize它直接决定可视化的可读性。8 min(40, mention_count * 2)的意思是节点大小与出现次数线性相关但封顶到 48防止高频实体霸屏。category字段用来让前端给不同实体类型上不同颜色。ensure_asciiFalse这里必须写否则中文字符会变成\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); }); /scriptlayout: 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-1jieba 读取时没有显式指定编码。 解决所有涉及文本读写的环节都用open(..., encodingutf-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], relationdata[relation], weight1) 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字段前端悬浮提示里就能直接弹出原文。这比纯黑盒模型好用的多至少出错了你知道去哪儿改。从那以后我每次跑完看新一批文本的增量结果都强制自己先过一遍置信度低于阈值的虚线段再决定要不要调整动词列表或模板窗口。知识图这个东西规则不是越复杂越好而是越能解释、越能追溯越好。希望这一套拆解思路和踩坑记录能帮你在构造自己的知识图时少走几趟弯路。本文还有配套的精品资源点击获取