Skip to content

Similarity Scoring

The QueryVectors
S3 Vectors QueryVectors API — finds the most similar vectors to a query vector using Top-K nearest neighbor search.
API returns results ranked by cosine similarity alone. That works for many use cases, but a news briefing app needs to balance relevance with freshness. This demo applies a two-stage scoring strategy: over-fetch from S3 Vectors, then rerank application-side using a weighted formula.

Every candidate gets a final score composed of two signals:

final_score = (0.8 × similarity) + (0.2 × recency)
  • similarity — the cosine similarity
    Cosine similarity — a distance metric that measures the angle between two vectors. Values range from 0 (opposite) to 1 (identical direction). Preferred for text embeddings.
    score returned by S3 Vectors (0 to 1)
  • recency — an exponential decay score based on days since publication (0 to 1)

The 80/20 weighting keeps semantic relevance as the primary signal while giving a meaningful boost to newer content.

The similarity component comes directly from the QueryVectors
S3 Vectors QueryVectors API — finds the most similar vectors to a query vector using Top-K nearest neighbor search.
response. S3 Vectors computes cosine similarity between the query embedding and each stored vector, returning values between 0 (unrelated) and 1 (identical direction).

rag/query.py
def _vector_similarity_score(vector: dict) -> float:
if isinstance(vector.get("similarity"), (int, float)):
return float(vector["similarity"])
if isinstance(vector.get("score"), (int, float)):
return float(vector["score"])
if isinstance(vector.get("distance"), (int, float)):
return 1.0 / (1.0 + float(vector["distance"]))
return 0.0

The function handles multiple response formats defensively — similarity, score, and distance — but the S3 Vectors API returns similarity directly when using cosine metric.

Recency uses exponential decay with a 30-day half-life. An article published today scores 1.0; an older article decays smoothly toward zero.

rag/query.py
import math
from datetime import datetime, timezone
RECENCY_HALF_LIFE_DAYS = 30.0
def _recency_score(published_dt: datetime | None) -> float:
if published_dt is None:
return 0.25
age_days = max(
(datetime.now(timezone.utc) - published_dt).total_seconds() / 86_400.0,
0.0,
)
return math.exp(-age_days / RECENCY_HALF_LIFE_DAYS)

Articles with no publication date get a conservative 0.25 — enough to participate in results but not enough to compete with recent, dated content.

AgeRecency scoreEffect on final score
Today (0 days)1.00Full recency bonus (+0.20)
7 days0.79Mild decay (+0.16)
30 days0.37Moderate decay (+0.07)
90 days0.05Near-zero bonus (+0.01)

The final ranking step is straightforward — multiply each component by its weight and sum:

rag/query.py
similarity = _vector_similarity_score(vector)
freshness = _recency_score(published_dt)
final = (0.8 * similarity) + (0.2 * freshness)

Consider two candidates for the query “What’s new with S3 Vectors?”:

CandidateSimilarityAgeRecencyFinal score
Article A (today)0.820 days1.000.8×0.82 + 0.2×1.00 = 0.86
Article B (old)0.9160 days0.140.8×0.91 + 0.2×0.14 = 0.76

Article A wins despite lower similarity because it was published today. Without recency, Article B would rank first — fine for a research tool, but wrong for a news briefing.

The demo doesn’t apply scoring inside S3 Vectors. Instead it uses a two-stage strategy:

  1. Over-fetch — request 3× the desired results from QueryVectors
  2. Filter — remove duplicates and articles outside the time window
  3. Score — apply the weighted formula to remaining candidates
  4. Select — take the top-K from the rescored list
rag/query.py
RETRIEVAL_MULTIPLIER = 3
desired_top_k = _adaptive_top_k(question, top_k)
retrieval_top_k = min(desired_top_k * RETRIEVAL_MULTIPLIER, 20)
response = s3vectors.query_vectors(
vectorBucketName=config.vector_bucket,
indexName=config.vector_index,
queryVector={"float32": query_embedding},
topK=retrieval_top_k,
returnMetadata=True,
)

After retrieval, candidates are sorted by final score and trimmed to the desired count:

rag/query.py
candidates.sort(key=lambda c: c.final_score, reverse=True)
selected = candidates[:desired_top_k]

Pure vector similarity answers the question “which documents are about this topic?” but not “which documents are about this topic and still current?” For a news briefing app, a 3-month-old announcement about the same feature shouldn’t outrank today’s GA launch post.

Reranking at the application layer gives you:

  • Freshness signals that the vector store doesn’t have
  • Deduplication across sources that report the same news
  • Time-window filtering for queries like “last week’s announcements”
  • Flexibility to tune weights without re-indexing vectors

The vector store handles the hard part (approximate nearest neighbor search at scale), and the application handles the domain-specific ranking logic that requires business context.