Alinhamento de entidades em grafos de conhecimento
Decide qual de 450 pares candidatos de dois catálogos de cerveja descreve o mesmo produto, com uma pergunta Score mais três Nouls auxiliares que revelam que campos não coincidem.
Um problema central nos grafos de conhecimento é decidir se uma entidade que chega
duplica uma que já existe, sobretudo quando só está disponível linguagem natural de
fontes díspares. Perante pares potencialmente duplicados, um único Score do TypeSafe
decide se cada par é um duplicado, ou se merece um olhar mais atento de um curador.
Supõe que duas fontes de dados descrevem conjuntos sobrepostos das mesmas coisas e precisas de saber qual entrada de um lado é a mesma coisa que qual entrada do outro. Um grafo de conhecimento chama a essas entradas entidades e guarda os factos registados sobre cada uma. Uma primeira passagem barata mas tosca já comparou as duas fontes e escolheu 450 pares que merecem um olhar mais atento. O que resta é fazer um juízo sobre cada par.
Fundir duas entidades de forma inadequada é o erro mais caro, já que todos os factos sobre qualquer das duas entidades passam a descrever a fundida, e tudo o que estava ligado a qualquer delas vem também. Desfazê-lo mais tarde implica descobrir que facto veio de onde. Perder uma correspondência só deixa um duplicado, por isso o juízo precisa de uma terceira opção: pares que não são seguros nem para fundir nem para descartar.
O juízo é uma pergunta Score com um nível por cada um dos três resultados:
- produto diferente — deixa as duas entidades sem ligação
- relacionadas, mas possivelmente não a mesma — entrega-o a um curador para decidir
- mesmo produto — funde-as
Usamos uma pergunta Score porque queremos associar uma etiqueta semântica, os criteria do score, diretamente a cada resultado, incluindo o resultado intermédio. Uma pergunta Noul poderia consegui-lo indiretamente, aplicando antes um limiar à sua saída, e uma pergunta Choice perderia a relação ordenada dos três resultados.
Depois, por cada campo da entidade que queiramos considerar, podem seguir no mesmo pedido
umas perguntas Noul sobre se
esses campos coincidem. Estes nouls dão ao curador informação mais detalhada, se o score
não cair nem no nível «mesmo produto» nem no de «produto diferente».
Acaba com um route() que recebe um par candidato e devolve um dos três resultados, sem
nenhum limiar que tenhas tido de ajustar aos teus 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 define TYPESAFE_API_KEY. Cada chamada fica em cache em json_cache.json, que é
distribuído com o cookbook, por isso voltar a renderizar reproduz os números publicados sem
chamar a API. Elimina esse ficheiro para voltar a executar tudo em direto.
Os números abaixo vêm do jev-1.12, de 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"))
Carrega os pares candidatos
Os pares vêm de um conjunto de benchmark publicado, os dados de cerveja da coleção
Magellan: dois catálogos de cerveja extraídos de sites diferentes, já reduzidos a 450
pares por essa primeira passagem tosca. Cada entidade traz quatro campos: nome, cervejeira,
estilo e teor alcoólico. Cada par traz também known_same_as, a própria resposta do
benchmark.
O texto é deixado exatamente como foi publicado, sem pré-processamento: entidades HTML que nunca foram convertidas de volta em caracteres, apóstrofos separados como palavras à parte, alguns caracteres descodificados mal.
É enviado um pedido por par, por isso o que gastas depende do número de pares que te deram, e não do 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 %"
}
}
Faz uma pergunta Score e três perguntas Noul por par candidato
Ambas as entidades entram num único estado, como entity_a e entity_b, por isso as
perguntas são sobre o par e não sobre cada lado por si. As quatro seguem num só pedido.
As três descrições de nível abaixo são toda a decisão: cada nível é um resultado. Não há nenhuma constante de limiar em lugar algum neste ficheiro. Também podes escrever estas descrições antes de teres visto um único score, o que não é verdade para um número que tenhas de ajustar.
O nível intermédio é o que merece ser escrito com cuidado. Aqui cobre variantes, edições especiais e nomes que poderiam plausivelmente referir-se a qualquer dos dois produtos, de modo que esses chegam a um curador em vez de serem fundidos ou descartados.
OUTCOME nomeia os três resultados. O resultado de fusão chama-se assert sameAs porque
sameAs é a forma padrão de registar que duas entidades são a mesma coisa, e escrever um é
como a fusão acontece de facto.
Três dos quatro campos recebem uma pergunta Noul: nome, cervejeira e estilo. O teor
alcoólico não recebe nenhuma, porque comparar dois números é aritmética; calcula-o em
código se o quiseres. Para usar isto com outro tipo de dados, reescreves 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 único produto e c427 são dois. Os outros dois caem no nível
intermédio por razões diferentes: c100 tem o mesmo nome e a mesma cervejeira, mas as
fontes escrevem o estilo de forma diferente, enquanto c428 emparelha uma cerveja com uma
variante sua 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
Encaminha cada par candidato
# 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 route() muda de resposta são os pontos de corte. A
maioria dos pares resolve-se: 360 pontuam abaixo do ponto de corte inferior e 40 acima do
superior, o que deixa 50 para o curador.
Neste conjunto os scores não se situam com nitidez nos números inteiros. A maioria cai perto de 0.25. Duas cervejas sem nada em comum podem ainda assim partilhar o nome de estilo, e os nomes das suas cervejeiras podem parecer-se, por isso o modelo dá ao nível intermédio parte da sua probabilidade em vez de nenhuma. O que decide um par é de que lado de um ponto de corte cai. O quão perto fica de um nível não conta.
Os dois pontos de corte não estão igualmente concorridos. Nove pares ficam a menos de 0.1 do superior, em 1.5, que é o que decide o que é fundido no grafo. Quarenta e sete ficam tão perto do inferior, em 0.5, que só decide se um curador vê o par. Nenhum dos dois números é algo que ajustes. Ambos decorrem de como redigiste os níveis, e a redação do nível intermédio é o que move pares entre os do curador e os que ficam sem ligação.
Abre-o no playground
O link do playground abaixo abre o c428, que pontuou 1.10 e foi para o curador.
Emparelha a Ambleside Amber Ale com a Bridge Ambleside Amber Ale - Pomegranate & Galena
Hops: a mesma cervejeira, o 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})"
)
)
Abre este par + as perguntas no playground da TypeSafe →