Standard knowledge graph edges are timeless by default. When you insert "Company X → CEO → Person A," there is no expiration date on that relationship. The graph becomes stale the moment the CEO changes, but it does not know it is stale.
I ran into this building a multilingual knowledge graph for East Asian corporate data at 2asy.ai. Corporate structures change constantly — leadership transitions, subsidiary changes, regulatory status updates — and every change makes some existing edges incorrect rather than just outdated.
The Problem with Static Edges
Here is what a static edge insertion looks like:
# Static edge — no temporal metadata
graph.add_edge(
source="samsung-electronics",
relation="CEO",
target="lee-jae-yong",
)
When the CEO changes, you either:
- Delete the old edge and insert a new one (losing history)
- Insert a new edge without removing the old one (getting conflicting answers)
Neither is acceptable when you need the graph to answer both current and historical queries correctly.
The Fix: (valid_from, valid_to) on Every Edge
Every relationship gets temporal properties as first-class fields:
# Temporal edge
graph.add_edge(
source="samsung-electronics",
relation="CEO",
target="lee-jae-yong",
valid_from="2022-10-27",
valid_to=None, # None = currently active
)
When an event modifies an existing relationship, the old edge gets a closed valid_to date rather than being deleted:
def supersede_ceo(company_id: str, new_ceo_id: str, effective_date: str):
current = graph.find_edge(
source=company_id,
relation="CEO",
valid_to=None
)
if current:
graph.update_edge(current.id, valid_to=effective_date)
graph.add_edge(
source=company_id,
relation="CEO",
target=new_ceo_id,
valid_from=effective_date,
valid_to=None,
)
Graph traversal queries default to as-of the current date unless the caller explicitly requests full history.
The Hard Part: Supersession Logic
The tricky design decision is distinguishing which edge types supersede and which accumulate.
A CEO appointment is one-to-one: the new edge invalidates the old one. A subsidiary relationship is many-to-many: a company can have multiple subsidiaries simultaneously, so the new edge extends rather than replaces.
This distinction has to live in the edge schema, not in the insertion code.
EDGE_CARDINALITY = {
"CEO": "one_to_one",
"CFO": "one_to_one",
"SUBSIDIARY": "many_to_many",
"BOARD_MEMBER": "many_to_many",
}
If it lives in insertion code, every developer who writes an insertion path has to know the business rules. If it lives in the schema, the graph enforces it automatically.
For East Asian corporate data, I had to map over 40 relationship types to cardinality rules. The initial list took two days; maintaining it as new event types were discovered was ongoing work.
Graph RAG Implications at Retrieval Time
A query for "Samsung's leadership structure in Q3 2024" needs to traverse edges that were valid during that specific window:
def query_as_of(start_node_id: str, relation: str, as_of_date: str):
return graph.find_edges(
source=start_node_id,
relation=relation,
filters={
"valid_from": {"lte": as_of_date},
"valid_to": {"gte": as_of_date, "or_null": True}
}
)
Without temporal modeling, the graph returns the current structure and nothing in the response signals that it is answering a different question than the one asked.
Why Test Sets Hide This Problem
The retrieval accuracy difference between a temporally-modeled graph and a static graph is not visible on test sets built from current data. It shows up the first time a user asks a historical question and the system answers confidently with the wrong year's data.
Standard RAG evaluation benchmarks almost always test against current-state questions. If your knowledge domain has history that matters — corporate actions, regulatory changes, personnel movements — you have to build your own eval set with historical queries and known-correct answers at specific timestamps.
I built ours from public Korean corporate disclosure data, which has machine-readable effective dates on most events. That gave us a dataset where we knew what the correct graph state was at any given quarter. The temporal modeling errors were the largest single category before we added (valid_from, valid_to) to the schema.
Working on multilingual knowledge graphs and Graph RAG pipelines at 2asy.ai. More writing at hannune.ai.
Cover: Planet Volumes on Unsplash
Top comments (2)
The valid_from/valid_to model is the right foundation, and the piece it opens up next is the second time axis. Valid-time tells you when the fact was true in the world; it does not tell you when your system learned it. The case that bites is the late-arriving change: you find out in July that the CEO actually changed in March. With valid-time alone you backfill the March edge and silently rewrite history, so the same "as of April" query returns a different answer today than it did in April, with nothing recording why. Bitemporal edges (valid_from/valid_to plus a recorded_at) let you answer both "who was CEO in April" and "who did we believe was CEO in April, given what we knew then," which is the difference between a knowledge graph and an auditable one.
The other trap hiding in this model is null. valid_to = null is doing two jobs at once: "still true" and "nobody ever sent the close event." A CEO who left but whose end-edge never arrived looks identical to a current one, so the temporal model makes staleness expressible but not visible. A last_confirmed_at on the open edge closes that gap: an edge that is open but has not been reconfirmed since some threshold should retrieve at lower confidence rather than as settled fact. Otherwise the graph is still confidently stale, just with better timestamps.
Temporal edges are easy to underestimate because static graphs feel cleaner in diagrams. But many real answers depend on when a relationship was true, whether it expired, and what replaced it. Graph RAG without time can retrieve a fact that is structurally relevant and historically wrong.