A single corporate event in East Asia rarely shows up once. A merger gets filed at OpenDART in Korean, reported by EDINET in Japanese a day later, picked up by a Hong Kong exchange notice in traditional Chinese, and summarized in an English wire story. Four documents, one real-world incident. If your pipeline extracts events document by document, you end up with four Event nodes in the graph that are really the same thing.
That is the situation I hit in the knowledge graph behind 2asy.ai, and the fix turned out to be a different problem than I first assumed.
Event coreference is not entity resolution
It is easy to conflate the two. Entity resolution answers "is this Samsung the same as that Samsung," matching company or person names across languages and spellings. I already solve that upstream with a cross-lingual entity resolution pass, so by the time events land in the graph, 삼성전자 and Samsung Electronics point to one canonical entity ID.
Event coreference is the next layer. Even with entities unified, the events themselves stay split. Two documents can both reference the same canonical Samsung entity and still describe what looks like two separate filings, because each was extracted in isolation. Coreference asks a harder question: are these two event records describing the same incident, or two genuinely different things that happen to share an actor?
Why the graph splits events in the first place
The graph builder follows a one-document, one-LLM-call rule. Each article or disclosure is read once, in its own language, and the model emits the events it finds in that document alone. That is deliberate. Extraction stays auditable, cost per document stays bounded, and I never hand one giant prompt a whole day of news and watch it hallucinate links between stories that were never connected.
The cost is duplication. The builder has no memory of what another document said, so the same merger becomes a Korean Event node, a Japanese one, and an English one. To a reader traversing the graph that looks like three mergers. There was one.
The naive fix breaks on East Asian data
The obvious shortcut is to dedup events by string similarity on their summaries. It fails here for two reasons. First, the summaries are in different languages, so token overlap between a Korean summary and an English one is close to zero even when they describe the identical event. Second, even within one language, two genuinely distinct events involving the same companies, say a supply contract and a separate lawsuit filed the same week, can share most of their vocabulary. String matching cannot tell coincidental overlap from real coreference.
The two-stage approach I settled on
I built a small standalone resolver that runs read-only against the graph and emits clusters as JSON. It never writes back to Neo4j. The design has two stages on purpose: a cheap rule-based filter, then a targeted LLM judgment.
Stage one pulls recent Event nodes inside a time window and forms candidate pairs without calling any model. A pair survives only if it clears three gates: the events share at least one canonical entity, their date buckets fall within seventy-two hours, and they either carry the same event type or their summaries clear a Jaccard token-overlap threshold. Each surviving pair gets a similarity score and the list is sorted so the strongest candidates come first. This stage exists to keep the LLM bill bounded. With a per-run cap on calls, you judge the top candidates by score and skip the long tail of weak pairs.
Stage two hands each candidate pair to a small model with both summaries, both entity lists, and both date buckets, and asks for one of three labels: same, related, or distinct. I keep the three-way distinction on purpose. Collapsing related into same would merge a contract and a lawsuit between the same two firms; collapsing it into distinct would scatter genuine follow-on coverage. Only a "same" verdict counts as a merge edge.
Stage three is plain union-find. Every "same" verdict unions the two event IDs, and the connected components become clusters. Any component with a single member gets dropped, since a cluster of one says nothing about cross-document coverage. Each surviving cluster record carries its member event IDs, the union of their entities, the source documents, the time range, and the model's one-line reason for every merge it made. I kept that reason log so I can read back why two events got joined when a cluster looks wrong.
Design decisions that mattered
The one-LLM-call-per-pair rule mirrors the one-document rule in the builder. It is slower than batching a cluster into a single prompt, but it keeps each judgment independent and easy to inspect, and it stops the model from inventing transitive links it was never asked about. Transitivity comes from union-find on discrete yes-or-no verdicts, not from the model reasoning over a whole group at once.
The seventy-two-hour window is a blunt instrument, and it is the part I trust least. Cross-border coverage of the same incident usually lands inside three days, but slow regulatory follow-ups can arrive a week later and get missed. Widening the window quadratically inflates the candidate-pair count, which is exactly what the rule filter is there to contain. That tension between recall and cost is unresolved, and for now I chose the cheaper side.
The whole thing runs against a read-only Neo4j connection and outputs a file. Nothing touches the operational graph. Promoting a cluster ID back onto the Event nodes is a separate, deliberate step, not something a nightly job does on its own.
Where this sits
Right now the resolver is an offline pass that produces clusters for review. It is not yet a wired-in stage of the daily pipeline. The next move is letting the content layer read cluster IDs, so a brief can say "covered across four filings in three jurisdictions" instead of treating each filing as its own event. The point was never a tidier graph. It was a reader who sees that one incident got reported four times, in four languages, and can trust that the graph already worked that out.
The public graph is at https://www.2asy.ai/. The entity resolution layer underneath it is at https://api.hannune.ai/entity-resolution/v1.
The related post on the layer below this one: Cross-Lingual Entity Resolution in a Trade Knowledge Graph