Alineación de entidades de grafos de conocimiento
Decide cuál de los 450 pares candidatos de dos catálogos de cerveza describe el mismo producto usando una pregunta de Score más tres Nouls complementarios que revelan qué campos no coinciden.
Un problema clave en los grafos de conocimiento es decidir si una entidad entrante duplica a una existente, sobre todo cuando lo único disponible es lenguaje natural de fuentes dispares. Dados unos pares potencialmente duplicados, un único Score de TypeSafe decide si cada par es un duplicado, o si merece una mirada más atenta de un curador.
Supón que dos fuentes de datos describen conjuntos solapados de las mismas cosas, y necesitas saber qué entrada de un lado es la misma cosa que qué entrada del otro. Un grafo de conocimiento llama a esas entradas entidades y guarda los hechos registrados sobre cada una. Una primera pasada barata pero tosca ya ha comparado las dos fuentes y ha seleccionado 450 pares que merecen una mirada más atenta. Lo que queda es emitir un juicio sobre cada par.
Fusionar dos entidades de forma inapropiada es el error más caro, ya que todos los hechos sobre cualquiera de las dos entidades pasan a describir la fusionada, y todo lo que estaba enlazado a cualquiera de ellas viene también. Deshacerlo después implica averiguar qué hecho vino de dónde. Perder una coincidencia solo deja un duplicado, así que el juicio necesita una tercera opción: pares que no son seguros ni para fusionar ni para descartar.
El juicio es una pregunta Score con un nivel por cada uno de los tres resultados:
- producto distinto — deja las dos entidades sin enlazar
- relacionadas, pero posiblemente no la misma — pásalo a un curador para que decida
- mismo producto — fusíonalas
Usamos una pregunta Score porque queremos asociar una etiqueta semántica, los criteria del score, directamente a cada resultado, incluido el resultado intermedio. Una pregunta Noul podría lograrlo indirectamente aplicando en cambio un umbral a su salida, y una pregunta Choice perdería la relación ordenada de los tres resultados.
Después, por cada campo de la entidad que queramos considerar, pueden viajar en la misma solicitud unas preguntas Noul sobre si esos campos coinciden. Estos nouls dan información más detallada al curador, si el score no cae ni en el nivel de «mismo producto» ni en el de «producto distinto».
Acabas con un route() que toma un par candidato y devuelve uno de los tres resultados, sin ningún umbral que hayas tenido que ajustar a tus propios datos.
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
Configuración
pip install matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
luego define TYPESAFE_API_KEY. Cada llamada se guarda en caché en json_cache.json, que se distribuye con el cookbook, así que volver a renderizar reproduce los números publicados sin llamar a la API. Elimina ese archivo para volver a ejecutarlo todo en vivo.
Los números de abajo provienen de jev-1.12 el 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"))
Carga los pares candidatos
Los pares vienen de un conjunto de benchmark publicado, los datos de cerveza de la colección Magellan: dos catálogos de cerveza extraídos de sitios web distintos, ya reducidos a 450 pares por esa primera pasada tosca. Cada entidad lleva cuatro campos: nombre, cervecería, estilo y contenido de alcohol. Cada par también lleva known_same_as, la propia respuesta del benchmark.
El texto se deja exactamente como se publicó, sin preprocesar: entidades HTML que nunca se convirtieron de nuevo en caracteres, apóstrofos separados como palabras aparte, algunos caracteres descodificados mal.
Sale una solicitud por par, así que lo que gastas depende del número de pares que te dieron, no del tamaño de ninguna de las dos fuentes.
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 %"
}
}
Haz una pregunta Score y tres preguntas Noul por par candidato
Ambas entidades van a un único state, como entity_a y entity_b, así que las preguntas son sobre el par y no sobre cada lado por separado. Las cuatro viajan en una sola solicitud.
Las tres descripciones de nivel de abajo son toda la decisión: cada nivel es un resultado. No hay ninguna constante de umbral en este archivo. Además, puedes escribir estas descripciones antes de haber visto un solo score, lo que no es cierto de un número que tengas que ajustar.
El nivel intermedio es el que merece escribirse con cuidado. Aquí cubre variantes, ediciones especiales y nombres que podrían referirse plausiblemente a cualquiera de los dos productos, de modo que esos llegan a un curador en lugar de fusionarse o descartarse.
OUTCOME nombra los tres resultados. El resultado de fusión se llama assert sameAs porque sameAs es la forma estándar de registrar que dos entidades son la misma cosa, y escribir uno es como ocurre de hecho la fusión.
Tres de los cuatro campos reciben una pregunta Noul: nombre, cervecería y estilo. El contenido de alcohol no recibe ninguna, porque comparar dos números es aritmética; calcúlalo en código si lo quieres. Para usar esto con otro tipo de datos, reescribes QUESTIONS y LEVELS. El único otro código que sabe de cerveza son las dos funciones que imprimen resultados, que nombran los 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}"
)
Cuatro pares. c446 es un solo producto y c427 son dos. Los otros dos caen en el nivel intermedio por razones distintas: c100 tiene el mismo nombre y la misma cervecería, pero las fuentes escriben su estilo de forma distinta, mientras que c428 empareja una cerveza con una variante suya con fruta y 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
Enruta 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%)
Los dos valores de score donde route() cambia su respuesta son los puntos de corte. La mayoría de los pares se resuelven: 360 puntúan por debajo del punto de corte inferior y 40 por encima del superior, lo que deja 50 para el curador.
En este conjunto los scores no se sitúan limpiamente en los números enteros. La mayoría caen cerca de 0.25. Dos cervezas sin nada en común podrían compartir aun así el nombre de estilo, y sus nombres de cervecería podrían parecerse, así que el modelo da al nivel intermedio parte de su probabilidad en lugar de nada. Lo que decide a un par es de qué lado de un punto de corte cae. Lo cerca que esté de un nivel no influye.
Los dos puntos de corte no están igual de concurridos. Nueve pares quedan a menos de 0.1 del superior, en 1.5, que es el que decide qué se fusiona en el grafo. Cuarenta y siete quedan igual de cerca del inferior, en 0.5, que solo decide si un curador ve el par. Ninguno de los dos números es algo que ajustes. Ambos se derivan de cómo redactaste los niveles, y la redacción del nivel intermedio es lo que mueve pares entre los del curador y los que quedan sin enlazar.
Ábrelo en el playground
El enlace de playground de abajo abre c428, que puntuó 1.10 y fue al curador. Empareja Ambleside Amber Ale con Bridge Ambleside Amber Ale - Pomegranate & Galena Hops: la misma cervecería, el mismo contenido de alcohol. Las cuatro preguntas vienen con él.
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 + las preguntas en el playground de TypeSafe →