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.
- {lodedb-1.2.0 → lodedb-1.3.0}/Cargo.toml +1 -1
- {lodedb-1.2.0 → lodedb-1.3.0}/PKG-INFO +50 -43
- {lodedb-1.2.0 → lodedb-1.3.0}/README.md +49 -42
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/Cargo.toml +4 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/ann.rs +105 -34
- lodedb-1.3.0/crates/lodedb-core/tests/ann_build_bench.rs +237 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-gpu/src/lib.rs +511 -24
- {lodedb-1.2.0 → lodedb-1.3.0}/pyproject.toml +1 -1
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/__init__.py +1 -1
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/native_adapter.py +40 -4
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/graph/knowledge_graph.py +25 -19
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/db.py +233 -38
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/Cargo.lock +3 -2
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/src/lib.rs +27 -5
- {lodedb-1.2.0 → lodedb-1.3.0}/LICENSE +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/NOTICE +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/filter_planner_bench.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/turbovec_adapter_smoke.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/write_generation_fixture.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/examples/write_wal_fixture.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/engine/mod.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/error.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/ast.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/doc_set.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/field_index.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/mod.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/predicate.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/resolve.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/filter/validate.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/bm25.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/mod.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/rrf.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lexical/tokenize.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/lib.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/commit_manifest.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/legacy.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/lexical_store.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/lsn.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/mod.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/multivec_store.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/state_journal.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/text_store.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/tvann_store.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/tvim_delta.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/util.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/storage/wal.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/text/chunk.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/text/hash.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/text/mod.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/types.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/index.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/math.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/mod.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/stable_id.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/vector/turbovec.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/src/version.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/core_engine.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/filter_plan_fixtures.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/identity_fixtures.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/lexical_fixtures.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/predicate_fixtures.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/storage_fixtures.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-core/tests/turbovec_adapter.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/crates/lodedb-gpu/Cargo.toml +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/__main__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/_native_core.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/config.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_atomic_io.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_commit_manifest.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_filelock.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_lexical.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/_predicate.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/core.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/embedding_backends.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/gpu_turbovec.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/route_profiles.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/route_registry.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/runtime_policy.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/state_journal_store.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/turbovec_delta_store.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/engine/turbovec_index.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/graph/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/graph/_store.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/appender.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/backends.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/benchmark.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/checkpointer.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/cli.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/collection.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/doctor.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/langchain.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/llama_index.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/llama_index_graph.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/mem0.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/integrations/privategpt.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/late_interaction.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/mcp_install.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/mcp_server.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/cli.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/detect.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/plan.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/report.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/runner.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/base.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/langchain_inmemory.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/llama_index_simple.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/mem0_qdrant.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/migrate/sources/pgvector.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/onnx_artifacts.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/presets.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/local/server.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/src/lodedb/py.typed +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/Cargo.toml +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/Cargo.toml +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/README.md +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/build.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/examples/dump_state.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/examples/kernel_xtest.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/codebook.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/encode.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/error.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/id_map.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/io.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/lib.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/maxsim.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/pack.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/rotation.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/src/search.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/codebook.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/concurrent_search.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/distortion.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/encode.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/encoded_rows.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/filtering.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/id_map.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/input_validation.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/io_versioning.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/kernel_correctness.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/lazy_init.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/reconstruction.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/rotation.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/state_sequences.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/swap_remove.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec/tests/tqplus_calibration.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/Cargo.toml +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/README.md +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/build.rs +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/__init__.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/_dedup.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/_persist.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/agno.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/haystack.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/langchain.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/python/turbovec/llama_index.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_agno.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_dedup.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_filtering.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_haystack.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_id_map.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_index.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_langchain.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_llama_index.py +0 -0
- {lodedb-1.2.0 → lodedb-1.3.0}/third_party/turbovec/turbovec-python/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lodedb
|
|
3
|
-
Version: 1.
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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**:
|
|
97
|
-
|
|
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
|
|
293
|
-
(`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`).
|
|
294
|
-
lexical BM25 ranker alongside the vector scan and
|
|
295
|
-
Rank Fusion
|
|
296
|
-
|
|
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)
|
|
302
|
-
db.search("E1234", k=5, mode="
|
|
303
|
-
db.search("E1234", k=5, mode="lexical")
|
|
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.
|
|
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
|
-
| `"
|
|
315
|
-
| `"hybrid"
|
|
316
|
-
| `"
|
|
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`),
|
|
322
|
-
raises a clear, actionable
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
|
454
|
-
|
|
|
455
|
-
|
|
|
456
|
-
|
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
scan across datasets and bit-widths, and edges ahead on GloVe-200 where quantization error
|
|
466
|
-
is largest.
|
|
474
|
+

|
|
467
475
|
|
|
468
|
-
|
|
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)
|
|
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
|
-

|
|
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
|
-

|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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**:
|
|
48
|
-
|
|
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
|
|
244
|
-
(`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`).
|
|
245
|
-
lexical BM25 ranker alongside the vector scan and
|
|
246
|
-
Rank Fusion
|
|
247
|
-
|
|
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)
|
|
253
|
-
db.search("E1234", k=5, mode="
|
|
254
|
-
db.search("E1234", k=5, mode="lexical")
|
|
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.
|
|
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
|
-
| `"
|
|
266
|
-
| `"hybrid"
|
|
267
|
-
| `"
|
|
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`),
|
|
273
|
-
raises a clear, actionable
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
|
405
|
-
|
|
|
406
|
-
|
|
|
407
|
-
|
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
scan across datasets and bit-widths, and edges ahead on GloVe-200 where quantization error
|
|
417
|
-
is largest.
|
|
425
|
+

|
|
418
426
|
|
|
419
|
-
|
|
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)
|
|
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
|
-

|
|
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
|
-

|
|
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.
|
|
18
|
-
//!
|
|
19
|
-
//!
|
|
20
|
-
//!
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// corpus
|
|
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
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
let cluster = nearest_centroid(entry.2, ¢roids, 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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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)
|