Adding facts to a knowledge base

Keep provenance, conflicts, NIL records, and review decisions visible

systems
knowledge base population
entity linking
Learn how knowledge base population links mentions, adds typed facts, and records why some candidates stay out of the knowledge base.

Grace receives a week of short announcements about Riverton training programs. Some repeat facts the team already has. One gives a new deadline. Another uses the name Riverton, which could mean two different places in the reference table.

Knowledge base population reads documents, links entity mentions, and adds typed facts to a knowledge base with provenance. A knowledge base is not a truth machine. It is a structured record of claims, sources, conflicts, and review decisions.

Note

The Riverton records in this lesson are fictional teaching examples about training programs, places, and organisations.

TipWhat you will learn

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

  • distinguish entity linking from fact insertion;
  • declare single-valued and list-valued slots before merging;
  • keep document dates, evidence text, and offsets for every fact;
  • merge duplicate facts without losing provenance;
  • keep conflicting dated values instead of overwriting them; and
  • route negated, hedged, ambiguous, NIL, and type-failed candidates to review.

Load the Riverton reference list

The existing knowledge base starts with entity IDs and aliases. The lesson reads the local CSV files with fixed column types and checks their recorded fingerprints before using them.

library(readr)
library(dplyr)
library(tibble)
library(tidyr)
library(purrr)
library(stringr)
library(digest)
library(knitr)

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()
  )
) |>
  select(entity_id, canonical_name, entity_type)

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

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()
  )
)

entity_hash <- digest(
  paste(read_lines("data/riverton/riverton-entities.csv"), collapse = "\n"),
  algo = "sha256",
  serialize = FALSE
)
alias_hash <- digest(
  paste(read_lines("data/riverton/riverton-aliases.csv"), collapse = "\n"),
  algo = "sha256",
  serialize = FALSE
)

reference_summary <- tibble(
  artifact = c("riverton-entities.csv", "riverton-aliases.csv"),
  rows = c(nrow(entities), nrow(aliases)),
  sha256 = c(entity_hash, alias_hash)
)

knitr::kable(
  entities,
  col.names = c("Entity ID", "Canonical name", "Type"),
  caption = "Existing Riverton entity IDs used by the population step",
  row.names = FALSE
)
Existing Riverton entity IDs used by the population step
Entity ID Canonical name Type
ORG-0001 Riverton Workforce Lab organisation
ORG-0002 Riverton Skills Centre organisation
ORG-0003 Marrow County Transit organisation
LOC-0001 Riverton place
LOC-0002 Riverton place
LOC-0003 Bellhaven place
CRD-0001 Forklift Operator Licence credential
CRD-0002 Data Support Certificate credential
knitr::kable(
  reference_summary,
  col.names = c("Artifact", "Rows", "SHA-256"),
  caption = "Reference files checked before use",
  row.names = FALSE
)
Reference files checked before use
Artifact Rows SHA-256
riverton-entities.csv 8 df260574f1f6088b51735a4778646ef22e5338186f74db321a96802d9033fe57
riverton-aliases.csv 17 0a419b6a8a742ffab404b8c58c209eaa90b0247a0ee30dbbfc85a6e914a85c64

The table prints names and types only. It does not repeat the description field for the forklift credential because this lesson is about provenance, and that description is not evidence for a job-board claim.

Declare slot rules before merging

A slot is a typed place where the knowledge base stores a value. Some slots can hold a list. Others are single-valued for a named time period, so a second dated value becomes a conflict to review.

slot_schema <- tribble(
  ~slot, ~subject_type, ~object_type, ~cardinality, ~merge_rule,
  "offers", "organisation", "credential", "list-valued",
  "One fact per subject-object pair; repeated evidence adds provenance.",
  "requires", "organisation", "credential", "list-valued",
  "One fact per subject-object pair; negated or hedged statements stay out of facts.",
  "application_deadline", "organisation", "date literal", "single-valued",
  "Keep each dated value, flag conflict, and let review decide."
)

knitr::kable(
  slot_schema,
  col.names = c("Slot", "Subject type", "Object type", "Cardinality", "Merge rule"),
  caption = "Slot cardinality and merge rules declared before population",
  row.names = FALSE
)
Slot cardinality and merge rules declared before population
Slot Subject type Object type Cardinality Merge rule
offers organisation credential list-valued One fact per subject-object pair; repeated evidence adds provenance.
requires organisation credential list-valued One fact per subject-object pair; negated or hedged statements stay out of facts.
application_deadline organisation date literal single-valued Keep each dated value, flag conflict, and let review decide.

The slot schema tells the code what counts as a duplicate and what counts as a conflict. Without that rule, a pipeline might replace a deadline without reporting the value recorded on 4 September, or turn two offerings into an error.

Prepare dated announcements

The documents below are constructed announcements. They include one duplicate, one single-valued conflict, one negated statement, one hedged statement, an ambiguous Riverton mention, one NIL organisation, one undeclared slot, and one visible type-check failure.

extractor_version <- "riverton-kbp-rules-1.0"

documents <- tribble(
  ~document_id, ~document_date, ~text,
  "A001", as.Date("2026-09-01"), "Riverton Skills Centre offers the Data Support Certificate.",
  "A002", as.Date("2026-09-03"), "The Skills Centre offers DATA SUPPORT CERTIFICATE.",
  "A003", as.Date("2026-09-04"), "Riverton Skills Centre application deadline is October 15.",
  "A004", as.Date("2026-09-06"), "Riverton Skills Centre application deadline is November 1.",
  "A005", as.Date("2026-09-07"), "Marrow County Transit does not require the Forklift Operator Licence.",
  "A006", as.Date("2026-09-08"), "Riverton will host the Data Support Certificate.",
  "A007", as.Date("2026-09-09"), "Riverton Transit offers forklift certification.",
  "A008", as.Date("2026-09-10"), "Bellhaven offers the Data Support Certificate.",
  "A009", as.Date("2026-09-11"), "Riverton Skills Centre may offer the Forklift Operator Licence.",
  "A010", as.Date("2026-09-12"), "Riverton Skills Centre promotes the Data Support Certificate."
)

candidate_plan <- tribble(
  ~candidate_id, ~document_id, ~slot, ~subject_mention, ~object_mention, ~status, ~expected_action,
  "CAND-001", "A001", "offers", "Riverton Skills Centre", "Data Support Certificate", "asserted", "add fact",
  "CAND-002", "A002", "offers", "Skills Centre", "DATA SUPPORT CERTIFICATE", "asserted", "merge duplicate",
  "CAND-003", "A003", "application_deadline", "Riverton Skills Centre", "October 15", "asserted", "add fact",
  "CAND-004", "A004", "application_deadline", "Riverton Skills Centre", "November 1", "asserted", "flag conflict",
  "CAND-005", "A005", "requires", "Marrow County Transit", "Forklift Operator Licence", "negated", "reject negated",
  "CAND-006", "A006", "offers", "Riverton", "Data Support Certificate", "asserted", "hold ambiguous",
  "CAND-007", "A007", "offers", "Riverton Transit", "forklift certification", "asserted", "hold NIL",
  "CAND-008", "A008", "offers", "Bellhaven", "Data Support Certificate", "asserted", "reject type",
  "CAND-009", "A009", "offers", "Riverton Skills Centre", "Forklift Operator Licence", "hedged", "hold hedged",
  "CAND-010", "A010", "promotes", "Riverton Skills Centre", "Data Support Certificate", "asserted", "reject undeclared slot"
)

knitr::kable(
  documents,
  col.names = c("Document ID", "Document date", "Evidence text"),
  caption = "Constructed dated announcements for knowledge base population",
  row.names = FALSE
)
Constructed dated announcements for knowledge base population
Document ID Document date Evidence text
A001 2026-09-01 Riverton Skills Centre offers the Data Support Certificate.
A002 2026-09-03 The Skills Centre offers DATA SUPPORT CERTIFICATE.
A003 2026-09-04 Riverton Skills Centre application deadline is October 15.
A004 2026-09-06 Riverton Skills Centre application deadline is November 1.
A005 2026-09-07 Marrow County Transit does not require the Forklift Operator Licence.
A006 2026-09-08 Riverton will host the Data Support Certificate.
A007 2026-09-09 Riverton Transit offers forklift certification.
A008 2026-09-10 Bellhaven offers the Data Support Certificate.
A009 2026-09-11 Riverton Skills Centre may offer the Forklift Operator Licence.
A010 2026-09-12 Riverton Skills Centre promotes the Data Support Certificate.
knitr::kable(
  candidate_plan,
  col.names = c(
    "Candidate ID", "Document ID", "Slot", "Subject mention", "Object mention",
    "Status", "Expected action"
  ),
  caption = "Same-author expected actions written before the pipeline runs",
  row.names = FALSE
)
Same-author expected actions written before the pipeline runs
Candidate ID Document ID Slot Subject mention Object mention Status Expected action
CAND-001 A001 offers Riverton Skills Centre Data Support Certificate asserted add fact
CAND-002 A002 offers Skills Centre DATA SUPPORT CERTIFICATE asserted merge duplicate
CAND-003 A003 application_deadline Riverton Skills Centre October 15 asserted add fact
CAND-004 A004 application_deadline Riverton Skills Centre November 1 asserted flag conflict
CAND-005 A005 requires Marrow County Transit Forklift Operator Licence negated reject negated
CAND-006 A006 offers Riverton Data Support Certificate asserted hold ambiguous
CAND-007 A007 offers Riverton Transit forklift certification asserted hold NIL
CAND-008 A008 offers Bellhaven Data Support Certificate asserted reject type
CAND-009 A009 offers Riverton Skills Centre Forklift Operator Licence hedged hold hedged
CAND-010 A010 promotes Riverton Skills Centre Data Support Certificate asserted reject undeclared slot

These expected actions are not a benchmark. They are a checklist for a teaching pipeline written by the same author as the examples. They are hand-supplied candidates, standing in for an extractor such as lesson 69’s relation extractor. The code below links their mentions, checks types and status, and applies population rules.

Apply slot, status, and type decisions

The action table is the evaluation for this lesson. It shows whether the pipeline made the expected decision for each candidate. It is not precision and recall.

schema_checks <- candidates |>
  left_join(slot_schema, by = "slot") |>
  mutate(
    declared_slot = !is.na(subject_type.y),
    subject_type_ok = declared_slot & subject_type.x == subject_type.y,
    object_type_ok = case_when(
      !declared_slot ~ FALSE,
      object_type.y == "date literal" ~ str_detect(object_mention, "^[A-Z][a-z]+ [0-9]+$"),
      TRUE ~ object_type.x == object_type.y
    ),
    type_check = subject_type_ok & object_type_ok,
    fact_key = paste(slot, subject_id, object_value, sep = "|")
  )

first_seen <- schema_checks |>
  filter(status == "asserted", subject_link_status == "linked", type_check) |>
  arrange(document_date, candidate_id) |>
  distinct(fact_key, .keep_all = TRUE) |>
  transmute(first_candidate_id = candidate_id, fact_key)

single_value_seen <- schema_checks |>
  filter(status == "asserted", subject_link_status == "linked", type_check) |>
  filter(cardinality == "single-valued") |>
  count(slot, subject_id, name = "single_value_count")

first_single_seen <- schema_checks |>
  filter(status == "asserted", subject_link_status == "linked", type_check) |>
  filter(cardinality == "single-valued") |>
  arrange(document_date, candidate_id) |>
  group_by(slot, subject_id) |>
  slice_head(n = 1) |>
  ungroup() |>
  transmute(slot, subject_id, first_single_candidate_id = candidate_id)

candidate_actions <- schema_checks |>
  left_join(first_seen, by = "fact_key") |>
  left_join(single_value_seen, by = c("slot", "subject_id")) |>
  left_join(first_single_seen, by = c("slot", "subject_id")) |>
  mutate(
    single_value_count = coalesce(single_value_count, 0L),
    actual_action = case_when(
      status == "negated" ~ "reject negated",
      status == "hedged" ~ "hold hedged",
      subject_link_status == "ambiguous" ~ "hold ambiguous",
      subject_link_status == "NIL" ~ "hold NIL",
      !declared_slot ~ "reject undeclared slot",
      object_link_status == "NIL" | is.na(type_check) ~ "hold NIL",
      !type_check ~ "reject type",
      candidate_id != first_candidate_id ~ "merge duplicate",
      cardinality == "single-valued" &
        single_value_count > 1L &
        candidate_id != first_single_candidate_id ~ "flag conflict",
      TRUE ~ "add fact"
    ),
    action_matches = actual_action == expected_action,
    action_reason = case_when(
      status == "negated" ~ "negated statements never become facts",
      status == "hedged" ~ "hedged statements wait for review",
      subject_link_status == "ambiguous" ~ "alias maps to more than one ID",
      subject_link_status == "NIL" ~ "new name receives a provisional ID for review",
      !declared_slot ~ "slot is not declared in the schema",
      object_link_status == "NIL" | is.na(type_check) ~ "object mention is not linked",
      !type_check ~ "slot type check failed",
      candidate_id != first_candidate_id ~ "same fact already exists; keep provenance",
      cardinality == "single-valued" &
        single_value_count > 1L &
        candidate_id != first_single_candidate_id ~ "single-valued slot has dated conflict",
      TRUE ~ "asserted, linked, and type-compatible"
    )
  )

knitr::kable(
  candidate_actions |>
    select(candidate_id, expected_action, actual_action, action_matches, action_reason),
  col.names = c("Candidate ID", "Expected action", "Pipeline action", "Match", "Reason"),
  caption = "Expected-versus-actual action table for candidate facts",
  row.names = FALSE
)
Expected-versus-actual action table for candidate facts
Candidate ID Expected action Pipeline action Match Reason
CAND-001 add fact add fact TRUE asserted, linked, and type-compatible
CAND-002 merge duplicate merge duplicate TRUE same fact already exists; keep provenance
CAND-003 add fact add fact TRUE asserted, linked, and type-compatible
CAND-004 flag conflict flag conflict TRUE single-valued slot has dated conflict
CAND-005 reject negated reject negated TRUE negated statements never become facts
CAND-006 hold ambiguous hold ambiguous TRUE alias maps to more than one ID
CAND-007 hold NIL hold NIL TRUE new name receives a provisional ID for review
CAND-008 reject type reject type TRUE slot type check failed
CAND-009 hold hedged hold hedged TRUE hedged statements wait for review
CAND-010 reject undeclared slot reject undeclared slot TRUE slot is not declared in the schema

The type failure is visible: Bellhaven is a place ID, not an organisation. The negated requirement row is rejected rather than stored as a negative fact or used to delete anything.

Build facts and provenance

The final fact table keeps one fact for the duplicate offer and two dated deadline values for the conflict. Provenance stays in a separate table so one fact can point to several source records.

accepted_candidates <- candidate_actions |>
  filter(actual_action %in% c("add fact", "merge duplicate", "flag conflict"))

fact_keys <- accepted_candidates |>
  distinct(slot, subject_id, object_value, fact_key) |>
  arrange(slot, subject_id, object_value) |>
  mutate(fact_id = sprintf("FACT-%03d", row_number()))

populated_facts <- accepted_candidates |>
  inner_join(fact_keys, by = c("slot", "subject_id", "object_value", "fact_key")) |>
  group_by(fact_id, slot, subject_id, object_value) |>
  summarise(
    provenance_records = n(),
    conflict_flag = first(cardinality) == "single-valued" &
      n_distinct(candidate_actions$object_value[
        candidate_actions$slot == first(slot) &
          candidate_actions$subject_id == first(subject_id) &
          candidate_actions$actual_action %in% c("add fact", "flag conflict")
      ]) > 1L,
    .groups = "drop"
  ) |>
  arrange(fact_id)

fact_provenance <- accepted_candidates |>
  inner_join(fact_keys, by = c("slot", "subject_id", "object_value", "fact_key")) |>
  transmute(
    fact_id,
    candidate_id,
    document_id,
    document_date,
    evidence_text = text,
    subject_start,
    subject_end,
    object_start,
    object_end,
    candidate_source = "hand-supplied candidate",
    pipeline_rules_version = extractor_version
  ) |>
  arrange(fact_id, document_id)

review_queue <- candidate_actions |>
  filter(!actual_action %in% c("add fact", "merge duplicate", "flag conflict")) |>
  transmute(
    candidate_id,
    document_id,
    subject_mention,
    object_mention,
    provisional_or_candidate_id = subject_id,
    review_reason = action_reason
  )

knitr::kable(
  populated_facts,
  col.names = c(
    "Fact ID", "Slot", "Subject ID", "Object value",
    "Provenance records", "Conflict flagged"
  ),
  caption = "Populated facts after duplicate and conflict handling",
  row.names = FALSE
)
Populated facts after duplicate and conflict handling
Fact ID Slot Subject ID Object value Provenance records Conflict flagged
FACT-001 application_deadline ORG-0002 November 1 1 TRUE
FACT-002 application_deadline ORG-0002 October 15 1 TRUE
FACT-003 offers ORG-0002 CRD-0002 2 FALSE
knitr::kable(
  fact_provenance,
  col.names = c(
    "Fact ID", "Candidate ID", "Document ID", "Document date",
    "Evidence text", "Subject start", "Subject end",
    "Object start", "Object end", "Candidate source",
    "Pipeline rules version"
  ),
  caption = "Provenance records for every populated fact",
  row.names = FALSE
)
Provenance records for every populated fact
Fact ID Candidate ID Document ID Document date Evidence text Subject start Subject end Object start Object end Candidate source Pipeline rules version
FACT-001 CAND-004 A004 2026-09-06 Riverton Skills Centre application deadline is November 1. 1 22 48 57 hand-supplied candidate riverton-kbp-rules-1.0
FACT-002 CAND-003 A003 2026-09-04 Riverton Skills Centre application deadline is October 15. 1 22 48 57 hand-supplied candidate riverton-kbp-rules-1.0
FACT-003 CAND-001 A001 2026-09-01 Riverton Skills Centre offers the Data Support Certificate. 1 22 35 58 hand-supplied candidate riverton-kbp-rules-1.0
FACT-003 CAND-002 A002 2026-09-03 The Skills Centre offers DATA SUPPORT CERTIFICATE. 5 17 26 49 hand-supplied candidate riverton-kbp-rules-1.0
knitr::kable(
  review_queue,
  col.names = c(
    "Candidate ID", "Document ID", "Subject mention", "Object mention",
    "Provisional or candidate ID", "Review reason"
  ),
  caption = "Candidates held out of the fact table for review",
  row.names = FALSE
)
Candidates held out of the fact table for review
Candidate ID Document ID Subject mention Object mention Provisional or candidate ID Review reason
CAND-005 A005 Marrow County Transit Forklift Operator Licence ORG-0003 negated statements never become facts
CAND-006 A006 Riverton Data Support Certificate LOC-0001; LOC-0002 alias maps to more than one ID
CAND-007 A007 Riverton Transit forklift certification NIL-0001 new name receives a provisional ID for review
CAND-008 A008 Bellhaven Data Support Certificate LOC-0003 slot type check failed
CAND-009 A009 Riverton Skills Centre Forklift Operator Licence ORG-0002 hedged statements wait for review
CAND-010 A010 Riverton Skills Centre Data Support Certificate ORG-0002 slot is not declared in the schema

One offer fact has two provenance records. The two deadline facts both remain, and both are flagged because a later document might be an update, an error, or a different session. The candidate set adds 3 populated facts after the review rules run. The table records the conflict; it does not resolve it. The FACT- values here are display IDs minted during this render; a real knowledge base keeps issued fact IDs stable once it publishes them.

Prepare the graph facts used in the visualization lesson

Lesson 81 redraws a small graph from the same IDs. The table below is a compact edge view of the populated facts. The two Riverton place IDs stay separate because entity IDs, not labels, are the keys.

graph_facts <- populated_facts |>
  left_join(
    fact_provenance |>
      group_by(fact_id) |>
      summarise(document_ids = paste(document_id, collapse = "; "), .groups = "drop"),
    by = "fact_id"
  ) |>
  transmute(
    subject_id,
    relation = slot,
    object_value,
    document_ids,
    source = "populated fact"
  )

knitr::kable(
  graph_facts,
  col.names = c("Subject ID", "Relation", "Object value", "Document IDs", "Source"),
  caption = "Fact edges rebuilt in the graph visualization lesson",
  row.names = FALSE
)
Fact edges rebuilt in the graph visualization lesson
Subject ID Relation Object value Document IDs Source
ORG-0002 application_deadline November 1 A004 populated fact
ORG-0002 application_deadline October 15 A003 populated fact
ORG-0002 offers CRD-0002 A001; A002 populated fact

These graph edges are still KB records, not proof about the real world. The source column says they came from the constructed population step, and the document IDs point back to the provenance table above. The two Riverton place IDs remain in the entity table, but their shared label is a naming note for the visualization lesson rather than a factual relation.

What the knowledge base does not know

A missing edge is not evidence that a relation is false. This is the open-world caveat: the KB records what it has seen and accepted under its current policy. It may be incomplete, stale, or contradicted by a later document. Provenance helps a reviewer inspect a claim; it does not certify the claim as true.

What to remember

  • Knowledge base population combines linking and slot filling.
  • Slot cardinality must be declared before merging facts.
  • Duplicate facts can share one fact ID with several provenance records.
  • Single-valued conflicts keep both dated values until review.
  • Negated and hedged statements do not become facts.
  • Riverton stays ambiguous, and a NIL organisation receives a provisional ID.

Grace ends with fewer automatic facts than candidate statements. That is the point: a useful KBP step records what it added, what it held, and why.

Sources