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
25 changes: 25 additions & 0 deletions .github/workflows/code-checker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,31 @@ jobs :
- name: Run Python unit tests
run: uv run pytest

codecov:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2

- name: Restore Caches
uses: ./.github/actions/cache-restore

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: pip install pytest pytest-cov

- name: Run tests
run: uv run pytest --cov --cov-branch --cov-report=xml

- name: Upload results to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

cpp-lint-check:
runs-on: ubuntu-latest
timeout-minutes: 180
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ venv*/**

# data
data/*

# codecov
.coverage
coverage.xml
7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ dependencies = ["pandas>=1.3.3"]
[project.urls]
"Homepage" = "https://github.com/AlayaDB-AI/AlayaLite"

[project.optional-dependencies]
dev = ["pytest", "ruff"]
[tool.uv]
dev-dependencies = ["pytest", "pytest-cov", "ruff"]

# ----------------- build whl begin ------------------
[tool.scikit-build]
Expand Down Expand Up @@ -52,9 +52,6 @@ archs = ["x86_64"]
before-build = "yum -y install perl-IPC-Cmd perl-Digest-SHA"
# ----------------- build whl end ------------------

[tool.uv]
dev-dependencies = ["pytest", "ruff"]

[[tool.uv.index]]
# url = "https://mirrors.sustech.edu.cn/pypi/web/simple" # SUSTech mirror
# url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" # Tsinghua mirror
Expand Down
14 changes: 6 additions & 8 deletions python/src/alayalite/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ def __init__(self, url=None):
self.__collection_map = {}
self.__index_map = {}
self.__url = None

if url is not None:
self.__url = os.path.abspath(url)
if not os.path.exists(self.__url):
Expand Down Expand Up @@ -103,9 +102,13 @@ def get_index(self, name: str = "default") -> Index:
name (str, optional): The name of the index to retrieve. Defaults to "default".

Returns:
Index or None: The index if found, else None.
_PyIndexInterface (cpp class): The index if found, else None
"""
return self.__index_map.get(name)
if name in self.__index_map:
return self.__index_map[name]
else:
print(f"Index {name} does not exist")
return None

def create_collection(self, name: str = "default", **_kwargs) -> Collection:
"""
Expand Down Expand Up @@ -249,8 +252,6 @@ def save_index(self, index_name: str):
raise RuntimeError(f"Index '{index_name}' does not exist")

index_url = os.path.join(self.__url, index_name)
if not os.path.exists(index_url):
os.makedirs(index_url)
schema_map = self.__index_map[index_name].save(index_url)
index_schema_url = os.path.join(index_url, "schema.json")
with open(index_schema_url, "w", encoding="utf-8") as f:
Expand All @@ -273,9 +274,6 @@ def save_collection(self, collection_name: str):
raise RuntimeError(f"Collection '{collection_name}' does not exist")

collection_url = os.path.join(self.__url, collection_name)
if not os.path.exists(collection_url):
os.makedirs(collection_url)

schema_map = self.__collection_map[collection_name].save(collection_url)
collection_schema_url = os.path.join(collection_url, "schema.json")

Expand Down
39 changes: 25 additions & 14 deletions python/src/alayalite/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,21 @@
Type[np.uint32],
]
""" Type alias for one of {`numpy.float32`, `numpy.int8`, `numpy.uint8`} """
DistanceMetric: TypeAlias = Literal["euclidean", "l2", "ip", "cosine"]
""" Type alias for one of {"euclidean", "l2", "ip", "cosine"} """
DistanceMetric: TypeAlias = Literal["euclidean", "l2", "ip", "cosine", "cos"]
""" Type alias for one of {"euclidean", "l2", "ip", "cosine", "cos"} """
QuantizationType: TypeAlias = Literal[None, "none", "sq8", "sq4"]
""" Type alias for one of {None, "none", "sq8", "sq4"} """
IndexType: TypeAlias = Literal["hnsw", "flat"]
""" Type alias for one of {"hnsw", "flat"} """
IndexType: TypeAlias = Literal["hnsw", "nsg", "fusion"]
""" Type alias for one of {"hnsw", "nsg" ,"fusion"} """
VectorLike: TypeAlias = npt.NDArray[VectorDType] # type: ignore
""" Type alias for something that can be treated as a vector """
VectorLikeBatch: TypeAlias = npt.NDArray[VectorDType] # type: ignore
""" Type alias for a batch of VectorLikes """

_VALID_IDTYPES = [np.uint64, np.uint32]
_VALID_DTYPES = [np.float32, np.int8, np.uint8, np.float64, np.int32, np.uint32]
_VALID_METRIC_TYPES = ["euclidean", "l2", "ip", "cosine"]
_VALID_INDEX_TYPES = ["hnsw", "flat", "nsg", "fusion"]
_VALID_METRIC_TYPES = ["euclidean", "l2", "ip", "cosine", "cos"]
_VALID_INDEX_TYPES = ["hnsw", "nsg", "fusion"]
_VALID_SQ_TYPES = [None, "none", "sq8", "sq4"]

__all__ = [
Expand Down Expand Up @@ -93,25 +93,33 @@ def valid_capacity_type(capacity: np.dtype) -> np.uint32:
return capacity


def valid_metric_type(metric: str) -> _MetricType:
def assert_valid_metric_type(metric: str) -> None:
_assert(
metric.lower() in _VALID_METRIC_TYPES,
f"Distance metric must be one of {_VALID_METRIC_TYPES}",
)


def valid_metric_type(metric: str) -> _MetricType:
assert_valid_metric_type(metric)
if metric.lower() == "ip":
return _MetricType.IP
elif metric.lower() == "l2" or metric.lower() == "euclidean":
return _MetricType.L2
elif metric.lower() == "cosine":
return _MetricType.COSINE
elif metric.lower() == "cosine" or metric.lower() == "cos":
return _MetricType.COS


def valid_quantization_type(quantization_type: str) -> _QuantizationType:
def assert_valid_quantization_type(quantization_type: str) -> None:
_assert(
quantization_type.lower() in _VALID_SQ_TYPES,
quantization_type is None or quantization_type.lower() in _VALID_SQ_TYPES,
f"Quantization type must be one of {_VALID_SQ_TYPES}",
)


def valid_quantization_type(quantization_type: str) -> _QuantizationType:
assert_valid_quantization_type(quantization_type)

if quantization_type is None:
return _QuantizationType.NONE
elif quantization_type.lower() == "none":
Expand All @@ -122,15 +130,18 @@ def valid_quantization_type(quantization_type: str) -> _QuantizationType:
return _QuantizationType.SQ4


def valid_index_type(index: str) -> _IndexType:
def assert_valid_index_type(index: str) -> None:
_assert(
index.lower() in _VALID_INDEX_TYPES,
f"Index type must be one of {_VALID_INDEX_TYPES}",
)


def valid_index_type(index: str) -> _IndexType:
assert_valid_index_type(index)

if index.lower() == "hnsw":
return _IndexType.HNSW
elif index.lower() == "flat":
return _IndexType.FLAT
elif index.lower() == "nsg":
return _IndexType.NSG
elif index.lower() == "fusion":
Expand Down
31 changes: 20 additions & 11 deletions python/src/alayalite/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
from .common import (
IDType,
VectorDType,
assert_valid_index_type,
assert_valid_metric_type,
assert_valid_quantization_type,
valid_capacity_type,
valid_dtype,
valid_id_type,
Expand All @@ -42,15 +45,15 @@

@dataclass
class IndexParams:
"""Parameters for defining a vector index."""
"""Parameters for configuring vector index creation and management."""

index_type: str = "hnsw"
data_type: VectorDType = np.float32
id_type: IDType = np.uint32
quantization_type: str = "none"
metric: str = "l2"
capacity: np.uint32 = 100000
max_nbrs: int = 32
index_type: str = None
data_type: VectorDType = None
id_type: IDType = None
quantization_type: str = None
metric: str = None
capacity: np.uint32 = None
max_nbrs: int = None

def index_path(self, folder_uri):
return os.path.join(folder_uri, f"{self.index_type}_{self.metric}_{self.max_nbrs}.index")
Expand Down Expand Up @@ -132,15 +135,21 @@ def from_kwargs(cls, **kwargs) -> "IndexParams":
max_nbrs = None

if kwargs.get("index_type") is not None:
index_type = valid_index_type(kwargs.get("index_type"))
ind_type = kwargs.get("index_type")
assert_valid_index_type(ind_type)
index_type = ind_type
if kwargs.get("data_type") is not None:
data_type = valid_dtype(kwargs.get("data_type"))
if kwargs.get("id_type") is not None:
id_type = valid_id_type(kwargs.get("id_type"))
if kwargs.get("quantization_type") is not None:
quantization_type = valid_quantization_type(kwargs.get("quantization_type"))
qt = kwargs.get("quantization_type")
assert_valid_quantization_type(qt)
quantization_type = qt
if kwargs.get("metric") is not None:
metric = valid_metric_type(kwargs.get("metric"))
mt = kwargs.get("metric")
assert_valid_metric_type(mt)
metric = mt
if kwargs.get("capacity") is not None:
capacity = valid_capacity_type(kwargs.get("capacity"))
if kwargs.get("max_nbrs") is not None:
Expand Down
27 changes: 18 additions & 9 deletions python/src/alayalite/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import numpy as np

__all__ = ["load_fvecs", "load_ivecs", "calc_recall", "calc_gt"]
__all__ = ["load_fvecs", "load_ivecs", "calc_recall", "calc_gt", "md5"]


def load_fvecs(file_path):
Expand Down Expand Up @@ -77,14 +77,23 @@ def load_ivecs(file_path):

def calc_recall(result, gt_data):
cnt = 0
for i in range(result.shape[0]):
for j in range(result.shape[1]):
for k in range(result.shape[1]):
if result[i][j] == gt_data[i][k]:
cnt += 1
break

return 1.0 * cnt / (len(result) * result.shape[1])
row = result.shape[0]
col = result.shape[1]
for i in range(row):
cnt += len(set(result[i]) & set(gt_data[i]))
return 1.0 * cnt / (row * col)


# def calc_recall(result, gt_data):
# cnt = 0
# for i in range(result.shape[0]):
# for j in range(result.shape[1]):
# for k in range(result.shape[1]):
# if result[i][j] == gt_data[i][k]:
# cnt += 1
# break

# return 1.0 * cnt / (len(result) * result.shape[1])


def calc_gt(data, query, topk):
Expand Down
46 changes: 36 additions & 10 deletions python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def test_create_collection(self):
collection = self.client.create_collection("test_collection")
self.assertIsInstance(collection, Collection)
self.assertIn("test_collection", self.client.list_collections())
with self.assertRaises(RuntimeError):
self.client.save_collection("test_collection")

def test_create_duplicate_collection(self):
self.client.create_collection("test_collection")
Expand All @@ -43,17 +45,19 @@ def test_get_collection(self):
self.assertIsInstance(collection, Collection)

def test_create_index(self):
index = self.client.create_index("test_index", index_type="flat")
index = self.client.create_index("test_index", metric="ip")
self.assertIsInstance(index, Index)
self.assertIn("test_index", self.client.list_indices())
with self.assertRaises(RuntimeError):
self.client.save_index("test_index")

def test_create_duplicate_index(self):
self.client.create_index("test_index", index_type="flat")
self.client.create_index("test_index")
with self.assertRaises(RuntimeError):
self.client.create_index("test_index", index_type="flat")
self.client.create_index("test_index")

def test_get_index(self):
self.client.create_index("test_index", index_type="flat")
self.client.create_index("test_index")
index = self.client.get_index("test_index")
self.assertIsInstance(index, Index)

Expand All @@ -63,27 +67,49 @@ def test_get_or_create_collection(self):
self.assertIs(collection1, collection2)

def test_get_or_create_index(self):
index1 = self.client.get_or_create_index("test_index", index_type="flat")
index2 = self.client.get_or_create_index("test_index", index_type="flat")
index1 = self.client.get_or_create_index("test_index")
index2 = self.client.get_or_create_index("test_index")
self.assertIs(index1, index2)

def test_delete_collection(self):
self.client.create_collection("test_collection")
self.client.delete_collection("test_collection")
with self.assertRaises(RuntimeError): # Without url
self.client.delete_collection("test_collection", True)
self.assertNotIn("test_collection", self.client.list_collections())
with self.assertRaises(RuntimeError):
self.client.delete_collection("non_exist")

def test_delete_index(self):
self.client.create_index("test_index", index_type="flat")
self.client.delete_index("test_index")
self.client.create_index("test_index")
with self.assertRaises(RuntimeError): # Without url
self.client.delete_index("test_index", True)
self.assertNotIn("test_index", self.client.list_indices())
with self.assertRaises(RuntimeError):
self.client.delete_index("non_exist")

def test_reset(self):
self.client.create_collection("test_collection")
self.client.create_index("test_index", index_type="flat")
self.client.create_index("test_index")
self.client.reset()
self.assertEqual(len(self.client.list_collections()), 0)
self.assertEqual(len(self.client.list_indices()), 0)

def test_get_non_exist(self):
index = self.client.get_index("non_exist")
self.assertIsNone(index)
coll = self.client.get_collection("non_exist")
self.assertIsNone(coll)

def test_dup_ind_coll(self):
_ = self.client.create_index("dup", metric="cosine", quantization_type=None)
with self.assertRaises(RuntimeError):
_ = self.client.create_collection("dup")

def test_dup_coll_ind(self):
_ = self.client.create_collection("dup")
with self.assertRaises(RuntimeError):
_ = self.client.create_index("dup")


if __name__ == "__main__":
unittest.main()
Loading
Loading