We had a neurons_edges table. It was filling up nicely. Every time the system noticed that two memories were about the same person, or that one superseded another, it wrote a row.
Retrieval never read a single one of them.
Not because of a bug. Because nobody had wired the read path. The edges were an audit trail that looked like a graph, and the whole retrieval stack ran on vector similarity alone. The system was recording its own associations and then answering questions without opening them.
This post is the read path we added, why it stops at one hop, and the two failure modes that decision avoids.
The flaw with no symptom
This is the part worth dwelling on, because it is the reason it survived for months.
A missing traversal produces no error. No exception, no timeout, no empty result. The query returns rows, they are plausible, and they are simply poorer than they should have been. There is no signal anywhere that a better answer existed.
The only way we found it was by asking a question we knew the answer to, and noticing that one obvious memory did not come back.
Why cosine similarity misses linked rows
Every memory carries an embedding, and retrieval ranks by cosine distance. That works well, and it fails on exactly one shape: two rows that are explicitly related but share almost no vocabulary.
row A: "renew the prescription before the end of the month"
row B: "Dr Guy, Thursday 2pm, blood test in three months"
Semantically adjacent to a human. Lexically almost disjoint. Their embeddings sit far enough apart that a top-k query on "prescription" will not surface B.
An edge between them says, flatly, that they belong together. That edge existed. It was in the table. Nothing read it.
The table
create table xneuronal.neurons_edges (
id uuid primary key default gen_random_uuid(),
user_id uuid,
device_id text,
from_node_id uuid not null,
to_node_id uuid not null,
relation_type text not null,
weight double precision not null default 1.0,
valid_from timestamptz,
valid_to timestamptz,
created_at timestamptz not null default now()
);
Two things to note.
user_id and device_id both nullable, exactly one of them set. That is the anonymous-mode pattern: a device can own rows before anyone signs up, and every query scopes on whichever identity is present. It costs a branch in every single query, and it is the price of not forcing an account on first launch.
valid_from / valid_to because an edge can stop being true. A relation is not deleted when it expires, it is closed. Same principle as everywhere else in this schema: nothing is erased, things change state.
The traversal
The read path is deliberately dumb. Two round-trips, no recursion, no CTE.
async expandByEdges(
auth: AuthContext,
seedIds: string[],
relationTypes: NeuronRelationType[] = ['relates_to', 'concerns']
): Promise<Neuron[]> {
if (seedIds.length === 0) return [];
const { column, value } = authScope(auth);
const { data: edges } = await serviceClient
.from('neurons_edges')
.select('from_node_id, to_node_id, relation_type')
.eq(column, value)
.in('relation_type', relationTypes)
.or(`from_node_id.in.(${seedIds.join(',')}),to_node_id.in.(${seedIds.join(',')})`);
if (!edges?.length) return [];
const seedSet = new Set(seedIds);
const neighbourIds = new Set<string>();
for (const e of edges) {
if (!seedSet.has(e.from_node_id)) neighbourIds.add(e.from_node_id);
if (!seedSet.has(e.to_node_id)) neighbourIds.add(e.to_node_id);
}
if (neighbourIds.size === 0) return [];
const { data: neighbours } = await serviceClient
.from('neurons')
.select('*')
.eq(column, value)
.in('id', [...neighbourIds]);
return neighbours ?? [];
}
In plain SQL that is:
select n.*
from xneuronal.neurons n
where n.user_id = $1
and n.id in (
select case when e.from_node_id = any($2) then e.to_node_id
else e.from_node_id end
from xneuronal.neurons_edges e
where e.user_id = $1
and e.relation_type = any($3)
and (e.from_node_id = any($2) or e.to_node_id = any($2))
)
and n.id <> all($2);
The edges are undirected for this purpose: we do not care which end the seed was on, we want the other one. Hence the case and the two-sided predicate.
Caller side, the traversal is opt-in per query, and the seeds are whatever the vector search already returned. The result sets are merged and deduplicated.
The call site asks for three relation types rather than the default two:
neuronService.expandByEdges(auth, seedIds, ['relates_to', 'concerns', 'same_person'])
same_person is the one that earns its place. Querying a single dated observation about someone reconstructs that person's whole timeline in one call, instead of the model issuing a chain of follow-up queries and stitching them together itself.
Why one hop
The technical reason is the boring one: past one hop you need cycle detection and a depth limit, and you are writing a recursive CTE for a feature nobody asked for yet.
The real reason is about result quality, and it took a moment to accept.
At two hops, in a graph of any density, you pull back nearly everything. Take an average degree of 4. One hop from 10 seeds gives you roughly 40 candidates. Two hops gives you 160, minus overlap, on a store where a heavy user has a few thousand rows. You have not enriched the answer, you have replaced it with a dump.
A memory that returns everything remembers nothing. The immediate neighbourhood is almost always relevant. The neighbourhood of the neighbourhood almost never is, because by then the only thing connecting a row to your question is that both of them touch something you once mentioned.
The failure is caught, not hidden
The call site degrades to the base result if the traversal blows up:
const neighbours = await neuronService
.expandByEdges(auth, seedIds, ['relates_to', 'concerns', 'same_person'])
.catch((err) => {
console.warn('[query_neurons] expand failed:', err instanceof Error ? err.message : err);
return [] as Neuron[];
});
The distinction that matters is the console.warn. A catch that returns a default and says nothing is how you end up with a feature that has been dead for three weeks while every dashboard stays green. The warn line is what makes the difference between degrading and disappearing.
Degrading is right here for one narrow reason: the traversal is strictly additive. If it throws, the user gets the answer they would have gotten last week. If it were allowed to propagate, one bad edge row would take down retrieval entirely, and retrieval is the product.
The rule I would generalise: catch and continue only when the code path could be deleted without changing correctness, and always log when you do. If removing it turns a right answer into a wrong one, it has to throw.
The indexes that carry it
create index neurons_edges_from_idx on xneuronal.neurons_edges (from_node_id);
create index neurons_edges_to_idx on xneuronal.neurons_edges (to_node_id);
create index neurons_edges_user_type_idx
on xneuronal.neurons_edges (user_id, relation_type)
where user_id is not null;
create index neurons_edges_device_type_idx
on xneuronal.neurons_edges (device_id, relation_type)
where device_id is not null;
Two single-column indexes because the predicate hits both ends independently, and two partial composite indexes on the scoping column plus relation type. Partial, because a row has either a user_id or a device_id, never both, so a full index would be half dead weight on each.
What it changed
The question that motivated all of this is "what do you know about X". Before, that meant a broad vector query and hoping similarity did the sorting. Now it is a lookup on the subject, then one hop out.
Cost: one extra round-trip on queries that opt in.
The wider lesson is the one about the flaw with no symptom. We had the data, we had the write path, we had months of edges accumulating, and the read path had simply never been connected. Nothing in the monitoring could ever have told us. The only detector for that class of defect is asking a question you already know the answer to, and being honest about the answer you get back.
I build XNeuronal, an Android memory assistant. The Postgres schema behind it was covered in an earlier post about putting reminders, contacts and recipes in one table.
Top comments (0)