Skip to content

search: planner-estimate circuit breaker for retrieve_temporal_combined - #1958

Closed
cdbartholomew wants to merge 2 commits into
mainfrom
fix/temporal-recall-candidate-limit
Closed

cdbartholomew wants to merge 2 commits into
mainfrom
fix/temporal-recall-candidate-limit

Conversation

@cdbartholomew

Copy link
Copy Markdown
Contributor

Summary

Adds a pre-flight planner-estimate gate to retrieve_temporal_combined that
skips 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_combined 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. This works
well when the filter is selective. It degrades nonlinearly when the
candidate set crosses ~100k rows for three reasons:

  1. 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 hundreds of thousands of 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 downstream cross-encoder still has to
    rerank.
  3. Phase 2's hash join probes the full table for the embedding distance,
    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:

  • 33 s execution time
  • 30 MB external sort to disk
  • ~2.8M shared buffer touches per query
  • ~184k disk reads per query

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 planner
walks 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), 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 arm (it's one of several parallel retrieval
arms).

Threshold

filter match count sort per-query top-50 signal
< 50k in-memory < 2 s meaningful
50k – 100k disk 5 – 15 s weak
> 100k disk > 15 s near-random

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

  • EXPLAIN can't be parsed (driver quirk, permission error, dialect
    drift) → fail open and let the temporal query run. Better a slow query
    than a silently-empty recall.
  • Non-postgres backend (Oracle) → gate opts out entirely. The
    EXPLAIN FORMAT JSON format used here is pg-specific and the Oracle
    planner surface would need its own implementation.

Tests

Five unit tests under
hindsight-api-slim/tests/test_temporal_recall_circuit_breaker.py:

  • gate fires above threshold (skips the real query)
  • gate allows below threshold (real query runs)
  • strict-inequality boundary (a refactor switching > to >= would
    silently change behavior — pinned)
  • EXPLAIN-failure fail-open path
  • non-postgres backend opt-out

All 5 pass. Broader regression sweep (112 tests across search/ and
db_abstraction) clean. Lint hook clean.

Test plan

  • CI green
  • Roll out behind the default 50,000 threshold; instrument logs to
    observe how often the gate triggers in real workloads
  • If false-positives appear, raise the env var without redeploying

…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
@nicoloboschi

Copy link
Copy Markdown
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants