知识图谱实体对齐
知识图谱里一个老问题:新来的一条实体,是不是图里已有的某个实体?两个数据源各自描述了一批重叠的东西,能比的往往只有名字和一段自然语言简介。某个便宜但粗糙的第一轮(这里是多语言句向量召回)已经挑出了一批「值得细看」的候选对,剩下的是对每一对下判断。
错误合并比漏掉一对更贵:合并以后,两个实体的所有事实都挂到同一个节点上,连向它们的边也一起过来,事后拆开要一条条追溯来源;漏掉一对只是留下一个重复实体。所以判断需要第三个出口:既不敢合并、也不敢丢掉的,交给人。
本文把这个判断写成一个 Score 问题,三个档位就是三种结果,再在同一个请求里附带三个 Noul,告诉复核的人两边在哪个字段上对不上。
一句话结论(全部来自真实运行):在 DBP15K 中英文百科的 420 个产品候选对上,每对一次请求,按「离哪个档位最近」路由,118 对自动写入 sameAs,其中 117 对正确(精确率 99.2%,召回率 100%);49 对送人工复核(全部是基准答案中的「不同实体」),253 对不链接,没有漏掉任何一个真匹配。作为对照,「粗筛排第一就合并」精确率 70.0%、召回率 83.8%。420 次请求共 182,074 个输入 token,费用约 ¥0.0076。
一个候选对(两个实体放进同一个 state)
│
▼ 一次请求,四个问题
├─ Score:两者是什么关系? 0 不同产品 / 1 密切相关但不一定同一个 / 2 同一个
└─ Noul ×3:名称相同?出品方相同?产品类别相同?
│
▼ 取最近的档位
├─ 0 → 不链接
├─ 1 → 人工复核队列(附上三个 Noul:哪个字段对不上)
└─ 2 → 写入 sameAs环境准备
pip install xiangxin-sdk
export XIANGXIN_API_KEY="sk-xx-..."每次调用的原始答案缓存在 results/scores.json,随代码一起提供;重跑 main.py 时命中缓存的候选对不会再发请求,删掉这个文件即可全部重新调用。
本文数字来自 2026-09-25 对 xiangxin-latest 的真实调用(响应中的模型版本为 xiangxin-1.0.0),原始输出见 results/。
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from xiangxin import Noul, RetryPolicy, Score, XiangxinClient
MODEL = "xiangxin-latest"
MAX_WORKERS = 4
client = XiangxinClient(timeout=120.0, retry=RetryPolicy(max_retries=8, backoff_max=30.0))数据:两份百科里的产品条目
候选对来自公开的实体对齐基准 DBP15K(ZH-EN)(Sun 等,ISWC 2017),我们用的是 Hugging Face 上的 matchbench/dbp15k-zh-en。它由中文 DBpedia 和英文 DBpedia 抽取而来(DBpedia 内容以 CC BY-SA 3.0 许可发布),附带 15,000 对人工对齐的「同一实体」链接作为基准答案,以及每个实体的百科摘要。
原数据里大部分是人物、地名、球队。为了贴近「两份产品目录」的场景,我们只保留简介首句形如「X 是……的<产品类别>」的中文实体(专辑/单曲、电子游戏、游戏机、手机、汽车、飞机、处理器/显卡),每类最多 50 个,随机抽 140 个。这个筛选只看中文首句,很粗糙:抽到的 140 个里混进了一个歌手条目(布蘭妮·斯皮爾斯),「系列」类的游戏条目也保留了,我们没有手工剔除。
第一轮粗筛用多语言句向量模型 paraphrase-multilingual-MiniLM-L12-v2:把每个中文实体的「名称 + 简介」和英文 DBpedia 全部 19,280 个带摘要的实体比较,取余弦相似度最高的 3 个作为候选。于是得到:
| 数量 | |
|---|---|
| 中文产品实体 | 140(专辑/单曲 50、电子游戏 33、手机 21、飞机 17、游戏机 10、汽车 6、处理器/显卡 3) |
| 候选对 | 420 |
| 其中基准答案为同一实体 | 117 |
| 粗筛 top-3 召回率 | 83.6%(23 个中文实体的正确对应没进前 3) |
粗筛挑出来的负例相当难:Xbox One 对 Xbox 360、Radeon Rx 200 对 Rx 300、HTC One Mini 对 One Mini 2、单曲对同名专辑。生成候选对的脚本是 prepare_data.py,结果 data/candidate_pairs.json 随代码提供。
文本保持原样:中文简介有繁有简,英文条目名带消歧括号,简介截断在 240(中文)/ 400(英文)个字符。模型看到的第一对是这样的:
{
"entity_a": {
"名称": "AH-1眼鏡蛇直升機",
"简介": "AH-1眼镜蛇是一款由贝尔直升机公司所製造的攻击直升机。AH-1的發動機、传动和旋翼系统与UH-1易洛魁相同。……"
},
"entity_b": {
"name": "Bell AH-1 Cobra",
"abstract": "The Bell AH-1 Cobra is a two-blade, single engine attack helicopter manufactured by Bell Helicopter. ……"
}
}一对一次请求,所以花费跟着候选对数量走,与两边目录的总规模无关。
问题设计:一个 Score + 三个 Noul
两个实体放进同一个 state,分别叫 entity_a 和 entity_b,这样问题问的是这一对,而不是某一边。
下面三个档位描述就是全部的决策规则:每个档位对应一个结果,文件里没有任何需要拟合的阈值常数。你可以在看到任何一个分数之前就把它们写好,而一个要在数据上调的阈值做不到这一点。中间档是最值得斟酌的:它覆盖同系列的不同型号、同一产品的变体或特别版,以及名称可能指任何一方的情况,让这些对走到人工,而不是被合并或丢掉。
用 Score 而不是 Noul,是因为我们想给每个结果(尤其是中间那个)直接挂上语义描述;Noul 只能靠对概率切阈值间接做到。用 Choice 则会丢掉三个结果之间的顺序关系(参见 Score)。
三个 Noul 各比较一个字段:名称、出品方、产品类别。它们搭同一个请求,分数落在中间档时给复核的人看。要换到别的数据上,只需要改写 QUESTIONS 和 LEVELS。
LEVELS = [
"两者是不同的产品。",
"两者是密切相关的产品,但不一定是同一个:同一系列的不同型号或代次、同一产品的变体或特别版,"
"或者名称可能指其中任何一个。",
"两者是同一个产品。",
]
OUTCOME = {0: "不链接", 1: "人工复核", 2: "写入 sameAs"}
QUESTIONS = {
"link_state": Score(
instructions="`entity_a`(来自中文百科)和 `entity_b`(来自英文百科)作为产品是什么关系?",
criteria=LEVELS,
),
"same_name": Noul(instructions="两个实体的名称指的是同一个名字吗(考虑翻译和音译)?"),
"same_maker": Noul(instructions="两个实体出自同一个出品方吗(制造商、开发商、发行商或艺人)?"),
"same_kind": Noul(instructions="两个实体属于同一类产品吗(例如都是专辑、都是手机、都是战斗机)?"),
}
PAIRS = json.loads(Path("data/candidate_pairs.json").read_text(encoding="utf-8"))
BY_ID = {p["id"]: p for p in PAIRS}
def score(pair_id: str) -> dict:
"""一个候选对 -> 一次请求 -> 分数 + 三个 Noul。"""
pair = BY_ID[pair_id]
resp = client.system_one(
state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]},
questions=QUESTIONS,
model=MODEL,
)
link = resp.answers["link_state"]
return {
"score": link.score,
"probabilities": link.probabilities,
"confidence": link.confidence,
"properties": {k: resp.answers[k].noul for k in QUESTIONS if k != "link_state"},
"input_tokens": resp.usage.input_tokens or 0,
}
def route(score_value: float) -> str:
"""全部决策规则:离哪个档位最近就是哪个结果。"""
return OUTCOME[min(int(score_value + 0.5), len(LEVELS) - 1)]score 是各档概率的期望(Σ i·pᵢ),所以 route 实际上在 0.5 和 1.5 两处切开。
先看五对
p229 score 1.75 confidence 0.81 -> 写入 sameAs (基准答案:同一实体)
A: 任天堂明星大亂鬥3DS/Wii U
B: Super Smash Bros. for Nintendo 3DS and Wii U
name 0.89 maker 0.82 kind 0.85
p201 score 0.36 confidence 0.66 -> 不链接 (基准答案:不同实体)
A: Xbox One
B: Xbox 360
name 0.15 maker 0.95 kind 0.78
p224 score 1.09 confidence 0.45 -> 人工复核 (基准答案:不同实体)
A: 任天堂3DS
B: New Nintendo 3DS
name 0.67 maker 0.96 kind 0.87
p173 score 1.14 confidence 0.46 -> 人工复核 (基准答案:不同实体)
A: SWEET 19 BLUES (單曲)
B: Sweet 19 Blues
name 0.58 maker 0.53 kind 0.34
p056 score 1.58 confidence 0.73 -> 写入 sameAs (基准答案:不同实体)
A: Don't cry anymore
B: Miwa (singer)
name 0.78 maker 0.77 kind 0.43p229名字一边是中文译名、一边是英文原名,照样判为同一个。p201同一家厂商、同一类产品,但名称指向不同的两代主机,same_name只有 0.15,直接不链接。p224是「原版 3DS」对「New 3DS」:同厂、同类、名字只差一个前缀,正好是中间档描述的「同一系列的不同型号」,送人工。p173中文条目是安室奈美惠 1996 年 8 月的单曲,英文条目是同年 7 月的同名专辑。same_kind只有 0.34,复核的人一眼就能看出分歧在类别上。p056是整批里唯一一个错误合并,下文细说。
路由全部 420 对
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
results = list(pool.map(lambda p: score(p["id"]), PAIRS))
outcomes = [route(r["score"]) for r in results]| 结果 | 对数 | 占比 | 其中基准答案「同一实体」 | 其中「不同实体」 |
|---|---|---|---|---|
| 写入 sameAs | 118 | 28.1% | 117 | 1 |
| 人工复核 | 49 | 11.7% | 0 | 49 |
| 不链接 | 253 | 60.2% | 0 | 253 |
以基准答案里的 117 个「同一实体」为正例,和两个基线比:
| 方法 | 精确率 | 召回率 | F1 | TP / FP / FN |
|---|---|---|---|---|
| 象信:写入 sameAs(最近档位) | 0.992 | 1.000 | 0.996 | 117 / 1 / 0 |
| 象信:sameAs + 人工复核都算「可能相同」 | 0.701 | 1.000 | 0.824 | 117 / 50 / 0 |
| 象信:不设中间档,score ≥ 1.0 即合并 | 0.907 | 1.000 | 0.951 | 117 / 12 / 0 |
| 基线:粗筛排第 1 名即合并 | 0.700 | 0.838 | 0.763 | 98 / 42 / 19 |
| 基线:余弦 ≥ 0.74(在基准答案上挑的事后最优阈值) | 0.561 | 0.709 | 0.626 | 83 / 65 / 34 |
余弦阈值那一行是「偷看了答案」才挑出来的,实际部署拿不到,即便如此也差得很远:句向量能把候选找回来,但分不清 Xbox One 和 Xbox 360。
分数分布(每格 0.1,两条虚线位置在 0.5 和 1.5):
0.0–0.1 ████████████████████████████████████████ 146
0.1–0.2 ███████████ 39
0.2–0.3 ████████ 29
0.3–0.4 █████ 17
0.4–0.5 ██████ 22
- - - - - - - - - - - - - - - - - - - - - - - - 0.5
0.5–0.6 ████ 16
0.6–0.7 ███ 10
0.7–0.8 ██ 6
0.8–0.9 █ 4
0.9–1.0 █ 2
1.0–1.1 █ 4
1.1–1.2 ▏ 1
1.2–1.3 █ 4
1.3–1.4 ▏ 1
1.4–1.5 ▏ 1
- - - - - - - - - - - - - - - - - - - - - - - - 1.5
1.5–1.6 ▏ 1
1.6–1.7 █ 4
1.7–1.8 █ 5
1.8–1.9 ████████ 30
1.9–2.0 █████████████████████ 78两个切点并不一样拥挤。距上切点 1.5 在 0.1 以内的只有 2 对,而决定「是否写进图谱」的正是这个切点;距下切点 0.5 在 0.1 以内的有 42 对,它只决定人工要不要看一眼。这两个数都不是调出来的,而是档位措辞的结果:中间档写得越宽,越多对会从「不链接」挪进人工队列。
人工队列里的 49 对,三个 Noul 判为「不一致」(< 0.5)的次数:名称 36 次、产品类别 21 次、出品方 5 次。也就是说,送到复核的大多是同一厂商/艺人的另一个型号、另一张唱片,这和中间档的写法一致。复核的人拿到的不只是一个 1.09 分,还有「名字对不上、类别对得上」这样的线索。
420 次请求共 182,074 个输入 token(每对平均约 434 个),按 ¥0.042 / 百万输入 token、输出免费计算,费用 ¥0.0076。本次运行时多个任务共用同一个 key,4 路并发下频繁遇到 429 / 529 重试,每次请求的平均总耗时 6,543 ms,不代表空闲时的延迟。
讨论
唯一的错误合并。 p056 的中文条目是 miwa 的出道单曲《don't cry anymore》,英文条目是歌手 miwa 本人,英文摘要第一句就提到她「以单曲 "Don't Cry Anymore" 出道」。名字、出品方都对得上,模型把「人」和「她的作品」合并成了一个(score 1.58,刚过 1.5)。同一请求里 same_kind 只有 0.43,已经察觉到类别不同。如果再加一条规则「same_kind < 0.5 时不自动合并、改送人工」,这个错误会被挪进人工队列,本批数据上 sameAs 的精确率和召回率都变成 1.000。但要说清楚:这条规则是看到这个错误之后才加的,在 420 对上只挪动了这 1 对,不能当作它普遍有效的证据;它的好处是符合直觉(「不同类的东西不该合并」),而且不需要额外请求。
召回率 100% 是在候选对之内。 象信只判断粗筛给它的对。140 个中文实体里有 23 个的正确对应没进粗筛前 3,这些是第一轮就丢掉的,端到端来看只找回了 117 / 140 = 83.6%。想要更高的端到端召回,应该放宽粗筛(例如取前 5 或前 10),代价是请求数线性增加。
为什么不需要阈值。 路由规则只有「取最近档位」一条,三个档位的措辞就是阈值。和「score ≥ 1.0 二分」对比:去掉中间档后多出 12 个错误合并,因为那些「同系列的另一款」只能被强行归到某一边;留着中间档,它们落进了人工队列,代价是人要看 49 对(11.7%)。
这次的数据比 TypeSafe 原文的啤酒目录更「干净」吗? 某种程度上是。百科简介信息量大,手机、飞机、游戏机的型号名在中英文里往往直接写成相同的拉丁字母和数字,这让「同一个」的判断比只有四个短字段的啤酒记录容易。但我们的负例是句向量挑出的最像的 3 个,里面有大量同厂商的相邻型号,并不简单。换成字段稀疏、又没有跨语言线索的目录(比如只有「商品名 + 规格」的两家电商 SKU),分布大概率会更多地挤向中间档。
什么时候不该这么用。
- 两边有可靠的共享标识(ISBN、国药准字、条形码)时,直接按标识对齐,不要让模型猜。
- 字段本身是数值(价格、容量、发布日期)时,在代码里算差值,比问 Noul 更准也更便宜;本文没有给日期出题就是这个原因。
- 候选对还没经过粗筛时,不要把 N×M 个组合全送进来:一对一次请求,成本随候选对数量线性增长。
- 象信一号 1.0 是 9B 模型,跨语言、长简介时判断仍可能被表面线索带偏(
p056就是例子)。关于它擅长和不擅长什么,见 象信一号 1.0 的能力边界;要把低置信度的判断系统地送人工,见 置信度 和 置信度门控路由。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/entity_alignment
prepare_data.py:从 DBP15K 生成 420 个候选对(需要sentence-transformers)main.py:逐对请求、路由、与基准答案对比、输出汇总data/candidate_pairs.json:候选对与基准答案results/scores.json、results/summary.json:逐对原始答案与汇总

