知识图谱中的一个关键问题是判断新来的实体是否与已有实体重复,尤其是在只有来自不同来源的自然语言可用时。给定潜在的重复对,一个 TypeSafe Score 就能决定每一对是否重复,还是值得交给审校人员再仔细看一眼。
假设两个数据源描述的是部分重叠的同一批事物,而你需要知道一侧的哪个条目与另一侧的哪个条目是同一事物。知识图谱把这些条目称为实体(entity),并保存着围绕每个实体记录的事实。某个廉价但粗糙的初筛已经比对过两个来源,挑出了 450 对值得进一步细看的候选。剩下的工作就是对每一对做出裁断。
不恰当地合并两个实体是代价更高的错误,因为关于任一实体的每条事实从此都描述合并后的实体,链接到任一实体的东西也会一并带过来。事后撤销意味着要弄清每条事实来自哪里。漏掉一个匹配只会留下一处重复,因此这个裁断需要第三个选项:那些既不宜合并也不宜丢弃的对。
这个判断是一个 Score 问题,三种结果各对应一个层级:
- different product(不同产品)——让两个实体保持不关联
- related, but possibly not the same(相关,但可能不是同一个)——交给审校人员决定
- same product(同一产品)——合并它们
之所以使用 Score 问题,是因为我们想把一个语义标签——即评分标准——直接挂到每个结果上,包括中间结果。Noul 问题也可以通过对其输出设阈值来间接实现这一点,而 Choice 问题则会丢掉三种结果之间的有序关系。
接下来,对于我们想考虑的实体的每个字段,关于这些字段是否匹配的 Noul 问题可以搭同一个请求的便车。如果分数既没有落在 "same product" 层级,也没有落在 "different product" 层级,这些 noul 就为审校人员提供更详细的信息。
最终你会得到一个 route():接收一个候选对,返回三种结果之一,而且没有任何需要你针对自己的数据去拟合的阈值。
准备工作
pip install matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
然后设置 TYPESAFE_API_KEY。每次调用都会缓存到随本指南一同发布的 json_cache.json,因此重新渲染会重放已发布的数字,而不会调用 API。删除该文件即可全部实跑。
下面的数字来自 jev-1.12,生成于 2026-08-11。
import json
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Noul, Score, TypeSafeClient
matplotlib.use("Agg") # headless render
TYPESAFE_MODEL = "jev-1.12"
MAX_WORKERS = 6 # small pool; the public endpoint rate-limits above roughly eight
client = TypeSafeClient(
api_key=os.environ.get(
"TYPESAFE_API_KEY", "cache-only"
), # keyless kernels replay the cache
base_url=os.environ.get("TYPESAFE_ENDPOINT"),
timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))
加载候选对
这些候选对来自一个已发布的基准数据集——Magellan 系列中的 Beer 数据:从不同网站抓取的两个啤酒目录,已经由第一轮粗筛缩减到 450 对。每个实体带四个字段:名称、酿酒厂、风格和酒精度。每对还带有 known_same_as,即基准数据集自己的答案。
文本保持发布时的原样,不做预处理:从未被转换回字符的 HTML 实体、被拆成单独单词的撇号、少数被错误解码的字符。
每个候选对发出一个请求,因此你的开销取决于交给你的候选对数量,而不是任何一个来源的规模。
PAIRS = json.loads(Path("candidate_pairs.json").read_text(encoding="utf-8"))
BY_ID = {pair["id"]: pair for pair in PAIRS}
print(f"{len(PAIRS)} candidate pairs. The first one, as the model will see it:")
print(json.dumps({k: PAIRS[0][k] for k in ("entity_a", "entity_b")}, indent=2)[:420])
450 candidate pairs. The first one, as the model will see it:
{
"entity_a": {
"name": "C N Red Imperial Red Ale",
"brewery": "Redwood Lodge",
"style": "American Amber / Red Ale",
"abv": "8.10 %"
},
"entity_b": {
"name": "Kinetic Infrared Imperial Red Ale",
"brewery": "Kinetic Brewing Company",
"style": "American Strong Ale",
"abv": "9.30 %"
}
}
对每个候选对提出一个 Score 问题和三个 Noul 问题
两个实体作为 entity_a 和 entity_b 放进同一个状态,因此问题是针对这一对,而不是单独针对某一方。四个问题都搭同一个请求。
下面的三段层级描述就是全部决策逻辑:每个层级即一种结果。这个文件里没有任何阈值常数。这些描述也可以在你看到一个分数之前就写好——这一点对需要拟合的数字并不成立。
中间层级值得仔细措辞。在这里它涵盖变体、特别版,以及有可能指代任一产品的名称,让这些到达审校人员手中,而不是被合并或丢弃。
OUTCOME 为三种结果命名。合并结果叫做 assert sameAs,因为 sameAs 是记录两个实体为同一事物的标准方式,而写出一条 sameAs 就是合并的实际执行方式。
四个字段中有三个各有一个 Noul 问题:名称、酿酒厂、风格。酒精度没有,因为比较两个数字是算术运算;想要的话在代码里算即可。要在另一类数据上使用,只需改写 QUESTIONS 和 LEVELS。其他知道"啤酒"这回事的代码只有那两个打印结果的函数,它们点名了字段。
LEVELS = [
"They describe two different products.",
"They describe closely related products that may or may not be the same one: "
"a variant, a special edition, or a name that could plausibly refer to either.",
"They describe one and the same product.",
]
OUTCOME = {0: "leave unlinked", 1: "curator queue", 2: "assert sameAs"}
QUESTIONS = {
"link_state": Score(
instructions="How do the two entity descriptions relate as products?",
criteria=LEVELS,
),
"same_name": Noul(
instructions="Do the two entities state the same beer name?",
),
"same_brewery": Noul(
instructions="Are the two entities from the same brewery?",
),
"same_style": Noul(
instructions="Do the two entities describe the same beer style?",
),
}
@json_cache
def score(pair_id: str) -> dict:
"""One request about one candidate pair -> the score plus the three noul answers."""
pair = BY_ID[pair_id]
response = client.system_one(
state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]},
questions=QUESTIONS,
model=TYPESAFE_MODEL,
)
link = response.answers["link_state"]
return {
"score": link.score,
"probabilities": link.probabilities,
"confidence": link.confidence,
"properties": {
k: response.answers[k].noul for k in QUESTIONS if k != "link_state"
},
# tokens and requests are the durable units; don't cache a derived cost
"input_tokens": response.usage.input_tokens or 0,
"output_tokens": response.usage.output_tokens or 0,
}
def route(score_value: float) -> str:
"""The whole decision rule: the nearest level names the outcome."""
return OUTCOME[min(int(score_value + 0.5), len(LEVELS) - 1)]
def show(pair_id: str) -> None:
pair, result = BY_ID[pair_id], score(pair_id)
print(
f"{pair_id} score {result['score']:.2f} confidence {result['confidence']:.2f}"
f" -> {route(result['score'])}"
)
for side in ("entity_a", "entity_b"):
e = pair[side]
print(f" {e['name'][:44]:<46}{e['brewery'][:30]:<32}{e['style'][:22]}")
nouls = result["properties"]
print(
f" name {nouls['same_name']:.2f} brewery {nouls['same_brewery']:.2f} "
f"style {nouls['same_style']:.2f}"
)
四个示例对。c446 是同一个产品,c427 是两个。另外两对因不同原因落在中间层级:c100 的名称和酿酒厂相同,但两个来源对其风格的措辞不同;c428 则把一款啤酒和它的水果加酒花变体配成了一对。
for pair_id in ("c446", "c427", "c100", "c428"):
show(pair_id)
print()
c446 score 1.94 confidence 0.92 -> assert sameAs
Thomas Hooker Old Marley Barleywine Thomas Hooker Brewing Company American Barleywine
Thomas Hooker Old Marley Barleywine Thomas Hooker Brewing Company Barley Wine
name 0.97 brewery 0.99 style 0.81
c427 score 0.03 confidence 0.95 -> leave unlinked
Frost Quake Bourbon Barrel Aged Barley Wine Wellington County Brewery American Barleywine
Lompoc Bourbon Barrel Aged Proletariat Red A Lompoc Brewing Amber Ale
name 0.02 brewery 0.09 style 0.08
c100 score 1.30 confidence 0.27 -> curator queue
Belle Gueule Rousse Brasseurs R.J. American Amber / Red A
Belle Gueule Rousse Brasseurs RJ Amber Lager/Vienna
name 0.95 brewery 0.94 style 0.35
c428 score 1.10 confidence 0.77 -> curator queue
Ambleside Amber Ale Bridge Brewing Company American Amber / Red A
Bridge Ambleside Amber Ale - Pomegranate & G Bridge Brewing Company Amber Ale
name 0.63 brewery 0.98 style 0.74
为所有候选对路由
# 450 candidate pairs, one request each; a small pool keeps a live run to a few minutes.
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
scored = list(pool.map(lambda pair: score(pair["id"]), PAIRS))
scores = [result["score"] for result in scored]
by_outcome: dict[str, list[str]] = {name: [] for name in OUTCOME.values()}
for pair, s in zip(PAIRS, scores):
by_outcome[route(s)].append(pair["id"])
SURFACE, INK, INK2, MUTED = "#fcfcfb", "#0b0b0b", "#52514e", "#898781"
GRID, AXIS, BLUE, ORANGE = "#e1e0d9", "#c3c2b7", "#2a78d6", "#eb6834"
BINS, TOP = 20, len(LEVELS) - 1
counts = [0] * BINS
for s in scores:
counts[min(int(s / TOP * BINS), BINS - 1)] += 1
centers = [(i + 0.5) / BINS * TOP for i in range(BINS)]
queued = [c if route(x) == "curator queue" else 0 for c, x in zip(counts, centers)]
settled = [c if route(x) != "curator queue" else 0 for c, x in zip(counts, centers)]
fig, ax = plt.subplots(figsize=(7.2, 3.6), facecolor=SURFACE)
ax.set_facecolor(SURFACE)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(AXIS)
ax.tick_params(colors=MUTED, labelcolor=INK2, labelsize=9)
ax.set_axisbelow(True)
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.bar(
centers, settled, width=TOP / BINS * 0.9, color=BLUE, label="settled automatically"
)
ax.bar(
centers, queued, width=TOP / BINS * 0.9, color=ORANGE, label="sent to the curator"
)
for edge in (0.5, 1.5):
ax.axvline(edge, color=INK2, linewidth=1, linestyle="--")
ax.set_xticks([0, 0.5, 1, 1.5, 2])
ax.set_xticklabels(["0\ndifferent", "0.5", "1\nrelated", "1.5", "2\nsame"])
ax.set_xlabel("score for the pair", color=INK2, fontsize=9)
ax.set_ylabel("candidate pairs", color=INK2, fontsize=9)
ax.set_title(
f"{len(PAIRS)} candidate pairs, scored once each",
loc="left",
color=INK,
fontsize=11,
)
ax.legend(frameon=False, labelcolor=INK2, fontsize=9)
display(fig)
plt.close(fig)
for name in ("assert sameAs", "curator queue", "leave unlinked"):
n = len(by_outcome[name])
print(f"{name:<16}{n:>5} ({n / len(PAIRS):>5.1%})")
assert sameAs 40 ( 8.9%)
curator queue 50 (11.1%)
leave unlinked 360 (80.0%)
route() 会改变答案的那两个分数值就是切分点。大多数候选对都能自动了结:360 对的分数低于下切分点,40 对高于上切分点,剩下 50 对留给审校人员。
在这个数据集上,分数并不整齐地落在整数上,大多落在 0.25 附近。两款毫无共同之处的啤酒可能仍共享一个风格名称,酿酒厂名也可能看起来相似,所以模型会把一部分概率分给中间层级,而不是一点也不给。决定一个候选对命运的是它落在切分点的哪一侧;离某个层级多近并不参与决策。
两个切分点的拥挤程度并不相同。九对落在上切分点 1.5 的 0.1 范围内,而这个切分点决定什么会被合并进图谱。四十七对同样靠近下切分点 0.5,它只决定审校人员是否会看到该候选对。这两个数字都不是拿来供你调节的。它们都源自你对各层级的措辞,而中间层级的措辞正是在"交给审校人员"与"保持不关联"之间移动候选对的关键。
在 Playground 中打开
下面的 Playground 链接打开 c428,它得了 1.10 分,被送入了审阅队列。它把 Ambleside Amber Ale 与 Bridge Ambleside Amber Ale - Pomegranate & Galena Hops 配成一对:同一酿酒厂、同一酒精度。四个问题也一并带上。
playground_link = make_playground_link(
{"entity_a": BY_ID["c428"]["entity_a"], "entity_b": BY_ID["c428"]["entity_b"]},
QUESTIONS,
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open this pair + questions in the TypeSafe playground]({playground_link})"
)
)