Contiguous Embedding Index¶
Status: proposed for the 2.5.x line. SCHEMA_VERSION stays 13, no new runtime
dependency is added, and the answers are the ones the current scan already
returns — bit for bit, including the order of equally-similar rows.
1. What changes and what does not¶
The local vector search ranks the newest MAX_MEMORIES rows by cosine. Phase 1
of that scan reads the embeddings:
SELECT id, embedding FROM memories
WHERE <isolation> AND embedding IS NOT NULL [AND <source>]
ORDER BY created_at DESC
LIMIT ?
Measured at 100,000 rows of 768 dimensions on the reference machine, the time splits like this:
| Segment | Time | Share |
|---|---|---|
| SQLite row read (index order, with the blob) | 485.9 ms | 72.9% |
b"".join + frombuffer |
162.3 ms | 24.4% |
| The matrix multiply itself | 18.1 ms | 2.7% |
| Same arithmetic, read from a contiguous file | 95.1 ms | 7.0x faster |
The bottleneck is not the arithmetic. It is turning stored bytes into a numpy
array one Python object at a time. This design moves the source of
(id, embedding) to a file already laid out the way numpy wants it, and
changes nothing else.
The arithmetic stays in numpy, deliberately. The value of this change is that it is exact, and exactness is the half a re-implementation spends.
Measured on the same bytes, a hand-written Go dot product disagrees with numpy on 77.9% of rows (largest absolute difference 1.9e-5), and two Go variants disagree with each other on 94.4%. Summation order is the whole story, and a blocked BLAS order is not reproducible by a hand-written loop.
Since a row's dot product does not depend on any other row, keeping the same
mat @ query_vec call means the scores are identical, and the existing
equivalence gate keeps its teeth.
One qualification, measured after the fact while the fallback scan was made to read its window in chunks: "the same call" has to mean the same bytes in the same shape.
A row's dot product does not depend on the other rows, but the BLAS chooses its kernel from the row count, and below a platform-dependent threshold the last bits move. On Apple Accelerate, matrices under 64 rows at 64 dimensions, and under 16 rows at 768, disagree with the whole-window result by about one ULP, while every larger matrix agrees bit for bit.
The index scores the selected rows as one matrix, and the scan now scores chunks of at least 512 rows (a short tail is merged into the chunk before it), so both stay above any threshold measured so far. The tests measure it on the machine they run on rather than assume it.
2. The invariants the index must preserve¶
The scan window stays MAX_MEMORIES. An index can afford to look at the
whole corpus, but a scan that ranks more rows than today returns different
answers. That is a different feature, needing a different gate, and it is not
this one.
The row order is created_at DESC, then id ASC. That order is the
tie-break: survivors keep scan order, the top-limit cut re-sorts back into
scan order, and heapq.nlargest is stable.
It also decides membership, not just sequence. When the limit cuts through a
group of rows sharing a created_at, the low ids are the ones that survive. On
a production corpus, 1.6% of rows share a created_at with another row of the
same agent (largest group: 9) — rare enough to be absent from an ordinary test
fixture, and common enough to happen in service.
The statement will also state that order rather than inheriting it. Today
id ASC is a by-product of SQLite walking an index whose last column is
created_at DESC. Nothing in the SQL asks for it, and one axis combination
resolves to a sort instead of an index walk — a sorter that is not required to
be stable.
Adding , id ASC was measured rather than assumed, because a term that forces
a sort would cost the very scan this work speeds up. It produced an identical
order under an identical plan in every case. Measurement record:
benchmarks/measurements/results-scan-order-stability.md.
The rest of the scan is untouched. The two-phase split, the pre-hydrate
limit cut, and the by-id hydrate that re-applies the isolation axes all stay
exactly as they are.
3. The index is not an authority¶
isolation_where() remains the single source of the isolation predicate, and
the hydrate keeps re-applying it. The index carries axis columns for one reason
only: the top-k cut happens before the hydrate.
An index that ignored the axes would rank the whole corpus, fill the top-k with rows the hydrate then drops, and return a short, wrong answer. The failure gets worse as a bucket gets smaller relative to the corpus.
So the index filters, but it is never asked to be right about who owns a row. Its only obligation is one-directional:
index candidates ⊇ the rows the authority admits.
| If the index filter is | What happens | Severity |
|---|---|---|
| Too loose | The hydrate drops the extras | Correct. Merely wasteful |
| Too strict | Rows vanish silently | Undetectable recall loss |
One side, and machine-checkable: assert the containment over random corpora and random axis values, then confirm by mutation that a deliberately over-strict index turns it red.
4. File format¶
A header followed by parallel arrays, all fixed-width:
| Part | Contents |
|---|---|
| Header | magic, format version, dimension, dtype, row count, watermark, and a fingerprint over the embedding model, the dimension and the scoring version |
embeddings |
float32[count][dim], contiguous, in canonical order |
ids |
int64[count] |
agent_code, project_code, channel_code, source_code |
int32[count], interned against small string tables in the header |
created_at |
fixed 19-byte ASCII, the column's canonical YYYY-MM-DD HH:MM:SS form |
agent_code carries the axis the isolation predicate compares for equality. The
other three are compared against a small set. Either way, the per-row test is an
integer one.
Interning is cheap because the axes are low-cardinality. A production corpus has 7 agents, 12 distinct projects, 15 channels and 38 source ids. The whole axis set costs 16 bytes per row, next to 3,072 bytes of embedding.
source_code exists because the cardinality was measured rather than assumed.
The source filter is a prefix match against a JSON field, which looks like
something an index cannot do. But with 38 distinct values, the prefix resolves
against the string table into a small set of codes, and the per-row test is
integer membership. Without it, every recall from a multi-user conversation
would fall back to the old path, which is a large share of real traffic.
Rows the format cannot represent are named, not dropped. A created_at
outside the canonical 19-character form (the import path carries a restored
record's own value through) is excluded from the arrays, and its id is listed in
the header. The query path unions that list into the tail read below, so the row
stays reachable.
The list is capped. Past the cap the builder declines to build, which leaves the
system on the current path rather than serving a corpus with holes in it and
saying nothing. On the production corpus the list is empty: every created_at
in both tables is exactly the canonical form.
Fixed-width ASCII is what makes the merge safe. SQLite compares these values as text, and for equal-length strings in this form, a byte comparison and a chronological comparison are the same comparison.
5. Freshness: the watermark¶
A stale index is worse for a memory system than a slow one. An index built an hour ago withholds the last hour of memories without saying so, and those are the most likely to be asked for.
The index records the maximum
idpresent when it was built, answers only for rows at or below that watermark, and the scan reads everything above it from SQLite exactly.
Five things follow, and they are the reason this is the design rather than a rebuild schedule:
- There is no window in which a new memory is invisible. Not "the window is small" — the window does not exist.
- Rebuild frequency stops being a correctness question and becomes a performance knob. A late rebuild grows the tail and degrades latency measurably; it never returns a wrong answer.
- The tail is exact. The newest rows, which are the ones most likely to be retrieved, are never served from a derived structure.
- Deletion needs no index path at all. A row deleted after the build still appears as a candidate, and the hydrate fails closed on it. The index never needs a delete, a repair or a compaction: if it is wrong, throw it away and rebuild.
- The tail read is the shape SQLite is best at — the newest few rows in index order.
The watermark answers one question, and it is not the only one that matters. It says whether a row existed at build time. It cannot say whether that row had an embedding at build time, and those come apart for every row the builder skipped because its vector was still NULL.
Such a row is not in the matrix; its id is at or below the watermark, so the
tail does not read it either; and the moment maintenance fills the embedding in,
the scan returns it and the index does not. That is the withholding this section
opens by ruling out, reached through check_health(fix=True), whose prefetch
exists to fill exactly those rows (bug-278).
So the build names them too. unembedded_ids sits beside excluded_ids in the
header and rides the same exact tail read. The cap covers the two lists
together, because they are bound into one statement, and a corpus that exceeds
it declines to build rather than shipping an index that cannot name its own
holes.
The merge is an ordered merge, not a concatenation. It is tempting to assume the tail is uniformly newer than the index and simply prepend it. Nothing promises that.
The import path carries a restored record's original created_at while ids are
assigned fresh by AUTOINCREMENT, so restoring an old export into a database
that already holds newer rows produces new ids bearing old timestamps. A
production corpus has no such inversion today, which is exactly the kind of fact
that stops being true without anyone noticing.
Both sides are already sorted, so merging them on (created_at DESC, id ASC)
costs nothing over prepending, and does not have the failure mode.
6. Absence, corruption, and dimension mismatch¶
The index is optional in the strongest sense: the scan it replaces is still there, still correct, and still the fallback. Three conditions return to it — the file is missing, the file fails its own integrity check (row count against file length, or fingerprint mismatch), or the query vector's dimension does not match the header's.
A mid-flight model swap leaves a mixed-dimension corpus behind, and that is a fourth condition — one the builder refuses rather than the reader.
The live scan applies its window before it skips foreign-width rows: it
ranks whatever survives inside the newest MAX_MEMORIES. An index holding a
single width cannot reproduce that window while other widths exist, because it
would rank the newest MAX_MEMORIES of its own width — more rows, and more
rows is a different answer even when every one of them scores identically.
So the build declines while the corpus is mixed. That state is transient by construction, and throughout it the scan this index replaces stays correct and merely slower, which is the trade the whole design exists to make safely.
A fallback has to be visible. If the only trace is a log line, an index that has been dead for a week reads as "somehow not faster", which is a failure this project has already made once. The condition is reported where a reader of the system's health will meet it.
7. What this design deliberately leaves out¶
- Approximation of any kind. No quantization, no approximate neighbours. Those buy resident memory, which is a different problem with a different acceptable-degradation question. Mixing them in would cost the exactness that makes this change gateable against the existing suite.
-
Episodes, in the first wave — and then not left out. The episode scan is structurally the same, and the format applies to it unchanged, but episodes were a fifth of the rows, so the first wave indexed memories only and left the next step to a measurement.
The measurement said the unindexed episode scan cost more per query than the indexed memory scan of a table five times its size, so the episode table now has its own index file, served by the same read path. Episodes carry no
sourcecolumn, and the read path applies the episode scan's source rule before it consults the index, so the index is never asked about a column the table lacks. - Repair. The index is a derived artifact: it is not backed up, and it is never fixed. It is deleted and rebuilt. - Re-tagging after a build. Moving a row to another project or channel leaves the index's axis columns stale until the next rebuild. In the loose direction the hydrate covers it; in the strict direction the row is missed until rebuild. Re-tagging is rare, and this is accepted rather than solved — stated here so that the next reader does not have to discover it.
8. How it is gated¶
The equivalence gate already exists. A benchmark harness in this repository
preloads a contiguous matrix, calls the same mat @ q, and compares against the
unmodified search at two levels — the result id sequence with its cosines, and
the full recall path's message ids — over a real corpus. It gains a backend
rather than a replacement.
Two additions, both because the existing gate cannot see them:
- Ties. A corpus of well-separated vectors ranks the same under any tie-break, so the gate must be fed a fixture containing rows of equal similarity. Otherwise it confirms an order it never tested.
- Scoped queries. The existing harness falls back to the original function whenever a channel, project or source filter is present, so every axis-carrying call — which is to say, nearly every real one — has never been through it.
Both the equivalence gate and the containment assertion are confirmed by mutation. An order deliberately broken in one place must turn the gate red, and an index deliberately made over-strict must turn the containment test red, with each mutation killing only its own check.