Alinhamento de entidades em grafos de conhecimento
Decide quais dos 450 pares de candidatos de dois catálogos de cerveja descrevem o mesmo produto, usando uma pergunta Score mais três Nouls companheiros que mostram em quais campos eles divergem.
Um problema central em grafos de conhecimento é decidir se uma entidade que chega duplica
uma que já existe, sobretudo quando só há linguagem natural de fontes díspares à
disposição. Diante de pares potencialmente duplicados, um único Score do TypeSafe decide
se cada par é um duplicado ou se merece um olhar mais atento de um curador.
Suponha que duas fontes de dados descrevam conjuntos sobrepostos das mesmas coisas, e que você precise saber qual registro de um lado é a mesma coisa que qual registro do outro. Um grafo de conhecimento chama esses registros de entidades e guarda os fatos registrados sobre cada uma. Uma primeira passada barata, mas grosseira, já comparou as duas fontes e separou 450 pares que merecem um olhar mais atento. O que resta é tomar uma decisão sobre cada par.
Fundir duas entidades de forma inadequada é o erro mais caro, porque todo fato de qualquer uma das duas passa a descrever a entidade fundida, e tudo que estava ligado a qualquer uma delas vem junto. Desfazer isso depois significa descobrir de onde veio cada fato. Deixar passar uma correspondência apenas deixa um duplicado, então a decisão precisa de uma terceira opção: pares que não são seguros nem para fundir nem para descartar.
A decisão é uma pergunta Score com um nível para cada um dos três desfechos:
- produto diferente — deixe as duas entidades sem vínculo
- relacionados, mas possivelmente não o mesmo — entregue a um curador para decidir
- mesmo produto — funda as duas
Usamos uma pergunta Score porque queremos atrelar um rótulo semântico, os critérios do score, diretamente a cada desfecho, inclusive ao desfecho do meio. Uma pergunta Noul conseguiria isso de forma indireta, aplicando um limiar à sua saída, e uma pergunta Choice perderia a relação de ordem entre os três desfechos.
Em seguida, para cada campo da entidade que quisermos considerar, perguntas Noul sobre
se esses campos coincidem podem viajar na mesma requisição. Esses nouls fornecem
informação mais detalhada para o curador, caso o score não caia nem no nível “mesmo
produto” nem no nível “produto diferente”.
No fim você tem um route() que recebe um par de candidatos e devolve um dos três
desfechos, sem nenhum limiar que você tenha de ajustar aos seus próprios dados.
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
Configuração
pip install matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
depois defina TYPESAFE_API_KEY. Toda chamada é armazenada em cache em json_cache.json,
que acompanha o cookbook, então re-renderizar reproduz os números publicados sem chamar a
API. Apague esse arquivo para rodar tudo ao vivo de novo.
Os números abaixo vêm do jev-1.12 em 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"))
Carregar os pares de candidatos
Os pares vêm de um conjunto de benchmark publicado, os dados de Beer da coleção Magellan:
dois catálogos de cerveja raspados de sites diferentes, já reduzidos a 450 pares por
aquela primeira passada grosseira. Cada entidade carrega quatro campos: nome, cervejaria,
estilo e teor alcoólico. Cada par também carrega known_same_as, a resposta do próprio
benchmark.
O texto é deixado exatamente como publicado, sem pré-processamento: entidades HTML que nunca foram convertidas de volta em caracteres, apóstrofos separados como palavras distintas, alguns caracteres decodificados errado.
Sai uma requisição por par, então o que você gasta acompanha o número de pares que recebeu, e não o tamanho de qualquer das duas fontes.
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 %"
}
}
Faça uma pergunta Score e três perguntas Noul por par de candidatos
As duas entidades entram num único estado, como entity_a e entity_b, então as
perguntas são sobre o par e não sobre cada lado isolado. As quatro viajam numa só
requisição.
As três descrições de nível abaixo são a decisão inteira: cada nível é um desfecho. Não há nenhuma constante de limiar em lugar nenhum deste arquivo. Você também pode escrever essas descrições antes de ter visto um único score, o que não é verdade para um número que você tenha de ajustar.
O nível do meio é o que vale a pena escrever com cuidado. Aqui ele cobre variantes, edições especiais e nomes que poderiam plausivelmente se referir a qualquer um dos produtos, então esses casos chegam a um curador em vez de serem fundidos ou descartados.
OUTCOME nomeia os três desfechos. O desfecho de fusão se chama assert sameAs porque
sameAs é a forma padrão de registrar que duas entidades são a mesma coisa, e é assim que
a fusão de fato acontece.
Três dos quatro campos ganham uma pergunta Noul: nome, cervejaria e estilo. O teor
alcoólico não ganha nenhuma, porque comparar dois números é aritmética; calcule no código
se quiser. Para usar isto com outro tipo de dado, você reescreve QUESTIONS e LEVELS. O
único outro código que sabe de cerveja são as duas funções que imprimem resultados, que
nomeiam os campos.
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}"
)
Quatro pares. c446 é um produto e c427 são dois. Os outros dois caem no nível do meio
por motivos diferentes: c100 tem o mesmo nome e a mesma cervejaria, mas as fontes
escrevem o estilo de formas diferentes, enquanto c428 junta uma cerveja com uma variante
dela com fruta e lúpulo.
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
Encaminhar cada par de candidatos
# 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%)
Os dois valores de score em que o route() muda de resposta são os pontos de corte. A
maioria dos pares se resolve: 360 ficam abaixo do ponto de corte inferior e 40 acima do
superior, deixando 50 para o curador.
Neste conjunto os scores não ficam certinhos sobre os números inteiros. A maioria cai perto de 0.25. Duas cervejas sem nada em comum ainda podem compartilhar o nome de um estilo, e os nomes de suas cervejarias podem se parecer, então o modelo dá ao nível do meio um pouco de sua probabilidade em vez de nenhuma. O que decide um par é de que lado de um ponto de corte ele cai. O quão perto ele está de um nível não entra na conta.
Os dois pontos de corte não estão igualmente cheios. Nove pares ficam a menos de 0.1 do superior, em 1.5, que é o que decide o que será fundido ao grafo. Quarenta e sete ficam igualmente perto do inferior, em 0.5, que só decide se um curador verá o par. Nenhum dos dois números é algo que você ajusta. Ambos decorrem de como você redigiu os níveis, e a redação do nível do meio é o que move pares entre o curador e os pares deixados sem vínculo.
Abra no playground
O link do playground abaixo abre o c428, que teve score 1.10 e foi para o curador.
Ele junta Ambleside Amber Ale com Bridge Ambleside Amber Ale - Pomegranate & Galena
Hops: mesma cervejaria, mesmo teor alcoólico. As quatro perguntas vêm com ele.
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})"
)
)
Abra este par + perguntas no playground do TypeSafe →