Duplicate place names are not an edge case. Any gazetteer covering more than one region will return several records for a substantial share of names, and a system that picks the most prominent every time will consistently answer about a city when the user meant a village. This guide implements the choice, and the refusal, as the decision stage of geocoding and place-name resolution.
When to Use This Approach
Run disambiguation whenever grounding returns more than one candidate. Skip it when the name is unique or when the user has already selected a record in this conversation.
| Signal available | Strength | Applies when |
|---|---|---|
| A containing administrative name in the question | Strongest | “Newport in Shropshire” |
| Consistency with the conversation’s area | Strong | Follow-up questions |
| A feature type implied by the question | Moderate | “the river Avon” |
| Gazetteer prominence | Weakest | Nothing else is available |
| Nothing | — | Refuse and ask |
The ordering is what makes this work. Prominence is a property of the world rather than of the question, so it can break a tie between otherwise equal candidates and must never outweigh something the user actually said.
Implementation
The scorer combines the signals, applies a margin test, and returns either a choice or an unresolved result carrying the alternatives.
import logging
from dataclasses import dataclass
from typing import Optional, Sequence
log = logging.getLogger("toponym_disambiguation")
MARGIN = 0.12 # below this gap, the candidates are indistinguishable
@dataclass(frozen=True)
class Choice:
record: Optional[object]
confidence: float
alternatives: tuple
rationale: str
def _overlaps(a, b) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def disambiguate(
candidates: Sequence,
context_names: frozenset[str],
session_bbox: Optional[tuple] = None,
implied_type: Optional[str] = None,
) -> Choice:
"""Pick a candidate or refuse. Never returns a choice it cannot justify."""
if not candidates:
return Choice(None, 0.0, (), "no candidates supplied")
if len(candidates) == 1:
return Choice(candidates[0], 0.8, (), "only one candidate")
scored = []
for c in candidates:
score, reasons = 0.15 + 0.20 * getattr(c, "importance", 0.0), []
parents = {p.lower() for p in getattr(c, "parents", ())}
if context_names & parents:
score += 0.45
reasons.append("a containing region is named in the question")
if session_bbox is not None and _overlaps(c.bbox, session_bbox):
score += 0.25
reasons.append("consistent with the area under discussion")
if implied_type and getattr(c, "feature_type", None) == implied_type:
score += 0.15
reasons.append(f"matches the implied type {implied_type!r}")
scored.append((round(min(1.0, score), 3), c, "; ".join(reasons)))
# Deterministic ordering: score, then identifier, so ties never depend on input order.
scored.sort(key=lambda t: (-t[0], getattr(t[1], "place_id", "")))
best, runner_up = scored[0], scored[1]
if best[0] - runner_up[0] < MARGIN:
log.info("toponym ambiguous: %.3f against %.3f", best[0], runner_up[0])
return Choice(None, best[0], tuple(c for _, c, _ in scored[:4]),
"candidates are within scoring noise of each other")
return Choice(best[1], best[0], tuple(c for _, c, _ in scored[1:4]),
best[2] or "selected on prominence alone")
The margin test is the whole design. Without it the function always returns a candidate, and its error rate equals the frequency with which the runner-up was correct — which for prominence-only decisions on small places is close to half. With it, ambiguous cases become a question the agent can ask, and a four-word clarification from the user resolves what no amount of scoring could.
Note also that “selected on prominence alone” appears in the rationale when no other signal fired. That string is what lets a reviewer see, in a log, that a decision was made on the weakest available evidence even though it cleared the margin.
Validation & Testing
def test_administrative_context_beats_prominence():
big = Rec("big", importance=0.9, parents=("Gwent",), bbox=B1)
small = Rec("small", importance=0.1, parents=("Shropshire",), bbox=B2)
choice = disambiguate([big, small], context_names=frozenset({"shropshire"}))
assert choice.record is small
def test_near_tie_refuses():
a = Rec("a", importance=0.50, parents=(), bbox=B1)
b = Rec("b", importance=0.48, parents=(), bbox=B2)
choice = disambiguate([a, b], context_names=frozenset())
assert choice.record is None and len(choice.alternatives) == 2
def test_session_context_resolves_a_follow_up():
a = Rec("a", importance=0.5, parents=(), bbox=(-3.3, 55.8, -3.0, 56.1))
b = Rec("b", importance=0.5, parents=(), bbox=(1.0, 51.0, 1.4, 51.4))
choice = disambiguate([a, b], frozenset(), session_bbox=(-3.4, 55.7, -2.9, 56.2))
assert choice.record is a
def test_ordering_is_deterministic():
a, b = Rec("a", 0.5, (), B1), Rec("b", 0.5, (), B2)
assert disambiguate([a, b], frozenset()).alternatives == \
disambiguate([b, a], frozenset()).alternatives
The first test encodes the priority ordering as an executable claim, which is the only way it survives. Every future change that “improves” the scoring by weighting prominence more heavily will fail it, and that failure is the conversation worth having.
The fourth test guards something subtler than it appears. Sorting on score alone leaves ties broken by input order, and input order comes from the gazetteer, which means the same question can resolve differently on two calls that returned the same records in a different sequence. Adding the identifier to the sort key costs nothing and removes a class of irreproducible behaviour.
Gotchas & Edge Cases
Session context inherited too eagerly. A conversation that has moved on to a different region will keep resolving names into the old one. Expire the session extent after a few turns without a spatial reference, or reset it when the user names a new region explicitly.
Administrative names matched loosely. Substring matching on region names produces false positives — a question mentioning “Newport” matches a candidate whose parent is “Newport”, which is circular. Match on whole normalised names against the candidate’s parent list, not on substrings of the question.
Alternatives truncated before the right one. Returning the top four alternatives is convenient and can exclude the correct record when a name is very common. Where the candidate count is large, say so in the rationale so the agent can offer to narrow rather than presenting four of forty as though they were all.
A margin tuned on one language or region. Score distributions differ between a gazetteer that covers one country densely and one that covers the world thinly. Check the margin against the ambiguity fixture rather than assuming a constant transfers.
Refusals that do not reach the user. An unresolved choice that the agent silently converts into “I could not find that place” wastes the alternatives it was given. The refusal path should name the candidates: asking “did you mean the one in Shropshire or the one in Gwent” is the entire point of returning them.
Frequently Asked Questions
Should the model be allowed to pick from the alternatives?
Yes, and it is often the best consumer of them, because it can see conversational cues the scorer cannot — a user's earlier mention of a county, a document they referenced. The constraint is that its choice must be intersected with the identifiers supplied: a model that returns a place not in the list has invented one, and that is exactly the failure grounding exists to prevent.
How should the session extent be maintained?
As the union of the extents of places resolved so far in the conversation, capped at a reasonable size and decayed over turns. An uncapped union grows to cover a country after a handful of questions about different places, at which point it stops discriminating; decay keeps it tracking what the conversation is currently about rather than everything it has ever mentioned.
What about names that are ambiguous within one region?
They are the hardest case and the one where prominence helps least, because two villages in the same county have similar prominence and share every parent. Feature type sometimes separates them; otherwise this is a genuine ambiguity and the refusal is the correct output. A user asking about one of two identically named villages in the same district expects to be asked which.
Does this belong before or after retrieval?
Before, because retrieval needs the region. That creates an ordering problem for names that only appear in retrieved documents, which cannot be disambiguated before the retrieval that surfaced them — handle those in a second pass with the retrieved context as an additional signal, and give them lower confidence, since the mention was incidental rather than chosen.
How should a user’s correction be recorded?
As a pinned resolution for that name in that conversation, overriding the scorer for the rest of the session. It should also be logged as an evaluation case: a user correcting a disambiguation is telling you precisely which signal was missing, and a handful of those cases is a better guide to weighting than any amount of reasoning about the scores.
Is prominence worth including at all?
Yes, as the tiebreaker it is. Without it, two candidates with no distinguishing context always fall inside the margin and every such question becomes a clarification, which is exhausting for a user asking about a well-known city that happens to share its name with a hamlet. A small prominence weight resolves the genuinely lopsided cases and leaves the close ones ambiguous, which is the behaviour you want from a tiebreaker.
Related
- Up to the parent topic: Geocoding and Place-Name Resolution
- Grounding Place Names Against a Gazetteer
- Related technique: Tuning Fusion Weights for Toponym-Heavy Queries
- Concept: Spatial Reasoning and Relation Inference