epzero 0.3.0__tar.gz → 0.4.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 (67) hide show
  1. epzero-0.4.0/CHANGELOG.md +54 -0
  2. {epzero-0.3.0 → epzero-0.4.0}/CMakeLists.txt +2 -1
  3. {epzero-0.3.0 → epzero-0.4.0}/PKG-INFO +46 -7
  4. {epzero-0.3.0 → epzero-0.4.0}/bench/bench_recall.cpp +32 -3
  5. {epzero-0.3.0 → epzero-0.4.0}/fuzz/make_seeds.cpp +17 -0
  6. epzero-0.4.0/include/epzero/auto.h +70 -0
  7. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/database.h +1 -0
  8. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/distance.h +5 -0
  9. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/hnsw.h +66 -0
  10. {epzero-0.3.0 → epzero-0.4.0}/pyproject.toml +5 -2
  11. {epzero-0.3.0 → epzero-0.4.0}/python/README.md +44 -5
  12. {epzero-0.3.0 → epzero-0.4.0}/python/epzero/__init__.py +13 -5
  13. epzero-0.4.0/python/epzero/auto.py +146 -0
  14. {epzero-0.3.0 → epzero-0.4.0}/python/epzero/langchain.py +19 -1
  15. {epzero-0.3.0 → epzero-0.4.0}/python/epzero_py.cpp +198 -22
  16. epzero-0.4.0/python/tests/test_auto.py +162 -0
  17. epzero-0.4.0/src/auto.cpp +269 -0
  18. {epzero-0.3.0 → epzero-0.4.0}/src/database.cpp +33 -8
  19. {epzero-0.3.0 → epzero-0.4.0}/src/distance.cpp +51 -9
  20. {epzero-0.3.0 → epzero-0.4.0}/src/distance_avx2.cpp +24 -1
  21. {epzero-0.3.0 → epzero-0.4.0}/src/distance_avxvnni.cpp +24 -1
  22. {epzero-0.3.0 → epzero-0.4.0}/src/hnsw.cpp +351 -44
  23. epzero-0.4.0/src/pca.h +206 -0
  24. {epzero-0.3.0 → epzero-0.4.0}/src/recall.cpp +8 -2
  25. epzero-0.4.0/tests/auto_test.cpp +216 -0
  26. {epzero-0.3.0 → epzero-0.4.0}/tests/database_test.cpp +31 -0
  27. {epzero-0.3.0 → epzero-0.4.0}/tests/hnsw_test.cpp +135 -2
  28. {epzero-0.3.0 → epzero-0.4.0}/bench/bench_adaptive.cpp +0 -0
  29. {epzero-0.3.0 → epzero-0.4.0}/bench/bench_churn.cpp +0 -0
  30. {epzero-0.3.0 → epzero-0.4.0}/bench/bench_distance.cpp +0 -0
  31. {epzero-0.3.0 → epzero-0.4.0}/bench/bench_filter.cpp +0 -0
  32. {epzero-0.3.0 → epzero-0.4.0}/bench/bench_guard.cpp +0 -0
  33. {epzero-0.3.0 → epzero-0.4.0}/fuzz/common.h +0 -0
  34. {epzero-0.3.0 → epzero-0.4.0}/fuzz/fuzz_graph.cpp +0 -0
  35. {epzero-0.3.0 → epzero-0.4.0}/fuzz/fuzz_snapshot.cpp +0 -0
  36. {epzero-0.3.0 → epzero-0.4.0}/fuzz/fuzz_text.cpp +0 -0
  37. {epzero-0.3.0 → epzero-0.4.0}/fuzz/fuzz_wal.cpp +0 -0
  38. {epzero-0.3.0 → epzero-0.4.0}/fuzz/regressions/graph-empty-with-entry-point +0 -0
  39. {epzero-0.3.0 → epzero-0.4.0}/fuzz/regressions/graph-entry-level-overflow +0 -0
  40. {epzero-0.3.0 → epzero-0.4.0}/fuzz/regressions/graph-upper-link-to-base-node +0 -0
  41. {epzero-0.3.0 → epzero-0.4.0}/fuzz/regressions/snapshot-empty-with-entry-point +0 -0
  42. {epzero-0.3.0 → epzero-0.4.0}/fuzz/regressions/snapshot-huge-capacity +0 -0
  43. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/brute_force.h +0 -0
  44. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/dataset.h +0 -0
  45. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/parallel.h +0 -0
  46. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/recall.h +0 -0
  47. {epzero-0.3.0 → epzero-0.4.0}/include/epzero/text.h +0 -0
  48. {epzero-0.3.0 → epzero-0.4.0}/python/tests/test_epzero.py +0 -0
  49. {epzero-0.3.0 → epzero-0.4.0}/python/tests/test_langchain.py +0 -0
  50. {epzero-0.3.0 → epzero-0.4.0}/server/node_main.cpp +0 -0
  51. {epzero-0.3.0 → epzero-0.4.0}/src/brute_force.cpp +0 -0
  52. {epzero-0.3.0 → epzero-0.4.0}/src/dataset.cpp +0 -0
  53. {epzero-0.3.0 → epzero-0.4.0}/src/distance_avx512.cpp +0 -0
  54. {epzero-0.3.0 → epzero-0.4.0}/src/distance_avx512vnni.cpp +0 -0
  55. {epzero-0.3.0 → epzero-0.4.0}/src/distance_neon.cpp +0 -0
  56. {epzero-0.3.0 → epzero-0.4.0}/src/distance_scalar.cpp +0 -0
  57. {epzero-0.3.0 → epzero-0.4.0}/src/distance_wasm.cpp +0 -0
  58. {epzero-0.3.0 → epzero-0.4.0}/src/kernels.h +0 -0
  59. {epzero-0.3.0 → epzero-0.4.0}/src/rabitq.h +0 -0
  60. {epzero-0.3.0 → epzero-0.4.0}/src/text.cpp +0 -0
  61. {epzero-0.3.0 → epzero-0.4.0}/src/visited_pool.h +0 -0
  62. {epzero-0.3.0 → epzero-0.4.0}/tests/dataset_test.cpp +0 -0
  63. {epzero-0.3.0 → epzero-0.4.0}/tests/distance_test.cpp +0 -0
  64. {epzero-0.3.0 → epzero-0.4.0}/tests/recall_test.cpp +0 -0
  65. {epzero-0.3.0 → epzero-0.4.0}/tests/text_test.cpp +0 -0
  66. {epzero-0.3.0 → epzero-0.4.0}/tools/export_graph.cpp +0 -0
  67. {epzero-0.3.0 → epzero-0.4.0}/wasm/epzero_wasm.cpp +0 -0
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ **Automatic configuration.** `epzero.build(vectors, metric=...)` (C++: `epzero::build_auto` in
6
+ `epzero/auto.h`) configures an index from its data and the machine it is built on:
7
+
8
+ - From the data: 8-bit codes, whether to walk the graph on principal directions (at 256 dimensions or
9
+ more, when few directions hold most of the variance), the build passes, the re-rank, and the beam
10
+ width (`ef`) that reaches a recall target, calibrated on rows held out of the graph or on queries
11
+ you pass (`queries=`).
12
+ - From the machine: the prefetch setting, timed on this CPU, and every core.
13
+ - Measured on an Intel Xeon Gold 6226 and an AMD Threadripper PRO 9985WX: as fast as hand-tuned
14
+ settings (within 1-5%), recall 0.949-0.957 on real queries for a 0.95 target, and 1.3-4.1× Glass's
15
+ queries per second on GIST1M, OpenAI DBpedia and DEEP10M.
16
+ - Every setting can be fixed instead (`epzero.build(vectors, nav_dim=0, M=32)`, or `epzero.plan()`,
17
+ edit, build). `index.plan` shows each setting and why it was chosen.
18
+ - The choices are saved inside the index (`IndexProfile`, file format v4), and `search()` uses them
19
+ when called without settings. A loaded index re-applies its prefetch setting only on the same kind
20
+ of CPU (`epzero.machine()` names it).
21
+
22
+ **Faster search and builds.**
23
+
24
+ - `nav_dim`: walk the graph on 8-bit codes of principal directions, re-ranking with the full vectors.
25
+ With the other changes here, 2.8-4.2× Glass's queries per second on GIST1M and 1.6-2.7× on OpenAI
26
+ DBpedia embeddings at equal recall, on Intel and AMD servers.
27
+ - `refine_links()`: a second build pass in which every node searches the finished graph and
28
+ reconnects; `fill_free_links()` gives free link slots to two-hop neighbours.
29
+ - `fast_build`: build on the 8-bit codes.
30
+ - `rerank_z`: stop the re-rank early once the remaining candidates can't reach the top k.
31
+ - Queries are projected onto principal directions on the integer kernel; the AVX2 and AVX-VNNI integer
32
+ kernels finish with vector loads instead of a scalar tail.
33
+ - The prefetch tuner can fetch a record's every cache line; on a Xeon Gold 6226 that was worth up to
34
+ 1.7× on some datasets.
35
+ - PCA runs as blocked matrix products (choosing directions 5× faster on GIST1M), training runs on
36
+ every core, and index memory is zeroed on every core, which spreads it across NUMA nodes.
37
+
38
+ **Fixes.**
39
+
40
+ - `Database`: a write that could not be applied (the database full, or not yet trained) was written to
41
+ the log first, after which every open failed replaying it. Writes are now checked before they are
42
+ logged, and replacing a vector in a full database frees its slot before compacting.
43
+ - `RecallGuard` searched, and measured its reference, with the default re-rank depth; it now uses the
44
+ index's own settings and re-ranks the reference's whole beam. `explain()` and `trace()` likewise.
45
+ - The LangChain store copied every stored vector on each add (quadratic in the store's size).
46
+ - `pip install epzero` from source failed on machines with gRPC installed: it tried to build the
47
+ cluster node, whose protocol files aren't in the package.
48
+
49
+ **Compatibility.** Indexes without a profile are still written in the format older versions read
50
+ (v2, or v3 with principal directions); automatically configured ones are v4, which 0.3 cannot load.
51
+ `search()`'s `k`, `ef`, `rerank` and `rerank_z` now default to the index's profile, which for an index
52
+ built by hand is the old defaults (10, 64, 2 × k, off).
53
+
54
+ Earlier releases are described by their git tags (`v0.2.0`, `v0.3.0`).
@@ -48,6 +48,7 @@ endif()
48
48
  find_package(Threads REQUIRED)
49
49
 
50
50
  add_library(epzero_engine
51
+ src/auto.cpp
51
52
  src/distance.cpp
52
53
  src/distance_scalar.cpp
53
54
  src/hnsw.cpp
@@ -118,7 +119,7 @@ if(EPZERO_BUILD_TESTS)
118
119
  FetchContent_MakeAvailable(googletest)
119
120
 
120
121
  enable_testing()
121
- add_executable(epzero_tests tests/distance_test.cpp tests/hnsw_test.cpp tests/recall_test.cpp tests/dataset_test.cpp tests/database_test.cpp tests/text_test.cpp)
122
+ add_executable(epzero_tests tests/auto_test.cpp tests/distance_test.cpp tests/hnsw_test.cpp tests/recall_test.cpp tests/dataset_test.cpp tests/database_test.cpp tests/text_test.cpp)
122
123
  target_link_libraries(epzero_tests PRIVATE epzero_engine GTest::gtest_main)
123
124
  if(EMSCRIPTEN)
124
125
  # Runs under Node (CMAKE_CROSSCOMPILING_EMULATOR); workers are pre-spawned for the
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: epzero
3
- Version: 0.3.0
4
- Summary: An HNSW vector database built from scratch in C++20: SIMD search, filtered search, recall monitoring, crash-safe storage
3
+ Version: 0.4.0
4
+ Summary: A fast HNSW vector database in C++20 that configures itself: SIMD search, filtered search, recall monitoring, crash-safe storage
5
5
  Keywords: vector database,hnsw,ann,nearest neighbor,embeddings,rag,langchain
6
6
  Author: Lajat
7
7
  License: MIT
@@ -31,9 +31,14 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  An HNSW vector database written from scratch in C++20, with Python bindings.
33
33
 
34
- - **Fast search:** hand-written SIMD kernels (AVX2, AVX-512, NEON), chosen at runtime, and
35
- lock-free reads. On SIFT1M it serves 1.13× hnswlib's and 1.17× FAISS's queries per second at
36
- equal recall, on the same machine.
34
+ - **Configures itself:** `epzero.build(vectors)` picks compression, the graph build and the search
35
+ effort for your data and recall target, times memory prefetching on your CPU, saves the choices
36
+ inside the index and tells you why it made each one. Any setting can be overridden.
37
+ - **Fast search:** hand-written SIMD kernels (AVX2, AVX-512, VNNI, NEON) chosen at runtime, 8-bit
38
+ codes, an optional walk on principal directions, and lock-free reads. On dedicated Intel Xeon Gold
39
+ 6226 and AMD EPYC 7513 servers, one thread, at equal recall: 1.4-4.2× the queries per second of
40
+ Glass, the fastest library we compared, on GIST1M, OpenAI DBpedia and DEEP10M, and 1.0-1.3× on
41
+ GloVe-100 (with each dataset's best settings; see the project's scoreboard for every loss).
37
42
  - **Filtered search that picks its own strategy:** an exact scan, a filtered graph walk, or a
38
43
  two-hop walk (the idea behind ACORN), chosen per query by a cost model.
39
44
  - **Recall you can see:** `RecallGuard` measures recall on a sample of live queries with a
@@ -43,7 +48,8 @@ An HNSW vector database written from scratch in C++20, with Python bindings.
43
48
  - **Hybrid search:** `TextIndex` (BM25 keyword search) and `hybrid_search()`, which merges it
44
49
  with vector search by reciprocal rank fusion.
45
50
  - **Durable:** `Database` adds a write-ahead log with crash recovery and checkpoints.
46
- - **8-bit quantization** with exact re-ranking.
51
+ - **8-bit quantization** with exact re-ranking, stopped early per query once the rest can't reach
52
+ the top k.
47
53
  - **A LangChain vector store** in `epzero.langchain`.
48
54
 
49
55
  ```bash
@@ -51,12 +57,45 @@ pip install epzero # the engine
51
57
  pip install "epzero[langchain]" # plus the LangChain vector store
52
58
  ```
53
59
 
54
- ## Quick start
60
+ ## Quick start: let it configure itself
55
61
 
56
62
  ```python
57
63
  import numpy as np
58
64
  import epzero
59
65
 
66
+ vectors = np.random.rand(100_000, 768).astype(np.float32)
67
+ index = epzero.build(vectors, metric="cosine") # chooses every setting (below)
68
+ labels, distances = index.search(vectors[:5], k=10) # at the effort calibrated for recall 0.95
69
+ print(index.plan) # what it chose, and why
70
+ ```
71
+
72
+ `build` decides from the data: 8-bit compression, whether to walk on principal directions (high
73
+ dimensions with a concentrated spectrum), the build passes, the re-rank, and the beam width that
74
+ reaches your recall target, measured on rows held out of the graph. From the machine: the SIMD
75
+ kernel, the prefetch setting (timed on this CPU) and every core. Change anything:
76
+
77
+ ```python
78
+ index = epzero.build(vectors, metric="cosine", recall=0.99) # a different target
79
+ index = epzero.build(vectors, metric="cosine", queries=my_queries) # calibrate on real queries
80
+ index = epzero.build(vectors, metric="cosine", nav_dim=0, refine=False, M=32) # override choices
81
+ plan = epzero.plan(vectors, metric="cosine") # look before building, edit, then pass it in
82
+ index = epzero.build(vectors, plan=plan.replace(ef_construction=400))
83
+ labels, distances = index.search(queries, k=50, ef=200) # per-call overrides
84
+ index.save("my.epz") # the settings and reasons are saved inside it
85
+ index = epzero.Index.load("my.epz") # search() uses them again
86
+ ```
87
+
88
+ `build` returns an ordinary `epzero.Index`: filters, deletes, `explain()` and `RecallGuard` all use
89
+ its settings. A saved index re-applies its prefetch setting only on the same kind of CPU (call
90
+ `tune_prefetch()` on another). The decisions are made in the C++ engine (`epzero/auto.h`), so C++
91
+ users get the same `build_auto()`.
92
+
93
+ If your queries differ from your data (text queries against image vectors, say), pass a few
94
+ hundred of them as `queries=`: recall calibrated on data rows is optimistic for those.
95
+
96
+ ## Configured by hand
97
+
98
+ ```python
60
99
  vectors = np.random.rand(100_000, 128).astype(np.float32)
61
100
  index = epzero.Index(dim=128, metric="l2", max_elements=100_000, M=16, ef_construction=200)
62
101
  index.add(vectors) # labels 0..n-1 (or pass labels=)
@@ -40,6 +40,15 @@ struct Options {
40
40
  std::size_t repeat = 1; // passes over the query set, so many-thread runs last long enough to time
41
41
  Quantization quantization = Quantization::None;
42
42
  bool keep_vectors = true;
43
+ std::size_t nav_dim = 0;
44
+ bool fill_links = false;
45
+ bool fast_build = false;
46
+ bool fill_after = false;
47
+ std::size_t refine = 0;
48
+ bool refine_exact = false;
49
+ std::uint64_t seed = 42;
50
+ float rerank_z = 0.0f;
51
+ std::size_t refine_ef = 0;
43
52
  std::size_t rerank = 0;
44
53
  std::size_t prefetch_depth = 0, prefetch_lines = 0; // 0: the index's default
45
54
  std::vector<std::size_t> ef = {10, 20, 40, 80, 160, 320};
@@ -86,6 +95,15 @@ Options parse(int argc, char** argv) {
86
95
  }
87
96
  else if (arg == "--no-keep") o.keep_vectors = false;
88
97
  else if (arg == "--rerank") o.rerank = std::stoul(value());
98
+ else if (arg == "--nav-dim") o.nav_dim = std::stoul(value());
99
+ else if (arg == "--fill-links") o.fill_links = true;
100
+ else if (arg == "--fast-build") o.fast_build = true;
101
+ else if (arg == "--fill-after") o.fill_after = true;
102
+ else if (arg == "--refine") o.refine = std::stoul(value());
103
+ else if (arg == "--refine-exact") o.refine_exact = true;
104
+ else if (arg == "--seed") o.seed = std::stoull(value());
105
+ else if (arg == "--rerank-z") o.rerank_z = std::stof(value());
106
+ else if (arg == "--refine-ef") o.refine_ef = std::stoul(value());
89
107
  else if (arg == "--prefetch") { // depth,lines
90
108
  const auto v = parse_list(value());
91
109
  if (v.size() != 2) throw std::invalid_argument("--prefetch takes depth,lines");
@@ -171,8 +189,9 @@ int main(int argc, char** argv) try {
171
189
  std::printf("load %s in %.2fs\n", opt.load.c_str(), seconds_since(t0));
172
190
  } else {
173
191
  HnswParams params{.dim = dim, .metric = opt.metric, .max_elements = base.n, .M = opt.M,
174
- .ef_construction = opt.ef_construction, .quantization = opt.quantization,
175
- .keep_vectors = opt.keep_vectors};
192
+ .ef_construction = opt.ef_construction, .seed = opt.seed, .quantization = opt.quantization,
193
+ .keep_vectors = opt.keep_vectors, .nav_dim = opt.nav_dim, .fill_links = opt.fill_links,
194
+ .fast_build = opt.fast_build};
176
195
  loaded = std::make_unique<HnswIndex>(params);
177
196
  t0 = Clock::now();
178
197
  loaded->train(base.data.data(), std::min<std::size_t>(base.n, 100'000)); // no-op unquantized
@@ -184,6 +203,16 @@ int main(int argc, char** argv) try {
184
203
  });
185
204
  }
186
205
  for (auto& b : builders) b.join();
206
+ for (std::size_t pass = 0; pass < opt.refine; ++pass) {
207
+ const double refine_at = seconds_since(t0);
208
+ loaded->refine_links(opt.threads, opt.refine_ef, opt.refine_exact);
209
+ std::printf("refined pass %zu in %.2fs\n", pass + 1, seconds_since(t0) - refine_at);
210
+ }
211
+ if (opt.fill_after) {
212
+ const double links_at = seconds_since(t0);
213
+ const std::size_t added = loaded->fill_free_links(opt.threads);
214
+ std::printf("filled %zu links in %.2fs\n", added, seconds_since(t0) - links_at);
215
+ }
187
216
  build_s = seconds_since(t0);
188
217
  std::printf("build %.2fs, %.0f inserts/s (%zu threads, M=%zu, efc=%zu)\n", build_s, base.n / build_s,
189
218
  opt.threads, opt.M, opt.ef_construction);
@@ -230,7 +259,7 @@ int main(int argc, char** argv) try {
230
259
  const std::size_t q = i % queries.n;
231
260
  SearchStats stats;
232
261
  const auto start = Clock::now();
233
- auto found = index.search(queries.row(q), opt.k, {.ef = ef, .rerank = opt.rerank, .stats = &stats});
262
+ auto found = index.search(queries.row(q), opt.k, {.ef = ef, .rerank = opt.rerank, .rerank_z = opt.rerank_z, .stats = &stats});
234
263
  latencies[i] = std::chrono::duration<double, std::micro>(Clock::now() - start).count();
235
264
  distances_per_query[i] = stats.distance_computations;
236
265
  full_per_query[i] = stats.full_distance_computations;
@@ -29,6 +29,23 @@ int main(int argc, char** argv) {
29
29
  for (std::size_t i = 0; i < 40; ++i) index.add(i, vectors.data() + i * kDim);
30
30
  index.save((out / "snapshot" / "valid").string());
31
31
 
32
+ // Format v4: 8-bit codes and a profile (search settings, reasons, a machine name).
33
+ epzero::HnswParams quantized = small_params();
34
+ quantized.quantization = epzero::Quantization::SQ8;
35
+ epzero::HnswIndex profiled(quantized);
36
+ profiled.train(vectors.data(), 40);
37
+ for (std::size_t i = 0; i < 40; ++i) profiled.add(i, vectors.data() + i * kDim);
38
+ epzero::IndexProfile profile;
39
+ profile.ef = 24;
40
+ profile.rerank = 30;
41
+ profile.rerank_z = 3.0f;
42
+ profile.recall_target = 0.95f;
43
+ profile.refined = true;
44
+ profile.machine = "fuzz/scalar";
45
+ profile.notes = {{"ef", "calibrated"}, {"nav_dim", "off"}};
46
+ profiled.set_profile(profile);
47
+ profiled.save((out / "snapshot" / "valid-profile").string());
48
+
32
49
  const auto graph = index.export_graph();
33
50
  std::ofstream(out / "graph" / "valid", std::ios::binary)
34
51
  .write(reinterpret_cast<const char*>(graph.data()), static_cast<std::streamsize>(graph.size()));
@@ -0,0 +1,70 @@
1
+ #pragma once
2
+
3
+ // Automatic configuration: an index set up for its data and for the machine it is built on, with
4
+ // every choice overridable. The rules come from measurements in docs/performance.md.
5
+ //
6
+ // epzero::AutoOptions options;
7
+ // options.metric = epzero::Metric::Cosine;
8
+ // options.fixed.nav_dim = 0; // override any choice
9
+ // auto index = epzero::build_auto(data, nullptr, n, dim, options);
10
+ // index->profile(); // what it chose, and why
11
+ //
12
+ // From the data: 8-bit codes, whether to walk on principal directions, the build passes, the
13
+ // re-rank, and the beam width (ef) that reaches the recall target, calibrated on rows held out of
14
+ // the graph (or on queries you pass). From the machine: the prefetch setting, timed here, and every
15
+ // core. All of it is saved with the index; see IndexProfile.
16
+
17
+ #include <cstddef>
18
+ #include <cstdint>
19
+ #include <memory>
20
+ #include <optional>
21
+ #include <utility>
22
+
23
+ #include "epzero/hnsw.h"
24
+
25
+ namespace epzero {
26
+
27
+ // Settings build_auto() would otherwise choose. Any that is set is used as given.
28
+ struct AutoFixed {
29
+ std::optional<Quantization> quantization;
30
+ std::optional<std::size_t> nav_dim;
31
+ std::optional<bool> fast_build;
32
+ std::optional<bool> refine; // refine_links() after the build
33
+ std::optional<bool> fill_after; // fill_free_links() after the build
34
+ std::optional<std::size_t> rerank;
35
+ std::optional<float> rerank_z;
36
+ std::optional<std::size_t> ef; // skips calibration
37
+ std::optional<std::pair<std::size_t, std::size_t>> prefetch; // (depth, lines); skips timing
38
+ };
39
+
40
+ struct AutoOptions {
41
+ Metric metric = Metric::L2;
42
+ std::size_t M = 16;
43
+ std::size_t ef_construction = 200;
44
+ std::size_t k = 10; // the k the beam width is calibrated for
45
+ float recall = 0.95f; // the recall target
46
+ std::size_t threads = 0; // 0: every core
47
+ std::size_t capacity = 0; // vectors the index can hold (0: the rows given)
48
+ std::uint64_t seed = 42;
49
+ // Optional typical queries (query_count rows of dim floats) to calibrate on. Rows of the data are
50
+ // accurate when queries look like the data, and optimistic when they don't.
51
+ const float* queries = nullptr;
52
+ std::size_t query_count = 0;
53
+ AutoFixed fixed;
54
+ };
55
+
56
+ struct AutoPlan {
57
+ HnswParams params;
58
+ IndexProfile profile; // ef is 0 while it still has to be calibrated
59
+ bool calibrate = true; // build_auto will calibrate ef
60
+ bool tune = true; // build_auto will time prefetch settings
61
+ };
62
+
63
+ // What build_auto() would do with `data` (n rows of dim floats), without building.
64
+ AutoPlan plan_auto(const float* data, std::size_t n, std::size_t dim, const AutoOptions& options = {});
65
+
66
+ // Builds an index of `data` with labels[i] for row i (labels null: 0..n-1), configured automatically.
67
+ std::unique_ptr<HnswIndex> build_auto(const float* data, const std::uint64_t* labels, std::size_t n, std::size_t dim,
68
+ const AutoOptions& options = {});
69
+
70
+ } // namespace epzero
@@ -107,6 +107,7 @@ class Database {
107
107
  private:
108
108
  Database(std::string dir, const HnswParams& params, const DatabaseOptions& options);
109
109
  void recover();
110
+ void check_upserts(const std::uint64_t* labels, std::size_t n) const;
110
111
  void append(const std::vector<std::uint8_t>& bytes, bool sync);
111
112
  void apply_upsert(std::uint64_t label, const float* vector);
112
113
  void apply_record(const LogRecord& record);
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <cstddef>
4
4
  #include <cstdint>
5
+ #include <string>
5
6
  #include <string_view>
6
7
  #include <utility>
7
8
  #include <vector>
@@ -29,6 +30,10 @@ bool simd_level_supported(SimdLevel level) noexcept;
29
30
  // forces a specific (supported) level, which is how the benchmarks measure SIMD speedups.
30
31
  SimdLevel active_simd_level() noexcept;
31
32
 
33
+ // The running CPU's model name ("" if unknown) and the active SIMD level, e.g. "AMD EPYC 7513 32-Core
34
+ // Processor/avx2": what machine-specific settings, such as prefetching, were measured on.
35
+ std::string machine_name();
36
+
32
37
  // Returns nullptr if `level` is not supported on this machine.
33
38
  DistanceFn get_distance_fn(Metric metric, SimdLevel level) noexcept;
34
39
  DistanceFn get_distance_fn(Metric metric) noexcept;
@@ -40,6 +40,18 @@ struct HnswParams {
40
40
  // SQ8: also keep full-precision vectors, used to build a better graph and to re-rank the final
41
41
  // candidates exactly. Without them the index stores ~4x less vector data. RaBitQ requires them.
42
42
  bool keep_vectors = true;
43
+ // SQ8: walk the graph on 8-bit codes of this many principal directions, learned by train(), instead
44
+ // of one code per dimension (0 = off). Each scored node then reads and computes nav_dim bytes, not
45
+ // dim, and the final re-rank with full vectors recovers exact distances. For embeddings whose
46
+ // variance sits in far fewer directions than they have dimensions. Needs keep_vectors, and
47
+ // usually a larger SearchOptions::rerank than the default.
48
+ std::size_t nav_dim = 0;
49
+ // Building: when the diversity heuristic leaves a node's link list short, fill the free slots with
50
+ // the closest candidates it pruned. The slots are allocated either way. Not saved with the index.
51
+ bool fill_links = false;
52
+ // Building, with SQ8: search for neighbours and prune them on the codes instead of the full
53
+ // vectors. Not saved with the index: later inserts use full vectors.
54
+ bool fast_build = false;
43
55
  };
44
56
 
45
57
  struct SearchResult {
@@ -114,6 +126,10 @@ struct SearchOptions {
114
126
  // and as re-scoring all ef of them, to within 0.0003, and 3-13% more queries per second than 3 x k
115
127
  // where full vectors are large). Ignored otherwise.
116
128
  std::size_t rerank = 0;
129
+ // Stops the re-rank early (0 = off): candidates are scored in blocks, and scoring ends once the
130
+ // next one, placed by this query's gap between estimated and exact distances, is more than this
131
+ // many standard deviations past the k-th best. `rerank` is then only a cap. 3 is a safe value.
132
+ float rerank_z = 0.0f;
117
133
  SearchStats* stats = nullptr; // optional
118
134
  SearchTrace* trace = nullptr; // optional; costs nothing when null
119
135
  // Optional: only labels the filter allows are returned. Must come from this index's make_filter().
@@ -141,6 +157,27 @@ std::string_view to_string(MissExplanation::Cause cause) noexcept;
141
157
 
142
158
  // Hierarchical Navigable Small World index (Malkov & Yashunin, 2016).
143
159
  //
160
+ // How an index was configured, and why, saved in its file. search() callers that pass no settings
161
+ // use k, ef, rerank and rerank_z from here (bindings fill them in; the C++ SearchOptions defaults are
162
+ // unchanged). build_auto() fills it in; set_profile() sets it by hand.
163
+ struct IndexProfile {
164
+ std::size_t k = 10;
165
+ std::size_t ef = 64;
166
+ std::size_t rerank = 0;
167
+ float rerank_z = 0.0f;
168
+ float recall_target = 0.0f; // the recall ef was calibrated for (0: not calibrated)
169
+ float recall_measured = 0.0f; // what calibration reached
170
+ bool built_on_codes = false; // the build searched and pruned on 8-bit codes (fast_build)
171
+ bool refined = false; // refine_links ran after the build
172
+ bool filled = false; // fill_free_links ran after the build
173
+ // The prefetch setting chosen on `machine` (see machine_name()); a loaded index applies it only
174
+ // on the same kind of machine.
175
+ std::size_t prefetch_depth = std::numeric_limits<std::size_t>::max();
176
+ std::size_t prefetch_lines = 1;
177
+ std::string machine;
178
+ std::vector<std::pair<std::string, std::string>> notes; // setting, why it was chosen
179
+ };
180
+
144
181
  // Thread safety: add(), remove() and search() may be called concurrently from any number of
145
182
  // threads. train(), save() and compacted() must not run concurrently with add() or remove().
146
183
  //
@@ -199,6 +236,16 @@ class HnswIndex {
199
236
  // where the decoded approximations are used instead).
200
237
  std::unique_ptr<HnswIndex> compacted(std::size_t threads = 0) const;
201
238
 
239
+ // Once the index is built: gives each node's free layer-0 link slots to its closest two-hop
240
+ // neighbours. A cheaper way than fill_links to the same end, as links added here are never pruned
241
+ // again. Must not run concurrently with add() or remove(). Returns the number of links added.
242
+ std::size_t fill_free_links(std::size_t threads = 0);
243
+
244
+ // Once the index is built: every node searches the finished graph and reconnects, as its first
245
+ // neighbours were chosen from a sparser one. Must not run concurrently with add() or remove().
246
+ // `exact` searches and prunes on full vectors even on an index built with fast_build.
247
+ void refine_links(std::size_t threads = 0, std::size_t ef = 0, bool exact = false); // ef 0: ef_construction
248
+
202
249
  void save(const std::string& path) const;
203
250
  // Loads a snapshot; corrupt or truncated files throw. With max_bytes, a snapshot whose index
204
251
  // would allocate more than that is refused before allocating (for untrusted files).
@@ -242,6 +289,10 @@ class HnswIndex {
242
289
  std::pair<std::size_t, std::size_t> tune_prefetch(const float* queries, std::size_t n, std::size_t k = 10,
243
290
  std::size_t ef = 64);
244
291
 
292
+ const IndexProfile& profile() const noexcept { return profile_; }
293
+ // Not thread-safe against concurrent searches that read it; set it before serving.
294
+ void set_profile(IndexProfile profile) { profile_ = std::move(profile); }
295
+
245
296
  private:
246
297
  using id_t = std::uint32_t;
247
298
  struct Candidate {
@@ -270,6 +321,11 @@ class HnswIndex {
270
321
  // Full-precision vector: inside the layer-0 record (None) or in full_ (quantized + keep_vectors).
271
322
  const float* full_vector(id_t id) const noexcept;
272
323
  bool has_full_vectors() const noexcept { return !quantized() || params_.keep_vectors; }
324
+ // Bytes of SQ8 code per node, and the projection onto the walk's directions (nav_dim only).
325
+ std::size_t code_dim() const noexcept { return params_.nav_dim != 0 ? params_.nav_dim : params_.dim; }
326
+ void project(const float* v, float* out) const noexcept;
327
+ void project_query(const float* query, float* out) const;
328
+ void prepare_query_projection();
273
329
  const std::uint8_t* codes_of(id_t id) const noexcept {
274
330
  return reinterpret_cast<const std::uint8_t*>(record(id) + sizeof(float));
275
331
  }
@@ -342,6 +398,15 @@ class HnswIndex {
342
398
  // SQ8: per-dimension x = min + code * scale, and the kept full-precision vectors.
343
399
  std::vector<float> sq8_min_;
344
400
  std::vector<float> sq8_scale_;
401
+ bool walk_full_ = false;
402
+ bool build_exact_ = false; // refine_links(exact): pair_distance uses full vectors for now
403
+ std::vector<float> nav_basis_; // nav_dim: the directions, nav_dim rows of dim floats
404
+ DistanceFn dot_ = nullptr; // nav_dim: 1 - dot, to project a vector
405
+ // nav_dim, for queries: nav_basis_ rounded to int8, one row at a time, with each row's scale and
406
+ // sum. Not saved: rebuilt from nav_basis_.
407
+ std::vector<std::int8_t> nav_ibasis_;
408
+ std::vector<float> nav_iscale_;
409
+ std::vector<std::int64_t> nav_isum_;
345
410
  std::unique_ptr<float[], AlignedFree> full_;
346
411
 
347
412
  // RaBitQ: the centroid, the rotation's random signs, and the padded (power of two) dimension.
@@ -371,6 +436,7 @@ class HnswIndex {
371
436
 
372
437
  std::size_t prefetch_depth_ = std::numeric_limits<std::size_t>::max(); // everything up front
373
438
  std::size_t prefetch_lines_ = 1;
439
+ IndexProfile profile_;
374
440
  };
375
441
 
376
442
  } // namespace epzero
@@ -4,8 +4,8 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "epzero"
7
- version = "0.3.0"
8
- description = "An HNSW vector database built from scratch in C++20: SIMD search, filtered search, recall monitoring, crash-safe storage"
7
+ version = "0.4.0"
8
+ description = "A fast HNSW vector database in C++20 that configures itself: SIMD search, filtered search, recall monitoring, crash-safe storage"
9
9
  readme = "python/README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
@@ -41,6 +41,9 @@ wheel.packages = ["python/epzero"]
41
41
  EPZERO_BUILD_TESTS = "OFF"
42
42
  EPZERO_BUILD_BENCH = "OFF"
43
43
  EPZERO_BUILD_PYTHON = "ON"
44
+ # The cluster node needs ../proto, outside the package: built from an sdist on a machine with gRPC
45
+ # installed, it failed the whole install.
46
+ EPZERO_BUILD_NODE = "OFF"
44
47
 
45
48
  [tool.cibuildwheel]
46
49
  build = "cp310-* cp311-* cp312-* cp313-*"
@@ -2,9 +2,14 @@
2
2
 
3
3
  An HNSW vector database written from scratch in C++20, with Python bindings.
4
4
 
5
- - **Fast search:** hand-written SIMD kernels (AVX2, AVX-512, NEON), chosen at runtime, and
6
- lock-free reads. On SIFT1M it serves 1.13× hnswlib's and 1.17× FAISS's queries per second at
7
- equal recall, on the same machine.
5
+ - **Configures itself:** `epzero.build(vectors)` picks compression, the graph build and the search
6
+ effort for your data and recall target, times memory prefetching on your CPU, saves the choices
7
+ inside the index and tells you why it made each one. Any setting can be overridden.
8
+ - **Fast search:** hand-written SIMD kernels (AVX2, AVX-512, VNNI, NEON) chosen at runtime, 8-bit
9
+ codes, an optional walk on principal directions, and lock-free reads. On dedicated Intel Xeon Gold
10
+ 6226 and AMD EPYC 7513 servers, one thread, at equal recall: 1.4-4.2× the queries per second of
11
+ Glass, the fastest library we compared, on GIST1M, OpenAI DBpedia and DEEP10M, and 1.0-1.3× on
12
+ GloVe-100 (with each dataset's best settings; see the project's scoreboard for every loss).
8
13
  - **Filtered search that picks its own strategy:** an exact scan, a filtered graph walk, or a
9
14
  two-hop walk (the idea behind ACORN), chosen per query by a cost model.
10
15
  - **Recall you can see:** `RecallGuard` measures recall on a sample of live queries with a
@@ -14,7 +19,8 @@ An HNSW vector database written from scratch in C++20, with Python bindings.
14
19
  - **Hybrid search:** `TextIndex` (BM25 keyword search) and `hybrid_search()`, which merges it
15
20
  with vector search by reciprocal rank fusion.
16
21
  - **Durable:** `Database` adds a write-ahead log with crash recovery and checkpoints.
17
- - **8-bit quantization** with exact re-ranking.
22
+ - **8-bit quantization** with exact re-ranking, stopped early per query once the rest can't reach
23
+ the top k.
18
24
  - **A LangChain vector store** in `epzero.langchain`.
19
25
 
20
26
  ```bash
@@ -22,12 +28,45 @@ pip install epzero # the engine
22
28
  pip install "epzero[langchain]" # plus the LangChain vector store
23
29
  ```
24
30
 
25
- ## Quick start
31
+ ## Quick start: let it configure itself
26
32
 
27
33
  ```python
28
34
  import numpy as np
29
35
  import epzero
30
36
 
37
+ vectors = np.random.rand(100_000, 768).astype(np.float32)
38
+ index = epzero.build(vectors, metric="cosine") # chooses every setting (below)
39
+ labels, distances = index.search(vectors[:5], k=10) # at the effort calibrated for recall 0.95
40
+ print(index.plan) # what it chose, and why
41
+ ```
42
+
43
+ `build` decides from the data: 8-bit compression, whether to walk on principal directions (high
44
+ dimensions with a concentrated spectrum), the build passes, the re-rank, and the beam width that
45
+ reaches your recall target, measured on rows held out of the graph. From the machine: the SIMD
46
+ kernel, the prefetch setting (timed on this CPU) and every core. Change anything:
47
+
48
+ ```python
49
+ index = epzero.build(vectors, metric="cosine", recall=0.99) # a different target
50
+ index = epzero.build(vectors, metric="cosine", queries=my_queries) # calibrate on real queries
51
+ index = epzero.build(vectors, metric="cosine", nav_dim=0, refine=False, M=32) # override choices
52
+ plan = epzero.plan(vectors, metric="cosine") # look before building, edit, then pass it in
53
+ index = epzero.build(vectors, plan=plan.replace(ef_construction=400))
54
+ labels, distances = index.search(queries, k=50, ef=200) # per-call overrides
55
+ index.save("my.epz") # the settings and reasons are saved inside it
56
+ index = epzero.Index.load("my.epz") # search() uses them again
57
+ ```
58
+
59
+ `build` returns an ordinary `epzero.Index`: filters, deletes, `explain()` and `RecallGuard` all use
60
+ its settings. A saved index re-applies its prefetch setting only on the same kind of CPU (call
61
+ `tune_prefetch()` on another). The decisions are made in the C++ engine (`epzero/auto.h`), so C++
62
+ users get the same `build_auto()`.
63
+
64
+ If your queries differ from your data (text queries against image vectors, say), pass a few
65
+ hundred of them as `queries=`: recall calibrated on data rows is optimistic for those.
66
+
67
+ ## Configured by hand
68
+
69
+ ```python
31
70
  vectors = np.random.rand(100_000, 128).astype(np.float32)
32
71
  index = epzero.Index(dim=128, metric="l2", max_elements=100_000, M=16, ef_construction=200)
33
72
  index.add(vectors) # labels 0..n-1 (or pass labels=)
@@ -3,11 +3,14 @@
3
3
  import numpy as np
4
4
  import epzero
5
5
 
6
- index = epzero.Index(dim=128, metric="cosine")
7
- index.add(vectors) # labels default to 0..n-1
8
- labels, distances = index.search(queries, k=10, ef=64)
6
+ index = epzero.build(vectors, metric="cosine") # configured for the data and this machine
7
+ labels, distances = index.search(queries, k=10) # at the effort calibrated for recall 0.95
8
+ print(index.plan) # every setting, and why; pass any to override
9
9
 
10
- `Index` is the in-memory HNSW index (filtered search, 8-bit quantization, exact search and miss
10
+ `build` chooses compression, the build passes, the re-rank and the beam width from the data, and
11
+ times prefetching on this CPU (see epzero.auto); the settings are saved with the index. `Index` is
12
+ the same engine configured by hand:
13
+ the in-memory HNSW index (filtered search, 8-bit quantization, exact search and miss
11
14
  explanations); `Database` adds a write-ahead log and crash recovery; `RecallGuard` measures recall
12
15
  on a sample of live queries and moves the search effort to hold a target. `TextIndex` is keyword
13
16
  search (BM25), and `hybrid_search` merges it with vector search. A LangChain vector store is in
@@ -16,7 +19,8 @@ search (BM25), and `hybrid_search` merges it with vector search. A LangChain vec
16
19
 
17
20
  from importlib.metadata import PackageNotFoundError, version
18
21
 
19
- from ._core import Database, Filter, Index, RecallGuard, TextIndex, fuse_rankings, hybrid_search, simd_level
22
+ from ._core import Database, Filter, Index, RecallGuard, TextIndex, fuse_rankings, hybrid_search, machine, simd_level
23
+ from .auto import Plan, build, plan
20
24
 
21
25
  try:
22
26
  __version__ = version("epzero")
@@ -27,10 +31,14 @@ __all__ = [
27
31
  "Database",
28
32
  "Filter",
29
33
  "Index",
34
+ "Plan",
30
35
  "RecallGuard",
31
36
  "TextIndex",
37
+ "build",
32
38
  "fuse_rankings",
33
39
  "hybrid_search",
40
+ "machine",
41
+ "plan",
34
42
  "simd_level",
35
43
  "__version__",
36
44
  ]