An empty answer is not ignorance.
A memory shared by an organisation's coding agents can hold exactly what an agent needs and still return nothing, with no error raised. Two unrelated mechanisms do it, and both are general: the first meets any memory that filters after an approximate index has chosen its candidates, the second any memory whose scope key changes form while its history stays filed under the old one. A read that returns nothing is a claim about the read, not about the store — and a brief that forgets the difference tells a model that nothing was ever written down.
§1A memory that knows, and says nothing
The memory these notes describe sits beside an organisation's coding agents and is read at the start of a task. What it holds is written in scopes, nested: a repository, a team, and the organisation above them. An agent working in one repository asks for a short brief — what is known here, and what the organisation knows that bears on it — and the brief is assembled from the nearest memories to the task's own words, restricted to the scopes that agent may read.
When a read comes back with no rows, the brief built from it has one honest thing to say and one easy thing to say. The easy one — nothing is known here — is the failure that matters for a memory whose whole job is to stop an agent re-deriving what a colleague already found, because a model handed it will reasonably proceed as if nothing had been written down. Both causes below were found in testing, and both are repaired.
An empty result licenses one conclusion: this read, as executed, matched nothing. It does not license "the store holds nothing relevant", and a brief that says the second has promoted a property of the query plan into a statement about the world.
Two causes were found, independently, and each alone produces the empty brief. The first is in how an approximate nearest-neighbour index interacts with a filter. The second is in the name the history was filed under.
§2An approximate index filters after it has chosen
A vector index of the kind used here, HNSW, answers "which stored items are nearest to this one" by
walking a layered graph and keeping a bounded list of the best candidates it has seen.[2]
The bound is what makes it fast. In pgvector, a condition in the query — only this repository, only
this organisation — is not part of that walk. pgvector's documentation says so directly: "With
approximate indexes, filtering is applied after the index is scanned. If a condition matches
10% of rows, with HNSW and the default hnsw.ef_search of 40, only 4 rows will match on
average."[1]
The index here covers current memories only, so the filter that matters is scope. One store serves every repository in an organisation, so any one repository is a share of it, and a scoped read filters a 40-candidate list down to that share, and when a prompt's nearest neighbours happen to live in other repositories, that share is zero. Nothing in the result distinguishes that from a scope with nothing to say.
The mechanism is reproduced below on a table anyone can build: 100,000 random vectors, 2% of them in one scope. Twenty probe vectors each ask for the ten nearest memories in that scope, first at pgvector's defaults and then with an iterative scan (added in 0.8.0),[1] a candidate list of 100 and a ceiling of 40,000 visited tuples — the three settings changed together, as they are in the repair.
-- pgvector 0.8.4, PostgreSQL 16 · docker image pgvector/pgvector:0.8.4-pg16
CREATE EXTENSION IF NOT EXISTS vector;
SELECT setseed(0.42);
CREATE TABLE memories (id int PRIMARY KEY, scope text, embedding vector(32));
INSERT INTO memories
SELECT i, CASE WHEN i % 50 = 0 THEN 'acme/app' ELSE 'acme/other-' || (i % 49) END,
(SELECT array_agg(random() - 0.5) FROM generate_series(1, 32) WHERE i > 0)::vector
FROM generate_series(1, 100000) AS i;
CREATE INDEX ON memories USING hnsw (embedding vector_cosine_ops);
ANALYZE memories;
CREATE TABLE probes AS
SELECT p, (SELECT array_agg(random() - 0.5) FROM generate_series(1, 32) WHERE p > 0)::vector AS q
FROM generate_series(1, 20) AS p;
-- the read: ten nearest memories in one scope
CREATE FUNCTION hits(q vector) RETURNS int LANGUAGE sql AS $$
SELECT count(*)::int FROM (SELECT id FROM memories WHERE scope = 'acme/app'
ORDER BY embedding <=> q LIMIT 10) t $$;
SET enable_seqscan = off;
-- the plan must be the index; a sequential scan answers exactly and proves nothing
EXPLAIN (COSTS OFF) SELECT id FROM memories WHERE scope = 'acme/app' ORDER BY embedding <=> (SELECT q FROM probes WHERE p = 1) LIMIT 10;
SELECT 'default (ef_search 40)' AS arm, min(hits(q)), round(avg(hits(q)), 2) AS mean, max(hits(q)),
count(*) FILTER (WHERE hits(q) = 0) AS empty FROM probes;
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.ef_search = 100;
SET hnsw.max_scan_tuples = 40000;
SELECT 'iterative, relaxed_order' AS arm, min(hits(q)), round(avg(hits(q)), 2) AS mean, max(hits(q)),
count(*) FILTER (WHERE hits(q) = 0) AS empty FROM probes;
| Build | Defaults · mean hits of 10 | Defaults · empty reads | Iterative, ef_search 100 · mean hits of 10 | Iterative, ef_search 100 · empty reads |
|---|---|---|---|---|
| Run 1 | 0.80 | 8 of 20 | 10.00 | 0 of 20 |
| Run 2 | 0.95 | 8 of 20 | 10.00 | 0 of 20 |
| Run 3 | 0.95 | 7 of 20 | 10.00 | 0 of 20 |
The three runs differ because HNSW construction is randomised, so each build is a slightly different
graph; the seed fixes the data, not the graph. The default arm lands where the documentation's
arithmetic says it should — 40 candidates at 2% is 0.8 — and between seven and eight of every twenty
reads come back with nothing at all, from a scope holding 2,000 rows. The vectors are uniform and
scope is assigned independently of them, which is the case the documentation's arithmetic
describes. Real memories cluster by topic, and a repository's memories sit near each other, so the
rate on a real store can differ in either direction: the experiment shows the mechanism, not a rate.
One trap in re-running it: an
earlier run that raised maintenance_work_mem failed to build the index inside the
container, the planner fell back to a sequential scan, and every read returned ten exact answers.
A sequential scan proves nothing about the index, which is why the plan is checked before the numbers
are read.
A scoped read can return a fraction of a scope's memories, or none, while reporting success
The fix is the table's right-hand columns, set per transaction on every read: an iterative scan in relaxed order, a candidate list of 100, and a ceiling of 40,000 visited tuples. Relaxed order is acceptable here because every result is re-ranked afterwards by a score that is not pure distance. Measured on the live store, inside a transaction that was rolled back:
a read of the live store, scoped to one repository, limit 60
defaults 20 of 60 rows
iterative scan, relaxed order 60 of 60 rows
§3A renamed identifier strands its history
The second cause needed no index at all. On 18 September 2026 the memory's clients were aligned on
one form for naming a repository, owner/name, which is unambiguous across
organisations; until then some writes had used the bare name. Reads and writes from
then on used the new form.
Nothing was rewritten: memories already stored stayed filed under the name they were written with.
A read asking for owner/name therefore saw only what had been written since the change,
and none of the history before it. Again nothing was malformed: the scope exists, the read is
permitted, the result is well-formed and small. A brief built from it describes a repository that
started the day its name changed — a failure any system meets when the key a history is filed under
changes form and the history does not move with it.
The repair is on the read side and deliberately narrow. Whenever a read names a repository as
owner/name, the bare name is appended to the scopes it may see, and ranked as the same
repository rather than as one more rung between the repository and the organisation — otherwise the
repository's own history would score as if it belonged to a neighbouring scope. A repository marked
sensitive has its bare name excluded too, so a history filed under the old key cannot leak past an
exclusion written against the new one. Writes are unchanged.
The alias maps owner/name to name by its last segment. Two repositories
in one organisation with the same final name under different owners — a fork beside its upstream, say —
would each be shown the other's pre-rename history. The correct end state is rewriting the stored
rows and removing the alias; until then the alias is a known over-reach traded for the history being
visible at all.
§4An empty answer describes the read, not the store
Both defects share a signature: the store held the answer, the read was allowed to see it, and the result was an honest description of a query that asked the wrong question. Neither is detectable by validating the result, because a well-formed empty set is valid. They were found by comparing a scoped read with the same read unscoped, and seeing the scoped one come back empty where the unscoped one did not.
Three limits remain. The iterative scan is bounded: a scope rare enough that 40,000 visited tuples do not reach its members can still come back short, and the read does not say so. The alias is temporary and over-reaches in the case above. And nothing in a result set distinguishes a read that matched nothing from a store that holds nothing; a brief has to be told which one it is looking at. What would settle that is a read that reports how much it examined beside what it matched, so that "searched 40, matched 0" is never printed as "knows nothing". These notes do not claim that change is made.
The defects here were found in SkilledMind, a Digital One property, while testing it; that repository is not public, so the one figure quoted from the live store in §2 is its builders' account and cannot be re-run by a reader. The index experiment in §2 is different: it is printed as it was run, apart from its header comment, against a public container image. Deliberately absent: prices, plans and tiers, and deployment internals.
References
- pgvector, README at tag v0.8.4, sections "Filtering" and "Iterative Index Scans"; the quotation in §2 is from "Filtering". github.com/pgvector/pgvector/blob/v0.8.4/README.md, read 19 September 2026.
- Yu. A. Malkov and D. A. Yashunin, "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs", arXiv:1603.09320 (2016).