arborist/tests/test_search_vec.py
russell@unturf.com 06f5a11651
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.)
2026-05-11 13:16:51 -04:00

249 lines
9.3 KiB
Python

"""Tests for the optional sqlite-vec semantic-retrieval backend (#000039).
Skips entirely when the ``[vec]`` extra (sqlite-vec) is not installed.
Uses a deterministic *stub* embedder (a hash → unit-vector map) so the
suite exercises the sqlite-vec plumbing — extension load, ``chunk_vecs``
schema, ingest, KNN query, JOIN back to chunks/documents, Hit shape —
without pulling in the heavy fastembed ONNX model. Real semantic
quality is demonstrated end-to-end on a corpus shard, not unit-tested
(embedding quality isn't a property a unit test can pin).
"""
from __future__ import annotations
import hashlib
import pytest
from arborist.search import VEC_AVAILABLE
pytestmark = pytest.mark.skipif(
not VEC_AVAILABLE, reason="sqlite-vec not installed — pip install 'arborist[vec]'"
)
if VEC_AVAILABLE:
from arborist.search.base import AuditMode
from arborist.search.vec import (
EMBED_DIM,
VEC_BACKEND_VERSION,
VecBackend,
embed_documents,
ensure_chunk_vecs_table,
)
from arborist.store import connect
# --- deterministic stub embedder -------------------------------------
def _stub_vec(text: str) -> list[float]:
"""Map text → a fixed unit vector in R^EMBED_DIM, deterministically.
Expand SHA-256(text) into EMBED_DIM bytes via counter mode, scale to
[-1, 1), then L2-normalize. Same text → same vector (so a query that
equals a chunk's text gets distance 0); different texts → (almost
surely) different vectors.
"""
raw = bytearray()
i = 0
while len(raw) < EMBED_DIM:
raw += hashlib.sha256(text.encode("utf-8") + i.to_bytes(4, "little")).digest()
i += 1
vals = [2.0 * (b / 256.0) - 1.0 for b in raw[:EMBED_DIM]]
norm = sum(v * v for v in vals) ** 0.5 or 1.0
return [v / norm for v in vals]
def _stub_embedder(texts: list[str]) -> list[list[float]]:
return [_stub_vec(t) for t in texts]
# --- fixtures --------------------------------------------------------
@pytest.fixture()
def db(tmp_path):
conn = connect(tmp_path / "vec-test.db")
# Three minimal documents, one chunk each.
docs = [
("root_a" * 8, "uri://a", "Alpha doc", "alpha content about combinations"),
("root_b" * 8, "uri://b", "Beta doc", "beta content about permutations"),
("root_c" * 8, "uri://c", "Gamma doc", "gamma content about pigeonhole"),
]
for root, uri, title, content in docs:
conn.execute(
"INSERT INTO documents("
" document_root, document_uri, source_type, kind, title,"
" chunking_version, canonicalization_version, schema_version, ingest_ts"
") VALUES (?, ?, 'test', 'surface', ?, 'tok-512-v1', 'norm-v1', 'v9.8.0', 0)",
(root, uri, title),
)
conn.execute(
"INSERT INTO chunks(document_root, idx, leaf_hash, content, tier) "
"VALUES (?, 0, ?, ?, 'hot')",
(root, hashlib.sha256(content.encode()).hexdigest(), content),
)
yield conn, docs
conn.close()
# --- tests -----------------------------------------------------------
def test_ensure_table_records_backend_version(db):
conn, _ = db
ensure_chunk_vecs_table(conn)
row = conn.execute(
"SELECT value FROM vec_meta WHERE key='backend_version'"
).fetchone()
assert row[0] == VEC_BACKEND_VERSION
def test_embed_then_search_roundtrips(db):
conn, docs = db
n = embed_documents(conn, embedder=_stub_embedder)
assert n == 3, "all three chunks should embed (none cold)"
backend = VecBackend(conn, embedder=_stub_embedder)
assert backend.populated()
# Query with text == doc B's content → distance 0 → top hit is doc B.
target_content = docs[1][3]
hits = backend.search(target_content, limit=3)
assert hits, "search should return hits"
assert hits[0].document_root == docs[1][0]
assert hits[0].audit_mode is AuditMode.UNGROUNDED
assert hits[0].title == "Beta doc"
assert hits[0].chunk_idx == 0
# Score is the negated distance: exact match → ~0; ordering is
# monotone (best first), so score is non-increasing down the list.
assert hits[0].score >= hits[-1].score
assert hits[0].snippet # non-empty snippet rendered
def test_search_respects_limit(db):
conn, _ = db
embed_documents(conn, embedder=_stub_embedder)
backend = VecBackend(conn, embedder=_stub_embedder)
hits = backend.search("anything", limit=2)
assert len(hits) <= 2
def test_search_empty_query_returns_nothing(db):
conn, _ = db
embed_documents(conn, embedder=_stub_embedder)
backend = VecBackend(conn, embedder=_stub_embedder)
assert backend.search(" ", limit=5) == []
def test_unpopulated_backend_returns_nothing(db):
conn, _ = db
backend = VecBackend(conn, embedder=_stub_embedder)
assert not backend.populated()
assert backend.search("anything", limit=5) == []
def test_embed_limit_caps_count(db):
conn, _ = db
n = embed_documents(conn, embedder=_stub_embedder, limit=2)
assert n == 2
cnt = conn.execute("SELECT COUNT(*) FROM chunk_vecs").fetchone()[0]
assert cnt == 2
def test_embed_is_idempotent(db):
conn, _ = db
embed_documents(conn, embedder=_stub_embedder)
embed_documents(conn, embedder=_stub_embedder) # re-run
cnt = conn.execute("SELECT COUNT(*) FROM chunk_vecs").fetchone()[0]
assert cnt == 3
def test_embed_incremental_only_embeds_new_chunks(db):
"""Default (incremental=True): a second embed pass after ingesting a
new doc embeds only the new chunk — re-running is cheap (#000039)."""
conn, _ = db
first = embed_documents(conn, embedder=_stub_embedder)
assert first == 3
# Simulate a follow-up ingest: one new document + chunk.
new_root = "root_d" * 8
conn.execute(
"INSERT INTO documents("
" document_root, document_uri, source_type, kind, title,"
" chunking_version, canonicalization_version, schema_version, ingest_ts"
") VALUES (?, 'uri://d', 'test', 'surface', 'Delta doc',"
" 'tok-512-v1', 'norm-v1', 'v9.8.0', 0)",
(new_root,),
)
conn.execute(
"INSERT INTO chunks(document_root, idx, leaf_hash, content, tier) "
"VALUES (?, 0, ?, 'delta content about derangements', 'hot')",
(new_root, hashlib.sha256(b"delta").hexdigest()),
)
second = embed_documents(conn, embedder=_stub_embedder) # incremental
assert second == 1, "only the new chunk should embed on the second pass"
assert conn.execute("SELECT COUNT(*) FROM chunk_vecs").fetchone()[0] == 4
# A third pass with nothing new is a no-op.
third = embed_documents(conn, embedder=_stub_embedder)
assert third == 0
def test_embed_rebuild_re_embeds_all(db):
"""rebuild=True: DROP + recreate + full pass — every chunk re-embedded
(the clean VEC_BACKEND_VERSION-bump path)."""
conn, _ = db
embed_documents(conn, embedder=_stub_embedder)
assert conn.execute("SELECT COUNT(*) FROM chunk_vecs").fetchone()[0] == 3
n = embed_documents(conn, embedder=_stub_embedder, rebuild=True)
assert n == 3, "rebuild re-embeds all chunks"
assert conn.execute("SELECT COUNT(*) FROM chunk_vecs").fetchone()[0] == 3
# vec_meta still records the backend version after the recreate.
from arborist.search.vec import VEC_BACKEND_VERSION
row = conn.execute(
"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]