teachyou.ai academy
← All posts
RAGretrievalvector searchembeddingssearch ranking

Time-Aware RAG: Ranking Retrieval by Recency

Pramod Dutta · Jun 21, 2026 · 12 min read

Time-aware RAG is retrieval-augmented generation that factors document age or recency into ranking, not just semantic similarity. If you have ever asked a support bot a question and gotten an answer from a deprecated API version because the old doc happened to score 0.91 cosine similarity against your query, you have felt the problem this solves. Plain vector search treats a policy from 2021 and a policy from last week as equals as long as they are semantically close. This article covers the scoring math, the implementation patterns, and the tradeoffs for making retrieval respect time without wrecking relevance for content that genuinely does not age.

Why cosine similarity alone gets recency wrong

Embedding models encode meaning, not time. A query like "what is our refund policy" will match every version of your refund policy doc that ever existed, because they all talk about refunds in similar language. The 2022 version and the 2026 version might differ in one clause, but their embeddings can sit within a hair of each other in vector space. Cosine similarity has no concept of "this changed" or "this is stale."

This bites in a few recurring scenarios:

  • Changelogs and release notes. A query about "rate limits" pulls in the announcement from two years ago alongside last month's update, and the LLM synthesizes both into a confusing answer.
  • News and blog corpora. "Latest AI regulation news" should weight last week over last year, but nothing in the embedding says so.
  • Internal wikis. Old runbooks that were never deleted keep outranking their replacements because they were written more verbosely and match the query terms better.
  • Pricing and API docs. Deprecated endpoints still get retrieved because their descriptions are lexically closer to how users phrase questions than the terser new docs.

The fix is not to throw away semantic similarity. It is to combine it with a recency signal in a way that is tunable per corpus, because not every corpus should be time-weighted the same amount. A legal contract archive and a live incident-response wiki need very different decay rates.

The core idea: blend similarity and recency into one score

The standard approach is a weighted combination:

final_score = alpha * similarity_score + (1 - alpha) * recency_score

Where similarity_score is your normalized cosine or dot-product similarity (0 to 1), recency_score is a normalized freshness signal (also 0 to 1), and alpha controls how much weight recency gets. alpha = 1.0 is pure semantic search. alpha = 0.5 gives recency equal footing.

The interesting part is computing recency_score. Three common decay functions:

1. Exponential decay (most common, smooth falloff):

import math
from datetime import datetime, timezone

def exponential_recency_score(doc_timestamp: datetime, half_life_days: float = 90) -> float:
    """
    Returns a score in (0, 1]. Score is 1.0 for a document created right now,
    and decays to 0.5 after `half_life_days`, continuing to decay after that.
    """
    now = datetime.now(timezone.utc)
    age_days = (now - doc_timestamp).total_seconds() / 86400
    decay_constant = math.log(2) / half_life_days
    return math.exp(-decay_constant * age_days)

2. Linear decay with a floor (simpler, predictable, good when you want a hard cutoff):

def linear_recency_score(doc_timestamp: datetime, max_age_days: float = 365, floor: float = 0.1) -> float:
    now = datetime.now(timezone.utc)
    age_days = (now - doc_timestamp).total_seconds() / 86400
    if age_days <= 0:
        return 1.0
    if age_days >= max_age_days:
        return floor
    fraction_remaining = 1 - (age_days / max_age_days)
    return floor + fraction_remaining * (1 - floor)

3. Step decay / bucket-based (useful when your product treats "this quarter" vs "last quarter" vs "older" as discrete tiers rather than a continuous curve):

def bucket_recency_score(doc_timestamp: datetime) -> float:
    now = datetime.now(timezone.utc)
    age_days = (now - doc_timestamp).total_seconds() / 86400
    if age_days <= 30:
        return 1.0
    elif age_days <= 90:
        return 0.75
    elif age_days <= 365:
        return 0.4
    else:
        return 0.15

Exponential decay is the right default for most RAG systems because it does not create a jarring cliff where a document that is 89 days old scores wildly different from one that is 91 days old.

Choosing the half-life for your corpus

The half-life parameter is the single most important tuning knob, and it should not be a global constant across every content type in your index. A reasonable starting point:

  • Breaking news, incident status pages: half-life of 1-3 days. Anything older than a week is nearly irrelevant.
  • Product changelogs, API version notes: half-life of 30-60 days.
  • Company blog posts, tutorials: half-life of 6-12 months. Still useful for a year or more, but a fresher post on the same topic should edge it out.
  • Legal documents, compliance policy, reference material that rarely changes: half-life of multiple years, or skip recency weighting entirely and rely on an is_current_version flag instead.

That last category matters: recency weighting is the wrong tool when what you actually need is version control. If your knowledge base has exactly one "true" current version of a doc and older versions should never surface at all, do not decay them, delete or flag them as superseded and filter them out of retrieval entirely. Recency scoring is for corpora where multiple documents on the same topic can be simultaneously valid but time-ordered in relevance, like news, changelogs, or advice that improves over time.

Implementing it in a retrieval pipeline

Here is the pattern end to end using a typical vector store setup where you store a created_at or updated_at timestamp as metadata alongside each chunk.

from dataclasses import dataclass
from datetime import datetime

@dataclass
class RetrievedChunk:
    text: str
    metadata: dict
    similarity_score: float

def rerank_with_recency(
    chunks: list[RetrievedChunk],
    alpha: float = 0.7,
    half_life_days: float = 90,
) -> list[RetrievedChunk]:
    scored = []
    for chunk in chunks:
        ts = chunk.metadata.get("updated_at") or chunk.metadata.get("created_at")
        recency = exponential_recency_score(ts, half_life_days) if ts else 0.5
        blended = alpha * chunk.similarity_score + (1 - alpha) * recency
        scored.append((blended, chunk))
    scored.sort(key=lambda pair: pair[0], reverse=True)
    return [chunk for _, chunk in scored]

The important detail: do the recency blend as a rerank step after initial vector retrieval, not inside the vector search itself. Pull the top 30-50 candidates by pure similarity first, then rerank that smaller set with the recency blend before truncating to the final top-k you pass to the LLM. This avoids two problems: it keeps your vector index simple (no need for a custom scoring function inside the ANN search), and it prevents a very fresh but semantically irrelevant document from crowding out a highly relevant older one, since it only competes with documents that already passed the similarity bar.

def time_aware_retrieve(query: str, vector_store, top_k: int = 5, candidate_pool: int = 40):
    candidates = vector_store.similarity_search(query, k=candidate_pool)
    reranked = rerank_with_recency(candidates, alpha=0.7, half_life_days=90)
    return reranked[:top_k]

Handling metadata filtering as a hard time cutoff

Sometimes you do not want soft decay, you want a hard filter: "never retrieve anything older than N days" for a query that is explicitly time-sensitive, like "what changed this week." Most vector databases support metadata filtering alongside the ANN search, which is far cheaper than fetching everything and filtering client-side.

# Example using a metadata filter at query time (syntax varies by vector store,
# this shows the pattern common to Pinecone, Qdrant, Weaviate, and pgvector with a WHERE clause)
from datetime import datetime, timedelta, timezone

cutoff = datetime.now(timezone.utc) - timedelta(days=30)

results = vector_store.similarity_search(
    query="what changed this week",
    k=10,
    filter={"updated_at": {"$gte": cutoff.isoformat()}},
)

For Postgres with pgvector, this is just a WHERE updated_at >= $1 clause combined with an ORDER BY embedding <=> $2 LIMIT $3, which lets the planner use a partial or composite index if the time-filtered volume is large.

SELECT id, content, updated_at,
       1 - (embedding <=> $1) AS similarity
FROM documents
WHERE updated_at >= NOW() - INTERVAL '30 days'
ORDER BY embedding <=> $1
LIMIT 10;

Decide up front whether a query needs a hard cutoff (time filter) or a soft preference (decay rerank). A good heuristic: if the user's query contains explicit temporal language ("latest," "this week," "recent," "current"), lean toward a hard filter or a very short half-life. If the query is topic-only with no temporal cue, use the standard decay rerank so old-but-still-correct content is not excluded outright.

Detecting temporal intent in the query

You can route between "hard filter," "soft decay," and "no time weighting at all" automatically by classifying the query first. A cheap approach is a keyword/regex check before falling back to an LLM classifier for ambiguous cases:

import re

TEMPORAL_PATTERNS = re.compile(
    r"\b(latest|recent|current|newest|this (week|month|quarter|year)|"
    r"today|now|up.to.date|as of)\b",
    re.IGNORECASE,
)

def classify_temporal_intent(query: str) -> str:
    if TEMPORAL_PATTERNS.search(query):
        return "hard_recency"
    return "soft_recency"

For higher accuracy, route ambiguous queries to a small LLM call that returns one of hard_recency, soft_recency, or none, and cache the classification per query pattern since query phrasing tends to repeat across users.

Combining recency with other ranking signals

Recency rarely travels alone in production systems. It usually sits alongside:

  • Source authority (an official docs page should outrank a forum post even if the forum post is fresher)
  • Click-through or feedback signals if you are logging which retrieved chunks actually led to accepted answers
  • Chunk position (title/intro chunks sometimes deserve a small boost over mid-document chunks)

When you have three or more signals, a flat weighted sum like the alpha formula above gets unwieldy fast. At that point it is worth moving to a small learned reranker, a cross-encoder or a gradient-boosted model trained on (query, chunk, label) triples where recency is just one input feature instead of a hand-tuned coefficient. But do not reach for a learned reranker before you have logged enough retrieval feedback to train one. Start with the hand-tuned blend, ship it, and only invest in a learned model once you have real usage data showing where the heuristic breaks down.

Common pitfalls

Applying decay to content that has no meaningful update timestamp. If your ingestion pipeline stamps every chunk with the crawl date instead of the actual document creation or last-modified date, your recency score is measuring "when we last scraped this" instead of "how fresh this content is." Always prefer a source-provided updated_at (from a CMS, a git commit date, an API's modified field) over an ingestion timestamp.

Treating "never updated" the same as "just created." A document with no timestamp metadata should not silently get a recency score of 1.0. In the code above it defaults to 0.5, a neutral score, rather than favoring or penalizing it. Decide explicitly what missing timestamps mean for your corpus.

Forgetting to re-embed on content update. If a document's text changes but you only bump updated_at without re-generating its embedding and re-indexing the chunk, you get fresh metadata pointing at stale semantics. Time-aware RAG only works if your update pipeline treats content changes and metadata changes as one atomic operation.

Using one half-life for a mixed corpus. If your index blends legal policy, marketing blog posts, and incident postmortems, apply the decay function per content-type bucket (tag chunks with a content_type field and look up the half-life from a small config dict) rather than one global half-life for everything.

Over-penalizing evergreen reference content. A well-written conceptual explainer from three years ago can still be the best answer to a query. If recency weighting is too aggressive, you start burying genuinely good, stable content behind mediocre recent filler. Validate with a held-out eval set that includes evergreen queries, not just time-sensitive ones, before shipping a new alpha or half-life value.

Evaluating whether it actually helped

Do not ship a recency rerank on vibes. Build a small labeled eval set: 30-50 queries, each with a known best chunk (or ranked list of acceptable chunks) from your corpus, split between time-sensitive queries and evergreen queries. Run retrieval with and without the recency blend, compare recall@k and mean reciprocal rank (MRR) on both subsets. A good result looks like: recall improves meaningfully on the time-sensitive subset while staying roughly flat on the evergreen subset. If evergreen recall drops, your alpha or half-life is too aggressive and needs to move back toward pure similarity.

def mean_reciprocal_rank(retrieved_ids: list[list[str]], relevant_ids: list[str]) -> float:
    reciprocal_ranks = []
    for retrieved, relevant in zip(retrieved_ids, relevant_ids):
        rank = next((i + 1 for i, doc_id in enumerate(retrieved) if doc_id == relevant), None)
        reciprocal_ranks.append(1 / rank if rank else 0)
    return sum(reciprocal_ranks) / len(reciprocal_ranks)

Track this eval as a regression test in CI for your retrieval pipeline, the same way you would track a unit test, so a future change to alpha or half-life does not silently regress one query type while improving another.

FAQ

What is time-aware RAG? It is a retrieval-augmented generation setup where document age or last-updated timestamp is factored into the ranking of retrieved chunks, alongside semantic similarity, so that fresher content is preferred when multiple documents are topically relevant.

Does time-aware retrieval replace semantic search? No. It blends with semantic similarity through a weighted score or reranks a candidate pool that similarity search already produced. Recency alone would retrieve garbage; similarity provides the relevance floor, recency breaks ties and demotes stale duplicates.

What half-life should I use for recency decay? It depends on the content type: 1-3 days for breaking news or incident pages, 30-60 days for changelogs and API docs, 6-12 months for blog and tutorial content, and multiple years or no decay at all for legal or reference material. Set it per content-type bucket rather than one value for the whole corpus.

Should recency be a hard filter or a soft score? Use a hard metadata filter (exclude anything older than N days) when the query has explicit temporal language like "latest" or "this week." Use a soft decay rerank for topic-only queries where older-but-correct content should still be eligible.

How do I know if adding recency weighting actually improved retrieval? Build a labeled eval set split between time-sensitive and evergreen queries, measure recall@k and MRR with and without the recency blend on both subsets, and only ship the change if it improves the time-sensitive subset without regressing the evergreen one.

Do I need a learned reranker for this, or is the weighted formula enough? The hand-tuned alpha * similarity + (1 - alpha) * recency formula is enough for most systems and should be your starting point. Move to a learned cross-encoder or gradient-boosted reranker only once you have enough logged query-chunk feedback to train one and the hand-tuned blend is visibly hitting its ceiling.

What if a document has no timestamp metadata? Default it to a neutral recency score rather than treating it as maximally fresh or maximally stale, and prioritize fixing your ingestion pipeline to always capture a source-provided last-modified date instead of relying on the crawl or ingestion date.