Documentação

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%)
saída

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 →