Spaces:
Running
Running
File size: 6,679 Bytes
a1dd5ba | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | """ANN tiers over the embeddings table. Three mechanisms, one contract:
- exact one matmul; correct by definition; right up to ~10^6 vectors
- IVF learned cells (HDBSCAN centroids, embeddings.py) — prune cells,
scan survivors exactly; noise always scanned
- HNSW graph ANN (hnswlib) for sub-ms search past the matmul crossover
- IVF-PQ product quantization: vectors compressed ~48x into subspace
codes; asymmetric-distance scan + EXACT rerank of the top pool
(the SCANN/Faiss recipe: approximate to shortlist, never to
answer)
Artifacts are version-suffixed sidecars under tables/embeddings/_index/,
rebuilt like any derived state, and recorded in the table log. Search picks
the best available tier automatically; every path supports HYBRID
retrieval — time-range and stream predicates pushed into candidate
selection so vector search composes with the store's core dimension, time.
"""
from __future__ import annotations
import json
import numpy as np
def _run_dir(store):
d = store.dir / "tables" / "embeddings" / "_index"
d.mkdir(parents=True, exist_ok=True)
return d
def _emb(store):
from .embeddings import _vec_table
return _vec_table(store)
def build_hnsw(store, M: int = 16, ef_construction: int = 200) -> dict:
"""Hierarchical navigable small world graph over unit vectors
(inner-product space == cosine). O(log n) expected search hops."""
import hnswlib
t, vecs = _emb(store)
v = store.table("embeddings").state().version
ix = hnswlib.Index(space="ip", dim=vecs.shape[1])
ix.init_index(max_elements=len(vecs), M=M,
ef_construction=ef_construction, random_seed=7)
ix.add_items(vecs, np.arange(len(vecs)))
path = _run_dir(store) / f"hnsw.v{v}.bin"
ix.save_index(str(path))
(path.with_suffix(".json")).write_text(json.dumps(
{"dim": int(vecs.shape[1]), "n": len(vecs), "M": M}))
# An ANN index is a derived sidecar keyed to the embeddings DATA version,
# not a new data version — building it must NOT advance the log (doing so
# would invalidate the artifact we just named .v{v} against the bumped
# current version). The .v{v} filename IS the binding.
return {"n": len(vecs), "bytes": path.stat().st_size, "version": v}
def load_hnsw(store):
# Look for the artifact BEFORE importing. hnswlib is an optional
# accelerator; when it is absent the planner must fall back to the exact
# scan, not raise ModuleNotFoundError out of the middle of a query and
# take down search entirely.
cands = sorted(_run_dir(store).glob("hnsw.v*.bin"), reverse=True)
if not cands:
return None
try:
import hnswlib
except ImportError:
return None
v = store.table("embeddings").state().version
for cand in cands:
if int(cand.stem.split(".v")[-1]) != v:
continue # stale: embeddings changed since this was built
meta = json.loads(cand.with_suffix(".json").read_text())
ix = hnswlib.Index(space="ip", dim=meta["dim"])
ix.load_index(str(cand), max_elements=meta["n"])
return ix
return None
def build_ivfpq(store, nlist: int | None = None, m: int = 8,
nbits: int = 8) -> dict:
"""Coarse k-means cells + per-subspace codebooks. A d=1152 float32
vector becomes `m` uint8 codes (m=8 → 576x… realistically 4608B → 8B =
576x raw, ~48x vs the parquet-compressed vectors). Scans read codes, not
vectors; the exact rerank reads only the shortlist's true vectors."""
from sklearn.cluster import KMeans
t, vecs = _emb(store)
n, d = vecs.shape
v = store.table("embeddings").state().version
nlist = nlist or max(1, int(np.sqrt(n)))
coarse = KMeans(n_clusters=min(nlist, n), n_init=4,
random_state=0).fit(vecs)
resid = vecs - coarse.cluster_centers_[coarse.labels_]
assert d % m == 0, f"dim {d} not divisible by m={m}"
sub = d // m
codebooks = np.zeros((m, 2 ** nbits, sub), np.float32)
codes = np.zeros((n, m), np.uint8)
for j in range(m):
block = resid[:, j * sub:(j + 1) * sub]
km = KMeans(n_clusters=min(2 ** nbits, n), n_init=2,
random_state=j).fit(block)
k = km.cluster_centers_.shape[0]
codebooks[j, :k] = km.cluster_centers_
codes[:, j] = km.labels_.astype(np.uint8)
path = _run_dir(store) / f"ivfpq.v{v}.npz"
np.savez_compressed(path, centers=coarse.cluster_centers_,
labels=coarse.labels_.astype(np.int32),
codebooks=codebooks, codes=codes)
# derived sidecar keyed to the embeddings data version; no log bump (see
# build_hnsw) — the .v{v} filename binds it to the current vectors.
return {"n": n, "nlist": int(nlist), "m": m,
"bytes": path.stat().st_size,
"code_bytes_per_vec": m, "version": v}
def load_ivfpq(store):
v = store.table("embeddings").state().version
for cand in sorted(_run_dir(store).glob("ivfpq.v*.npz"), reverse=True):
if int(cand.stem.split(".v")[-1]) == v:
return np.load(cand)
return None
def search_ivfpq(store, q: np.ndarray, k: int, nprobe: int = 8,
rerank: int = 4, mask: np.ndarray | None = None):
"""ADC scan: distance ≈ coarse-center dot + sum of per-subspace code
dots (table lookups, no vector reads), then exact rerank of the top
`rerank*k` shortlist. Approximation shortlists; it never answers."""
art = load_ivfpq(store)
if art is None:
return None
t, vecs = _emb(store)
centers, labels = art["centers"], art["labels"]
codebooks, codes = art["codebooks"], art["codes"]
m, ksub, sub = codebooks.shape
probe = np.argsort(centers @ q)[::-1][:nprobe]
cand = np.isin(labels, probe)
if mask is not None:
cand &= mask
idx = np.where(cand)[0]
if len(idx) == 0:
return [], {"scanned": 0, "total": len(vecs)}
# lookup tables: q-subvector · every codeword, per subspace
lut = np.stack([codebooks[j] @ q[j * sub:(j + 1) * sub]
for j in range(m)]) # [m, ksub]
approx = centers[labels[idx]] @ q + \
lut[np.arange(m)[None, :], codes[idx]].sum(axis=1)
short = idx[np.argsort(approx)[::-1][:max(k * rerank, k)]]
exact = vecs[short] @ q
order = np.argsort(exact)[::-1][:k]
return ([(int(short[i]), float(exact[i])) for i in order],
{"scanned": int(len(idx)), "total": len(vecs),
"code_bytes": int(len(idx) * m),
"reranked": int(len(short))})
|