Linking named entities

Turn mentions into stable identifiers

entity enrichment
entity linking
workforce research
Learn how alias tables generate entity candidates and why resolution and NIL handling remain separate decisions.

Credential counts get messy when one notice says forklift certification and a reference list says Forklift Operator Licence. The coordinator still wants to know whether the notices point to the same credential.

Named entity linking connects a mention in text to a stable identifier in a reference list. The identifier matters because spellings can change while the record stays the same.

Note

The Riverton organisations, places, and credentials here are invented for the lesson.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • explain why an entity identifier matters;
  • join mention strings to an alias table;
  • count mentions with zero, one, or several candidates;
  • show how one credential can have multiple aliases; and
  • state the limit of alias matching.

Load entities and aliases

Three CSV files provide the reference list, aliases, and sentence examples. The chunk checks the alias-file fingerprint, then uses dplyr and tibble for the joins and tables. An entity is a thing the reference list knows about, such as an organisation, place, or credential. The canonical name is the reference list’s main name for that entity. An alias is a surface form that may appear in text.

library(readr)
library(dplyr)
library(tibble)
library(digest)

sentences <- read_csv(
  "data/workforce/workforce_sentences.csv",
  na = character(),
  col_types = cols(
    sentence_id = col_character(),
    document_id = col_character(),
    source_line = col_character(),
    text = col_character(),
    reference_label = col_character(),
    uncertainty = col_character(),
    annotator_id = col_character(),
    rationale = col_character(),
    codebook_version = col_character(),
    codebook_hash = col_character(),
    derived = col_character(),
    transformation = col_character()
  )
)

entities <- read_csv(
  "data/riverton/riverton-entities.csv",
  na = character(),
  col_types = cols(
    entity_id = col_character(),
    canonical_name = col_character(),
    entity_type = col_character(),
    description = col_character()
  )
)

aliases <- read_csv(
  "data/riverton/riverton-aliases.csv",
  na = character(),
  col_types = cols(
    entity_id = col_character(),
    alias = col_character()
  )
)

metadata <- read_csv(
  "data/riverton/riverton-reference-metadata.csv",
  na = character(),
  col_types = cols(
    artifact = col_character(),
    description = col_character(),
    source = col_character(),
    license = col_character(),
    created_on = col_character(),
    fingerprint = col_character()
  )
)

alias_hash <- digest(
  paste(read_lines("data/riverton/riverton-aliases.csv"), collapse = "\n"),
  algo = "sha256",
  serialize = FALSE
)
expected_alias_hash <- metadata |>
  filter(artifact == "riverton-aliases.csv") |>
  pull(fingerprint)

knitr::kable(
  entities,
  col.names = c("Entity ID", "Canonical name", "Type", "Description"),
  caption = "Invented Riverton entity reference list",
  row.names = FALSE
)
Invented Riverton entity reference list
Entity ID Canonical name Type Description
ORG-0001 Riverton Workforce Lab organisation Fictional research group that labels workforce text
ORG-0002 Riverton Skills Centre organisation Fictional training provider in Riverton
ORG-0003 Marrow County Transit organisation Fictional public transport operator
LOC-0001 Riverton place Fictional city in Marrow County, Calder
LOC-0002 Riverton place Fictional town in Tidewater, unrelated to LOC-0001
LOC-0003 Bellhaven place Fictional city in Marrow County
CRD-0001 Forklift Operator Licence credential Fictional certificate named in the job board
CRD-0002 Data Support Certificate credential Fictional certificate named in the training flyer

The alias table contains two rows for Riverton. That deliberate duplicate makes Riverton an ambiguity case for this lesson. It also contains the exact strings used below, including forklift certification and Forklift Operator Licence, so the singleton candidates are a property of this small teaching table.

Riverton Skills Centre is an organisation in this entity list. In the geocoding lesson, the same name is a building. The difference is a normal entity-linking problem: one name can point to an institution, a site, or both.

Prepare mention candidates

Two mention candidates come from the Riverton sentences. Three are constructed checks that exercise the credential and miss cases.

real_mentions <- bind_rows(
  sentences |>
    filter(sentence_id == "s023") |>
    transmute(mention_id = sentence_id, source = "Riverton text", text, mention = "Riverton"),
  sentences |>
    filter(sentence_id == "s024") |>
    transmute(mention_id = sentence_id, source = "Riverton text", text, mention = "DATA SUPPORT CERTIFICATE")
)

mention_candidates <- bind_rows(
  real_mentions,
  tibble(
    mention_id = c("link-03", "link-04", "link-05"),
    source = "constructed check",
    text = c(
      "Applicants ask about forklift certification.",
      "The posting names a Forklift Operator Licence.",
      "Customer service badge"
    ),
    mention = c(
      "forklift certification",
      "Forklift Operator Licence",
      "Customer service badge"
    )
  )
)

knitr::kable(
  mention_candidates,
  col.names = c("Mention ID", "Source", "Text", "Mention"),
  caption = "Mention candidates for alias matching",
  row.names = FALSE
)
Mention candidates for alias matching
Mention ID Source Text Mention
s023 Riverton text RIVERTON SKILLS OPEN HOUSE Riverton
s024 Riverton text DATA SUPPORT CERTIFICATE DATA SUPPORT CERTIFICATE
link-03 constructed check Applicants ask about forklift certification. forklift certification
link-04 constructed check The posting names a Forklift Operator Licence. Forklift Operator Licence
link-05 constructed check Customer service badge Customer service badge

A mention candidate is a span of text the team wants to link. This lesson starts with hand-selected mentions so the linking step remains visible.

Generate candidates from normalized aliases

In real datasets, exact casing rarely matches perfectly. Before joining, both the mentions and aliases are lowercased so differently capitalized names can match. This join generates candidates; it does not prove a link. A mention can have zero, one, or several candidates, and a separate resolution policy must decide whether to accept one or return NIL.

library(stringr)
normalized_mentions <- mention_candidates |>
  mutate(normalized_mention = str_to_lower(mention))

normalized_aliases <- aliases |>
  mutate(normalized_alias = str_to_lower(alias)) |>
  distinct(entity_id, normalized_alias)

linked_rows <- normalized_mentions |>
  left_join(
    normalized_aliases,
    by = join_by(normalized_mention == normalized_alias),
    relationship = "many-to-many"
  ) |>
  left_join(entities, by = join_by(entity_id))

link_status <- linked_rows |>
  summarise(
    candidate_ids = paste(unique(na.omit(entity_id)), collapse = ", "),
    candidates = n_distinct(na.omit(entity_id)),
    .by = c(mention_id, source, text, mention)
  ) |>
  mutate(
    candidate_ids = if_else(candidate_ids == "", "none", candidate_ids),
    outcome = case_when(
      candidates == 0L ~ "no candidate",
      candidates == 1L ~ "one candidate",
      TRUE ~ "multiple candidates"
    )
  )

resolved_links <- link_status |>
  mutate(
    resolved_entity_id = if_else(
      candidates == 1L,
      candidate_ids,
      NA_character_
    ),
    resolution = case_when(
      candidates == 0L ~ "NIL",
      candidates == 1L ~ "accepted singleton candidate",
      TRUE ~ "needs disambiguation"
    )
  )

outcome_counts <- tibble(
  outcome = c("one candidate", "multiple candidates", "no candidate")
) |>
  left_join(
    link_status |>
      count(outcome, name = "mentions"),
    by = join_by(outcome)
  ) |>
  mutate(mentions = coalesce(mentions, 0L))

knitr::kable(
  link_status,
  col.names = c(
    "Mention ID", "Source", "Text", "Mention", "Candidate IDs",
    "Candidates", "Candidate outcome"
  ),
  caption = "Alias matching outcomes",
  row.names = FALSE
)
Alias matching outcomes
Mention ID Source Text Mention Candidate IDs Candidates Candidate outcome
s023 Riverton text RIVERTON SKILLS OPEN HOUSE Riverton LOC-0001, LOC-0002 2 multiple candidates
s024 Riverton text DATA SUPPORT CERTIFICATE DATA SUPPORT CERTIFICATE CRD-0002 1 one candidate
link-03 constructed check Applicants ask about forklift certification. forklift certification CRD-0001 1 one candidate
link-04 constructed check The posting names a Forklift Operator Licence. Forklift Operator Licence CRD-0001 1 one candidate
link-05 constructed check Customer service badge Customer service badge none 0 no candidate
knitr::kable(
  outcome_counts,
  col.names = c("Outcome", "Mentions"),
  caption = "Candidate-generation outcomes",
  row.names = FALSE
)
Candidate-generation outcomes
Outcome Mentions
one candidate 3
multiple candidates 1
no candidate 1

The relationship argument tells dplyr that one mention may match several alias rows. DATA SUPPORT CERTIFICATE produces one candidate. This toy policy accepts singleton candidates, but a production linker would still apply a score or NIL threshold. Riverton maps to two place records, so the alias alone cannot choose one. Customer service badge has no candidate. The 3/1/1 counts describe only candidate generation for these five mentions.

Count by identifier

Two different aliases can point to the same credential identifier. That is the reason the identifier matters.

credential_mentions <- resolved_links |>
  filter(resolution == "accepted singleton candidate") |>
  rename(entity_id = resolved_entity_id) |>
  left_join(entities, by = join_by(entity_id)) |>
  filter(entity_type == "credential") |>
  count(entity_id, canonical_name, name = "mentions") |>
  arrange(entity_id)

knitr::kable(
  credential_mentions,
  col.names = c("Entity ID", "Canonical name", "Mentions"),
  caption = "Credential mentions counted by stable identifier",
  row.names = FALSE
)
Credential mentions counted by stable identifier
Entity ID Canonical name Mentions
CRD-0001 Forklift Operator Licence 2
CRD-0002 Data Support Certificate 1

The two forklift aliases count under CRD-0001. Without that identifier, the team would have to decide later whether the two spellings named the same credential.

Real-world linking pipelines

The normalized join here shows candidate generation from an alias table. Production entity linking systems add a separate resolution step:

  • Unicode and case normalization policy: A pipeline must define how it handles accents (e.g., NFC/NFD normalization) and casing before joining, ensuring mentions and aliases are normalized consistently. Never report linking rates over an unnormalized exact string join.
  • Candidate generation and ranking: When an alias is ambiguous (like Riverton), a modern pipeline generates candidate entities and ranks them using the surrounding sentence context.
  • NIL handling: A robust system detects when a mention matches an alias string but refers to a different entity not in the knowledge base (a NIL entity), rather than blindly linking it.
  • KB versioning: An entity’s canonical name or status may change. Linking results must record the specific version of the knowledge base (KB) used.
  • Alias and candidate-recall checks: Pipelines need ongoing evaluation to ensure the alias table contains common misspellings (alias recall) and the ranking system surfaces the correct entity (candidate recall).

What to remember

  • Entity linking connects a mention to a stable identifier.
  • An alias table can turn different spellings into one record.
  • Duplicate aliases create ambiguity that should be counted.
  • Alias matching only finds names someone has already written down.

The two forklift spellings count as one credential in these examples. The table does not resolve which Riverton is meant, and a badge name absent from the alias file stays unlinked.

Sources