0044 compression belongs to the caller - CyrilB1531/lodestar GitHub Wiki
0044 โ Compression belongs to the caller, not to the artifact format
Status: accepted ยท Date: 2026-08-20
Context
#378 is what this records.
ADR 0011 chose versioned JSON written with System.Text.Json, with
float blocks in base64. Base64 spends eight bits to carry six, so an artifact lands at about 1.33ร
the raw block it holds โ 8 231 006 bytes where the vectors are 6 144 000. #378 opened on the
observation that deflate takes that expansion back almost exactly, and asked whether the format
should carry it: how a reader would recognise a compressed artifact, whether it would be opt-in,
and what ArtifactSaveOptions would cost to invent.
Two of those three questions dissolved under measurement, and the third answered the issue.
Load already reads a compressed artifact, with no change at all. A decompressing stream is
neither seekable nor of known length, so it takes the growable path in JsonArtifact.ReadAllBytes
โ the same path a network stream takes. Verified round-trip from memory and from a .json.gz
file: same count, same dimension, same search results. So there is no format to recognise and no
old artifact to keep compatible, because nothing about the bytes on disk changes.
And the price is not affordable as a default. Measured on a synthetic 4 000 ร 384 index through
the real Save and Load, Intel i7-4770S, .NET 10.0.10, warmed, median of 7, five modes
interleaved in one window (the performance guide
has the full table):
| ร size | ร save | ร load | |
|---|---|---|---|
gzip Fastest |
0.760 | 26.67ร | 7.19ร |
gzip Optimal |
0.747 | 37.62ร | 5.92ร |
brotli Fastest |
0.738 | 3.68ร | 5.19ร |
#323,
#324,
#336 and
#377 are four lots spent taking copies and
zeroing out of this path. The cheapest compression available multiplies the load by 5.19 and the
save by 3.68, to buy 26% of a disk โ and the price grows with the artifact rather than staying
put: the benchmark corpus's 20 MB index pays 76.8x the save and 14.8x the load at Optimal,
against 37.62x and 5.92x on the 8 MB one. The indexes big enough for the size to matter are the
ones where compressing costs the most.
Decision
The library does not compress an artifact, and does not offer an option to. A caller who wants
compression wraps the stream, on both sides. index.Save(new GZipStream(file, level)) writing,
and EmbeddingIndex.Load reading from a
GZipStream(file, CompressionMode.Decompress).
What follows from that:
- No
ArtifactSaveOptions. It was #378's heaviest stated cost, and the opt-in it would have carried already exists as a constructor the caller writes. A flag would add public surface, a reference page and a packaging-gate reference to express what one wrapper already expresses. - No magic-byte sniffing on load.
Loadrefusing raw gzip bytes is the right answer, and it already says which byte was wrong:'0x1F' is an invalid start of a value. Sniffing would makeLoadsilently accept a shape the caller did not mean to hand it, to save that caller one wrapper they wrote deliberately on the other side. - No change to ADR 0011, and none to what a written artifact looks like. #100's byte-for-byte compatibility is untouched because nothing was touched.
- gzip is the documented recipe, not brotli, despite brotli being smaller on every level and
seven times cheaper to write.
BrotliStreamdoes not exist onnetstandard2.0, and a recipe that works on one of two target frameworks is not one this project publishes. The guide names brotli as the .NET-10-only alternative for a caller who is not on the older target.
Consequences
The capability was accidental โ true by construction, decided by nobody, tested by nothing. Two
things make it a contract rather than a coincidence:
tests/Lodestar.Embeddings.Tests/Persistence/CompressedArtifactTests.cs pins the round trip, the
asynchronous read, the diagnosable failure on undecompressed bytes, and that MaxTotalBytes is
enforced on what the artifact expands to rather than on what it occupies. compare-persistence
carries embedding_index_save_gzip and embedding_index_load_gzip beside the plain rows, against
numpy's savez_compressed, so the table above is re-measured on every nightly rather than
remembered from this one.
The loser is worth naming: a format-level Compression = Deflate would have made small artifacts
the default and put this project's own numbers back where they were two releases ago. The size was
never in doubt. It was priced, and it is the caller's call to make.