diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e73917d..2cb058a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - The linux-arm64 and linux-arm64-musl binaries use NEON, as the darwin-arm64 binary does: `make` enables it on Linux arm64, and `OMIT_SIMD=1` builds without it. An L2 KNN query on a 100,000-row `float[768]` table took 19 ms instead of 57 ms there. Hamming and `int8` L1 distances are unchanged; `float32` L1 distances can differ in the last digit when their double-precision sum rounds, and `float32` L2 distances can differ from 2.1.1's in the last digits, closer to the exact value (at 768 dimensions, a mean relative error of 3.0e-8 instead of 1.7e-7), so a KNN query can order rows with nearly equal distances differently; `int8` L2 distances whose squared differences sum past 2^24 are computed from the exact sum. - Cosine distance between `float32` vectors of more than 16 dimensions, or `int8` vectors of more than 15, uses NEON in NEON builds, which include the darwin-arm64 binary: a KNN query on a 100,000-row `float[768]` table took 26 ms instead of 94 ms on Linux arm64. `float32` distances can differ from the scalar code's in the last digits, as NEON adds the same double-precision products in a different order: differences are rare between random vectors and common between near-duplicate ones, and a vector's distance to itself is between -2.2e-16 and 2.2e-16 in either. `int8` distances match up to 1,024 dimensions; past that, vectors whose squared elements sum past 2^24 can get different distances, usually closer to the exact value, as NEON sums them exactly. Ported from [asg017@0de765f](https://github.com/asg017/sqlite-vec/commit/0de765f), except that `float32` sums stay in `double` ([asg017#324](https://github.com/asg017/sqlite-vec/issues/324)), where asg017's kernel sums them in `float32`, and `int8` sums are kept in 64 bits, where asg017's 32-bit sums overflow from 131,072 elements. - A KNN query picks each chunk's k nearest rows with the heap of upstream's `SQLITE_VEC_EXPERIMENTAL_MIN_IDX` build option ([asg017/sqlite-vec@0de765f](https://github.com/asg017/sqlite-vec/commit/0de765f4570c1d65a0699e8e38d6c5562da9e23e)) instead of one pass over the chunk for every row it keeps, ordered so that it returns the same rows in the same order as before: on a 10,000-row `float[768]` table, `k = 1000` and `k = 4096` take 9.3 ms instead of 17.8 and 18.0 ms, `k = 100` 8.8 ms instead of 9.2, and `k = 1` 8.7 ms either way. A NaN distance ranks after every number instead of where its position put it. A static blob can produce one, and so can a `vec0` table written before 2.1.0, which started rejecting NaN elements. On such a table a NaN row used to push nearer rows out of the result or behind farther ones; queries now return the nearest rows in order, which can leave out a NaN row that a query with `k` smaller than the row count used to return. +- A `DELETE` that removes the last row of a chunk now deletes the chunk's rows from the `_chunks`, `_vector_chunksNN`, and `_metadatachunksNN` shadow tables, so their pages are free for reuse without `optimize`; the emptied chunk used to stay until `optimize` removed it. Ported from [asg017@cb147c8](https://github.com/asg017/sqlite-vec/commit/cb147c8) via [vlasky@3a64182](https://github.com/vlasky/sqlite-vec/commit/3a64182) ([asg017#268](https://github.com/asg017/sqlite-vec/pull/268)). ### Fixed diff --git a/README.md b/README.md index ac44622f..ddc5076c 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,9 @@ order by distance; ### Space Reclamation with Optimize -`optimize` compacts vec shadow tables. To shrink the database file: +A `DELETE` that removes the last row of a chunk deletes the chunk, and +`optimize` compacts the chunks that deletes leave partly empty. To shrink the +database file: ```sql -- Before creating vec tables: enable autovacuum and apply it (recommended) diff --git a/sqlite-vec.c b/sqlite-vec.c index e6b8d3e2..539735e3 100644 --- a/sqlite-vec.c +++ b/sqlite-vec.c @@ -11123,6 +11123,110 @@ int vec0Update_Delete_ClearMetadata(vec0_vtab *p, int metadata_idx, i64 rowid, return rc; } +// Once a DELETE has cleared the last validity bit of its chunk, delete the +// chunk's rows from _chunks, _vector_chunksNN and _metadatachunksNN, so their +// pages are free without an optimize. +// https://github.com/asg017/sqlite-vec/commit/cb147c8 +int vec0Update_Delete_DeleteChunkIfEmpty(vec0_vtab *p, i64 chunk_id) { + int rc; + sqlite3_blob *blobValidity = NULL; + sqlite3_stmt *stmt = NULL; + char *zSql; + + rc = sqlite3_blob_open(p->db, p->schemaName, p->shadowChunksName, "validity", + chunk_id, 0, &blobValidity); + if (rc != SQLITE_OK) { + vtab_set_error(&p->base, "could not open validity blob for %s.%s.%lld", + p->schemaName, p->shadowChunksName, chunk_id); + return vec0_error_rc(rc); + } + int validitySize = sqlite3_blob_bytes(blobValidity); + u8 *validity = sqlite3_malloc(validitySize); + if (!validity) { + sqlite3_blob_close(blobValidity); + return SQLITE_NOMEM; + } + rc = sqlite3_blob_read(blobValidity, validity, validitySize, 0); + int brc = sqlite3_blob_close(blobValidity); + if (rc != SQLITE_OK) { + sqlite3_free(validity); + vtab_set_error(&p->base, "could not read validity blob for %s.%s.%lld", + p->schemaName, p->shadowChunksName, chunk_id); + return rc; + } + if (brc != SQLITE_OK) { + sqlite3_free(validity); + vtab_set_error(&p->base, "could not close validity blob for %s.%s.%lld", + p->schemaName, p->shadowChunksName, chunk_id); + return brc; + } + int empty = !bitmap_any(validity, validitySize * CHAR_BIT); + sqlite3_free(validity); + if (!empty) { + return SQLITE_OK; + } + + zSql = sqlite3_mprintf("DELETE FROM " VEC0_SHADOW_CHUNKS_NAME + " WHERE chunk_id = ?", + p->schemaName, p->tableName); + if (!zSql) { + return SQLITE_NOMEM; + } + rc = sqlite3_prepare_v2(p->db, zSql, -1, &stmt, NULL); + sqlite3_free(zSql); + if (rc != SQLITE_OK) { + return rc; + } + sqlite3_bind_int64(stmt, 1, chunk_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return vec0_error_rc(rc); + } + + for (int i = 0; i < p->numVectorColumns; i++) { + zSql = sqlite3_mprintf("DELETE FROM " VEC0_SHADOW_VECTOR_N_NAME + " WHERE rowid = ?", + p->schemaName, p->tableName, i); + if (!zSql) { + return SQLITE_NOMEM; + } + rc = sqlite3_prepare_v2(p->db, zSql, -1, &stmt, NULL); + sqlite3_free(zSql); + if (rc != SQLITE_OK) { + return rc; + } + sqlite3_bind_int64(stmt, 1, chunk_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return vec0_error_rc(rc); + } + } + + for (int i = 0; i < p->numMetadataColumns; i++) { + zSql = sqlite3_mprintf("DELETE FROM " VEC0_SHADOW_METADATA_N_NAME + " WHERE rowid = ?", + p->schemaName, p->tableName, i); + if (!zSql) { + return SQLITE_NOMEM; + } + rc = sqlite3_prepare_v2(p->db, zSql, -1, &stmt, NULL); + sqlite3_free(zSql); + if (rc != SQLITE_OK) { + return rc; + } + sqlite3_bind_int64(stmt, 1, chunk_id); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return vec0_error_rc(rc); + } + } + + return SQLITE_OK; +} + int vec0Update_Delete(sqlite3_vtab *pVTab, sqlite3_value *idValue) { vec0_vtab *p = (vec0_vtab *)pVTab; int rc; @@ -11184,7 +11288,7 @@ int vec0Update_Delete(sqlite3_vtab *pVTab, sqlite3_value *idValue) { } } - // 6. delete metadata + // 7. delete metadata for (int i = 0; i < p->numMetadataColumns; i++) { rc = vec0Update_Delete_ClearMetadata(p, i, rowid, chunk_id, chunk_offset); if (rc != SQLITE_OK) { @@ -11192,7 +11296,8 @@ int vec0Update_Delete(sqlite3_vtab *pVTab, sqlite3_value *idValue) { } } - return SQLITE_OK; + // 8. delete the chunk if that was its last row + return vec0Update_Delete_DeleteChunkIfEmpty(p, chunk_id); } int vec0Update_UpdateAuxColumn(vec0_vtab *p, int auxiliary_column_idx, diff --git a/tests/fuzz/vec0-delete-completeness.c b/tests/fuzz/vec0-delete-completeness.c index 08b43bbb..a1432667 100644 --- a/tests/fuzz/vec0-delete-completeness.c +++ b/tests/fuzz/vec0-delete-completeness.c @@ -71,10 +71,8 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { } } - // Delete all remaining rows. This fork's DELETE leaves emptied chunks in - // place until optimize removes them. + // Delete all remaining rows sqlite3_exec(db, "DELETE FROM v", NULL, NULL, NULL); - sqlite3_exec(db, "INSERT INTO v(v) VALUES ('optimize')", NULL, NULL, NULL); // Assert all shadow tables are empty after full deletion sqlite3_prepare_v2(db, diff --git a/tests/test-error-paths.py b/tests/test-error-paths.py index 32cfe844..bbf0eb20 100644 --- a/tests/test-error-paths.py +++ b/tests/test-error-paths.py @@ -386,7 +386,9 @@ def interrupt_template(): db.load_extension(get_extension_path()) # Text values are longer than the 12 bytes a metadata chunk holds, so # filters and reads go to the _metadatatext table. v's one chunk is full, - # so an INSERT adds a chunk; vp has a deleted row for optimize to skip. + # so an INSERT adds a chunk; vp has a deleted row for optimize to skip, and + # rowid 4 is the last row of its partition 0 chunk, so deleting it deletes + # the chunk. db.execute( "CREATE VIRTUAL TABLE v USING vec0(vector float[2], m integer, t text, " "+a text, chunk_size=8)" @@ -500,6 +502,7 @@ def handler(): ), pytest.param("DELETE FROM v WHERE rowid = 4", id="delete"), pytest.param("DELETE FROM vt WHERE id = 'id4'", id="delete-text-pk"), + pytest.param("DELETE FROM vp WHERE rowid = 4", id="delete-emptying-chunk"), pytest.param("INSERT INTO vp(vp) VALUES ('optimize')", id="optimize"), ], ) diff --git a/tests/test-insert-delete.py b/tests/test-insert-delete.py index 6827c4ac..c5a9334b 100644 --- a/tests/test-insert-delete.py +++ b/tests/test-insert-delete.py @@ -2,12 +2,17 @@ import struct import pytest +from conftest import get_extension_path def _f32(values): return struct.pack("%sf" % len(values), *values) +def _int8(values): + return struct.pack("%sb" % len(values), *values) + + def test_insert_or_replace_integer_pk(db): """INSERT OR REPLACE should update vector when rowid already exists.""" db.execute("create virtual table v using vec0(emb float[4], chunk_size=8)") @@ -161,3 +166,289 @@ def test_insert_or_replace_rejected_keeps_original_row(db): "select vec_to_json(emb), tag, note from v where rowid = 1" ).fetchone() assert tuple(row) == ("[1.000000]", "old", 1) + + +# The delete tests below are ported from asg017/sqlite-vec@cb147c8. + + +def test_delete_with_text_primary_key(db): + db.execute( + "create virtual table v using vec0(" + "id text primary key, emb float[4], chunk_size=8" + ")" + ) + + db.execute("insert into v(id, emb) values ('a', ?)", [_f32([1.0, 2.0, 3.0, 4.0])]) + db.execute("insert into v(id, emb) values ('b', ?)", [_f32([5.0, 6.0, 7.0, 8.0])]) + + db.execute("delete from v where id = 'a'") + + # Vector blob slot 0 should be zeroed + blob = db.execute( + "select vectors from v_vector_chunks00 where rowid = 1" + ).fetchone()[0] + first_slot = struct.unpack("<4f", blob[:16]) + assert first_slot == (0.0, 0.0, 0.0, 0.0) + + # Remaining row still queryable + row = db.execute("select emb from v where id = 'b'").fetchone() + assert row[0] == _f32([5.0, 6.0, 7.0, 8.0]) + + +def test_delete_int8_vectors(db): + db.execute("create virtual table v using vec0(emb int8[4], chunk_size=8)") + + db.execute( + "insert into v(rowid, emb) values (1, vec_int8(?))", + [_int8([1, 2, 3, 4])], + ) + db.execute( + "insert into v(rowid, emb) values (2, vec_int8(?))", + [_int8([5, 6, 7, 8])], + ) + + db.execute("delete from v where rowid = 1") + + blob = db.execute( + "select vectors from v_vector_chunks00 where rowid = 1" + ).fetchone()[0] + # int8[4] = 4 bytes per slot + first_slot = struct.unpack("<4b", blob[:4]) + assert first_slot == (0, 0, 0, 0) + second_slot = struct.unpack("<4b", blob[4:8]) + assert second_slot == (5, 6, 7, 8) + + +def test_delete_bit_vectors(db): + db.execute("create virtual table v using vec0(emb bit[8], chunk_size=8)") + + db.execute( + "insert into v(rowid, emb) values (1, vec_bit(?))", + [bytes([0xFF])], + ) + db.execute( + "insert into v(rowid, emb) values (2, vec_bit(?))", + [bytes([0xAA])], + ) + + db.execute("delete from v where rowid = 1") + + blob = db.execute( + "select vectors from v_vector_chunks00 where rowid = 1" + ).fetchone()[0] + # bit[8] = 1 byte per slot + assert blob[0:1] == bytes([0x00]) + assert blob[1:2] == bytes([0xAA]) + + +def test_delete_all_rows_deletes_chunk(db): + db.execute("create virtual table v using vec0(emb float[4], chunk_size=8)") + + for i in range(1, 9): + db.execute( + "insert into v(rowid, emb) values (?, ?)", + [i, _f32([float(i)] * 4)], + ) + + for i in range(1, 9): + db.execute("delete from v where rowid = ?", [i]) + + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 0 + assert db.execute("select count(*) from v_vector_chunks00").fetchone()[0] == 0 + + # Inserting after full deletion still works + db.execute( + "insert into v(rowid, emb) values (100, ?)", [_f32([9.0, 9.0, 9.0, 9.0])] + ) + row = db.execute("select emb from v where rowid = 100").fetchone() + assert row[0] == _f32([9.0, 9.0, 9.0, 9.0]) + + +def test_delete_chunk_multiple_chunks(db): + db.execute("create virtual table v using vec0(emb float[4], chunk_size=8)") + + for i in range(1, 17): + db.execute( + "insert into v(rowid, emb) values (?, ?)", + [i, _f32([float(i)] * 4)], + ) + + # Delete all rows from the first chunk (rows 1-8) + for i in range(1, 9): + db.execute("delete from v where rowid = ?", [i]) + + # Only 1 chunk should remain + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 1 + + # Rows 9-16 still queryable + for i in range(9, 17): + row = db.execute("select emb from v where rowid = ?", [i]).fetchone() + assert row[0] == _f32([float(i)] * 4) + + +def test_delete_with_metadata_columns(db): + db.execute( + "create virtual table v using vec0(" + "emb float[4], " + "m_bool boolean, " + "m_int integer, " + "m_float float, " + "m_text text, " + "chunk_size=8" + ")" + ) + + for i in range(1, 9): + db.execute( + "insert into v(rowid, emb, m_bool, m_int, m_float, m_text) " + "values (?, ?, ?, ?, ?, ?)", + [i, _f32([float(i)] * 4), i % 2 == 0, i * 10, float(i) / 2.0, f"text_{i}"], + ) + + for i in range(1, 9): + db.execute("delete from v where rowid = ?", [i]) + + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 0 + assert db.execute("select count(*) from v_vector_chunks00").fetchone()[0] == 0 + assert db.execute("select count(*) from v_metadatachunks00").fetchone()[0] == 0 + assert db.execute("select count(*) from v_metadatachunks01").fetchone()[0] == 0 + assert db.execute("select count(*) from v_metadatachunks02").fetchone()[0] == 0 + assert db.execute("select count(*) from v_metadatachunks03").fetchone()[0] == 0 + + +def test_delete_with_auxiliary_columns(db): + db.execute( + "create virtual table v using vec0(" + "emb float[4], " + "+aux_text text, " + "chunk_size=8" + ")" + ) + + for i in range(1, 9): + db.execute( + "insert into v(rowid, emb, aux_text) values (?, ?, ?)", + [i, _f32([float(i)] * 4), f"aux_{i}"], + ) + + for i in range(1, 9): + db.execute("delete from v where rowid = ?", [i]) + + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 0 + assert db.execute("select count(*) from v_auxiliary").fetchone()[0] == 0 + + +def test_delete_with_partition_keys(db): + db.execute( + "create virtual table v using vec0(" + "part text partition key, emb float[4], chunk_size=8" + ")" + ) + + for i in range(1, 9): + db.execute( + "insert into v(rowid, part, emb) values (?, 'A', ?)", + [i, _f32([float(i)] * 4)], + ) + for i in range(9, 17): + db.execute( + "insert into v(rowid, part, emb) values (?, 'B', ?)", + [i, _f32([float(i)] * 4)], + ) + + # Delete all from partition A + for i in range(1, 9): + db.execute("delete from v where rowid = ?", [i]) + + # 1 chunk should remain (partition B's) + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 1 + + # Partition B rows intact + for i in range(9, 17): + row = db.execute("select emb from v where rowid = ?", [i]).fetchone() + assert row[0] == _f32([float(i)] * 4) + + # Re-insert into partition A works + db.execute( + "insert into v(rowid, part, emb) values (100, 'A', ?)", + [_f32([99.0, 99.0, 99.0, 99.0])], + ) + row = db.execute("select emb from v where rowid = 100").fetchone() + assert row[0] == _f32([99.0, 99.0, 99.0, 99.0]) + + +def _file_db(tmp_path): + """Open a file-backed DB (required for page_count to shrink after VACUUM).""" + db = sqlite3.connect(str(tmp_path / "test.db")) + db.row_factory = sqlite3.Row + db.enable_load_extension(True) + db.load_extension(get_extension_path()) + db.enable_load_extension(False) + return db + + +def test_delete_chunk_shrinks_pages(tmp_path): + """Use large vectors (float[256]) so each chunk blob spans multiple pages, + making the page_count difference measurable after VACUUM.""" + dims = 256 + db = _file_db(tmp_path) + db.execute(f"create virtual table v using vec0(emb float[{dims}], chunk_size=8)") + + for i in range(1, 25): # 3 full chunks of 8 + db.execute( + "insert into v(rowid, emb) values (?, ?)", + [i, _f32([float(i)] * dims)], + ) + db.commit() + pages_before = db.execute("pragma page_count").fetchone()[0] + + # Delete all rows + for i in range(1, 25): + db.execute("delete from v where rowid = ?", [i]) + db.commit() + + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 0 + + db.execute("vacuum") + pages_after = db.execute("pragma page_count").fetchone()[0] + assert pages_after < pages_before, ( + f"page_count should shrink after deleting all chunks and vacuum: " + f"{pages_before} -> {pages_after}" + ) + db.close() + + +def test_delete_one_chunk_of_two_shrinks_pages(tmp_path): + """Use large vectors (float[256]) so each chunk blob spans multiple pages, + making the page_count difference measurable after VACUUM.""" + dims = 256 + db = _file_db(tmp_path) + db.execute(f"create virtual table v using vec0(emb float[{dims}], chunk_size=8)") + + for i in range(1, 17): # 2 full chunks of 8 + db.execute( + "insert into v(rowid, emb) values (?, ?)", + [i, _f32([float(i)] * dims)], + ) + db.commit() + pages_before = db.execute("pragma page_count").fetchone()[0] + + # Delete all rows from the first chunk (rows 1-8) + for i in range(1, 9): + db.execute("delete from v where rowid = ?", [i]) + db.commit() + + assert db.execute("select count(*) from v_chunks").fetchone()[0] == 1 + + db.execute("vacuum") + pages_after = db.execute("pragma page_count").fetchone()[0] + assert pages_after < pages_before, ( + f"page_count should shrink after deleting one chunk and vacuum: " + f"{pages_before} -> {pages_after}" + ) + + # Remaining rows still queryable after vacuum + for i in range(9, 17): + row = db.execute("select emb from v where rowid = ?", [i]).fetchone() + assert row[0] == _f32([float(i)] * dims) + db.close() diff --git a/tests/test-legacy-compat.py b/tests/test-legacy-compat.py index 1be0e3d2..9cefff2c 100644 --- a/tests/test-legacy-compat.py +++ b/tests/test-legacy-compat.py @@ -143,10 +143,9 @@ def test_legacy_name_conflict_has_no_optimize(legacy_db): def test_legacy_insert_after_chunk_deleted(legacy_db): # v0.1.6 declares _vector_chunks00 with "rowid PRIMARY KEY", which is not - # an alias for _rowid_. Once its only chunk is empty, optimize deletes it, + # an alias for _rowid_. Once its only chunk is empty, DELETE deletes it, # so the next insert creates chunk 2, whose _rowid_ would otherwise be 1. legacy_db.execute("DELETE FROM legacy_vectors") - legacy_db.execute("INSERT INTO legacy_vectors(legacy_vectors) VALUES ('optimize')") legacy_db.execute( "INSERT INTO legacy_vectors(rowid, emb) VALUES (100, ?)", [_f32([100.0, 0.0, 0.0, 0.0])], diff --git a/tests/test-optimize-reclaims-space.py b/tests/test-optimize-reclaims-space.py index 9123d637..25a24629 100644 --- a/tests/test-optimize-reclaims-space.py +++ b/tests/test-optimize-reclaims-space.py @@ -50,8 +50,10 @@ def test_optimize_reclaims_pages_with_autovacuum_incremental(tmp_path): db.commit() chunk_rows_after_insert = pragma_int(db, "select count(*) from v_chunks") - # Delete half the rows to create free space inside vec shadow tables - db.execute("delete from v where rowid > 128") + # Delete every other row to create free space inside vec shadow tables. + # Every chunk keeps some rows, so DELETE removes none of them and leaves the + # space for optimize to reclaim. + db.execute("delete from v where rowid % 2 = 0") db.commit() chunk_rows_after_delete = pragma_int(db, "select count(*) from v_chunks")