Repository navigation
search: planner-estimate circuit breaker for retrieve_temporal_combined - #1958
Closed
cdbartholomew wants to merge 2 commits into
Closed
cdbartholomew wants to merge 2 commits into
cdbartholomew wants to merge 2 commits into
Conversation
…bined Phase 1 of the temporal retrieval query (date_ranked CTE) ranks rows by `COALESCE(occurred_start, mentioned_at, occurred_end)` over the entire set matching its WHERE clause, then keeps the top 50 per fact_type. The query is correct in design but degrades nonlinearly when the candidate set crosses ~100k rows because: 1. The window-function sort spills from work_mem to disk (~10× slower per tuple under an external merge sort). 2. The top-50-per-fact_type sample stops being statistically meaningful — when 500k rows are ~tied on the sort key, the date ranking has no signal and the join back to memory_units produces near-random candidates that the cross-encoder then has to rerank. 3. Phase 2's hash join probes the full table once for the embedding distance, making the whole query I/O-bound. Banks that accumulate dense, near-uniform date metadata (e.g. bulk ingest that stamps mentioned_at = NOW() across hundreds of thousands of rows) land in this regime for any temporal recall that intersects the dense zone. EXPLAIN ANALYZE on a 660k-row bank with a 1-day window confirmed: 33 seconds execution time, 30 MB external sort to disk, ~2.8M shared buffer touches, and 184k disk reads per query. Under N-way concurrent recall this saturates the primary on its own. This change adds a pre-flight gate that asks the planner via EXPLAIN (FORMAT JSON) how many rows the temporal filter would match. Above the configurable threshold (`HINDSIGHT_API_TEMPORAL_RECALL_MAX_ESTIMATED_ROWS`, default 50,000) the function returns an empty per-fact_type dict and lets semantic+BM25 carry the recall. The caller in `retrieve_all_fact_types_parallel` already tolerates an empty temporal result (it's one of several parallel arms). Empirical numbers used to pick the threshold: | filter match count | sort | per-query | top-50 signal | |--------------------|------|-----------|---------------------| | <50k | mem | <2s | meaningful | | 50k–100k | disk | 5–15s | weak (random-ish) | | >100k | disk | >15s | pure noise | 50k is the conservative lower bound — it doesn't false-positive on healthy banks (planner estimates over-shoot real counts, which is the safe direction here: the gate only over-blocks, never under-blocks). Verified estimate accuracy on the catastrophic case at 99.7% (planner 644,518 vs actual 646,482) and on healthy cases as a 2–38× over-estimate (still well under the threshold for any real workload). Failure-mode: if EXPLAIN fails to return a parseable plan (driver quirk, permission error, dialect drift), the gate fails open and lets the temporal query run rather than silently dropping the result. Better a slow query than a silently-empty recall. Non-postgres backends opt out entirely since the EXPLAIN format here is pg-specific. Tests cover: gate fires above threshold, gate allows at-or-below, strict-inequality boundary (a refactor swapping `>` for `>=` is caught), EXPLAIN-failure fail-open path, non-postgres backend opt-out.
…w estimator The temporal recall circuit breaker's pre-flight EXPLAIN was inheriting the caller's params list as-is, including ``$1`` (the query embedding) and ``$6`` (the semantic similarity threshold) which the EXPLAIN SQL never references. asyncpg fails the prepared statement at type-inference time when a bound parameter has no usage in the query, raising ``IndeterminateDatatypeError: could not determine data type of parameter $1``. The estimator caught the exception and fell open (the safe direction), so no recall regressed — but the gate itself never actually ran. The production logs show one ``temporal filter row estimate failed; proceeding without gate`` entry per recall on every code path that exercises the temporal arm. Fix: ``_estimate_temporal_filter_rows`` now takes the raw inputs (``tags``, ``tag_groups``, ``created_after/before``) and builds its own EXPLAIN-specific param list starting at ``$1``, rather than borrowing the caller's layout. Every bind in ``estimate_params`` is referenced by a ``$N`` in ``explain_sql``; asyncpg can infer all types and the EXPLAIN runs. Adds ``test_explain_sql_references_every_param`` — a structural regression that captures the SQL + params emitted to ``conn.fetch`` and asserts every position in ``estimate_params`` is referenced by at least one ``$N`` in the SQL. This is the exact invariant asyncpg enforces at prepare time; the previous mock-based tests couldn't surface it because the fake conn bypassed type inference entirely.
nicoloboschi
added a commit
that referenced
this pull request
Jun 5, 2026
…overage
retrieve_temporal_combined Phase 1 ranked the *entire* date-window match set by
COALESCE(occurred_start, mentioned_at, occurred_end) and kept the 50 most recent.
Two problems, one perf and one functional:
- Perf: on banks with dense/near-uniform date metadata (e.g. a retain pipeline
that stamps a large batch with one date) any recall window intersects
(near-)all rows, so Phase 1 degraded to a full sequential scan + disk-spilling
sort. EXPLAIN on a 680k-row bank: Seq Scan 680k + Sort 680k to keep 50
("Rows Removed by Filter: 679,950"), ~672ms Phase 1 alone (30s+ in prod).
- Functional: ranking by recency biases results toward the END of the window,
and when dates are degenerate the "50 most recent" is a near-random sample
that can drop the single most relevant in-window memory before similarity is
ever considered.
Switch the entry-point gate to embedding similarity within the window
(ORDER BY embedding <=> query, per fact_type, LIMIT pool), then narrow the pool
to N per fact_type with coverage-first round-robin across time-buckets so the
entry points span the window's range instead of clustering. Degenerate dates
collapse to plain similarity order.
The planner serves the similarity-ordered window query from the existing
per-(bank, fact_type) HNSW index when the window is broad (the dense case) and
from the existing partial date indexes + an exact sort when it is narrow — so no
new index is needed. (An earlier revision of this PR added a recency expression
index; Option A makes it unnecessary, so it's removed.)
Measured on a 680k-row dense-date bank (recall_perf): temporal arm
1.174s -> 0.009s; the arm is now both fast and returns the most relevant
in-window memories, spread across the window.
This is the alternative to #1958, which skipped the temporal arm entirely above a
planner row estimate (losing temporal recall on large banks).
- tests (no LLM): coverage round-robin + degenerate-date fallback (pure
selector); similarity-over-recency selection and window filtering (DB-backed)
- recall_perf: `generate --event-date` (dense zone) + `benchmark
--temporal-date` (forces the temporal arm) to reproduce and track this
Collaborator
|
superseded by #1983 |
nicoloboschi
added a commit
that referenced
this pull request
Jun 5, 2026
…(alternative to #1958) (#1983) * fix(recall): select temporal entry points by similarity with window coverage retrieve_temporal_combined Phase 1 ranked the *entire* date-window match set by COALESCE(occurred_start, mentioned_at, occurred_end) and kept the 50 most recent. Two problems, one perf and one functional: - Perf: on banks with dense/near-uniform date metadata (e.g. a retain pipeline that stamps a large batch with one date) any recall window intersects (near-)all rows, so Phase 1 degraded to a full sequential scan + disk-spilling sort. EXPLAIN on a 680k-row bank: Seq Scan 680k + Sort 680k to keep 50 ("Rows Removed by Filter: 679,950"), ~672ms Phase 1 alone (30s+ in prod). - Functional: ranking by recency biases results toward the END of the window, and when dates are degenerate the "50 most recent" is a near-random sample that can drop the single most relevant in-window memory before similarity is ever considered. Switch the entry-point gate to embedding similarity within the window (ORDER BY embedding <=> query, per fact_type, LIMIT pool), then narrow the pool to N per fact_type with coverage-first round-robin across time-buckets so the entry points span the window's range instead of clustering. Degenerate dates collapse to plain similarity order. The planner serves the similarity-ordered window query from the existing per-(bank, fact_type) HNSW index when the window is broad (the dense case) and from the existing partial date indexes + an exact sort when it is narrow — so no new index is needed. (An earlier revision of this PR added a recency expression index; Option A makes it unnecessary, so it's removed.) Measured on a 680k-row dense-date bank (recall_perf): temporal arm 1.174s -> 0.009s; the arm is now both fast and returns the most relevant in-window memories, spread across the window. This is the alternative to #1958, which skipped the temporal arm entirely above a planner row estimate (losing temporal recall on large banks). - tests (no LLM): coverage round-robin + degenerate-date fallback (pure selector); similarity-over-recency selection and window filtering (DB-backed) - recall_perf: `generate --event-date` (dense zone) + `benchmark --temporal-date` (forces the temporal arm) to reproduce and track this * docs(retrieval): explain temporal selection (relevance-gated + window coverage)
nicoloboschi
added a commit
that referenced
this pull request
Jun 8, 2026
The existing recall suites only exercise the temporal retrieval arm incidentally. This adds a dedicated 'recall-temporal' suite that stamps all memories with one event_date and augments every query with a 1-day window on it, so the temporal entry-point scan matches (near-)all rows — the dense-temporal-zone regime from #1958 that #1983 bounded. - _populate_bank gains an optional event_date for the clustered regime - registered in SUITES; runs by default in the daily all-suites job - added to the workflow_dispatch suite choices for manual single runs Results flow to the perf dashboard automatically (publish script keeps the full suites[] array); a matching 'Recall + temporal' page has been added there.
nicoloboschi
added a commit
that referenced
this pull request
Jun 8, 2026
…2046) The existing recall suites only exercise the temporal retrieval arm incidentally. This adds a dedicated 'recall-temporal' suite that stamps all memories with one event_date and augments every query with a 1-day window on it, so the temporal entry-point scan matches (near-)all rows — the dense-temporal-zone regime from #1958 that #1983 bounded. - _populate_bank gains an optional event_date for the clustered regime - registered in SUITES; runs by default in the daily all-suites job - added to the workflow_dispatch suite choices for manual single runs Results flow to the perf dashboard automatically (publish script keeps the full suites[] array); a matching 'Recall + temporal' page has been added there.
cdbartholomew
added a commit
that referenced
this pull request
Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a pre-flight planner-estimate gate to
retrieve_temporal_combinedthatskips the temporal retrieval pass when the date filter would match an
excessive number of rows. This protects the database from queries that are
both expensive and statistically meaningless on banks with dense or
near-uniform date metadata.
Why
Phase 1 of
retrieve_temporal_combinedranks rows byCOALESCE(occurred_start, mentioned_at, occurred_end)over the entire setmatching its WHERE clause, then keeps the top 50 per fact_type. This works
well when the filter is selective. It degrades nonlinearly when the
candidate set crosses ~100k rows for three reasons:
external merge sort).
when hundreds of thousands of rows are ~tied on the sort key, the date
ranking has no signal and the join back to
memory_unitsproducesnear-random candidates that the downstream cross-encoder still has to
rerank.
making the whole query I/O-bound.
A bank that accumulates dense, near-uniform date metadata — for example
because a retain pipeline stamps
mentioned_at = NOW()on a large batch —lands in this regime for any temporal recall that intersects the dense
zone. Profiling against a representative 660k-row bank with a 1-day window
showed:
Under N-way concurrent recall this can saturate the primary.
How
Before issuing the real temporal query, ask the planner via
EXPLAIN (FORMAT JSON)how many rows the Phase 1 filter would match. The plannerwalks its statistics and returns an estimate in milliseconds with no row
I/O. Above the configurable threshold
(
HINDSIGHT_API_TEMPORAL_RECALL_MAX_ESTIMATED_ROWS, default 50,000), thefunction returns an empty per-fact_type dict and lets semantic+BM25 carry
the recall. The caller in
retrieve_all_fact_types_parallelalreadytolerates an empty temporal arm (it's one of several parallel retrieval
arms).
Threshold
50k is the conservative lower bound — it doesn't false-positive on healthy
banks (planner estimates tend to over-shoot real counts on partial-index
filters, which is the safe direction here: the gate only over-blocks,
never under-blocks). Estimate accuracy verified at ~99.7% on the
catastrophic case (planner 644,518 vs actual 646,482) and 2–38× over-shoot
on healthy cases, all of which remain well under the threshold.
Failure modes
drift) → fail open and let the temporal query run. Better a slow query
than a silently-empty recall.
EXPLAIN FORMAT JSONformat used here is pg-specific and the Oracleplanner surface would need its own implementation.
Tests
Five unit tests under
hindsight-api-slim/tests/test_temporal_recall_circuit_breaker.py:>to>=wouldsilently change behavior — pinned)
All 5 pass. Broader regression sweep (112 tests across
search/anddb_abstraction) clean. Lint hook clean.Test plan
observe how often the gate triggers in real workloads