Actor Profiles to Knowledge Graphs: Modeling, Disambiguation, and Quality Assessment in Practice

    This article walks through turning semi-structured, multi-source actor and filmography records into a queryable person knowledge graph. It covers ontology design that models credits as first-class entities, PostgreSQL schema patterns (including NULL-aware deduplication), a six-stage extraction pipeline with explicit review gates, multi-feature entity disambiguation, tiered cross-source conflict resolution, graph query patterns, and a measurable set of data quality metrics — plus the privacy and compliance constraints that apply when the entities are real people.

    1. Four Data Challenges Hidden in a Single Person Profile

    The conclusion first: the hard part of structuring film-person profiles is not “too few fields.” It is that the same fact has multiple versions, the same person has multiple identities, and key attributes are missing for different reasons.

    Encyclopedia entries, film database records, and media archives typically carry a foreign-language name, professions, positions held, early biography, a work list ordered by year, a detail table of works, and a list of collaborators. All of it looks tidy. Once you want to make it queryable and analyzable, four classes of problems appear.

    1. Multiple roles and multiple responsibilities coexist. A person may simultaneously be an actor, assistant director, second assistant director, and casting director. On the same film, they appear on camera and also work behind it as assistant director or in casting. If the model only allows “one person, one job,” information is lost.

    2. The same fact exists in several versions. For one debut feature, the body text says “acting from 1989” while the filmography table records 1988; for another film, the body text gives 1994 with complete character names, while the table records 1992 with empty role and responsibility cells. Naive extraction produces two mutually exclusive records, and downstream statistics are skewed accordingly.

    3. Key attributes are missing, for different reasons. The “character played” column may hold a placeholder, only the word “actor,” or a precise character name. The reasons for absence differ: the source page was never completed, the character itself was uncredited, or a cell was lost during scraping. Filling all of these in the same way only adds noise.

    4. Related information is only half-finished. Collaborator lists are full of truncated names — “Didier B…”, “Charlotte…”, “Catherin…” — with no indication of who they refer to or which works were shared. A “joint works (2)” entry lists only film titles with no co-actor and no definition of what “total appearances” counts.

    That defines the engineering problem: convert semi-structured, multi-source, conflict-laden person profile text into a person knowledge graph with clean entities, explicit relationships, and measurable quality.

    2. Ontology Modeling: Define Entities and Relationships First

    The most common modeling mistake is storing whatever fields show up. Abstract the entities and relationships first, then decide how fields land.

    Entity type Description Example
    Person Natural person: actor, director, producer, etc. Film person P
    Work Film, TV series, short Film A, series B
    Role A specific character a person plays in a work Driver, boss
    Credit A record of one person participating in one work in one capacity P in film A as an actor
    Source Page, database record, or manual entry Profile page S1

    Key design decision: model Credit as its own entity rather than as a simple attribute of Person. Person↔Work is a many-to-many relationship with attributes, and the same person can hold both an actor credit and an assistant-director credit on the same work, each with its own character name, confidence, and source.

    Responsibility values are normalized against a controlled vocabulary:

    Raw expression Normalized value Category On-camera
    actor actor performing yes
    plays / appears / stars actor (starring also sets billing=lead) performing yes
    assistant director / second assistant director assistant_director directing unit no
    casting director casting_director casting no
    director director directing no
    producer / executive producer producer production no

    Two points matter here. “Starring” is a billing tier inside the actor category and should not become a third responsibility alongside “actor.” And a combined label such as “assistant director / second assistant director” should be split into an alias list on one record, defaulting to “assistant director” while retaining the raw text for traceability.

    Relationship types:

    • WORKED_IN: Person → Work, carrying role_category, role_name, is_lead, effective_year, source_id, confidence.
    • DIRECTED, PRODUCED: specialized views of WORKED_IN, for fast retrieval along director and producer dimensions.
    • COLLABORATED_WITH: Person ↔ Person, derived from two credits pointing at the same work, with works_count, first_year, last_year.
    • SAME_AS: Person ↔ Person, asserted when disambiguation concludes two records are the same natural person, carrying the evidence and confidence.

    Time modeling: the “year” in film sources has at least three meanings — release, production/shooting, and database ingestion. Use effective_year for the year an event happened and attach a year_type. When multiple sources yield different years, keep them all with their sources rather than making an arbitrary choice at extraction time.

    3. Schema Design: From Relational Tables to a Property Graph

    The schema below is a reference. Adjust it to your PostgreSQL version, field conventions, and data sources before running it. Confirm three things first: version capability (UNIQUE NULLS NOT DISTINCT and INT4RANGE require PostgreSQL 14/15 or newer; older versions need a compatibility approach); dependencies and privileges (generating IDs by hash may require an extension such as pgcrypto, creating tables and materialized views needs CREATE on the schema, and refreshing a materialized view needs a separate scheduled account); and data conventions (agree with the business on the version of the year_type and role_category controlled vocabularies before creating tables, or you will be changing types afterward).

    1. -- Person master table
    2. CREATE TABLE dim_person (
    3. person_id VARCHAR(32) PRIMARY KEY, -- globally unique ID, decoupled from name
    4. canonical_name VARCHAR(128) NOT NULL,
    5. display_name VARCHAR(128) NOT NULL,
    6. name_variants JSONB, -- foreign spellings, translated names, abbreviations
    7. professions TEXT[], -- set of normalized profession values
    8. birth_year SMALLINT, -- year only, see the minimization list in section 9
    9. active_period INT4RANGE, -- active year range, convenient for range queries
    10. data_source VARCHAR(64) NOT NULL,
    11. confidence NUMERIC(4,3),
    12. updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
    13. );
    14. -- Work table: note release_year only, no effective_year
    15. CREATE TABLE dim_work (
    16. work_id VARCHAR(32) PRIMARY KEY,
    17. title_original VARCHAR(256) NOT NULL,
    18. title_translated VARCHAR(256),
    19. work_type VARCHAR(16) NOT NULL, -- film / tv_series / tv_episode
    20. release_year SMALLINT,
    21. source_id VARCHAR(64) NOT NULL
    22. );
    23. -- Credit table: one row per person, work, responsibility, and character
    24. CREATE TABLE fact_credit (
    25. credit_id BIGSERIAL PRIMARY KEY,
    26. person_id VARCHAR(32) NOT NULL REFERENCES dim_person(person_id),
    27. work_id VARCHAR(32) NOT NULL REFERENCES dim_work(work_id),
    28. role_category VARCHAR(32) NOT NULL,
    29. role_raw VARCHAR(256), -- raw responsibility text, kept for traceability
    30. role_name VARCHAR(256), -- character played, may be NULL
    31. missing_reason VARCHAR(32), -- not_credited / source_incomplete / scrape_lost
    32. is_lead BOOLEAN NOT NULL DEFAULT FALSE,
    33. effective_year SMALLINT,
    34. year_type VARCHAR(16), -- release / production
    35. source_id VARCHAR(64) NOT NULL,
    36. confidence NUMERIC(4,3) NOT NULL,
    37. raw_snippet TEXT -- source snippet, for auditing
    38. );

    Deduplication has one boundary worth stating explicitly: PostgreSQL’s default UNIQUE treats NULLs as mutually distinct, so (person_id, work_id, role_category, role_name) does not prevent duplicates when role_name is NULL — uncredited-character rows will be written again and again. Two usable options:

    1. -- Option A: PostgreSQL 15+, NULLS NOT DISTINCT treats NULL as a comparable value
    2. ALTER TABLE fact_credit
    3. ADD CONSTRAINT uq_credit_nd UNIQUE NULLS NOT DISTINCT
    4. (person_id, work_id, role_category, role_name);
    5. -- Option B: PostgreSQL 14 and below, use a COALESCE generated column as a fallback
    6. ALTER TABLE fact_credit
    7. ADD COLUMN role_name_key TEXT
    8. GENERATED ALWAYS AS (COALESCE(role_name, '')) STORED;
    9. CREATE UNIQUE INDEX uq_credit_compat
    10. ON fact_credit (person_id, work_id, role_category, role_name_key);

    A unique constraint is a cheap line of quality defense: duplicate writes are rejected or require explicit ON CONFLICT handling, which covers the repeated “joint works” entries common in profile pages. It does not distinguish billing tiers, however — if is_lead needs to stand as its own row, include it in the deduplication key.

    Graph vertices and edges map directly onto these tables:

    1. (:Person {person_id, canonical_name, professions})
    2. (:Work {work_id, title_original, work_type, release_year})
    3. (:Person)-[:WORKED_IN {role_category, role_name, effective_year, confidence}]->(:Work)
    4. (:Person)-[:COLLABORATED_WITH {works_count, first_year, last_year}]->(:Person)

    4. Extraction Pipeline: Layered Processing with a Gate at Every Layer

    A six-stage pipeline where each stage solves one class of problem, and each has observable output and a rejection rate.

    1. Raw page
    2. │ (1) structural parsing
    3. Field fragments (body paragraphs / table rows / list items)
    4. │ (2) mention detection: person names, film titles, years, role terms
    5. Raw entries (role_raw / year_raw / title_raw)
    6. │ (3) normalization
    7. Standard entries (role_category / effective_year / canonical title)
    8. │ (4) entity linking and disambiguation
    9. Entries carrying person_id / work_id
    10. │ (5) conflict detection and resolution
    11. Candidate facts (with confidence)
    12. │ (6) validation and load (unique constraints + manual review queue)
    13. Fact tables + graph

    (1) Structural parsing. Identify the block type first — overview field area, career list, work detail table, collaborator list — since each needs different rules. Map table rows to fields via the header, parse list items with a “year + title + appearance + role” pattern, and treat the collaborator list only as disambiguation evidence, never as a direct source of facts.

    (2) Mention detection. Combine a lexicon (role vocabulary, plus cues like title brackets and the character 饰 used for “plays the role of”), rules (regex for years and the “plays X” construction), and models (person and organization name recognition).

    (3)–(6). Map role terms to standard values and set year types; attach mentions to existing entities or create new ones; merge evidence across sources and years; block duplicates with the unique constraint and route low-confidence items to a manual queue.

    Parse by column header, not by position. Career rows often have five columns (year | title | appearance | director | role). If you blindly take the first four fields, the fourth column turns “some director” into a role and produces unknown rows. The correct approach is to build a column mapping from the header and to route anomalies to manual review instead of dropping them silently. The role labels and column names below are the Chinese labels found in the source pages.

    1. import re
    2. from dataclasses import dataclass, field
    3. ROLE_LEXICON = {
    4. "演员": "actor", "出演": "actor", "饰演": "actor", "主演": "actor",
    5. "副导演": "assistant_director", "助理导演": "assistant_director",
    6. "选角导演": "casting_director", "选角": "casting_director",
    7. "导演": "director", "制片人": "producer", "监制": "producer",
    8. }
    9. LEAD_WORDS = {"主演", "领衔主演"}
    10. ROLE_ALIASES = {"assistant_director": ["副导演", "助理导演"]}
    11. @dataclass
    12. class CreditCandidate:
    13. person_name: str
    14. work_title: str
    15. role_category: str
    16. role_raw: str
    17. role_name: str | None
    18. effective_year: int | None
    19. year_type: str = "release"
    20. is_lead: bool = False
    21. source_id: str = ""
    22. evidence: list = field(default_factory=list)
    23. @dataclass
    24. class ParseResult:
    25. candidate: CreditCandidate | None
    26. verdict: str # ok / need_review / dropped
    27. reasons: list[str] = field(default_factory=list)
    28. def normalize_role(role_raw: str) -> tuple[str, bool]:
    29. category = ROLE_LEXICON.get(role_raw.strip())
    30. return (category, role_raw.strip() in LEAD_WORDS) if category else ("unknown", False)
    31. def extract_role_name(acted: str) -> str | None:
    32. m = re.match(r"饰\s*(\S.+)$", acted.strip())
    33. return m.group(1).strip() if m else None
    34. def parse_credit_line(line: str, header: str, source_id: str) -> ParseResult:
    35. """Parse one career row by header column order; return an explicit verdict
    36. for every anomaly instead of failing silently."""
    37. cols = [c.strip() for c in header.split("|")]
    38. parts = [p.strip() for p in line.split("|")]
    39. if len(parts) != len(cols):
    40. return ParseResult(None, "need_review",
    41. [f"column count mismatch: header has {len(cols)}, row has {len(parts)}"])
    42. row = dict(zip(cols, parts))
    43. reasons = []
    44. role_raw = row.get("职责", "")
    45. if not role_raw:
    46. return ParseResult(None, "need_review", ["role column is empty"])
    47. category, is_lead = normalize_role(role_raw)
    48. title = row.get("片名", "")
    49. if not title:
    50. reasons.append("title missing")
    51. year_raw = row.get("年份", "")
    52. year = None
    53. ym = re.search(r"(\d{4})", year_raw)
    54. if ym:
    55. year = int(ym.group(1))
    56. year_type = "production" if "拍摄" in year_raw else "release"
    57. else:
    58. year_type, reasons = "release", reasons + ["year missing"]
    59. role_name = extract_role_name(row.get("出演", ""))
    60. if role_name is None:
    61. reasons.append("character name missing: must distinguish uncredited / incomplete source / lost in scraping")
    62. cand = CreditCandidate(
    63. person_name="", work_title=title,
    64. role_category=category, role_raw=role_raw, role_name=role_name,
    65. effective_year=year, year_type=year_type, is_lead=is_lead,
    66. source_id=source_id, evidence=[f"{source_id}::{line}"],
    67. )
    68. if category == "unknown":
    69. return ParseResult(cand, "need_review", reasons + [f"role not in controlled vocabulary: {role_raw}"])
    70. return ParseResult(cand, "need_review" if reasons else "ok", reasons)

    Supporting tests cover five input classes: normal rows, missing year, missing character, combined roles, and truncated names.

    1. HEADER = "年份|片名|出演|导演|职责"
    2. CASES = [
    3. ("1992|影片X|饰 Norrodine|R·雷诺|演员", "ok"),
    4. ("影片Y|饰 司机|A·杜蒙|演员", "need_review"), # missing year
    5. ("1994|影片Z|—|L·卡雷|演员", "need_review"), # missing character
    6. ("1989|影片W|饰 老板|B·塔维|副导演/助理导演", "need_review"), # combined role label
    7. ]
    8. for line, expect in CASES:
    9. r = parse_credit_line(line, HEADER, "S1")
    10. assert r.verdict == expect, (line, r.verdict, r.reasons)
    11. bad = parse_credit_line("1992|影片X|饰 司机|演员", HEADER, "S1") # only 4 columns
    12. assert bad.verdict == "need_review" and "column count mismatch" in bad.reasons[0]

    One more hard rule in normalization: names never participate in ID generation. Once a name enters the primary key, a translation change or a same-name/different-person case breaks referential integrity across the whole table.

    5. Entity Normalization and Disambiguation: Same Names, Translations, Truncated Lists

    Disambiguation should score a combination of strong and weak features, not compare name strings alone.

    • Strong features: birth date/year, profession type, native language and nationality, country of residence.
    • Medium features: overlap in collaboration network, continuity and overlap of active year ranges.
    • Weak features: name similarity (edit distance, pinyin similarity, overlap of translated-name characters).

    Example of separating two same-name records:

    Dimension Record X Record Y
    Name Person P Person P
    Profession actor, assistant director, casting director producer
    Active period 1988–2005 2010–2020
    Shared collaborators 3 works with person Q 0 works with person Q

    Almost no temporal overlap, different profession types, and no intersection in the collaboration network mean two different natural persons.

    The handling flow for truncated names is recall → score → route, not direct merging. For an entry like “Didier B…”, first recall candidates by surname prefix, pinyin, and works collaborated on in the same period; keep only high-probability candidates (for example, a recall cap of 20 and a prefix match length of at least 3), then score and route. Anything below the threshold is forbidden from automatic merging and is stored in a pending state.

    1. def score_match(a: dict, b: dict) -> float:
    2. return (0.35 * jaccard(set(a["professions"]), set(b["professions"]))
    3. + 0.25 * interval_overlap_ratio(a["active_period"], b["active_period"])
    4. + 0.20 * jaccard(set(a["collaborators"]), set(b["collaborators"]))
    5. + 0.20 * name_similarity(a["name"], b["name"]))
    6. # Threshold routing: >=0.85 merge automatically; 0.6–0.85 manual review; <0.6 different entities

    Pending state should be carried by dedicated fields, not by NULL standing in for “not evaluated yet”:

    1. ALTER TABLE dim_person
    2. ADD COLUMN link_status VARCHAR(16) NOT NULL DEFAULT 'unlinked',
    3. ADD COLUMN link_candidate JSONB, -- candidate entities, scores, matched features
    4. ADD CONSTRAINT ck_link_status
    5. CHECK (link_status IN ('linked','pending_review','unlinked','merged'));
    6. ALTER TABLE dim_person
    7. ADD COLUMN merged_into VARCHAR(32) REFERENCES dim_person(person_id);

    Merging never overwrites raw data. It creates a SAME_AS relationship plus a canonical entity, while the original entry stays in raw_entity, so every merge is traceable and reversible. The default disambiguation policy is “prefer a blank over a wrong link” — one incorrect edge contaminates path query results across the whole graph.

    6. Cross-Source Conflict Resolution: One Film, Two Years

    Use a three-stage approach: evidence tiering, rule-based adjudication, and human fallback.

    Tier Source type Typical characteristics
    S1 Official material, rights-holder publication authoritative, up to date
    S2 Professional film databases highly structured
    S3 Encyclopedia-style page body broad coverage, occasional typos
    S4 Auto-generated page tables/statistics may be truncated or stale
    1. RULES = [
    2. ("official_override", lambda ev: any(e["source_level"] <= 1 for e in ev), 100),
    3. ("majority_vote", majority_vote, 60),
    4. ("body_over_table", lambda ev: prefer_level(ev, allow=[3], deny=[4]), 40),
    5. ("latest_update", latest_updated, 20),
    6. ]
    7. def resolve_conflict(evidence: list[dict]) -> dict:
    8. for name, judge, priority in sorted(RULES, key=lambda r: -r[2]):
    9. result = judge(evidence)
    10. if result:
    11. return {**result, "rule": name, "priority": priority, "evidence": evidence}
    12. return {"value": None, "rule": "unresolved", "priority": 0, "evidence": evidence}

    In the earlier example, no official source exists and majority voting fails because each version has one vote, so “body over auto-generated table” decides it: the table value is retained as alt_year and the evidence field records the original text from both sides. Downstream statistics run on effective_year, and reviewers can open the conflict details in one click.

    Three supplementary rules: when multiple sources agree, confidence combines as 1 - Π(1 - c_i); a year gap of more than two years usually means “not the same work at all,” so keep two work records and let disambiguation decide rather than force a merge; and every automatic decision records the rule name and input evidence, so any fact can answer why it has this value.

    7. Knowledge Graph Queries: From Looking Up a Person to Analyzing a Network

    First, a common mistake to correct: dim_work has only release_year and no effective_year; the authoritative work-level year lives on fact_credit (effective_year + year_type). Collaboration-year aggregation should therefore be based on the credit table, falling back to release_year when the credit row lacks a year.

    1. CREATE MATERIALIZED VIEW mv_collaboration AS
    2. WITH pair AS (
    3. SELECT a.person_id AS person_a,
    4. b.person_id AS person_b,
    5. a.work_id,
    6. COALESCE(a.effective_year, c.release_year) AS y_a,
    7. COALESCE(b.effective_year, c.release_year) AS y_b
    8. FROM fact_credit a
    9. JOIN fact_credit b ON a.work_id = b.work_id AND a.person_id < b.person_id
    10. JOIN dim_work c ON c.work_id = a.work_id
    11. WHERE a.role_category = 'actor'
    12. AND b.role_category = 'actor'
    13. AND COALESCE(a.effective_year, c.release_year) IS NOT NULL
    14. AND COALESCE(b.effective_year, c.release_year) IS NOT NULL
    15. )
    16. SELECT person_a, person_b,
    17. COUNT(DISTINCT work_id) AS works_count,
    18. MIN(LEAST(y_a, y_b)) AS first_year,
    19. MAX(GREATEST(y_a, y_b)) AS last_year
    20. FROM pair
    21. GROUP BY person_a, person_b
    22. HAVING COUNT(DISTINCT work_id) >= 2;

    COALESCE keeps rows usable when the credit lacks a year but the work has a release year; the two IS NOT NULL filters prevent LEAST/GREATEST from returning surprising results when NULL participates. Materialized views have no automatic incremental refresh, so schedule REFRESH MATERIALIZED VIEW (PG 14+ can add CONCURRENTLY to avoid read locks).

    Typical analysis scenarios:

    • Recurring team discovery: find the actor pairs with the most shared works to check whether a “regular ensemble” really exists. Collaborating once may be coincidence; repeated collaboration reflects a long-term choice.
    • Behind-the-camera network: bring assistant_director and casting_director edges into the same graph. Casting directors often connect to a large number of actors, and their betweenness centrality may be significantly higher than the actors’ own.
    • Timeline evolution: slice relationship edges by year. Some sources list a 1989 debut and a 1992 film in the same career table without explaining the gap; a timeline query surfaces such “career gaps” automatically and prompts verification or backfill.

    Property graph queries suit multi-hop exploration:

    1. MATCH (p1:Person {person_id: $pid})-[c:COLLABORATED_WITH]->(p2:Person)
    2. WHERE c.works_count >= 2 AND c.first_year >= 1990 AND c.last_year <= 2000
    3. RETURN p2.canonical_name, c.works_count, c.first_year, c.last_year
    4. ORDER BY c.works_count DESC
    5. LIMIT 50;

    Two lessons on tooling and performance: for relationship queries up to two hops, JOINs plus materialized views in a relational database are usually sufficient — do not adopt a graph database just for the sake of it. Consider a graph database for deep path queries (three hops or more) and dynamic community discovery.

    If you do introduce a graph database, note three things: index Person.person_id, Work.work_id, and WORKED_IN.role_category, or property filters trigger full-graph scans; explicitly bound variable-length paths (such as [:WORKED_IN*1..3]) and always include LIMIT, since unbounded multi-hop traversal can exhaust memory; and pre-materialize edges for high-frequency fixed patterns (such as COLLABORATED_WITH) instead of aggregating in real time during online queries.

    8. Data Quality Evaluation: Make Quality Measurable

    Without measurement there is no improvement. The core metrics below should feed monitoring and alerting.

    Metric Definition Target direction
    Field coverage non-null key fields / fields that should be filled continuously increasing
    Role annotation completeness share of credits with a role_category ≥ 99%
    Character-name fill rate share of actor credits with non-null role_name baseline per work type
    Conflict resolution rate adjudicated conflicts / detected conflicts approaching 100%
    Automatic disambiguation precision share of correct decisions in the gold set and sampled review ≥ 95%
    Duplicate rate share of writes blocked by the unique constraint continuously decreasing
    Freshness time since the last successful update within the agreed SLA

    “≥ 95% precision” is meaningless without a measurement method. A workable protocol: build a gold set of 300–500 pairs, stratified into same-person pairs, different-person pairs, truncated-name pairs, and cross-language translation pairs; after launch, sample a fixed number of items from each confidence band (≥0.85, 0.6–0.85, <0.6) for manual labeling, output a confusion matrix, and report precision and recall separately — precision alone hides the case where “many pairs that should have been merged were not.” Feed results back every two weeks, routing human decisions back into training samples or rule fixes.

    1. Daily quality inspection pipeline:
    2. (1) Reconcile totals and increments: are the numbers of new, changed, and retired entities within the expected range?
    3. (2) Rule validation: foreign key integrity, plausible year ranges, role values inside the controlled vocabulary
    4. (3) Scan conflict and pending-review queues: alert above thresholds
    5. (4) Sample by confidence band for manual review; write back precision/recall
    6. (5) Generate a daily quality report; auto-assign anomalies to the data owner

    9. Engineering Rollout: Architecture, Personal-Data Minimization, and Compliance

    • Storage tiers: raw pages go to object storage with snapshots retained; parsed intermediate results go to a data lake; a relational database serves the online entity and fact tables; a graph database carries deep relationship analysis as needed.
    • Compute scheduling: offline batch jobs handle full rebuilds, streaming handles incremental updates, and idempotent writes plus version numbers connect the two to avoid duplicate consumption.
    • Observability: instrument drop rate, rejection rate, and latency at each stage, collect them centrally, and configure threshold notifications.
    • Stated assumptions: numbers used for capacity planning, cost estimation, and SLA commitments must come from load tests and trial runs; the examples here express structure only and are not performance or pricing commitments.

    Personal-data minimization checklist (person profiles involve natural persons, so minimum necessity applies): collect only fields that are publicly available and directly relevant to the analytical purpose. Names and their public variants, professions, works and responsibilities, and public award records may be retained; for birth dates keep only the year or even the active period; exclude ID document numbers, home addresses, private contact details, and health or family-relationship information entirely; mask any contact information that must be displayed; classify sensitive levels by field on the query side and grant least privilege; log every manual change (who, when, before/after values, source of evidence) and include access behavior in audit logs.

    Compliance baseline: process only public material, respect the terms of service of source platforms, and define the boundaries of data use and retention periods. When implementing, reuse common capabilities such as object storage, data lakes, containers, logging, and monitoring available in mainstream cloud platforms or a self-managed stack rather than rebuilding them; evaluate specific choices against your own compliance requirements and operational capacity, without locking in a particular vendor.

    10. Common Pitfalls, Troubleshooting Checklist, and Best Practices

    1. Using the name as the primary key: a translation change or a same-name/different-person case breaks references across the entire table. Use a surrogate ID decoupled from the name.
    2. Loading statistics straight from the page: the definition behind “total appearances” is unclear. Derive it by aggregating the fact table in real time instead of copying the page.
    3. Splitting combined labels into multiple values: “assistant director / second assistant director” should be one record with aliases, or work counts get inflated by duplication.
    4. Silently zero-filling or guessing missing values: explicitly mark placeholders and empty cells as NULL and write missing_reason; they must not be read as “this character does not exist.”
    5. Storing results without evidence: every fact carries source_id and a raw snippet — that is the foundation for auditing, correction, and model iteration.
    6. Rerunning everything in full each time: support incremental updates by source and by entity, and keep historical versions.
    7. Relying on UNIQUE to block all duplicates: deduplication silently fails for NULL character names; use NULLS NOT DISTINCT or a COALESCE generated column.
    8. Parsing columns by position instead of by header: one extra director column produces unknown roles. Always map by header and track the share of each verdict.

    When troubleshooting, look at three numbers first: the need_review share from structural parsing (usually high when headers or column order are wrong), the unknown role share (usually controlled vocabulary missing new phrasings), and the length of the pending-disambiguation queue (steady growth means thresholds or feature weights need adjustment).

    Key Takeaways

    Behind an ordinary actor profile entry sit classic data engineering problems: multi-role responsibilities, cross-source conflicts, entity disambiguation, and missing-value governance. The approach in four steps:

    1. Use the Credit entity to separate who participated in which work, and in what capacity.
    2. Use a layered extraction pipeline to turn text into standard entries, parse by header, and route anomalies to review.
    3. Disambiguate with multi-feature scoring, and stick to “prefer a blank over a wrong link.”
    4. Resolve conflicts with evidence tiering and rule adjudication, so every fact is traceable.

    On top of that, operate continuously with deduplication constraints (mind the NULL semantics), quality metrics, and a human-review loop. Semi-structured film and TV material then becomes a person knowledge graph that supports search, recommendation, and casting analysis. The same method applies to any archive governance scenario with multi-identity entities, multiple sources, and conflicts — supplier records, academic author publication databases, medical practitioner credential histories, and similar.