diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..0fd128c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,40 @@ +name: Release to PyPI + +on: + workflow_dispatch: + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Install uv + uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff + + - name: Build package + run: uv build + + - name: Upload dist + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Download dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b diff --git a/README.md b/README.md index 31b2ee6..91b3e2c 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ NOTE: the ANN backends do not support dynamic deletion. To delete items, you nee | | `m` | Number of connections per layer. | `16` | | **PYNNDESCENT** | `metric` | Similarity metric to use (`cosine`, `euclidean`, `manhattan`). | `"cosine"` | | | `n_neighbors` | Number of neighbors to use for search. | `15` | -| **USEARCH** | `metric` | Similarity metric to use (`cos`, `ip`, `l2sq`, `hamming`, `tanimoto`). | `"cos"` | +| **USEARCH** | `metric` | Similarity metric to use (`cos`, `ip`, `l2sq`, `hamming`, `tanimoto`). `hamming` and `tanimoto` take uint8 vectors bit-packed with `np.packbits`. | `"cos"` | | | `connectivity` | Number of connections per node in the graph. | `16` | | | `expansion_add` | Number of candidates considered during graph construction. | `128` | | | `expansion_search` | Number of candidates considered during search. | `64` | diff --git a/tests/test_vicinity.py b/tests/test_vicinity.py index a30c864..1f87175 100644 --- a/tests/test_vicinity.py +++ b/tests/test_vicinity.py @@ -333,3 +333,34 @@ def test_vicinity_evaluate(vicinity_instance: Vicinity, vectors: np.ndarray) -> vicinity_instance.backend.arguments.metric = "manhattan" with pytest.raises(ValueError): vicinity_instance.evaluate(vectors, query_vectors) + + +@pytest.mark.parametrize("metric", ["hamming", "tanimoto"]) +def test_vicinity_usearch_binary_metrics(tmp_path: Path, metric: str) -> None: + """ + Test that the usearch backend supports binary metrics on bit-packed vectors. + + :param tmp_path: Temporary directory for saving and loading. + :param metric: The binary metric to use. + """ + bits = np.random.default_rng(42).integers(0, 2, size=(50, 64)).astype(bool) + packed = np.packbits(bits, axis=1) + items = [str(i) for i in range(len(bits))] + vicinity = Vicinity.from_vectors_and_items(packed, items, backend_type=Backend.USEARCH, metric=metric) + assert vicinity.dim == packed.shape[1] + + if metric == "hamming": + expected = (bits[0] != bits).sum(axis=1) + else: + expected = 1 - (bits[0] & bits).sum(axis=1) / (bits[0] | bits).sum(axis=1) + ((_, distance),) = vicinity.query(packed[:1], k=1)[0] + ((_, farthest),) = vicinity.query(packed[:1], k=len(items))[0][-1:] + assert distance == pytest.approx(0) + assert farthest == pytest.approx(expected.max(), abs=1e-5) + + vicinity.insert(["new"], packed[:1]) + vicinity.save(tmp_path / "binary") + assert Vicinity.load(tmp_path / "binary").query(packed[:1], k=3)[0] == vicinity.query(packed[:1], k=3)[0] + + with pytest.raises(ValueError, match="bit-packed"): + Vicinity.from_vectors_and_items(bits.astype(np.float32), items, backend_type=Backend.USEARCH, metric=metric) diff --git a/vicinity/backends/usearch.py b/vicinity/backends/usearch.py index 55c3ead..756a536 100644 --- a/vicinity/backends/usearch.py +++ b/vicinity/backends/usearch.py @@ -25,6 +25,9 @@ class UsearchArgs(BaseArgs): class UsearchBackend(AbstractBackend[UsearchArgs]): argument_class = UsearchArgs supported_metrics = {Metric.COSINE, Metric.INNER_PRODUCT, Metric.L2_SQUARED, Metric.HAMMING, Metric.TANIMOTO} + # Binary metrics take vectors bit-packed into uint8, as produced by np.packbits, and usearch counts their + # dimensions in bits. + binary_metrics = {Metric.HAMMING, Metric.TANIMOTO} inverse_metric_mapping = { Metric.COSINE: "cos", Metric.INNER_PRODUCT: "ip", @@ -60,6 +63,13 @@ def from_vectors( metric = cls._map_metric_to_string(metric_enum) dim = vectors.shape[1] + if metric_enum in cls.binary_metrics: + if vectors.dtype != np.uint8: + raise ValueError( + f"Metric '{metric_enum.value}' requires vectors bit-packed into uint8 with np.packbits, " + f"got dtype {vectors.dtype}." + ) + dim *= 8 index = UsearchIndex( ndim=dim, metric=metric, @@ -84,7 +94,9 @@ def backend_type(self) -> Backend: @property def dim(self) -> int: - """Get the dimension of the space.""" + """Get the dimension of the space, in bytes for bit-packed binary metrics.""" + if self.arguments.metric in self.binary_metrics: + return self.index.ndim // 8 return self.index.ndim def __len__(self) -> int: diff --git a/vicinity/version.py b/vicinity/version.py index 53e8923..fc18fd8 100644 --- a/vicinity/version.py +++ b/vicinity/version.py @@ -1,2 +1,2 @@ -__version_triple__ = (0, 4, 4) +__version_triple__ = (0, 4, 5) __version__ = ".".join(map(str, __version_triple__))