{"slug": "query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen", "title": "Query-Time Entity Disambiguation in Graph RAG: When One Name Means Seventeen Nodes", "summary": "A developer at 2asy.ai built a query-time entity disambiguation system for Graph RAG that resolves ambiguous entity names like 'Hyundai'—which matches seventeen separate nodes in the company's Korean corporate knowledge graph—by combining a frequency prior, context coherence, and temporal activity signals. The system uses a weighted scoring model (35% frequency, 45% context, 20% temporal) to select the correct node, preventing the pipeline from generating coherent-sounding answers about the wrong company.", "body_md": "The hardest retrieval problem in Graph RAG is not missing data. It is the query that arrives with an ambiguous entity name.\n\nIn [2asy.ai](https://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.\n\nIn a standard Graph RAG pipeline, the flow is:\n\nStep 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.\n\n``` python\nfrom dataclasses import dataclass\nfrom datetime import datetime\n\n@dataclass\nclass DisambiguationResult:\n    node_id: str\n    display_name: str\n    confidence: float\n    signal_breakdown: dict[str, float]\n\ndef disambiguate(\n    mention: str,\n    candidates: list[dict],\n    context_terms: list[str],\n    query_time: datetime,\n) -> DisambiguationResult:\n    scores = {}\n    for candidate in candidates:\n        freq = frequency_prior_score(mention, candidate)\n        ctx = context_coherence_score(candidate, context_terms)\n        temporal = temporal_activity_score(candidate, query_time)\n        scores[candidate[\"node_id\"]] = {\n            \"frequency\": freq,\n            \"context\": ctx,\n            \"temporal\": temporal,\n            \"combined\": freq * 0.35 + ctx * 0.45 + temporal * 0.20,\n        }\n\n    best = max(scores, key=lambda nid: scores[nid][\"combined\"])\n    return DisambiguationResult(\n        node_id=best,\n        display_name=next(c[\"name\"] for c in candidates if c[\"node_id\"] == best),\n        confidence=scores[best][\"combined\"],\n        signal_breakdown=scores[best],\n    )\n```\n\nWithout any query context, the entity that appears most often in the source corpus where the mention is unqualified is the right default.\n\nIn 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.\n\n``` php\ndef frequency_prior_score(mention: str, candidate: dict) -> float:\n    \"\"\"\n    Fraction of unqualified mentions of `mention` that refer to this candidate\n    in the training corpus. Stored as a per-mention-string dict on the node.\n    \"\"\"\n    priors = candidate.get(\"mention_priors\", {})\n    return priors.get(mention.lower(), candidate.get(\"default_frequency_prior\", 0.0))\n```\n\nThe 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.\n\nThe 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.\n\nThis is a co-occurrence statistic between the query terms and each candidate's entity description:\n\n``` python\nimport math\nfrom collections import Counter\n\ndef context_coherence_score(\n    candidate: dict,\n    context_terms: list[str],\n    smoothing: float = 1.0,\n) -> float:\n    \"\"\"\n    Pointwise mutual information between context_terms and candidate description tokens.\n    Higher score = more context terms co-occur with this entity in training data.\n    \"\"\"\n    if not context_terms:\n        return 0.5  # neutral when no context available\n\n    description_tokens = set(candidate.get(\"description_tokens\", []))\n    cooccurrence_counts = candidate.get(\"cooccurrence_counts\", {})\n    total_docs = candidate.get(\"total_docs_in_corpus\", 1)\n\n    pmi_sum = 0.0\n    for term in context_terms:\n        p_term = cooccurrence_counts.get(term, 0) / total_docs\n        p_entity = candidate.get(\"doc_count\", 0) / total_docs\n        p_joint = cooccurrence_counts.get(f\"joint:{term}\", 0) / total_docs\n\n        if p_joint > 0 and p_term > 0 and p_entity > 0:\n            pmi = math.log2(p_joint / (p_term * p_entity) + smoothing)\n            pmi_sum += max(0.0, pmi)\n\n    return min(1.0, pmi_sum / (len(context_terms) + smoothing))\n```\n\nIn 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.\n\nA historical subsidiary with a `valid_to`\n\ndate 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.\n\n``` php\ndef temporal_activity_score(candidate: dict, query_time: datetime) -> float:\n    \"\"\"\n    Returns 1.0 if the entity is active at query_time, 0.1 if inactive.\n    Entities without a valid_to are assumed ongoing.\n    \"\"\"\n    valid_from = candidate.get(\"valid_from\")\n    valid_to = candidate.get(\"valid_to\")\n\n    if valid_to is not None:\n        active = valid_from <= query_time <= valid_to\n    else:\n        active = valid_from <= query_time if valid_from else True\n\n    return 1.0 if active else 0.1\n```\n\nThe 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.\n\nWhen 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.\n\nThe 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.\n\nThis 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.\n\nThe disambiguation machinery is already doing the work of picking a candidate. The only change is surfacing that decision before the answer is rendered.\n\n``` php\ndef rag_query(user_query: str, graph, llm) -> str:\n    mentions = extract_entity_mentions(user_query)\n    resolved = []\n\n    for mention in mentions:\n        candidates = graph.get_candidates(mention)\n        if len(candidates) == 1:\n            resolved.append(candidates[0])\n            continue\n\n        result = disambiguate(\n            mention=mention,\n            candidates=candidates,\n            context_terms=extract_context_terms(user_query, exclude=mentions),\n            query_time=datetime.utcnow(),\n        )\n        resolved.append(result)\n\n        # Surface the decision before answering\n        if result.confidence < 0.85:\n            print(f\"[disambiguation] '{mention}' → {result.display_name} \"\n                  f\"(confidence: {result.confidence:.0%}, \"\n                  f\"top alternative: {get_second_best(candidates, result.node_id)})\")\n\n    subgraph = graph.traverse(start_nodes=[r.node_id for r in resolved])\n    return llm.generate(context=subgraph, query=user_query)\n```\n\nShowing `[disambiguation] 'Hyundai' → Hyundai Motor Company (confidence: 71%, top alternative: Hyundai E&C)`\n\nbefore the answer converts a silent failure into something the user can catch and correct.\n\nThe 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.\n\nMost 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.\n\nThe 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.\n\n*Building 2asy.ai, a knowledge graph for Korean corporate intelligence. More at hannune.ai.*\n\n*Cover: Y (@yi2026) on Unsplash*", "url": "https://wpnews.pro/news/query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen", "canonical_source": "https://dev.to/hannune/query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen-nodes-4kfg", "published_at": "2026-07-26 02:41:58+00:00", "updated_at": "2026-07-26 03:28:38.763589+00:00", "lang": "en", "topics": ["artificial-intelligence", "machine-learning", "natural-language-processing", "developer-tools"], "entities": ["2asy.ai", "Hyundai Motor Company", "Hyundai Engineering & Construction", "Hyundai Steel", "Hyundai Merchant Marine"], "alternates": {"html": "https://wpnews.pro/news/query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen", "markdown": "https://wpnews.pro/news/query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen.md", "text": "https://wpnews.pro/news/query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen.txt", "jsonld": "https://wpnews.pro/news/query-time-entity-disambiguation-in-graph-rag-when-one-name-means-seventeen.jsonld"}}