
简介面向Python开发者和NLP学习者的完整工程化实现解决从非结构化文本中自动抽取实体与关系并构建知识图谱的问题。方案覆盖中文分词、命名实体识别、关系抽取、图构建与可视化等关键环节适用于智能问答、推荐系统及信息抽取等场景。资源包共63个文件大小约605.62MB包含Python核心算法脚本、JS前端关系图展示、HTML可视化页面及项目配置与依赖数据整体结构完整便于直接运行与二次开发。已有597人学习/浏览。除核心模块外还提供句子解析、关键词抽取和图展示脚本附带测试样例和LTP数据处理包可验证各阶段输出并辅助调优。通过复用现成的封装函数能快速搭建知识图谱原型提升从海量文本中提取结构化信息的能力是学习NLP与知识图谱结合的实用参考资料。1. 文本转知识图谱为什么正则抽三元组注定翻车把一段中文文本变成一张能点击、能缩放的知识图谱核心不在画图而在怎么从一句话里稳定抽出实体, 关系, 实体三元组。很多人一上手就写正则去匹配“某某公司收购某某”“某某位于某某”换一种句式就全部失效。我拆的这份 text2graph 项目走的是另一条更耐打的路jieba 分词 哈工大 LTP 依存句法分析 TextRank 关键词抽取先解析出主谓宾结构用 networkx 组边最后用 ECharts 渲染成一个自包含的 HTML 文件。输入“深圳大学位于深圳”这类短句它就能输出[深圳大学, 位于, 深圳]并画成图。适合两类人刚接触知识图谱、想跑通全流程做课程设计的人以及手里有几百条短文本、想快速生成实体关系草图的从业者。2. 从句子到三元组sentence_parser 与 LTP 依存句法的读法2.1 LTP 模型加载分词、词性、依存弧三件套text2graph 里最核心的文件是 sentence_parser.py它负责把一条原始句子拆成机器能懂的语法结构。这里没有用 spaCy 也没有用 HanLP而是选了哈工大的 LTP原因很实际LTP 对中文依存句法的标注质量稳定而且模型文件是开箱即用的不需要联网拉预训练权重。项目根目录里的 ltp_data.zip 解压后就是模型包里面有 cws.model、pos.model、parser.model 三个文件分别对应分词、词性标注、依存句法分析。# sentence_parser.py 的核心调用骨架已精简 from pyltp import Segmentor, Postagger, Parser # 加载三件套模型路径按实际位置修改 segmentor Segmentor() segmentor.load(ltp_data/cws.model) # 分词模型 postagger Postagger() postagger.load(ltp_data/pos.model) # 词性标注模型 parser Parser() parser.load(ltp_data/parser.model) # 依存句法模型 def parse_sentence(text): # 分词 - 词性标注 - 依存句法顺序不能乱 words list(segmentor.segment(text)) postags list(postagger.postag(words)) arcs parser.parse(words, postags) # arcs 存放每个词的依存弧 result [] for i, arc in enumerate(arcs): # arc.head 是父节点索引从 1 开始0 表示根节点 # arc.relation 是依存关系标签如 SBV/VOB/ATT head_index arc.head - 1 head_word words[head_index] if head_index 0 else ROOT result.append((words[i], postags[i], head_word, arc.relation)) return result # 示例对深圳大学位于深圳这句话做依存分析 for item in parse_sentence(深圳大学位于深圳): print(item)这段代码逻辑不复杂但有两个参数要注意。pyltp的parse方法返回的每个 arc 包含head和relation两个属性head是 1-based 索引所以在取父节点词的时候要减 1根节点的head为 0减完变成 -1必须做一次边界判断否则必然 IndexError。模型加载完别急着扔进循环LTP 的 Segmentor 和 Postagger 都是可复用对象不要在每句话里重复 load 模型否则性能会惨不忍睹几百条文本就能让你等到怀疑人生。2.2 一条句子如何变成一组主谓宾三元组拿到了依存弧下一步就是从弧里挑出三元组。LTP 的依存关系标签里最有用的是SBV主谓关系和VOB动宾关系。一个典型的“主语-谓语-宾语”句式在依存树上的表现是核心动词作为父节点主语通过 SBV 弧挂到它下面宾语通过 VOB 弧挂到它下面。所以抽取逻辑就变成两步先找出所有 SBV 弧确定主语和谓语再拿这个谓语去找它的 VOB 宾语。def extract_triples(words, postags, arcs): triples [] # 第一遍收集所有 SBV 主语记录 (主语, 谓语) sbv_pairs [] for i, arc in enumerate(arcs): if arc.relation SBV: sbv_pairs.append((words[i], words[arc.head - 1], arc.head - 1)) # 第二遍对每个谓语找 VOB 宾语 for subj, pred, pred_idx in sbv_pairs: for i, arc in enumerate(arcs): if arc.relation VOB and arc.head - 1 pred_idx: triples.append((subj, pred, words[i])) return triples这段抽取逻辑看着简单但它已经能覆盖相当一部分中文陈述句。我在实际测试里发现像“北京是中国的首都”“华为发布新款手机”这类句子LTP 基本都能正确标出 SBV 和 VOB。你如果想把范围扩到“事件-发生于-地点”这种结构可以再补一条规则找ADV或POB弧把介词宾语也拉进来。但别一开始就贪多先把主谓宾跑通再看数据里哪些句式频次高针对性加规则。关系抽取的玄学部分就在这里规则越多冲突越难查我一般会控制在十几种关系以内。解析完记得把三元组去重同一个实体对出现多次只保留一条边用频率做权重。3. 关键词召回keywords_textrank 的 TextRank 实现与词表取舍3.1 窗口共现与边权计算文本里不是所有词都配当知识图谱的节点像“进行”“一个”“我们”这类词进图只会把画面糊成一片。项目里的 keywords_textrank.py 就是干这个的用 TextRank 算法从分词结果里筛出 TopN 关键词只让这些词参与建图。TextRank 和 PageRank 是一家人核心思想是“一个词的重要程度由指向它的其他重要词决定”具体到实现上就是滑动窗口共现。# keywords_textrank.py 的 TextRank 构建逻辑核心片段 from collections import defaultdict import math def build_word_graph(words, window5): # 共现矩阵两个词在同一个窗口内出现就建立一条边 co_occur defaultdict(lambda: defaultdict(int)) for i in range(len(words)): for j in range(i 1, min(i window, len(words))): # 跳过停用词和单字虚词这里只做基础过滤 if len(words[i]) 1 and len(words[j]) 1: co_occur[words[i]][words[j]] 1 co_occur[words[j]][words[i]] 1 return co_occur def textrank(co_occur, damping0.85, max_iter200, tol1e-4): # 经典 PageRank 迭代公式WS(Vi) (1-d) d * sum( Wji / sum(Wjk) * WS(Vj) ) nodes list(co_occur.keys()) scores {w: 1.0 for w in nodes} for _ in range(max_iter): new_scores {} for w in nodes: total 0.0 for neigh, weight in co_occur[w].items(): # 分母是邻居节点所有出边权重之和 neighbor_sum sum(co_occur[neigh].values()) if neighbor_sum 0: total weight / neighbor_sum * scores[neigh] new_scores[w] (1 - damping) damping * total # 判断收敛所有节点得分变化小于阈值就提前结束 diff sum(abs(new_scores[w] - scores[w]) for w in nodes) scores new_scores if diff tol: break return sorted(scores.items(), keylambda x: x[1], reverseTrue)两个超参数需要根据语料调。window是共现窗口默认 5 对中文短句比较合适窗口太大把不相关的词全连在一起太小又切断了语义关联damping保持 0.85 就好这是 PageRank 家族的标准值调它收益很低。max_iter给 200配合tol1e-4的收敛判断大多数情况下几十轮就停了。这套实现比直接调 jieba.analyse.textrank 的好处是你能看到中间结果知道哪些词是因为共现被推上来的排查“为什么抽出一个莫名奇妙的词”时方便得多。3.2 停用词表、单字过滤与实体白名单TextRank 抽出来的词只代表“重要”不代表“适合当实体”。项目实际跑起来你会发现光靠窗口共现过滤是不够的还要在前后各加一道闸门。前面过滤停用词和单字虚词后面做实体白名单校验比如“位于”“进行”这种词虽然 TextRank 得分不低但放进图谱就是噪声。注意停用词表不要用网上通用的百行大表那会把“发展”“问题”这种业务词也干掉。正确做法是先跑一遍全量语料把出现频率最高的一批词拉出来人工过一眼挑出真正的虚词加进停用表。我拆解这份代码时发现作者很聪明地把 TextRank 的结果和词性标注结果做了交叉只有名词、动词、形容词中的词才允许进候选集专有名词ns、nh 词性直接放行进白名单。这个策略对中文特别有效因为人名地名是 LTP 词性标注里最准的一类。如果你想把关键词从 5 个调到 10 个记得同时调整后端的节点过滤阈值否则图谱会突然多出一圈边节点一多 ECharts 渲染就会卡。4. 建图与可视化networkx 组边 ECharts 前端渲染4.1 三元组装进 DiGraph节点频度与边权三元组是平的要变成图谱得先建图结构。项目里用的是 networkx 的 DiGraph 有向图因为“深圳大学位于深圳”和“深圳位于深圳大学”是完全相反的两条知识不能用无向图抹掉方向。同时用词频调整节点大小用同一条关系出现的次数调整边权重这是把静态数据变成可读图谱的关键一步。# text_grapher.py 中建图与转图的逻辑核心片段 import networkx as nx def build_graph(triples): g nx.DiGraph() for subj, rel, obj in triples: # 节点不存在则自动创建重复 add 会累加词频 if g.has_edge(subj, obj): g[subj][obj][weight] 1 else: g.add_edge(subj, obj, labelrel, weight1) return g def graph_to_echarts(g): # 节点大小与度数挂钩10 8 * degree # 这样核心实体一眼就能看出来 nodes [ {name: n, symbolSize: 10 8 * g.degree(n)} for n in g.nodes ] links [ {source: u, target: v, label: d[label], value: d[weight]} for u, v, d in g.edges(dataTrue) ] return {nodes: nodes, links: links}networkx 在这里只是中间态真正的渲染交给 ECharts。所以把 networkx 的图对象转成 nodes/links 两个 JSON 数组是关键一步。symbolSize用10 8 * degree而非直接degree是为了避免孤立节点小到看不见边上的label存的是关系名。ECharts 的 graph 系列需要的就是这两个数组剩下布局它自己算。如果你发现某个节点箭头方向不对先检查 DiGraph 是不是被 networkx 转成了无向图——我在调试时踩过这个坑nx.DiGraph和nx.Graph混用后边方向会莫名消失。4.2 GraphShow 生成自包含 HTML 的渲染管线可视化部分由 GraphShow.py 负责它把上一步生成的 JSON 灌进一个 HTML 模板最终输出 graph_show.html。这个 HTML 是自包含的ECharts 的 JS 直接内嵌在文件里所以双击就能打开不用起本地服务。项目根目录的 echarts-optimize-conf.js 是 ECharts 的按需打包配置只保留了 graph 和 force 相关模块这也是为什么整个 HTML 体积能压得住。# GraphShow.py 的渲染管线核心片段 import json def render_html(graph_data, output_pathgraph_show.html): # 从模板文件读取 HTML 骨架把数据以 JSON 形式注入 with open(template.html, encodingutf-8) as f: template f.read() # dumps 时不转义中文保证 HTML 里直接可见方便调试 data_json json.dumps(graph_data, ensure_asciiFalse, indent2) html template.replace(__GRAPH_DATA__, data_json) with open(output_path, w, encodingutf-8) as f: f.write(html)模板里关键的配置是 ECharts 的force布局参数repulsion控制节点间的斥力默认值 300 适合节点数在 50 以内的场景节点太多就要调大斥力并缩小edgeLength否则整个图会挤成一团。另外ECharts 的categories可以把人名、地名、机构分类上色但这个项目里没有做这个概念分类所以颜色是随机分配的。想加分类色需要在 graph_to_echarts 阶段给每个节点打上 category 标签模板里再配一个 category 列表改动量不大值得一试。5. 避坑路径、模型、版本与编码的五个真实翻车点5.1 pyltp 版本和 ltp_data 模型对不上进程直接崩现象模型加载时没有任何报错但一旦调用segmentor.segment()进程就直接崩溃退出连异常信息都不给。原因pyltp 的版本和 ltp_data 模型的版本必须严格匹配。旧版 pyltp 0.2.x 配的是ltp_data_v3.4.0新版 pyltp 3.x 配的是ltp_data两个模型文件的格式不兼容加载时虽然不报错但实际解析时会触发底层 C 段的非法访问。解决先pip show pyltp看清版本再去哈工大 LTP 官方模型库下对应版本。项目里既然给了 ltp_data.zip就优先用包里的别手痒去下最新的。5.2 Python 3.6 的 .pyc 在高版本解释器上直接爆炸现象在 Python 3.8 或更高版本的环境里 import sentence_parser 时报Bad magic number in .pyc file或ValueError: source code string cannot contain null bytes。原因项目里带了__pycache__目录里面有sentence_parser.cpython-36.pyc这些字节码是 Python 3.6 编译的pyc 文件头部的 magic number 和 3.8 不一致解释器拒绝加载。解决把项目里的__pycache__整个删掉让 Python 重新生成对应版本的新 pyc。不用改代码但如果不删干净不同版本解释器之间反复切换会不停踩这个坑。5.3 Windows 下相对路径定位不到 ltp_data模型加载静默失败现象代码里写segmentor.load(ltp_data/cws.model)直接运行代码时load返回 False或者报[ERROR] Failed to load model。但在 PyCharm 里点运行却好好的。原因PyCharm 会把项目根目录自动加进工作路径而命令行跑脚本时当前工作目录是终端所在目录。相对路径是相对于工作目录解析的不是脚本文件所在目录所以一换启动方式就找不到模型。解决把模型路径改成基于__file__的绝对路径Path(__file__).parent / ltp_data / cws.model。这个改动虽然只多了几行但能杜绝九成以上的路径问题。5.4 Windows 下文本编码混乱输出全是乱码或直接 UnicodeDecodeError现象读取待解析的 txt 文件时报UnicodeDecodeError: gbk codec cant decode byte。或者最终生成 graph_show.html 后打开页面所有中文字都变成一排问号。原因Windows 上open()不指定编码时默认走 GBK而项目里的文本和模板基本都是 UTF-8两边编码打架。写 HTML 时同样的问题文件被以 GBK 编码写出浏览器按 UTF-8 解析就全乱。解决所有open()统一显式写encodingutf-8。注意写 CSV 给 Excel 用时要反过来用utf-8-sig会多一个 BOM 头Excel 才不会把中文识别成乱码。这个细节在我后来对接数据导出时救了大命。5.5 短句子抽不出三元组白板上只有一个孤立节点现象输入“今天天气不错”“我爱 Python”这类短句三元组列表是空的图谱上只有一个孤零零的节点。原因LTP 对无宾语、无主语的句子不会产出 SBV 和 VOB 弧规则自然落空。这不是 bug是规则抽取的天然边界任何基于依存句法的抽取器都对口语化短句无能为力。解决加一层兜底规则——如果整个文本的三元组数量为 0就把 TextRank 关键词两两配对用“关联”作为默认关系建边保证图谱能看到实体聚集结构同时记录这些兜底边后续人工审核时重点排查。更好地是跳过那些抽不出 SBV/VOB 的句子宁缺毋滥。6. 进阶把三元组导出为 Neo4j 导入 CSV方便后续做复杂图查询HTML 图谱适合给人看不适合给机器查。真要用图谱做推理或问答就得把三元组导进图数据库。Neo4j 的批量导入有一套自己的 CSV 格式约定节点文件和关系文件分开字段名不能乱起。我通常会在 text_grapher.py 里加一个导出函数直接生成符合neo4j-admin import规范的 CSV。# 导出 Neo4j 批量导入所需的 CSV import csv def export_neo4j_csv(triples, node_filenodes.csv, rel_filerels.csv): nodes {} rels [] for subj, rel, obj in triples: # 节点去重label 字段是节点显示名name 是唯一标识 nodes.setdefault(subj, {name: subj, label: subj}) nodes.setdefault(obj, {name: obj, label: obj}) rels.append({start: subj, end: obj, type: rel}) # 节点文件头两列是 name 和 label必须带 :ID 标记 with open(node_file, w, encodingutf-8, newline) as f: writer csv.writer(f) writer.writerow([name:ID, label]) for n in nodes.values(): writer.writerow([n[name], n[label]]) # 关系文件START_ID 和 END_ID 是端点:TYPE 是关系类型 with open(rel_file, w, encodingutf-8, newline) as f: writer csv.writer(f) writer.writerow([START_ID, END_ID, TYPE]) for r in rels: writer.writerow([r[start], r[end], r[type]])导出后执行 Neo4j 的批量导入命令时节点文件和关系文件的路径、分隔符都要和 CSV 头对齐而且导入的数据库必须是空库不能对已有数据的库直接跑。从那以后我每次跑新语料都强制走一遍“先数三元组总数再导出 CSV最后导入 Neo4j”的三步流程确认数量对得上才继续这个习惯帮我挡了好几次返工。希望帮到你。本文还有配套的精品资源点击获取