arborist/tests/test_search_vec.py
russell@unturf.com 38d9116c88
ticket #000039 Phase 1: sqlite-vec semantic retrieval backend
Implements the optional vec backend from the #000039 doc, with the
"obvious" v1 tuning, and demonstrates it on a real corpus shard.

arborist/search/vec.py (new):
- VecBackend(SearchBackend) — ANN over chunk_vecs, UNGROUNDED hits
  (same as FTS5; vec changes recall, never warrant — embeddings are
  soft signal, never in the proof path).
- chunk_vecs vec0 virtual table + vec_meta — sibling tables, additive,
  don't touch chunks/documents/the audit chain.
- embed_documents() — batched ingest; delete-then-insert per chunk_id
  (vec0 doesn't honor INSERT-OR-REPLACE — re-inserting an existing PK
  is a hard UNIQUE error), so re-runs are idempotent and content-
  changed → re-embed works. Skips cold-evicted chunks (content NULL).
- Pluggable Embedder callable; default = fastembed bge-small-en-v1.5
  (~130 MB ONNX, downloads on first use). load_vec_extension(conn)
  toggles enable_load_extension + sqlite_vec.load.
- v1 hyperparams (VEC_BACKEND_VERSION = vec-v1-bge-small-en-v1.5-
  384float32-cosine-flat): model bge-small-en-v1.5, dim 384, quant
  float32 (int8/binary = the production storage knob per §3.1, not
  wired in v1), metric cosine (bge outputs L2-normalized, so cosine
  ranking ≡ L2 ranking), ANN flat (vec0 default), top_k 20. These
  five fold into governance_policy_hash in a later phase (§6).

CLI (arborist/cli.py):
-  — populate chunk_vecs
  for --db; prints progress + timing.
-  — semantic ANN search (errors with
  an install/embed hint if [vec] missing or chunk_vecs empty).
- Both surfaced only when sqlite_vec imports (mirrors the [html] /
  selectolax pattern).

pyproject.toml: [vec] optional extra (sqlite-vec>=0.1.9, fastembed>=0.4);
added to [dev]. Note: sentence-transformers is the heavier "official"
embedder path §5 names; fastembed is the lightweight ONNX one.

tests/test_search_vec.py (7 tests, skip-if-no-[vec]): deterministic
stub embedder (hash → unit vector) so the suite exercises the
sqlite-vec plumbing — ext load, schema, ingest, KNN, JOIN, Hit shape,
limit, idempotent re-embed, --limit cap, empty/unpopulated — without
the heavy fastembed model. Semantic quality is demonstrated on a
shard, not unit-tested.

Demonstrated on ~/.arborist/shards/crawl_appliedcombinatorics_org.db:
168 chunks embedded in ~37 s (mostly model load); semantic queries
return topically-correct hits — "how many ways to choose k things
from n" → top hit "AC Combinations", "binomial coefficient counting"
→ "AC Introduction" (integer-solution counting) + "AC Combinatorial
Proofs". None of the query tokens need stem-match the chunk — the
semantic-allusion-gap closure the ticket promised. chain-check on
that shard reports 0 after embedding (chunk_vecs is a sibling table).

#000039 status flipped to "in progress · Phase 1 landed"; Phase 2
(RRF hybrid fusion in query.py) gated on a ≥5pp recall-lift
measurement with no STRICT-rate regression (§8).

(Unrelated: tests/test_weights.py::test_as_dict_returns_all_eleven_fields
fails in the working tree — that's a parallel-clone in-flight change
to arborist/substrate/weights.py + its test, not touched here.)
2026-05-11 08:23:29 -04:00

159 lines
5.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; INSERT OR REPLACE
cnt = conn.execute("SELECT COUNT(*) FROM chunk_vecs").fetchone()[0]
assert cnt == 3