早前调研了知识图谱的基础概念和技术框架,最近这两个月倒腾了一个古诗词的图谱demo,仅以此文记录一下实验过程。从零开始做这个Demo,整个过程大致分为三大步骤:数据采集,数据存储以及图谱应用,全文将按这三步进行记录。
一、数据采集
既然是从零开始,那第一步就是要爬取数据。搜了几个诗词网站,对比网页排版结构和内容丰富程度,个人觉得古诗词网是个不错的选择,在这里感谢站长为经典文化传承作出的贡献。
1. 网页分析
F12打开诗词列表页的源码,查看头部信息如下图:
请求的url格式固定,只有页码改变;请求的类型为get。
多看几个页面,可以发现请求头中Cookie的hm_lvt和hm_lpvt为两个时间戳,不同页面只有hml_pvt发生改变;old_url取值为当前页码;Referer也是随页码改变的固定格式url。
请求列表页面的返回结果为json列表,可以非常方便地提取需要的信息,而不用去html中定位并解析目标元素,省去了爬虫中的一半工作量:
每一个json对应一首诗词,包含标题、正文、作者、朝代、标签、体裁、作者介绍、译注、赏析等信息,这种结构化的数据,也免去了数据抽取和整理的很多工作。
2. 爬虫代码
这一类网站广告很少,也没有收费业务,带有公益性质,网站服务器一般也扛不住爬虫的压力,常常会采取一些反爬措施,比如封禁IP。为了爬取这些网站,一方面要降低爬取速度;另一方面要维护代理池,在被封的时候更换IP。爬取过程中及时保存爬虫结果,并记录爬取失败的页面,方便以后再重爬。
def crawl_pages(page_list, save_path, ip_pool, retry_times=5):
fail_list = list()
lvt_code = int(time.time())
ip = random.choice(ip_pool)
for page in page_list:
time.sleep(3 * random.random())
lpvt_code = int(time.time())
page_url = 'https://www.gushici.com/poetry_list?page={0}'.format(page)
referer = 'https://www.gushici.com/p_{0}'.format(page)
headers = {'Host': 'www.gushici.com',
'Connection': 'keep-alive',
'Accept': '*/*',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36',
'Referer': referer,
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Cookie': 'JSESSIONID=48304F9E8D55A9F2F8ACC14B7EC5A02D; Hm_lvt_98209c07e81fcbdd5f79bd9e94c617eb={0}; Hm_lpvt_98209c07e81fcbdd5f79bd9e94c617eb={1};\old_url=/p_{2}'.format(lvt_code, lpvt_code, page)}
times = 0
flag = False
while times <= retry_times:
times += 1
try:
response = requests.get(page_url, headers=headers, proxies={'https': ip}, verify=False, timeout=10)
flag = True
with open(os.path.join(save_path, page), 'w', encoding='utf-8') as f:
json.dump(response.text, f, ensure_ascii=False)
break
except:
ip = random.choice(ip_pool)
if not flag:
fail_list.append(page)
with open(os.path.join(save_path, 'fail'), 'w', encoding='utf-8') as f:
json.dump(fail_list, f, ensure_ascii=False)
二、数据存储
知识图谱的数据层有多重存储方式,本文选择采用Neo4j搭建。Noe4j是一个高性能的轻量级图形数据库,应对小型知识图谱绰绰有余。虽然关系型数据库通过多重join也可以实现数据间的复杂关系查询,但是多表数据join然后过滤筛选导致性能会非常差,而图数据库很好地解决这样的问题,它只用遍历相关节点,不用操作全量数据,性能会大大提升。
1. Neo4j安装
从Neo4j官网可以下载开源的Noe4j社区版,解压到D盘,然后配置环境变量。注意:Neo4j依赖于Java运行环境,安装Neo4j前请检查本机是否安装Java并配置Java的环境变量。
打开cmd命令行窗口,进入安装目录下的bin文件夹,执行“neo4j install-service”命令,安装Neo4j服务。然后执行“neo4j start”命令,启动Neo4j的服务。
2. Neo4j操作
Neo4j是一种图数据库,其中并没有数据表的概念,只包含节点和边,节点表示实体,边表示实体间的关系(分为有向关系和无向关系),节点和边可以包含键值对表示的属性。
用惯了关系型数据库,初次接触图数据库感觉有点别扭。为了便于自己理解,我是这样来类比的:
节点和关系是图数据库中定义的最原始的两个基类,节点(关系)的标签表示由节点(关系)基类派生出来的一类节点(关系)。当导入数据之后,具有属性值的某一个节点(关系),就是该标签对应的节点(关系)派生类所生成的实例。
(1)导入数据
Neo4j提供import命令,可以批量导入csv格式的数据。针对json格式的数据,可以转为csv格式,然后用import导入,注意json中的双引号需要进行转义。为避免格式转换过程中的错误,可以调用apoc函数库中的json导入工具。
apoc的安装方式为:从github中下载apoc的jar包,将jar包复制到Neo4j安装目录的plugins路径下,在neo4j.conf中配置apoc.import.file.enabled=true,表示允许apoc导入文件。重启Neo4j服务,调用apoc.load.json即可导入json数据。
(2)查询数据
Neo4j的查询语言为Cypher(第一眼看成了Cython,然而这两个半点不沾边)。官网有完整版的Cypher手册,本文只挑选最基础的几个语句简要介绍。
A.增:
新建节点: CREATE (node: NodeType {AttributeKey : AttributeValue})
// 创建一个姓名为Jack的Person类的节点,并返回该节点
CREATE (a:Person {name:"Jack"})
RETURN a
新建关系:不能单独创建关系,必须指明关系的起始节点和终止节点。--表示无向关系,->和 EndNode
// 创建两个Person之间Knows的关系,并返回节点和关系
CREATE (a:Person)-[k:KNOWS]-(b:Person)
RETURN a, k, b
B.查:
查询节点:MATCH (node: NodeType {AttributeKey : AttributeValue}) WHERE node.AttributeKey = AttributeValue
// 查询1970年后出生的Person节点,并返回节点
MATCH (n)
WHERE n.born > 1970
RETURN n;
查询关系:MATCH StartNode - (relationship: RelationshipType {AttributeKey : AttributeValue}) -> EndNode WHERE relationship.AttributeKey = AttributeValue
// 查询自从2015年起居住(LIVES_IN)在NewYork城市(City)的名叫Mike的人(Person),并返回节点和关系
MATCH (p:Person {name:"Michel"})-[s:LIVES_IN]->(c:City {name:"NewYork"})
WHERE s.since = 2015
RETURN p,s,c
C.改:
修改属性:MATCH (variable : NodeType|RelationshipType) SET variable = {AttributeKey : AttributeValue}
// 查询名为Jack的Person类节点,并将名字改为Michel,年龄改为23
MATCH (p:Person)
WHERE p.name = "Jack"
SET p = {name: "Michel", age: 23}
D.删:
删除节点:与该节点相关的关系也需要删除。MATCH (node) - [relationship] - () DELETE node, relationship
// 删除名为Jack的Person节点及关联关系
MATCH (p:Person)-[relationship]-()
WHERE p.name = "Jack"
DELETE relationship, p
删除属性:MATCH (node) - [relationship] - () REMOVE node.AttributeKey, relationship.AttributeKey
// 删除名为Michel的Person节点的年龄属性
MATCH (p:Person)
WHERE p.name = "Michel"
REMOVE p.age
3. Neo4j实践
本文设计的知识图谱包含三类节点:诗词(Poem)、作者(Author)、标签(Tag)。作者与诗词是写作(WRITE)的关系,诗词、作者与标签是标识(LABEL)关系。
// 在三类节点上创建索引
create index on :Poem(uuid);
create index on :Author(name);
create index on :Tag(tag);
// 将数据导入Neo4j
call apoc.periodic.iterate('call apoc.load.json("web_file_poetry.json") yield value as poem',
'merge (p:Poem{uuid: poem.poem_id})
set p.title = poem.title, p.content=poem.poem, p.tag=poem.tag, p.appreciation=poem.appreciation, p.background=poem.background
// 作者节点
merge (a:Author{name: poem.poet, dynasty: poem.dynasty})
// 作者到诗词的关系
merge (a)-[r1:WRITE]->(p)',
{batchSize:100000, iterateList:true, parallel:true});
// 建立诗词、作者与标签之间的关系
match (a:Author)-[:WRITE]->(p:Poem)
where p.tag <> ''
unwind split(trim(p.tag), ",") as tag
// 标签节点
merge (t:Tag{tag: tag})
// 诗词到标签的关系
merge (p)-[r1:LABEL]->(t)
// 作者到标签的关系
merge (a)-[r2:LABEL]->(t);
图数据库创建成功之后,可以查询看看效果,Neo4j的可视化做的还是挺好看的。
三、图谱应用
知识问答是基于知识图谱的一项应用,前沿的问答系统多采用深度学习、自然语言处理等技术。本文采用最简单的正则匹配( ̄▽ ̄)~*
1.首先,定义可以回答的问题类型:
查找诗词的正则:
source_list = [
'[\"\'“‘《]?(?:是|出自|来[自|源])(?:哪[首|篇|个|里|儿|]?|什么)的?(?:[诗词][文句]?|文章|句子)?',
'[\"\'“‘《]?的(?:来源|出处|(?:整[首|篇]|完整|全)[诗词文])',
'(?:含有?|包[含括])[\"\'“‘《]?的(?:[诗词][文句]?|文章|句子)'
]
source_list = list(map(re.compile, source_list))
查找作者的正则:
author_list = [
'[\"\'“‘《]?的(?:作者|[诗词]人)',
'[\"\'“‘《]?是(?:谁|哪[位个])(?:作者|[诗词]人)?',
]
author_list = list(map(re.compile, author_list))
查找标签的正则:
tag_list = [
'(?:写|描[写绘述]|表达)(\S+?)的?[诗词]',
]
tag_list = list(map(re.compile, tag_list))
整合问题正则:
rules = {
'source': source_list,
'author': author_list,
'tag': tag_list,
}
2.其次,根据正则判断问题类型,并提取问题中的要素
def match(question):
match_result = None
for mode, temp_list in rules.items():
for temp in temp_list:
text = temp.findall(question)
if text:
match_result = (mode, text[0])
break
return match_result
3.最后,从Neo4j中根据问题要素查找并返回问题答案
def parse(question):
match_result = match(question)
if match_result is None:
return None
mode, text = match_result
res = None
cql = None
if mode == 'source':
cql = 'match (p:Poem) where p.content contains "{0}" return p.title, p.content'.format(text)
elif mode == 'author':
cql = 'match (a:Author)-[:WRITE]->(p:Poem) where p.content contains "{0}" return a.name'.format(text)
elif mode == 'tag':
tag_list = jieba.lcut(text)
cql = 'match (p:Poem)-[:LABEL]->(t:Tag) where t.tag in {0} return p.content, t.tag'.format(tag_list)
else:
pass
if cql:
res = neo4j_graph.run(cql).to_data_frame()
return res
后记:
本文只是搭建了一个非常小的图谱demo,后续还有很多地方需要完善,如有遗漏或错误,请大家不吝指出,欢迎交流。