lodedb 1.2.0__tar.gz → 1.3.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 (168) hide show
  1. {lodedb-1.2.0 → lodedb-1.3.0}/Cargo.toml +1 -1
  2. {lodedb-1.2.0 → lodedb-1.3.0}/PKG-INFO +50 -43
  3. {lodedb-1.2.0 → lodedb-1.3.0}/README.md +49 -42
  4. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/Cargo.toml +4 -0
  5. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/ann.rs +105 -34
  6. lodedb-1.3.0/crates/lodedb-core/tests/ann_build_bench.rs +237 -0
  7. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-gpu/src/lib.rs +511 -24
  8. {lodedb-1.2.0 → lodedb-1.3.0}/pyproject.toml +1 -1
  9. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/__init__.py +1 -1
  10. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/native_adapter.py +40 -4
  11. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/graph/knowledge_graph.py +25 -19
  12. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/db.py +233 -38
  13. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/Cargo.lock +3 -2
  14. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/src/lib.rs +27 -5
  15. {lodedb-1.2.0 → lodedb-1.3.0}/LICENSE +0 -0
  16. {lodedb-1.2.0 → lodedb-1.3.0}/NOTICE +0 -0
  17. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/filter_planner_bench.rs +0 -0
  18. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/turbovec_adapter_smoke.rs +0 -0
  19. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/write_generation_fixture.rs +0 -0
  20. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/write_wal_fixture.rs +0 -0
  21. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/engine/mod.rs +0 -0
  22. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/error.rs +0 -0
  23. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/ast.rs +0 -0
  24. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/doc_set.rs +0 -0
  25. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/field_index.rs +0 -0
  26. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/mod.rs +0 -0
  27. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/predicate.rs +0 -0
  28. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/resolve.rs +0 -0
  29. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/validate.rs +0 -0
  30. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/bm25.rs +0 -0
  31. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/mod.rs +0 -0
  32. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/rrf.rs +0 -0
  33. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/tokenize.rs +0 -0
  34. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lib.rs +0 -0
  35. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/commit_manifest.rs +0 -0
  36. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/legacy.rs +0 -0
  37. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/lexical_store.rs +0 -0
  38. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/lsn.rs +0 -0
  39. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/mod.rs +0 -0
  40. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/multivec_store.rs +0 -0
  41. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/state_journal.rs +0 -0
  42. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/text_store.rs +0 -0
  43. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/tvann_store.rs +0 -0
  44. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/tvim_delta.rs +0 -0
  45. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/util.rs +0 -0
  46. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/wal.rs +0 -0
  47. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/text/chunk.rs +0 -0
  48. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/text/hash.rs +0 -0
  49. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/text/mod.rs +0 -0
  50. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/types.rs +0 -0
  51. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/index.rs +0 -0
  52. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/math.rs +0 -0
  53. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/mod.rs +0 -0
  54. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/stable_id.rs +0 -0
  55. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/turbovec.rs +0 -0
  56. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/version.rs +0 -0
  57. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/core_engine.rs +0 -0
  58. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/filter_plan_fixtures.rs +0 -0
  59. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/identity_fixtures.rs +0 -0
  60. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/lexical_fixtures.rs +0 -0
  61. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/predicate_fixtures.rs +0 -0
  62. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/storage_fixtures.rs +0 -0
  63. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/turbovec_adapter.rs +0 -0
  64. {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-gpu/Cargo.toml +0 -0
  65. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/__main__.py +0 -0
  66. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/_native_core.py +0 -0
  67. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/config.py +0 -0
  68. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/__init__.py +0 -0
  69. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_atomic_io.py +0 -0
  70. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_commit_manifest.py +0 -0
  71. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_filelock.py +0 -0
  72. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_lexical.py +0 -0
  73. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_predicate.py +0 -0
  74. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/core.py +0 -0
  75. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/embedding_backends.py +0 -0
  76. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/gpu_turbovec.py +0 -0
  77. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/route_profiles.py +0 -0
  78. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/route_registry.py +0 -0
  79. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/runtime_policy.py +0 -0
  80. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/state_journal_store.py +0 -0
  81. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/turbovec_delta_store.py +0 -0
  82. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/turbovec_index.py +0 -0
  83. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/graph/__init__.py +0 -0
  84. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/graph/_store.py +0 -0
  85. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/__init__.py +0 -0
  86. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/appender.py +0 -0
  87. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/backends.py +0 -0
  88. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/benchmark.py +0 -0
  89. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/checkpointer.py +0 -0
  90. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/cli.py +0 -0
  91. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/collection.py +0 -0
  92. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/doctor.py +0 -0
  93. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/__init__.py +0 -0
  94. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/langchain.py +0 -0
  95. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/llama_index.py +0 -0
  96. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/llama_index_graph.py +0 -0
  97. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/mem0.py +0 -0
  98. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/privategpt.py +0 -0
  99. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/late_interaction.py +0 -0
  100. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/mcp_install.py +0 -0
  101. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/mcp_server.py +0 -0
  102. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/__init__.py +0 -0
  103. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/cli.py +0 -0
  104. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/detect.py +0 -0
  105. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/plan.py +0 -0
  106. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/report.py +0 -0
  107. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/runner.py +0 -0
  108. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/__init__.py +0 -0
  109. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/base.py +0 -0
  110. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/langchain_inmemory.py +0 -0
  111. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/llama_index_simple.py +0 -0
  112. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/mem0_qdrant.py +0 -0
  113. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/pgvector.py +0 -0
  114. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/onnx_artifacts.py +0 -0
  115. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/presets.py +0 -0
  116. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/server.py +0 -0
  117. {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/py.typed +0 -0
  118. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/Cargo.toml +0 -0
  119. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/Cargo.toml +0 -0
  120. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/README.md +0 -0
  121. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/build.rs +0 -0
  122. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/examples/dump_state.rs +0 -0
  123. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/examples/kernel_xtest.rs +0 -0
  124. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/codebook.rs +0 -0
  125. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/encode.rs +0 -0
  126. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/error.rs +0 -0
  127. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/id_map.rs +0 -0
  128. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/io.rs +0 -0
  129. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/lib.rs +0 -0
  130. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/maxsim.rs +0 -0
  131. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/pack.rs +0 -0
  132. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/rotation.rs +0 -0
  133. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/search.rs +0 -0
  134. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/codebook.rs +0 -0
  135. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/concurrent_search.rs +0 -0
  136. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/distortion.rs +0 -0
  137. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/encode.rs +0 -0
  138. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/encoded_rows.rs +0 -0
  139. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/filtering.rs +0 -0
  140. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/id_map.rs +0 -0
  141. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/input_validation.rs +0 -0
  142. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/io_versioning.rs +0 -0
  143. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/kernel_correctness.rs +0 -0
  144. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/lazy_init.rs +0 -0
  145. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/reconstruction.rs +0 -0
  146. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/rotation.rs +0 -0
  147. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/state_sequences.rs +0 -0
  148. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/swap_remove.rs +0 -0
  149. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/tqplus_calibration.rs +0 -0
  150. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/Cargo.toml +0 -0
  151. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/README.md +0 -0
  152. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/build.rs +0 -0
  153. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/__init__.py +0 -0
  154. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/_dedup.py +0 -0
  155. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/_persist.py +0 -0
  156. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/agno.py +0 -0
  157. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/haystack.py +0 -0
  158. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/langchain.py +0 -0
  159. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/llama_index.py +0 -0
  160. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_agno.py +0 -0
  161. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_dedup.py +0 -0
  162. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_filtering.py +0 -0
  163. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_haystack.py +0 -0
  164. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_id_map.py +0 -0
  165. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_index.py +0 -0
  166. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_langchain.py +0 -0
  167. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_llama_index.py +0 -0
  168. {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_security.py +0 -0
@@ -7,7 +7,7 @@ members = [
7
7
  resolver = "2"
8
8
 
9
9
  [workspace.package]
10
- version = "1.2.0"
10
+ version = "1.3.0"
11
11
  edition = "2021"
12
12
  rust-version = "1.70"
13
13
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lodedb
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -89,12 +89,12 @@ All numbers are reported as the mean of 3 independent runs on a L40S server. [Fu
89
89
  > and it will migrate your existing store onto the LodeDB backend.
90
90
 
91
91
  Most embedded vector databases stop at the CPU. LodeDB runs the same on-disk index **on the
92
- GPU** when you have one: batched search hits *24k queries/sec on an A10 and 50k qps on an L40S*,
93
- 2.8× to 4.8× the all-CPU ceiling, with recall unchanged. It also persists changed rows
94
- incrementally, so a commit stays **sub-millisecond even at 1M vectors**.
92
+ GPU** when you have one: batched search hits *24k queries/sec on an A10 and 53k qps on an L40S*,
93
+ with recall matching the CPU scan. It also persists changed rows incrementally, so a commit
94
+ stays **sub-millisecond even at 1M vectors**.
95
95
 
96
- - **GPU-resident batch search**: an fp16 copy of the index lives on the GPU, scored with a
97
- tiled GEMM plus a streaming top-k (`[gpu]`, Linux/CUDA). [How it works](#gpu-resident-index).
96
+ - **GPU-resident batch search**: a float32 copy of the index lives on the GPU, scored with a
97
+ cuBLAS GEMM plus an on-device top-k (`[gpu]`, Linux/CUDA). [How it works](#gpu-resident-index).
98
98
  - **O(changed) persistence**: commits only the rows that changed, 173× to 1,308× faster
99
99
  than a full rewrite. [How it works](#delta-persistence).
100
100
  - **Compact storage**: the MIT [TurboVec](#turbovec) core packs vectors into 2/4-bit codes
@@ -289,37 +289,42 @@ operational gotchas live in [Deployment and performance](docs/deployment-and-per
289
289
 
290
290
  ## Hybrid search
291
291
 
292
- Vector search alone misses exact tokens the embedding does not capture: error codes
293
- (`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`). Pass `mode="hybrid"` to run a
294
- lexical BM25 ranker alongside the vector scan and fuse the two ranked lists with Reciprocal
295
- Rank Fusion. The lexical ranker matches those tokens exactly, so a document whose body carries
296
- the code is recovered even when the embedding ranks it nowhere near the top.
292
+ Hybrid retrieval is the default. Vector search alone misses exact tokens the embedding does not
293
+ capture: error codes (`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`). By default
294
+ LodeDB runs a lexical BM25 ranker alongside the vector scan and fuses the two ranked lists with
295
+ Reciprocal Rank Fusion, so a document whose body carries the code is recovered even when the
296
+ embedding ranks it nowhere near the top. The default resolves to hybrid whenever a text source is
297
+ available (the out-of-the-box configuration) and falls back to a plain vector scan otherwise, so
298
+ it never raises on a vector-only store.
297
299
 
298
300
  ```python
299
301
  db.add("the turbine tripped and reported fault code E1234 overnight", metadata={"unit": "t3"})
300
302
 
301
- db.search("E1234", k=5) # mode="vector" (default): may miss the exact code
302
- db.search("E1234", k=5, mode="hybrid") # BM25 + RRF: surfaces it in the top-k
303
- db.search("E1234", k=5, mode="lexical") # BM25 ranking alone, no vector scan
303
+ db.search("E1234", k=5) # default: hybrid (BM25 + RRF) when text is retained
304
+ db.search("E1234", k=5, mode="vector") # vector scan alone: may miss the exact code
305
+ db.search("E1234", k=5, mode="lexical") # BM25 ranking alone, no vector scan
304
306
  ```
305
307
 
306
308
  <details>
307
309
  <summary><b>Prerequisites</b></summary>
308
310
 
309
311
  `mode="hybrid"` and `mode="lexical"` build a BM25 index over your text, so they need a text
310
- source enabled when you open the database. `mode="vector"` (the default) needs nothing.
312
+ source enabled when you open the database. Both text sources are on by default, so the hybrid
313
+ default works out of the box; `mode="vector"` needs nothing and is the automatic fallback when no
314
+ text source is present.
311
315
 
312
316
  | Mode | Enable | Source of the BM25 index |
313
317
  | --- | --- | --- |
314
- | `"vector"` (default) | nothing | not used |
315
- | `"hybrid"`, `"lexical"` | `store_text=True` (on by default) | rebuilt in memory from the retained raw text |
316
- | `"hybrid"`, `"lexical"` | `index_text=True` (on by default, follows `store_text`) | a durable on-disk postings store, no raw text required |
318
+ | `"hybrid"` (default), `"lexical"` | `store_text=True` (on by default) | rebuilt in memory from the retained raw text |
319
+ | `"hybrid"` (default), `"lexical"` | `index_text=True` (on by default, follows `store_text`) | a durable on-disk postings store, no raw text required |
320
+ | `"vector"` (fallback) | nothing | not used |
317
321
 
318
322
  Both sources are on by default, so hybrid and lexical work out of the box: `store_text=True`
319
323
  retains the raw text and `index_text` defaults to match it, persisting the lexical postings.
320
324
  Either source alone is enough, and `index_text` decouples from `store_text` when set explicitly.
321
- With neither source enabled (`store_text=False, index_text=False`), a hybrid or lexical query
322
- raises a clear, actionable error rather than silently degrading.
325
+ With neither source enabled (`store_text=False, index_text=False`), the default resolves to a
326
+ plain vector scan; an *explicit* `mode="hybrid"`/`"lexical"` then raises a clear, actionable
327
+ error rather than silently degrading.
323
328
  </details>
324
329
 
325
330
  <details>
@@ -440,37 +445,43 @@ persists with the index. See [`docs/late-interaction.md`](docs/late-interaction.
440
445
 
441
446
  ## GPU-resident index
442
447
 
443
- With the `[gpu]` extra on a CUDA host, LodeDB reconstructs the compact index into an fp16
444
- matrix resident on the GPU and scores batched `search_many` with a tiled GEMM plus a
445
- streaming top-k. It is opt-in and lazy: single queries, non-CUDA hosts, and GPU-memory
448
+ With the `[gpu]` extra on a CUDA host, LodeDB keeps a reconstructed float32 copy of the
449
+ compact index resident on the GPU and scores a batched `search_many` with a cuBLAS GEMM plus
450
+ an on-device top-k. It is opt-in and lazy: single queries, non-CUDA hosts, and GPU-memory
446
451
  rejection fall back to the CPU scan, which stays the source of truth.
447
452
 
448
- GPU throughput climbs with batch size while the CPU scan is flat. Same 4-bit index
449
- (d=1536, 100K), same host, only the scoring step differs. Crossover is around batch 50:
453
+ Both scans stream the corpus once per batch and amortize that read across the queries, so
454
+ per-query throughput climbs with batch size; the GPU pulls away as the batch grows and its
455
+ parallelism dominates. End-to-end through the public arrays API
456
+ (`search_many_by_vector_arrays`, scores and ids) on a 4-bit index (d=1536, 100K):
450
457
 
451
458
  | query batch | A10 GPU | L40S GPU |
452
459
  |---:|---:|---:|
453
- | 1 | 261 q/s | 432 q/s |
454
- | 16 | 3,531 | 5,562 |
455
- | 64 | 11,463 | 18,175 |
456
- | 256 | 19,998 | 39,449 |
457
- | 1024 | **24,037** | **50,326** |
460
+ | 16 | 8,413 | 12,469 |
461
+ | 64 | 19,030 | 33,040 |
462
+ | 256 | 23,669 | 49,927 |
463
+ | 1024 | **24,359** | **52,940** |
458
464
 
459
- Vanilla TurboVec CPU (all threads) on the same boxes: 8,497 q/s (A10 host), 10,420 q/s
460
- (L40S host). At batch 1024 the GPU is 2. / 4.8× that, and it scales with GPU class.
465
+ These are the arrays fast path; the default hits API (per-hit result objects) runs somewhat
466
+ lower, about 18.4k / 35.6k q/s at batch 1024. A single query stays on the CPU (the GPU batch
467
+ path engages at batch >= 4) and is one exact scan over the whole corpus, so its cost is bound
468
+ by memory bandwidth rather than the API; batching amortizes that corpus read, which is what
469
+ the throughput above exploits.
461
470
 
462
- ![GPU throughput vs batch size: A10 and L40S vs the vanilla CPU scan](benchmarks/gpu_vanilla_vs_augmented/docs/speed_batch.png)
471
+ The curves below are the GPU and CPU scans through that same default hits API, so the gap is
472
+ like-for-like:
463
473
 
464
- Recall is unchanged: the GPU scores the exact 4-bit reconstruction, so R@1 tracks the CPU
465
- scan across datasets and bit-widths, and edges ahead on GloVe-200 where quantization error
466
- is largest.
474
+ ![Native vector-search throughput: GPU vs CPU on the default hits API](benchmarks/vector_search_native/docs/throughput_batch.png)
467
475
 
468
- ![Recall: vanilla CPU scan vs GPU fp16 reconstruction](benchmarks/gpu_vanilla_vs_augmented/docs/recall.png)
476
+ A one-line reproduction is in [benchmarks/vector_search_native](benchmarks/vector_search_native/).
477
+
478
+ Recall matches the CPU scan: the GPU scores the same 4-bit reconstruction, and LodeDB's
479
+ parity tests hold its document recall within 0.002 of the CPU scan across batch sizes, so
480
+ moving a query to the GPU changes its latency, not its results.
469
481
 
470
482
  Other in-process vector databases stay CPU-bound. Alibaba's
471
483
  [zvec](https://github.com/alibaba/zvec) reports about 8.4k q/s (VectorDBBench, 16-vCPU CPU,
472
- Cohere 768-dim): the same class as the TurboVec CPU scan, and a different regime from ours,
473
- so read it as the CPU-class baseline. The GPU-resident path is what clears it.
484
+ Cohere 768-dim); read it as the CPU-class baseline that the GPU-resident path clears.
474
485
 
475
486
  **Scope.** GPU search is Linux/CUDA-only and opt-in (`[gpu]`). macOS scans on the CPU by
476
487
  default; a first-class opt-in MPS exact scan exists (`LODEDB_MPS_DIRECT_TURBOVEC`) but NEON
@@ -489,8 +500,6 @@ rows that changed (O(changed)), so a 1,000-row commit stays sub-millisecond at a
489
500
  | 500K | 190.4 ms | 0.24 ms | 782× |
490
501
  | 1M | 404.9 ms | 0.31 ms | **1,308×** |
491
502
 
492
- ![Persist time: full rewrite vs delta export](benchmarks/gpu_vanilla_vs_augmented/docs/update.png)
493
-
494
503
  The GPU path makes reads fast; the delta makes writes cheap. The on-disk format stays a
495
504
  plain snapshot that replays on reopen.
496
505
 
@@ -513,8 +522,6 @@ and the complete figure set are in [docs/benchmarks.md](docs/benchmarks.md); eac
513
522
  Local is the common case. On an Apple M1 (MiniLM, 20K docs) the CPU scan is ~0.25 ms p50,
514
523
  and end-to-end single-query latency is 5.7 ms p50.
515
524
 
516
- ![Single-query latency on a laptop](benchmarks/laptop/docs/query_latency.png)
517
-
518
525
  ## CLI
519
526
 
520
527
  ```bash
@@ -40,12 +40,12 @@ All numbers are reported as the mean of 3 independent runs on a L40S server. [Fu
40
40
  > and it will migrate your existing store onto the LodeDB backend.
41
41
 
42
42
  Most embedded vector databases stop at the CPU. LodeDB runs the same on-disk index **on the
43
- GPU** when you have one: batched search hits *24k queries/sec on an A10 and 50k qps on an L40S*,
44
- 2.8× to 4.8× the all-CPU ceiling, with recall unchanged. It also persists changed rows
45
- incrementally, so a commit stays **sub-millisecond even at 1M vectors**.
43
+ GPU** when you have one: batched search hits *24k queries/sec on an A10 and 53k qps on an L40S*,
44
+ with recall matching the CPU scan. It also persists changed rows incrementally, so a commit
45
+ stays **sub-millisecond even at 1M vectors**.
46
46
 
47
- - **GPU-resident batch search**: an fp16 copy of the index lives on the GPU, scored with a
48
- tiled GEMM plus a streaming top-k (`[gpu]`, Linux/CUDA). [How it works](#gpu-resident-index).
47
+ - **GPU-resident batch search**: a float32 copy of the index lives on the GPU, scored with a
48
+ cuBLAS GEMM plus an on-device top-k (`[gpu]`, Linux/CUDA). [How it works](#gpu-resident-index).
49
49
  - **O(changed) persistence**: commits only the rows that changed, 173× to 1,308× faster
50
50
  than a full rewrite. [How it works](#delta-persistence).
51
51
  - **Compact storage**: the MIT [TurboVec](#turbovec) core packs vectors into 2/4-bit codes
@@ -240,37 +240,42 @@ operational gotchas live in [Deployment and performance](docs/deployment-and-per
240
240
 
241
241
  ## Hybrid search
242
242
 
243
- Vector search alone misses exact tokens the embedding does not capture: error codes
244
- (`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`). Pass `mode="hybrid"` to run a
245
- lexical BM25 ranker alongside the vector scan and fuse the two ranked lists with Reciprocal
246
- Rank Fusion. The lexical ranker matches those tokens exactly, so a document whose body carries
247
- the code is recovered even when the embedding ranks it nowhere near the top.
243
+ Hybrid retrieval is the default. Vector search alone misses exact tokens the embedding does not
244
+ capture: error codes (`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`). By default
245
+ LodeDB runs a lexical BM25 ranker alongside the vector scan and fuses the two ranked lists with
246
+ Reciprocal Rank Fusion, so a document whose body carries the code is recovered even when the
247
+ embedding ranks it nowhere near the top. The default resolves to hybrid whenever a text source is
248
+ available (the out-of-the-box configuration) and falls back to a plain vector scan otherwise, so
249
+ it never raises on a vector-only store.
248
250
 
249
251
  ```python
250
252
  db.add("the turbine tripped and reported fault code E1234 overnight", metadata={"unit": "t3"})
251
253
 
252
- db.search("E1234", k=5) # mode="vector" (default): may miss the exact code
253
- db.search("E1234", k=5, mode="hybrid") # BM25 + RRF: surfaces it in the top-k
254
- db.search("E1234", k=5, mode="lexical") # BM25 ranking alone, no vector scan
254
+ db.search("E1234", k=5) # default: hybrid (BM25 + RRF) when text is retained
255
+ db.search("E1234", k=5, mode="vector") # vector scan alone: may miss the exact code
256
+ db.search("E1234", k=5, mode="lexical") # BM25 ranking alone, no vector scan
255
257
  ```
256
258
 
257
259
  <details>
258
260
  <summary><b>Prerequisites</b></summary>
259
261
 
260
262
  `mode="hybrid"` and `mode="lexical"` build a BM25 index over your text, so they need a text
261
- source enabled when you open the database. `mode="vector"` (the default) needs nothing.
263
+ source enabled when you open the database. Both text sources are on by default, so the hybrid
264
+ default works out of the box; `mode="vector"` needs nothing and is the automatic fallback when no
265
+ text source is present.
262
266
 
263
267
  | Mode | Enable | Source of the BM25 index |
264
268
  | --- | --- | --- |
265
- | `"vector"` (default) | nothing | not used |
266
- | `"hybrid"`, `"lexical"` | `store_text=True` (on by default) | rebuilt in memory from the retained raw text |
267
- | `"hybrid"`, `"lexical"` | `index_text=True` (on by default, follows `store_text`) | a durable on-disk postings store, no raw text required |
269
+ | `"hybrid"` (default), `"lexical"` | `store_text=True` (on by default) | rebuilt in memory from the retained raw text |
270
+ | `"hybrid"` (default), `"lexical"` | `index_text=True` (on by default, follows `store_text`) | a durable on-disk postings store, no raw text required |
271
+ | `"vector"` (fallback) | nothing | not used |
268
272
 
269
273
  Both sources are on by default, so hybrid and lexical work out of the box: `store_text=True`
270
274
  retains the raw text and `index_text` defaults to match it, persisting the lexical postings.
271
275
  Either source alone is enough, and `index_text` decouples from `store_text` when set explicitly.
272
- With neither source enabled (`store_text=False, index_text=False`), a hybrid or lexical query
273
- raises a clear, actionable error rather than silently degrading.
276
+ With neither source enabled (`store_text=False, index_text=False`), the default resolves to a
277
+ plain vector scan; an *explicit* `mode="hybrid"`/`"lexical"` then raises a clear, actionable
278
+ error rather than silently degrading.
274
279
  </details>
275
280
 
276
281
  <details>
@@ -391,37 +396,43 @@ persists with the index. See [`docs/late-interaction.md`](docs/late-interaction.
391
396
 
392
397
  ## GPU-resident index
393
398
 
394
- With the `[gpu]` extra on a CUDA host, LodeDB reconstructs the compact index into an fp16
395
- matrix resident on the GPU and scores batched `search_many` with a tiled GEMM plus a
396
- streaming top-k. It is opt-in and lazy: single queries, non-CUDA hosts, and GPU-memory
399
+ With the `[gpu]` extra on a CUDA host, LodeDB keeps a reconstructed float32 copy of the
400
+ compact index resident on the GPU and scores a batched `search_many` with a cuBLAS GEMM plus
401
+ an on-device top-k. It is opt-in and lazy: single queries, non-CUDA hosts, and GPU-memory
397
402
  rejection fall back to the CPU scan, which stays the source of truth.
398
403
 
399
- GPU throughput climbs with batch size while the CPU scan is flat. Same 4-bit index
400
- (d=1536, 100K), same host, only the scoring step differs. Crossover is around batch 50:
404
+ Both scans stream the corpus once per batch and amortize that read across the queries, so
405
+ per-query throughput climbs with batch size; the GPU pulls away as the batch grows and its
406
+ parallelism dominates. End-to-end through the public arrays API
407
+ (`search_many_by_vector_arrays`, scores and ids) on a 4-bit index (d=1536, 100K):
401
408
 
402
409
  | query batch | A10 GPU | L40S GPU |
403
410
  |---:|---:|---:|
404
- | 1 | 261 q/s | 432 q/s |
405
- | 16 | 3,531 | 5,562 |
406
- | 64 | 11,463 | 18,175 |
407
- | 256 | 19,998 | 39,449 |
408
- | 1024 | **24,037** | **50,326** |
411
+ | 16 | 8,413 | 12,469 |
412
+ | 64 | 19,030 | 33,040 |
413
+ | 256 | 23,669 | 49,927 |
414
+ | 1024 | **24,359** | **52,940** |
409
415
 
410
- Vanilla TurboVec CPU (all threads) on the same boxes: 8,497 q/s (A10 host), 10,420 q/s
411
- (L40S host). At batch 1024 the GPU is 2. / 4.8× that, and it scales with GPU class.
416
+ These are the arrays fast path; the default hits API (per-hit result objects) runs somewhat
417
+ lower, about 18.4k / 35.6k q/s at batch 1024. A single query stays on the CPU (the GPU batch
418
+ path engages at batch >= 4) and is one exact scan over the whole corpus, so its cost is bound
419
+ by memory bandwidth rather than the API; batching amortizes that corpus read, which is what
420
+ the throughput above exploits.
412
421
 
413
- ![GPU throughput vs batch size: A10 and L40S vs the vanilla CPU scan](benchmarks/gpu_vanilla_vs_augmented/docs/speed_batch.png)
422
+ The curves below are the GPU and CPU scans through that same default hits API, so the gap is
423
+ like-for-like:
414
424
 
415
- Recall is unchanged: the GPU scores the exact 4-bit reconstruction, so R@1 tracks the CPU
416
- scan across datasets and bit-widths, and edges ahead on GloVe-200 where quantization error
417
- is largest.
425
+ ![Native vector-search throughput: GPU vs CPU on the default hits API](benchmarks/vector_search_native/docs/throughput_batch.png)
418
426
 
419
- ![Recall: vanilla CPU scan vs GPU fp16 reconstruction](benchmarks/gpu_vanilla_vs_augmented/docs/recall.png)
427
+ A one-line reproduction is in [benchmarks/vector_search_native](benchmarks/vector_search_native/).
428
+
429
+ Recall matches the CPU scan: the GPU scores the same 4-bit reconstruction, and LodeDB's
430
+ parity tests hold its document recall within 0.002 of the CPU scan across batch sizes, so
431
+ moving a query to the GPU changes its latency, not its results.
420
432
 
421
433
  Other in-process vector databases stay CPU-bound. Alibaba's
422
434
  [zvec](https://github.com/alibaba/zvec) reports about 8.4k q/s (VectorDBBench, 16-vCPU CPU,
423
- Cohere 768-dim): the same class as the TurboVec CPU scan, and a different regime from ours,
424
- so read it as the CPU-class baseline. The GPU-resident path is what clears it.
435
+ Cohere 768-dim); read it as the CPU-class baseline that the GPU-resident path clears.
425
436
 
426
437
  **Scope.** GPU search is Linux/CUDA-only and opt-in (`[gpu]`). macOS scans on the CPU by
427
438
  default; a first-class opt-in MPS exact scan exists (`LODEDB_MPS_DIRECT_TURBOVEC`) but NEON
@@ -440,8 +451,6 @@ rows that changed (O(changed)), so a 1,000-row commit stays sub-millisecond at a
440
451
  | 500K | 190.4 ms | 0.24 ms | 782× |
441
452
  | 1M | 404.9 ms | 0.31 ms | **1,308×** |
442
453
 
443
- ![Persist time: full rewrite vs delta export](benchmarks/gpu_vanilla_vs_augmented/docs/update.png)
444
-
445
454
  The GPU path makes reads fast; the delta makes writes cheap. The on-disk format stays a
446
455
  plain snapshot that replays on reopen.
447
456
 
@@ -464,8 +473,6 @@ and the complete figure set are in [docs/benchmarks.md](docs/benchmarks.md); eac
464
473
  Local is the common case. On an Apple M1 (MiniLM, 20K docs) the CPU scan is ~0.25 ms p50,
465
474
  and end-to-end single-query latency is 5.7 ms p50.
466
475
 
467
- ![Single-query latency on a laptop](benchmarks/laptop/docs/query_latency.png)
468
-
469
476
  ## CLI
470
477
 
471
478
  ```bash
@@ -12,6 +12,10 @@ name = "lodedb_core"
12
12
 
13
13
  [dependencies]
14
14
  lodedb-gpu = { path = "../lodedb-gpu" }
15
+ # Parallelizes the ANN cluster-index build (k-means seeding + assignment) across
16
+ # cores. Same version TurboVec already pins, and rayon's public API is safe, so
17
+ # lodedb-core keeps unsafe_code = forbid.
18
+ rayon = "1.10"
15
19
  serde = { version = "1.0", features = ["derive"] }
16
20
  serde_json = "1.0"
17
21
  sha2 = "0.10"
@@ -14,13 +14,18 @@
14
14
  //! Everything here is deterministic: no clocks, no RNG (`unsafe_code = forbid`
15
15
  //! and no `rand` dependency), fixed iteration counts, and stable-order tie
16
16
  //! breaks. The same corpus always yields the same clustering and the same
17
- //! candidates. Vectors are the TurboVec-reconstructed rows (rotated space), and
18
- //! the index stores the matching rotation so it can rotate a raw query into that
19
- //! space itself before scoring centroids. The metric is dot product to match the
20
- //! exact scan.
17
+ //! candidates. The seeding and assignment passes run under rayon, but each is
18
+ //! order-preserving (indexed collects) or per-element independent (an in-place
19
+ //! max), so the clustering never depends on thread count or scheduling and is
20
+ //! byte-identical to a serial build. Vectors are the TurboVec-reconstructed rows
21
+ //! (rotated space), and the index stores the matching rotation so it can rotate a
22
+ //! raw query into that space itself before scoring centroids. The metric is dot
23
+ //! product to match the exact scan.
21
24
 
22
25
  use std::cmp::Ordering;
23
26
 
27
+ use rayon::prelude::*;
28
+
24
29
  use crate::vector::math::{dot, rotate};
25
30
 
26
31
  /// Lloyd iterations for the cluster build. Candidate generation needs a decent
@@ -65,12 +70,13 @@ impl ClusterIndex {
65
70
  let n = entries.len();
66
71
  let k = requested_k.clamp(1, n.max(1));
67
72
 
68
- // Deterministic farthest-first seeding: start from the first (lowest
69
- // chunk id) row, then repeatedly take the unseeded row least similar to
70
- // every seed chosen so far. No RNG. Only unseeded rows are considered, so
71
- // a zero- or low-norm first row (whose dot with everything is near zero)
72
- // cannot make the search re-pick an already-seeded index and collapse the
73
- // corpus to a single cluster.
73
+ // Deterministic farthest-first seeding: start from the first (lowest chunk
74
+ // id) row, then repeatedly take the unseeded row least similar to every seed
75
+ // chosen so far. No RNG. Only unseeded rows are considered, so a zero- or
76
+ // low-norm first row (whose dot with everything is near zero) cannot make the
77
+ // search re-pick an already-seeded index and collapse the corpus to a single
78
+ // cluster. Each seed's similarity fold over the corpus is parallel; the
79
+ // argmin pick is a cheap serial scan with a lowest-index tie-break.
74
80
  let mut seeds: Vec<usize> = Vec::with_capacity(k);
75
81
  let mut seeded = vec![false; n];
76
82
  seeds.push(0);
@@ -104,24 +110,13 @@ impl ClusterIndex {
104
110
  centroids[cluster * dim..(cluster + 1) * dim].copy_from_slice(entries[row].2);
105
111
  }
106
112
 
107
- // Lloyd iterations: assign by max dot product, update centroids to the
108
- // mean of their members. Means accumulate in f64 in a fixed order so the
109
- // build is bit-reproducible for the same entries.
110
- let mut assignment = vec![usize::MAX; n];
111
- for _ in 0..MAX_ITERS {
112
- let mut changed = false;
113
- for (i, entry) in entries.iter().enumerate() {
114
- let cluster = nearest_centroid(entry.2, &centroids, cluster_count, dim);
115
- if cluster != assignment[i] {
116
- assignment[i] = cluster;
117
- changed = true;
118
- }
119
- }
120
- if !changed {
121
- break;
122
- }
123
- recompute_centroids(&mut centroids, entries, &assignment, cluster_count, dim);
124
- }
113
+ // Lloyd iterations: assign every row to its nearest centroid (parallel) and
114
+ // update centroids to the f64 mean of their members (serial, fixed order so
115
+ // the means are bit-reproducible). Both parallel passes are order-preserving
116
+ // or per-row independent, so the clustering is byte-identical regardless of
117
+ // thread count, and the final centroids are the means of the returned
118
+ // assignment (so a persisted reload via `from_assignment` reproduces them).
119
+ let assignment = lloyd(&mut centroids, entries, cluster_count, dim);
125
120
 
126
121
  // Materialize postings and drop empty clusters. An empty cluster owns no
127
122
  // chunks, so probing it can only waste a probe slot; dropping it keeps
@@ -352,6 +347,48 @@ fn write_cluster_mean(centroid: &mut [f32], sum: &[f64], count: usize) {
352
347
  }
353
348
  }
354
349
 
350
+ /// Runs Lloyd's algorithm in place over `entries`: assign every entry to its
351
+ /// nearest centroid (parallel), update each centroid to the f64 mean of its
352
+ /// members (sequential, fixed order for bit-reproducibility), repeat up to
353
+ /// [`MAX_ITERS`] with an unchanged-assignment early exit. Returns the final
354
+ /// assignment. On an early exit the centroids are the means of that assignment
355
+ /// *and* every entry's nearest centroid is its assigned cluster; at the iteration
356
+ /// cap only the former holds. Empty clusters keep their prior centroid and are
357
+ /// dropped later by the caller.
358
+ fn lloyd(
359
+ centroids: &mut [f32],
360
+ entries: &[ClusterEntry<'_>],
361
+ cluster_count: usize,
362
+ dim: usize,
363
+ ) -> Vec<usize> {
364
+ let mut assignment = vec![usize::MAX; entries.len()];
365
+ for _ in 0..MAX_ITERS {
366
+ let next = assign_all(entries, centroids, cluster_count, dim);
367
+ if next == assignment {
368
+ break;
369
+ }
370
+ assignment = next;
371
+ recompute_centroids(centroids, entries, &assignment, cluster_count, dim);
372
+ }
373
+ assignment
374
+ }
375
+
376
+ /// Assigns every entry to its nearest centroid, in parallel. The per-row result
377
+ /// depends only on that row and the (shared, read-only) centroids, and the
378
+ /// indexed collect preserves order, so the assignment is byte-identical to a
379
+ /// sequential pass regardless of thread count.
380
+ fn assign_all(
381
+ entries: &[ClusterEntry<'_>],
382
+ centroids: &[f32],
383
+ cluster_count: usize,
384
+ dim: usize,
385
+ ) -> Vec<usize> {
386
+ entries
387
+ .par_iter()
388
+ .map(|entry| nearest_centroid(entry.2, centroids, cluster_count, dim))
389
+ .collect()
390
+ }
391
+
355
392
  fn nearest_centroid(vector: &[f32], centroids: &[f32], cluster_count: usize, dim: usize) -> usize {
356
393
  let mut best = 0usize;
357
394
  let mut best_dot = f32::NEG_INFINITY;
@@ -368,13 +405,19 @@ fn nearest_centroid(vector: &[f32], centroids: &[f32], cluster_count: usize, dim
368
405
  best
369
406
  }
370
407
 
408
+ /// Folds a newly chosen seed's similarities into the running per-row nearest-seed
409
+ /// maxima, in parallel. Each slot is updated from its own row only (an in-place
410
+ /// max), so the result is independent of thread count.
371
411
  fn update_nearest(nearest: &mut [f32], entries: &[ClusterEntry<'_>], centroid: &[f32]) {
372
- for (slot, entry) in nearest.iter_mut().zip(entries) {
373
- let similarity = dot(entry.2, centroid);
374
- if similarity > *slot {
375
- *slot = similarity;
376
- }
377
- }
412
+ nearest
413
+ .par_iter_mut()
414
+ .zip(entries.par_iter())
415
+ .for_each(|(slot, entry)| {
416
+ let similarity = dot(entry.2, centroid);
417
+ if similarity > *slot {
418
+ *slot = similarity;
419
+ }
420
+ });
378
421
  }
379
422
 
380
423
  #[cfg(test)]
@@ -554,6 +597,34 @@ mod tests {
554
597
  );
555
598
  }
556
599
 
600
+ #[test]
601
+ fn parallel_build_recovers_each_row_with_a_single_probe() {
602
+ // Posting/centroid consistency on a larger, converging corpus: after the
603
+ // parallel Lloyd, each row's own cluster is the one a self-query scores
604
+ // highest, so probing just the single nearest cluster recovers the row. This
605
+ // guards against the centroids drifting out of sync with the postings they
606
+ // were grouped from (which would drop true neighbors from the probed set).
607
+ let mut rows = Vec::new();
608
+ for blob in 0..8 {
609
+ for i in 0..40 {
610
+ let mut vector = vec![0.0f32; 8];
611
+ vector[blob] = 1.0;
612
+ vector[(blob + 1) % 8] = 0.001 * (i as f32 + 1.0);
613
+ rows.push((format!("b{blob}c{i:02}"), vector));
614
+ }
615
+ }
616
+ let (owned, reverse) = entries(&rows);
617
+ let index = ClusterIndex::build(&refs(&owned), 8, 16, None);
618
+ assert_eq!(index.num_vectors(), owned.len());
619
+ for (id, _, vector) in &owned {
620
+ let hits = chunk_ids(&index.candidate_stable_ids(vector, 1, 1), &reverse);
621
+ assert!(
622
+ hits.contains(id),
623
+ "self-query for {id} must probe the cluster that owns it"
624
+ );
625
+ }
626
+ }
627
+
557
628
  #[test]
558
629
  fn handles_k_larger_than_corpus_and_identical_vectors() {
559
630
  let rows: Vec<(String, Vec<f32>)> = (0..3)