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.
Scoring formula
Section titled “Scoring 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.
Similarity score
Section titled “Similarity score”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).
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.0The function handles multiple response formats defensively — similarity, score, and distance — but the S3 Vectors API returns similarity directly when using cosine metric.
Recency score
Section titled “Recency score”Recency uses exponential decay with a 30-day half-life. An article published today scores 1.0; an older article decays smoothly toward zero.
import mathfrom 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.
Decay examples
Section titled “Decay examples”| Age | Recency score | Effect on final score |
|---|---|---|
| Today (0 days) | 1.00 | Full recency bonus (+0.20) |
| 7 days | 0.79 | Mild decay (+0.16) |
| 30 days | 0.37 | Moderate decay (+0.07) |
| 90 days | 0.05 | Near-zero bonus (+0.01) |
Combining the scores
Section titled “Combining the scores”The final ranking step is straightforward — multiply each component by its weight and sum:
similarity = _vector_similarity_score(vector)freshness = _recency_score(published_dt)final = (0.8 * similarity) + (0.2 * freshness)Worked example
Section titled “Worked example”Consider two candidates for the query “What’s new with S3 Vectors?”:
| Candidate | Similarity | Age | Recency | Final score |
|---|---|---|---|---|
| Article A (today) | 0.82 | 0 days | 1.00 | 0.8×0.82 + 0.2×1.00 = 0.86 |
| Article B (old) | 0.91 | 60 days | 0.14 | 0.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.
Over-fetch and rerank
Section titled “Over-fetch and rerank”The demo doesn’t apply scoring inside S3 Vectors. Instead it uses a two-stage strategy:
- Over-fetch — request 3× the desired results from QueryVectors
- Filter — remove duplicates and articles outside the time window
- Score — apply the weighted formula to remaining candidates
- Select — take the top-K from the rescored list
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:
candidates.sort(key=lambda c: c.final_score, reverse=True)selected = candidates[:desired_top_k]Why rerank application-side?
Section titled “Why rerank application-side?”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.