← Handoff overview MGC Knowledge Graph · Engineering Handoff
Internal engineering handoff  ·  research-kg  ·  maps + warehouse

The Mapping Global China Knowledge Graph what it is, how it is built, and how the maps plug into it

The knowledge graph turns MGC's datasets and documents into a queryable graph of typed, cited relationships. This page is the handoff for whoever owns the platform's maps and the warehouse Postgres: the pipeline, the schema, the deterministic map-to-graph link, the read API, and an honest account of what is proven locally versus what still needs a deploy.

44,781Nodes
118,205Edges
22,341Claims
5,622Documents
45,430Mentions
8,441Map links
00

Read this first

Four things to carry into the rest of the page.

The graph is built and it is real
44,781 nodes and 118,205 edges over 5,622 documents, every one of them carrying provenance back to a character span in a source document. It rebuilds daily today, on a local machine.
The map link is deterministic, not fuzzy
The pipeline ingested the platform's own /viz dataset rows and kept their ids, so a map point and its graph node share the same id. Resolution is a table lookup, not a name match. Section 04 is the one to read closely.
Everything demonstrated so far runs locally
A local FastAPI /kg service over a SQLite copy of the graph, sitting behind the real frontend. Nothing is on the live platform yet. Section 07 is the build list.
One decision gates the rest
Option A, a standalone kg.* schema keyed by research-kg's own text node ids, or Option B, keyed to global.entity_registry through a bridge. You own that call, and the API work in 07 branches on it.
01

What it is

A research-intelligence instrument: typed relationships between the actors, projects and places already on the platform, each one traceable to the sentence it came from.

The map shows where things are. The knowledge graph shows how they connect. The AI guide lets you ask across both. Project lead's framing

That framing is the useful mental model for the whole system. The maps answer where; the graph answers how these things are related, and on whose authority. The two are joined by a bridge table rather than by inference, which is what makes the round trip between them cheap and exact.

Node types

Twelve types share one id space. Entities, the documents they were found in, and the reified claims and events extracted from those documents all live in the same node table.

  • Country
  • Organization
  • Person
  • Project
  • Framework
  • Sector
  • Document
  • Claim
  • Impact
  • Event
  • Theme
  • Tag

Provenance is not optional

Every node, edge and claim carries the same block: the source document, the character span inside it, the verbatim evidence sentence, the extractor, the model, and a confidence score. Nothing enters the graph without it. That is what lets the frontend render a "From the source" citation next to every single row it displays, and what makes the graph usable as research output rather than as a suggestion engine.

Cross-lingual by construction

Chinese and English mentions of the same entity resolve to the same node. A user searching in either language reaches the same neighbourhood, and evidence extracted from a Chinese-language source hangs off the same node as evidence extracted from an English one.

02

How it is built

Five stages. One of them costs money, and it is cached per document.

%%{init:{"theme":"base","themeVariables":{"background":"#F1F4F5","primaryColor":"#E3E9EB","primaryTextColor":"#12191F","primaryBorderColor":"#7E8F98","lineColor":"#5F7079","secondaryColor":"#F6E3C4","secondaryBorderColor":"#C8801C","tertiaryColor":"#FAFBFB","tertiaryBorderColor":"#B6C2C7","clusterBkg":"#FAFBFB","clusterBorder":"#B6C2C7","textColor":"#12191F","edgeLabelBackground":"#F1F4F5","fontFamily":"ui-monospace, SFMono-Regular, Menlo, monospace","fontSize":"13px"}}}%%
flowchart LR
  subgraph SRC["sources"]
    direction TB
    S1["MFA press-conference corpus"]
    S2["~21 academic papers, by DOI"]
    S3["/viz structured datasets"]
    S4["MGC blog + stories adapter"]
  end
  SRC --> A["a. GLiNER multilingual NER
entity mentions"] A --> B["b. closed-vocabulary LLM pass, DeepSeek
9 relation types, reified claims, dated events"] B --> C{"c. NLI entailment gate, mDeBERTa
score = raw_confidence x entailment"} C -- "below 0.5" --> X["discarded"] C -- "0.5 and above" --> D["d. cross-lingual resolution
BGE-M3 + alias table, zh and en merged"] D --> E["e. PMI co-mention edges
Leiden communities"] E --> G[("nodes, edges, claims
mentions, map_link")] CK[("LLM checkpoint
key: kind, doc_id,
model, prompt_version")] -. "replayed docs cost 0" .-> B B -. "write-through" .-> CK
Fig. 1   extraction pipeline · the checkpoint is what makes a full rebuild affordable
StageMechanismProduces
aMention extractionGLiNER, multilingualEntity mentions with character offsets
bTyped extractionClosed-vocabulary LLM pass (DeepSeek)9 relation types, reified claims carrying stance and register, dated events
cVerification gateNLI entailment (mDeBERTa)Each extraction checked against its own source sentence. Final score = raw confidence × entailment; kept only at 0.5 and above
dEntity resolutionBGE-M3 embeddings + alias tableDuplicates merged, Chinese and English surfaces linked to one node
eStructurePMI co-mention edges, Leiden communitiesAssociation edges and community grouping over the resolved graph
Stage b is the only paid step. Stage c is why a low-quality extraction never reaches the graph.

Checkpointed and resumable

The LLM cache keys on (kind, doc_id, model, prompt_version). A full rebuild therefore replays every already-extracted document for free, and only new or changed documents cost anything, on the order of $0.0003 per document. This is the property that makes the "reset and rebuild" cadence in section 06 defensible rather than wasteful, and it is also why an interrupted run can simply be restarted.

What has been ingested

  • The MFA press-conference corpus.
  • ~21 academic papers, pulled by DOI with full text.
  • The platform's own /viz structured datasets: ports, energy, investments, infrastructure, BRI, state visits and the rest. These are the rows that make the map link deterministic.
  • MGC website blog and story content, via a new adapter that ingests Research Briefs from Directus alongside Global China Stories.
03

The data model

Eight tables, three edge tiers, and one schema decision that belongs to you.

TableWhat it holds
documentsEvery ingested source: press items, papers, dataset rows, site content.
nodesThe twelve node types in one id space, keyed by text node id.
node_mentionsThe reversible mention layer sitting under every node: which span in which document produced it. Merges stay undoable because the mentions are never destroyed.
edgesAll relations, across the three tiers below, each with its own provenance block.
claimsReified claims, carrying stance and register, attached to their subjects.
aliasesSurface forms per node, including the Chinese and English pairs that drive cross-lingual resolution.
eventsEvents are modelled as Event nodes plus HAS_EVENT, HAS_PARTICIPANT and OCCURRED_IN edges, not as a separate relational shape.
map_linkThe map bridge: (source_dataset, source_row_id) → node_id. See section 04.

Edge tiers

Every edge declares which tier it belongs to. The tier is the honest label for how much the edge should be trusted, and it is the natural axis for filtering in a UI.

KeyTierWhat lands here
structuralDeterministic facts taken from the datasets themselves.
inferredLLM and computed relations, claims expressed as edges, co-mention edges.
provenanceDocument to entity mentions: the layer that ties everything back to text.
%%{init:{"theme":"base","themeVariables":{"background":"#F1F4F5","primaryColor":"#E3E9EB","primaryTextColor":"#12191F","primaryBorderColor":"#7E8F98","lineColor":"#5F7079","secondaryColor":"#F6E3C4","secondaryBorderColor":"#C8801C","tertiaryColor":"#FAFBFB","tertiaryBorderColor":"#B6C2C7","clusterBkg":"#FAFBFB","clusterBorder":"#B6C2C7","textColor":"#12191F","edgeLabelBackground":"#F1F4F5","fontFamily":"ui-monospace, SFMono-Regular, Menlo, monospace","fontSize":"13px"}}}%%
flowchart TB
  subgraph CORE["kg.* core, keyed by text node_id"]
    direction LR
    DOC["documents"]
    NOD["nodes"]
    MEN["node_mentions"]
    EDG["edges"]
    CLM["claims"]
    EVT["events
Event nodes + edges"] ALI["aliases"] end subgraph BRG["bridges"] direction LR ML["map_link
source_dataset, source_row_id
node_id, entity_id, is_primary"] NM["node_map
node_id to entity_id
the Option B bridge"] end subgraph WH["warehouse"] direction LR VIZ["/viz dataset rows
the map's own ids"] REG["global.entity_registry
entity_id uuid"] end DOC -- "source_doc_id" --> EDG DOC --> MEN NOD -- "src_id, dst_id" --> EDG NOD --> MEN ALI -- "zh and en surfaces" --> NOD CLM -- "reified as edges" --> EDG EVT -- "HAS_PARTICIPANT
OCCURRED_IN" --> EDG VIZ --> ML NOD --> ML NOD --> NM NM -. "Option B" .-> REG ML -. "reserved entity_id" .-> REG
Fig. 2   core tables and the two bridges · map_link ships today, node_map is what carries Option B

The schema decision: Option A or Option B

The graph is keyed by research-kg's own text node ids, things like proj:gwadar-port and country:PAK. The warehouse is keyed by global.entity_registry(entity_id uuid). Those two id spaces have to meet somewhere, and where they meet is your call.

 Option A — standaloneOption B — integrated
Keyed bykg.* text node idsglobal.entity_registry(entity_id uuid)
Bridge to the registryNonekg.node_map: research-kg node_id → entity_id
Work to land itLoad the DDL and the dump as-isLoad, then reconcile every node against the registry
Entity identitySelf-contained. KG entities stay separate from the warehouse's canonical entitiesShared. The KG uses the warehouse's canonical entities
TradeFastest path to a working graphMore work now, one entity space afterwards

Three known reconciliation snags under Option B

  1. research-kg's Project and Framework entities, Gwadar, CPEC, BRI and their kin, are not in entity_registry. Expect a propose-new path, not a match.
  2. Two resolvers are in play, and two resolvers can over-merge or under-merge relative to each other.
  3. The press corpus and the papers are not yet bridged into global.documents.
Your call

Option A gets a working graph into the warehouse quickly and keeps it self-contained. Option B buys one canonical entity space across the platform and costs a reconciliation pass. The API branch in section 07 was written for the Option B shape, so whichever way you go the joins get pointed accordingly. That consequence is mechanical; the identity question underneath it is not.

04

Maps and the graph

The crux of this handoff. A map point resolves to a graph node by table lookup, because the pipeline ingested the map's own rows and kept their ids.

This is worth stating plainly, because it is the part most likely to be assumed harder than it is: there is no fuzzy matching between the map and the graph, and no name lookup. The pipeline ingested the platform's /viz dataset rows directly, so a map point and its knowledge-graph node share the same id. The bridge table just makes that lookup explicit and indexable.

The bridge table

map_link maps (source_dataset, source_row_id) → node_id, plus a reserved entity_id column for Option B, plus is_primary. It currently holds 8,441 rows across 8,302 map points, covering the /viz layers. The two counts differ because a point can legitimately carry more than one node.

The primary-node rule

For a given (dataset, row_id), the node that represents that point is the source node of the edge, typically the Project, Person or Organization. Two exceptions matter:

  • Conflict events invert it: the represented node is the destination Impact node.
  • Investments can map to several nodes at once, one per co-investor Organization. This is why /kg/resolve returns a list rather than a single node.

The requirement for wiring a layer

Hard requirement

A map layer resolves to the knowledge graph only if it serves the /viz row id scheme: port:34, plant:<hash>, investment:<n>, asset:<n> and the rest of that family. If the ids the layer emits are in that scheme, the click wiring is mechanical.

Gotcha

Some map layers may be served by a separate platform API with a different id namespace. The energy layer was one (it carried a cgp_id such as BY.001) and has been repointed at /viz/energy-projects, so its plant:<hash> ids now resolve. Any layer still on another namespace only resolves once it is repointed at /viz the same way. Worth checking per layer before wiring the click, because the failure mode is a silent empty panel rather than an error.

Country selection is the one case that does not go through a row id. A country resolves via its node id, country:<ISO3>. Note the country: prefix: passing a bare ISO3 will not resolve.

The frontend path, already built

The pattern exists on the frontend branch and has been exercised against the local /kg service. A click yields a descriptor, the descriptor resolves to node ids, and the node id opens the panel. What is left is handing that descriptor over from each layer's click handler:

%%{init:{"theme":"base","themeVariables":{"background":"#F1F4F5","primaryColor":"#E3E9EB","primaryTextColor":"#12191F","primaryBorderColor":"#7E8F98","lineColor":"#5F7079","secondaryColor":"#F6E3C4","secondaryBorderColor":"#C8801C","tertiaryColor":"#FAFBFB","tertiaryBorderColor":"#B6C2C7","clusterBkg":"#FAFBFB","clusterBorder":"#B6C2C7","textColor":"#12191F","actorBkg":"#E3E9EB","actorBorder":"#7E8F98","actorTextColor":"#12191F","actorLineColor":"#8E9AA0","signalColor":"#5F7079","signalTextColor":"#12191F","labelBoxBkgColor":"#F6E3C4","labelBoxBorderColor":"#C8801C","labelTextColor":"#12191F","noteBkgColor":"#F6E3C4","noteBorderColor":"#C8801C","noteTextColor":"#12191F","sequenceNumberColor":"#FFFFFF","fontFamily":"ui-monospace, SFMono-Regular, Menlo, monospace","fontSize":"13px"}}}%%
sequenceDiagram
  autonumber
  actor U as User
  participant M as Map layer
  participant H as useKgResolve
  participant K as /kg service
  participant P as RelationshipsPanel

  U->>M: click a map point
  M->>H: deriveMapPoint feature, family
  Note over M,H: yields dataset + row_id,
the /viz row id scheme H->>K: GET /kg/resolve with dataset + row_id K-->>H: list of node_id, empty list if unmapped H->>K: GET /kg/entity/{node_id} K-->>P: center + edges + neighbours, all with provenance P->>U: relations, claims, events, network graph Note over P,U: every row carries a
From the source citation
Fig. 3   map click to panel · /kg/resolve returns a list and returns empty rather than 404

The panel itself is RelationshipsPanel: typed relations, claims, events, and a d3-force network graph, each row carrying its own "From the source" citation block with the verbatim evidence sentence.

The round trip

The lead's "View connections" and "Show on map" are not two features. They are one table read in two directions: forward from (dataset, row_id) to a node, backward from a node to the map rows that carry its coordinates.

%%{init:{"theme":"base","themeVariables":{"background":"#F1F4F5","primaryColor":"#E3E9EB","primaryTextColor":"#12191F","primaryBorderColor":"#7E8F98","lineColor":"#5F7079","secondaryColor":"#F6E3C4","secondaryBorderColor":"#C8801C","tertiaryColor":"#FAFBFB","tertiaryBorderColor":"#B6C2C7","clusterBkg":"#FAFBFB","clusterBorder":"#B6C2C7","textColor":"#12191F","edgeLabelBackground":"#F1F4F5","fontFamily":"ui-monospace, SFMono-Regular, Menlo, monospace","fontSize":"13px"}}}%%
flowchart LR
  MP["map point
dataset, row_id"] -- "View connections
map_link, forward" --> KN["KG node
node_id"] KN -- "Show on map
map_link, backward" --> MP
Fig. 4   one bridge table, read in both directions

To wire a layer

  1. Confirm the layer serves /viz row ids. If it is served by the other platform API, repoint it first.
  2. Have the click handler pass {dataset, row_id} into the panel, via deriveMapPoint.
  3. Let useKgResolve do the rest. Treat an empty list as "no graph coverage for this point" and hide the entry point rather than showing an empty panel.
05

The /kg API contract

Read-only GET endpoints. Every row that comes back carries its provenance.

EndpointParamsReturns
GET /kg/search q Entity lookup. The general entry point for finding a node by name.
GET /kg/resolve dataset, row_id Map point to node. Returns a list, because investment points can carry several co-investor nodes. An unmapped point returns an empty list, not a 404.
GET /kg/entity/{id} id The neighbourhood bundle: the centre node, its edges, and its neighbours in one response.
GET /kg/entity/{id}/relations id Just the typed relations for that node.
GET /kg/claims about Claims whose subject is the given entity, with stance and evidence.
GET /kg/events participant Dated events the given entity participates in.
GET /kg/path from, to A bounded path between two nodes.
GET /kg/ask q The keyless natural-language copilot: an interpretation of the question, cited results, and a focused graph. Designed never to come back empty. Reference implementation only: this endpoint lives in the local service (kg-demo/local_kg_api.py), not in PR #43, and needs porting to the production API.
All read-only. All GET.

Provenance on every row

Relations, claims and events all carry source_doc_id, url, title, published_date, evidence_text and the character span. That block is what CitationBlock renders, and it is why a result list can be read as sourced research rather than as model output.

The relevance filter

Default behaviour

Trivial hub edges are hidden by default: a node's country, sector or framework, LOCATED_IN, IN_SECTOR and their kind. They duplicate what the map popup already says and they are not informative connections, so they crowd out the ones that are. Pass include_context=true to get them back.

Where it lives: the filter (and the copilot above) are implemented in the local reference service kg-demo/local_kg_api.py, not yet in the PR #43 endpoints. Deploying kg.py as-is would return unfiltered hub edges until the filter is ported.

Id conventions

Worth being explicit, because it changes with the schema decision. Today, under Option A, the {id} in these routes is a text node id, proj:gwadar-port or country:PAK. Under Option B, those same ids become entity_registry uuids. Same routes, same shapes, different key space.

06

Keeping it fresh

The pipeline resets and rebuilds the whole graph on every run. That is deliberate.

Do not append

The store writes with plain INSERTs. Appending a run's output to an existing graph would duplicate edges and mentions. The correct operation is a full rebuild, and it is cheap because the checkpoint replays every already-extracted document at zero cost. Only genuinely new or changed documents reach the model.

The daily job, as it runs today

A daily job rebuilds the graph, regenerates map_link, and refreshes the served snapshot. It currently runs locally, as a macOS launchd agent with a tmux runner as fallback.

Budget

The spend cap is cumulative and persisted, not per-run, and it is a hard stop. It is currently set to $20 for the local runs. At steady state the pipeline costs roughly $2–9 a month.

The production shape

The design for production is to run the same pipeline as an mgc-hub enrichment module on a systemd timer, with an acceptance gate in front of promotion: the run only becomes visible (via a materialized-view refresh) if the provenance, anomaly and NLI checks pass. A bad run leaves yesterday's graph served rather than publishing itself. That gate is the difference between a cron job and a service.

07

Going live

What you build to put this on the real platform, and the five things to fix on the way.

Be clear about this

Everything demonstrated so far runs locally: a local FastAPI /kg service over a SQLite copy of the graph, sitting behind the real frontend. The pipeline, the graph, the map link, the endpoints and the panel are all real and all exercised, but none of it is deployed. The four steps below are the gap.

  1. Load the graph into the warehouse Postgres

    The handoff package ships postgres_kg_schema.sql, a pgvector-ready kg.* DDL; mgc_kg_data.sql.gz, a 21 MB SQL dump of nodes, edges, claims, documents, mentions and aliases; and map_link.sql.

    Decide Option A or Option B first (section 03), because step 2 depends on the answer.

  2. Deploy the /kg endpoints into the production mgc-api

    They exist on branch feat/kg-endpoints, PR #43, written for the Option B entity_registry shape. If you go Option A, repoint their joins at kg.node_map / kg.nodes.

  3. Ship the frontend panel and copilot

    Branch feat/kg-relationships-panel, PR #175. Point its /kg proxy at the deployed API, then wire each map layer's click to pass {dataset, row_id} into the panel (section 04).

  4. Deploy

    At that point the map-to-graph round trip is live on the platform rather than on a laptop.

Five pre-ship risks the review flagged

RiskWhy it matters / what to do
R1No natural unique key on kg.edgesThere is only a serial primary key today. Without a natural unique key, repeated upserts duplicate edges. Add one before anything writes twice.
R2Checkpoint key lacks a text hashThe key is (kind, doc_id, model, prompt_version). An edited source document keeps its old extraction. Add a text hash so changed text re-extracts.
R3Three-way schema reconciliationOption A versus Option B has to be settled before the API points at real tables, or the joins are written against a shape that does not exist.
R4Per-day budget reuses the cumulative counterThe cumulative cap is persisted and hard. A per-day cap must not read that same counter, or it trips immediately.
R5Documents committed before their edgesDocuments must be written last, in the same transaction as their edges. Otherwise a crash strands half-extracted documents that later runs think are done.
08

Done vs pending

Across the three repos. The split that matters is proven-locally versus needs-your-deploy.

Proven locally

  • The full pipeline, and the graph it produces, rebuilt daily.
  • A local FastAPI /kg service over a SQLite copy of the graph.
  • The real frontend running against that local service.
  • The map_link bridge and the resolve path through it.
Repo / branchWhat it containsStateWaiting on
research-kg
main
The pipeline, the graph, the kg-handoff package, and the daily cron. Done Nothing. It runs today.
mgc-api
PR #43 · feat/kg-endpoints
The /kg read endpoints and a dossier relationships section. 16 passing unit tests. Open The warehouse schema being deployed.
mgc-frontend
PR #175 · feat/kg-relationships-panel
RelationshipsPanel, EntityNetworkGraph, useKgResolve, and the Ask MGC copilot UI. Open The deployed API, plus the per-layer map-click wiring.
Two of the three PRs are blocked on the same thing: the warehouse.
09

The package, and the next step

Everything needed to load the graph is in one directory.

Clone kg-handoff/. It is the technical package, meant to be self-sufficient:

FileWhat it is
READMEOrientation and load order.
sqlite_schema.sqlThe SQLite shape the pipeline writes natively.
postgres_kg_schema.sqlThe pgvector-ready kg.* DDL for the warehouse.
mgc_kg_data.sql.gz21 MB SQL dump: nodes, edges, claims, documents, mentions, aliases.
map_link.sqlThe map bridge table and its rows.
INTEGRATION_GUIDE.mdStep-by-step integration, being written in parallel by a teammate.

For the full design rationale behind any of this, including the production cadence, the serving layer and the website integration, the design/system/ docs are the source of truth.

The first decision

Option A or Option B. Option A is fast and standalone: load the DDL and the dump as-is and have a working graph in the warehouse, keyed by research-kg's own text node ids, with its entities separate from global.entity_registry. Option B is integrated: key the graph to entity_registry through kg.node_map so the KG shares the warehouse's canonical entities, at the cost of a reconciliation pass and the three known snags in section 03.

Everything else in this handoff, the API joins, the id conventions in the routes, the shape of the frontend's calls, follows from that one answer. It is the right thing to settle first.