Entitätsabgleich in Wissensgraphen
Entscheidet mithilfe einer Score-Frage plus drei begleitenden Nouls, welche von 450 Kandidatenpaaren aus zwei Bierkatalogen dasselbe Produkt beschreiben, und legt offen, welche Felder sich widersprechen.
Ein zentrales Problem in Wissensgraphen ist die Frage, ob eine eingehende Entität eine
bereits vorhandene dupliziert – besonders dann, wenn als Einziges natürliche Sprache aus
unterschiedlichen Quellen zur Verfügung steht. Sind mögliche Duplikatpaare erst einmal
gefunden, entscheidet ein einziges TypeSafe-Score, ob jedes Paar ein Duplikat ist oder ob
es einen genaueren Blick von einem Kurator verdient.
Angenommen, zwei Datenquellen beschreiben überlappende Mengen derselben Dinge, und du musst wissen, welcher Eintrag auf der einen Seite dasselbe Ding ist wie welcher Eintrag auf der anderen. Ein Wissensgraph nennt diese Einträge Entitäten und hält die zu jeder erfassten Fakten fest. Ein billiger, aber grober erster Durchlauf hat die beiden Quellen bereits verglichen und 450 Paare herausgefiltert, die einen genaueren Blick verdienen. Was bleibt, ist ein Urteil über jedes Paar zu fällen.
Zwei Entitäten unpassend zusammenzuführen ist der teurere Fehler, denn jeder Fakt über eine der beiden Entitäten beschreibt nun die zusammengeführte, und alles, was mit einer von beiden verknüpft war, kommt mit. Das später rückgängig zu machen heißt herauszufinden, welcher Fakt woher kam. Eine Übereinstimmung zu verpassen hinterlässt nur ein Duplikat. Das Urteil braucht also eine dritte Option: Paare, die weder sicher zusammenzuführen noch sicher zu verwerfen sind.
Das Urteil ist eine Score-Frage mit einer Stufe für jedes der drei Ergebnisse:
- verschiedenes Produkt — lasse die beiden Entitäten unverknüpft
- verwandt, aber möglicherweise nicht dasselbe — gib es einem Kurator zur Entscheidung
- dasselbe Produkt — führe sie zusammen
Wir verwenden eine Score-Frage, weil wir jeder Stufe direkt ein semantisches Label anhängen wollen, die Score-Kriterien, auch der mittleren Stufe. Eine Noul-Frage könnte das stattdessen indirekt leisten, indem sie einen Schwellenwert auf ihre Ausgabe anwendet, und eine Choice-Frage würde die geordnete Beziehung der drei Ergebnisse verlieren.
Als Nächstes können für jedes Feld der Entität, das wir betrachten wollen, Noul-Fragen
dazu, ob diese
Felder übereinstimmen, in derselben Anfrage mitfahren. Diese Nouls liefern dem Kurator
genauere Informationen, falls der Score weder auf der Stufe „dasselbe Produkt“ noch auf
„verschiedenes Produkt“ landet.
Am Ende hast du ein route(), das ein Kandidatenpaar nimmt und eines der drei Ergebnisse
zurückgibt – ohne Schwellenwert, den du an deine eigenen Daten anpassen musstest.
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 -->|"different"| DROP["leave unlinked"]
R -->|"same"| M["assert sameAs"]
%% the queue is last so the dotted edge below reaches it without crossing
%% the arrow into `assert sameAs`
R -->|"related"| Q["curator queue"]
N -.->|"which field<br/>they disagree on"| Q
Einrichtung
pip install matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
Setze dann TYPESAFE_API_KEY. Jeder Aufruf wird in json_cache.json zwischengespeichert,
das mit dem Cookbook ausgeliefert wird, sodass ein erneutes Rendern die veröffentlichten
Zahlen ohne API-Aufrufe wiedergibt. Lösche diese Datei, um alles live neu auszuführen.
Die Zahlen unten stammen aus jev-1.12 vom 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"))
Lade die Kandidatenpaare
Die Paare stammen aus einem veröffentlichten Benchmark-Datensatz, die Bier-Daten aus der
Magellan-Sammlung: zwei Bierkataloge, die von verschiedenen Websites gescrapt und von
diesem ersten groben Durchlauf bereits auf 450 Paare reduziert wurden. Jede Entität trägt
vier Felder: Name, Brauerei, Stil und Alkoholgehalt. Jedes Paar trägt außerdem
known_same_as, die eigene Antwort des Benchmarks.
Der Text bleibt genau so, wie er veröffentlicht wurde, ohne Vorverarbeitung: HTML-Entities, die nie zurück in Zeichen umgewandelt wurden, Apostrophe, die als eigene Wörter abgetrennt sind, ein paar falsch dekodierte Zeichen.
Es geht eine Anfrage pro Paar raus, was du ausgibst, richtet sich also nach der Anzahl der Paare, die du bekommen hast, und nicht nach der Größe einer der beiden Quellen.
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 %"
}
}
Stelle pro Kandidatenpaar eine Score-Frage und drei Noul-Fragen
Beide Entitäten gehen in einen einzigen Zustand, als entity_a und entity_b, sodass die
Fragen das Paar betreffen und nicht eine Seite für sich. Alle vier fahren in einer Anfrage
mit.
Die drei Stufenbeschreibungen unten sind die gesamte Entscheidung: jede Stufe ist ein Ergebnis. Es gibt nirgends in dieser Datei eine Schwellenwert-Konstante. Du kannst diese Beschreibungen außerdem schreiben, bevor du einen einzigen Score gesehen hast – das gilt nicht für eine Zahl, die du anpassen musst.
Die mittlere Stufe ist die, die sich sorgfältig zu formulieren lohnt. Hier deckt sie Varianten, Sondereditionen und Namen ab, die sich plausibel auf eines der beiden Produkte beziehen könnten, sodass diese zu einem Kurator gelangen, statt zusammengeführt oder verworfen zu werden.
OUTCOME benennt die drei Ergebnisse. Das Zusammenführungs-Ergebnis heißt assert sameAs,
weil sameAs die Standardart ist, festzuhalten, dass zwei Entitäten dasselbe Ding sind, und
genau das zu schreiben ist, wie die Zusammenführung tatsächlich geschieht.
Drei der vier Felder bekommen eine Noul-Frage: Name, Brauerei und Stil. Der Alkoholgehalt
bekommt keine, denn zwei Zahlen zu vergleichen ist Arithmetik; berechne ihn im Code, wenn du
ihn willst. Um das auf eine andere Art von Daten anzuwenden, schreibst du QUESTIONS und
LEVELS neu. Der einzige andere Code, der etwas über Bier weiß, sind die beiden Funktionen,
die Ergebnisse ausgeben und die Felder benennen.
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}"
)
Vier Paare. c446 ist ein Produkt und c427 sind zwei. Die anderen beiden landen aus
unterschiedlichen Gründen auf der mittleren Stufe: c100 hat denselben Namen und dieselbe
Brauerei, aber die Quellen formulieren seinen Stil unterschiedlich, während c428 ein Bier
mit einer Frucht-und-Hopfen-Variante davon paart.
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
Route jedes Kandidatenpaar
# 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%)
Die beiden Score-Werte, bei denen route() seine Antwort ändert, sind die Grenzpunkte. Die
meisten Paare klären sich: 360 liegen unter dem unteren Grenzpunkt und 40 über dem oberen,
sodass 50 für den Kurator übrig bleiben.
In diesem Datensatz sitzen die Scores nicht sauber auf den ganzen Zahlen. Die meisten landen nahe bei 0.25. Zwei Biere ohne jede Gemeinsamkeit könnten sich trotzdem einen Stilnamen teilen, und ihre Brauereinamen könnten ähnlich aussehen, also gibt das Modell der mittleren Stufe einen Teil seiner Wahrscheinlichkeit statt gar keiner. Was ein Paar entscheidet, ist die Seite eines Grenzpunkts, auf die es fällt. Wie nah es an einer Stufe sitzt, spielt keine Rolle.
Die beiden Grenzpunkte sind nicht gleich stark besetzt. Neun Paare liegen innerhalb von 0.1 des oberen, bei 1.5, der entscheidet, was in den Graphen zusammengeführt wird. Siebenundvierzig liegen ebenso nah am unteren, bei 0.5, der nur entscheidet, ob ein Kurator das Paar sieht. Keine der beiden Zahlen ist etwas, das du einstellst. Beide folgen daraus, wie du die Stufen formuliert hast, und die Formulierung der mittleren Stufe ist es, die Paare zwischen dem Kurator und den unverknüpft gelassenen Paaren verschiebt.
Im Playground öffnen
Der Playground-Link unten öffnet c428, das 1.10 erreichte und zum Kurator ging. Er paart
Ambleside Amber Ale mit Bridge Ambleside Amber Ale - Pomegranate & Galena Hops: dieselbe
Brauerei, derselbe Alkoholgehalt. Alle vier Fragen sind dabei.
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})"
)
)
Öffne dieses Paar + die Fragen im TypeSafe-Playground →