zeusdb-vector-database 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/NOTICE +9 -13
  2. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/PKG-INFO +53 -21
  3. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/README.md +52 -20
  4. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/pyproject.toml +11 -5
  5. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/src/zeusdb_vector_database/__init__.py +1 -1
  6. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/src/zeusdb_vector_database/vector_database.py +87 -6
  7. zeusdb_vector_database-0.7.0/vdb-core/Cargo.lock +1073 -0
  8. zeusdb_vector_database-0.7.0/vdb-core/Cargo.toml +59 -0
  9. zeusdb_vector_database-0.7.0/vdb-core/src/conversion.rs +128 -0
  10. zeusdb_vector_database-0.7.0/vdb-core/src/distance.rs +1871 -0
  11. zeusdb_vector_database-0.7.0/vdb-core/src/filter.rs +226 -0
  12. zeusdb_vector_database-0.7.0/vdb-core/src/graph/dump.rs +1642 -0
  13. zeusdb_vector_database-0.7.0/vdb-core/src/graph/levels.rs +129 -0
  14. zeusdb_vector_database-0.7.0/vdb-core/src/graph/mod.rs +758 -0
  15. zeusdb_vector_database-0.7.0/vdb-core/src/graph/mutable/insert.rs +538 -0
  16. zeusdb_vector_database-0.7.0/vdb-core/src/graph/mutable/mod.rs +1590 -0
  17. zeusdb_vector_database-0.7.0/vdb-core/src/graph/structure.rs +802 -0
  18. zeusdb_vector_database-0.7.0/vdb-core/src/graph/test_graph.rs +116 -0
  19. zeusdb_vector_database-0.7.0/vdb-core/src/graph/traverse.rs +320 -0
  20. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/construct.rs +459 -0
  21. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/graph_guard_tests.rs +478 -0
  22. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/input.rs +649 -0
  23. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/insert.rs +833 -0
  24. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/mod.rs +1186 -0
  25. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/persist.rs +448 -0
  26. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/search.rs +481 -0
  27. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/stats.rs +742 -0
  28. zeusdb_vector_database-0.7.0/vdb-core/src/hnsw_index/training.rs +536 -0
  29. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/vdb-core/src/lib.rs +10 -0
  30. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/vdb-core/src/logging.rs +14 -18
  31. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/vdb-core/src/persistence.rs +141 -128
  32. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/vdb-core/src/pq.rs +117 -9
  33. zeusdb_vector_database-0.7.0/vdb-core/src/rerank.rs +1697 -0
  34. zeusdb_vector_database-0.7.0/vdb-core/src/rng.rs +171 -0
  35. zeusdb_vector_database-0.7.0/vdb-core/src/test_vectors.rs +43 -0
  36. zeusdb_vector_database-0.5.0/LICENSES/hnsw_rs-Apache-2.0.txt +0 -176
  37. zeusdb_vector_database-0.5.0/LICENSES/hnsw_rs-MIT.txt +0 -25
  38. zeusdb_vector_database-0.5.0/vdb-core/Cargo.lock +0 -1681
  39. zeusdb_vector_database-0.5.0/vdb-core/Cargo.toml +0 -48
  40. zeusdb_vector_database-0.5.0/vdb-core/src/distance.rs +0 -1026
  41. zeusdb_vector_database-0.5.0/vdb-core/src/hnsw_index.rs +0 -8115
  42. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/.cargo-ok +0 -1
  43. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/.cargo_vcs_info.json +0 -6
  44. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/.gitignore +0 -9
  45. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/Cargo.lock +0 -1296
  46. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/Cargo.toml +0 -145
  47. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/Cargo.toml.orig +0 -111
  48. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/Changes.md +0 -59
  49. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/LICENSE-APACHE +0 -13
  50. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/LICENSE-MIT +0 -25
  51. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/README.md +0 -168
  52. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/ZEUSDB-PATCH.md +0 -469
  53. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/ann-glove25-angular.rs +0 -220
  54. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/ann-mnist-784-euclidean.rs +0 -162
  55. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/ann-sift1m-128-euclidean.rs +0 -196
  56. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/levensthein.rs +0 -63
  57. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/random.rs +0 -80
  58. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/utils/annhdf5.rs +0 -233
  59. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/examples/utils/mod.rs +0 -3
  60. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/api.rs +0 -94
  61. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/datamap.rs +0 -456
  62. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/filter.rs +0 -24
  63. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/flatten.rs +0 -200
  64. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/hnsw.rs +0 -2023
  65. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/hnswio.rs +0 -1721
  66. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/lib.rs +0 -30
  67. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/libext.rs +0 -1240
  68. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/src/prelude.rs +0 -11
  69. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/tests/deallocation_test.rs +0 -34
  70. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/tests/filtertest.rs +0 -266
  71. zeusdb_vector_database-0.5.0/vendor/hnsw_rs/tests/serpar.rs +0 -328
  72. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/LICENSE +0 -0
  73. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/src/zeusdb_vector_database/logging_config.py +0 -0
  74. {zeusdb_vector_database-0.5.0 → zeusdb_vector_database-0.7.0}/src/zeusdb_vector_database/py.typed +0 -0
@@ -6,19 +6,15 @@ This product includes software developed by:
6
6
  - The Python Software Foundation (https://www.python.org/psf/)
7
7
  - The Rust Project Developers (https://www.rust-lang.org/)
8
8
  - The PyO3 Project (https://pyo3.rs/)
9
- - Jean-pierreBoth (https://crates.io/crates/hnsw_rs/)
10
-
11
- The hnsw_rs crate, version 0.3.4, is vendored in source form at vendor/hnsw_rs
12
- and carries five ZeusDB patches, one to the reverse link layer assignment, one
13
- seeding level assignment so index builds are reproducible, one guarding the
14
- neighbour list overflow pop against stranding a point's last inbound link, one
15
- correcting the filtered search path so it cannot panic or return an empty
16
- result set, and one correcting the per-layer capacity reservation so a declared
17
- index size does not commit hundreds of times the memory it needs. See
18
- vendor/hnsw_rs/ZEUSDB-PATCH.md for the patch record. hnsw_rs is dual licensed
19
- under MIT or Apache-2.0.
20
-
21
- Third-party components may be licensed under their respective terms. See LICENSES/ for full license texts.
9
+
10
+ Releases up to and including 0.6.0 were built on a vendored copy of a Rust HNSW
11
+ crate by Jean-Pierre Both, dual licensed under MIT or Apache-2.0. From 0.7.0 the
12
+ graph structure, its traversal, its insert, its level stream and its on-disk
13
+ format are ZeusDB's own, and neither that crate nor its source is part of this
14
+ package. ZeusDB's graph reproduces the behaviour of that implementation, and the
15
+ acknowledgement is kept here for that reason.
16
+
17
+ Third-party components may be licensed under their respective terms.
22
18
 
23
19
  Licensed under the Apache License, Version 2.0 (the "License");
24
20
  you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeusdb-vector-database
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Requires-Dist: numpy>=2.2.6,<3.0.0
@@ -35,7 +35,7 @@ Project-URL: Repository, https://github.com/zeusdb/zeusdb-vector-database
35
35
  <td><strong>Meta</strong></td>
36
36
  <td>
37
37
  <a href="https://pypi.org/project/zeusdb-vector-database/"><img src="https://img.shields.io/pypi/v/zeusdb-vector-database?label=PyPI&color=blue"></a>&nbsp;
38
- <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.10%7C3.11%7C3.12%7C3.13-blue?logo=python&logoColor=ffdd54"></a>&nbsp;
38
+ <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.10%7C3.11%7C3.12%7C3.13%7C3.14-blue?logo=python&logoColor=ffdd54"></a>&nbsp;
39
39
  <a href="https://github.com/zeusdb/zeusdb-vector-database/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a>&nbsp;
40
40
  <a href="https://www.rust-lang.org"><img src="https://img.shields.io/badge/Powered%20by-Rust-black?logo=rust&logoColor=white" alt="Powered by Rust"></a>&nbsp;
41
41
  <a href="https://github.com/ZeusDB"><img src="https://github.com/user-attachments/assets/e140d900-1160-4eaa-85c0-2b3507a5f0f5" alt="ZeusDB"></a>&nbsp;
@@ -212,7 +212,7 @@ HNSWIndex(dim=8, space=cosine, m=16, ef_construction=200, expected_size=5, vecto
212
212
  | `dim` | `int` | `1536` | Dimensionality of the vectors to be indexed. Each vector must have this length. Must be positive. The default of 1536 matches the output dimensionality of OpenAI's `text-embedding-3-small` and `text-embedding-ada-002` models. |
213
213
  | `space` | `str` | `"cosine"`| Distance metric used for similarity search. One of `"cosine"`, `"l1"`, `"l2"`. Case-insensitive. |
214
214
  | `m` | `int` | `16` or `32`, see below | Number of bi-directional connections created for each new node, from 2 to 256. Higher `m` improves recall but increases index size and build time. |
215
- | `ef_construction`| `int` | `200` | Size of the dynamic list used during index construction. Must be positive. Larger values increase indexing time and memory, but improve quality. |
215
+ | `ef_construction`| `int` | `200` | Width of the candidate search each insertion runs. Must be positive. It costs build time and buys graph quality, and it changes neither search latency nor the size of the finished index. See below. |
216
216
  | `expected_size` | `int` | `10000` | Estimated number of records to be inserted, from 1 to 100,000,000. Used for preallocating internal data structures and for choosing the default `m`. Not a hard limit, see below. |
217
217
  | `quantization_config` | `dict` | `None` | Product Quantization configuration for memory-efficient vector compression. See [Product Quantization](#️-product-quantization). |
218
218
 
@@ -229,6 +229,24 @@ The upper bound of 100,000,000 exists because the graph reserves one slot per de
229
229
 
230
230
  **`m` starts at 2, not 1.** Layer assignment samples from a scale of `1 / ln(m)`, which is infinity at `m` of 1, so every point is redispatched uniformly across all 16 layers rather than following the exponential distribution the graph depends on. On 3,000 records of 32 dimensions, recall at 10 measured 0.0220 at `m` of 1 against 0.6880 at 2 and 1.0000 at 16.
231
231
 
232
+ **`ef_construction` governs insertion and nothing else.** It is the width of the candidate search each insertion runs, at the new point's own layer and at every layer below it. The descent above that layer runs at a width of 1 and ignores it. The candidates that search returns are the pool the neighbour selection draws from, so `ef_construction` sets the supply and `m` sets how much of it is kept.
233
+
234
+ **It does not change search latency or index size.** A node holds at most `2 × m` neighbours at layer zero and `m` above it whatever width found them, so the finished graph is the same size at every `ef_construction`. Measured over 50,000 records, mean query latency moved between 0.89 ms and 2.85 ms across a sixteenfold range of `ef_construction` with no trend in it, and resident memory ran backwards on one of the three datasets. What it does change is build time, which is linear in it above 100. On OpenAI embeddings of 1,536 dimensions, 50,000 records built in 62.7 s, 131.2 s, 262.8 s and 464.9 s at `ef_construction` 100, 200, 400 and 800. On GloVe of 100 dimensions the same four are 37.7 s, 75.0 s, 140.4 s and 239.1 s. Below 100 the curve flattens, because the fixed per-insert work stops being dominated by the candidate search.
235
+
236
+ **Recall stops improving at or near the default.** Recall at 10 over 50,000 records at the default `m` of 32, 500 queries, at the default search width:
237
+
238
+ | `ef_construction` | GloVe 100d | SIFT 128d | OpenAI 1536d |
239
+ |-------------------|------------|-----------|--------------|
240
+ | 50 | 0.9176 | 0.9982 | 0.9852 |
241
+ | 100 | 0.9400 | 0.9992 | 0.9912 |
242
+ | 200 | 0.9606 | 0.9996 | 0.9962 |
243
+ | 400 | 0.9684 | 0.9998 | 0.9972 |
244
+ | 800 | 0.9714 | 0.9996 | 0.9978 |
245
+
246
+ SIFT plateaus at 100 and OpenAI at 200. GloVe is the one dataset that keeps climbing, and there the cheaper move is the search width rather than the build. At `ef_construction` 200 GloVe returns 0.9606 at the default width and 0.9984 at `ef_search=500`, so widening the search buys 0.0378 on the graph already built where doubling `ef_construction` buys 0.0078 for 1.9 times the build time. Raise `ef_search` before raising this.
247
+
248
+ **Keep `ef_construction` above `2 × m`.** The neighbour selection heuristic runs only when the candidate list is longer than the neighbour budget, which is `2 × m` at layer zero, and the candidate list is exactly `ef_construction` long on any index holding more records than that. At or below the budget the graph keeps every candidate the search returned, in distance order, and prunes none of them. `create()` warns when the pair reaches that point. `m` is capped at 256, so `m=100` with the default `ef_construction` is enough to switch the heuristic off. The defaults are clear of it, 200 against a budget of 32 at `m` 16 and 64 at `m` 32.
249
+
232
250
  <br/>
233
251
 
234
252
  ### 2️⃣ Add Data to the Index
@@ -625,17 +643,21 @@ storage_mode_description: raw_only
625
643
 
626
644
  `get_stats()` returns a `str` to `str` map. It also carries `dimension`, `space`, `m`, `ef_construction`, `expected_size`, `index_type`, `raw_vectors_stored`, `quantized_codes_stored` and `storage_mode`, plus training and compression fields once quantization is configured.
627
645
 
628
- It is also where the memory figures live, on every index rather than only on a quantized one. `graph_memory_mb` is the HNSW graph, `raw_vectors_memory_mb` is the raw vector store and `total_memory_mb` is the sum of everything below. On a quantized index `quantized_codes_memory_mb` scales with the record count, while `codebook_memory_mb` and `sdc_table_memory_mb` are fixed by `dim`, `subvectors` and `bits` and do not move as records arrive. `raw_vectors_retained` states the mode's policy: `none_once_trained` for `quantized_only`, whose raw vectors are released when training completes, and `all_records` for `quantized_with_raw`.
646
+ It is also where the memory figures live, on every index rather than only on a quantized one. `graph_memory_mb` is the HNSW graph, `raw_vectors_memory_mb` is the raw vector store, `index_bookkeeping_memory_mb` is the hash tables that find a record, and `total_memory_mb` is the sum of everything below. On a quantized index `quantized_codes_memory_mb` scales with the record count, while `codebook_memory_mb` and `sdc_table_memory_mb` are fixed by `dim`, `subvectors` and `bits` and do not move as records arrive. `raw_vectors_retained` states the mode's policy: `none_once_trained` for `quantized_only`, whose raw vectors are released when training completes, and `all_records` for `quantized_with_raw`.
629
647
 
630
648
  **The graph is usually the largest of those figures, and on a memory optimized index it is nearly all of it.** It owns a second copy of every point on top of its neighbour lists, its sixteen per point layer headers and the counters around them. At 50,000 records of `dim=1536` a trained `quantized_only` index reports 98.1 MB of graph against 2.3 MB of codes and 7.5 MB of fixed tables.
631
649
 
632
- **`total_memory_mb` is not the resident set and it does not claim to be.** It prices the five structures above. The id maps, the metadata map, the hash table slots and the allocator's own headers and fragmentation sit outside it, and together they run at roughly 1,500 bytes per record whatever the dimension. Measured on three loaded indexes of 50,000 real 1,536-dimensional embeddings, one process each:
650
+ `index_bookkeeping_memory_mb` is what the index spends on finding a record rather than on holding one. Five hash tables carry a record, being `id_map`, `rev_map`, the metadata map, the raw vector store and the code store, and two of them carry a second copy of its id. It is proportional to the record count and independent of the dimension, at 265 bytes per record with no quantization, 334 under `quantized_with_raw` and 265 under `quantized_only` at 50,000 records.
651
+
652
+ **`total_memory_mb` is not the resident set and it does not claim to be.** It prices the six structures above, meaning the bytes the index asked the allocator for. What it leaves out is the allocator itself, being its own block headers, its rounding and its fragmentation, and that is a property of the platform rather than something the index holds. Measured on three loaded indexes of 50,000 real 1,536-dimensional embeddings, one process each, with the interpreter's own resident set subtracted:
633
653
 
634
- | storage mode | `total_memory_mb` | resident | ratio |
635
- |---|---:|---:|---:|
636
- | no quantization | 692.4 MB | 805.9 MiB | 1.16x |
637
- | `quantized_with_raw` | 401.2 MB | 474.8 MiB | 1.18x |
638
- | `quantized_only` | 107.8 MB | 181.4 MiB | 1.68x |
654
+ | storage mode | `total_memory_mb` | resident | share reported | unpriced |
655
+ |---|---:|---:|---:|---:|
656
+ | no quantization | 705.1 MB | 805.4 MiB | 0.88 | 2,103 B per record |
657
+ | `quantized_with_raw` | 415.9 MB | 473.3 MiB | 0.88 | 1,202 B per record |
658
+ | `quantized_only` | 119.7 MB | 181.0 MiB | 0.66 | 1,285 B per record |
659
+
660
+ The unpriced share is almost all allocator overhead on the graph, which asks for six small blocks per point. It runs 1.25 times the graph figure with no quantization, where the per point data block is 6,144 bytes and rounding is a small share of it, and 1.63 and 1.67 times under the two quantized modes, where that block is 48 bytes. That is why `quantized_only` reports the smallest share of its resident set.
639
661
 
640
662
  Size infrastructure from the resident column rather than the reported one. The reported figure is what the index holds; the process holds that plus what the allocator takes to hold it.
641
663
 
@@ -1012,13 +1034,22 @@ The relation is curved as well as sublinear. On dbpedia-openai the calibration m
1012
1034
 
1013
1035
  What the calibration asks for at 100,000 records, against what the data needs and against the fixed 2%. Every figure is measured on a built index through the ordinary search path, 1,000 queries at `top_k=10` against exact ground truth. The requirement is the smallest fetch on that same index reaching recall at 10 of 0.99, read off a sweep of the explicit `rerank` argument:
1014
1036
 
1015
- | dataset | calibrated fetch | measured requirement | the old fixed fetch | recall, calibrated | recall, fixed |
1016
- |---|---:|---:|---:|---:|---:|
1017
- | dbpedia-openai | 776 | 750 | 2,000 | 0.9905 | 0.9954 |
1018
- | glove-100 | 7,749 | 4,500 | 2,000 | 0.9962 | 0.9723 |
1019
- | sift-128 | 596 | 450 | 2,000 | 0.9941 | 1.0000 |
1037
+ | dataset | records | calibrated fetch | measured requirement | ratio | recall at 10 | mean query |
1038
+ |---|---:|---:|---:|---:|---:|---:|
1039
+ | dbpedia-openai | 50,000 | 554 | 860 | 0.64 | 0.9883 | 3.33 ms |
1040
+ | glove-100 | 50,000 | 6,534 | 3,060 | 2.14 | 0.9982 | 35.79 ms |
1041
+ | sift-128 | 50,000 | 465 | 370 | 1.26 | 0.9953 | 1.88 ms |
1042
+ | dbpedia-openai | 100,000 | 747 | 620 | 1.20 | 0.9907 | 5.02 ms |
1043
+ | glove-100 | 100,000 | 11,439 | 4,620 | 2.48 | 0.9985 | 140.08 ms |
1044
+ | sift-128 | 100,000 | 656 | 450 | 1.46 | 0.9968 | 3.19 ms |
1045
+
1046
+ **The margin is not the same on the three, and glove-100 carries most of it.** The rule is one measurement per index scaled by two constants that are the same everywhere, being a safety factor of 1.75 and a bias of 0.15 on the fitted exponent. Taking the fitted exponent alone with neither constant, the extrapolation to 100,000 records asks for 4,626 candidates on glove-100 against a requirement of 4,620, which is 1.001 times what that index needs. On the other two it asks for 0.49 and 0.59 times what they need. The constants exist for those two, and on glove-100 they are margin over a fetch that was already right.
1020
1047
 
1021
- The calibration clears the requirement on all three. **It does not land on it and it is not meant to**, since the measurement is taken on a tenth of the records with queries drawn from the corpus, where your queries will not be.
1048
+ Neither constant can come down without costing recall elsewhere. The constant that would bring glove-100 to the 1.20 ratio dbpedia-openai has at 100,000 records takes the dbpedia-openai fetch from 747 to 359, and a sweep of that same index reads recall 0.9787 at 360 candidates. dbpedia-openai also sits under 0.99 at 10,000, 25,000 and 50,000 records, at 0.9878, 0.9800 and 0.9883, so it has nothing to give back. What separates the datasets is the ratio between the requirement on a built index and the depth the calibration measures in the code ordering over its training sample, and the training sample cannot measure that ratio because the graph does not exist yet when it runs.
1049
+
1050
+ **If query time on glove-like data matters more to you than the last thousandth of recall, name `rerank` yourself.** On glove-100 at 50,000 records a fetch of 3,060 reads recall 0.9900 at 18.14 ms against 0.9982 at 35.79 ms, so half the query time buys back 0.008 of recall.
1051
+
1052
+ The two tables below carry fetch figures measured before the codebook seeding this release ships, which moved the calibrated fetch upward on glove-100. Read their ratios as the shape rather than as current figures.
1022
1053
 
1023
1054
  **On data with no resolvable structure no fetch works.** Once the group the codes cannot separate is smaller than `top_k`, the true top ten span groups, the distances between groups differ in the fourth decimal, and nothing reaches them. At 5,000 clusters over 25,000 records, being five records to a cluster, recall at 10 reaches 0.917 at a fetch of half the corpus, and uniform points on the sphere reach 0.859. Measure recall on your own data before you rely on quantization.
1024
1055
 
@@ -1136,7 +1167,7 @@ print("saved:", sorted(os.listdir("my_index.zdb")))
1136
1167
 
1137
1168
  *Output, with the progress lines omitted*
1138
1169
  ```
1139
- saved: ['config.json', 'hnsw_index.hnsw.data', 'hnsw_index.hnsw.graph', 'manifest.json', 'mappings.bin', 'metadata.json', 'vectors.bin']
1170
+ saved: ['config.json', 'hnsw_index.zdbgraph', 'manifest.json', 'mappings.bin', 'metadata.json', 'vectors.bin']
1140
1171
  ```
1141
1172
 
1142
1173
  <br />
@@ -1206,7 +1237,7 @@ print("saved:", sorted(os.listdir("quantized_index.zdb")))
1206
1237
  quantization active: True
1207
1238
  quantization active after load: True
1208
1239
  storage mode after load: quantized_active
1209
- saved: ['config.json', 'hnsw_index.hnsw.data', 'hnsw_index.hnsw.graph', 'manifest.json', 'mappings.bin', 'metadata.json', 'pq_centroids.bin', 'pq_codes.bin', 'quantization.json', 'vectors.bin']
1240
+ saved: ['config.json', 'hnsw_index.zdbgraph', 'manifest.json', 'mappings.bin', 'metadata.json', 'pq_centroids.bin', 'pq_codes.bin', 'quantization.json', 'vectors.bin']
1210
1241
  ```
1211
1242
 
1212
1243
  <br/>
@@ -1224,11 +1255,12 @@ my_index.zdb/
1224
1255
  ├── quantization.json # PQ configuration (if enabled)
1225
1256
  ├── pq_centroids.bin # Trained centroids (if PQ trained)
1226
1257
  ├── pq_codes.bin # Quantized codes (if PQ active)
1227
- ├── hnsw_index.hnsw.graph # HNSW graph structure
1228
- └── hnsw_index.hnsw.data # HNSW graph payload
1258
+ └── hnsw_index.zdbgraph # HNSW graph structure and payload
1229
1259
  ```
1230
1260
 
1231
- `manifest.json` lists both graph files under `files_included`. The load path restores the saved graph rather than rebuilding it, so both are required to reopen a directory holding records.
1261
+ `manifest.json` lists the graph file under `files_included`. The load path restores the saved graph rather than rebuilding it, so the file is required to reopen a directory holding records.
1262
+
1263
+ A directory saved by 0.6.0 or earlier holds `hnsw_index.hnsw.graph` and `hnsw_index.hnsw.data` instead. Opening it still works: the graph is rebuilt once from the stored records, and the next `.save()` writes the single file.
1232
1264
 
1233
1265
  <br/>
1234
1266
 
@@ -14,7 +14,7 @@
14
14
  <td><strong>Meta</strong></td>
15
15
  <td>
16
16
  <a href="https://pypi.org/project/zeusdb-vector-database/"><img src="https://img.shields.io/pypi/v/zeusdb-vector-database?label=PyPI&color=blue"></a>&nbsp;
17
- <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.10%7C3.11%7C3.12%7C3.13-blue?logo=python&logoColor=ffdd54"></a>&nbsp;
17
+ <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.10%7C3.11%7C3.12%7C3.13%7C3.14-blue?logo=python&logoColor=ffdd54"></a>&nbsp;
18
18
  <a href="https://github.com/zeusdb/zeusdb-vector-database/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a>&nbsp;
19
19
  <a href="https://www.rust-lang.org"><img src="https://img.shields.io/badge/Powered%20by-Rust-black?logo=rust&logoColor=white" alt="Powered by Rust"></a>&nbsp;
20
20
  <a href="https://github.com/ZeusDB"><img src="https://github.com/user-attachments/assets/e140d900-1160-4eaa-85c0-2b3507a5f0f5" alt="ZeusDB"></a>&nbsp;
@@ -191,7 +191,7 @@ HNSWIndex(dim=8, space=cosine, m=16, ef_construction=200, expected_size=5, vecto
191
191
  | `dim` | `int` | `1536` | Dimensionality of the vectors to be indexed. Each vector must have this length. Must be positive. The default of 1536 matches the output dimensionality of OpenAI's `text-embedding-3-small` and `text-embedding-ada-002` models. |
192
192
  | `space` | `str` | `"cosine"`| Distance metric used for similarity search. One of `"cosine"`, `"l1"`, `"l2"`. Case-insensitive. |
193
193
  | `m` | `int` | `16` or `32`, see below | Number of bi-directional connections created for each new node, from 2 to 256. Higher `m` improves recall but increases index size and build time. |
194
- | `ef_construction`| `int` | `200` | Size of the dynamic list used during index construction. Must be positive. Larger values increase indexing time and memory, but improve quality. |
194
+ | `ef_construction`| `int` | `200` | Width of the candidate search each insertion runs. Must be positive. It costs build time and buys graph quality, and it changes neither search latency nor the size of the finished index. See below. |
195
195
  | `expected_size` | `int` | `10000` | Estimated number of records to be inserted, from 1 to 100,000,000. Used for preallocating internal data structures and for choosing the default `m`. Not a hard limit, see below. |
196
196
  | `quantization_config` | `dict` | `None` | Product Quantization configuration for memory-efficient vector compression. See [Product Quantization](#️-product-quantization). |
197
197
 
@@ -208,6 +208,24 @@ The upper bound of 100,000,000 exists because the graph reserves one slot per de
208
208
 
209
209
  **`m` starts at 2, not 1.** Layer assignment samples from a scale of `1 / ln(m)`, which is infinity at `m` of 1, so every point is redispatched uniformly across all 16 layers rather than following the exponential distribution the graph depends on. On 3,000 records of 32 dimensions, recall at 10 measured 0.0220 at `m` of 1 against 0.6880 at 2 and 1.0000 at 16.
210
210
 
211
+ **`ef_construction` governs insertion and nothing else.** It is the width of the candidate search each insertion runs, at the new point's own layer and at every layer below it. The descent above that layer runs at a width of 1 and ignores it. The candidates that search returns are the pool the neighbour selection draws from, so `ef_construction` sets the supply and `m` sets how much of it is kept.
212
+
213
+ **It does not change search latency or index size.** A node holds at most `2 × m` neighbours at layer zero and `m` above it whatever width found them, so the finished graph is the same size at every `ef_construction`. Measured over 50,000 records, mean query latency moved between 0.89 ms and 2.85 ms across a sixteenfold range of `ef_construction` with no trend in it, and resident memory ran backwards on one of the three datasets. What it does change is build time, which is linear in it above 100. On OpenAI embeddings of 1,536 dimensions, 50,000 records built in 62.7 s, 131.2 s, 262.8 s and 464.9 s at `ef_construction` 100, 200, 400 and 800. On GloVe of 100 dimensions the same four are 37.7 s, 75.0 s, 140.4 s and 239.1 s. Below 100 the curve flattens, because the fixed per-insert work stops being dominated by the candidate search.
214
+
215
+ **Recall stops improving at or near the default.** Recall at 10 over 50,000 records at the default `m` of 32, 500 queries, at the default search width:
216
+
217
+ | `ef_construction` | GloVe 100d | SIFT 128d | OpenAI 1536d |
218
+ |-------------------|------------|-----------|--------------|
219
+ | 50 | 0.9176 | 0.9982 | 0.9852 |
220
+ | 100 | 0.9400 | 0.9992 | 0.9912 |
221
+ | 200 | 0.9606 | 0.9996 | 0.9962 |
222
+ | 400 | 0.9684 | 0.9998 | 0.9972 |
223
+ | 800 | 0.9714 | 0.9996 | 0.9978 |
224
+
225
+ SIFT plateaus at 100 and OpenAI at 200. GloVe is the one dataset that keeps climbing, and there the cheaper move is the search width rather than the build. At `ef_construction` 200 GloVe returns 0.9606 at the default width and 0.9984 at `ef_search=500`, so widening the search buys 0.0378 on the graph already built where doubling `ef_construction` buys 0.0078 for 1.9 times the build time. Raise `ef_search` before raising this.
226
+
227
+ **Keep `ef_construction` above `2 × m`.** The neighbour selection heuristic runs only when the candidate list is longer than the neighbour budget, which is `2 × m` at layer zero, and the candidate list is exactly `ef_construction` long on any index holding more records than that. At or below the budget the graph keeps every candidate the search returned, in distance order, and prunes none of them. `create()` warns when the pair reaches that point. `m` is capped at 256, so `m=100` with the default `ef_construction` is enough to switch the heuristic off. The defaults are clear of it, 200 against a budget of 32 at `m` 16 and 64 at `m` 32.
228
+
211
229
  <br/>
212
230
 
213
231
  ### 2️⃣ Add Data to the Index
@@ -604,17 +622,21 @@ storage_mode_description: raw_only
604
622
 
605
623
  `get_stats()` returns a `str` to `str` map. It also carries `dimension`, `space`, `m`, `ef_construction`, `expected_size`, `index_type`, `raw_vectors_stored`, `quantized_codes_stored` and `storage_mode`, plus training and compression fields once quantization is configured.
606
624
 
607
- It is also where the memory figures live, on every index rather than only on a quantized one. `graph_memory_mb` is the HNSW graph, `raw_vectors_memory_mb` is the raw vector store and `total_memory_mb` is the sum of everything below. On a quantized index `quantized_codes_memory_mb` scales with the record count, while `codebook_memory_mb` and `sdc_table_memory_mb` are fixed by `dim`, `subvectors` and `bits` and do not move as records arrive. `raw_vectors_retained` states the mode's policy: `none_once_trained` for `quantized_only`, whose raw vectors are released when training completes, and `all_records` for `quantized_with_raw`.
625
+ It is also where the memory figures live, on every index rather than only on a quantized one. `graph_memory_mb` is the HNSW graph, `raw_vectors_memory_mb` is the raw vector store, `index_bookkeeping_memory_mb` is the hash tables that find a record, and `total_memory_mb` is the sum of everything below. On a quantized index `quantized_codes_memory_mb` scales with the record count, while `codebook_memory_mb` and `sdc_table_memory_mb` are fixed by `dim`, `subvectors` and `bits` and do not move as records arrive. `raw_vectors_retained` states the mode's policy: `none_once_trained` for `quantized_only`, whose raw vectors are released when training completes, and `all_records` for `quantized_with_raw`.
608
626
 
609
627
  **The graph is usually the largest of those figures, and on a memory optimized index it is nearly all of it.** It owns a second copy of every point on top of its neighbour lists, its sixteen per point layer headers and the counters around them. At 50,000 records of `dim=1536` a trained `quantized_only` index reports 98.1 MB of graph against 2.3 MB of codes and 7.5 MB of fixed tables.
610
628
 
611
- **`total_memory_mb` is not the resident set and it does not claim to be.** It prices the five structures above. The id maps, the metadata map, the hash table slots and the allocator's own headers and fragmentation sit outside it, and together they run at roughly 1,500 bytes per record whatever the dimension. Measured on three loaded indexes of 50,000 real 1,536-dimensional embeddings, one process each:
629
+ `index_bookkeeping_memory_mb` is what the index spends on finding a record rather than on holding one. Five hash tables carry a record, being `id_map`, `rev_map`, the metadata map, the raw vector store and the code store, and two of them carry a second copy of its id. It is proportional to the record count and independent of the dimension, at 265 bytes per record with no quantization, 334 under `quantized_with_raw` and 265 under `quantized_only` at 50,000 records.
630
+
631
+ **`total_memory_mb` is not the resident set and it does not claim to be.** It prices the six structures above, meaning the bytes the index asked the allocator for. What it leaves out is the allocator itself, being its own block headers, its rounding and its fragmentation, and that is a property of the platform rather than something the index holds. Measured on three loaded indexes of 50,000 real 1,536-dimensional embeddings, one process each, with the interpreter's own resident set subtracted:
612
632
 
613
- | storage mode | `total_memory_mb` | resident | ratio |
614
- |---|---:|---:|---:|
615
- | no quantization | 692.4 MB | 805.9 MiB | 1.16x |
616
- | `quantized_with_raw` | 401.2 MB | 474.8 MiB | 1.18x |
617
- | `quantized_only` | 107.8 MB | 181.4 MiB | 1.68x |
633
+ | storage mode | `total_memory_mb` | resident | share reported | unpriced |
634
+ |---|---:|---:|---:|---:|
635
+ | no quantization | 705.1 MB | 805.4 MiB | 0.88 | 2,103 B per record |
636
+ | `quantized_with_raw` | 415.9 MB | 473.3 MiB | 0.88 | 1,202 B per record |
637
+ | `quantized_only` | 119.7 MB | 181.0 MiB | 0.66 | 1,285 B per record |
638
+
639
+ The unpriced share is almost all allocator overhead on the graph, which asks for six small blocks per point. It runs 1.25 times the graph figure with no quantization, where the per point data block is 6,144 bytes and rounding is a small share of it, and 1.63 and 1.67 times under the two quantized modes, where that block is 48 bytes. That is why `quantized_only` reports the smallest share of its resident set.
618
640
 
619
641
  Size infrastructure from the resident column rather than the reported one. The reported figure is what the index holds; the process holds that plus what the allocator takes to hold it.
620
642
 
@@ -991,13 +1013,22 @@ The relation is curved as well as sublinear. On dbpedia-openai the calibration m
991
1013
 
992
1014
  What the calibration asks for at 100,000 records, against what the data needs and against the fixed 2%. Every figure is measured on a built index through the ordinary search path, 1,000 queries at `top_k=10` against exact ground truth. The requirement is the smallest fetch on that same index reaching recall at 10 of 0.99, read off a sweep of the explicit `rerank` argument:
993
1015
 
994
- | dataset | calibrated fetch | measured requirement | the old fixed fetch | recall, calibrated | recall, fixed |
995
- |---|---:|---:|---:|---:|---:|
996
- | dbpedia-openai | 776 | 750 | 2,000 | 0.9905 | 0.9954 |
997
- | glove-100 | 7,749 | 4,500 | 2,000 | 0.9962 | 0.9723 |
998
- | sift-128 | 596 | 450 | 2,000 | 0.9941 | 1.0000 |
1016
+ | dataset | records | calibrated fetch | measured requirement | ratio | recall at 10 | mean query |
1017
+ |---|---:|---:|---:|---:|---:|---:|
1018
+ | dbpedia-openai | 50,000 | 554 | 860 | 0.64 | 0.9883 | 3.33 ms |
1019
+ | glove-100 | 50,000 | 6,534 | 3,060 | 2.14 | 0.9982 | 35.79 ms |
1020
+ | sift-128 | 50,000 | 465 | 370 | 1.26 | 0.9953 | 1.88 ms |
1021
+ | dbpedia-openai | 100,000 | 747 | 620 | 1.20 | 0.9907 | 5.02 ms |
1022
+ | glove-100 | 100,000 | 11,439 | 4,620 | 2.48 | 0.9985 | 140.08 ms |
1023
+ | sift-128 | 100,000 | 656 | 450 | 1.46 | 0.9968 | 3.19 ms |
1024
+
1025
+ **The margin is not the same on the three, and glove-100 carries most of it.** The rule is one measurement per index scaled by two constants that are the same everywhere, being a safety factor of 1.75 and a bias of 0.15 on the fitted exponent. Taking the fitted exponent alone with neither constant, the extrapolation to 100,000 records asks for 4,626 candidates on glove-100 against a requirement of 4,620, which is 1.001 times what that index needs. On the other two it asks for 0.49 and 0.59 times what they need. The constants exist for those two, and on glove-100 they are margin over a fetch that was already right.
999
1026
 
1000
- The calibration clears the requirement on all three. **It does not land on it and it is not meant to**, since the measurement is taken on a tenth of the records with queries drawn from the corpus, where your queries will not be.
1027
+ Neither constant can come down without costing recall elsewhere. The constant that would bring glove-100 to the 1.20 ratio dbpedia-openai has at 100,000 records takes the dbpedia-openai fetch from 747 to 359, and a sweep of that same index reads recall 0.9787 at 360 candidates. dbpedia-openai also sits under 0.99 at 10,000, 25,000 and 50,000 records, at 0.9878, 0.9800 and 0.9883, so it has nothing to give back. What separates the datasets is the ratio between the requirement on a built index and the depth the calibration measures in the code ordering over its training sample, and the training sample cannot measure that ratio because the graph does not exist yet when it runs.
1028
+
1029
+ **If query time on glove-like data matters more to you than the last thousandth of recall, name `rerank` yourself.** On glove-100 at 50,000 records a fetch of 3,060 reads recall 0.9900 at 18.14 ms against 0.9982 at 35.79 ms, so half the query time buys back 0.008 of recall.
1030
+
1031
+ The two tables below carry fetch figures measured before the codebook seeding this release ships, which moved the calibrated fetch upward on glove-100. Read their ratios as the shape rather than as current figures.
1001
1032
 
1002
1033
  **On data with no resolvable structure no fetch works.** Once the group the codes cannot separate is smaller than `top_k`, the true top ten span groups, the distances between groups differ in the fourth decimal, and nothing reaches them. At 5,000 clusters over 25,000 records, being five records to a cluster, recall at 10 reaches 0.917 at a fetch of half the corpus, and uniform points on the sphere reach 0.859. Measure recall on your own data before you rely on quantization.
1003
1034
 
@@ -1115,7 +1146,7 @@ print("saved:", sorted(os.listdir("my_index.zdb")))
1115
1146
 
1116
1147
  *Output, with the progress lines omitted*
1117
1148
  ```
1118
- saved: ['config.json', 'hnsw_index.hnsw.data', 'hnsw_index.hnsw.graph', 'manifest.json', 'mappings.bin', 'metadata.json', 'vectors.bin']
1149
+ saved: ['config.json', 'hnsw_index.zdbgraph', 'manifest.json', 'mappings.bin', 'metadata.json', 'vectors.bin']
1119
1150
  ```
1120
1151
 
1121
1152
  <br />
@@ -1185,7 +1216,7 @@ print("saved:", sorted(os.listdir("quantized_index.zdb")))
1185
1216
  quantization active: True
1186
1217
  quantization active after load: True
1187
1218
  storage mode after load: quantized_active
1188
- saved: ['config.json', 'hnsw_index.hnsw.data', 'hnsw_index.hnsw.graph', 'manifest.json', 'mappings.bin', 'metadata.json', 'pq_centroids.bin', 'pq_codes.bin', 'quantization.json', 'vectors.bin']
1219
+ saved: ['config.json', 'hnsw_index.zdbgraph', 'manifest.json', 'mappings.bin', 'metadata.json', 'pq_centroids.bin', 'pq_codes.bin', 'quantization.json', 'vectors.bin']
1189
1220
  ```
1190
1221
 
1191
1222
  <br/>
@@ -1203,11 +1234,12 @@ my_index.zdb/
1203
1234
  ├── quantization.json # PQ configuration (if enabled)
1204
1235
  ├── pq_centroids.bin # Trained centroids (if PQ trained)
1205
1236
  ├── pq_codes.bin # Quantized codes (if PQ active)
1206
- ├── hnsw_index.hnsw.graph # HNSW graph structure
1207
- └── hnsw_index.hnsw.data # HNSW graph payload
1237
+ └── hnsw_index.zdbgraph # HNSW graph structure and payload
1208
1238
  ```
1209
1239
 
1210
- `manifest.json` lists both graph files under `files_included`. The load path restores the saved graph rather than rebuilding it, so both are required to reopen a directory holding records.
1240
+ `manifest.json` lists the graph file under `files_included`. The load path restores the saved graph rather than rebuilding it, so the file is required to reopen a directory holding records.
1241
+
1242
+ A directory saved by 0.6.0 or earlier holds `hnsw_index.hnsw.graph` and `hnsw_index.hnsw.data` instead. Opening it still works: the graph is rebuilt once from the stored records, and the next `.save()` writes the single file.
1211
1243
 
1212
1244
  <br/>
1213
1245
 
@@ -1,13 +1,19 @@
1
1
  [project]
2
2
  name = "zeusdb-vector-database"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "Blazing-fast vector DB with real-time similarity search and metadata filtering."
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "ZeusDB", email = "contact@zeusdb.com" }
8
8
  ]
9
+ # The floor and the abi3 target move together. vdb-core/Cargo.toml enables
10
+ # pyo3/abi3-py310, so the wheel is tagged cp310-abi3 and pip on 3.10 selects
11
+ # it. Raising one without the other leaves a version with no wheel that pip
12
+ # can match, which falls back to building the sdist from source.
9
13
  requires-python = ">=3.10"
10
14
  license = "Apache-2.0"
15
+ # CPython only, and now literally so. A stable ABI wheel cannot be loaded by
16
+ # PyPy or by a free-threaded build, so no wheel is produced for either.
11
17
  classifiers = [
12
18
  "Programming Language :: Rust",
13
19
  "Programming Language :: Python :: Implementation :: CPython",
@@ -21,20 +27,20 @@ requires = ["maturin>=1.9.1,<2.0"]
21
27
  build-backend = "maturin"
22
28
 
23
29
  [tool.maturin]
30
+ # abi3-py310 is deliberately not listed here. maturin has no abi3 key and
31
+ # reads the target from the pyo3 cargo feature, which is declared in
32
+ # vdb-core/Cargo.toml instead, so that cargo build, cargo clippy and cargo
33
+ # test compile the configuration the wheel ships rather than a different one.
24
34
  features = ["pyo3/extension-module"]
25
35
  python-source = "src"
26
36
  manifest-path = "vdb-core/Cargo.toml"
27
37
  module-name = "zeusdb_vector_database" # <-- matches Rust crate name
28
38
  bindings = "pyo3" # <-- explicitly state to maturin that it's a PyO3 project
29
- # vendor/** must ship in the sdist, or an sdist build cannot resolve the
30
- # patched hnsw_rs path dependency declared in vdb-core/Cargo.toml.
31
39
  include = [
32
40
  "LICENSE",
33
41
  "NOTICE",
34
42
  "README.md",
35
43
  "src/**",
36
- { path = "vendor/**/*", format = "sdist" },
37
- { path = "LICENSES/*", format = "sdist" },
38
44
  ]
39
45
 
40
46
  [project.optional-dependencies]
@@ -5,7 +5,7 @@
5
5
  """
6
6
  ZeusDB Vector Database Module
7
7
  """
8
- __version__ = "0.5.0"
8
+ __version__ = "0.7.0"
9
9
 
10
10
  # STEP 1: Configure logging FIRST, before importing anything that uses Rust.
11
11
  from .logging_config import _auto_configure_logging
@@ -69,6 +69,71 @@ class VectorDatabase:
69
69
  return 16
70
70
  return 32 if expected_size > 25_000 else 16
71
71
 
72
+ @staticmethod
73
+ def _warn_if_selection_disabled(m: Any, ef_construction: Any) -> None:
74
+ """Warn where ef_construction switches the neighbour selection heuristic off.
75
+
76
+ `select_neighbours` in the vendored crate opens with
77
+ `if candidates.len() <= nb_neighbours_asked`, and with
78
+ `extend_candidates` false it copies every candidate into the neighbour
79
+ list and returns from inside that branch. The budget is `2 * m` at
80
+ layer zero and `m` above it, both set at `hnsw.rs:1270` to `:1278`.
81
+
82
+ The candidate list is exactly `ef_construction` long once the index
83
+ holds more records than that, which is a property of `search_layer`
84
+ rather than an assumption. Insertion passes it no filter, so every
85
+ point it pushes into the candidate heap it also pushes into the result
86
+ heap, and the result heap is trimmed only above `ef`. While the result
87
+ heap is short it therefore holds every visited point, which makes the
88
+ nearest unexplored candidate a member of it and leaves the early
89
+ return unable to fire. The traversal
90
+ therefore stops either at `ef` results or with the whole reachable
91
+ component visited. Measured at m 8 over 2,000 records, mean layer zero
92
+ out-degree is 16.000 at ef_construction 16 with every node at the cap,
93
+ and 7.170 at ef_construction 17 with 6.5 percent of nodes at it, so the
94
+ flip lands exactly on `2 * m` and needs no margin. That measurement is
95
+ `neighbour_selection_threshold_is_twice_m` in the Rust guard tests.
96
+
97
+ The threshold is therefore `ef_construction <= 2 * m` and the warning
98
+ carries no slack. Slack would claim something the source does not say
99
+ and would fire on m 64 with the default ef_construction, which measured
100
+ higher recall on real embeddings than the default m of 32 does.
101
+
102
+ A pair the Rust layer is going to reject returns without warning, so
103
+ that an invalid m or ef_construction produces its real validation error
104
+ and nothing else. That covers a non-integer of either, an
105
+ ef_construction below 1, and an m outside the 2 to 256 the graph
106
+ accepts.
107
+ """
108
+ integral = (
109
+ isinstance(m, int) and not isinstance(m, bool)
110
+ and isinstance(ef_construction, int)
111
+ and not isinstance(ef_construction, bool)
112
+ )
113
+ if not integral or ef_construction < 1 or not 2 <= m <= 256:
114
+ return
115
+
116
+ budget = 2 * m
117
+ if ef_construction > budget:
118
+ return
119
+
120
+ # The largest m that leaves the heuristic running at this
121
+ # ef_construction. Below the floor of 2 there is no such m, so the
122
+ # message offers the one remedy that exists.
123
+ largest_m = (ef_construction - 1) // 2
124
+ remedy = f"Raise ef_construction above {budget}"
125
+ if largest_m >= 2:
126
+ remedy += f", or lower m to {largest_m} or below"
127
+
128
+ import warnings
129
+ warnings.warn(
130
+ f"ef_construction={ef_construction} is not greater than 2*m={budget}, "
131
+ f"so the neighbour selection heuristic does not run. Layer zero "
132
+ f"insertion keeps every candidate the construction search returns, in "
133
+ f"distance order, and prunes none of them. {remedy}, to run it.",
134
+ UserWarning,
135
+ stacklevel=3
136
+ )
72
137
 
73
138
  def __init__(self):
74
139
  """Initialize the vector database factory."""
@@ -93,7 +158,17 @@ class VectorDatabase:
93
158
  expected_size honestly or set m directly. The floor is 2
94
159
  because at 1 the layer scale is infinite and the graph is
95
160
  degenerate.
96
- - ef_construction (int): Construction candidate list size (default: 200)
161
+ - ef_construction (int): Width of the candidate search each
162
+ insertion runs (default: 200). It sets the supply the
163
+ neighbour selection draws from, and m sets how much of it is
164
+ kept, so it changes neither search latency nor the size of
165
+ the finished index. Build time is linear in it above 100: at
166
+ 50,000 records of 1,536 dimensions, 62.7s, 131.2s, 262.8s and
167
+ 464.9s at 100, 200, 400 and 800. Recall at 10 plateaus at 100
168
+ on SIFT and at 200 on OpenAI embeddings, and on GloVe raising
169
+ ef_search on the graph already built buys more than raising
170
+ this. The neighbour selection heuristic runs only while this
171
+ is above 2 * m, and create() warns where it is not.
97
172
  - expected_size (int): Expected number of vectors (default: 10000,
98
173
  max: 100,000,000). Also selects the default m, see above. It is
99
174
  a capacity hint rather than a limit, and an index that grows
@@ -121,10 +196,11 @@ class VectorDatabase:
121
196
  Under 'quantized_only' the records collected for training are held at
122
197
  full width only until training completes, then released. get_stats()
123
198
  reports the codes, the raw vectors, the codebook, the centroid
124
- distance table and the graph, and sums them under total_memory_mb.
125
- That sum is what the index holds rather than what the process holds,
126
- since the id maps, the metadata map and the allocator's own headers
127
- sit outside it at roughly 1,500 bytes a record. Recall falls sharply,
199
+ distance table, the graph and the hash tables that find a record,
200
+ and sums them under total_memory_mb. That sum is what the index
201
+ asked the allocator for rather than what the process holds, since
202
+ the allocator's own headers and its rounding sit outside it at
203
+ between 1,200 and 2,100 bytes a record. Recall falls sharply,
128
204
  not slightly. Only 'quantized_with_raw' can recover it, by reranking
129
205
  against the raw vectors it keeps. Training triggers automatically on
130
206
  the first .add() call that reaches the training_size threshold, and
@@ -195,6 +271,10 @@ class VectorDatabase:
195
271
  kwargs.setdefault("expected_size", 10000)
196
272
  # After expected_size, since the graph degree is derived from it.
197
273
  kwargs.setdefault("m", self._default_m(kwargs["expected_size"]))
274
+ # After both, since the pair is what decides it. No default
275
+ # combination reaches it: the default m is 16 or 32, so the budget
276
+ # is 32 or 64, and the default ef_construction of 200 clears both.
277
+ self._warn_if_selection_disabled(kwargs["m"], kwargs["ef_construction"])
198
278
 
199
279
  # Validate and process quantization config
200
280
  if quantization_config is not None:
@@ -359,7 +439,8 @@ class VectorDatabase:
359
439
  "holds 0.69 times an unquantized index at 10,000 records and 0.59 "
360
440
  "times at 100,000. "
361
441
  "get_stats() reports the memory the codes, the raw vectors, the codebook, "
362
- "the centroid distance table and the graph hold once records are loaded, "
442
+ "the centroid distance table, the graph and the index bookkeeping hold "
443
+ "once records are loaded, "
363
444
  "and sums them under total_memory_mb. This mode is required for rerank "
364
445
  "and for exact vector reconstruction.",
365
446
  UserWarning,