Skip to content

fix(recall): bound temporal entry-point scan to top-50-per-fact_type (alternative to #1958) - #1983

Merged
nicoloboschi merged 2 commits into
mainfrom
fix/temporal-recall-bounded-scan
Jun 5, 2026
Merged

nicoloboschi merged 2 commits into
mainfrom
fix/temporal-recall-bounded-scan

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Summary

retrieve_temporal_combined Phase 1 ranked the entire date-window match set
with a window function and then discarded all but the top 50 per fact_type.
On banks with dense or near-uniform date metadata — e.g. a retain pipeline that
stamps a large batch with a single mentioned_at/event_date — any temporal
recall window intersects (near-)all rows, so the query degraded to a full
sequential scan plus a disk-spilling sort. This is the 30s+ prod temporal recall
behind #1958.

This PR fixes the query so the temporal arm stays correct and fast at scale,
rather than skipping it.

Root cause (reproduced + EXPLAIN'd on a 680k-row dense-date bank)

The window function forces a full sort: ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC) must rank
every matched row before the outer rn <= 50 filter applies. EXPLAIN ANALYZE:

Aggregate (actual time=671 ms)
  -> WindowAgg (rows=680000)
       -> Sort  Sort Key: fact_type, COALESCE(...) DESC  (rows=680000)  -- spills to disk under prod work_mem
            -> Seq Scan on memory_units (rows=680000)  Buffers: read=140655
  -> CTE Scan ... Filter: (rn <= 50)  Rows Removed by Filter: 679,950   -- sorted 680k rows to keep 50

The date filter is index-able; the ranking sort is the trigger. When all
events share ~one date the sort key is degenerate (all tied), so the "top 50 most
recent" is 50 arbitrary rows produced by sorting the whole bank.

Fix

Rewrite Phase 1 as a CROSS JOIN LATERAL with ORDER BY … LIMIT 50 per
fact_type, backed by a new partial expression index:

CREATE INDEX idx_memory_units_temporal_recency
ON memory_units (bank_id, fact_type, (COALESCE(occurred_start, mentioned_at, occurred_end)) DESC NULLS LAST)
WHERE embedding IS NOT NULL

The planner walks the index in recency order and stops after 50 matching rows
per fact_type
, so work is bounded to ~50 × len(fact_types) index entries
regardless of how many rows fall in the window. Semantics are unchanged (50
most-recent in-window per type → similarity rerank → top 10). The NULLS LAST in
the index is load-bearing: it matches the query's DESC NULLS LAST so the index
supplies the ordering directly (a regression test pins this).

Results (680k-row dense-date bank, recall_perf)

before after
temporal arm (recall trace) 1.174 s 0.009 s (~130×)
Phase-1 EXPLAIN exec time 672 ms 0.1 ms
Phase-1 plan Seq Scan 680k + Sort 680k Index Scan, no Sort

(Local pg0 has more work_mem than prod, so the absolute local number is ~1s vs
prod's 33s, but the pathology and the fix are the same.)

vs #1958

#1958 added a planner-estimate circuit breaker that skips the temporal arm
entirely
above a row estimate — losing temporal recall on exactly the large
banks where it matters. This PR keeps the arm working and makes it fast, so the
circuit breaker isn't needed.

Reproduce

cd hindsight-api-slim
uv run python ../hindsight-dev/benchmarks/perf/recall_perf.py generate \
    --bank-id recall-perf-temporal --scale very-large --event-date 2025-01-15
uv run python ../hindsight-dev/benchmarks/perf/recall_perf.py benchmark \
    --bank-id recall-perf-temporal --query "database migration" \
    --temporal-date 2025-01-15 --iterations 5

generate --event-date stamps all memories into one date (dense zone);
benchmark --temporal-date appends an absolute date to the query so the temporal
arm fires on that window. Both flags are new in this PR.

Tests

tests/test_temporal_recall_bounded_scan.py (deterministic, no LLM):

  • window filter + top-50-by-recency bound: an out-of-window or too-old unit
    is excluded even with a perfect-match embedding
  • the recency index serves the DESC NULLS LAST ordering with no Sort node
    (guards a NULLS-order / index-shape regression)

test_migration_shape.py passes (migration dispatches through run_for_dialect;
PG-only, Oracle slot intentionally absent since the query uses pgvector <=>).

…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
nicoloboschi force-pushed the fix/temporal-recall-bounded-scan branch from e1edf2e to 673ce76 Compare June 5, 2026 08:23
@nicoloboschi
nicoloboschi merged commit 4c33a4e into main Jun 5, 2026
78 of 80 checks passed
@nicoloboschi
nicoloboschi deleted the fix/temporal-recall-bounded-scan branch June 5, 2026 08:57
nicoloboschi added a commit that referenced this pull request Jun 5, 2026
…o unnest)

The temporal-recall entry-point selection was rewritten on main (#1983) to gate
candidates by embedding similarity within the window. That new query expanded the
fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has
no Oracle equivalent — so after merging main, Oracle recall would again fail with
ORA-03048 on any temporal query, in the entry-point query this time (the spreading
guard added here only covers the multi-hop spread).

Rebuild the entry-point query as a UNION ALL of one similarity-ranked,
window-filtered arm per fact_type with the fact_type inlined as a literal — the
same shape retrieve_semantic_bm25_combined already uses and which the Oracle
backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and
FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone.

Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type)
vector index; selection + coverage logic is identical) — verified by the existing
temporal selection tests and the recall_perf temporal benchmark (temporal arm
~0.002s on the 680k dense bank). Oracle output verified through the real
_rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST.
nicoloboschi added a commit that referenced this pull request Jun 5, 2026
…backend (#1980)

* fix(oracle): make recall and mental-model history work on the Oracle backend

Two code paths emitted PostgreSQL-specific SQL that has no Oracle equivalent
and is not handled by the PG→Oracle query rewriter, so they raised hard
errors on the Oracle 23ai backend:

1. Recall — `retrieve_temporal_combined` expands a batch of seed ids for
   multi-hop temporal-link spreading with `FROM unnest($2::uuid[]) AS
   src(from_unit_id)`. Oracle has no `unnest`, so recall raised
   `ORA-03048` whenever the matched memories had temporal/causal links
   (the common case). Fix: guard the spreading loop on the connection's
   `backend_type`; on backends without `unnest` we skip only the multi-hop
   spread. The temporal entry points are still returned, and the
   semantic / keyword / graph retrievers are unaffected.

2. Mental-model history — `update_mental_model` trims the history array in
   SQL with `jsonb_agg(... ORDER BY ...)` over
   `jsonb_array_elements(...) WITH ORDINALITY`, which raised `ORA-00907`
   and made mental-model creation fail (the create path triggers a refresh
   that updates content). Fix: on Oracle, compute the trimmed history in
   Python (we already fetch the current array) and bind it as a single JSON
   value. The PostgreSQL SQL path is unchanged.

Both are instances of the dialect-asymmetry trap called out in CLAUDE.md.

Test plan:
- Oracle 23ai e2e smoke + HTTP integration: mental-model create/CRUD and
  full-lifecycle (previously failing with ORA-00907) now pass.
- Full Oracle integration suite shows zero ORA-03048 occurrences.
- PostgreSQL mental-model history unit tests (including max-entries
  trimming) still pass — the PG path is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(oracle): return CLOB columns from RETURNING without a 4000-byte cap

The Oracle backend's RETURNING handler bound every non-numeric, non-timestamp
output column as DB_TYPE_VARCHAR. VARCHAR out-binds cap at 4000 bytes, so any
CLOB-backed column returned via a RETURNING clause raised
`ORA-22835: buffer too small for CLOB to CHAR conversion` once its value
exceeded 4000 bytes. This surfaced as mental-model creation failing on Oracle:
the post-create refresh UPDATEs `content` (a CLOB) with `RETURNING content`,
and a sufficiently long synthesized snapshot (>4000 bytes) aborted the update.

Fix: bind known CLOB columns (the JSON-as-CLOB set plus the large-text columns
content/text/context/structured_content/text_signals/search_vector) as
DB_TYPE_CLOB in the RETURNING var setup, and read the LOB handle back to a
string in _read_returning_values (the async pool yields AsyncLOB, whose read()
is awaited). Non-CLOB columns are unchanged.

Verified against Oracle 23ai:
- A 4277-byte CLOB now round-trips through UPDATE ... RETURNING (previously
  ORA-22835); other columns (RAW(16) ids, etc.) still convert correctly.
- Mental-model create/refresh with large content succeeds.
- RETURNING-heavy Oracle integration tests (retain, tags, document/memory CRUD,
  http retain/recall, full lifecycle) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(oracle): make the temporal entry-point query Oracle-compatible (no unnest)

The temporal-recall entry-point selection was rewritten on main (#1983) to gate
candidates by embedding similarity within the window. That new query expanded the
fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has
no Oracle equivalent — so after merging main, Oracle recall would again fail with
ORA-03048 on any temporal query, in the entry-point query this time (the spreading
guard added here only covers the multi-hop spread).

Rebuild the entry-point query as a UNION ALL of one similarity-ranked,
window-filtered arm per fact_type with the fact_type inlined as a literal — the
same shape retrieve_semantic_bm25_combined already uses and which the Oracle
backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and
FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone.

Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type)
vector index; selection + coverage logic is identical) — verified by the existing
temporal selection tests and the recall_perf temporal benchmark (temporal arm
~0.002s on the 680k dense bank). Oracle output verified through the real
_rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST.

---------

Co-authored-by: DK09876 <dk09876@DK09876s-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
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.
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.

1 participant