知识图谱构建实战:从数据采集到实体关系抽取的完整流程与代码实现
1. 项目概述:从零构建知识图谱的数据基石
“知识图谱实战(一):数据采集与实体关系抽取(完整代码)”这个标题,对于任何一个想动手构建自己知识图谱的开发者来说,都充满了吸引力。它直指了知识图谱构建流程中最关键、也最耗费精力的第一步:数据从哪里来,以及如何从原始数据中提炼出结构化的知识。我见过太多项目,模型设计得很精巧,但最终效果却差强人意,究其根源,往往是在数据采集和实体关系抽取这两个环节上“埋了雷”。
简单来说,这个项目要解决的核心问题,就是如何将互联网上或企业内部那些非结构化的文本(比如新闻、报告、百科页面),或者半结构化的数据(比如表格、JSON),转化为一张由“实体-关系-实体”构成的知识网络。这听起来像是自然语言处理(NLP)的经典任务,但实战中远不止调用几个API那么简单。它涉及到数据源的稳定性评估、不同抽取策略的权衡、以及如何处理抽取结果中的噪声和冲突。适合的读者包括:有一定Python基础,对NLP和知识图谱感兴趣的数据工程师、算法工程师,以及任何希望将杂乱信息体系化的技术爱好者。
接下来的内容,我会以一个具体的领域——比如“科技人物与公司关系图谱”为例,带你完整走一遍从数据抓取到关系抽取的实战流程,并提供可以直接运行的代码。你会发现,真正的难点不在于代码怎么写,而在于面对真实、复杂、多变的数据时,如何设计一个鲁棒、可扩展的流水线。
2. 整体设计与核心思路拆解
在动手写第一行代码之前,我们必须把整个流水线的设计思路理清楚。一个糟糕的设计会导致后期维护成本激增,甚至推倒重来。
2.1 需求定义与数据源规划
我们的目标是构建一个“科技人物与公司关系图谱”。这个图谱的“骨架”由两类节点(实体)和一类边(关系)构成:
-
实体类型
:
- 人物 :如“马斯克”、“黄仁勋”。
- 公司/组织 :如“特斯拉”、“英伟达”、“OpenAI”。
-
关系类型
:
- 任职于 :连接“人物”与“公司”,表示人物在该公司担任职务(如创始人、CEO、工程师)。
数据从哪里来?我们优先选择信息结构化程度高、相对权威的源。对于中文领域, 百度百科 是一个不错的起点。它页面结构相对统一,包含信息框(InfoBox),这为我们后续的抽取工作降低了难度。当然,单一数据源存在偏见和覆盖不全的问题,成熟的系统会融合多个源(如维基百科、新闻、招股书),但作为入门实战,我们先聚焦于一个源,把流程打通。
注意 :任何数据采集行为都必须严格遵守网站的
robots.txt协议,控制请求频率,避免对目标服务器造成压力。本实战示例仅用于技术学习,请务必尊重数据版权和网站的使用条款。
2.2 技术栈选型与考量
为什么选择以下技术栈?这是基于效率、社区支持和与后续环节的衔接度综合考量的结果。
-
数据采集层 :
- Requests + BeautifulSoup4 :这是Python生态中最经典、最轻量的静态网页抓取组合。Requests负责HTTP请求,BeautifulSoup负责解析HTML。对于百度百科这类无需复杂JavaScript渲染的页面,这个组合完全够用,且学习成本低。
- 备用方案:Selenium/Playwright :如果目标页面数据是通过JS动态加载的,则需启用这些浏览器自动化工具。但它们更重、更慢。我们的策略是:优先用轻量级方案,遇到问题再升级。
-
实体关系抽取层 :
- 基于规则与词典的抽取 :针对百科信息框这种半结构化数据,规则方法(正则表达式、XPath)准确率极高,且速度飞快。这是我们的首选。
- 基于预训练模型的深度学习抽取 :用于处理纯文本描述段落。我们选用 HanLP 或 LTP 这类开源中文NLP工具包。它们提供了训练好的命名实体识别(NER)和关系抽取(RE)模型。相比于从零训练BERT模型,使用这些工具包能让我们快速搭建可用的原型。
- 为什么不用纯深度学习方案? 因为成本。标注高质量的关系抽取数据非常困难,且模型训练和推理成本高。在实战中,**“规则优先,模型补充”**是性价比最高的策略。先用规则保证高置信度数据的获取,再用模型从自由文本中挖掘潜在关系。
-
数据存储与流水线 :
- JSON/TXT中间存储 :在抽取过程中,将原始页面、清洗后的文本、抽取的初步结果按步骤保存下来。这便于调试和回溯,避免因程序中断而前功尽弃。
- Neo4j(后续) :这是专门为图数据设计的数据库,非常适合存储和查询我们最终产生的“实体-关系”三元组。但在本阶段(数据采集与抽取),我们暂时将结果保存为结构化的文件(如CSV),为后续导入Neo4j做准备。
整个流水线的设计思路是模块化的:数据采集模块只管抓取和保存原始数据;数据解析模块负责从原始数据中提取出纯文本和信息框;抽取模块则应用规则和模型,产出最终的三元组。各模块之间通过文件或简单数据结构耦合,方便独立测试和替换。
3. 核心模块一:数据采集与页面解析
这是所有工作的源头,必须保证稳定和可重复。
3.1 定向爬虫编写与防屏蔽策略
我们以抓取“马斯克”的百度百科页面为例。首先,观察页面URL规律:
https://baike.baidu.com/item/马斯克
。人名就是URL的一部分。
import requests
from bs4 import BeautifulSoup
import time
import random
import json
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
def fetch_baike_page(keyword):
"""
抓取百度百科词条页面
:param keyword: 词条关键词,如 '马斯克'
:return: 页面的HTML文本
"""
url = f'https://baike.baidu.com/item/{keyword}'
try:
# 添加随机延迟,模拟人类行为
time.sleep(random.uniform(1, 3))
response = requests.get(url, headers=HEADERS, timeout=10)
response.raise_for_status() # 检查请求是否成功
# 百度百科默认编码是utf-8,但有时会乱码,这里用apparent_encoding更稳健
response.encoding = response.apparent_encoding
return response.text
except requests.RequestException as e:
print(f"抓取 {keyword} 失败: {e}")
return None
# 测试抓取
html_content = fetch_baike_page('马斯克')
if html_content:
print("页面抓取成功,长度:", len(html_content))
关键策略与避坑点 :
- User-Agent :务必设置,否则可能被简单的反爬机制拦截。
-
随机延迟
:
time.sleep(random.uniform(1, 3))是必须的。高频访问是触发封禁的最主要原因。 -
编码处理
:
response.apparent_encoding比直接设置utf-8更可靠,它能自动推断编码。 -
异常处理
:网络请求可能失败,必须用
try...except包裹,并记录日志,避免单个页面失败导致整个程序崩溃。
3.2 页面结构解析与关键信息定位
拿到HTML后,我们需要从中提取两部分核心内容: 百科信息框 和 正文文本 。
def parse_baike_page(html):
"""
解析百度百科页面,提取信息框和正文文本
:param html: 页面HTML
:return: 字典,包含‘title’,‘summary’,‘infobox’(字典格式),‘text’(正文纯文本)
"""
if not html:
return None
soup = BeautifulSoup(html, 'html.parser')
result = {'title': '', 'summary': '', 'infobox': {}, 'text': ''}
# 1. 提取标题
title_tag = soup.find('dd', class_='lemmaWgt-lemmaTitle-title').find('h1')
if title_tag:
result['title'] = title_tag.get_text().strip()
# 2. 提取摘要 (lemma-summary)
summary_tag = soup.find('div', class_='lemma-summary')
if summary_tag:
result['summary'] = summary_tag.get_text().strip()
# 3. 提取信息框 (basicInfo cmn-clearfix)
# 百度百科的信息框是一个dl列表,每个dt是属性名,dd是属性值
infobox = {}
info_div = soup.find('div', class_='basic-info cmn-clearfix')
if info_div:
dl_list = info_div.find_all('dl')
for dl in dl_list:
dt_tags = dl.find_all('dt')
dd_tags = dl.find_all('dd')
for dt, dd in zip(dt_tags, dd_tags):
key = dt.get_text().strip().replace('\xa0', ' ')
value = dd.get_text().strip().replace('\xa0', ' ').replace('\n', ' ')
if key and value:
infobox[key] = value
result['infobox'] = infobox
# 4. 提取主要正文文本 (para-list)
# 只提取文本内容,忽略引用、图表等
main_content = soup.find('div', class_='main-content')
if main_content:
# 找到所有段落
para_tags = main_content.find_all('div', class_='para')
full_text = ' '.join([para.get_text().strip() for para in para_tags if para.get_text().strip()])
# 简单清洗:合并多个空格和换行
import re
full_text = re.sub(r'\s+', ' ', full_text)
result['text'] = full_text
return result
# 测试解析
if html_content:
parsed_data = parse_baike_page(html_content)
print(f"标题: {parsed_data['title']}")
print(f"摘要: {parsed_data['summary'][:100]}...") # 打印前100字符
print("信息框示例:", list(parsed_data['infobox'].items())[:3]) # 打印前3项
print(f"正文长度: {len(parsed_data['text'])} 字符")
实操心得 :
-
Class选择器
:百度百科的HTML结构会变化,但核心内容的class名相对稳定。
lemma-summary,basic-info cmn-clearfix,main-content,para是几个关键的选择器。如果未来失效,需要重新审查页面结构。 -
信息框解析
:信息框是
规则抽取的黄金地带
。它的结构是
<dt>属性名</dt><dd>属性值</dd>,非常规整。我们将其解析为Python字典,后续可以直接用键(如“主要成就”、“出生地”)来获取值。 -
正文提取
:我们只提取了
para标签内的文本,这已经包含了绝大部分描述性内容。更精细的处理可以过滤掉参考文献标记(如[1]),但作为关系抽取的输入,暂时可以保留。
3.3 数据存储与流水线管理
我们不能每次测试都重新抓取页面。需要将抓取和解析的结果持久化。
import os
DATA_DIR = './baike_data'
os.makedirs(DATA_DIR, exist_ok=True)
def save_page_data(keyword, parsed_data):
"""将解析后的数据保存为JSON文件"""
if not parsed_data:
return
filename = os.path.join(DATA_DIR, f"{keyword}.json")
with open(filename, 'w', encoding='utf-8') as f:
json.dump(parsed_data, f, ensure_ascii=False, indent=2)
print(f"数据已保存至: {filename}")
def load_page_data(keyword):
"""从JSON文件加载数据"""
filename = os.path.join(DATA_DIR, f"{keyword}.json")
if os.path.exists(filename):
with open(filename, 'r', encoding='utf-8') as f:
return json.load(f)
else:
print(f"文件不存在: {filename}")
return None
# 整合:抓取、解析、保存
def crawl_and_save(keyword_list):
for kw in keyword_list:
print(f"正在处理: {kw}")
html = fetch_baike_page(kw)
if html:
data = parse_baike_page(html)
if data:
save_page_data(kw, data)
# 每个词条处理完后,等待稍长时间
time.sleep(random.uniform(3, 5))
# 示例:抓取一批科技人物和公司
initial_keywords = ['马斯克', '黄仁勋', '特斯拉', '英伟达', 'OpenAI', '萨蒂亚·纳德拉', '微软']
# 首次运行时执行抓取
# crawl_and_save(initial_keywords)
# 后续调试直接加载
# data = load_page_data('马斯克')
这种设计使得数据采集层与后续的抽取层完全解耦。抽取代码只需要读取本地的JSON文件即可,无需反复请求网络,极大提升了开发调试效率。
4. 核心模块二:基于规则的信息框抽取
信息框是高度结构化的数据,用规则抽取准确率接近100%,应作为我们获取三元组的首要途径。
4.1 设计实体与关系的映射规则
我们需要制定一套规则,将信息框中的“键-值对”映射到我们定义的“实体-关系-实体”三元组上。
观察“马斯克”页面的信息框,我们可能看到:
-
{“中文名”: “埃隆·马斯克”, “外文名”: “Elon Musk”, “国籍”: “美国”, “出生地”: “南非比勒陀利亚”, “主要成就”: “SpaceX创始人、特斯拉CEO” ...}
我们的目标是抽取出
(埃隆·马斯克, 任职于, SpaceX)
和
(埃隆·马斯克, 任职于, 特斯拉)
。
规则设计如下:
-
实体识别
:当前页面的标题(
parsed_data[‘title’])是核心实体(Subject)。 - 关系触发 :扫描信息框字典的每一个“值”(Value)。
- 公司名称模式匹配 :在“值”中,寻找可能包含公司名的模式。例如,“XXX创始人”、“YYY CEO”、“ZZZ联合创始人”、“前AAABBB工程师”。
- 公司实体提取 :一旦匹配到模式,使用正则表达式或简单的字符串分割,提取出公司名(Object)。
- 关系确认 :关系类型固定为“任职于”。(更精细的可以区分“创始人”、“CEO”、“工程师”等子关系,这里我们先统一)。
import re
def extract_relations_from_infobox(parsed_data):
"""
从百科信息框中抽取(人物,任职于,公司)关系
:param parsed_data: parse_baike_page 返回的字典
:return: 三元组列表 [(subject, relation, object), ...]
"""
triples = []
subject = parsed_data['title'] # 核心实体,例如“马斯克”
infobox = parsed_data['infobox']
# 定义可能触发“任职于”关系的键,以及从中提取公司名的模式
# 这些键是经验性的,可能需要根据数据扩充
relevant_keys = ['主要成就', '职务', '职业', '任职于', '创始人']
for key in relevant_keys:
if key in infobox:
value = infobox[key]
# 模式1: “XXX创始人” 或 “XXX的创始人”
patterns = [
r'([^、,,]+)(?:公司)?(?:的)?创始人', # 匹配“特斯拉创始人”、“SpaceX的创始人”
r'([^、,,]+)(?:公司)?(?:的)?CEO', # 匹配“特斯拉CEO”
r'([^、,,]+)(?:公司)?(?:的)?首席执行官',
r'([^、,,]+)(?:公司)?(?:的)?董事长',
r'([^、,,]+)(?:公司)?(?:的)?工程师', # 匹配“前PayPal工程师”
r'([^、,,]+)(?:公司)?(?:的)?总裁',
]
for pattern in patterns:
matches = re.findall(pattern, value)
for company in matches:
company = company.strip()
# 简单的清洗:去掉可能的前缀如“前”
if company.startswith('前'):
company = company[1:].strip()
if company and len(company) > 1: # 过滤掉过短的噪声
# 去重检查
if (subject, '任职于', company) not in triples:
triples.append((subject, '任职于', company))
# 额外规则:有时公司名直接出现在“任职于”键下
if '任职于' in infobox:
companies = re.split(r'[、,,;;]', infobox['任职于'])
for company in companies:
company = company.strip()
if company and (subject, '任职于', company) not in triples:
triples.append((subject, '任职于', company))
return triples
# 测试规则抽取
data = load_page_data('马斯克')
if data:
relations = extract_relations_from_infobox(data)
print("从信息框中抽取的关系:")
for sub, rel, obj in relations:
print(f" ({sub}, {rel}, {obj})")
运行上述代码,对于“马斯克”页面,我们很可能抽取出
(马斯克, 任职于, SpaceX)
和
(马斯克, 任职于, 特斯拉)
。
4.2 规则系统的优化与维护
规则抽取很快,但维护成本是随着领域变化而增长的。以下是几个优化方向:
-
公司名归一化 :抽出来的“特斯拉”和“特斯拉汽车”可能指向同一实体。我们需要一个“公司别名映射表”来进行归一化。这个表可以手动维护,也可以通过后续的实体链接技术来完善。
company_alias_map = { '特斯拉': '特斯拉', '特斯拉汽车': '特斯拉', 'Tesla': '特斯拉', 'SpaceX': 'SpaceX', '太空探索技术公司': 'SpaceX', # ... }在存入三元组前,将
object通过这个映射表进行转换。 -
规则的可配置化 :将正则表达式模式和相关的信息框键名写入一个配置文件(如YAML或JSON),而不是硬编码在函数里。这样,当需要适配新的领域(如“影视明星与作品”)时,只需修改配置文件,无需改动核心代码。
-
多值处理 :信息框的值中经常用顿号、逗号分隔多个项目。我们的代码已经用
re.split做了简单处理,但对于更复杂的中文并列结构(如“A、B和C”),需要更精细的分词或规则。
踩坑记录 :规则抽取最大的敌人是 数据格式的不一致性 。不同编辑者编辑的百科页面,信息框的键名可能略有不同(如“主要成就” vs “重大成就”)。因此,规则系统需要一定的模糊匹配和容错能力,并且要准备一个“未匹配键”的日志,用于定期审查和补充规则。
5. 核心模块三:基于模型的文本关系抽取
信息框虽然精准,但覆盖的关系有限。大量的隐含关系(比如“马斯克收购了Twitter”)藏在正文描述文本中。这时就需要借助深度学习模型。
5.1 使用预训练工具包进行实体识别与关系抽取
我们以
HanLP
为例,因为它提供了开箱即用的中文NER和RE功能。首先安装:
pip install hanlp
HanLP提供了多种模型,我们使用其推荐的轻量级联合模型进行演示。
# 注意:首次运行会自动下载模型,可能需要几分钟,请保持网络通畅
import hanlp
def extract_relations_from_text_with_hanlp(text, subject_entity):
"""
使用HanLP从纯文本中抽取与特定主体实体相关的关系
:param text: 输入文本
:param subject_entity: 我们关注的主体实体(如‘马斯克’)
:return: 三元组列表
"""
# 加载预训练模型(这是一个管道,包含分词、词性标注、NER、句法分析等)
# 使用‘close’分词,以及一个联合模型进行语义角色标注(SRL),SRL可以用于关系抽取
HanLP = hanlp.pipeline() \
.append(hanlp.utils.rules.split_sentence, output_key='sentences') \
.append(hanlp.load('COARSE_ELECTRA_SMALL_ZH'), output_key='tok') \
.append(hanlp.load('CTB9_CON_ELECTRA_SMALL'), output_key='con') \
.append(hanlp.load('SEMEVAL16_NEWS_BIAFFINE_ZH'), output_key='srl')
triples = []
# 1. 先分句,避免长文本超出模型限制
sentences = hanlp.utils.rules.split_sentence(text)
for sent in sentences:
# 只处理包含主体实名的句子,提高效率
if subject_entity in sent:
try:
# 执行模型预测
doc = HanLP(sent)
# doc['srl'] 包含了语义角色标注结果,可以解析出谓词和论元(关系)
# 这里是一个简化的示例:我们寻找句子中与subject_entity相关的动词(谓词)和宾语(对象)
# 实际应用中,需要根据SRL的复杂输出结构来设计解析逻辑
tokens = doc['tok']
srl_results = doc['srl']
# 简化处理:这里打印出SRL结果,展示其结构
# 在实际项目中,你需要编写代码来遍历srl_results,将‘ARG0’(施事者)匹配subject_entity,
# 然后提取‘V’(谓词)和‘ARG1’(受事者)等,组合成关系。
# 例如,对于句子“马斯克创立了SpaceX”,SRL可能输出:谓词=“创立”,ARG0=“马斯克”,ARG1=“SpaceX”
# 我们可以将(马斯克,创立,SpaceX)映射为(马斯克,任职于(创始人),SpaceX)
# 此处为演示,我们用一个更简单但粗糙的方法:使用依存句法分析
con = doc['con']
# 遍历依存弧,寻找以主体实体为支配词或从属词的关系
# 这是一个启发式方法,效果有限,但易于理解
for arc in con.arcs:
governor = tokens[arc.head]
dependent = tokens[arc.head + arc.relation]
# 一个非常简单的规则:如果支配词或从属词是主体,且另一个词看起来像组织名(简单判断)
# 这需要更完善的NER来识别组织名
if subject_entity in governor or subject_entity in dependent:
# 这里只是示例,实际关系需要更精细的判定
pass
except Exception as e:
# 模型预测可能出错,记录并跳过
print(f"处理句子时出错: {sent[:50]}... 错误: {e}")
continue
# 由于完整实现SRL解析逻辑较为复杂,此处不展开,下文将介绍更实用的方法
return triples
重要说明 :直接使用SRL进行开放域关系抽取是一个复杂任务,需要深入理解SRL的输出格式并设计复杂的映射规则。对于新手,这条路坑很多。
5.2 更实用的方法:实体识别 + 自定义关系分类
一个更可控、更常见的实战方法是:
- 先用NER识别出句子中的所有实体 (人物、组织、地点等)。
- 判断句子中是否同时存在我们关心的两类实体 (如“人物”和“组织”)。
- 如果存在,将包含这两个实名的句子片段,送入一个关系分类模型 ,判断是否存在“任职于”关系。
我们可以利用HanLP的NER功能完成第一步,第二步是简单的逻辑判断。第三步的关系分类模型,我们可以用一个简单的 基于BERT的文本分类微调 来实现,但这需要标注数据。
这里提供一个折中的、基于规则的文本模式匹配方案 ,作为模型方案上线前的补充:
def extract_relations_from_text_with_patterns(text, subject):
"""
使用文本模式匹配从正文中抽取关系(作为模型方案的补充和初筛)
:param text: 正文文本
:param subject: 主体实体名
:return: 三元组列表
"""
triples = []
# 定义一些可能表示“任职于”关系的文本模式
# 格式: (模式, 关系类型), 模式中用 {subj} 和 {obj} 占位
patterns = [
(r'{subj}是{obj}的(创始人|联合创始人|创立者)之一?', '任职于'),
(r'{subj}担任{obj}的(CEO|首席执行官|董事长|总裁|工程师|研究员)', '任职于'),
(r'{subj}在{obj}任职', '任职于'),
(r'{obj}由{subj}创立', '任职于'),
(r'{subj}创办了{obj}', '任职于'),
(r'{subj}曾供职于{obj}', '任职于'),
]
sentences = hanlp.utils.rules.split_sentence(text)
for sent in sentences:
if subject in sent:
# 首先,我们需要从句子中提取可能的组织名(Object)
# 这里我们用一个非常粗糙的方法:使用HanLP的NER识别组织名(ORG)
# 为了演示,我们假设有一个函数 get_org_entities(sent) 返回句子中的组织名列表
# 在实际中,你需要调用HanLP的NER并过滤出ORG类型的实体
# 以下是一个模拟流程:
# 1. 调用HanLP NER
recognizer = hanlp.load(hanlp.pretrained.ner.MSRA_NER_ELECTRA_SMALL_ZH)
ner_result = recognizer([list(sent)]) # 注意输入格式
# ner_result 是列表,例如 [[('马斯克', 'PERSON', 0, 2), ('特斯拉', 'ORG', 5, 7), ...]]
# 2. 提取ORG实体作为候选Object
candidate_objects = []
for entity in ner_result[0]:
if entity[1] == 'ORG': # 假设标签为'ORG'的是组织
candidate_objects.append(entity[0])
# 对于每个候选组织,检查句子是否匹配模式
for obj in candidate_objects:
if obj == subject: # 避免自己和自己匹配
continue
for pattern_template, rel in patterns:
# 将模式中的占位符替换为实际实体名
# 注意:实体名中可能包含正则特殊字符,需要转义
import re
subj_escaped = re.escape(subject)
obj_escaped = re.escape(obj)
pattern = pattern_template.format(subj=subj_escaped, obj=obj_escaped)
if re.search(pattern, sent):
if (subject, rel, obj) not in triples:
triples.append((subject, rel, obj))
break # 一个句子匹配一个关系即可
return triples
# 测试文本模式抽取
data = load_page_data('马斯克')
if data and data['text']:
# 取部分正文测试
sample_text = data['text'][:5000] # 取前5000字符避免太长
text_relations = extract_relations_from_text_with_patterns(sample_text, '马斯克')
print("\n从正文文本中抽取的关系(模式匹配):")
for sub, rel, obj in text_relations:
print(f" ({sub}, {rel}, {obj})")
这种方法结合了NER和规则,比纯规则更智能一些,能发现正文中诸如“马斯克是SpaceX的创始人兼CEO”这样的关系。但它依然受限于预设的模式。对于更复杂、更隐含的关系(如“马斯克将其在PayPal的收益投入了SpaceX”),就需要真正的深度学习模型了。
6. 数据融合、去重与存储
我们从信息框和正文中抽取出了两批三元组,它们可能存在重复,也可能存在冲突(例如信息框说“任职于A”,正文说“曾任职于B”)。我们需要一个简单的融合流程。
6.1 三元组清洗与冲突解决
def merge_and_deduplicate_triples(rule_triples, text_triples):
"""
合并并去重来自不同来源的三元组
:param rule_triples: 规则抽取的三元组列表
:param text_triples: 文本抽取的三元组列表
:return: 去重后的三元组列表
"""
all_triples = rule_triples + text_triples
unique_triples = []
seen = set()
for sub, rel, obj in all_triples:
# 简单的字符串标准化:去除空格,统一关系名称
sub_norm = sub.strip()
obj_norm = obj.strip()
rel_norm = rel.strip()
# 创建一个唯一标识
triple_key = (sub_norm, rel_norm, obj_norm)
if triple_key not in seen:
seen.add(triple_key)
unique_triples.append((sub_norm, rel_norm, obj_norm))
# 简单的冲突解决(可选):如果出现(某人,任职于,A)和(某人,任职于,B),我们都保留。
# 更复杂的系统会引入时间信息(如“曾任”、“现任”)或置信度评分。
return unique_triples
def resolve_conflicts_with_simple_heuristics(triples):
"""
使用简单的启发式规则解决明显冲突(示例)
:param triples: 三元组列表
:return: 处理后的三元组列表
"""
# 例如,如果同时存在(某人,任职于,A)和(某人,任职于,B),且A和B是别名关系,则合并
# 这里需要公司别名映射表 company_alias_map
# 实现略,取决于具体的业务逻辑
return triples
6.2 结构化存储为知识图谱基础文件
最终,我们将清洗后的三元组保存为结构化的文件,为后续导入图数据库或进一步分析做准备。最通用的格式是 CSV 。
import csv
def save_triples_to_csv(triples, filename='knowledge_triples.csv'):
"""
将三元组保存为CSV文件
:param triples: 三元组列表
:param filename: 输出文件名
"""
with open(filename, 'w', newline='', encoding='utf-8-sig') as f: # utf-8-sig 支持Excel直接打开
writer = csv.writer(f)
writer.writerow(['subject', 'relation', 'object']) # 写入表头
for sub, rel, obj in triples:
writer.writerow([sub, rel, obj])
print(f"三元组已保存至 {filename},共 {len(triples)} 条。")
# 整合流程
def full_pipeline(keyword):
"""从关键词到知识三元组的完整流水线"""
# 1. 加载数据(假设已抓取)
data = load_page_data(keyword)
if not data:
print(f"未找到 {keyword} 的数据,请先抓取。")
return []
# 2. 规则抽取(信息框)
rule_triples = extract_relations_from_infobox(data)
print(f"[规则抽取] 获得 {len(rule_triples)} 条三元组")
# 3. 文本抽取(模式匹配)
text_triples = extract_relations_from_text_with_patterns(data['text'], data['title'])
print(f"[文本抽取] 获得 {len(text_triples)} 条三元组")
# 4. 融合与去重
all_triples = merge_and_deduplicate_triples(rule_triples, text_triples)
print(f"[融合去重] 剩余 {len(all_triples)} 条唯一三元组")
# 5. 保存
save_triples_to_csv(all_triples, f'{keyword}_triples.csv')
return all_triples
# 对多个实体运行流水线
all_entities_triples = []
for entity in ['马斯克', '黄仁勋', '特斯拉']:
print(f"\n=== 处理实体: {entity} ===")
triples = full_pipeline(entity)
all_entities_triples.extend(triples)
# 保存所有实体的总三元组
save_triples_to_csv(all_entities_triples, 'all_knowledge_triples.csv')
生成的CSV文件内容大致如下:
subject,relation,object
马斯克,任职于,SpaceX
马斯克,任职于,特斯拉
黄仁勋,任职于,英伟达
...
这个文件就是构建知识图谱最原始、最核心的“数据基石”。
7. 常见问题、优化方向与实战心得
走到这一步,一个最基础的知识图谱数据采集与抽取流水线就完成了。但在真实生产环境中,你会遇到更多挑战。
7.1 典型问题与排查技巧
-
抓取被封IP :
-
现象
:
requests返回403、404,或收到验证码页面。 -
排查
:检查
User-Agent是否设置;检查请求频率是否过高;检查是否触发了网站的反爬策略(如Cookie验证)。 -
解决
:增加随机延迟和随机User-Agent池;使用IP代理池(商业或自建);对于需要Cookie的复杂站点,考虑使用
requests.Session()维持会话,并模拟登录(如果合法且必要)。
-
现象
:
-
解析失败或数据为空 :
-
现象
:
BeautifulSoup找不到对应的class,返回空列表或None。 - 排查 :打印出抓取到的HTML片段,检查目标内容是否真的在HTML中。可能是页面结构已更新,或者数据是通过JavaScript动态加载的。
-
解决
:更新选择器;如果数据是JS加载,考虑使用
Selenium或Playwright等工具渲染页面后再解析。
-
现象
:
-
规则抽取准确率低 :
- 现象 :抽取出大量无关信息或漏抽。
- 排查 :检查正则表达式是否过于宽松或严格;检查信息框的键名是否多样。
- 解决 :收集一批正负样本,反复调试规则;将规则列表做成可配置的,便于维护和扩展;引入简单的机器学习分类器(如基于关键词特征的逻辑回归)来过滤低置信度结果。
-
文本抽取模型效果不佳 :
- 现象 :NER识别不出实体,或关系分类错误率高。
- 排查 :检查输入文本的预处理(分词、编码);检查模型是否适用于你的领域(科技新闻 vs 古汉语)。
- 解决 :尝试更换更先进的预训练模型(如HanLP的其他模型或Hugging Face上的中文BERT变体);如果领域特殊,考虑在自己的数据上进行微调(Fine-tuning),但这需要标注数据。
7.2 项目优化与扩展方向
- 多数据源融合 :不要只依赖百度百科。可以引入维基百科(需处理英文)、新闻网站、公司官网、招聘网站等。不同源的数据可以相互验证和补充。
- 实体链接与消歧 :抽出来的“苹果”可能指公司,也可能指水果。需要实体链接技术,将提及(Mention)链接到知识库中唯一的实体(Entity)上。这是构建高质量图谱的核心难题。
- 属性抽取 :除了关系,还可以抽取实体的属性,如人物的出生日期、公司的创立地点等。信息框是属性抽取的宝库。
- 事件抽取 :关系是静态的。更高级的是抽取动态事件,如“A公司收购了B公司”、“C发布了新产品D”。这通常需要更复杂的NLP模型。
- 流水线自动化与监控 :将整个流程脚本化、定时化,并加入日志监控和异常报警,实现知识的自动更新。
- 引入图数据库 :将CSV文件导入Neo4j或Nebula Graph,利用图查询语言(Cypher/nGQL)进行复杂的关联查询和可视化,这才是知识图谱价值的最终体现。
7.3 核心实战心得
- 80/20法则 :80%的结构化知识可能只来自20%高度结构化的数据源(如信息框、表格)。优先把这些“低垂的果实”摘干净,再考虑用复杂的模型去挖掘剩下的20%。
- 迭代开发 :不要试图一开始就设计一个完美系统。先构建一个最小可行产品(MVP),比如只从百科信息框抽关系。跑通流程、看到结果后,再逐步加入文本抽取、多数据源、消歧等模块。
- 数据质量高于算法复杂度 :一个由100条精准三元组构成的小图谱,远比一个由1万条噪声数据构成的大图谱有价值。在抽取的每个环节,都要加入清洗、验证和去噪的步骤。
- 领域适应性是关键 :为“科技人物-公司”领域设计的规则和模型,直接套用到“疾病-药物”领域大概率会失败。知识图谱构建是一个高度领域相关的任务,深入理解业务和数据特点,比盲目套用算法更重要。
这个实战项目的第一部分到此就完成了。我们拥有了一个能够从特定数据源自动采集信息,并通过“规则为主,模型为辅”的方式抽取实体关系的基础框架。代码虽然简单,但涵盖了核心思想。接下来,你可以拿着产出的
all_knowledge_triples.csv
文件,开启知识图谱实战的第二部分:图数据库存储与可视化探索。那时,你会发现,当散乱的数据点被连接成网,真正的洞察才开始浮现。
更多推荐
所有评论(0)