Сопоставление сущностей в графе знаний
Определяет, какие из 450 пар-кандидатов из двух каталогов пива описывают один и тот же продукт, с помощью одного вопроса Score и трёх сопутствующих Noul, показывающих, какие поля расходятся.
Ключевая задача в графах знаний — решить, дублирует ли входящая сущность уже
существующую, особенно когда в распоряжении есть только естественный язык из
разнородных источников. Для каждой пары возможных дубликатов один вопрос Score
в TypeSafe решает, является ли пара дубликатом или заслуживает более внимательного
разбора куратором.
Предположим, два источника данных описывают пересекающиеся наборы одних и тех же вещей, и вам нужно узнать, какая запись на одной стороне — то же самое, что какая запись на другой. В графе знаний такие записи называются сущностями, и он хранит записанные о каждой факты. Некий дешёвый, но грубый первый проход уже сравнил два источника и отобрал 450 пар, заслуживающих более внимательного взгляда. Остаётся вынести суждение по каждой паре.
Неуместное слияние двух сущностей — более дорогая ошибка: каждый факт о любой из них теперь описывает объединённую сущность, и всё, что было связано с любой из них, тоже переходит туда. Отменить это потом — значит разбираться, какой факт откуда пришёл. Пропущенное совпадение оставляет всего лишь дубликат, поэтому суждению нужен третий вариант: пары, которые небезопасно ни сливать, ни отбрасывать.
Суждение — это вопрос Score с одним уровнем на каждый из трёх исходов:
- разные продукты — оставить две сущности несвязанными
- связаны, но, возможно, не одно и то же — передать куратору на решение
- один и тот же продукт — слить их
Мы используем вопрос Score, потому что хотим привязать семантическую метку, критерии score, напрямую к каждому исходу, включая средний. Вопрос Noul мог бы решить это косвенно — через порог на своём выводе, — а вопрос Choice потерял бы упорядоченное отношение трёх исходов.
Затем для каждого поля сущности, которое мы хотим рассмотреть, в том же запросе могут
пойти вопросы Noul о том, совпадают ли эти поля. Эти noul дают куратору более
подробную информацию, если score не попал ни в уровень «один и тот же продукт», ни в
уровень «разные продукты».
В итоге получается route(), который берёт одну пару-кандидата и возвращает один из
трёх исходов, без порога, который вам пришлось бы подгонять под свои данные.
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
Установка
pip install matplotlib ipython 'cooksafe>=0.2.0,<0.3.0'
затем задайте TYPESAFE_API_KEY. Каждый вызов кэшируется в json_cache.json, а он
поставляется вместе с cookbook, поэтому повторный рендеринг воспроизводит опубликованные
числа без обращения к API. Удалите этот файл, чтобы перезапустить всё вживую.
Числа ниже получены на jev-1.12 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"))
Загрузка пар-кандидатов
Пары взяты из опубликованного бенчмарк-набора, данные о пиве — из коллекции Magellan:
два каталога пива, собранные с разных сайтов и уже сокращённые до 450 пар тем первым
грубым проходом. Каждая сущность несёт четыре поля: name, brewery, style и содержание
алкоголя. Каждая пара также несёт known_same_as — собственный ответ бенчмарка.
Текст оставлен ровно как опубликован, без предобработки: HTML-сущности, которые так и не превратили обратно в символы, апострофы, оторванные в отдельные слова, несколько неправильно раскодированных символов.
На каждую пару уходит один запрос, поэтому ваши затраты следуют за числом выданных вам пар, а не за размером любого из источников.
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 %"
}
}
Один вопрос Score и три вопроса Noul на пару-кандидата
Обе сущности идут в одно состояние — как entity_a и entity_b, — поэтому вопросы
касаются пары, а не какой-либо стороны по отдельности. Все четыре идут в одном
запросе.
Три описания уровней ниже — это всё решение целиком: каждый уровень есть один исход. В этом файле нигде нет константы-порога. К тому же эти описания можно написать, ещё не увидев ни одного score, чего нельзя сказать о числе, которое приходится подгонять.
Средний уровень стоит прописать особенно тщательно. Здесь он охватывает варианты, специальные выпуски и названия, которые правдоподобно могут относиться к любому из двух продуктов, поэтому такие случаи попадают к куратору, а не сливаются и не отбрасываются.
OUTCOME называет три исхода. Исход слияния называется assert sameAs, потому что
sameAs — стандартный способ записать, что две сущности — одно и то же, и именно
запись такого утверждения и есть само слияние.
Три из четырёх полей получают вопрос Noul: name, brewery и style. Содержание алкоголя
не получает ни одного, потому что сравнивать два числа — это арифметика; посчитайте его
в коде, если нужно. Чтобы применить это к другому виду данных, вы переписываете
QUESTIONS и LEVELS. Единственный другой код, который знает о пиве, — это две
функции, печатающие результаты, и они называют поля.
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}"
)
Четыре пары. c446 — один продукт, а c427 — два. Остальные две попадают на средний
уровень по разным причинам: у c100 совпадают название и пивоварня, но источники
описывают его стиль по-разному, а c428 сопоставляет пиво с его фруктово-хмелевым
вариантом.
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
Маршрутизация каждой пары-кандидата
# 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%)
Два значения score, на которых route() меняет свой ответ, — это точки отсечения.
Большинство пар решается сами: 360 набирают меньше нижней точки отсечения, а 40 —
больше верхней, и 50 остаётся куратору.
На этом наборе score не ложатся ровно на целые числа. Большинство попадают около 0.25. Два пива без ничего общего всё же могут делить название стиля, а их названия пивоварен могут выглядеть похоже, поэтому модель отдаёт среднему уровню часть вероятности, а не ноль. Пару решает то, по какую сторону от точки отсечения она оказалась. То, насколько близко она стоит к уровню, роли не играет.
Две точки отсечения заселены неодинаково. Девять пар стоят в пределах 0.1 от верхней, на 1.5, а именно она решает, что сливается в граф. Сорок семь стоят так же близко к нижней, на 0.5, а она решает лишь, увидит ли куратор эту пару. Ни одно из чисел не подбирается вручную. Оба следуют из того, как вы сформулировали уровни, и именно формулировка среднего уровня перекладывает пары между куратором и оставшимися несвязанными.
Открыть в playground
Ссылка на playground ниже открывает c428, который набрал 1.10 и ушёл к куратору. Она
сопоставляет Ambleside Amber Ale с Bridge Ambleside Amber Ale - Pomegranate & Galena
Hops: одна и та же пивоварня, одно и то же содержание алкоголя. Все четыре вопроса идут
вместе с ней.
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})"
)
)
Открыть эту пару и вопросы в playground TypeSafe →