Spaces:
Running
Running
| """Semantic layer over Parquet: embeddings and centroids are tables like | |
| everything else — versioned by the log, readable by DuckDB, no sidecar | |
| binary formats. Vectors are fixed_size_list<float32>[d] columns. | |
| Retrieval is two-stage (learned-cell IVF): rank cluster centroids, scan the | |
| top nprobe cells exactly in full space; HDBSCAN noise is always scanned so | |
| the prune can cost recall nothing. All ranking is one numpy matmul — exact, | |
| simple, and fast far past 100k windows.""" | |
| from __future__ import annotations | |
| import time | |
| import numpy as np | |
| import pyarrow as pa | |
| import pyarrow.compute as pc | |
| import pyarrow.parquet as pq | |
| _MODEL_CACHE = {} | |
| DEFAULT_MODEL = "mlx-community/siglip-so400m-patch14-384" | |
| # Ingest cost is ALL model, not storage: measured on an M-series machine, | |
| # byte-range decode runs at 2.7 ms/frame while the 384px tower runs at | |
| # 90.3 ms/frame — 97% of ingest is the encoder. So the encoder is a choice, | |
| # not a constant. | |
| # | |
| # quality siglip-so400m-patch14-384 90.3 ms/frame 1152-d (default) | |
| # fast siglip-so400m-patch14-224 27.7 ms/frame 1152-d 3.3x faster | |
| # | |
| # `fast` is the SAME model and the same output space, fed 224px instead of | |
| # 384px, so it is 256 patches per image instead of 729. Vectors from the two | |
| # are NOT interchangeable — an index must be built and queried with one of | |
| # them, which is why the model id is recorded in the table's metadata and the | |
| # query path reads it back. | |
| MODELS = { | |
| "quality": "mlx-community/siglip-so400m-patch14-384", | |
| "fast": "mlx-community/siglip-so400m-patch14-224", | |
| } | |
| def resolve_model(name): | |
| """Accept a preset name ('fast'/'quality') or a raw HF model id.""" | |
| return MODELS.get(name, name) if name else DEFAULT_MODEL | |
| # PORTABILITY: the mlx build of siglip-so400m is a straight conversion | |
| # of the google checkpoint, so the SAME weights run through | |
| # transformers on any machine and land in the same embedding space. | |
| # Backend is auto-detected (mlx where it imports, torch elsewhere) and | |
| # can be forced with ELIDEDB_TEXT_BACKEND=torch for parity testing. | |
| _HF_EQUIV = { | |
| "mlx-community/siglip-so400m-patch14-384": | |
| "google/siglip-so400m-patch14-384", | |
| "mlx-community/siglip-so400m-patch14-224": | |
| "google/siglip-so400m-patch14-224", | |
| } | |
| def _backend(): | |
| if "backend" not in _MODEL_CACHE: | |
| import os | |
| forced = os.environ.get("ELIDEDB_TEXT_BACKEND", "").strip() | |
| if forced: | |
| _MODEL_CACHE["backend"] = forced | |
| else: | |
| try: | |
| import mlx_embeddings # noqa: F401 | |
| _MODEL_CACHE["backend"] = "mlx" | |
| except ImportError: | |
| _MODEL_CACHE["backend"] = "torch" | |
| return _MODEL_CACHE["backend"] | |
| def _load_model(model_id): | |
| if model_id not in _MODEL_CACHE: | |
| from mlx_embeddings.utils import load | |
| _MODEL_CACHE[model_id] = load(model_id) | |
| return _MODEL_CACHE[model_id] | |
| def _load_torch(model_id): | |
| key = ("torch", model_id) | |
| if key not in _MODEL_CACHE: | |
| from transformers import AutoModel, AutoProcessor | |
| from .device import pick, strip_vision | |
| dev, dtype = pick() | |
| hf = _HF_EQUIV.get(model_id, model_id) | |
| m = AutoModel.from_pretrained( | |
| hf, dtype=dtype, low_cpu_mem_usage=True).to(dev).eval() | |
| m = strip_vision(m, "vision_model") | |
| _MODEL_CACHE[key] = (m, AutoProcessor.from_pretrained(hf), dev) | |
| return _MODEL_CACHE[key] | |
| def _embed_images(images, model_id): | |
| if _backend() == "torch": | |
| import torch | |
| model, processor, dev = _load_torch(resolve_model(model_id)) | |
| iv = processor(images=images, return_tensors="pt") | |
| with torch.no_grad(): | |
| out = model.get_image_features( | |
| pixel_values=iv["pixel_values"].to( | |
| dev, model.dtype)) | |
| out = out.float().cpu().numpy().astype(np.float32) | |
| return out / np.linalg.norm(out, axis=1, keepdims=True) | |
| import mlx.core as mx | |
| model, processor = _load_model(resolve_model(model_id)) | |
| iv = processor(images=images, return_tensors="np") | |
| out = np.array(model.get_image_features(mx.array(iv["pixel_values"])), | |
| dtype=np.float32) | |
| return out / np.linalg.norm(out, axis=1, keepdims=True) | |
| def embed_text(text, model_id=DEFAULT_MODEL): | |
| if _backend() == "torch": | |
| import torch | |
| model, processor, dev = _load_torch(resolve_model(model_id)) | |
| try: | |
| max_len = int( | |
| model.config.text_config.max_position_embeddings) | |
| except AttributeError: | |
| max_len = 64 | |
| ti = processor(text=[text], padding="max_length", | |
| max_length=max_len, truncation=True, | |
| return_tensors="pt") | |
| with torch.no_grad(): | |
| v = model.get_text_features( | |
| input_ids=ti["input_ids"].to(dev)) | |
| v = v[0].float().cpu().numpy().astype(np.float32) | |
| return v / np.linalg.norm(v) | |
| import mlx.core as mx | |
| model, processor = _load_model(resolve_model(model_id)) | |
| # Each checkpoint has its own text context (so400m-384: 64 tokens, | |
| # so400m-224: 16). Ask the model rather than assuming. | |
| try: | |
| max_len = int(model.config.text_config.max_position_embeddings) | |
| except AttributeError: | |
| max_len = 64 | |
| ti = processor(text=[text], padding="max_length", max_length=max_len, | |
| truncation=True, return_tensors="np") | |
| v = np.array(model.get_text_features(mx.array(ti["input_ids"])), | |
| dtype=np.float32)[0] | |
| return v / np.linalg.norm(v) | |
| _MAT_CACHE: dict = {} | |
| def _vec_table(store, name="embeddings", version=None, column="vector"): | |
| """Vector table + MEMORY-MAPPED matrix, cached per log version. | |
| Two generations of this function materialized the matrix in RAM. At | |
| pilot scale that broke: bridge-full's 180k x 1152 embeddings are 0.83 GB | |
| of data but cost +4.3 GB peak RSS to load (parquet decode + Arrow | |
| chunks + combine copy + the cache holding table AND matrix), and the | |
| desk warms every store — 7 GB before the first query. The fix is the | |
| store's own law applied to vectors: mmap for reads. The matrix is | |
| materialized ONCE per (table, version) into a raw .npy sidecar, then | |
| every process maps it — RSS is only the pages a query touches, startup | |
| costs a file open, and the OS page cache decides residency. | |
| Row alignment: the sidecar is written from the same scan() that serves | |
| the meta columns; scan's ts sort is stable over a deterministic file | |
| order, so a later projected scan yields the identical permutation. A | |
| length mismatch (e.g. sidecar from a dead version) forces a rebuild. | |
| The parquet remains the source of truth — a sidecar is disposable. | |
| """ | |
| import os | |
| import uuid as _uuid | |
| ver = store.table(name).state().version if version is None else version | |
| key = (str(store.dir), name, column, ver) | |
| if key in _MAT_CACHE: | |
| return _MAT_CACHE[key] | |
| tab = store.table(name) | |
| cache_dir = tab.dir / "_cache" | |
| npy = cache_dir / f"{column}-v{ver}.npy" | |
| t = vecs = None | |
| if npy.exists(): | |
| st = tab.state(version) | |
| if st.files: | |
| names = pq.ParquetFile( | |
| tab.dir / st.files[0].path).schema_arrow.names | |
| meta_cols = [c for c in names if c != column] | |
| t = tab.scan(version=version, columns=meta_cols) | |
| vecs = np.load(npy, mmap_mode="r") | |
| if len(vecs) != len(t): | |
| t = vecs = None # stale sidecar: rebuild | |
| if vecs is None: | |
| t_full = tab.scan(version=version) | |
| if len(t_full) == 0: | |
| extra = "" | |
| try: | |
| if store.table("frame_vectors").state().files: | |
| extra = (" Per-frame vectors already exist, so this " | |
| "costs a numpy mean, not a GPU pass.") | |
| except Exception: | |
| pass | |
| raise RuntimeError( | |
| f"store '{store.name}' has no '{name}' table — run " | |
| f"store.embed_windows() first.{extra}") | |
| col = t_full.column(column) | |
| if isinstance(col, pa.ChunkedArray): | |
| col = col.combine_chunks() | |
| try: # FixedSizeList: flat buffer reshape | |
| mat = col.values.to_numpy(zero_copy_only=False) \ | |
| .astype(np.float32, copy=False).reshape(len(t_full), -1) | |
| except Exception: # any other layout: the slow road | |
| mat = np.stack([np.asarray(v, dtype=np.float32) | |
| for v in col.to_pylist()]) | |
| cache_dir.mkdir(parents=True, exist_ok=True) | |
| tmp = cache_dir / f".{_uuid.uuid4().hex[:8]}.npy" | |
| np.save(tmp, np.ascontiguousarray(mat)) | |
| os.replace(tmp, npy) # atomic: readers see whole files | |
| t = t_full.drop_columns([column]) # meta only — no double storage | |
| del t_full, mat, col | |
| vecs = np.load(npy, mmap_mode="r") | |
| if len(_MAT_CACHE) > 8: | |
| _MAT_CACHE.clear() | |
| _MAT_CACHE[key] = (t, vecs) | |
| return t, vecs | |
| def pool_windows(store, window_s=2.0, stride_s=None, table="frame_vectors"): | |
| """Build the `embeddings` table by POOLING existing per-frame vectors. | |
| A window embedding is the mean of its frame embeddings. If `frame_vectors` | |
| already exists there is nothing to compute with a model: decoding every | |
| frame again and re-running SigLIP to reach the same answer is pure waste — | |
| on the Bridge store that was 25 minutes of GPU to reproduce a number a | |
| numpy mean gives in under a second. | |
| This is the ordinary database move: two indexes over one scan, not two | |
| scans. | |
| """ | |
| fv = store.table(table).scan() | |
| if len(fv) == 0: | |
| raise RuntimeError(f"'{table}' is empty — run embed_frames() first") | |
| win = int(window_s * 1e9) | |
| stride = int((stride_s or window_s) * 1e9) | |
| t_start = time.time() | |
| rows = {"ts": [], "t1": [], "stream": [], "vector": []} | |
| for s in sorted(set(fv.column("stream").to_pylist())): | |
| sub = fv.filter(pc.equal(fv.column("stream"), s)) | |
| ts = sub.column("ts").to_numpy() | |
| order = np.argsort(ts) | |
| ts = ts[order] | |
| # zero-copy reshape, NOT to_pylist(): at 1.6M frames the Python-list | |
| # road needs ~50 GB; the FixedSizeList buffer is already the matrix | |
| col = sub.column("vector") | |
| if isinstance(col, pa.ChunkedArray): | |
| col = col.combine_chunks() | |
| try: | |
| vecs = col.values.to_numpy(zero_copy_only=False) \ | |
| .astype(np.float32, copy=False).reshape(len(sub), -1)[order] | |
| except Exception: | |
| vecs = np.asarray(col.to_pylist(), dtype=np.float32)[order] | |
| t = int(ts[0]) | |
| while t <= int(ts[-1]): | |
| lo, hi = np.searchsorted(ts, [t, t + win]) | |
| if hi > lo: | |
| v = vecs[lo:hi].mean(axis=0) | |
| v /= np.linalg.norm(v) + 1e-8 | |
| rows["ts"].append(t) | |
| rows["t1"].append(min(t + win - 1, int(ts[-1]))) | |
| rows["stream"].append(s) | |
| rows["vector"].append(v) | |
| t += stride | |
| dim = len(rows["vector"][0]) | |
| # FixedSizeListArray straight from the flat float32 buffer. The | |
| # tolist() road materialises n*dim PYTHON floats — at 1.8M frames / | |
| # 180k windows that was tens of GB and the process died by jetsam | |
| # (exit 137) on the very last stage of a 100 h load. | |
| flat = np.ascontiguousarray( | |
| np.stack(rows["vector"]).astype(np.float32)).reshape(-1) | |
| vec_arr = pa.FixedSizeListArray.from_arrays(pa.array(flat), dim) | |
| tbl = pa.table({ | |
| "ts": pa.array(rows["ts"], pa.int64()), | |
| "t1": pa.array(rows["t1"], pa.int64()), | |
| "stream": pa.array(rows["stream"]), | |
| "vector": vec_arr, | |
| }) | |
| st = store.table("embeddings").state() | |
| # `model` must be the id of the model that defines the SPACE — the query | |
| # path loads it as the text tower. Student-produced vectors live in the | |
| # TEACHER's space, so when the source was written by an engine (model | |
| # "fdnnv"), the space id is its `teacher` field. Writing the engine name | |
| # here sent "fdnnv" to the HF loader as a repo id. | |
| src = store.table(table).state().meta or {} | |
| src_model = src.get("model", DEFAULT_MODEL) | |
| if src_model in (None, "fdnnv"): | |
| src_model = src.get("teacher", DEFAULT_MODEL) | |
| meta = {"model": src_model, "built_by": "pooled from frame_vectors", | |
| "dim": dim, "window_s": window_s, "source_table": table, | |
| "seconds": round(time.time() - t_start, 2)} | |
| if st.files: | |
| import uuid as _uuid | |
| from .log import FileEntry | |
| from .store import write_parquet | |
| fn = f"part-{_uuid.uuid4().hex[:12]}.parquet" | |
| p = store.dir / "tables" / "embeddings" / fn | |
| write_parquet(tbl, p) | |
| tsv = tbl.column("ts").to_numpy() | |
| version = store.table("embeddings").log.commit( | |
| op="replace", kind="embeddings", schema=str(tbl.schema), | |
| add=[FileEntry(fn, len(tbl), p.stat().st_size, | |
| int(tsv.min()), int(tsv.max()))], | |
| remove=[f.path for f in st.files], meta=meta) | |
| else: | |
| version = store.table("embeddings").append(tbl, kind="embeddings", | |
| meta=meta) | |
| return {"windows": len(tbl), "dim": dim, "version": version, | |
| "seconds": meta["seconds"], "source": table} | |
| def embed_windows(store, frame_table="frames", window_s=2.0, | |
| frames_per_window=2, model=None, batch=16, stride_s=None, | |
| incremental=True, reuse_frame_vectors=True): | |
| """Tumbling windows over every video stream → mean-pooled SigLIP vectors | |
| → one commit to the `embeddings` table. Frames come through the same | |
| byte-range path queries use. | |
| If per-frame vectors already exist, they are pooled instead of re-running | |
| the model (see `pool_windows`) — same result, no GPU. | |
| """ | |
| if reuse_frame_vectors: | |
| try: | |
| if store.table("frame_vectors").state().files: | |
| return pool_windows(store, window_s, stride_s) | |
| except Exception: | |
| pass | |
| from PIL import Image # noqa: F401 (decode happens in FrameSet) | |
| model = model or DEFAULT_MODEL | |
| tab = store.table(frame_table) | |
| st = tab.state() | |
| win_ns = int(window_s * 1e9) | |
| stride_ns = int((stride_s or window_s) * 1e9) | |
| frames = tab.scan() | |
| streams = sorted(set(frames.column("stream").to_pylist())) | |
| # Incremental: only embed windows past what the embeddings table already | |
| # covers per stream — adding a new day of footage costs a new day of | |
| # embedding, not a re-run of history. | |
| done_until = {} | |
| if incremental: | |
| try: | |
| prev = store.table("embeddings").scan() | |
| if len(prev): | |
| s_arr = prev.column("stream").to_pylist() | |
| t1_arr = prev.column("t1").to_pylist() | |
| for s_, e_ in zip(s_arr, t1_arr): | |
| done_until[s_] = max(done_until.get(s_, 0), e_) | |
| except Exception: | |
| pass | |
| jobs = [] # (stream, t0, t1) | |
| for s in streams: | |
| rows = frames.filter(pc.equal(frames.column("stream"), s)) | |
| ts = rows.column("ts").to_numpy() | |
| t = (int(ts[0]) // win_ns) * win_ns | |
| while t <= ts[-1]: | |
| lo, hi = np.searchsorted(ts, [t, t + win_ns]) | |
| if hi > lo and t >= done_until.get(s, -1): | |
| jobs.append((s, max(t, int(ts[0])), | |
| min(t + win_ns - 1, int(ts[-1])))) | |
| t += stride_ns | |
| from .video import FrameSet | |
| t_start = time.time() | |
| if not jobs: | |
| return {"windows": 0, "dim": None, "version": None, "seconds": 0.0, | |
| "note": "nothing new to embed (incremental)"} | |
| recs = {"ts": [], "t1": [], "stream": [], "vector": []} | |
| imgs, owners = [], [] | |
| def flush(): | |
| nonlocal imgs, owners | |
| if not imgs: | |
| return | |
| vecs = _embed_images(imgs, model) | |
| for (key, v) in zip(owners, vecs): | |
| pooled.setdefault(key, []).append(v) | |
| imgs, owners = [], [] | |
| pooled = {} | |
| for (s, t0, t1) in jobs: | |
| fs = FrameSet(store, frame_table, | |
| frames.filter(pc.and_( | |
| pc.equal(frames.column("stream"), s), | |
| pc.and_(pc.greater_equal(frames.column("ts"), t0), | |
| pc.less_equal(frames.column("ts"), t1))))) | |
| n = len(fs) | |
| picks = np.linspace(0, n - 1, min(frames_per_window, n)).round().astype(int) | |
| decoded = fs.decode(width=512) | |
| for p in picks: | |
| if p < len(decoded): | |
| from PIL import Image as PILImage | |
| imgs.append(PILImage.fromarray(decoded[p][1])) | |
| owners.append((s, t0, t1)) | |
| if len(imgs) >= batch: | |
| flush() | |
| flush() | |
| for (s, t0, t1), vs in pooled.items(): | |
| v = np.mean(vs, axis=0) | |
| v /= np.linalg.norm(v) | |
| recs["stream"].append(s) | |
| recs["ts"].append(t0) | |
| recs["t1"].append(t1) | |
| recs["vector"].append(v) | |
| dim = len(recs["vector"][0]) | |
| t = pa.table({ | |
| "ts": pa.array(recs["ts"], pa.int64()), | |
| "t1": pa.array(recs["t1"], pa.int64()), | |
| "stream": pa.array(recs["stream"]), | |
| "vector": pa.array([v.tolist() for v in recs["vector"]], | |
| pa.list_(pa.float32(), dim)), | |
| }) | |
| version = store.table("embeddings").append( | |
| t, kind="embeddings", | |
| meta={"model": model, "dim": dim, "window_s": window_s, | |
| "source_table": frame_table, | |
| "embedded_in_s": round(time.time() - t_start, 1)}) | |
| return {"windows": len(t), "dim": dim, "version": version, | |
| "seconds": round(time.time() - t_start, 1)} | |
| def cluster(store, pca_dims=50, min_cluster_size=8): | |
| """PCA → HDBSCAN over the embeddings table → cluster ids written back as | |
| a new embeddings version + a `centroids` table (full-space, normalized: | |
| the coarse stage must rank in the space the fine stage scores in).""" | |
| t, vecs = _vec_table(store) | |
| from sklearn.decomposition import PCA | |
| import hdbscan | |
| red = PCA(n_components=min(pca_dims, len(vecs), vecs.shape[1]), | |
| random_state=0).fit_transform(vecs) | |
| labels = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size).fit_predict(red) | |
| out = t.drop_columns(["cluster"]) if "cluster" in t.column_names else t | |
| out = out.append_column("cluster", pa.array(labels.astype("int32"))) | |
| # replace = remove old files + add the re-clustered ones, one commit | |
| st = store.table("embeddings").state() | |
| log = store.table("embeddings").log | |
| import pyarrow.parquet as pq | |
| import uuid as _uuid | |
| fname = f"part-{_uuid.uuid4().hex[:12]}.parquet" | |
| from .store import write_parquet | |
| write_parquet(out, store.dir / "tables" / "embeddings" / fname) | |
| from .log import FileEntry | |
| p = store.dir / "tables" / "embeddings" / fname | |
| tsv = out.column("ts").to_numpy() | |
| log.commit(op="recluster", kind="embeddings", schema=str(out.schema), | |
| add=[FileEntry(fname, len(out), p.stat().st_size, | |
| int(tsv.min()), int(tsv.max()))], | |
| remove=[f.path for f in st.files], | |
| meta={**st.meta, "clusters": int(labels.max() + 1), | |
| "noise": int((labels < 0).sum())}) | |
| cents = [] | |
| for c in range(labels.max() + 1): | |
| m = vecs[labels == c].mean(axis=0) | |
| cents.append(m / np.linalg.norm(m)) | |
| if cents: | |
| ct = pa.table({ | |
| "ts": pa.array([0] * len(cents), pa.int64()), | |
| "cluster": pa.array(range(len(cents)), pa.int32()), | |
| "vector": pa.array([c.tolist() for c in cents], | |
| pa.list_(pa.float32(), vecs.shape[1])), | |
| }) | |
| cst = store.table("centroids").state() | |
| store.table("centroids").log.commit( | |
| op="replace", kind="centroids", schema=str(ct.schema), | |
| add=[], remove=[f.path for f in cst.files]) | |
| store.table("centroids").append(ct, kind="centroids") | |
| return {"clusters": int(labels.max() + 1), | |
| "noise": int((labels < 0).sum()), "windows": len(out)} | |
| def _score_windows(vecs, idx, pos_vecs, neg_vecs, neg_weight): | |
| """Compositional scoring over a candidate set. | |
| - ONE positive term → plain cosine (classic semantic search). | |
| - MANY positive terms → the window's score is the WORST of its per-term | |
| cosines (min-pool). This is the compositional AND: 'two people' AND | |
| 'a laptop' means a clip of two people with NO laptop scores low on the | |
| laptop term and is therefore rejected — the fix for 'it returns every | |
| clip with two people'. | |
| - negative terms → each subtracts its cosine (weighted), so | |
| '... NOT a phone' pushes phone-heavy frames down. | |
| """ | |
| cand = vecs[idx] # [m, d] | |
| pos = cand @ pos_vecs.T # [m, n_pos] | |
| score = pos.min(axis=1) # min-pool = AND | |
| if neg_vecs is not None and len(neg_vecs): | |
| score = score - neg_weight * (cand @ neg_vecs.T).max(axis=1) | |
| return score | |
| def _rank(store, q, k, nprobe, merge=True, t0=None, t1=None, streams=None, | |
| method="auto", pos_vecs=None, neg_vecs=None, neg_weight=0.5, | |
| min_score=None, percentile=None, table="embeddings", ctx=None, | |
| column="vector"): | |
| t, vecs = _vec_table(store, table, column=column) | |
| if table != "embeddings": | |
| # The ANN artifacts (HNSW graph, IVF-PQ codes, HDBSCAN centroids) are | |
| # built over `embeddings` and index THOSE row ids. Reusing them here | |
| # would return neighbours of the wrong table — silently, with | |
| # plausible-looking scores. Any other table scans exactly. | |
| method = "exact" | |
| all_t0 = t.column("ts").to_numpy() | |
| all_t1 = t.column("t1").to_numpy() | |
| all_s = t.column("stream").to_numpy(zero_copy_only=False) | |
| # `q` (the coarse retrieval direction) is the mean of positive terms; | |
| # `pos_vecs` carries the individual terms for compositional scoring. | |
| if pos_vecs is None: | |
| pos_vecs = q[None, :] | |
| # ---- hybrid retrieval: predicates pushed INTO candidate selection ------ | |
| # Time and stream are first-class dimensions of this database; vector | |
| # search composes with them instead of post-filtering a global top-k | |
| # (which silently starves filtered queries of results). | |
| pred = np.ones(len(vecs), bool) | |
| if t0 is not None: | |
| pred &= all_t1 >= t0 | |
| if t1 is not None: | |
| pred &= all_t0 <= t1 | |
| if streams: | |
| pred &= np.isin(all_s, list(streams)) | |
| labels = (t.column("cluster").to_numpy() | |
| if "cluster" in t.column_names else None) | |
| probed = total_clusters = 0 | |
| used = "exact" | |
| idx = scores = None | |
| if method in ("auto", "hnsw"): | |
| from . import ann | |
| hx = ann.load_hnsw(store) | |
| if hx is not None: | |
| # overfetch beyond k so predicate filtering and segment merging | |
| # still see the event's neighborhood, then score exactly | |
| fetch = int(min(len(vecs), max(k * 8, 64))) | |
| hx.set_ef(max(fetch, 64)) | |
| cand, _ = hx.knn_query(q, k=fetch) | |
| cand = cand[0] | |
| cand = cand[pred[cand]] | |
| if len(cand) >= min(k, pred.sum()): | |
| idx = np.asarray(cand) | |
| scores = vecs[idx] @ q | |
| used = "hnsw" | |
| if idx is None and method in ("auto", "ivfpq"): | |
| from . import ann | |
| r = ann.search_ivfpq(store, q, k=max(k * 4, 32), nprobe=max(nprobe, 8), | |
| mask=pred) if method == "ivfpq" else None | |
| if r is not None and r[0]: | |
| idx = np.array([i for i, _ in r[0]]) | |
| scores = np.array([s for _, s in r[0]]) | |
| used = "ivfpq" | |
| if idx is None: | |
| mask = pred.copy() | |
| if labels is not None and nprobe > 0: | |
| try: | |
| _, cents = _vec_table(store, "centroids") | |
| total_clusters = len(cents) | |
| order = np.argsort(cents @ q)[::-1][:nprobe] | |
| probed = len(order) | |
| mask &= np.isin(labels, order) | (labels < 0) # noise stays | |
| used = "ivf" | |
| except RuntimeError: | |
| pass | |
| idx = np.where(mask)[0] | |
| scores = None | |
| scanned = len(idx) | |
| # Final score is ALWAYS the compositional/exact function over the | |
| # candidate set (the coarse tier only shortlists; it never answers). | |
| scores = _score_windows(vecs, idx, pos_vecs, neg_vecs, neg_weight) | |
| # ---- optional fusion with the context index ---------------------------- | |
| # Appearance cosines and context cosines live on different scales (SigLIP | |
| # image-text similarity is squashed by the modality gap into ~0.01-0.15, | |
| # while context vectors are mean-free and spread over most of [-1,1]). | |
| # A raw weighted sum would therefore be governed entirely by the context | |
| # term regardless of alpha. Standardising each over the CANDIDATE SET | |
| # first makes alpha mean what it says. | |
| if ctx is not None and len(idx): | |
| def _z(a): | |
| return (a - a.mean()) / (a.std() + 1e-8) | |
| a = float(ctx["alpha"]) | |
| scores = (1.0 - a) * _z(scores) + a * _z(ctx["vecs"][idx] @ ctx["q"]) | |
| streams_sel = all_s[idx] | |
| w_t0 = all_t0[idx] | |
| w_t1 = all_t1[idx] | |
| # ---- precision floor: an ABSOLUTE cut the user controls ---------------- | |
| # A percentile keeps only the strongest fraction; min_score is a hard | |
| # cosine floor. Either turns "top-k of everything" into "only real hits", | |
| # so a query with 6 true matches returns 6, not 50. | |
| keep = np.ones(len(idx), bool) | |
| if percentile is not None and len(scores): | |
| keep &= scores >= np.percentile(scores, percentile) | |
| if min_score is not None: | |
| keep &= scores >= min_score | |
| if not keep.all(): | |
| idx, scores = idx[keep], scores[keep] | |
| streams_sel, w_t0, w_t1 = streams_sel[keep], w_t0[keep], w_t1[keep] | |
| stats = {"scanned": scanned, "total": len(vecs), "method": used, | |
| "clusters_probed": probed, "clusters_total": total_clusters, | |
| "predicate_candidates": int(pred.sum()), | |
| "after_floor": int(len(idx))} | |
| if not merge: | |
| order = np.argsort(scores)[::-1][:k] | |
| hits = [{"stream": str(streams_sel[i]), "t0": int(w_t0[i]), | |
| "t1": int(w_t1[i]), "score": float(scores[i]), | |
| "windows": 1} for i in order] | |
| return hits, stats | |
| if len(idx) == 0: | |
| stats["qualifying_windows"] = 0 | |
| stats["segments"] = 0 | |
| return [], stats | |
| # ---- dynamic segments: merge, don't chunk ------------------------------- | |
| # Fixed embedding windows are an INDEXING granularity, not an answer | |
| # granularity. A result is the maximal run of consecutive qualifying | |
| # windows on one stream: a 20 s event comes back as ONE 20 s hit (its | |
| # sub-windows are never returned separately), while a query that only | |
| # matches 2 s of it comes back as that tight 2 s. "Qualifying" is decided | |
| # per query from the score distribution — an absolute cutoff cannot work | |
| # because SigLIP cosines live on different scales per query. | |
| med = float(np.median(scores)) | |
| top = float(scores.max()) | |
| thr = med + 0.55 * (top - med) | |
| stats["threshold"] = round(thr, 4) | |
| qual = np.where(scores >= thr)[0] | |
| order = np.lexsort((w_t0[qual], streams_sel[qual])) | |
| qual = qual[order] | |
| gap_ns = int(np.median(w_t1[qual] - w_t0[qual])) + 1 if len(qual) else 0 | |
| segs = [] | |
| for i in qual: | |
| s, a, b, sc = (str(streams_sel[i]), int(w_t0[i]), int(w_t1[i]), | |
| float(scores[i])) | |
| last = segs[-1] if segs else None | |
| if last and last["stream"] == s and a - last["t1"] <= gap_ns: | |
| last["t1"] = max(last["t1"], b) | |
| last["score"] = max(last["score"], sc) # peak represents the segment | |
| last["mean"] = (last["mean"] * last["windows"] + sc) / (last["windows"] + 1) | |
| last["windows"] += 1 | |
| else: | |
| segs.append({"stream": s, "t0": a, "t1": b, "score": sc, | |
| "mean": sc, "windows": 1}) | |
| segs.sort(key=lambda g: -g["score"]) | |
| stats["qualifying_windows"] = len(qual) | |
| stats["segments"] = len(segs) | |
| return segs[:k], stats | |
| def _parse_query(text): | |
| """Parse a compositional query string into (positive terms, negatives). | |
| Grammar (all optional, combinable): | |
| 'a AND b' — every term must match (compositional AND) | |
| 'a NOT b' — exclude b (also '-b' or 'a -b') | |
| 'a; b' — same as AND | |
| Plain text with none of these is a single positive term (classic search). | |
| """ | |
| import re | |
| neg = [] | |
| # split on NOT / leading-minus tokens | |
| parts = re.split(r'\bNOT\b', text) | |
| head = parts[0] | |
| for extra in parts[1:]: | |
| neg.append(extra.strip()) | |
| pos_raw = re.split(r'\bAND\b|;', head) | |
| pos = [] | |
| for term in pos_raw: | |
| term = term.strip() | |
| # pull out inline -word exclusions | |
| toks = term.split() | |
| keep = [] | |
| for tk in toks: | |
| if tk.startswith("-") and len(tk) > 1: | |
| neg.append(tk[1:]) | |
| else: | |
| keep.append(tk) | |
| if keep: | |
| pos.append(" ".join(keep)) | |
| pos = [p for p in pos if p] | |
| neg = [n for n in neg if n] | |
| return (pos or [text]), neg | |
| def search(store, text, k=10, nprobe=3, merge=True, t0=None, t1=None, | |
| streams=None, method="auto", neg_weight=0.5, min_score=None, | |
| percentile=None, rerank=False, rerank_top=12, rerank_alpha=0.7): | |
| """Compositional text search. `text` may use AND / NOT / -term: | |
| 'two people AND a laptop NOT a phone' | |
| `min_score` (absolute cosine floor) or `percentile` (keep top X%) turn | |
| ranked-everything into precise retrieval.""" | |
| st = store.table("embeddings").state() | |
| model = st.meta.get("model", DEFAULT_MODEL) | |
| pos_terms, neg_terms = _parse_query(text) | |
| pos_vecs = np.stack([embed_text(p, model) for p in pos_terms]) | |
| neg_vecs = (np.stack([embed_text(n, model) for n in neg_terms]) | |
| if neg_terms else None) | |
| q = pos_vecs.mean(axis=0) | |
| q /= np.linalg.norm(q) # coarse retrieval direction | |
| hits, stats = _rank(store, q, k, nprobe, merge=merge, t0=t0, t1=t1, | |
| streams=streams, method=method, pos_vecs=pos_vecs, | |
| neg_vecs=neg_vecs, neg_weight=neg_weight, | |
| min_score=min_score, percentile=percentile) | |
| stats["positive_terms"] = pos_terms | |
| stats["negative_terms"] = neg_terms | |
| if rerank and hits: | |
| # relational stage: the expensive operator runs LAST, on the pruned set | |
| from .rerank import rerank_hits | |
| hits, info = rerank_hits(store, hits, text, top_n=rerank_top, | |
| alpha=rerank_alpha) | |
| stats["rerank"] = info | |
| return hits, stats | |
| def search_text(store, text, k=10, nprobe=3, merge=True, t0=None, t1=None, | |
| streams=None, method="auto", **kw): | |
| # backward-compatible alias; forwards compositional kwargs too | |
| return search(store, text, k=k, nprobe=nprobe, merge=merge, t0=t0, t1=t1, | |
| streams=streams, method=method, **kw) | |
| def search_clip(store, stream, t0, t1, k=10, nprobe=3, merge=True, | |
| pt0=None, pt1=None, pstreams=None, method="auto"): | |
| t, vecs = _vec_table(store) | |
| s = t.column("stream").to_numpy(zero_copy_only=False) | |
| a = t.column("ts").to_numpy() | |
| b = t.column("t1").to_numpy() | |
| sel = (s == stream) & (a <= t1) & (b >= t0) | |
| if not sel.any(): | |
| raise ValueError(f"no embedded windows overlap {stream} [{t0},{t1}]") | |
| q = vecs[sel].mean(axis=0) | |
| q /= np.linalg.norm(q) | |
| hits, stats = _rank(store, q, k + 8, nprobe, merge=merge, | |
| t0=pt0, t1=pt1, streams=pstreams, method=method) | |
| hits = [h for h in hits | |
| if not (h["stream"] == stream and h["t0"] <= t1 and h["t1"] >= t0)] | |
| return hits[:k], stats | |