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
/vizdataset 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
/kgservice 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 toglobal.entity_registrythrough a bridge. You own that call, and the API work in 07 branches on it.
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.
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
| Stage | Mechanism | Produces | |
|---|---|---|---|
| a | Mention extraction | GLiNER, multilingual | Entity mentions with character offsets |
| b | Typed extraction | Closed-vocabulary LLM pass (DeepSeek) | 9 relation types, reified claims carrying stance and register, dated events |
| c | Verification gate | NLI entailment (mDeBERTa) | Each extraction checked against its own source sentence. Final score = raw confidence × entailment; kept only at 0.5 and above |
| d | Entity resolution | BGE-M3 embeddings + alias table | Duplicates merged, Chinese and English surfaces linked to one node |
| e | Structure | PMI co-mention edges, Leiden communities | Association edges and community grouping over the resolved 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
/vizstructured 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.
The data model
Eight tables, three edge tiers, and one schema decision that belongs to you.
| Table | What it holds |
|---|---|
| documents | Every ingested source: press items, papers, dataset rows, site content. |
| nodes | The twelve node types in one id space, keyed by text node id. |
| node_mentions | The reversible mention layer sitting under every node: which span in which document produced it. Merges stay undoable because the mentions are never destroyed. |
| edges | All relations, across the three tiers below, each with its own provenance block. |
| claims | Reified claims, carrying stance and register, attached to their subjects. |
| aliases | Surface forms per node, including the Chinese and English pairs that drive cross-lingual resolution. |
| events | Events are modelled as Event nodes plus HAS_EVENT, HAS_PARTICIPANT and OCCURRED_IN edges, not as a separate relational shape. |
| map_link | The 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.
| Key | Tier | What lands here |
|---|---|---|
| structural | Deterministic facts taken from the datasets themselves. | |
| inferred | LLM and computed relations, claims expressed as edges, co-mention edges. | |
| provenance | Document 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
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 — standalone | Option B — integrated | |
|---|---|---|
| Keyed by | kg.* text node ids | global.entity_registry(entity_id uuid) |
| Bridge to the registry | None | kg.node_map: research-kg node_id → entity_id |
| Work to land it | Load the DDL and the dump as-is | Load, then reconcile every node against the registry |
| Entity identity | Self-contained. KG entities stay separate from the warehouse's canonical entities | Shared. The KG uses the warehouse's canonical entities |
| Trade | Fastest path to a working graph | More work now, one entity space afterwards |
Three known reconciliation snags under Option B
- 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. - Two resolvers are in play, and two resolvers can over-merge or under-merge relative to each other.
- The press corpus and the papers are not yet bridged into
global.documents.
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.
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/resolvereturns a list rather than a single node.
The requirement for wiring a layer
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.
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
/kg/resolve returns a list and returns empty rather than 404The 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
To wire a layer
- Confirm the layer serves
/vizrow ids. If it is served by the other platform API, repoint it first. - Have the click handler pass
{dataset, row_id}into the panel, viaderiveMapPoint. - Let
useKgResolvedo the rest. Treat an empty list as "no graph coverage for this point" and hide the entry point rather than showing an empty panel.
The /kg API contract
Read-only GET endpoints. Every row that comes back carries its provenance.
| Endpoint | Params | Returns |
|---|---|---|
| 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. |
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
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.
Keeping it fresh
The pipeline resets and rebuilds the whole graph on every run. That is deliberate.
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.
Going live
What you build to put this on the real platform, and the five things to fix on the way.
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.
-
Load the graph into the warehouse Postgres
The handoff package ships
postgres_kg_schema.sql, a pgvector-readykg.*DDL;mgc_kg_data.sql.gz, a 21 MB SQL dump of nodes, edges, claims, documents, mentions and aliases; andmap_link.sql.Decide Option A or Option B first (section 03), because step 2 depends on the answer.
-
Deploy the
/kgendpoints into the production mgc-apiThey exist on branch
feat/kg-endpoints, PR #43, written for the Option Bentity_registryshape. If you go Option A, repoint their joins atkg.node_map/kg.nodes. -
Ship the frontend panel and copilot
Branch
feat/kg-relationships-panel, PR #175. Point its/kgproxy at the deployed API, then wire each map layer's click to pass{dataset, row_id}into the panel (section 04). -
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
| Risk | Why it matters / what to do | |
|---|---|---|
| R1 | No natural unique key on kg.edges | There is only a serial primary key today. Without a natural unique key, repeated upserts duplicate edges. Add one before anything writes twice. |
| R2 | Checkpoint key lacks a text hash | The 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. |
| R3 | Three-way schema reconciliation | Option 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. |
| R4 | Per-day budget reuses the cumulative counter | The cumulative cap is persisted and hard. A per-day cap must not read that same counter, or it trips immediately. |
| R5 | Documents committed before their edges | Documents must be written last, in the same transaction as their edges. Otherwise a crash strands half-extracted documents that later runs think are done. |
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
/kgservice over a SQLite copy of the graph. - The real frontend running against that local service.
- The
map_linkbridge and the resolve path through it.
Not true anywhere yet
- The graph is not in the warehouse Postgres.
- The
/kgendpoints are not deployed in the production mgc-api. - The frontend PR is unmerged and no map layer's click is wired per layer.
- No production cadence: no systemd timer, no acceptance gate.
| Repo / branch | What it contains | State | Waiting 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. |
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:
| File | What it is |
|---|---|
| README | Orientation and load order. |
| sqlite_schema.sql | The SQLite shape the pipeline writes natively. |
| postgres_kg_schema.sql | The pgvector-ready kg.* DDL for the warehouse. |
| mgc_kg_data.sql.gz | 21 MB SQL dump: nodes, edges, claims, documents, mentions, aliases. |
| map_link.sql | The map bridge table and its rows. |
| INTEGRATION_GUIDE.md | Step-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.
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.