1. 项目概述:从零构建知识图谱的数据基石

“知识图谱实战(一):数据采集与实体关系抽取(完整代码)”这个标题,对于任何一个想动手构建自己知识图谱的开发者来说,都充满了吸引力。它直指了知识图谱构建流程中最关键、也最耗费精力的第一步:数据从哪里来,以及如何从原始数据中提炼出结构化的知识。我见过太多项目,模型设计得很精巧,但最终效果却差强人意,究其根源,往往是在数据采集和实体关系抽取这两个环节上“埋了雷”。

简单来说,这个项目要解决的核心问题,就是如何将互联网上或企业内部那些非结构化的文本(比如新闻、报告、百科页面),或者半结构化的数据(比如表格、JSON),转化为一张由“实体-关系-实体”构成的知识网络。这听起来像是自然语言处理(NLP)的经典任务,但实战中远不止调用几个API那么简单。它涉及到数据源的稳定性评估、不同抽取策略的权衡、以及如何处理抽取结果中的噪声和冲突。适合的读者包括:有一定Python基础,对NLP和知识图谱感兴趣的数据工程师、算法工程师,以及任何希望将杂乱信息体系化的技术爱好者。

接下来的内容,我会以一个具体的领域——比如“科技人物与公司关系图谱”为例,带你完整走一遍从数据抓取到关系抽取的实战流程,并提供可以直接运行的代码。你会发现,真正的难点不在于代码怎么写,而在于面对真实、复杂、多变的数据时,如何设计一个鲁棒、可扩展的流水线。

2. 整体设计与核心思路拆解

在动手写第一行代码之前,我们必须把整个流水线的设计思路理清楚。一个糟糕的设计会导致后期维护成本激增,甚至推倒重来。

2.1 需求定义与数据源规划

我们的目标是构建一个“科技人物与公司关系图谱”。这个图谱的“骨架”由两类节点(实体)和一类边(关系)构成:

  • 实体类型
    1. 人物 :如“马斯克”、“黄仁勋”。
    2. 公司/组织 :如“特斯拉”、“英伟达”、“OpenAI”。
  • 关系类型
    • 任职于 :连接“人物”与“公司”,表示人物在该公司担任职务(如创始人、CEO、工程师)。

数据从哪里来?我们优先选择信息结构化程度高、相对权威的源。对于中文领域, 百度百科 是一个不错的起点。它页面结构相对统一,包含信息框(InfoBox),这为我们后续的抽取工作降低了难度。当然,单一数据源存在偏见和覆盖不全的问题,成熟的系统会融合多个源(如维基百科、新闻、招股书),但作为入门实战,我们先聚焦于一个源,把流程打通。

注意 :任何数据采集行为都必须严格遵守网站的 robots.txt 协议,控制请求频率,避免对目标服务器造成压力。本实战示例仅用于技术学习,请务必尊重数据版权和网站的使用条款。

2.2 技术栈选型与考量

为什么选择以下技术栈?这是基于效率、社区支持和与后续环节的衔接度综合考量的结果。

  1. 数据采集层

    • Requests + BeautifulSoup4 :这是Python生态中最经典、最轻量的静态网页抓取组合。Requests负责HTTP请求,BeautifulSoup负责解析HTML。对于百度百科这类无需复杂JavaScript渲染的页面,这个组合完全够用,且学习成本低。
    • 备用方案:Selenium/Playwright :如果目标页面数据是通过JS动态加载的,则需启用这些浏览器自动化工具。但它们更重、更慢。我们的策略是:优先用轻量级方案,遇到问题再升级。
  2. 实体关系抽取层

    • 基于规则与词典的抽取 :针对百科信息框这种半结构化数据,规则方法(正则表达式、XPath)准确率极高,且速度飞快。这是我们的首选。
    • 基于预训练模型的深度学习抽取 :用于处理纯文本描述段落。我们选用 HanLP LTP 这类开源中文NLP工具包。它们提供了训练好的命名实体识别(NER)和关系抽取(RE)模型。相比于从零训练BERT模型,使用这些工具包能让我们快速搭建可用的原型。
    • 为什么不用纯深度学习方案? 因为成本。标注高质量的关系抽取数据非常困难,且模型训练和推理成本高。在实战中,**“规则优先,模型补充”**是性价比最高的策略。先用规则保证高置信度数据的获取,再用模型从自由文本中挖掘潜在关系。
  3. 数据存储与流水线

    • 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) (埃隆·马斯克, 任职于, 特斯拉)

规则设计如下:

  1. 实体识别 :当前页面的标题( parsed_data[‘title’] )是核心实体(Subject)。
  2. 关系触发 :扫描信息框字典的每一个“值”(Value)。
  3. 公司名称模式匹配 :在“值”中,寻找可能包含公司名的模式。例如,“XXX创始人”、“YYY CEO”、“ZZZ联合创始人”、“前AAABBB工程师”。
  4. 公司实体提取 :一旦匹配到模式,使用正则表达式或简单的字符串分割,提取出公司名(Object)。
  5. 关系确认 :关系类型固定为“任职于”。(更精细的可以区分“创始人”、“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 规则系统的优化与维护

规则抽取很快,但维护成本是随着领域变化而增长的。以下是几个优化方向:

  1. 公司名归一化 :抽出来的“特斯拉”和“特斯拉汽车”可能指向同一实体。我们需要一个“公司别名映射表”来进行归一化。这个表可以手动维护,也可以通过后续的实体链接技术来完善。

    company_alias_map = {
        '特斯拉': '特斯拉',
        '特斯拉汽车': '特斯拉',
        'Tesla': '特斯拉',
        'SpaceX': 'SpaceX',
        '太空探索技术公司': 'SpaceX',
        # ...
    }
    

    在存入三元组前,将 object 通过这个映射表进行转换。

  2. 规则的可配置化 :将正则表达式模式和相关的信息框键名写入一个配置文件(如YAML或JSON),而不是硬编码在函数里。这样,当需要适配新的领域(如“影视明星与作品”)时,只需修改配置文件,无需改动核心代码。

  3. 多值处理 :信息框的值中经常用顿号、逗号分隔多个项目。我们的代码已经用 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 更实用的方法:实体识别 + 自定义关系分类

一个更可控、更常见的实战方法是:

  1. 先用NER识别出句子中的所有实体 (人物、组织、地点等)。
  2. 判断句子中是否同时存在我们关心的两类实体 (如“人物”和“组织”)。
  3. 如果存在,将包含这两个实名的句子片段,送入一个关系分类模型 ,判断是否存在“任职于”关系。

我们可以利用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 典型问题与排查技巧

  1. 抓取被封IP

    • 现象 requests 返回403、404,或收到验证码页面。
    • 排查 :检查 User-Agent 是否设置;检查请求频率是否过高;检查是否触发了网站的反爬策略(如Cookie验证)。
    • 解决 :增加随机延迟和随机User-Agent池;使用IP代理池(商业或自建);对于需要Cookie的复杂站点,考虑使用 requests.Session() 维持会话,并模拟登录(如果合法且必要)。
  2. 解析失败或数据为空

    • 现象 BeautifulSoup 找不到对应的 class ,返回空列表或 None
    • 排查 :打印出抓取到的HTML片段,检查目标内容是否真的在HTML中。可能是页面结构已更新,或者数据是通过JavaScript动态加载的。
    • 解决 :更新选择器;如果数据是JS加载,考虑使用 Selenium Playwright 等工具渲染页面后再解析。
  3. 规则抽取准确率低

    • 现象 :抽取出大量无关信息或漏抽。
    • 排查 :检查正则表达式是否过于宽松或严格;检查信息框的键名是否多样。
    • 解决 :收集一批正负样本,反复调试规则;将规则列表做成可配置的,便于维护和扩展;引入简单的机器学习分类器(如基于关键词特征的逻辑回归)来过滤低置信度结果。
  4. 文本抽取模型效果不佳

    • 现象 :NER识别不出实体,或关系分类错误率高。
    • 排查 :检查输入文本的预处理(分词、编码);检查模型是否适用于你的领域(科技新闻 vs 古汉语)。
    • 解决 :尝试更换更先进的预训练模型(如HanLP的其他模型或Hugging Face上的中文BERT变体);如果领域特殊,考虑在自己的数据上进行微调(Fine-tuning),但这需要标注数据。

7.2 项目优化与扩展方向

  1. 多数据源融合 :不要只依赖百度百科。可以引入维基百科(需处理英文)、新闻网站、公司官网、招聘网站等。不同源的数据可以相互验证和补充。
  2. 实体链接与消歧 :抽出来的“苹果”可能指公司,也可能指水果。需要实体链接技术,将提及(Mention)链接到知识库中唯一的实体(Entity)上。这是构建高质量图谱的核心难题。
  3. 属性抽取 :除了关系,还可以抽取实体的属性,如人物的出生日期、公司的创立地点等。信息框是属性抽取的宝库。
  4. 事件抽取 :关系是静态的。更高级的是抽取动态事件,如“A公司收购了B公司”、“C发布了新产品D”。这通常需要更复杂的NLP模型。
  5. 流水线自动化与监控 :将整个流程脚本化、定时化,并加入日志监控和异常报警,实现知识的自动更新。
  6. 引入图数据库 :将CSV文件导入Neo4j或Nebula Graph,利用图查询语言(Cypher/nGQL)进行复杂的关联查询和可视化,这才是知识图谱价值的最终体现。

7.3 核心实战心得

  • 80/20法则 :80%的结构化知识可能只来自20%高度结构化的数据源(如信息框、表格)。优先把这些“低垂的果实”摘干净,再考虑用复杂的模型去挖掘剩下的20%。
  • 迭代开发 :不要试图一开始就设计一个完美系统。先构建一个最小可行产品(MVP),比如只从百科信息框抽关系。跑通流程、看到结果后,再逐步加入文本抽取、多数据源、消歧等模块。
  • 数据质量高于算法复杂度 :一个由100条精准三元组构成的小图谱,远比一个由1万条噪声数据构成的大图谱有价值。在抽取的每个环节,都要加入清洗、验证和去噪的步骤。
  • 领域适应性是关键 :为“科技人物-公司”领域设计的规则和模型,直接套用到“疾病-药物”领域大概率会失败。知识图谱构建是一个高度领域相关的任务,深入理解业务和数据特点,比盲目套用算法更重要。

这个实战项目的第一部分到此就完成了。我们拥有了一个能够从特定数据源自动采集信息,并通过“规则为主,模型为辅”的方式抽取实体关系的基础框架。代码虽然简单,但涵盖了核心思想。接下来,你可以拿着产出的 all_knowledge_triples.csv 文件,开启知识图谱实战的第二部分:图数据库存储与可视化探索。那时,你会发现,当散乱的数据点被连接成网,真正的洞察才开始浮现。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐