Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
31 changes: 31 additions & 0 deletions tests/test_vicinity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 13 additions & 1 deletion vicinity/backends/usearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion vicinity/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version_triple__ = (0, 4, 4)
__version_triple__ = (0, 4, 5)
__version__ = ".".join(map(str, __version_triple__))
Loading