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
38 changes: 37 additions & 1 deletion docs/architecture/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,13 +818,49 @@ Results are normalized into `SearchHit`:
- source/index generation
- modality
- start/end time
- score and score semantics
- `rank`, `score`, and `raw_distance` (see ranking values below)
- displayable text/metadata
- optional preview reference

The application owns limits, score normalization, multimodal fusion and deterministic
ordering. UI/API/MCP may request stricter limits but cannot loosen policy.

### Ranking values

Every search response is self-describing about its numbers so callers never have to
guess which value they received or which direction ranks better. A `RetrievalScoring`
descriptor is attached to `SearchResult`, `FusedSearchResult`, and `QueryAnswer`, so a
fused search keeps the same meaning when it becomes a video-query answer, and the CLI,
HTTP, MCP, and stored job results all expose it.

- `raw_distance` is the raw vector-store distance under `scoring.distance_metric`
(`l2`, `cosine`, or `ip`); smaller is closer. The metric is null when it was not
recorded, such as results saved before the descriptor existed, and a fused result
reports it only when every searched channel recorded the same metric.
- `score` on a hit is derived as `score = -raw_distance` (`score_transform`), so larger
ranks better.
- A `SearchHit.rank` is 1-based within one modality channel; ranks from different
channels are not comparable.
- A `FusedMoment.rank`/`score` is the combined position and reciprocal-rank-fusion score
across channels; `fusion.searched_modalities` lists the channels that ran and
`FusedMoment.modalities` lists the channels that contributed to that moment.
- All of these are `ordering_only` (`scoring.score_calibration` and
`fusion.score_calibration`): valid for sorting within a single response, never a
probability or confidence. Calibrated scores are deferred to the end-to-end ranking
evaluation in issue #76.

Evidence items (`EvidenceBoardCandidate`, `EvidenceBoardTile`, and `EvidenceDeliveryItem`)
copy a `score` from their source, and `score_semantics` states which score it is:

- `fused_moment` for search evidence: the moment's combined reciprocal-rank-fusion
score, described by the carried `fusion` provenance.
- `channel_hit` for video-query moment evidence: one channel's hit score, described by
the carried `scoring` descriptor. Compare it only with hits from the same channel in
the same response.

`score_semantics` is null when the evidence has no score, such as actor evidence, or
when its source was not recorded.

Actor cluster and detection queries use the same pagination conventions.

## 16. Natural-language query layer
Expand Down
220 changes: 210 additions & 10 deletions src/vidxp/application_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,65 @@ class FusionProfile(StrEnum):
reciprocal_rank = "rrf_v1"


class RankDirection(StrEnum):
"""Whether smaller or larger values of a ranking quantity rank better."""

lower_is_better = "lower_is_better"
higher_is_better = "higher_is_better"


class ScoreCalibration(StrEnum):
"""How much meaning a numeric score carries beyond ordering."""

ordering_only = "ordering_only"


class RetrievalScoring(ApplicationModel):
"""Self-describing meaning of the ranking values in a search response.

Callers see several numbers on a hit (a raw vector-store distance and a
derived score) and a rank. This descriptor states, in the payload itself,
which distance metric produced ``raw_distance``, which direction ranks
better, how ``score`` is derived from the distance, and that neither value
is calibrated. Search results and video-query answers carry the same
descriptor, and evidence copied from a channel hit carries it too, so the
meaning never drifts between surfaces. Calibrated, probability-like scores are
deferred to the end-to-end ranking evaluation tracked in issue #76.
"""

distance_metric: Literal["l2", "cosine", "ip"] | None = Field(
default=None,
description=(
"Vector-store distance space that produced each raw_distance. Null "
"when the metric was not recorded, such as results saved before "
"this descriptor existed."
),
)
raw_distance_direction: Literal[RankDirection.lower_is_better] = Field(
default=RankDirection.lower_is_better,
description="raw_distance sorts ascending; a smaller distance is closer.",
)
score_transform: Literal["negated_distance"] = Field(
default="negated_distance",
description="Each hit score is derived as score = -raw_distance.",
)
score_direction: Literal[RankDirection.higher_is_better] = Field(
default=RankDirection.higher_is_better,
description="score sorts descending; a larger score ranks better.",
)
score_calibration: Literal[ScoreCalibration.ordering_only] = Field(
default=ScoreCalibration.ordering_only,
description=(
"raw_distance and score are ordering_only: valid for sorting hits "
"within one response, never a probability or confidence."
),
)
hit_rank_direction: Literal[RankDirection.lower_is_better] = Field(
default=RankDirection.lower_is_better,
description="A hit's per-channel rank starts at 1; rank 1 is the closest.",
)


class EvidenceDeliveryMode(StrEnum):
none = "none"
keyframes = "keyframes"
Expand Down Expand Up @@ -923,7 +982,21 @@ class EvidenceBoardCandidate(ApplicationModel):
representative_timestamp: float = Field(ge=0)
frame_index: int | None = Field(default=None, ge=0)
frame_match: "EvidenceFrameMatch"
score: float | None = None
score: float | None = Field(
default=None,
description=(
"Ordering-only score copied from the evidence source, never a "
"probability; larger ranks better. score_semantics states whether "
"it is a fused moment score or one channel's hit score."
),
)
score_semantics: "EvidenceScoreSemantics | None" = Field(
default=None,
description=(
"Source and meaning of score. Null when the evidence has no score "
"or its source was not recorded."
),
)
display_text: str | None = Field(default=None, max_length=512)
provenance: dict[str, JsonValue] = Field(default_factory=dict)

Expand All @@ -933,6 +1006,12 @@ def _valid_interval(self) -> "EvidenceBoardCandidate":
raise ValueError("Evidence board candidate end precedes its start.")
return self

@model_validator(mode="after")
def _score_semantics_describe_a_score(self) -> "EvidenceBoardCandidate":
if self.score is None and self.score_semantics is not None:
raise ValueError("Evidence score semantics require a score.")
return self


class SearchCommand(ApplicationModel):
query: SearchQuery = Field(description="Text to match against indexed moments.")
Expand Down Expand Up @@ -978,14 +1057,32 @@ def _unique_modalities(


class SearchHit(ApplicationModel):
rank: int = Field(gt=0)
rank: int = Field(
gt=0,
description=(
"1-based rank of this hit within its own modality channel "
"(rank 1 is closest). Ranks from different channels are not "
"comparable; the combined position is FusedMoment.rank."
),
)
media_id: MediaId
video_id: VideoId
generation_id: IndexGenerationId
start: float = Field(ge=0)
end: float = Field(gt=0)
score: float
raw_distance: float
score: float = Field(
description=(
"Ordering-only channel score, score = -raw_distance, so larger "
"ranks better. Not a probability or confidence; see the response "
"scoring descriptor."
),
)
raw_distance: float = Field(
description=(
"Raw vector-store distance under the configured metric; smaller is "
"closer. See scoring.distance_metric for the metric."
),
)
modality: str = Field(min_length=1)
source_id: str = Field(min_length=1)
metadata: dict[str, JsonValue] = Field(default_factory=dict)
Expand Down Expand Up @@ -1033,6 +1130,10 @@ class SearchResult(ApplicationModel):
query_id: str = Field(min_length=1)
query: str = Field(min_length=1)
modality: str = Field(min_length=1)
scoring: RetrievalScoring = Field(
default_factory=RetrievalScoring,
description="Meaning of the rank, score, and raw_distance on each hit.",
)
hits: tuple[SearchHit, ...] = ()

def to_dict(self) -> dict[str, Any]:
Expand All @@ -1046,18 +1147,81 @@ class FusionProvenance(ApplicationModel):
profile: Literal[FusionProfile.reciprocal_rank] = FusionProfile.reciprocal_rank
rank_constant: int = Field(default=60, gt=0)
overlap_rule: Literal["connected_intervals", "shared_overlap"] = "connected_intervals"
requested_modalities: tuple[Identifier, ...] = ()
searched_modalities: tuple[Identifier, ...] = ()
requested_modalities: tuple[Identifier, ...] = Field(
default=(),
description="Channels the caller asked to search.",
)
searched_modalities: tuple[Identifier, ...] = Field(
default=(),
description=(
"Channels actually run for this response. A moment's contributing "
"channels are the subset in FusedMoment.modalities."
),
)
score_direction: Literal[RankDirection.higher_is_better] = Field(
default=RankDirection.higher_is_better,
description="FusedMoment.score sorts descending; a larger score ranks better.",
)
score_calibration: Literal[ScoreCalibration.ordering_only] = Field(
default=ScoreCalibration.ordering_only,
description=(
"The combined FusedMoment.score is ordering_only: valid for sorting "
"moments within one response, never a probability or confidence."
),
)


class FusedMomentScoreSemantics(ApplicationModel):
"""An evidence score copied from a fused moment's combined score."""

source: Literal["fused_moment"] = "fused_moment"
fusion: FusionProvenance = Field(
description=(
"Fusion that produced the score: reciprocal-rank fusion over the "
"searched channels, ordering-only and higher-is-better."
),
)


class ChannelHitScoreSemantics(ApplicationModel):
"""An evidence score copied from one channel's search hit."""

source: Literal["channel_hit"] = "channel_hit"
scoring: RetrievalScoring = Field(
description=(
"Retrieval descriptor for the hit score: score = -raw_distance, with "
"raw_distance measured under scoring.distance_metric. Ordering-only; "
"compare it only with other hits from the same channel in the same "
"response."
),
)


EvidenceScoreSemantics = Annotated[
FusedMomentScoreSemantics | ChannelHitScoreSemantics,
Field(discriminator="source"),
]


class FusedMoment(ApplicationModel):
moment_id: Sha256 | None = None
rank: int = Field(gt=0)
score: float = Field(gt=0)
rank: int = Field(
gt=0,
description="1-based combined rank across all searched channels; rank 1 is best.",
)
score: float = Field(
gt=0,
description=(
"Combined reciprocal-rank-fusion score; larger ranks better. "
"Ordering-only, not a probability; see fusion.score_calibration."
),
)
media_id: MediaId
start: float = Field(ge=0)
end: float = Field(gt=0)
modalities: tuple[Identifier, ...]
modalities: tuple[Identifier, ...] = Field(
description="Channels that contributed a hit to this moment.",
)
hits: tuple[SearchHit, ...] = Field(min_length=1)

@model_validator(mode="after")
Expand All @@ -1076,6 +1240,14 @@ class FusedSearchResult(ApplicationModel):
query_id: str = Field(min_length=1)
query: SearchQuery
modalities: tuple[Identifier, ...]
scoring: RetrievalScoring = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds the retrieval descriptor to regular search results, but video-query answers lose it. GroundedQueryService.answer() copies the moments and evidence into QueryAnswer, which has no scoring field, and the individual hits don’t carry the descriptor either.

Please preserve this information in query answers too. A test using cosine retrieval should verify that the metric and score conversion survive when the fused result becomes a query answer.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. QueryAnswer now carries the same scoring descriptor as FusedSearchResult, copied from the fused search in GroundedQueryService.answer(), so it also survives in stored query job results. I kept it at the response level rather than copying it onto every hit, since each hit in the answer is described by that single descriptor, the same way search results already work. test_query_answer_keeps_cosine_retrieval_scoring runs a cosine index through search_embeddings, fusion, and the answer, then checks the metric and the score conversion, including after a JSON round trip.

default_factory=RetrievalScoring,
description=(
"Meaning of the per-hit rank, score, and raw_distance carried by "
"each moment's hits. The combined moment score is described by "
"fusion.score_calibration and fusion.score_direction."
),
)
moments: tuple[FusedMoment, ...] = ()
fusion: FusionProvenance
evidence_delivery: "EvidenceDeliveryResult | None" = None
Expand Down Expand Up @@ -1260,14 +1432,34 @@ class EvidenceDeliveryItem(ApplicationModel):
media_id: MediaId
generation_id: IndexGenerationId
modalities: tuple[Identifier, ...] = Field(min_length=1)
score: float | None = None
score: float | None = Field(
default=None,
description=(
"Ordering-only score copied from the evidence source, never a "
"probability; larger ranks better. score_semantics states whether "
"it is a fused moment score or one channel's hit score."
),
)
score_semantics: EvidenceScoreSemantics | None = Field(
default=None,
description=(
"Source and meaning of score. Null when the evidence has no score "
"or its source was not recorded."
),
)
provenance: dict[str, JsonValue] = Field(default_factory=dict)
state: EvidenceDeliveryState
range: EvidenceRangeResolution | None = None
keyframe: EvidenceKeyframe | None = None
clip: EvidenceArtifact | None = None
errors: tuple[ErrorDetail, ...] = ()

@model_validator(mode="after")
def _score_semantics_describe_a_score(self) -> "EvidenceDeliveryItem":
if self.score is None and self.score_semantics is not None:
raise ValueError("Evidence score semantics require a score.")
return self


class EvidenceDeliveryResult(ApplicationModel):
policy: EvidenceDeliveryPolicy
Expand Down Expand Up @@ -1360,6 +1552,14 @@ class QueryAnswer(ApplicationModel):
model: QueryModelIdentity | None = None
claims: tuple[GroundedClaim, ...] = ()
evidence: tuple[Evidence, ...] = Field(default=(), max_length=200)
scoring: RetrievalScoring = Field(
default_factory=RetrievalScoring,
description=(
"Meaning of the rank, score, and raw_distance on the hits in "
"evidence and moments, carried over from the fused search. The "
"combined moment score is described by fusion."
),
)
moments: tuple[FusedMoment, ...] = ()
fusion: FusionProvenance
evidence_delivery: EvidenceDeliveryResult | None = None
Expand Down
2 changes: 2 additions & 0 deletions src/vidxp/capabilities/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path
from typing import Any, Mapping

from vidxp.application_models import RetrievalScoring
from vidxp.capabilities.schemas import SearchHit, SearchResult
from vidxp.core.contracts import (
IndexConfig,
Expand Down Expand Up @@ -143,6 +144,7 @@ def search_embeddings(
query_id=query_id or stable_query_id(query, modality, config),
query=query,
modality=modality,
scoring=RetrievalScoring(distance_metric=config.vector_distance),
hits=_to_hits(modality, rows, required_metadata),
)

Expand Down
11 changes: 9 additions & 2 deletions src/vidxp/cli_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,18 @@ def emit_search(
if not result.moments:
typer.echo("No matching moments found.")
return
table = Table(title="Fused search results")
table = Table(
title="Fused search results",
caption=(
"Rank 1 = best match. Score = reciprocal-rank fusion "
"(higher ranks better); ordering-only, not a probability. "
f"Distance metric: {result.scoring.distance_metric or 'not recorded'}."
),
)
table.add_column("Rank", justify="right")
table.add_column("Start", justify="right")
table.add_column("End", justify="right")
table.add_column("Score", justify="right")
table.add_column("Score (RRF)", justify="right")
table.add_column("Video")
table.add_column("Modalities")
for moment in result.moments:
Expand Down
Loading