基于百科分类体系构建中文语义词典的工程实践
中文语义词典这件事,做过的人都懂那种憋屈:分词、实体识别、意图理解、同义词扩展,下游一堆任务等着用,可真正拿得出手的中文词库资源要么年代久远、要么覆盖太窄、要么授权卡得死。于是很多人把目光转向一个现成的巨型半结构化语料——公开协作百科的分类系统。从维基分类系统构建中文语义词典,本质上是把几十万条人工维护的"分类页面—条目"归属关系,翻译成机器能用的上下位、近义、领域归属等语义关系,最终产出一份可落地的中文语义词典。这套路子的妙处在于:分类体系不是纯文本,它天然带结构;但它又不是严格本体,噪声一大堆,所以不能当现成语料直接用,必须做清洗、抽取、验证。这篇文章我会按真实动手的顺序拆一遍——从需求判断、数据获取、结构清洗、语义关系抽取、相似度计算,到词典落地和踩坑排查,适合做中文 NLP 基础资源、搜索召回、知识图谱和推荐系统的同学参考,也适合刚上手、想练一遍"从原始语料造资源"全流程的新手。
1. 为什么盯上百科分类体系:需求判断与方案选型
1.1 中文语义词典到底缺的是哪一块
先别急着写代码,先想清楚你缺的是哪种语义。中文语义资源大致分三层:第一层是词法层,分词、词性、命名实体;第二层是词义层,同义、近义、反义、上下位(is-a)、部分整体(part-of);第三层是领域层,也就是"这个词属于哪个话题簇"。这三层里,第一层开源资源最丰富,第三层靠主题模型和向量也能糊出来,最缺的恰恰是第二层——尤其是上下位关系,也就是"猫是一种哺乳动物"这种层级知识。它难在哪儿?难在你没法靠语料共现统计直接算出来。两个词在句子里天天一起出现,那叫相关,不叫上下位。上下位是一种明确的、有方向的、需要世界知识的断言,而人工标注这类关系成本极高,标一万对可能就要一个人干好几个月,覆盖度还远远不够。
百科分类体系刚好补这个洞。它的组织方式本身就是层级式的:一个条目挂在若干分类下,分类又挂在更抽象的分类下,形成一棵(其实是有向有环的)图。这种"条目—分类—上级分类"的结构,本质上是一条条粗糙的 is-a 声明。分类页面上的人工维护者,在给条目归类时,脑子里做的判断就是"这个条目属于这个类别",这和语义学里的上下位判断在意图上高度重合。所以你拿到的不是纯文本,而是一份已经被几十万人手工标注过、但标注规范不一致的层级知识草案。要做的事情,是把它从"草案"变成"词典"。
这里我需要强调一个心态问题:很多人第一次接触这种分类体系,看到几十万分类、上百万归属边,会觉得这是金矿,直接导出成三元组就能用。实测下来,不做清洗直接用的语义准确率会低到你怀疑人生,因为分类体系里混着大量根本不是语义关系的边。所以下面的所有工作,重点不在"拿数据",而在"筛数据"。
1.2 分类体系与人工词典的差异对照
为了让你对投入产出有个预期,我把百科分类体系和我熟悉的人工语义词典做了个对照。这张表建议你在动手前先看一遍,因为它直接决定了你的技术路线和验收口径。
| 维度 | 人工语义词典 | 百科分类体系 |
|---|---|---|
| 覆盖规模 | 数万至十余万词条 | 分类节点数量级在十万以上,归属边百万级 |
| 关系类型 | 明确标注同义、反义、上下位、部分整体 | 主要隐含上下位,其余关系靠推导 |
| 标注一致性 | 有编纂规范和审校流程 | 靠社区共识,规范松散、因人而异 |
| 层级深度 | 通常 5 到 8 层,刻意控制 | 深浅不一,最深处可达十几层,长尾极长 |
| 噪声构成 | 少量错标 | 时间分类、列表分类、维护分类混杂,占比高 |
| 更新方式 | 版本化,周期长 | 持续增量,可定期取快照 |
| 中文特有问题 | 已处理 | 繁简并存、别名重定向、消歧义页需自行处理 |
从这张表能看出核心矛盾:覆盖度换来了噪声。人工词典是"少而准",分类体系是"多而脏"。你的工程目标不是追求百分之百纯净,而是把噪声压到一个可用阈值以下,同时保住覆盖度。我的经验是,上下位关系的抽样准确率能做到八成以上,对绝大多数下游任务就已经很够用了——搜索引擎的查询扩展、意图分类的类目映射,都不需要百分之百正确,错几条不影响整体效果。
1.3 三条技术路线的取舍
真要落地,路线上有三条分岔,选错会浪费很多时间。
第一条是纯规则解析,直接按分类命名模式抽关系,比如"XX学家"的上级是"XX学","XX市"的上级是"XX省"。优点是快、可解释、零算力;缺点是脆,命名模式一变就失效,而且覆盖不了那些命名不规则但语义明确的分类。
第二条是图结构分析,把分类体系当成一个有向图,用入度出度、最短路径、社区发现来推断关系强度和语义簇。优点是全局视野,能自动发现长尾结构;缺点是对图的质量极度敏感,环和噪声节点会污染整个传播。
第三条是向量与模型辅助,用分类名和条目正文训词向量,或者拿预训练模型算分类名的语义相似度,再用图的边做约束。优点是能处理不规则命名,泛化好;缺点是需要算力,而且模型会引入自己的偏差,评估起来更麻烦。
我的建议是先规则、再图、最后模型,按这个顺序叠加,而不是三选一。理由很简单:规则能给你一个高置信度的种子集,图分析能在这个种子集上做扩展和补全,模型只用来处理前两步都搞不定的疑难边。如果你一上来就上模型,遇到问题时你没法判断是数据脏还是模型弱,排查成本会翻倍。这个"由确定到不确定"的推进顺序,是我在好几个资源构建类项目里反复验证过的。
2. 数据获取与结构化:从页面分类到归属三元组
2.1 分类页面的结构解剖
在动手抓之前,得先搞清楚数据长什么样。百科的分类体系由两种页面构成:一种是普通条目页,页面底部挂着若干分类;另一种是分类页本身,页面上列出属于它的子分类和条目。数据获取有两条路:一条走接口按需查询,一条走全量快照离线解析。
接口路线的核心是三个查询:查某个分类下的成员、查某个页面属于哪些分类、查页面的基本信息(重定向、命名空间等)。这里的关键细节是命名空间。分类页在专门的分类命名空间里,普通条目在主命名空间,两个命名空间的编号不同,你必须按编号过滤,否则会把"分类:XX"这种页面当成普通条目塞进词典,后面全是脏数据。另外,分类成员查询返回的字段里有排序键,中文站点的排序键经常带拼音或部首的排序前缀,直接用会把词条名搞乱,需要单独处理。
快照路线的优势是不用一条条请求,一次拿全量,适合反复迭代清洗规则。劣势是文件体积大、解析慢。我的做法是:先用接口小样本摸清结构、把清洗规则调通,再切到快照做全量。这样能避免"全量跑完发现规则错了要重跑"的浪费。
2.2 抓取策略与限速礼貌
写抓取脚本时有两个原则:一是广度优先 + 访问集合去重,因为分类图存在环,不做去重会无限循环;二是限速,公共知识库不是给你一个人用的,请求要克制。
下面这段是我常用的分类树遍历骨架,用队列做广度优先,visited 集合防环,请求之间加固定间隔:
import time import requests API_BASE = "https://<your-wiki-host>/w/api.php" HEADERS = {"User-Agent": "semantic-dict-builder/0.1 (contact: example)"} SLEEP = 0.2 # 每次请求间隔,别调太小 def fetch_category_members(cat_title, max_retry=3): """拉取某个分类下的直接成员(子分类 + 条目)""" params = { "action": "query", "list": "categorymembers", "cmtitle": cat_title, "cmlimit": "500", "cmprop": "ids|title|type|sortkey", "format": "json", } for attempt in range(max_retry): try: resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=20) data = resp.json() return data.get("query", {}).get("categorymembers", []) except Exception: time.sleep(1.5 * (attempt + 1)) return [] def crawl_tree(root_cat, max_depth=5): visited, edges, queue = set(), [], [(root_cat, 0)] while queue: cat, depth = queue.pop(0) if cat in visited or depth > max_depth: continue visited.add(cat) for m in fetch_category_members(cat): # type: page / subcat / file edges.append({ "parent": cat, "child": m["title"], "child_type": m["type"], "sortkey": m.get("sortkey", ""), }) if m["type"] == "subcat": queue.append((m["title"], depth + 1)) time.sleep(SLEEP) return edges这段代码有几个刻意的设计点。max_depth是为了控制爆炸半径——分类图深度不均,不设上限的话有些分支能跑出十几层,而十几层的下级分类语义已经非常稀薄,对词典价值不大。child_type一定要保留,因为子分类和条目在后续处理里走完全不同的逻辑:子分类产生的是"分类—分类"的层级边,条目产生的是"条目—分类"的归属边,两者在词典里分别对应概念层级和实例归属,混在一起用会出问题。至于sortkey,先存着,后面处理中文排序和别名时有用。
注意:抓取前先看清楚目标站点的接口使用条款和请求频率建议,别把公共资源当自家服务器压。做个十万级节点的图,按上面的间隔慢慢跑,一两个小时就完事了,不值得为省时间被封。
2.3 清洗第一关:把不是语义的东西剔出去
数据拿到手,第一件事不是抽关系,而是删。分类体系里混着大批边,它们在图里合法,在语义上却是垃圾。我按危害程度排了个序:
第一类是时间分类。像"2019年"、"1990年代"、"20世纪"这种,实际上是事件发生时间的标注,不是上下位关系。如果不删,你会得到"某位数学家 is-a 1950年代"这种荒唐结论,更糟的是这类分类节点连极度高,会在图传播里把一堆不相关的词拉到一起,直接污染相似度计算。这是最容易被忽视、危害最大的一类噪声。
第二类是维护性分类。这类分类的存在目的是管理页面而不是描述语义,比如标记条目状态、标注来源问题、区分页面类型的那些。它们通常在分类页上带隐藏标记,接口查询时可以显式排除隐藏分类,这是个很实用的开关。
第三类是列表与索引分类。名字里带"列表""索引""词条"的分类,本质是导航页的集合,不是概念层级。
第四类是人名类强分类。像"在世人物"这种,覆盖面极广但语义价值极低,留着只会让所有人名节点共享一个超级父节点,破坏层级结构。
清理顺序建议是先删时间分类,再删隐藏分类,再按命名模式删列表类。每删一类,统计一下边数变化,能直观看出你砍掉了多少噪声比例。在模拟项目里,单是时间分类这一项,通常就能砍掉可观比例的分类边,而且砍完之后图的连通结构明显更干净了。
2.4 中文特有的三个坑
做中文和做英文,最大的差别在数据预处理,这块的坑必须提前说。
第一个坑是繁简并存。中文站点的内容可能来自不同地区的贡献者,同一个概念可能以简体、繁体两种形态同时存在,甚至同一个词条内部繁简混用。如果你的词典要做检索用,必须做统一的形态归并,否则"计算机"和"計算機"会变成两个互不相干的节点,检索召回白白损失一半。做法是先做一轮繁简转换统一到一种形态,再做去重合并,合并时保留两个原始写法作为别名。
第二个坑是重定向和别名。同一个概念常有多条路径指向同一个页面,比如俗称、错写、旧称。接口查询时可以请求自动跟随重定向,但你要把原始名和最终名都记下来,因为别名本身就是宝贵资源——一张语义词典如果没有别名表,实用性会大打折扣。我的做法是单独维护一张别名表,从重定向关系里批量抽取,顺带还能补一批同义关系。
第三个坑是消歧义页。同一个词可能对应多个概念,站点会用消歧义页把它们分开。这类页面在分类体系里通常是"挂在很多分类下但自身没有实质内容"。如果不剔除,一个词会被同时归到十几个不相干的类别里,相似度计算直接崩掉。识别方式是看页面是否带消歧义标记,接口查询可以拿到页面所属模板,按模板名过滤即可。
这三个坑处理完,你的数据才算真正"可计算"。我见过不少人跳过这一步直接抽关系,最后评估时发现模型学到了大量形态噪声,回头重做,浪费的时间远超预处理耗时。
3. 语义关系抽取:把分类边翻译成语义
3.1 上下位关系的判定:规则打底
到这一步你手上有了一张干净的图,但图上的边只是"归属",还不是"上下位"。需要一层翻译。翻译的核心问题是:哪条归属边能当 is-a 用?
最可靠的一类边是分类与分类之间的层级边。如果"某类哺乳动物"挂在"哺乳动物"下面,这条边几乎可以确定是上下位关系,因为分类体系的分类层级本身就是按概念抽象度组织的。这类边的准确率最高,应该作为种子集保留。
条目与分类之间的边就复杂了。条目名是具体概念,分类名是类别,直觉上也是 is-a,但实际上有很多是"主题归属"而非"类别归属"——比如某个人物条目挂在某个学科分类下,更接近"研究领域"关系而不是"是一种"。要区分这两种,我的经验是用命名模式 + 图结构双重验证:命名模式上,看分类名是否带明确的类别后缀(学科、职业、地点类型、技术门类等);图结构上,看这个分类有没有更抽象的父分类,以及它的子节点是否同质。两边都对上,才判为高置信度。
这里必须提醒一句:不要追求一条规则通吃。中文分类命名五花八门,规则一定是多条并存、各自覆盖一段。我的做法是把规则写成"加分项"而不是"一票通过",每条规则给一个权重,累计超过阈值才输出。这样规则之间可以互相纠错,单条规则误判不会直接污染结果。
3.2 图结构分析:用全局视角补全和裁剪
规则跑完之后,你会得到一个高置信度但覆盖有限的种子集。接下来用图结构做扩展。
首先是度分析。统计每个分类节点的入度和出度。入度极高、名字又特别泛的分类(想想那些"各种主题"类的超级节点),通常是维护性的伞状分类,应当降权甚至剔除;入度极高但语义明确的分类(比如"生物"这种),保留但要做路径长度归一化,否则所有生物词条之间的相似度都会趋近于一。出度极低的节点是叶子分类,往往是最具体的概念类,价值高但信息量少,可以往上合并。
其次是社区发现。把图当无向图跑一遍社区划分,你会看到清晰的话题簇浮现出来——学科簇、地理簇、职业簇、艺术簇。这些簇天然就是词典的"领域标签"层。这一步的实用价值在于:当你要给一个未登录词猜领域时,直接查它所属分类落在哪个社区就行,比训主题模型快得多,而且可解释。
最后是路径分析。计算任意两个分类节点之间的最短路径和最近公共祖先。这两个量后面算相似度要用,也顺便能帮你发现异常——如果两个明显不相干的概念之间路径极短,说明中间有个超级节点在捣鬼,回去查它。
3.3 相似度计算:三种口径的取舍
语义词典里最常被下游调用的能力,其实是"给两个词算个相似度"。基于分类图算相似度,常见三种口径,各有适用场景。
第一种是路径距离。直接看两个节点在图上的最短跳数,跳数越少越相似。优点是简单直观;缺点是严重依赖图的密度,深层的两个近义词可能因为中间隔了好几层,距离反而比表层的两个远亲更大。
第二种是最近公共祖先的深度法。用最近公共祖先的深度和两节点各自深度的比值来算,公式大致是:
sim(a, b) = 2 * depth(LCA(a, b)) / (depth(a) + depth(b))这个式子的直觉是:两个概念共享的祖先越深(越具体),它们越相似;同时用两边深度之和做归一化,避免深节点占便宜。实测下来,这个口径在层级清晰的子图上效果好,是性价比最高的选择。
第三种是信息量法。用节点在语料中的出现概率取负对数当"信息量",共享祖先的信息量越大越相似。公式是IC(c) = -log P(c),其中P(c)用该分类及其子树下覆盖的条目数占全体的比例来估。这个口径的好处是能自动压低那些超级泛化节点的影响——因为它们概率高、信息量低,不怎么贡献相似度。
import math from collections import defaultdict def compute_ic(node_leaf_count, total_leaf): """node_leaf_count: 该节点子树覆盖的条目数""" p = max(node_leaf_count, 1) / total_leaf return -math.log(p) def resnik_sim(ic_a, ic_b, ic_lca): """最经典的信息量口径:取共享祖先的信息量""" return ic_lca def lin_sim(ic_a, ic_b, ic_lca): return 2 * ic_lca / (ic_a + ic_b) if (ic_a + ic_b) > 0 else 0.0 def jcn_sim(ic_a, ic_b, ic_lca): denom = ic_a + ic_b - 2 * ic_lca return 1.0 / (denom + 1.0) if denom >= 0 else 0.0我在实际项目里的做法是以第二种为主,第三种为辅:主用公共祖先深度法保证响应速度,对少数深度差异悬殊的词对,再用信息量法做一次修正。第三种方法的还要注意一个前提——子树条目数得算准,这个统计要在清洗后的干净图上做,用脏图算出来的概率全是偏的。
3.4 阈值怎么定:别拍脑袋
所有基于规则和图的方法最终都要落到阈值上,而阈值不能凭感觉。我用的方法是小样本标注 + 阈值扫描。
具体流程:从候选关系里随机抽 300 到 500 条,人工标注是否正确,得到一份小标注集;然后让阈值从低到高滑动,每档计算精确率和召回率的调和平均;选曲线上拐点最明显的那一档。这个流程跑一轮大概半天到一天,但它能帮你省下后面反复调参的好几天。
有个经验值可以给你当起点:做上下位关系抽取时,我对单条规则输出的正样本,会要求累计权重超过满分的六成;做相似度过滤时,公共祖先深度法的阈值大约在中等偏上的位置效果最稳。但这只是起点,不同领域的图密度差异很大,生物类和艺术类的合适阈值能差出一截,所以分领域各扫一遍阈值是值得的。扫描结果一定要留档,因为后面数据增量更新时,图结构会变,阈值需要重新确认。
4. 词典落地:结构设计、验证与工程化
4.1 词典的数据结构怎么设计
结构设计取决于你要怎么用。如果只是给下游任务做查询扩展,一张扁平的"词—上位词—权重"表就够了;如果要支持多跳推理和路径查询,就得上图结构。我一般会做双层:关系表 + 图存储。
关系表存三张:词条表(词名、归一化形态、别名、所属领域)、关系表(头词、尾词、关系类型、置信度、来源边)、别名表(标准名、别名、别名类型)。关系表和别名表分开是关键——很多人把别名塞进关系表,结果查询时到处要做特殊判断。分开之后,查询逻辑干净得多,别名扩展也变成一次简单的联表。
置信度字段一定不能省。词典用起来时,下游会需要按置信度过滤:做召回扩展时可以宽进,做严格的类目映射时必须严出。同一个词库,两种用法,靠的就是这个字段。
存储选型上,几十万节点、百万级边这个量级,其实很多方案都扛得住。要快速查询就用轻量关系库加索引;要做多跳路径查询就上图库;要做全文模糊匹配就上检索引擎。我的常规组合是关系库存主数据、搜索引擎存别名的模糊匹配索引,图查询用内存图结构现算——因为这个规模的图直接加载进内存也就几十兆,没必要为它单独部署一套图服务。
4.2 评估:人工抽样之外还得看下游
词典这种东西,光看准确率是不够的。我一般做三层评估。
第一层是关系级抽样。就是前面说的,每个关系类型各抽几百条人工核对精确率,同时用一个独立的召回探针估召回——从原文语料里随机取一批明确存在上下位关系的词对,看你的词典能不能覆盖到。
第二层是稳定性检查。检查图的连通性、环的数量、超级节点的影响范围。这些指标不直接反映准确率,但能反映你词典的"健康度"。环太多说明清洗不彻底,超级节点太大说明伞状分类没压住,这两项都会让相似度计算失准。
第三层是下游任务验证。这层最有说服力。找一个你手上有标注数据的任务,比如短文本分类或查询意图识别,做 A/B:一组用词典做特征扩展,一组不用,看指标提升。提升不明显不一定是词典不好,可能是词典和任务的领域不匹配——这时候回去看第一层的分领域准确率,往往能定位问题。
这三层评估的节奏是:先第一层快速迭代规则,再第二层做健康度体检,最后第三层做终极验收。别跳过第二层,我吃过这个亏——词典抽样准确率看着漂亮,结果一做相似度任务就爆炸,查了半天发现是几个超级节点把整个图拉平了。
4.3 增量更新与版本管理
分类体系是活的,天天有人在改。你的词典要么定期重建,要么做增量合并。全量重建简单粗暴但浪费算力,增量合并省资源但要处理冲突。
我的做法是按月取快照,做差量合并。合并时有三种情况要分别处理:新增的边直接加,删除的边标记失效而不是物理删除,修改的边保留历史版本。物理删除是个坏习惯,因为下游可能还在用旧版本,突然消失的关系会让线上逻辑报错。标记失效加一个有效时间区间字段,就能保证任何时候查询都能拿到"当时"的正确答案。
版本号也建议跟着主版本走,词典的 schema 一旦变化(比如新增了关系类型),版本号必须跳,别让下游在不知情的情况下吃了格式变更的亏。
还有一点容易被忽略:更新要能回滚。增量合并跑出问题时,你得能退回上一个可用版本。所以每次合并前做一次快照备份,成本极低,救命的概率不低。
5. 踩坑实录与常见问题排查
做这类项目,坑的分布其实很集中。我把实际遇到的问题整理成一张速查表,按现象、原因、处理方式三列排,方便你遇到类似情况时对号入座。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 分类图遍历跑不完,内存持续上涨 | 分类图存在环,访问集合没生效或键值不统一 | 检查去重用的键是否做了繁简与大小写归一 |
| 相似度算出来所有词都差不多 | 超级父节点把所有节点拉平 | 找出高入度伞状分类,降权或剔除,重算深度 |
| 明明很相近的词相似度很低 | 两个词挂在不同的分类分支下,图上距离远 | 补一层基于别名和重定向的横向连接,做路径短路 |
| 抽样准确率很高但下游没提升 | 词典领域和任务领域不匹配,或置信度门槛不当 | 分领域看准确率,下游放宽或收紧置信度门槛 |
| 同一个概念出现多条重复记录 | 繁简、别名、重定向未做归并 | 建别名表,统一归一化形态后再入主表 |
| 时间类词条关系混乱 | 时间分类未剔除 | 按命名模式批量剔除时间分类后重建 |
| 接口请求频繁失败 | 未做重试与限速 | 加指数退避重试,降低并发 |
| 增量合并后查询变慢 | 失效标记堆积,索引未重建 | 定期归档失效关系,重建索引 |
除了表格里这些,还有两个我个人觉得很值得单独说的经验。
一个是别过早追求关系类型的丰富度。很多人一开始就想把上下位、同义、反义、部分整体全抽出来,结果每条线都做不深。我的建议是先把上下位这一条做扎实,它是整个词典的骨架,其他关系很多可以从它推导——比如同层同父节点的兄弟概念,往往就是近义或平行概念,能批量产出候选。先把骨架立住,后面加肉才顺。
另一个是保留原始出处。每条关系都要记清楚它是从哪条边、哪条规则来的。这件事在开发阶段看起来多余,但当你需要定位"为什么这个词被判成了那个词的上位词"时,没有出处就只能全量重跑。我在后期排查里靠这个字段省下的时间,比当初加它花的时间多得多。
提示:清洗规则的顺序会影响结果。先删时间分类再算深度,和先算深度再删时间分类,得到的层级结构完全不同。建议固定一套处理顺序并写进文档,避免不同批次数据口径不一致。
最后分享一个我在实际操作中的体会:这类"从半结构化语料造资源"的活,真正的难点从来不是算法,而是判断力——判断哪条边是语义、哪条是噪声,判断阈值该松还是该紧,判断评估结果说明的是词典问题还是任务问题。算法部分网上资料很多,判断力只能靠一轮轮抽样、核对、回头改规则练出来。所以别怕前期慢,把清洗和抽样这两步做厚,后面所有环节都会顺。