9.1 KiB
Task 07 — Recency boost: newer documents rank higher, without breaking retrieval (D6)
Phase: 106_document_dates · Source: owner request 2026-09-13 — "Newer documents should be ranked higher in retrieval somehow, or at least given a boost, without breaking the existing retrieval process (so make sure to test with documents that have the correct answer but are older against documents that are similar and newer but don't quit correctly answer the question). This will be a fine line to walk, so testing is crucial here."
Objective
A small, env-tunable, kill-switchable additive recency term on the RRF-fused score — applied once in retrieve() after fuse() — so a fresh document gets a bounded head start on near-ties while an older document that ACTUALLY answers the question keeps its rank. The owner's scenario is pinned by a permanent real-Postgres battery with deterministic axis vectors. The A7 math, the A8 cosine gate, query_log.top_score, and the never-truncated contract are untouched.
Work
app/config.py— the hybrid block (L163-174, afterrrf_k):Add the startup validator (find#: Recency boost on the RRF-fused retrieval score (phase 106, D6): the #: MAXIMUM additive score a zero-age document gets — #: ``fused + recency_boost * exp(-age_days / recency_half_life_days)`` #: (``app.rag.retriever.apply_recency_boost``, applied in #: ``retrieve()`` after ``fuse()``). ``0`` = off — the pre-phase #: ranking is byte-identical (the kill switch); negative values fail #: startup loudly (the ``agent_max_rounds`` validator pattern). #: 0.001 ≈ a 1-2 rank head start on a 60+ RRF scale — enough to break #: near-ties toward the newer document, far below the gap between a #: document that answers and one that merely resembles (the #: phase-106 fine-line battery pins it). recency_boost: float = 0.001 #: Age (days) at which the recency boost halves (phase 106, D6). #: ``<= 0`` fails startup loudly (same validator family). recency_half_life_days: int = 365agent_max_rounds's field-validator and follow it — fail loudly naming the field):recency_boost < 0→ error;recency_half_life_days <= 0→ error..env.example— documentBOR_RECENCY_BOOST+BOR_RECENCY_HALF_LIFE_DAYS(the hybrid section, the existing comment style).app/rag/retriever.py:- NEW pure function (module-level, next to
fuse):Defaults fromdef apply_recency_boost( chunks: Sequence[RetrievedChunk], *, now: datetime | None = None, weight: float | None = None, half_life_days: int | None = None, ) -> list[RetrievedChunk]:get_settings()when omitted;nowdefaults todatetime.now(UTC). For each chunk:age_days = max(0.0, (now − doc.created_at).total_seconds() / 86400.0)(a futurecreated_atclamps to 0 — consistent with D3's today-folding),score = score + weight * math.exp(−age_days / half_life_days)(importmath;replace(rc, score=new_score)— never mutate inputs, thefuseconvention). Return the list re-sorted with the EXISTING deterministic key(−score, −cosine, document.path, position)— withweight=0every score is untouched and the order is byte-identical (pinned). Docstring: the D6 contract, the magnitude rationale (0.001 ≈ 1-2 ranks on the k=60 scale — rank 1 vs 2 in one list differs by ~0.00026, rank 1 vs 10 by ~0.0021), the untouched surfaces (A8 gate = cosine,query_log.top_score= cosine,weak_hit_titles= titles only, the never-truncated top-N), and the single-apply-site rule (retrieve()only — chat API +eval_retrievalinherit it). retrieve()(L398-425): afterreturn fuse(vector, lexical, settings.rrf_k)→ apply:fused = fuse(...);if settings.recency_boost > 0: return apply_recency_boost(fused);return fused(weight-0 callers pay nothing).
- NEW pure function (module-level, next to
scripts/eval_retrieval.py— the printed top-N table gains two columns: the document'screated_at(UTC date) and the post-boost effective score (the script callsretrieve(), which now applies the boost — print both the raw fused and effective where they differ, or just effective + date; keep the verdict column). Docstring line updated.- Tests:
tests/unit/test_retriever_recency.py(NEW — fake rows, no DB): age 0 →+weightexact; age = half-life →+weight*exp(-1)(±1e-9); age 10× half-life → ~+weight*exp(-10)(assert< weight * 1e-3); future date → full weight (the clamp);weight=0→ the returned list's(score, order)is byte-identical to the input (the kill-switch pin); a tie on raw score breaks toward the newer document; the sort key's(path, position)tie-break still applies when scores AND cosines are equal (two docs, same age).tests/integration/test_recency_boost.py(NEW — real Postgres,tests/integration/test_name_hit_lexical.py's axis-vector idiom VERBATIM:D=768unit vectors, exact cosines,TRUNCATE chunks, documentsfixture,retrieve()+select_documents()with settings overrides via the house settings-override pattern — check how that suite's siblings inject settings, e.g.monkeypatchonget_settingsorSettings(_env_file=None, …)):- THE OWNER SCENARIO (old-correct beats new-similar). Question
"How did I configure the backup retention policy?". Doc Abackups/retention.md,created_at=2020-01-01: the exact answer — chunk vector = the question vector's axis (cosine 1.0) + its exact tokens in the chunk text (top FTS rank). Doc Bbackups/retention-draft.md,created_at=yesterday(the test computesnow − 1d): topically similar (sharesbackup retention policytokens — a solid FTS hit at rank 2-3) but a weaker vector (half-parallel axis → cosine ~0.707) and its text says the policy is "under review, no decision yet" (no answer). Assert with DEFAULTS:select_documents(...)[0].path == "backups/retention.md"AND the fused (pre-boost, computed viafusedirectly in the test for the margin) gap A−B ≥ 3× the zero-age boost (record the measured margin in the test docstring — the "comfortable margin" requirement). Assert AGAIN withrecency_boost=0(settings override): A still first (no-regression pin — relevance alone ordered them). - The boost is real (near-tie flips toward newer). Docs C (2019) and D (yesterday) with IDENTICAL chunk text + IDENTICAL vectors (a true tie — same fused score, cosine, FTS rank; the deterministic sort key would otherwise order by path, and path is set so the OLDER sorts first lexicographically, e.g.
c-older.md<d-newer.md). With defaults: D (newer) is first. Withweight=0: C (older) is first (proving the boost — not drift — is the differentiator). - Decay end-to-end: the same C/D pair with D aged to
half_life + 365days (≈weight*e^{-3}≈ 0.00005, below the tie gap 0) → C first again (the boost faded — recency is an age signal, not a binary). - The gate is untouched: the owner-scenario question's
max cosine(the A8 input) equals the pre-boost run's (assert on the retrieved chunks'cosinevalues — the boost never touches them).
- THE OWNER SCENARIO (old-correct beats new-similar). Question
- If test 1's measured margin under the DEFAULTS is thin (< 3× the boost) or the scenario flips, tune the DEFAULTS (0.001/365 are the starting point — the owner re-tunes live via the env) until old-correct wins comfortably, and record the final margin in the docstring. The test asserts the SEMANTICS (A first, margin ≥ 3× boost), never the exact floats.
- Run
uv run pytest tests/unit/test_retriever_recency.py tests/integration/test_recency_boost.py -q(DB up) — green; thenuv run pytest tests/integration/test_name_hit_lexical.py tests/integration/test_chat_api.py -q(the retriever's existing contract suites stay green — the boost is ON by default in them, so any drift surfaces here).
Testing & Quality
- Unit: the decay/weight/clamp/tie/kill-switch pins (pure function).
- Integration: the fine-line battery on real Postgres with exact axis cosines — the owner's scenario + the near-tie flip + the decay + the cosine-gate-untouched pin.
- Coverage: >90% on
app/(config validator + retriever branches covered — the validate.sh gate).
Completion Criteria
Settings.recency_boost(default 0.001, 0 = byte-identical off, negative fails startup) +recency_half_life_days(default 365,<= 0fails startup);.env.exampledocuments bothapply_recency_boostis pure (defaults from settings,nowinjectable, inputs unmutated, the existing 4-key sort) and is applied inretrieve()afterfuse()and ONLY there — chat API +eval_retrievalinherit it;eval_retrievalprints the date + effective score- The owner's scenario is pinned: older-correct beats newer-similar under defaults (margin ≥ 3× the zero-age boost, recorded) AND with the boost off; the near-tie flips toward the newer with the boost on and back without; the decay pin holds; the A8 cosine input is untouched
tests/unit/test_retriever_recency.py+tests/integration/test_recency_boost.py+ the two existing retriever-contract suites green;uv run ruff check . && uv run pyrightclean