0. 背景
知识图谱构建是指从原始数据到结构化图谱的数据抽取、转换的过程。由于原始数据来源众多、结构不统一、数据语义不一致,因此在整个知识图谱系统的建设过程中, 往往是最为复杂的环节,但又是必不可少的环节。
GoIN系统是由中科院计算所天玑团队研发的知识图谱分析产品,核心数据处理流程就是知识抽取和图谱构建。
在之前的产品设计中,这个过程被设计为交互式的,用户通过界面上传数据、配置规则,调用后台的抽取服务最终形成图谱,这样最大的问题是图谱规模受到限制。另外一个问题是抽取服务中很多业务逻辑是固化的,隐藏在零碎的if-else语句中,如果要增加新的逻辑处理会非常困难,也就是扩展性不好。
1. 需求分析
在前面的背景介绍中总体介绍了这项工作的需求来源,整理一下就是:
- 需要一套离线程序进行知识图谱构建,从而能够实现自动化、规模化处理
- 图谱构建业务逻辑比较容易扩展,能够轻松地根据具体需求进行配置和开发
- 支持多种数据来源和数据类型,包括目前产品规划的结构化数据、半结构化数据和非结构化数据
- 支持流程可配置,根据具体业务处理需要进行组装
2. 设计思路
根据需求,首先设计一套处理框架,将主要的处理过程划分为框架中的一个或多个步骤,通过配置和框架程序将这些步骤串联起来,就可以提供较好的扩展性。因为每个步骤在理论上就可以被置换、被改进、被重组。也很容易支持第3点需求,根据具体需要实现就可以。
通过对框架进行并行设计,就可以实现规模化处理。自动化不难,难的是规模化,处理1万条数据哪怕很老旧的笔记本都可以很快,但是要处理1000万,就必须仔细考虑。多线程、多进程、生产者-消费者模式就是经常需要考虑的技术。
3. 总体设计
3.1 业务流程
根据对知识图谱构建过程进行梳理,形成以下处理流程:
如图所示,从原始数据(包括文件和数据库)进行数据接入,非结构化内容数据经过NLU处理过程,形成NLU结果(如实体、关系、事件等),下一步可通过结构化处理,核心步骤是与知识本体进行映射,最后再转成统一的图结构化数据,最后在写入到系统里面。
这个流程基本上是目前GoIN系统的数据处理逻辑过程,最后进入的ES、SQLGraph、Postgis数据库是目前实际的存储选型,但理论上是可以更换的。
3.2 流程框架
将这个过程进一步抽象,形成下面这个处理框架:
图中每个方框都称为组件,简单说明一下各个组件:
- source 数据源,指定了原始数据来源,比如URL、Websocket、数据库或文件
- loader 数据加载,从数据源中读取数据,对接各类source
- parser 非结构化数据(目前主要考虑文本)的解析处理,这样解析器可以替换,CoreNLU、GolaxyNLP、LTP、HanLP都可以
- mapper 对数据进行转换,借鉴了map-reduce框架的mapper步骤。理想的情况是支持一个或多个mapper串联,甚至可以进行分叉、合并
- writer 输出组件,对接各种target
- target 输出目标,包括文件、数据库等
这些组件根据输入输出情况可分为3类:
- 源组件:主要是loader,提供数据源,本身不需要前置的输入组件
- 终端组件:包括writer和没有输出的mapper,即作为流程终点的组件
- 其他组件:需要其他组件作为输入并产生输出的组件,例如parser和普通的mapper
3.3 输入设计(需求相关)
为了满足常见业务需求,source支持文件和数据库,其中文件包括txt、word(doc/docx)、csv、excel(xls/xlsx)、pdf、json等,数据库支持MySQL、MongoDB、PostgreSQL等。未来可进一步扩展。
为了便于处理,将输入数据分为结构化和非结构化。非结构化数据是包含至少一个文本(str类型)字段的结构,前述所有类型的文件和数据库均可作为非结构化数据看待,但是对txt、word、pdf进行特殊处理,具体处理逻辑是:文件正文中的所有文字内容作为content字段;尝试从word、pdf的元数据提取标题title;如果标题未抽取或为空,可根据配置把文件名作为标题字段。
另一方面,所有数据也都可以作为结构化数据看待,根据mapper逻辑进行转换即可。
需要注意的是,excel文件包含多个sheet,每个sheet是一张二维表。Excel在日常业务中会有各种使用方式,例如表头不在第一行、单元格合并、包含了图表图像、单元格有样式及批注等,可以承载复杂的信息,但是这些使用方式虽然方便人们浏览查看,自动化处理却比较困难。这里只处理了简单情况,即只提取表格文字内容,表格第一行为表头。
3.4 流程配置设计(需求相关)
提供一种JSON格式的配置,用于对整个流程以及每个环节的参数进行配置。
框架程序对配置进行解析,构建一个流程实例,实例化每个流程组件,然后启动整个流程。
4. 详细设计
4.1 数据结构
以JSON对象(python中的dict)作为在流程中的数据结构,不仅可以保持整体结构一致,而且可以方便中间过程数据访问、增加字段信息。
输入中的一行记录(json/csv/excel/数据库等)或一个文本文件(word/pdf/txt等)对应一个JSON对象,整个处理的过程就是一个一个对象在处理管道中流动。这个对象既可以是业务数据(比如一篇新闻报道),也可以是业务元数据(比如一个任务,指示要删除的数据库记录),对于这两者,框架的处理没有本质区别,不同在于实际的业务逻辑。
通过增加特殊字段,记录流程元信息。例如对于一般文件word/pdf/txt,除了标题和正文字段,还可以获取原文件路径、文件owner、文件修改时间等;对于excel、数据库记录,除了单元格数据外,还可以获取其所在的表(table或sheet)、数据库(文件或database)、数据库地址(包括主机名和端口、协议等)。通过加入元信息,为后续处理流程提供更多信息,但也有问题,会导致有效载荷率降低,影响处理效率。可以通过配置进行控制。
4.2 配置文件设计
与流程组件对应,设计了对应的流程配置文件,其中每个组件的配置项与组件的业务逻辑相匹配,需要由组件设计者提供相应信息。其中start明确了流程的输入源,如果process中没有loader,则需要start明确对应的源(一般就是一个消息队列)作为输入。process是指明了需要运行的流程,它们自动进行串联,例如["loader", "parser"] 是把loader组件的结果输出给parser组件。loader组件较为特殊,一般只能作为第一个。process中的每个组件通过对应的配置项进行具体配置。
配置文件示例如下:
{
"start": null,
"process": [
"loader",
"parser",
"mapper",
"writer"
],
"loader": {
"source": {
"type": "file",
"file": {
"path": "E:\\台湾凤梨非机构化数据\\data1",
"allow": true,
"include": [],
"exclude": []
},
"db": {
"type": "mysql",
"table": []
}
},
"name_as_title": true,
"add_id": true,
"keep_all": true,
"select": [],
"content": [
"cont",
"content"
]
},
"parser": {
"module_name": "my_parser",
"batch": 200,
"name": "corenlu",
"service": "http://117.160.193.19:9080/nlu_inte",
"timeout": 180,
"tasks": [
"ner",
"nel",
"event",
"topic",
"sentiment"
],
"lang": "zh",
"parse_fields": [
"content"
],
"max_docs": 20,
"max_length": 1500,
"parallels": 10,
"repeat_count": 3
},
"mapper": {
"pipes": [
"graph",
"geo",
"goin_graph",
"rm_docfields"
],
"graph": {
"parser_field": "content",
"keep_parse_field": false,
"entity_linking": true,
"shift_fields": [
"topic",
"sentiment",
"keywords"
]
},
"geo": {
},
"rm_docfields": {
"nlu_fields": true,
"fields": []
}
},
"writer": {
"targets": [
"kg_instance"
],
"file": {
"format": "jsonl",
"file_path": "/Users/chenbo/data/testout.jsonl",
"open_mode": "append"
},
"kg_instance": {
"service": "http://127.0.0.1:8881/goin_instance/v1.0/graphs/_publish",
"user_id": "42a4b1fa295206ed27df6720e68330a1",
"dataset_id": 5273,
"dataset_name": "国内外新闻报道"
},
"mongo": {
"host": "127.0.0.1",
"port": "27017",
"user": "admin",
"password": "123456",
"db": "goin_data1",
"table": "table1"
}
}
}
4.3 动态加载
为了实现处理环节的可配置、可扩展,采用元编程技术,实现根据组件名称加载对应的模块(或类)。
在框架代码方面,设计统一的包结构,实现模块按需加载。根据前文序数的环节名称,分别设计loader、parser、mapper、writer几个包,里面包含相应的模块文件,例如在loader下面包含load_csv.py、load_xls.py、load_doc.py、load_mysql.py等模块文件。通过内置函数import即可加载相应的模块。示例代码如下:
class ModuleManager:
def __init__(self):
self.modules = {}
def load(self, prefix, name):
mod_name = f"{prefix}.{name}"
if mod_name not in self.modules:
try:
mod = __import__(mod_name, fromlist=(mod_name,))
except Exception as e:
print(mod_name, "not found")
return None
self.modules[mod_name] = mod
return self.modules[mod_name].load
具体来说,是约定每个模块提供一个load函数,ModuleManager根据提供的包前缀和名称,加载相应的模块,返回其中的load函数。
这里可进一步改进:采用完全面向对象的方式进行实现,更加灵活、高效。另外可以考虑启动时加载和懒加载两种机制,各有优劣。
4.4 流程串联
为了实现流程可配置、可串联,需要能够将配置中的前一个组件的输出作为后一个组件的输入。考虑了两种实现方式:
- 简单实现方式,通过循环,对于每个输入对象,依次执行配置的流程组件。每个组件执行完,其输出作为当前对象值,如果对象为None/null,即结束此次输入对象的处理。
- 基于生产者-消费者模式的输入,各个组件从自己配置的输入队列(默认配置就是前一个组件的名称)中获取输入数据,处理完成后输出到配置的输出队列(默认配置为自己的名称),队列通过消息中间件或消息中心进行管理,从而将前后组件之间解耦。为了调试方便,框架提供一种本地的跨进程通信的消息队列,同时提供mongo和kafka的消息队列。
流程初始化代码如下:
def start_process(mq_server, task_config):
"""
启动处理流程
:param mq_server 消息队列
:param task_config 任务配置
:return:
"""
process_list = task_config["process"]
p_list = []
# 开始的队列,支持从非loader环节开始
_input = task_config.get("start")
# 启动处理流程
for process_item in process_list:
_output = process_item
config = task_config.get(process_item, {})
config["_task"] = task_config
pipe = Pipeline(process_item, config, _input, _output)
p = Process(target=pipe.run, args=(mq_server,))
p.start()
p_list.append(p)
_input = _output
return p_list
这里采用python多进程启动每个组件,从而可以并行处理。组件从自己的_input队列中获取输入,产生的输出数据输出到_output队列中。
对于loader组件,由于是从配置的source中加载数据,并不需要一个_input队列,这种情况配置start为null即可。
4.5 组件处理
每个组件实例,定义了自己的输入_input和输出_output,如果_input不为空,则从_input中获取数据,否则就自己“创造”数据,比如loader是从配置的源中接入数据,相当于是自己“创造”了数据;处理结束后,如果输出不为空且_output也不为空,就输出到_output中,否则就忽略继续下一次循环。
5. 其他细节
- 对于loader,如果数据源是文件,一般是遍历每个文件(文件夹及子文件夹)后,整个流程结束。按前面的设计,如果不做相应处理的话,后续组件一直会空转。
为了处理这个问题,设计一个特殊消息{"_id": "__stop__"}
,每个组件遇到这个组件的时候,就往输出队列中输出同样的消息,然后自己结束退出。loader组件在扫描完文件或读取完数据库后就发送这样一条消息(也可配置一直监控文件夹或数据库),后续所有组件就会自己结束。
每个组件的配置参数中加入整体配置信息
config["_task"] = task_config
,方便每个组件获取整体流程配置。例如在mapper组件中,需要知道前面的parser组件是对哪个字段进行了NLU处理,就可以通过_task,进而获取parser的配置。采用Python多进程实现组件,本地调试用的消息队列采用
multiprocessing
包中的Manager
提供的数据结构,才能支持跨进程数据共享。如果使用多线程,就可以直接使用常用的数据结构(list
或dict
)消息队列也采用了动态加载的可扩展设计,每种队列提供
read
、write
和delete
三个方法。简单流程、小规模数据或本地调试,可采用本地消息队列mq_local
,数据不会持久化存储。提供了mq_mongo
,基于mongodb数据库的简单队列,数据持久保存。后续可提供其他队列,如kafka的支持。注意,read方法不会删除数据,只是获取数据,这样可以在处理失败后稍后重试,处理成功需要显式调用delete,对应kafka的提交偏移量的操作。考虑到实际处理效率,消息队列
read
操作支持一次获取多余一条数据。这样主要是为了提高IO的效率,比如使用mongodb,一次读取一条开销相对比较大,因为可能处理一条会特别快。所以read支持可选的参数limit
,默认为1,各个组件可以配置。loader如何避免重复接入数据?目前先考虑文件情况,通过配置备份文件夹,将读取过的文件都移动到备份文件夹,这样,如果数据量比较大,程序处理中断了,下次再继续处理其他文件,不用重复处理之前已经处理的每一个文件。但是对于多行文件,只处理了部分的情况怎么办?这个问题一般的处理框架和处理引擎都没法解决,得增加外部可靠的KV数据库(例如redis)进行记录。
组件执行报错怎么办?出错后的处理有几种情况:(1)处理偶尔出错或不可用,重试即可,立即重试或稍后重试 (2)组件有BUG或数据本身有问题,那么无论怎么重试都是错的。总体来说错误处理还是要依赖具体的业务组件,框架的设计支持重试几次后如果仍然失败,则加入到错误队列中,交给人工处理。
CLI参数设计。为了方便使用,提供了一些CLI参数,能够实现对文件数据源的快速加载并输出到指定的文件中。
6. 灵魂拷问:为什么要重复造轮子
对于这个问题,必须说,应该在设计之前就必须要考虑。
大部分软件设计开发工作中,大部分甚至每一个模块、每一个函数、每一行代码,可能都是重复的,是有很多基础工作的。但是往往又不是完全都符合要求,有的是细节的差异,比如界面上的提示文字,有的是逻辑处理的差异,有的则是面对完全不同的应用场景,尽管表面上看起来可以复用。
对于设计人员来说,真正地艺术正在于此,找到符合多方面约束条件的设计方案。客观地说,这应该是工程,不是艺术。
首先,有没有现成的差不多满足需求的方案呢?ETL、tika、kafka、poi以及各种NLP库等,工具很多,但是没有能够进行知识图谱构建的。知识图谱目前开源的完整系统几乎没有。
第二,处理框架选择上为什么没有选择大数据的框架,比如Map-Reduce、Spark Streaming或Flink等?GoIN系统确实缺少一个强有力的大数据处理的后台,但是这样最主要的问题是把GoIN系统变得更加复杂。GoIN目前已经很复杂了,内部使用了7种数据库!!!以及一堆服务、凌乱不规整的库表结构和业务逻辑设计,可以说,简化系统的需求甚至比功能需求更迫切。通过这个框架设计,很大程度上将原有的数据接入、离线数据入库、案例数据处理等工作简单化、清晰化、弹性化、可配置化。
第三,开发层面、代码层面是否可以充分利用已有基础?这个确实是有的,但基本是一些基础代码的拷贝,例如多种数据源的访问读取、多进程、CoreNLU结果解析、入库等。
7. 下一步工作
目前基于这个框架基本实现了GoIN系统的非结构化数据解析入库流程。下一步工作包括:
- 对结构化数据,支持用户配置转换规则(已有基础代码),实现快速转换,未来处理一般的案例数据,就会非常方便
- 提供web服务,能够为现有GoIN的线上的业务流程提供支持或替换
- GoIN比较常用的一类需求是针对社交媒体数据,社交媒体数据是包含结构化部分和非结构化部分,需要实现一套针对社交媒体数据的图谱投建的基本流程