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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ jobs:
- name: Run tests
run: pytest -q --cov=snapvec --cov-report=term-missing

- name: Run examples smoke test
shell: bash
run: |
set -e
for ex in examples/quickstart.py \
examples/pq_index.py \
examples/ivf_pq.py \
examples/filter_search.py \
examples/save_load.py \
examples/streaming_ingest.py; do
echo "=== $ex ==="
python "$ex"
done

- name: Upload coverage (ubuntu + py3.12 only)
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Docs

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

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

permissions:
contents: read

Comment on lines +14 to +16

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs workflow sets pages: write and id-token: write at the workflow level, which also applies to pull_request runs. Consider scoping elevated permissions to the deploy job only (and keeping build at read-only) to follow least-privilege and reduce the impact of a compromised build step.

Copilot uses AI. Check for mistakes.
jobs:
build:
name: Build docs
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install package + docs dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[docs]"

- name: Build site (strict)
run: mkdocs build --strict

- name: Upload Pages artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: site

deploy:
name: Deploy to GitHub Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ dist/
.pytest_cache/
.ruff_cache/

# experiment caches and harness state — large binary, not source of truth
# experiment caches and harness state -- large binary, not source of truth
experiments/.cache_*.npy
.claude/scheduled_tasks.lock
*.so
snapvec/_fast.c
build/

# mkdocs build output
site/
688 changes: 53 additions & 635 deletions README.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions docs/api/helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Helpers

Lower-level building blocks exposed for advanced use. Most users should
reach for one of the index classes.

## `rht`

::: snapvec.rht

## `padded_dim`

::: snapvec.padded_dim

## `get_codebook`

::: snapvec.get_codebook
3 changes: 3 additions & 0 deletions docs/api/ivf-pq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# IVFPQSnapIndex

::: snapvec.IVFPQSnapIndex
3 changes: 3 additions & 0 deletions docs/api/pq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# PQSnapIndex

::: snapvec.PQSnapIndex
3 changes: 3 additions & 0 deletions docs/api/residual.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ResidualSnapIndex

::: snapvec.ResidualSnapIndex
3 changes: 3 additions & 0 deletions docs/api/snap-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SnapIndex

::: snapvec.SnapIndex
105 changes: 105 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Architecture

How `snapvec` compresses vectors without destroying inner-product recall.

## Motivation

Dense embeddings from modern encoders (384-dim BGE, 768-dim E5, 1024-dim+
OpenAI, etc.) are expensive to store and scan at scale. At float32,
1M vectors of dim=768 take 3 GB. Float16 halves that but still scales
linearly with dim and only halves latency because memory bandwidth is
the bottleneck.

`snapvec` targets the compression / recall frontier: give up a small
amount of recall (typically 1-5 percentage points) for 6-96x less disk
and RAM, and run search kernels that fit in L2 cache.

## Pipeline (SnapIndex)

1. **Normalize** (optional): unit-length every vector. Turns inner
product into cosine similarity and removes one nuisance dimension.
2. **Randomized Hadamard Transform (RHT)**: multiply by a signed random
Hadamard matrix. Decorrelates coordinates so their marginal
distribution approaches iid Gaussian, even when the input is not.
3. **Lloyd-Max scalar quantization**: quantize each coordinate to `bits`
levels using the Lloyd-Max boundaries for a unit Gaussian. This is
the optimal scalar quantizer for a Gaussian source under MSE.
4. **Bit-pack** the quantized codes so storage is exactly `bits * pdim / 8`
bytes per vector (`pdim` is the Hadamard-padded dim).

At query time, the query goes through the same normalize + RHT + quantize
path, then a vectorized inner-product sum over the unpacked codes scores
the whole corpus.

## Why RHT?

Scalar quantization is optimal for a Gaussian source. Real embeddings are
not Gaussian along each coordinate. RHT is an isometry that makes
coordinates look Gaussian without changing inner products, so Lloyd-Max
becomes near-optimal. It also distributes signal energy across
coordinates, so no single quantization error dominates.

The RHT is implemented as a reshape-view + in-place xor across `log2(pdim)`
levels, so it costs `O(pdim * log(pdim))` time but `O(log(pdim))` Python
dispatch overhead.

## Lloyd-Max codebooks

Pre-computed per bit-depth:

- 2-bit: 4 reconstruction levels at Gaussian-optimal boundaries
- 3-bit: 8 levels
- 4-bit: 16 levels

Stored as constants in [`snapvec/_codebooks.py`](https://github.com/stffns/snapvec/blob/main/snapvec/_codebooks.py).

## Product Quantization (PQSnapIndex)

For higher recall at the same bytes/vec, `PQSnapIndex` replaces scalar
quantization with product quantization:

1. Split each vector into `M` contiguous sub-vectors.
2. For each subspace, learn `K=256` k-means centroids over a training
sample.
3. Encode each vector as `M` bytes (one centroid index per subspace).

Query scoring uses an ADC (asymmetric distance computation) lookup table:
pre-compute `M * K` partial inner products against the query, then sum
`M` table lookups per corpus vector. The Cython+OpenMP kernel
(`snapvec._fast.adc_colmajor`) runs this in parallel with scores as a
stripe of ~4-8 MB that fits in L2.

## Inverted File (IVFPQSnapIndex)

For sub-linear search, layer an inverted file (IVF) on top of PQ:

1. Learn `nlist` coarse centroids from a training sample.
2. Assign each corpus vector to its nearest coarse centroid.
3. Store vectors contiguous per cluster, with an `offsets` array for
random access.
4. At query time, rank coarse centroids by query-centroid distance and
visit only the top `nprobe` clusters.

Each cluster stores PQ codes of the **residual** (vector minus its coarse
centroid), which is easier to quantize than the raw vector.

### Float16 rerank

PQ has a recall ceiling set by codebook granularity. To break it,
`IVFPQSnapIndex` with `keep_full_precision=True` stores each vector in
float16 as well. At query time, the top `rerank_candidates` returned by
the PQ pass are rescored against their stored fp16 vectors, and the
final top-k comes from the rerank scores.

This recovers 4-7 percentage points of recall at the cost of one
`(rerank_candidates, dim) @ (dim,)` matmul per query, typically under
1 ms even at large `nprobe`.

## References

- Zandieh et al. (2025). *TurboQuant: Online Vector Quantization with
Near-optimal Distortion Rate.* [arXiv:2504.19874](https://arxiv.org/abs/2504.19874)
- Jegou et al. (2011). *Product Quantization for Nearest Neighbor Search.*
IEEE TPAMI.
- Andre et al. (2015). *Cache Locality is Not Enough: High-performance
Nearest Neighbor Search with Product Quantization Fast Scan.*
59 changes: 59 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Benchmarks

All numbers measured on the same hardware with a fixed random seed. Raw
scripts live in [`experiments/`](https://github.com/stffns/snapvec/tree/main/experiments);
a reproducible CI-runnable suite is planned.

## Headline: IVFPQSnapIndex on FIQA

BEIR FIQA, N = 57,638, dim = 384 (BGE-small), nlist = 512, M = 192, K = 256.
`keep_full_precision=True`, `rerank_candidates=100`.

| `nprobe` | Configuration | recall@10 | Latency (us/query) |
|---------|---------------|-----------|--------------------|
| 8 | PQ only | 0.85 | 180 |
| 32 | PQ only | 0.92 | 340 |
| 64 | PQ + fp16 rerank | **0.977** | **441** |
| 256 | PQ + fp16 rerank | **0.998** | **1021** |

5.8x speedup vs v0.6 at identical recall, past the PQ-only 0.929 ceiling.

## snapvec vs sqlite-vec (measured)

Same corpus, same hardware. `snapvec` at `nprobe=64` + rerank.

| N | sqlite-vec | snapvec | Speedup | Recall tradeoff |
|---|------------|---------|---------|-----------------|
| 10k | 2.3 ms | 0.44 ms | 5x | 0.997 |
| 57k | 15.1 ms | 0.44 ms | 34x | 0.977 |
| 100k | 23.8 ms | 1.04 ms | 23x | 0.994 |
| 500k | ~110 ms | 0.9 ms | 125x | ~0.97 |
| 1M | brute-force infeasible | 1.1 ms | -- | -- |

Disk footprint is 2-8x smaller across the range.

## Compression ratios

For BGE-small (dim=384, float32 baseline = 1536 B/vec):

| Index | Config | B/vec | Compression |
|-------|--------|-------|-------------|
| `SnapIndex` | bits=2 | 132 | 11.6x |
| `SnapIndex` | bits=3 | 196 | 7.8x |
| `SnapIndex` | bits=4 | 260 | 5.9x |
| `PQSnapIndex` | M=16 | 16 | 96x |
| `PQSnapIndex` | M=32 | 32 | 48x |
| `IVFPQSnapIndex` | M=192 + fp16 rerank | ~960 | 1.6x (rerank cache dominates) |
| `IVFPQSnapIndex` | M=192, no rerank | ~192 | 8x |

## Reproduction

```bash
pip install -e ".[dev]"
python experiments/bench_v090_fiqa.py # FIQA recall / latency
python experiments/bench_sqlite_vec_baseline.py # sqlite-vec comparison
```

The `experiments/` folder is WIP; expect rough edges. A first-class
`bench/` suite that runs in CI and emits machine-readable results is
tracked on the roadmap.
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--8<-- "CHANGELOG.md"
54 changes: 54 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Installation

## From PyPI

```bash
pip install snapvec
```

Pre-compiled wheels are published for CPython 3.10-3.13 on Linux (x86_64,
aarch64), macOS (x86_64 and arm64, macOS 13+), and Windows (AMD64). NumPy
>= 1.24 is the only runtime dependency.

## From source

```bash
git clone https://github.com/stffns/snapvec.git
cd snapvec
pip install -e ".[dev]"
```

On macOS you need OpenMP from Homebrew before building:

```bash
brew install libomp
```

Without `libomp`, the Cython kernels compile in serial mode. The library
still works but the parallel search paths do not.

## Input dtype

`snapvec` expects `np.float32` inputs everywhere. Internally, `add_batch`
and `search` pass your arrays through `np.asarray(..., dtype=np.float32)`,
so a `float64` input will be cast silently. That cast allocates a full
copy of the batch on every call, which is easy to miss in a hot path.
Prefer `float32` at the source.

```python
# Models that return float32 directly (most modern embeddings)
vecs = model.encode(texts) # already float32

# Arrays from np.array([...]) default to float64 -- cast explicitly
vecs = np.array(vecs_list, dtype=np.float32)

# Loading from disk -- respect the original dtype or force float32
vecs = np.load("embeddings.npy").astype(np.float32, copy=False)
```

## Verify install

```python
import snapvec
print(snapvec.__version__)
```
Loading
Loading