Skip to content

Query-cache hit skips _last_search_summary, so a reused index reports zero passages for a full result #992

Description

@laynepenney

Summary

TranscriptIndex.lookup nulls _last_search_summary at entry and only assigns it deep in the computation. The query cache returns above that assignment. So on a cache hit the retrieval returns byte-identical content while the summary stays None — and any caller that reads the summary to report how much was retrieved reports zero.

The count is bound to a cache-miss side effect rather than to the retrieval.

Mechanism

src/synapt/recall/core.py:

1858   self._last_search_summary = None          # reset at entry

1875   cached = self._query_cache.get(cache_key)
1876   if cached is not None:
1877       return cached                          # early return, above the assignment

3578   self._last_search_summary = SearchResultSummary(...)   # only reached on a miss
3587   self._last_search_summary = SearchResultSummary(...)

The cache is per-instance (_query_cache, max 32 entries), so this affects any caller that holds one index across repeated queries.

Reproduction

Runs against this package alone; no other dependency.

import tempfile
from pathlib import Path
from synapt.recall.core import TranscriptChunk, TranscriptIndex

TOPICS = ["a bicycle repair", "a garden fence", "a kitchen faucet", "a laptop battery",
          "a bookshelf", "a car alternator", "a roof gutter", "a violin bridge",
          "a camera lens", "a door hinge", "a water heater", "a bathroom tile",
          "a lawn mower", "a window latch", "a stair rail"]

chunks = [
    TranscriptChunk(
        id=f"s{i:02d}:t0",
        session_id=f"{i:08d}-0000-0000-0000-000000000000",
        timestamp=f"2026-08-{6 + i:02d}T12:00:00+00:00",
        turn_index=0,
        user_text=f"doc-{i:02d} maintenance notes for {t}.",
        assistant_text=f"Recorded maintenance notes for {t}.",
    )
    for i, t in enumerate(TOPICS)
]

with tempfile.TemporaryDirectory() as d:
    index = TranscriptIndex(chunks, cache_dir=Path(d))
    for i in range(12):
        result = index.lookup("maintenance", max_chunks=5, max_tokens=2400)
        summary = getattr(index, "_last_search_summary", None)
        print(i + 1, len(result), "None" if summary is None else summary.chunk_blocks)

Observed:

call  len(result)  _last_search_summary
   1         1686  10
   2         1686  None
   3         1686  None
   ...
  12         1686  None

len(result) is identical on every call — the retrieval is correct and unchanged throughout. Only the reported summary disappears.

Why it matters

Any surface that renders "N passages retrieved" from this summary shows a correct result body beside the number 0, on every repeat of a query. A caller cannot distinguish "nothing matched" from "cached hit" without reaching further into private state, and the two mean opposite things.

Suggested fix

Either cache the summary alongside the result and restore it on a hit, or move the summary assignment above the cache return so it describes what is being returned in both paths. The first keeps the cache's benefit; the second is simpler if recomputing the summary is cheap.

A regression test wants two calls, not one: a single-call test passes under every implementation, because the defect only appears from the second identical query onward.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions