ticket #000039: --quant {float32,int8} + int8 head-to-head

Wire vector quantization (the §3.1 production knob): arborist embed
--quant int8 [--rebuild]. The chunk_vecs vec0 column becomes int8[384]
vs float[384] per quant; the quant folds into VEC_BACKEND_VERSION
(...-384int8-... / ...-384float32-...) and vec_meta records it per
shard. Switching quant on an existing chunk_vecs requires --rebuild
(the vec0 element type can't be altered in place — embed_documents
raises ValueError telling you to --rebuild).

int8 serialization: scale each bge component by 127 (theoretical
[-1,1] range), clamp to [-127,127], round, serialize_int8. The same
scaling on the query vector → distances comparable; cosine is
scale-invariant so the uniform x127 cancels in the ranking.

sqlite-vec v0.1.9 quirk worked around: a bare blob inserted into a
vec0 column is interpreted as float32 regardless of the column's
declared type — int8 vectors MUST be wrapped in vec_int8(...). So
the INSERT and the MATCH now wrap the blob in vec_f32(?) (float32)
or vec_int8(?) (int8) — constructor name from a fixed dict, no
injection surface. (Discovered the hard way: a bare int8 blob into
an int8[384] column → "expected int8, but a float32 vector was
provided".)

VecBackend reads the quant from the existing chunk_vecs schema (or
defaults to float32) so search uses the matching wrapper. New module
exports: QUANTS, EMBED_QUANT, vec_backend_version(quant), existing_quant.

int8 head-to-head on crawl_appliedcombinatorics_org.db (168 chunks):
- storage: float32 1,597,440 B -> int8 417,792 B = 3.8x smaller
  (~4x at corpus scale where the 1024-vector blocks fill; the
  ~28 KB of vec0 metadata doesn't quarter, hence 3.8 not 4.0).
- recall vs the float32 baseline:
    Q "how many ways to choose k things from n":
      identical top-5 (Combinations, Permutations, Exercises,
      Derangements, Graph Coloring).
    Q "pigeonhole principle counting":
      identical top-2 (Graph Coloring, Exercises); ranks 3-4 swap
      Derangements <-> Permutations at Δdistance 0.002 — sub-noise.
- embed speed unchanged (~3.9 chunks/s — model-load-dominated).
Conclusion: int8 is the obvious production config (§3.1's +6%-tax
recommendation confirmed empirically). v1 default stays float32 for
max fidelity; flipping the default to int8 is a fox call.

CLI (arborist/cli.py): arborist embed --quant {float32,int8}; output
JSON gains "quant"; embed_documents ValueError → exit 2 with the
"--rebuild" hint.

tests/test_search_vec.py (9 -> 16): test_int8_quant_roundtrips
(int8[384] schema, vec_meta version, search round-trip, quant
inferred by VecBackend), test_quant_mismatch_requires_rebuild,
test_invalid_quant_rejected.

#000039 status updated. Full suite: 2343 passed, 28 skipped.

(Unrelated parallel-clone work in the tree — Makefile, arborist/qa/
verify.py, bench/fixtures/5f/*, tests/test_bench_batteries.py,
tests/test_verify.py — is #000046's hard-fixture tier, not touched.)
This commit is contained in:
russell@unturf.com 2026-05-11 13:16:51 -04:00
parent 7a64939e2b
commit 06f5a11651
No known key found for this signature in database
4 changed files with 202 additions and 41 deletions

View file

@ -203,3 +203,47 @@ def test_embed_rebuild_re_embeds_all(db):
"SELECT value FROM vec_meta WHERE key='backend_version'"
).fetchone()
assert row[0] == VEC_BACKEND_VERSION
def test_int8_quant_roundtrips(db):
"""quant='int8': table is int8[384], vec_meta records the int8 version,
search round-trips (a query equal to a chunk's content → that chunk on
top, since same float vec → same int8 vec → distance ~0)."""
from arborist.search.vec import existing_quant, vec_backend_version
conn, docs = db
n = embed_documents(conn, embedder=_stub_embedder, quant="int8", rebuild=True)
assert n == 3
assert existing_quant(conn) == "int8"
sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE name='chunk_vecs'"
).fetchone()[0]
assert "int8[384]" in sql
assert conn.execute(
"SELECT value FROM vec_meta WHERE key='backend_version'"
).fetchone()[0] == vec_backend_version("int8")
backend = VecBackend(conn, embedder=_stub_embedder) # quant inferred from table
assert backend.quant == "int8"
hits = backend.search(docs[2][3], limit=3) # query == doc C's content
assert hits and hits[0].document_root == docs[2][0]
assert hits[0].audit_mode is AuditMode.UNGROUNDED
def test_quant_mismatch_requires_rebuild(db):
"""Switching quant on an existing chunk_vecs without --rebuild errors —
the vec0 column element type can't be altered in place."""
conn, _ = db
embed_documents(conn, embedder=_stub_embedder, quant="float32") # default
with pytest.raises(ValueError, match="rebuild"):
embed_documents(conn, embedder=_stub_embedder, quant="int8") # no rebuild
# ...but with rebuild it switches cleanly.
embed_documents(conn, embedder=_stub_embedder, quant="int8", rebuild=True)
from arborist.search.vec import existing_quant
assert existing_quant(conn) == "int8"
@pytest.mark.parametrize("bad", ["fp16", "binary", "int4", "", None])
def test_invalid_quant_rejected(db, bad):
conn, _ = db
with pytest.raises(ValueError, match="quant"):
embed_documents(conn, embedder=_stub_embedder, quant=bad) # type: ignore[arg-type]