把一部古典小说建模成知识图谱:红楼梦图鉴的数据、本体和单文件 HTML
最近做了一个 side project:《红楼梦·人物事件地理图鉴》,把《红楼梦》前八十回整理成一个可交互的网页------人物谱、逐日事件、地图、诗词检索,再加一张知识图谱。免费、开源。
这篇不讲红学,主要聊工程上的几个问题:数据怎么组织、本体怎么设计、古代的时辰和地名怎么"翻译"成程序能处理的东西,以及为什么最后交付的是一个单独的 HTML 文件。
一、数据来源
底本是《红楼梦脂评汇校本》(甲戌本系统),回目、正文摘句、脂批都依此本。我从版面里解出了前八十回的正文和批语,放在 data/chapters.json。
按各回统计字段 n_chars 加总,正文共 623,645 字;n_comments 加总,批语 5,634 条(其中夹有回目标记,不全是批语正文)。第十七、十八回在汇校本里未分回,所以仍是 80 条章节记录。
人物与情节插图取自清代改琦《红楼梦图咏》和孙温《绘全本红楼梦》,以 base64 形式嵌进页面(img 里共 255 张),每张都有 imgsrc / imgcap 记录出处和图注。
二、数据结构
仓库 data/ 下有四个文件。
1. honglou_data.json:页面用到的全部结构化数据,顶层键是:
css
people, places, events, relations, quotes, years, eras, maps,
chapters, gems, days, onto, china, img, imgsrc, imgcap
数量:people 115、places 54、events 51、relations 163、quotes(脂批)14、eras 7、gems(诗词曲赋判词等)117、days 71。一条人物记录长这样(节选):
json
{
"id": "jiajing", "name": "贾敬", "fam": "贾",
"role": "宁府老爷,袭爵后弃家好道",
"nick": ["炼丹翁"], "place": "玄真观",
"birth": -25, "death": "红楼十七年四月廿六夜", "death_ch": 63,
"mentions": 20, "first": 2,
"chapters": [2, 10, 13, 18, 53, 63, 64], "cls": "zhuzi"
}
人物关系用了最省事的四元组:[主体, 客体, 关系, 备注]。
json
["jiamu", "baoyu", "祖孙", "隔代最疼"]
细粒度的关系词(祖孙、叔侄、翁婿......)再通过 onto.rel2prop 映射到本体里的粗粒度属性,比如 "祖孙": "qinshu"(血亲眷属)。这样页面上显示"祖孙"这样贴近原文的词,图谱里又能按大类聚合。
2. chapters.json :80 条,每条字段为 num, title, start_pdf, end_pdf, body, comments:
json
{ "num": 1, "title": "甄士隐梦幻识通灵贾雨村风尘怀闺秀",
"start_pdf": 12, "end_pdf": 28,
"body": "甄士隐梦幻识通灵 贾雨村风尘怀闺秀① 列位看官,你道此书从何而来?......" }
start_pdf / end_pdf 保留了在原 PDF 中的页码,方便回查。
3. mentions.json :只有一个键 first_seen,记录 159 个姓名/称呼的出现统计:
json
"贾母": { "chapter": 3, "count_at_first": 41, "total": 1717,
"chapters": [3, 4, 5, 6, ..., 80] }
注意人物谱里的"提及次数"不是直接拿这里的数------同一个人有姓名、简称、外号,需要另行归并。
4. ontology.ttl:同一套知识的 OWL / Turtle 序列化,下一节展开。
三、本体设计
本体分两层:模型层(TBox)定义类和属性,实例层(ABox)放具体的人、事、地。前缀用的是 http://example.org/honglou#。
类:7 个顶层类------人物、事件、地点、家族、纪年、脂批、作品;下挂 22 个子类。人物按身份分 7 类(主子姻眷、官宦皇亲、仆婢仆役、优伶伶人、医卜清客、方外僧道、市井平民),事件按性质分 9 类,地点按虚实分 6 类。
turtle
:人物 a owl:Class .
:事件 a owl:Class .
:地点 a owl:Class .
:主子姻眷 rdfs:subClassOf :人物 .
:诗社雅集 rdfs:subClassOf :事件 .
:园林院苑 rdfs:subClassOf :地点 .
对象属性 :共 21 个。其中人物---人物关系 10 个:血亲眷属、夫妻妾室、主仆役使、朋友知交、情缘私恋、仇怨对立、师恩庇护、交易勾结、事缘交往、泛关联;其余把不同类型连起来,如 参与(人物→事件)、发生于(事件→地点)、居于、撰作、所咏、题于、位于。
turtle
:血亲眷属 a owl:ObjectProperty ; rdfs:domain :人物 ; rdfs:range :人物 .
:参与 a owl:ObjectProperty ; rdfs:domain :人物 ; rdfs:range :事件 .
:发生于 a owl:ObjectProperty ; rdfs:domain :事件 ; rdfs:range :地点 .
实例层直接引用:
turtle
:jiamu :血亲眷属 :baoyu . # 祖孙:隔代最疼
:e3 a :身世遭际 ; :hasTitle "元宵失英莲·葫芦庙火" ;
:hasSeason "红楼元年·元宵/三月十五" ; :inChapter 1 ;
:inEra :era1 ; :occursAt :gusu .
用 rdflib 解析了一下实际数据:
python
import rdflib
g = rdflib.Graph(); g.parse("data/ontology.ttl")
len(g) # 3428
整个 .ttl 文件解析出 3428 条 RDF 三元组(含 TBox 声明、类型断言和数据属性)。页面上图谱显示的"3311 条三元组"是 onto.triples 字段里的统计口径,两者不是同一个数,这里如实说明。实例里用到的人物关系属性有 9 种(泛关联 声明了但目前没有实例),最多的是 参与 289 条、血亲眷属 54 条、主仆役使 28 条。
四、时间建模
小说里的时间是"红楼纪年 + 农历 + 时辰",程序没法直接排序。我的做法:
- 以宝玉出生那年为"红楼元年",
years和eras(7 段)给出每年覆盖的回次; days里 71 条逐日记录,每条有y(红楼纪年)、m、d、mo(数值月份,用于排序);- 原文没写明日期、靠情节推定的,
est: true,页面上标"约 / 推定"------71 条里有 42 条是推定的; - 写明时辰的,用
tm_m字段附上现代钟点,共 29 条。
比如第十四回"凤姐协理宁国府":
json
{ "id": "d015", "y": 12, "m": "9月", "d": "约26日", "ch": 14,
"title": "凤姐协理宁国府", "ev": "e13", "pl": "ningfu",
"tm": "卯正二刻点卯·戌初查夜",
"tm_m": "卯正二刻(6:30)点卯 · 巳正(10:00)早饭 · 午初刻(11:15)领牌回事 · 戌初(19:00)查夜" }
每个时辰两小时,分"初""正",每刻 15 分钟:卯正是 6:00,加二刻就是 6:30。
要注意:51 条 events 是情节单元,71 条 days 是带日期的流水,二者通过 ev 字段互相引用,并不是同一份清单。
五、地理建模
maps 有三层:city(天下形势)、fu(荣宁二府)、yuan(大观园)。第一层的底图是今日省级行政区划(阿里云 DataV GeoAtlas),地名落点采用周汝昌"京---宁双城"说:京都即今北京,金陵即今南京,姑苏、扬州是实录地名。
json
{ "id": "gusu", "name": "姑苏", "kind": "city",
"region": "江南·今江苏苏州", "cls": "zhoufu" }
有争议的地方不硬编码成定论:大如州、平安州、黑山村、大荒山等推考落点在图上用虚线标出,注明"存疑"或"一家之言";太虚幻境是梦境,不入地理。荣宁二府和大观园的平面则按黛玉进府路线、游园顺序约拟,不是实测。
六、单文件交付 + GitHub Pages
最终产物是一个约 4.4 MB 的 index.html,数据、图片、省界都内嵌,页面里没有引用任何外部 URL。好处很实际:
- 不用安装、构建,也不需要后端,双击就能打开;
- 断网能看,发给别人一个文件就够了;
- 部署到 GitHub Pages 只要把 Pages 指到
main分支根目录。
data/ 里那几个 JSON 和 TTL 是给想复用数据的人准备的,页面本身不依赖它们。
七、开源与贡献
项目免费开源,代码和数据都在仓库里:
纪年、地名、人物关系、引文有疏漏的地方,欢迎提 Issue 或 PR。改动推定日期或有争议的落点时,请保留"约""推定""一家之言"这些标注------这个项目的原则是不把推考写成定论。