Query-Time Entity Disambiguation in Graph RAG: When One Name Means Seventeen Nodes
The hardest retrieval problem in Graph RAG is not missing data. It is the query that arrives with an ambiguous entity name. In 2asy.ai, a knowledge graph built on Korean corporate data, "Hyundai" matches seventeen separate nodes. A vector search returns all of them ranked by embedding similarity. A graph traversal needs exactly one starting node. The gap between these two realities is where Graph RAG quietly breaks.
The Setup: Where Disambiguation Fits in the Pipeline
In a standard Graph RAG pipeline, the flow is:
- Parse the natural language query
- Extract entity mentions
- Disambiguate each mention to a specific graph node โ the problem
- Traverse the graph from that node
- Feed retrieved subgraph into the generation step
Step 3 is where "Hyundai" needs to resolve to either Hyundai Motor Company, Hyundai Engineering & Construction, Hyundai Steel, Hyundai Merchant Marine, or one of the other thirteen. The wrong choice produces a coherent-sounding answer about the wrong company.
from dataclasses import dataclass
from datetime import datetime
@dataclass
class DisambiguationResult:
node_id: str
display_name: str
confidence: float
signal_breakdown: dict[str, float]
def disambiguate(
mention: str,
candidates: list[dict],
context_terms: list[str],
query_time: datetime,
) -> DisambiguationResult:
scores = {}
for candidate in candidates:
freq = frequency_prior_score(mention, candidate)
ctx = context_coherence_score(candidate, context_terms)
temporal = temporal_activity_score(candidate, query_time)
scores[candidate["node_id"]] = {
"frequency": freq,
"context": ctx,
"temporal": temporal,
"combined": freq * 0.35 + ctx * 0.45 + temporal * 0.20,
}
best = max(scores, key=lambda nid: scores[nid]["combined"])
return DisambiguationResult(
node_id=best,
display_name=next(c["name"] for c in candidates if c["node_id"] == best),
confidence=scores[best]["combined"],
signal_breakdown=scores[best],
)
Signal 1: Corpus Frequency Prior
Without any query context, the entity that appears most often in the source corpus where the mention is unqualified is the right default. In the Korean financial news corpus underlying 2asy.ai, "Hyundai" without a qualifier co-occurs with Hyundai Motor documents about two-thirds of the time. That prior is learnable from the corpus and beats a random guess at the disambiguation step even before any query-specific signals are considered.
def frequency_prior_score(mention: str, candidate: dict) -> float:
"""
Fraction of unqualified mentions of `mention` that refer to this candidate
in the training corpus. Stored as a per-mention-string dict on the node.
"""
priors = candidate.get("mention_priors", {})
return priors.get(mention.lower(), candidate.get("default_frequency_prior", 0.0))
The prior is stored on each node at graph-build time. It is not recomputed at query time - that would be slow. What gets stored is the fraction of unambiguous co-occurrences from the corpus. A candidate with a prior of 0.65 means: historically, this name referred to this entity 65% of the time it appeared without additional qualifiers.
Signal 2: Query Context Coherence
The mention rarely arrives in isolation. If the same query mentions "battery technology" or "manufacturing capacity," that co-occurrence signal shifts probability mass toward Hyundai Motor. If the query mentions "construction permits" or "project bids," Hyundai Engineering moves up. This is a co-occurrence statistic between the query terms and each candidate's entity description:
import math
from collections import Counter
def context_coherence_score(
candidate: dict,
context_terms: list[str],
smoothing: float = 1.0,
) -> float:
"""
Pointwise mutual information between context_terms and candidate description
tokens. Higher score = more context terms co-occur with this entity in
training data.
"""
if not context_terms:
return 0.5 # neutral when no context available
description_tokens = set(candidate.get("description_tokens", []))
cooccurrence_counts = candidate.get("cooccurrence_counts", {})
total_docs = candidate.get("total_docs_in_corpus", 1)
pmi_sum = 0.0
for term in context_terms:
p_term = cooccurrence_counts.get(term, 0) / total_docs
p_entity = candidate.get("doc_count", 0) / total_docs
p_joint = cooccurrence_counts.get(f"joint:{term}", 0) / total_docs
if p_joint > 0 and p_term > 0 and p_entity > 0:
pmi = math.log2(p_joint / (p_term * p_entity) + smoothing)
pmi_sum += max(0.0, pmi)
return min(1.0, pmi_sum / (len(context_terms) + smoothing))
In practice, storing full co-occurrence counts per candidate is expensive. We store the top-500 co-occurring terms per entity node, which covers the vast majority of real query patterns.
Signal 3: Temporal Suppression
A historical subsidiary with a valid_to date should rank below currently-active entities for queries that do not specify a historical time window. The graph already has the structure to express this - active vs. inactive entity status - but the query layer has to use it.
def temporal_activity_score(candidate: dict, query_time: datetime) -> float:
"""
Returns 1.0 if the entity is active at query_time, 0.1 if inactive.
Entities without a valid_to are assumed ongoing.
"""
valid_from = candidate.get("valid_from")
valid_to = candidate.get("valid_to")
if valid_to is not None:
active = valid_from <= query_time <= valid_to
else:
active = valid_from <= query_time if valid_from else True
return 1.0 if active else 0.1
The weight of 0.1 (not zero) lets historical entities still be retrieved if the other signals are strong enough - useful when a query explicitly references a past entity. But for the common case of a present-tense query, a deactivated entity is strongly suppressed.
The Failure Mode: Silent Wrong Answers
When disambiguation selects the wrong candidate, the failure is invisible. The graph traversal returns a coherent subgraph, the generation step produces a confident-sounding answer, and nothing in the output signals that retrieval started from the wrong node. The user who asked about "Hyundai's battery supply chain" gets a detailed answer about Hyundai Engineering's infrastructure projects instead. The answer is internally consistent. It is just about the wrong company.
This is worse than a retrieval miss. A retrieval miss produces an "I don't have information about that" response that the user recognizes as incomplete. A disambiguation error produces a fluent, confident, wrong answer.
The Fix: Make the Disambiguation Decision Visible
The disambiguation machinery is already doing the work of picking a candidate. The only change is surfacing that decision before the answer is rendered.
def rag_query(user_query: str, graph, llm) -> str:
mentions = extract_entity_mentions(user_query)
resolved = []
for mention in mentions:
candidates = graph.get_candidates(mention)
if len(candidates) == 1:
resolved.append(candidates[0])
continue
result = disambiguate(
mention=mention,
candidates=candidates,
context_terms=extract_context_terms(user_query, exclude=mentions),
query_time=datetime.utcnow(),
)
resolved.append(result)
# Surface the decision before answering
if result.confidence < 0.85:
print(
f"[disambiguation] '{mention}' โ {result.display_name}"
f" (confidence: {result.confidence:.0%}, "
f"top alternative: {get_second_best(candidates, result.node_id)})"
)
subgraph = graph.traverse(start_nodes=[r.node_id for r in resolved])
return llm.generate(context=subgraph, query=user_query)
Showing [disambiguation] 'Hyundai' โ Hyundai Motor Company (confidence: 71%, top alternative: Hyundai E&C) before the answer converts a silent failure into something the user can catch and correct. The threshold of 0.85 matters. Above that, the disambiguation is confident enough that surfacing it would be noise. Below it, the user should know the system made a choice.
Why This Matters More Than Retrieval Precision
Most Graph RAG evaluation focuses on retrieval recall - whether the right documents or subgraph edges are found. Disambiguation happens before retrieval. An error there means retrieval runs correctly on the wrong entity's neighborhood. The node count in a well-maintained corporate knowledge graph is in the hundreds of thousands. Unqualified company name mentions resolve to the right entity most of the time - until they do not, and then the failure is invisible. Treating disambiguation as a first-class step, with explicit confidence scoring and user-visible decisions, is what keeps it from becoming a silent quality drain.
Building 2asy.ai, a knowledge graph for Korean corporate intelligence. More at hannune.ai. Cover: Y (@yi2026) on Unsplash
Comments
No comments yet. Start the discussion.