Heads-up on a latent recall bug in the default vector search — I hit and fixed the identical one in my own pgvector memory system, so passing it back.
Where
The setup match_thoughts + the HNSW index (docs/01-getting-started.md):
create index on thoughts using hnsw (embedding vector_cosine_ops);
-- match_thoughts:
where 1 - (t.embedding <=> query_embedding) > match_threshold
and (filter = '{}'::jsonb or t.metadata @> filter)
order by t.embedding <=> query_embedding
limit match_count;
The bug
pgvector's HNSW scan collects the top-ef_search (default 40) globally-nearest rows first, then your WHERE t.metadata @> filter (+ match_threshold) is applied to only those 40. So a filtered call —
match_thoughts(query_embedding := ..., filter := '{"source":"readwise"}')
— can return near-zero rows even when hundreds match, because few of the global top-40 happen to satisfy the filter. Recall collapses as the filtered subset shrinks relative to the corpus, and it does so silently (no error, just fewer/empty results).
Unfiltered calls (filter := '{}') are unaffected, which is why it hides — but the agent-memory / multi-source / per-agent design is about filtered recall. (Note: match_thoughts_recency is not affected — its blended ORDER BY can't use the HNSW index, so it does an exact scan. It's specifically the base match_thoughts.)
Fix
Raise ef_search inside the function so enough candidates survive the filter:
-- first line of match_thoughts, before the query:
set local hnsw.ef_search = 200; -- tune 100–500 by corpus size / filter selectivity
SET LOCAL scopes it to the function's transaction. One gotcha: setting it session/pool-wide doesn't stick if the connection pool resets connections on release (that bit me for a while) — putting it inside the function is the robust place. Higher ef_search = better recall, marginally slower.
(For low-cardinality filters, a partial index or pre-filter is the scalable long-term answer, but bumping ef_search is the one-liner that restores correctness.)
Thanks for OB1 — I built my own self-hosted second-brain (Mneme) inspired by your videos, and this was a gift worth passing back. 🧠
Heads-up on a latent recall bug in the default vector search — I hit and fixed the identical one in my own pgvector memory system, so passing it back.
Where
The setup
match_thoughts+ the HNSW index (docs/01-getting-started.md):The bug
pgvector's HNSW scan collects the top-
ef_search(default 40) globally-nearest rows first, then yourWHERE t.metadata @> filter(+match_threshold) is applied to only those 40. So a filtered call —— can return near-zero rows even when hundreds match, because few of the global top-40 happen to satisfy the filter. Recall collapses as the filtered subset shrinks relative to the corpus, and it does so silently (no error, just fewer/empty results).
Unfiltered calls (
filter := '{}') are unaffected, which is why it hides — but the agent-memory / multi-source / per-agent design is about filtered recall. (Note:match_thoughts_recencyis not affected — its blendedORDER BYcan't use the HNSW index, so it does an exact scan. It's specifically the basematch_thoughts.)Fix
Raise
ef_searchinside the function so enough candidates survive the filter:SET LOCALscopes it to the function's transaction. One gotcha: setting it session/pool-wide doesn't stick if the connection pool resets connections on release (that bit me for a while) — putting it inside the function is the robust place. Higheref_search= better recall, marginally slower.(For low-cardinality filters, a partial index or pre-filter is the scalable long-term answer, but bumping
ef_searchis the one-liner that restores correctness.)Thanks for OB1 — I built my own self-hosted second-brain (Mneme) inspired by your videos, and this was a gift worth passing back. 🧠