Incremental Graph Updates for Corporate Knowledge Graphs: Three Problems Batch Pipelines Can't Solve A developer building 2asy.ai, a Graph-RAG system for East Asian corporate intelligence, found that batch pipelines fail for incremental graph updates, causing duplicate entities and conflicting facts. They propose resolving entities against the live graph before insertion and modeling property values as time-bounded assertions to handle supersession. Corporate knowledge graphs break in production the moment you treat them as a batch artifact. I ran into this building 2asy.ai https://2asy.ai , a Graph-RAG system for East Asian corporate intelligence. The initial architecture was standard: ingest a document set, run NER and entity resolution across the batch, write the graph. Clean, deterministic, fast to prototype. Then the first update cycle hit. The batch pipeline handled entity resolution using blocking keys across the entire document corpus. With incremental updates, you only have the incoming batch. The blocking keys no longer reach the existing graph nodes. In practice this means: a new trade document arrives with "Samsung Elec. Co., Ltd." The existing graph has "Samsung Electronics Co., Ltd." as a canonical node with 847 existing edges. Without live-graph entity resolution, you insert a second node and start building a disconnected duplicate. Here is the resolution-at-boundary pattern that works: python from dataclasses import dataclass from typing import Optional import numpy as np @dataclass class EntityCandidate: raw name: str normalized name: str language: str doc id: str embedding: Optional np.ndarray = None def resolve against live graph candidate: EntityCandidate, graph client, neo4j.GraphDatabase or similar embed fn, threshold: float = 0.85, - Optional str : """ Run entity resolution against existing graph nodes before inserting. Returns the existing node ID if a match is found, else None. """ Step 1: Blocking — retrieve candidate neighbors by name prefix blocking query = """ MATCH e:Entity WHERE e.blocking key STARTS WITH $prefix RETURN e.id AS id, e.canonical name AS name, e.name embedding AS emb LIMIT 50 """ prefix = candidate.normalized name :4 .upper neighbors = graph client.run blocking query, prefix=prefix .data if not neighbors: return None Step 2: Embedding similarity against blocked candidates if candidate.embedding is None: candidate.embedding = embed fn candidate.normalized name best score = 0.0 best id = None for row in neighbors: if row "emb" is None: continue existing emb = np.array row "emb" score = float np.dot candidate.embedding, existing emb / np.linalg.norm candidate.embedding np.linalg.norm existing emb + 1e-9 if score best score: best score = score best id = row "id" return best id if best score = threshold else None If resolve against live graph returns a node ID, you merge onto that node. If it returns None, you insert as new. The full entity resolution run happens at the boundary, not after insertion. A company relocates its headquarters. The new filing contains the correct address. The old record is already in the graph. Naive append gives you both — which means every Graph-RAG query touching that entity gets conflicting facts. The LLM has to guess which one is current. The fix is to model each property value as a time-bounded assertion rather than a mutable attribute: python from datetime import date from neo4j import GraphDatabase def upsert fact with supersession driver: GraphDatabase, entity id: str, property name: str, new value: str, valid from: date, source doc id: str, - None: """ Write a new fact, terminating any currently-open assertion for the same property. Old assertions are retained for audit; only valid to is set. """ with driver.session as session: session.run """ // Terminate the previous open assertion for this property MATCH e:Entity {id: $entity id} - :HAS FACT - f:Fact WHERE f.property name = $property name AND f.valid to IS NULL SET f.valid to = date $valid from - duration 'P1D' """, entity id=entity id, property name=property name, valid from=str valid from session.run """ // Insert the new assertion with open valid to MATCH e:Entity {id: $entity id} CREATE e - :HAS FACT - f:Fact { property name: $property name, value: $new value, valid from: date $valid from , valid to: null, source doc id: $source doc id } """, entity id=entity id, property name=property name, new value=new value, valid from=str valid from , source doc id=source doc id Queries then specify a point-in-time context: // Get headquarters address as of 2024-03-01 MATCH e:Entity {id: $entity id} - :HAS FACT - f:Fact WHERE f.property name = 'headquarters address' AND f.valid from <= date '2024-03-01' AND f.valid to IS NULL OR f.valid to = date '2024-03-01' RETURN f.value AS address This is the pattern that makes Graph-RAG as-of queries reliable. Without it, you are relying on insertion order, which is neither guaranteed nor queryable. When two entity nodes merge because resolve against live graph found a match , the incoming node's edges need to be transferred to the surviving node. If your update pipeline does not model this explicitly, you end up with a partially disconnected graph where relationships point to deprecated node IDs. python def merge nodes and transfer edges driver: GraphDatabase, surviving id: str, deprecated id: str, - int: """ Transfer all edges from deprecated node to surviving node, then mark deprecated node as merged. Returns the number of edges transferred. """ with driver.session as session: Transfer outgoing edges result = session.run """ MATCH deprecated:Entity {id: $deprecated id} - r - target WHERE NOT target.id = $surviving id MATCH surviving:Entity {id: $surviving id} CALL apoc.refactor.from r, surviving YIELD input, output RETURN count AS transferred """, deprecated id=deprecated id, surviving id=surviving id outgoing = result.single "transferred" Transfer incoming edges result = session.run """ MATCH source - r - deprecated:Entity {id: $deprecated id} WHERE NOT source.id = $surviving id MATCH surviving:Entity {id: $surviving id} CALL apoc.refactor.to r, surviving YIELD input, output RETURN count AS transferred """, deprecated id=deprecated id, surviving id=surviving id incoming = result.single "transferred" Mark deprecated node session.run """ MATCH e:Entity {id: $deprecated id} SET e.merged into = $surviving id, e.merged at = datetime """, deprecated id=deprecated id, surviving id=surviving id return outgoing + incoming The merged into property lets you build a forwarding index. Any query that arrives with a deprecated node ID can be redirected to the surviving node without a full graph scan. After a merge, the direct neighbors of the deprecated node may have consistency issues — edges that now point through the merged node incorrectly, or facts that reference the deprecated entity in their source context. The lightweight fix is a dirty-neighbor queue: python def flag dirty neighbors driver: GraphDatabase, node id: str, reason: str, - list str : """ Flag the direct neighbors of a node for consistency recheck in the next ingestion cycle. """ with driver.session as session: result = session.run """ MATCH n:Entity {id: $node id} - 1 - neighbor:Entity WHERE NOT neighbor.id = $node id SET neighbor.dirty = true, neighbor.dirty reason = $reason, neighbor.dirty at = datetime RETURN collect DISTINCT neighbor.id AS neighbor ids """, node id=node id, reason=reason return result.single "neighbor ids" At the start of each ingestion cycle, you pull all dirty neighbors and run a consistency check before processing new documents. This handles cascade propagation without a full graph traversal on every update. Batch rebuild works in a prototype. Production knowledge graphs need an explicit update model from the start: Adding these after the fact means rebuilding the schema and reprocessing historical documents. The three problems above are not edge cases — they are the production steady state for any graph that receives continuous updates. I work on entity resolution and agentic infrastructure at er-api.hannune.ai. The Graph-RAG system this is based on runs against East Asian corporate registries where transliteration variance and entity deduplication are the hard problems.