Wikidata知识图谱实战:从数据下载到ClickHouse存储优化

📅 发布时间:2026/7/8 2:33:13 👁️ 浏览次数:
Wikidata知识图谱实战:从数据下载到ClickHouse存储优化
1. 从零开始理解Wikidata与知识图谱实战价值大家好我是老陈一个在数据领域摸爬滚打了十多年的老兵。今天我们不聊那些虚无缥缈的概念直接上手干一件“大事”把全球最大的开放知识库——Wikidata——给“搬”到我们自己的数据库里并且让它跑得飞快。你可能听说过知识图谱觉得它很高深是谷歌、百度那些大厂才玩得转的东西。其实不然有了Wikidata这个宝库和我们今天要用的ClickHouse这把利器你自己就能搭建一个强大的知识图谱应用后台。Wikidata是什么简单说它就是一个结构化的、多语言的、谁都可以编辑的“世界事实数据库”。它不像维基百科那样是一篇篇文章而是把知识拆解成“实体-属性-值”这样的三元组。比如“爱因斯坦实体的出生日期属性是1879年3月14日值”。这种结构化的方式正是机器最擅长理解和处理的。目前它的全量数据快照已经接近100GB包含了超过9500万个实体Item和上亿条关系陈述Claim而且每天都在增长。那么我们为什么要费劲下载和处理这近百GB的数据呢实战价值太大了。你可以用它来构建智能问答系统比如用户问“《三体》的作者是谁”系统能直接回答“刘慈欣”你可以用它做知识推理和发现比如找出所有获得诺贝尔奖的华裔科学家你还可以用它作为推荐系统、搜索引擎的底层知识支撑。但这一切的前提是你得有一个能高效存储和查询这些海量结构化数据的“家”。直接处理原始的JSON文件那简直是噩梦一个简单的查询可能就要跑上几个小时。所以我们的目标非常明确把Wikidata的JSON数据经过清洗和转换高效地存入ClickHouse数据库并针对典型的图谱查询进行优化。ClickHouse是一款开源的列式数据库以惊人的查询速度著称特别适合我们这种读多写少、数据量巨大的分析场景。接下来我就带你走一遍完整的实战流程从数据下载的“坑”说起到表结构设计的心得再到让查询速度提升十倍的优化技巧。相信我跟着做下来你不仅能收获一个可用的知识图谱数据仓库更能深刻理解海量数据处理的核心门道。2. 实战第一步高效获取近百GB的原始数据处理数据的第一步永远是拿到数据。对于Wikidata来说这第一步就可能劝退不少人因为它最新的latest-all.json.gz文件已经稳稳地站在了100GB的门槛上。直接从官网下载对于国内网络环境来说这几乎是一个不可能完成的任务速度可能只有几十KB/s还不稳定。我踩过的坑告诉我莽撞地开始下载很可能几天后得到一个损坏的压缩包一切又得重来。这里我分享两个经过实测、稳定可靠的方法。第一个方法是利用云服务器“中转”。你可以在海外的主流云服务商比如AWS、Google Cloud、DigitalOcean上开一台按量计费的虚拟机选择离下载源较近的地区如欧洲。在服务器上使用wget或curl命令直接下载。因为服务器是海外网络下载速度通常可以跑满带宽一两个小时就能搞定。下载完成后再利用rsync或scp将文件同步到你的本地或国内服务器。虽然数据需要传输两遍但全程可控成功率最高。第二个方法是寻找可用的镜像或数据托管平台。有些研究机构或开源社区会定期同步Wikidata的数据快照并提供更友好的下载方式。例如一些学术数据集平台。当然使用这些镜像时务必核对文件的MD5或SHA校验和确保数据完整性。我个人的习惯是无论从哪里下载第一件事就是校验文件。# 示例在Linux服务器上下载并校验 wget https://dumps.wikimedia.org/wikidatawiki/entities/latest-all.json.gz # 下载完成后校验注意Wikidata官网会提供校验文件 wget https://dumps.wikimedia.org/wikidatawiki/entities/latest-all.json.gz.md5 md5sum -c latest-all.json.gz.md5下载下来的latest-all.json.gz是一个巨大的、每行一个JSON对象的文本文件压缩包。请注意千万不要试图在本地解压它解压后的JSON文件会膨胀到接近500GB绝大多数个人电脑的硬盘和内存都吃不消。我们的所有后续处理都应该基于这个压缩流gzip stream进行。Python的gzip模块可以很好地支持流式读取让我们可以像处理普通文件一样一行一行地处理而不需要解压到磁盘。import gzip import json # 正确的流式处理方式 with gzip.open(latest-all.json.gz, rt, encodingutf-8) as f: for line in f: if line.strip() [ or line.strip() ]: continue # 跳过JSON数组的开头和结尾 if line.endswith(,\n): line line[:-2] # 去掉行尾的逗号和换行 try: entity json.loads(line) # 在这里处理每一个实体Item或Property process_entity(entity) except json.JSONDecodeError as e: print(fJSON解析错误: {e}, 行内容: {line[:200]})这里有个关键细节Wikidata的JSON文件是一个巨大的JSON数组但我们可以把它当作每行一个独立JSON对象来处理除了首行的[和末行的]这极大地简化了处理逻辑。在开始正式处理前我强烈建议你先写一个小脚本抽取前几万个实体快速跑一遍看看数据结构和内容预估一下处理时间和可能遇到的问题比如内存溢出、字段缺失等。磨刀不误砍柴工这个步骤能帮你避开很多后期的麻烦。3. 庖丁解牛设计面向查询的ClickHouse表结构数据拿到了接下来就是决定怎么存。表结构设计是性能的基石设计得好后续查询事半功倍设计得不好ClickHouse再快也救不了你。我们的核心思想是根据查询模式来设计表而不是简单照搬原始JSON结构。原始Wikidata的JSON结构非常灵活一个实体Item可以有任意多的属性Claim每个属性下又可能有限定符Qualifier。这种嵌套结构虽然表达能力强但不利于高效的批量分析。我们需要将其“扁平化”和“规范化”。参考了原始文章的思路并加以优化我设计了下面四张核心表这在实际查询中证明是非常高效的。第一张表item实体表。这张表存放所有实体Item的核心元信息也就是那些不管它是什么类型都有的信息。CREATE TABLE IF NOT EXISTS item ( id String, -- 实体ID如 ‘Q42‘ modified DateTime, -- 修改时间从原始时间戳转换而来 labels Map(String, String), -- 多语种标签如 {‘en‘: ‘Douglas Adams‘, ‘zh‘: ‘道格拉斯·亚当斯‘} descriptions Map(String, String), -- 多语种描述 aliases Map(String, Array(String)), -- 多语种别名一个语言可能有多个别名 sitelinks Map(String, String) -- 站点链接如对应维基百科页面的标题 ) ENGINE MergeTree ORDER BY (id) SETTINGS index_granularity 8192;这里我做了几个关键改进1) 将modified字段明确为DateTime类型便于时间范围查询。2) 使用Map类型来存储多语言信息这比用多个字段如name_en,name_zh更灵活扩展性更强ClickHouse对Map类型的查询优化也做得不错。3) 主键ORDER BY直接使用id因为对实体的查询大多直接通过ID进行。第二张表property属性表。结构基本与item表类似但多了一个datatype字段表示该属性值的数据类型如字符串、时间、实体ID等。CREATE TABLE IF NOT EXISTS property ( id String, -- 属性ID如 ‘P31‘ datatype String, -- 数据类型如 ‘wikibase-item‘ labels Map(String, String), descriptions Map(String, String), aliases Map(String, Array(String)) ) ENGINE MergeTree ORDER BY (id);第三张表item_property实体-属性关系表。这是最核心、数据量最大的一张表存储了所有实体拥有的属性及其值。CREATE TABLE IF NOT EXISTS item_property ( id String, -- 该条陈述Claim的唯一ID可选可用于去重 qid String, -- 实体ID如 ‘Q42‘ pid String, -- 属性ID如 ‘P569‘ (出生日期) datatype String, -- 值的数据类型 value_string String, -- 字符串类型的值 value_quantity Float64, -- 数值类型的值 value_time DateTime, -- 时间类型的值 value_item String, -- 实体ID类型的值指向另一个Item rank String -- 陈述的等级如 ‘normal‘, ‘preferred‘ ) ENGINE MergeTree ORDER BY (qid, pid) SETTINGS index_granularity 8192;这里的设计精髓在于将不同类型的值拆到不同的列中value_*。为什么这么做因为ClickHouse是列式存储如果所有值都塞进一个String类型的datavalue字段查询时就需要解析字符串并做类型转换效率极低。而拆分开后查询出生日期P569时可以直接对value_time列进行快速的区间过滤查询身高P2048时可以直接对value_quantity列进行数值比较。ORDER BY (qid, pid)意味着数据首先按实体ID排序再按属性ID排序这样查找某个实体的所有属性或者查找所有拥有某个属性的实体速度都会非常快。第四张表property_qualifier属性限定符表。用于存储属性的附加信息结构类似于item_property但多了一个指向主属性陈述的引用。CREATE TABLE IF NOT EXISTS property_qualifier ( claim_id String, -- 对应 item_property 表中的 id pid String, -- 限定符属性ID datatype String, value_string String, value_quantity Float64, value_time DateTime, value_item String ) ENGINE MergeTree ORDER BY claim_id;通过这四张表我们就把Wikidata复杂的、嵌套的JSON结构转化成了关系型数据库擅长的扁平表结构为后续的高性能分析扫清了障碍。4. 化繁为简将JSON数据高效导入ClickHouse表建好了下一步就是把那100GB的JSON数据按照我们的表结构解析并导入到ClickHouse中。这是整个流程中最消耗计算资源和时间的部分我们需要一个高效、稳定且能容错的流水线。我强烈建议使用Python 多进程/协程的方式来构建这个ETL提取-转换-加载流程。核心思路是主进程负责流式读取巨大的gzip文件然后将解析出的实体对象放入一个队列多个工作进程从队列中领取实体进行解析、转换和批量插入。这里有一个关键技巧不要每处理一个实体就向ClickHouse发起一次插入请求那样网络I/O会成为致命瓶颈。一定要批量插入。下面是一个简化的核心处理逻辑示例import gzip import json from multiprocessing import Pool, Queue from clickhouse_driver import Client def process_entity_batch(entity_batch): 处理一批实体并返回格式化好的数据列表 items, properties, item_props, qualifiers [], [], [], [] for entity in entity_batch: eid entity.get(id) # 1. 处理实体/属性基本信息放入 items 或 properties 列表 # 2. 处理 claims拆解后放入 item_props 列表 # 3. 处理 qualifiers放入 qualifiers 列表 # ... 具体的解析逻辑 ... return items, properties, item_props, qualifiers def worker(input_queue, ch_client): 工作进程函数 batch_size 1000 # 每积累1000个实体处理一次 entity_batch [] while True: entity input_queue.get() if entity is None: # 结束信号 if entity_batch: # 处理最后一批 data_to_insert process_entity_batch(entity_batch) insert_to_clickhouse(ch_client, data_to_insert) break entity_batch.append(entity) if len(entity_batch) batch_size: data_to_insert process_entity_batch(entity_batch) insert_to_clickhouse(ch_client, data_to_insert) entity_batch.clear() def insert_to_clickhouse(client, data): 批量插入数据到ClickHouse if data[0]: # items client.execute(INSERT INTO item FORMAT RowBinary, data[0]) if data[1]: # properties client.execute(INSERT INTO property FORMAT RowBinary, data[1]) # ... 插入其他表在这个流程中有几个坑需要特别注意内存管理流式读取和队列机制保证了我们不会一次性将100GB数据读入内存。但每个工作进程内部积累批次数据时也要注意控制batch_size避免内存激增。通常1000-5000是一个比较平衡的范围。类型转换与容错原始数据中存在大量不规范的格式。比如时间字段可能是2023-00-00T00:00:00Z这样缺失月份日期的值。在转换到ClickHouse的DateTime类型时必须做好异常处理对于无法转换的值可以记录日志并置为NULL或默认值保证流程不中断。连接与重试长时间运行中网络或ClickHouse服务可能波动。在insert_to_clickhouse函数中必须加入重试机制和异常捕获对于插入失败的一批数据可以暂存到本地文件稍后重试。进度监控处理这么大量的数据没有进度条会让人崩溃。可以在主进程中记录已读取的行数并定期打印进度。更好的方式是向一个监控表插入进度状态。整个导入过程根据你的机器性能CPU核心数、内存、磁盘IO、网络可能需要运行数小时到数十小时。耐心是关键。完成后你会拥有一个完全受你掌控、可以任意驰骋的Wikidata知识图谱数据仓库。5. 性能飞跃针对图谱查询的ClickHouse优化策略数据导进去了只是万里长征第一步。要让查询快起来还得针对ClickHouse的特性做一系列优化。经过多次实战我总结出了以下几个最有效的策略。策略一活用物化视图预计算高频查询。知识图谱里有些查询非常经典但计算量巨大比如“找出所有‘人类’Q5的实例”。虽然我们可以在item_property表上写WHERE pidP31 AND value_itemQ5但每次执行都要扫描上亿行数据。这时物化视图就是神器。-- 创建一个专门存储“实例-类型”关系的物化视图表 CREATE TABLE item_instance_of ( qid String, type_id String ) ENGINE MergeTree ORDER BY (type_id, qid); -- 创建一个物化视图自动从 item_property 表同步数据 CREATE MATERIALIZED VIEW item_instance_of_mv TO item_instance_of AS SELECT qid, value_item AS type_id FROM item_property WHERE pid P31 AND datatype wikibase-item AND value_item ! ;创建后所有新的P31关系都会自动同步到item_instance_of表。以后查“所有人类”直接SELECT qid FROM item_instance_of WHERE type_idQ5速度是毫秒级的。你可以为P279子类关系等高频属性创建类似的物化视图。策略二精心设计主键与索引。ClickHouse的表引擎如MergeTree的性能极度依赖于ORDER BY子句主键。它决定了数据在磁盘上的物理排序。我们的设计原则是把WHERE条件中最常出现的字段放在前面。对于item_property表如果你的查询模式是“给定一个实体查它的所有属性”那么ORDER BY (qid, pid)是最优的。如果你的查询模式更偏向“给定一个属性查哪些实体拥有它”例如找所有有出生日期的实体那么ORDER BY (pid, qid)可能更好。实际上很难满足所有场景。这时可以考虑使用投影Projection功能。ClickHouse的投影允许你以不同的排序顺序物理存储另一份数据副本查询优化器会自动选择最合适的投影。ALTER TABLE item_property ADD PROJECTION property_centric_projection ( SELECT * ORDER BY (pid, qid) );添加投影后对于按pid过滤的查询ClickHouse可能会自动使用这个投影从而获得极速响应。策略三使用合适的编码和压缩。ClickHouse默认的LZ4压缩算法在速度和压缩比上取得了很好的平衡对于文本数据居多的Wikidata非常合适通常压缩比能达到3:1甚至更高。对于Map、Array这类复杂类型确保其内部的字符串也使用了高效的编码。策略四分区与分片应对未来增长。目前100GB数据单机尚可处理。但Wikidata数据在不断增长未来可能达到TB级。我们需要未雨绸缪。分区Partitioning可以按实体的修改时间modified字段的月份进行分区。这样当查询最近新增的实体时可以只扫描相关分区大幅减少IO。CREATE TABLE item_partitioned (... 字段同前 ...) ENGINE MergeTree PARTITION BY toYYYYMM(modified) ORDER BY (id);分片Sharding如果数据量或查询并发大到单机无法承受就需要使用ClickHouse集群。可以按实体ID的哈希值进行分片将数据分布到多个节点上。查询时借助分布式表Distributed表引擎来透明地访问所有分片。经过这些优化原本需要分钟级甚至更久的复杂图谱查询大多可以压缩到秒级甚至毫秒级。这才是我们使用ClickHouse的意义所在。6. 实战分析从数据中发现知识的乐趣一切就绪现在让我们用几个实际的例子看看如何在这个优化后的Wikidata数据仓库上进行有趣的分析。你会发现之前所有的辛苦都是值得的。例1构建“人物”知识图谱。我们想知道Wikidata里有多少位“人类”以及他们都有哪些共同属性。-- 首先利用我们预建的物化视图快速找到所有人类实例的ID SELECT count(*) FROM item_instance_of WHERE type_id Q5; -- 结果可能超过800万 -- 然后看看这些“人类”最常拥有的属性是什么Top 10 SELECT pid, count(*) as cnt FROM item_property ip WHERE ip.qid IN (SELECT qid FROM item_instance_of WHERE type_id Q5) AND ip.pid NOT IN (P31, P279) -- 排除实例和子类属性本身 GROUP BY pid ORDER BY cnt DESC LIMIT 10;这个查询可能会返回P21性别、P569出生日期、P19出生地、P106职业等属性。这其实就是“人物”这个类型的隐含Schema。例2探索类型体系与继承关系。利用P279子类关系我们可以尝试自动构建一个类型层次树。-- 找出所有顶层类型没有P279属性的类型 WITH types AS ( SELECT DISTINCT value_item as type_id FROM item_property WHERE pid P31 UNION SELECT DISTINCT qid as type_id FROM item_property WHERE pid P279 UNION SELECT DISTINCT value_item as type_id FROM item_property WHERE pid P279 ) SELECT t.type_id, i.labels[en] as label FROM types t LEFT JOIN item_property ip ON t.type_id ip.qid AND ip.pid P279 LEFT JOIN item i ON t.type_id i.id WHERE ip.qid IS NULL -- 找不到P279属性说明是顶层类型 ORDER BY label;这个查询能帮我们找出像“实体”、“物理对象”、“事件”、“抽象概念”这样的顶层分类。虽然Wikidata的数据有噪声但通过设定支持度阈值比如要求一个类型至少有100个实例我们可以过滤掉大部分不靠谱的“类型”得到一个相对干净的类型体系。例3属性推荐与知识补全。假设我们系统中新增了一个实体“流浪地球电影”我们知道它是“电影”Q11424的一个实例。那么它可能拥有哪些属性呢我们可以从已有的“电影”实例中挖掘。-- 找出“电影”Q11424实例们最常拥有的属性 SELECT pid, count(distinct ip.qid) as instance_count FROM item_property ip WHERE ip.qid IN (SELECT qid FROM item_instance_of WHERE type_id Q11424) AND ip.pid NOT IN (P31, P279) GROUP BY pid HAVING instance_count 100 -- 设定一个支持度阈值比如出现超过100次 ORDER BY instance_count DESC;结果可能会给出P57导演、P58编剧、P86配乐、P495原产国、P577上映日期等属性。这就为我们的新实体提供了属性填写的推荐实现了初步的知识补全建议功能。通过这些例子你应该能感受到将Wikidata数据经过清洗、结构化并存入高性能的ClickHouse后知识的探索和分析变得多么直接和高效。它不再是一个黑盒而是一个可以任你查询、挖掘的金矿。