文档目录

知识图谱实体对齐

用一个 Score 题型加上三个配套 Noul 题型,从两份啤酒目录的 450 对候选配对中判断哪些描述的是同一件产品,同时指出哪些字段存在分歧。

非官方中文翻译 · 内容以 英文原文 ↗ 为准 · 同步于 2026-10-15

知识图谱中的一个核心问题是判断一个待接入的实体(entity)是否与现有实体重复,尤其是在只能拿到来自不同来源的自然语言描述时。给定一批疑似重复的实体对后,单次 TypeSafe Score 调用就能判定每对是真正的重复,还是值得交由复核员(curator)仔细审查。

假设两份数据源各自描述了一组相互重叠的事物,你需要知道一侧的哪条记录与另一侧的哪条记录是同一个事物。在知识图谱中,这些记录被称为实体,每条记录各自保存着与之相关的事实。一轮粗略但廉价的初筛已经比对了两个来源,并挑出了 450 对值得进一步查看的配对。剩下要做的,就是对每一对做出判断。

错误地合并两个实体是代价更高的错误,因为原本属于任一实体的每一条事实现在都会变成合并后实体的描述,与之关联的任何内容也会一并带过来。之后再想拆开,就得重新梳理每条事实原本来自哪一侧。漏掉一对匹配只会留下一个重复项,因此判断需要一个第三选项——既不适合放心合并、也不适合放心丢弃的那类配对。

判断本身是一个 Score 题型,对应三种结果各设一个档位(level):

  • different product —— 保持两个实体不关联
  • related, but possibly not the same —— 交给复核员决定
  • same product —— 将其合并

选用 Score 题型,是因为我们希望给每一种结果都直接挂上一段语义标签——即该档对应的判定标准(criteria),中间那一档也不例外。改用 Noul 题型也能借助对其输出做阈值化来达到同样目的,而 Choice 题型则会丢失三种结果之间的有序关系。

接下来,对实体中我们关心的每一个字段,都可以附带一个 Noul 题型来询问这些字段是否一致,并搭载在同一次请求中。当 score 既未落入 same product 也未落入 different product 时,这些 Noul 的答案会为复核员提供更详细的信息。

最终的 route() 接受一对候选配对,并返回三种结果之一,全程不需要你为自家数据去拟合任何阈值。

mermaid
flowchart LR
    PAIR["one candidate pair<br/><i>both entities, one state</i>"] --> CALL

    subgraph CALL["one request, four questions"]
        direction TB
        S["<b>Score:</b> how do the two relate?<br/>· different product<br/>· related, but possibly not the same<br/>· same product"]
        N["<b>Nouls:</b> one per compared field<br/>· same name?<br/>· same brewery?<br/>· same style?"]
        %% invisible link: without an edge these two share a rank, which in a TB
        %% subgraph puts them side by side instead of stacked
        S ~~~ N
    end

    S --> R{"round to the<br/>nearest level"}
    R purple_diff@-->|"different"| DROP["leave unlinked"]
    R green_same@-->|"same"| M["assert sameAs"]
    %% the queue is last so the dotted edge below reaches it without crossing
    %% the arrow into `assert sameAs`
    R blue_related@-->|"related"| Q["curator queue"]
    N blue_field@-.->|"which field<br/>they disagree on"| Q
    %% Branch colours (styles.css): node classes and green_/blue_/purple_/orange_ edge ids.
    class M neonGreen
    class Q neonBlue
    class DROP neonPurple

Setup

bash
pip install matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'

然后设置 TYPESAFE_API_KEY。每次调用都会被缓存到 json_cache.json,该文件随 cookbook 一同发布,因此重新渲染会直接重放已发布的数字,而无需访问 API。删除该文件即可重新在线跑一遍。

以下数字来自 jev-1.12 模型在 2026-08-11 时的结果。

python
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"))

Load the candidate pairs

这些配对来自一个公开的基准数据集——Magellan 集合中的 Beer 数据:从两个不同网站抓取的两份啤酒目录,已经被前述的粗筛缩减为 450 对。每个实体带有四个字段:name、brewery、style 和 abv(酒精度数)。每一对还带有一个 known_same_as 字段,即基准自带的标注答案。

文本完全按发布时的原样保留,不做任何预处理:包括从未被还原回字符的 HTML 实体、被拆成独立单词的撇号,以及少量错误解码的字符。

每一对发一次请求,因此你消耗的资源取决于你拿到手多少对候选配对,而不是任一来源的规模。

python
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])
text
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 %"
  }
}

Ask one Score question and three Noul questions per candidate pair

两个实体一起放进同一个 state(上下文状态),分别记作 entity_a 和 entity_b,因此问题问的是这一对,而不是单独某一侧。四个问题搭载在同一次请求中。

下面的三档描述构成了完整的决策:每一档对应一种结果。本文件中没有任何阈值常量。并且你可以在看到任何一次打分之前就把这些描述写好——这一点对于需要拟合的数字来说是不可能的。

中间这一档值得仔细措辞。此处它涵盖了变种、特别版,以及那些名称本身可能指代其中任何一款的产品——这些情况会交给复核员,而不是被合并或丢弃。

OUTCOME 字典给出了三种结果的名称。合并结果被命名为 assert sameAs,因为 sameAs 是用来记录两个实体同一关系的标准方式,而写出一条这样的关系就等同于完成合并。

四个字段中有三个配有 Noul 题型:name、brewery 和 style。abv 没有对应的 Noul,因为比较两个数字只是算术;如果你想要这条信息,在代码里算即可。要把这套方案用到另一类数据上,只需重写 QUESTIONS 和 LEVELS。其余唯一与啤酒相关的代码就是两个用于打印结果的函数,它们会引用字段名。

python
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 的 name 和 brewery 一致,但两个来源对 style 的措辞不同;而 c428 把一款啤酒与它的水果啤酒花变种配成了一对。

python
for pair_id in ("c446", "c427", "c100", "c428"):
    show(pair_id)
    print()
text
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

路由每一对候选

python
# 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%})")
text
assert sameAs      40  ( 8.9%)
curator queue      50  (11.1%)
leave unlinked    360  (80.0%)

输出结果

route() 改变结果的两个分值就是切分点。大多数 pair 都能直接落定:360 个得分低于下切分点,40 个高于上切分点,剩下的 50 个交给策展人。

在这批数据上,得分并不会整齐地落在整数上。大多集中在 0.25 附近。两款啤酒即便没有任何共同点,也可能共用一个风格名称,并且酒厂名字看起来也很相近,因此模型不会完全把中间档位的概率归零,而是会保留一部分。决定一个 pair 去向的,是它落在切分点的哪一侧,距离某个档位有多近并不起作用。

两个切分点的密集程度不同。9 个 pair 落在上切分点(1.5)附近 0.1 的范围内,这个切分点决定哪些会被合并到图谱中。47 个 pair 距离下切分点(0.5)这么近,这个切分点只决定策展人是否能看到这个 pair。这两个数字都不是你需要调参的对象,它们都取决于你如何措辞各档位,而中间档位的措辞决定了 pair 是被送到策展人那里,还是留作不关联。

在 playground 中打开

下面的 playground 链接会打开 c428,它得了 1.10 分,被送到了策展人那里。它把 Ambleside Amber Ale 和 Bridge Ambleside Amber Ale - Pomegranate & Galena Hops 配成了一对:同一家酒厂,酒精含量相同。随附全部四个问题。

python
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})"
    )
)

在 TypeSafe playground 中打开这个 pair 和问题 →

本文档由 Mintlify 构建和托管,这是一个开发者文档平台。