diff --git a/site/guides/performance.md b/site/guides/performance.md index 29729573..f89dcdc9 100644 --- a/site/guides/performance.md +++ b/site/guides/performance.md @@ -1,4 +1,76 @@ -- page_size -- memory mapping -- in-memory index -- chunk_size (?) +# Performance tuning + +This page describes the knobs sqlite-vec exposes for tuning the on-disk and +in-memory performance of `vec0` tables. + +## `chunk_size` table option + +`vec0` stores vectors in fixed-size chunks. Each chunk holds up to +`chunk_size` vectors in a contiguous run, plus a per-chunk rowid array and a +bit-packed validity bitmap. sqlite-vec allocates a new chunk for a partition +only when the current chunk is full, and a chunk is reclaimed only when every +slot in it has been deleted or moved. + +The `chunk_size` is a table-level option set with `CREATE VIRTUAL TABLE`: + +```sql +CREATE VIRTUAL TABLE items USING vec0( + embedding float[1536], + chunk_size=64 +); +``` + +### Contract + +- **Default**: `1024`. +- **Range**: `8` to `4096`, inclusive. +- **Constraint**: must be divisible by `8`. +- **Scope**: per vec0 table. Two `vec0` tables in the same database may + declare different `chunk_size` values; the choice does not leak between + tables. +- **Immutability**: the option is set at `CREATE VIRTUAL TABLE` time and is + fixed for the lifetime of the table. To change it, drop and recreate the + table. + +Invalid values raise an error at `CREATE VIRTUAL TABLE` time: + +```sql +-- too large +CREATE VIRTUAL TABLE v USING vec0(a float[4], chunk_size=8200); +-- vec0 constructor error: chunk_size too large + +-- not divisible by 8 +CREATE VIRTUAL TABLE v USING vec0(a float[4], chunk_size=7); +-- vec0 constructor error: chunk_size must be a multiple of 8 + +-- malformed +CREATE VIRTUAL TABLE v USING vec0(a float[4], chunk_sizex=100); +-- Unknown table option: chunk_sizex +``` + +### Choosing a value + +A smaller `chunk_size` reduces the empty-slot waste at the tail of the last +chunk in a partition. A larger `chunk_size` reduces the per-chunk metadata +overhead (the rowid array and validity bitmap are sized per chunk, not per +vector). + +| Workload characteristic | Recommended `chunk_size` | +| -------------------------------------------- | ------------------------ | +| Dense, large corpus (>10k vectors/partition) | `1024` (default) | +| Sparse per-partition corpus (<1k) | `64`–`128` | +| Small test corpus at minimum size | `8` (minimum) | + +The on-disk savings from a smaller `chunk_size` come entirely from the +unused slots at the end of the last chunk in each partition. Once a +partition is dense, reducing `chunk_size` no longer saves space and only +increases the metadata overhead. + +### Per-table shadow storage + +Each `vec0` table owns its own shadow tables (`_chunks`, +`_info`, `_rowids`, `_vector_chunks00`, +`_auxiliary`). With a different `chunk_size`, the shadow tables for +each table differ in row count and bitmap width, but they do not interact. +Creating two `vec0` tables with different `chunk_size` values in the same +database does not cause shared state or contention between them. diff --git a/tests/test-loadable.py b/tests/test-loadable.py index 0044144b..6a3219ee 100644 --- a/tests/test-loadable.py +++ b/tests/test-loadable.py @@ -1590,6 +1590,90 @@ def test_vec0_constructor(): db.execute("create virtual table v using vec0(4)") +def test_chunk_size_64_knn_and_per_table_isolation(): + # chunk_size=64 is a useful mid-range option that: + # - accepts insert of a corpus that crosses the 64-row chunk boundary, + # - returns the same top-k ordering as default-chunk_size on the same + # vector set, and + # - keeps per-table shadow state isolated when a second vec0 table in + # the same database uses a different chunk_size. + db.execute("drop table if exists t_cs64") + db.execute("drop table if exists t_cs1024") + + # chunk_size=64 is accepted and the table is creatable. + db.execute( + "create virtual table t_cs64 using vec0(" + "row_id text primary key, embedding float[4] distance_metric=cosine, chunk_size=64)" + ) + + # Insert 65 rows: forces the second chunk to materialize at the 64-row + # boundary. Each row uses a distinct primary key. + for i in range(65): + db.execute( + "insert into t_cs64(row_id, embedding) values (?, ?)", + [f"id_{i}", _f32([(i % 13) * 0.1, (i % 7) * 0.05, i * 0.001, 1.0])], + ) + + # The shadow `chunks` table must record at least two chunks at chunk_size=64. + chunk_count = db.execute( + "select count(*) from t_cs64_chunks where size = 64" + ).fetchone()[0] + assert chunk_count >= 2, ( + f"expected at least 2 chunks at chunk_size=64, got {chunk_count}" + ) + + # KNN over chunk_size=64 returns the same top-k ordering as chunk_size=1024 + # on the same vector set. KNN correctness is preserved across small chunk + # boundaries — chunk_size is a storage-only knob. + db.execute( + "create virtual table t_cs1024 using vec0(" + "row_id text primary key, embedding float[4] distance_metric=cosine, chunk_size=1024)" + ) + for i in range(65): + db.execute( + "insert into t_cs1024(row_id, embedding) values (?, ?)", + [f"id_{i}", _f32([(i % 13) * 0.1, (i % 7) * 0.05, i * 0.001, 1.0])], + ) + + query = _f32([0.1, 0.05, 0.001, 1.0]) + cs64_top = [ + r[0] + for r in db.execute( + "select row_id from t_cs64 where embedding match ? and k = 5 order by distance", + [query], + ).fetchall() + ] + cs1024_top = [ + r[0] + for r in db.execute( + "select row_id from t_cs1024 where embedding match ? and k = 5 order by distance", + [query], + ).fetchall() + ] + assert cs64_top == cs1024_top, ( + f"top-k ordering diverged between chunk_size=64 and chunk_size=1024: " + f"{cs64_top} vs {cs1024_top}" + ) + + # chunk_size is per-table — the shadow tables reflect each table's choice + # independently, with no cross-table leakage. + cs64_size = db.execute( + "select distinct size from t_cs64_chunks" + ).fetchall() + cs1024_size = db.execute( + "select distinct size from t_cs1024_chunks" + ).fetchall() + assert cs64_size == [(64,)], ( + f"t_cs64 shadow table should report only size=64, got {cs64_size}" + ) + assert cs1024_size == [(1024,)], ( + f"t_cs1024 shadow table should report only size=1024, got {cs1024_size}" + ) + + db.execute("drop table t_cs64") + db.execute("drop table t_cs1024") + + def test_vec0_indexed_by_flat(): db.execute("drop table if exists t_ibf") db.execute("drop table if exists t_ibf2")