Dokumentation

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%)
Ausgabe

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 →