Skip to content

match_thoughts silently loses recall on filtered queries (HNSW ef_search default) #417

Description

@bealmot

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. 🧠

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