Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion registry/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1503,7 +1503,11 @@ def effective_ui_title(self) -> str:
default=0.7,
ge=0.0,
le=1.0,
description="Minimum semantic-search score (0..1) for an advisory match to be returned.",
description=(
"Minimum cosine similarity (0..1) between the incoming entity's "
"name plus description and an existing one's for an advisory "
"match to be returned."
),
)
dedup_max_suggestions: int = Field(
default=3,
Expand Down
38 changes: 38 additions & 0 deletions registry/repositories/documentdb/search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,40 @@ def _reciprocal_rank_fusion(
SCORE_DISPLAY_FLOOR: float = 0.10


def _attach_similarity_scores(
grouped_results: dict[str, list[dict[str, Any]]],
selected_results: list[tuple[dict, float]],
query_embedding: list[float] | None,
) -> None:
"""Stamp each hit with its raw cosine similarity to the query, in place.

``relevance_score`` is a ranking signal: under RRF it is rank-relative,
and ``_normalize_scores`` maps the best hit to exactly 1.0 whatever its
real similarity. Callers that need an absolute "how alike are these"
number, rather than "what came first", read ``similarity_score``.

Hits are matched to their source document by ``path``. Entries without
one (tools extracted from a parent server) are left untouched, as are
all entries when the query could not be embedded.
"""
if not query_embedding:
return

similarity_by_path: dict[str, float] = {}
for doc, _ in selected_results:
path = doc.get("path")
if path:
similarity_by_path[str(path)] = cosine_similarity(
query_embedding, doc.get("embedding") or []
)

for entries in grouped_results.values():
for entry in entries:
path = entry.get("path")
if path is not None and str(path) in similarity_by_path:
entry["similarity_score"] = similarity_by_path[str(path)]


def _normalize_scores(
scored_results: list[tuple[dict, float]],
max_results: int = 10,
Expand Down Expand Up @@ -2040,6 +2074,8 @@ async def _client_side_search(
else:
grouped_results["custom"].append(_format_custom_result(doc, relevance_score))

_attach_similarity_scores(grouped_results, selected, query_embedding)

logger.info(
"Client-side search returned "
"%d servers, %d tools, %d agents, %d skills, "
Expand Down Expand Up @@ -2722,6 +2758,8 @@ async def search(
# the schema-driven UI renders attributes from the descriptor.
grouped_results["custom"].append(_format_custom_result(doc, relevance_score))

_attach_similarity_scores(grouped_results, selected_results, query_embedding)

# Sort each group by relevance_score (descending) to ensure highest matches
# appear first. This is needed because the DB sorts by text_boost only,
# but relevance_score combines both vector similarity and text boost.
Expand Down
22 changes: 20 additions & 2 deletions registry/services/duplicate_check_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@
_QUERY_TEXT_CHAR_CAP: int = 500


def _similarity_of(candidate: dict) -> float:
"""Absolute cosine similarity of a search hit to the query.

Returns 0.0 when the backend could not supply one, which keeps the
candidate below any configured threshold: an entity we cannot compare
is not an entity we should call a possible duplicate.
"""
similarity = candidate.get("similarity_score")
if similarity is None:
return 0.0
return float(similarity)


class DuplicateCheckService:
"""Cross-entity duplicate detection for entity registration.

Expand Down Expand Up @@ -306,6 +319,11 @@ async def _fetch_similarity_advisory(
the configured similarity threshold and the caller's
visibility scope, then capped to ``dedup_max_suggestions``
across all entity types (the cap is global, not per-type).

Ranking and filtering read ``similarity_score``, the hit's cosine
similarity to the query, not ``relevance_score``: the latter is a
position in a result list, so the best hit carries the top value
even when nothing in the registry is remotely similar.
"""
threshold = self._settings.dedup_score_threshold
max_suggestions = self._settings.dedup_max_suggestions
Expand Down Expand Up @@ -334,11 +352,11 @@ async def _fetch_similarity_advisory(
return [], False

candidates = self._flatten_search_results(raw_results)
candidates.sort(key=lambda c: float(c[1].get("relevance_score") or 0.0), reverse=True)
candidates.sort(key=lambda c: _similarity_of(c[1]), reverse=True)

advisory: list[ExistingEntity] = []
for entity_type, candidate in candidates:
score = float(candidate.get("relevance_score") or 0.0)
score = _similarity_of(candidate)
if score < threshold:
continue
candidate_path = str(candidate.get("path") or "")
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/api/test_check_duplicates_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def test_advisory_matches_only_when_no_url_collision(self) -> None:
{
"path": "/sim",
"server_name": "Similar",
"relevance_score": 0.85,
"similarity_score": 0.85,
}
]
}
Expand Down Expand Up @@ -236,7 +236,7 @@ def test_both_collision_and_advisory_can_populate(self) -> None:
{
"path": "/sim",
"server_name": "Similar",
"relevance_score": 0.85,
"similarity_score": 0.85,
}
]
},
Expand Down Expand Up @@ -397,7 +397,7 @@ def test_skill_advisory_only(self) -> None:
{
"path": "/skills/sim",
"skill_name": "Similar Skill",
"relevance_score": 0.88,
"similarity_score": 0.88,
}
]
}
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/repositories/test_search_similarity_scores.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Unit tests for the absolute similarity carried alongside relevance_score.

``relevance_score`` answers "where did this hit rank"; under RRF fusion it is
min-max rescaled so the best hit is 1.0 regardless of how alike the two things
actually are. ``similarity_score`` answers "how alike are they", which is what
the duplicate-check advisory needs (issue #1696).

Covers:
- The top RRF hit normalizes to 1.0 while its similarity stays low
- Hits are matched to their source document by path
- Entries with no path (tools lifted out of a parent server) are left alone
- A query that could not be embedded stamps nothing
"""

from registry.repositories.documentdb.search_repository import (
_attach_similarity_scores,
_normalize_scores,
_reciprocal_rank_fusion,
)

QUERY = [1.0, 0.0, 0.0]
NEAR = [0.96, 0.28, 0.0]
FAR = [0.26, 0.97, 0.0]


def _doc(path: str, embedding: list[float]) -> dict:
return {"_id": path, "path": path, "name": path.rsplit("/", 1)[-1], "embedding": embedding}


def test_display_score_of_one_can_accompany_a_low_similarity() -> None:
"""The regression this file exists for: rank 1.0 does not mean similar."""
far = _doc("/servers/payroll", FAR)
scored = _reciprocal_rank_fusion([far, _doc("/servers/weather", FAR)], [])
normalized = _normalize_scores(scored, max_results=30)

assert normalized[0][1] == 1.0

grouped = {"servers": [{"path": "/servers/payroll", "relevance_score": 1.0}]}
_attach_similarity_scores(grouped, normalized, QUERY)

assert grouped["servers"][0]["relevance_score"] == 1.0
assert grouped["servers"][0]["similarity_score"] < 0.4


def test_similarity_reflects_the_embedding_not_the_ranking() -> None:
selected = [(_doc("/servers/near", NEAR), 1.0), (_doc("/servers/far", FAR), 0.0)]
grouped = {
"servers": [{"path": "/servers/near"}, {"path": "/servers/far"}],
}
_attach_similarity_scores(grouped, selected, QUERY)

near, far = grouped["servers"]
assert near["similarity_score"] > 0.9
assert far["similarity_score"] < 0.4


def test_entries_without_a_path_are_left_untouched() -> None:
selected = [(_doc("/servers/near", NEAR), 1.0)]
grouped = {
"servers": [{"path": "/servers/near"}],
"tools": [{"server_path": "/servers/near", "tool_name": "do_thing"}],
}
_attach_similarity_scores(grouped, selected, QUERY)

assert "similarity_score" in grouped["servers"][0]
assert "similarity_score" not in grouped["tools"][0]


def test_unknown_path_is_left_untouched() -> None:
selected = [(_doc("/servers/near", NEAR), 1.0)]
grouped = {"servers": [{"path": "/servers/somewhere-else"}]}
_attach_similarity_scores(grouped, selected, QUERY)

assert "similarity_score" not in grouped["servers"][0]


def test_missing_query_embedding_stamps_nothing() -> None:
selected = [(_doc("/servers/near", NEAR), 1.0)]
grouped = {"servers": [{"path": "/servers/near"}]}
_attach_similarity_scores(grouped, selected, None)

assert "similarity_score" not in grouped["servers"][0]


def test_document_without_an_embedding_scores_zero() -> None:
selected = [({"_id": "x", "path": "/servers/no-vector"}, 1.0)]
grouped = {"servers": [{"path": "/servers/no-vector"}]}
_attach_similarity_scores(grouped, selected, QUERY)

assert grouped["servers"][0]["similarity_score"] == 0.0
Loading
Loading