Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Unit tests (Node)
run: npm test

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Browser tests (Chromium, WebGPU via SwiftShader)
run: npm run test:browser

- name: Build library
run: npm run build

- name: Build site
run: npx vite build --config vite.site.config.ts
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ the public API may still shift between minor versions).
## [Unreleased]

### Added
- Metadata filtering (FR-7): `query()`/`queryText()`/`queryBatch()` accept a
Mongo-ish `filter` in `QueryOptions` — bare values for equality plus `$eq`,
`$ne`, `$in`, `$gt`/`$gte`/`$lt`/`$lte` (AND across fields; unknown operators
throw). Execution is selectivity-aware: tiny match sets (≤ ~4k rows) are
scored exactly on the CPU straight from the store's fp32 vectors (exact on
every index type); on flat stores (fp32 and quantized) larger filters run
**in-index on the GPU** — a new score-mask kernel (`engine/wgsl/mask.ts` +
`index/scoreMask.ts`) stamps non-matching rows to the `-FLT_MAX` sentinel
between the distance dispatch and top-k, so filtered queries return exactly
the top-k matching rows at full GPU speed with no over-fetch (mask = 1
bit/row). IVF/HNSW/CPU-fallback stores use the exact CPU scan when selective
and tombstone-style over-fetch + post-filter when the filter matches nearly
everything; masked IVF scans and filtered HNSW traversal are future work.
New public types: `MetadataFilter`, `FilterOps`, `FilterValue`. Validated in
headless Chrome on a real GPU (masked flat exact vs brute force on both
top-k paths, chunked corpora, int8 re-rank, tombstones, k > matches).
- `stats()` now reports a per-query time breakdown: `lastQueryGpuMs` (measured
wait on submitted GPU work + readback) and `lastQueryCpuMs` (JS-side prep,
candidate gather, re-rank, top-k merge). 0/total respectively on the CPU
Expand Down
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ client-side, no server round-trip.

## Status

M1–M5 complete, M6 mostly complete (encryption, CPU/WASM fallback done;
cross-device tuning in progress). In short: flat brute-force + IVF approximate
search, fp32/int8/int4/1-bit quantization (and every combination of the two),
OPFS/IndexedDB persistence with optional AES-256-GCM encryption, an on-device
text embedder, Worker-offloaded ingest, and a WASM-SIMD CPU fallback for
M1–M5 and M7 complete, M6 mostly complete (encryption, CPU/WASM fallback done;
cross-device tuning in progress). In short: flat brute-force + two ANN families —
IVF clustering and an HNSW graph index (with an optional GPU beam-search kernel
and batched queries) — fp32/int8/int4/1-bit quantization (and every IVF×quant
combination), OPFS/IndexedDB persistence (HNSW graphs persist too, so loads skip
the rebuild) with optional AES-256-GCM encryption, an on-device text embedder,
Worker-offloaded ingest and index builds, and a WASM-SIMD CPU fallback for
devices without WebGPU. See [CHANGELOG.md](./CHANGELOG.md) for the release
history and [Not yet here](#not-yet-here) below for open milestone work.

Expand All @@ -32,8 +34,9 @@ import { BrowserVec } from 'browservec';
```

Requires a browser with [WebGPU](https://caniuse.com/webgpu) for the GPU-accelerated
path; falls back to a WASM-SIMD/scalar CPU path (exact fp32 flat search) where WebGPU
is unavailable — see [CPU fallback](./docs/features.md#cpu-fallback--no-webgpu-nfr-7--m6).
path; falls back to a WASM-SIMD/scalar CPU path (exact fp32 flat search, plus the
HNSW graph index) where WebGPU is unavailable — see
[CPU fallback](./docs/features.md#cpu-fallback--no-webgpu-nfr-7--m6).

## Quick start

Expand Down Expand Up @@ -63,6 +66,12 @@ await db.addBatch([
const hits = await db.query(queryVec, { k: 5 });
// → [{ id, score, metadata? }, ...] (higher score = closer)

await db.query(queryVec, { k: 5, filter: { lang: 'en', year: { $gte: 2020 } } });
// metadata pre-filtering: $eq (bare value), $ne, $in, $gt/$gte/$lt/$lte

const batches = await db.queryBatch([q1, q2, q3], { k: 5 });
// → QueryResult[][] — one GPU dispatch on an HNSW store with search: 'gpu'

db.get('a'); // → { id, vector, metadata? } | null
db.delete('a'); // tombstone by id → true/false (compacted on save)
await db.update({ id: 'a', vector: v2 }); // replace/upsert a vector
Expand All @@ -79,11 +88,14 @@ Each links to a short guide with runnable code:

| Feature | What it does |
|---|---|
| [Metadata filtering](./docs/api-reference.md#metadatafilter) | Mongo-ish `filter` on queries (`$eq`/`$ne`/`$in`/ranges). Flat stores filter **in-index on the GPU** via a score-mask kernel (exact top-k of matching rows, any selectivity, no over-fetch); tiny match sets take an exact CPU scan, exact on every index type. |
| [Deleting vectors](./docs/features.md#deleting-vectors) | Tombstone-based delete/update/compact — cheap deletes, GPU memory reclaimed on compact or reload. |
| [Persistence](./docs/features.md#persistence-m2) | Versioned binary snapshots to OPFS (or IndexedDB), auto-load on `create()`, export/import as a `Blob`. |
| [Encryption at rest](./docs/features.md#encryption-at-rest-m6) | AES-256-GCM + PBKDF2 passphrase envelope for persisted/exported snapshots. |
| [Quantization (TurboQuant)](./docs/features.md#quantization--turboquant-int8-m3a) | int8/int4/1-bit codes via randomized Hadamard rotation + exact fp32 re-rank — ~4×/8×/32× less memory. |
| [Approximate search (IVF)](./docs/features.md#approximate-search--ivf-m4) | GPU-assisted k-means clustering; queries scan only the nearest `nprobe` clusters. Combines with quantization for the ~1M-row path. |
| [Graph search (HNSW)](./docs/features.md#graph-search--hnsw-m7) | Layered proximity graph with O(log N) beam search — incremental inserts (no rebuild), all metrics, works without WebGPU. Graph persists in snapshots, so loads skip the rebuild (~180×). |
| [GPU graph search](./docs/features.md#gpu-graph-search-m7b) | CAGRA-style WGSL kernel: the whole beam search in one dispatch, one workgroup per query — `queryBatch()` searches many queries concurrently (~4× the CPU walk at 60k×768×128). |
| [Text retrieval / embedder](./docs/features.md#text-retrieval--on-device-embedder-m5) | `addText`/`queryText` via a zero-dep hashing embedder or an optional real semantic model (transformers.js). |
| [Worker ingest offload](./docs/features.md#worker-ingest-offload-nfr-8) | Rotate+quantize and IVF k-means mean-updates run off the main thread so ingest doesn't freeze the UI. |
| [Corpus chunking](./docs/features.md#corpus-chunking-nfr-10) | Corpus spreads across multiple GPU buffers once it would exceed the device's per-buffer limit — transparent, same results. |
Expand Down Expand Up @@ -137,9 +149,10 @@ browsers are WebKit under the hood, so WebGPU isn't exposed), no OPFS
(§NFR-10) triggers off the device's actual reported limit, so this needs no
configuration — but where the chunking crossover happens is device-dependent.
- **iOS is CPU-fallback-only today**: no WebGPU means quantization/IVF are
unavailable and only exact fp32-flat search runs, over IndexedDB persistence
(no OPFS on iOS). Pass `fallback: 'wasm'` explicitly when targeting iOS Chrome
or Safari, or `BrowserVec.create()` will throw.
unavailable; exact fp32-flat search and the HNSW graph index (M7 — CPU-native,
so it works here) run over IndexedDB persistence (no OPFS on iOS). Pass
`fallback: 'wasm'` explicitly when targeting iOS Chrome or Safari, or
`BrowserVec.create()` will throw.

Still missing from the matrix: Android Chrome (has WebGPU — a real gap),
Windows + NVIDIA/AMD (likely a much smaller buffer-size cap, which would
Expand All @@ -159,7 +172,11 @@ device-report JSON block from any of these are welcome — see
paste-back JSON block per device, feeding the [Benchmarks](#benchmarks)
table above.

Everything else (M1–M5, plus M6's other pieces) is done — see
- **HNSW × quantization** — the graph index is fp32-only for now; combining it
with the TurboQuant codes (quantized distances inside the traversal) is
future work. IVF×quant remains the memory-constrained ~1M path.

Everything else (M1–M5, M7 graph search, plus M6's other pieces) is done — see
[CHANGELOG.md](./CHANGELOG.md) for what shipped in each release.

## Docs & further reading
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Open any via `npm run dev`:
| [`examples/02-quantization.html`](../examples/02-quantization.html) | int8 / int4 quantization comparison |
| [`examples/03-ivf-index.html`](../examples/03-ivf-index.html) | IVF approximate search with nprobe sweep |
| [`examples/04-ivf-quant-combo.html`](../examples/04-ivf-quant-combo.html) | IVF × int8/int4 combo — the 1M path |
| [`examples/18-hnsw-index.html`](../examples/18-hnsw-index.html) | HNSW graph index — efSearch sweep, recall vs speed, works without WebGPU |
| [`examples/19-hnsw-gpu.html`](../examples/19-hnsw-gpu.html) | GPU graph search — one-dispatch beam kernel, queryBatch, CPU vs GPU crossover |
| [`examples/05-text-retrieval.html`](../examples/05-text-retrieval.html) | End-to-end text → search |
| [`examples/06-persistence.html`](../examples/06-persistence.html) | Save/load to OPFS/IndexedDB |
| [`examples/07-encryption.html`](../examples/07-encryption.html) | AES-256-GCM encrypted snapshots |
Expand Down
52 changes: 44 additions & 8 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ linked from each entry below.

| Member | Signature | Notes |
|---|---|---|
| `query(vector, opts?)` | `(vector: Vector, opts?: QueryOptions) => Promise<QueryResult[]>` | Core search. Handles tombstone over-fetch, quantized re-rank, and per-query `nprobe` override internally. |
| `query(vector, opts?)` | `(vector: Vector, opts?: QueryOptions) => Promise<QueryResult[]>` | Core search. Handles tombstone over-fetch, quantized re-rank, and per-query `nprobe`/`efSearch` overrides internally. |
| `queryBatch(vectors, opts?)` | `(vectors: Vector[], opts?: QueryOptions) => Promise<QueryResult[][]>` | Many queries at once. On an HNSW store with `search: 'gpu'` this is ONE compute dispatch (one workgroup per query); elsewhere it loops `query()`. Results are per-query, input order. |
| `queryText(text, opts?)` | `(text: string, opts?: QueryOptions) => Promise<QueryResult[]>` | Requires `config.embedder`. Embeds then `query()`s. |
| `get(id)` | `(id: string) => VectorRecord \| null` | Fetch a stored record (including its vector) by id. |

Expand Down Expand Up @@ -64,20 +65,24 @@ linked from each entry below.
| Field | Type | Default | Notes |
|---|---|---|---|
| `dimension` | `number` | required | Positive integer. 384/768/1024/1536 are fast-pathed. |
| `metric` | `'cosine' \| 'dot' \| 'l2'` | `'cosine'` | IVF and quantization currently require cosine/dot (`l2` is flat-only). |
| `metric` | `'cosine' \| 'dot' \| 'l2'` | `'cosine'` | IVF and quantization currently require cosine/dot (`l2` works on flat and HNSW). |
| `normalize` | `boolean` | `metric === 'cosine'` | Normalize vectors on insert. |
| `device` | `GPUDevice` | — | Reuse an existing device instead of requesting a new one. |
| `fallback` | `'wasm' \| 'error'` | `'error'` | `'wasm'` degrades to an exact CPU scan (fp32 flat only) when WebGPU is unavailable, instead of throwing. |
| `fallback` | `'wasm' \| 'error'` | `'error'` | `'wasm'` degrades to a CPU path (exact fp32 flat scan, or HNSW) when WebGPU is unavailable, instead of throwing. |
| `persist` | `PersistConfig` | — | Enables `save()` / auto-load. |
| `embedder` | `Embedder` | — | Enables `addText`/`addTexts`/`queryText`. Its `dimension` must match. |
| `quantBits` | `0 \| 1 \| 4 \| 8` | `0` | `0` = fp32. `8`/`4`/`1` = TurboQuant int8/int4/binary. Requires cosine/dot. |
| `quant` | `QuantConfig` | — | Tuning for quantized mode (seed, rounds, rerank factor). |
| `ann` | `ANNConfig` | — | Enables IVF. Omit for exact flat search. |
| `ann` | `ANNConfig` | — | Enables an approximate index — IVF (`type` omitted or `'ivf'`) or HNSW (`type: 'hnsw'`). Omit for exact flat search. |
| `chunkRows` | `number` | auto | Force corpus chunking below the auto-trigger threshold (mainly for tests/demos). |

### `ANNConfig`
### `ANNConfig` = `IVFConfig | HNSWConfig`

`nlist` (default ≈ `sqrt(count)`, clamped `[16, 4096]`), `nprobe` (default ≈ 5% of `nlist`), `sampleSize` (default `50_000`, reservoir sample for k-means training), `iters` (default `8` Lloyd iterations), `seed`.
A discriminated union on `type`. Omitting `type` (or `'ivf'`) selects IVF, so pre-M7 configs are unchanged.

**`IVFConfig`** — `type?: 'ivf'`, `nlist` (default ≈ `sqrt(count)`, clamped `[16, 4096]`), `nprobe` (default ≈ 5% of `nlist`), `sampleSize` (default `50_000`, reservoir sample for k-means training), `iters` (default `8` Lloyd iterations), `seed`.

**`HNSWConfig`** — `type: 'hnsw'`, `M` (graph out-degree per layer, layer 0 keeps 2·M; default `16`), `efConstruction` (build beam width; default `200`), `efSearch` (query beam width, clamped ≥ k; default `64`), `seed` (level RNG, reproducible builds), `search` (`'cpu' | 'gpu'`, default `'cpu'` — `'gpu'` runs the single-dispatch beam-search kernel; needs WebGPU, `M ≤ 32`, `efSearch ≤ 256`, corpus within one storage buffer, and shines on `queryBatch`).

### `QuantConfig`

Expand All @@ -89,7 +94,38 @@ linked from each entry below.

### `QueryOptions`

`k` (default `10`), `rerank` (override the store's default re-rank behavior, quantized stores only), `nprobe` (per-query override, IVF stores only).
`k` (default `10`), `filter` (metadata predicate — see [`MetadataFilter`](#metadatafilter)), `rerank` (override the store's default re-rank behavior, quantized stores only), `nprobe` (per-query override, IVF stores only), `efSearch` (per-query beam-width override, HNSW stores only — higher = better recall, slower).

### `MetadataFilter`

Mongo-ish predicate over record metadata (FR-7). Fields AND together; a bare
value is shorthand for `{ $eq: value }`:

```ts
await db.query(q, { k: 8, filter: { lang: 'en', year: { $gte: 2020, $lt: 2025 } } });
```

Operators: `$eq`, `$ne` (also matches records missing the field), `$in` (array
of values), and numeric `$gt` / `$gte` / `$lt` / `$lte` (only match stored
numbers). Unknown operators throw.

Execution picks a strategy per query:

- **Tiny match set (≤ ~4k rows)** — bypasses the index: matching rows are scored
exactly against the store's CPU-side fp32 vectors (`O(matches · dim)`,
independent of corpus size). Exact on *every* index type, including
quantized/IVF/HNSW.
- **Flat stores (fp32 or quantized), larger match sets** — **in-index GPU
filtering**: a mask pass stamps non-matching rows' scores to `-FLT_MAX`
between the distance kernel and the top-k reduction, so the GPU returns
exactly the top-k *matching* rows with no over-fetch. Full GPU speed at any
selectivity; exact for fp32, and quantized stores keep their usual exact
re-rank. Mask upload is 1 bit/row (~125 KB per query at 1M rows).
- **IVF/HNSW/CPU-fallback stores, larger match sets** — selective filters take
the exact CPU scan; filters matching nearly everything stay on the index
path, over-fetching by the excluded count and post-filtering (the tombstone
mechanism). In-index filtering for IVF and HNSW (masked cluster scans /
filtered graph traversal) is future work.

## Result / status types

Expand All @@ -99,7 +135,7 @@ linked from each entry below.

### `Stats` (from `stats()`)

`count`, `deleted?`, `dimension`, `metric`, `device: 'webgpu' | 'wasm'`, `lastQueryMs?`, `lastQueryGpuMs?` (GPU kernel portion), `lastQueryCpuMs?` (CPU re-rank/filtering portion), `persist?: 'opfs' | 'indexeddb'`, `quantBits`, `nlist?` (IVF cluster count once built), `chunks?` (>1 once the corpus spans multiple GPU buffers), `ingest?: 'worker' | 'main-thread'` (where quantized rotate+quantize ran), `train?: 'worker' | 'main-thread'` (where IVF k-means centroid updates ran).
`count`, `deleted?`, `dimension`, `metric`, `device: 'webgpu' | 'wasm'`, `lastQueryMs?`, `lastQueryGpuMs?` (GPU kernel portion), `lastQueryCpuMs?` (CPU re-rank/filtering portion), `persist?: 'opfs' | 'indexeddb'`, `quantBits`, `nlist?` (IVF cluster count once built), `maxLevel?` (HNSW top graph layer once non-empty), `graphSearch?: 'gpu' | 'cpu'` (which engine answers HNSW queries), `chunks?` (>1 once the corpus spans multiple GPU buffers), `ingest?: 'worker' | 'main-thread'` (where quantized rotate+quantize ran), `train?: 'worker' | 'main-thread'` (where the ANN index build ran — IVF k-means updates, or HNSW graph construction).

### `SupportInfo` (from `isSupported()`)

Expand Down
Loading
Loading