Files
alibaba--zvec/python/tests/test_embedding.py
T
2026-07-13 12:47:42 +08:00

2100 lines
81 KiB
Python

# Copyright 2025-present the zvec project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import os
from http import HTTPStatus
from unittest.mock import MagicMock, patch, Mock
import numpy as np
import pytest
from zvec.extension import (
BM25EmbeddingFunction,
DefaultLocalDenseEmbedding,
DefaultLocalSparseEmbedding,
OpenAIDenseEmbedding,
QwenDenseEmbedding,
QwenSparseEmbedding,
)
# Environment variable to control integration tests
# Set ZVEC_RUN_INTEGRATION_TESTS=1 to run real API/model tests
RUN_INTEGRATION_TESTS = os.environ.get("ZVEC_RUN_INTEGRATION_TESTS", "0") == "1"
# ----------------------------
# QwenDenseEmbedding Test Case
# ----------------------------
class TestQwenDenseEmbedding:
def test_init_with_api_key(self):
# Test initialization with explicit API key
embedding_func = QwenDenseEmbedding(dimension=128, api_key="test_key")
assert embedding_func.dimension == 128
assert embedding_func.model == "text-embedding-v4"
assert embedding_func._api_key == "test_key"
@patch.dict(os.environ, {"DASHSCOPE_API_KEY": "env_key"})
def test_init_with_env_api_key(self):
# Test initialization with API key from environment
embedding_func = QwenDenseEmbedding(dimension=128)
assert embedding_func._api_key == "env_key"
@patch.dict(os.environ, {"DASHSCOPE_API_KEY": ""})
def test_init_with_empty_env_api_key(self):
# Test initialization with empty API key from environment
with pytest.raises(ValueError, match="DashScope API key is required"):
QwenDenseEmbedding(dimension=128)
def test_model_property(self):
embedding_func = QwenDenseEmbedding(dimension=128, api_key="test_key")
assert embedding_func.model == "text-embedding-v4"
embedding_func = QwenDenseEmbedding(
dimension=128, model="custom-model", api_key="test_key"
)
assert embedding_func.model == "custom-model"
@patch("zvec.extension.qwen_function.require_module")
def test_embed_with_empty_text(self, mock_require_module):
# Test embed method with empty text raises ValueError
embedding_func = QwenDenseEmbedding(dimension=128, api_key="test_key")
with pytest.raises(
ValueError, match="Input text cannot be empty or whitespace only"
):
embedding_func.embed("")
with pytest.raises(TypeError):
embedding_func.embed(None)
@patch("zvec.extension.qwen_function.require_module")
def test_embed_success(self, mock_require_module):
# Test successful embedding
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.output = {"embeddings": [{"embedding": [0.1, 0.2, 0.3]}]}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenDenseEmbedding(dimension=3, api_key="test_key")
# Clear cache to avoid interference
embedding_func.embed.cache_clear()
result = embedding_func.embed("test text")
assert result == [0.1, 0.2, 0.3]
mock_dashscope.TextEmbedding.call.assert_called_once_with(
model="text-embedding-v4",
input="test text",
dimension=3,
output_type="dense",
)
@patch("zvec.extension.qwen_function.require_module")
def test_embed_http_error(self, mock_require_module):
# Test embedding with HTTP error
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.BAD_REQUEST
mock_response.message = "Bad Request"
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenDenseEmbedding(dimension=128, api_key="test_key")
embedding_func.embed.cache_clear()
with pytest.raises(ValueError):
embedding_func.embed("test text")
@patch("zvec.extension.qwen_function.require_module")
def test_embed_invalid_response(self, mock_require_module):
# Test embedding with invalid response (wrong number of embeddings)
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.output = {"embeddings": []}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenDenseEmbedding(dimension=128, api_key="test_key")
embedding_func.embed.cache_clear()
with pytest.raises(ValueError):
embedding_func.embed("test text")
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
def test_real_embed_success(self):
"""Integration test with real DashScope API.
To run this test, set environment variable:
export ZVEC_RUN_INTEGRATION_TESTS=1
export DASHSCOPE_API_KEY=your-api-key
"""
embedding_func = QwenDenseEmbedding(dimension=128)
dense = embedding_func("test text")
assert len(dense) == 128
# ----------------------------
# QwenSparseEmbedding Test Case
# ----------------------------
class TestQwenSparseEmbedding:
"""Test suite for QwenSparseEmbedding (Qwen sparse embedding via DashScope API)."""
def test_init_with_api_key(self):
"""Test initialization with explicit API key."""
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
assert embedding_func._dimension == 1024
assert embedding_func.model == "text-embedding-v4"
assert embedding_func._api_key == "test_key"
# encoding_type defaults to "query" via extra_params
assert embedding_func.extra_params.get("encoding_type", "query") == "query"
def test_init_with_custom_encoding_type(self):
"""Test initialization with custom encoding type."""
embedding_func = QwenSparseEmbedding(
dimension=1024, encoding_type="document", api_key="test_key"
)
assert embedding_func.extra_params.get("encoding_type") == "document"
@patch.dict(os.environ, {"DASHSCOPE_API_KEY": "env_key"})
def test_init_with_env_api_key(self):
"""Test initialization with API key from environment."""
embedding_func = QwenSparseEmbedding(dimension=1024)
assert embedding_func._api_key == "env_key"
@patch.dict(os.environ, {"DASHSCOPE_API_KEY": ""})
def test_init_without_api_key(self):
"""Test initialization fails without API key."""
with pytest.raises(ValueError, match="DashScope API key is required"):
QwenSparseEmbedding(dimension=1024)
def test_model_property(self):
"""Test model property."""
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
assert embedding_func.model == "text-embedding-v4"
embedding_func = QwenSparseEmbedding(
dimension=1024, model="text-embedding-v3", api_key="test_key"
)
assert embedding_func.model == "text-embedding-v3"
def test_encoding_type_property(self):
"""Test encoding_type via extra_params."""
query_emb = QwenSparseEmbedding(
dimension=1024, encoding_type="query", api_key="test_key"
)
assert query_emb.extra_params.get("encoding_type") == "query"
doc_emb = QwenSparseEmbedding(
dimension=1024, encoding_type="document", api_key="test_key"
)
assert doc_emb.extra_params.get("encoding_type") == "document"
@patch("zvec.extension.qwen_function.require_module")
def test_embed_with_empty_text(self, mock_require_module):
"""Test embed method with empty text raises ValueError."""
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
with pytest.raises(
ValueError, match="Input text cannot be empty or whitespace only"
):
embedding_func.embed("")
with pytest.raises(
ValueError, match="Input text cannot be empty or whitespace only"
):
embedding_func.embed(" ")
@patch("zvec.extension.qwen_function.require_module")
def test_embed_with_non_string_input(self, mock_require_module):
"""Test embed method with non-string input raises TypeError."""
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
with pytest.raises(TypeError, match="Expected 'input' to be str"):
embedding_func.embed(123)
with pytest.raises(TypeError, match="Expected 'input' to be str"):
embedding_func.embed(None)
@patch("zvec.extension.qwen_function.require_module")
def test_embed_success(self, mock_require_module):
"""Test successful sparse embedding generation."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Sparse embedding returns array of {index, value, token} objects
mock_response.output = {
"embeddings": [
{
"sparse_embedding": [
{"index": 10, "value": 0.5, "token": "机器"},
{"index": 245, "value": 0.8, "token": "学习"},
{"index": 1023, "value": 1.2, "token": "算法"},
]
}
]
}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
# Clear cache to avoid interference
embedding_func.embed.cache_clear()
result = embedding_func.embed("test text")
# Verify result is a dict
assert isinstance(result, dict)
# Verify keys are integers
assert all(isinstance(k, int) for k in result.keys())
# Verify values are floats
assert all(isinstance(v, float) for v in result.values())
# Verify all values are positive
assert all(v > 0 for v in result.values())
# Verify sorted by indices
keys = list(result.keys())
assert keys == sorted(keys)
# Verify specific keys
assert keys == [10, 245, 1023]
mock_dashscope.TextEmbedding.call.assert_called_once_with(
model="text-embedding-v4",
input="test text",
dimension=1024,
output_type="sparse",
text_type="query",
)
@patch("zvec.extension.qwen_function.require_module")
def test_embed_with_document_encoding_type(self, mock_require_module):
"""Test embedding with document encoding type."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.output = {
"embeddings": [
{
"sparse_embedding": [
{"index": 5, "value": 0.3, "token": "文档"},
{"index": 100, "value": 0.7, "token": "内容"},
{"index": 500, "value": 0.9, "token": "检索"},
]
}
]
}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(
dimension=1024, encoding_type="document", api_key="test_key"
)
embedding_func.embed.cache_clear()
result = embedding_func.embed("test document")
assert isinstance(result, dict)
assert list(result.keys()) == [5, 100, 500]
# Verify text_type parameter is "document"
call_args = mock_dashscope.TextEmbedding.call.call_args
assert call_args[1]["text_type"] == "document"
assert call_args[1]["output_type"] == "sparse"
@patch("zvec.extension.qwen_function.require_module")
def test_embed_output_sorted_by_indices(self, mock_require_module):
"""Test that output is always sorted by indices in ascending order."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Return unsorted indices
mock_response.output = {
"embeddings": [
{
"sparse_embedding": [
{"index": 9999, "value": 1.5, "token": "A"},
{"index": 5, "value": 2.0, "token": "B"},
{"index": 1234, "value": 0.8, "token": "C"},
{"index": 77, "value": 3.2, "token": "D"},
{"index": 500, "value": 1.1, "token": "E"},
]
}
]
}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
result = embedding_func.embed("test sorting")
# Verify keys are sorted
result_keys = list(result.keys())
assert result_keys == sorted(result_keys)
# Verify expected sorted order
assert result_keys == [5, 77, 500, 1234, 9999]
@patch("zvec.extension.qwen_function.require_module")
def test_embed_filters_zero_values(self, mock_require_module):
"""Test that zero and negative values are filtered out."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Include zero and negative values
mock_response.output = {
"embeddings": [
{
"sparse_embedding": [
{"index": 10, "value": 0.5, "token": "正"},
{
"index": 20,
"value": 0.0,
"token": "零",
}, # Should be filtered
{
"index": 30,
"value": -0.3,
"token": "负",
}, # Should be filtered
{"index": 40, "value": 0.8, "token": "正"},
{
"index": 50,
"value": 0.0,
"token": "零",
}, # Should be filtered
]
}
]
}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
result = embedding_func.embed("test filtering")
# Only positive values should remain
assert list(result.keys()) == [10, 40]
assert all(v > 0 for v in result.values())
@patch("zvec.extension.qwen_function.require_module")
def test_embed_http_error(self, mock_require_module):
"""Test embedding with HTTP error."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.BAD_REQUEST
mock_response.message = "Bad Request"
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
with pytest.raises(ValueError, match="DashScope API error"):
embedding_func.embed("test text")
@patch("zvec.extension.qwen_function.require_module")
def test_embed_invalid_response_no_embeddings(self, mock_require_module):
"""Test embedding with invalid response (no embeddings)."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.output = {"embeddings": []}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
with pytest.raises(ValueError, match="Expected exactly 1 embedding"):
embedding_func.embed("test text")
@patch("zvec.extension.qwen_function.require_module")
def test_embed_invalid_response_not_dict(self, mock_require_module):
"""Test embedding with invalid response (sparse_embedding not list)."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# sparse_embedding should be list, not dict
mock_response.output = {
"embeddings": [{"sparse_embedding": {"index": 10, "value": 0.5}}]
}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
with pytest.raises(
ValueError, match="'sparse_embedding' field is missing or not a list"
):
embedding_func.embed("test text")
@patch("zvec.extension.qwen_function.require_module")
def test_embed_callable_interface(self, mock_require_module):
"""Test that embedding function is callable."""
mock_dashscope = MagicMock()
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.output = {
"embeddings": [
{
"sparse_embedding": [
{"index": 100, "value": 1.0, "token": "测试"},
{"index": 200, "value": 0.5, "token": "调用"},
]
}
]
}
mock_dashscope.TextEmbedding.call.return_value = mock_response
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
# Test calling the function directly
result = embedding_func("test text")
assert isinstance(result, dict)
assert list(result.keys()) == [100, 200]
@patch("zvec.extension.qwen_function.require_module")
def test_embed_api_connection_error(self, mock_require_module):
"""Test handling of API connection errors."""
mock_dashscope = MagicMock()
mock_dashscope.TextEmbedding.call.side_effect = Exception("Connection timeout")
mock_require_module.return_value = mock_dashscope
embedding_func = QwenSparseEmbedding(dimension=1024, api_key="test_key")
embedding_func.embed.cache_clear()
with pytest.raises(RuntimeError, match="Failed to call DashScope API"):
embedding_func.embed("test text")
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
def test_real_embed_success(self):
"""Integration test with real DashScope API.
To run this test, set environment variable:
export ZVEC_RUN_INTEGRATION_TESTS=1
export DASHSCOPE_API_KEY=your-api-key
"""
# Test query embedding
query_emb = QwenSparseEmbedding(dimension=1024, encoding_type="query")
query_vec = query_emb.embed("machine learning")
assert isinstance(query_vec, dict)
assert len(query_vec) > 0
assert all(isinstance(k, int) for k in query_vec.keys())
assert all(isinstance(v, float) and v > 0 for v in query_vec.values())
# Verify sorted output
keys = list(query_vec.keys())
assert keys == sorted(keys)
# Test document embedding
doc_emb = QwenSparseEmbedding(dimension=1024, encoding_type="document")
doc_vec = doc_emb.embed("Machine learning is a subset of AI")
assert isinstance(doc_vec, dict)
assert len(doc_vec) > 0
# Verify sorted output
doc_keys = list(doc_vec.keys())
assert doc_keys == sorted(doc_keys)
# ----------------------------
# OpenAIDenseEmbedding Test Case
# ----------------------------
class TestOpenAIDenseEmbedding:
def test_init_with_api_key(self):
"""Test initialization with explicit API key."""
embedding_func = OpenAIDenseEmbedding(api_key="sk-test-key")
assert embedding_func.dimension == 1536 # Default for text-embedding-3-small
assert embedding_func.model == "text-embedding-3-small"
assert embedding_func._api_key == "sk-test-key"
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-env-key"})
def test_init_with_env_api_key(self):
"""Test initialization with API key from environment."""
embedding_func = OpenAIDenseEmbedding()
assert embedding_func._api_key == "sk-env-key"
@patch.dict(os.environ, {"OPENAI_API_KEY": ""})
def test_init_without_api_key(self):
"""Test initialization fails without API key."""
with pytest.raises(ValueError, match="OpenAI API key is required"):
OpenAIDenseEmbedding()
def test_init_with_custom_dimension(self):
"""Test initialization with custom dimension."""
embedding_func = OpenAIDenseEmbedding(
model="text-embedding-3-large", dimension=1024, api_key="sk-test"
)
assert embedding_func.dimension == 1024
assert embedding_func.model == "text-embedding-3-large"
def test_init_with_base_url(self):
"""Test initialization with custom base URL."""
embedding_func = OpenAIDenseEmbedding(
api_key="sk-test", base_url="https://custom.openai.com/"
)
assert embedding_func._base_url == "https://custom.openai.com/"
def test_model_property(self):
"""Test model property."""
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
assert embedding_func.model == "text-embedding-3-small"
embedding_func = OpenAIDenseEmbedding(
model="text-embedding-ada-002", api_key="sk-test"
)
assert embedding_func.model == "text-embedding-ada-002"
def test_extra_params(self):
"""Test extra_params property."""
# Test without extra params
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
assert embedding_func.extra_params == {}
# Test with extra params
embedding_func = OpenAIDenseEmbedding(
api_key="sk-test",
encoding_format="float",
user="test-user",
)
assert embedding_func.extra_params == {
"encoding_format": "float",
"user": "test-user",
}
@patch("zvec.extension.openai_function.require_module")
def test_embed_with_empty_text(self, mock_require_module):
"""Test embed method with empty text raises ValueError."""
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
with pytest.raises(
ValueError, match="Input text cannot be empty or whitespace only"
):
embedding_func.embed("")
with pytest.raises(
ValueError, match="Input text cannot be empty or whitespace only"
):
embedding_func.embed(" ")
@patch("zvec.extension.openai_function.require_module")
def test_embed_with_non_string_input(self, mock_require_module):
"""Test embed method with non-string input raises TypeError."""
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
with pytest.raises(TypeError, match="Expected 'input' to be str"):
embedding_func.embed(123)
with pytest.raises(TypeError, match="Expected 'input' to be str"):
embedding_func.embed(None)
@patch("zvec.extension.openai_function.require_module")
def test_embed_success(self, mock_require_module):
"""Test successful embedding generation."""
# Mock OpenAI client
mock_openai = Mock()
mock_client = Mock()
mock_response = Mock()
# Create mock embedding data
fake_embedding = [0.1, 0.2, 0.3]
mock_embedding_obj = Mock()
mock_embedding_obj.embedding = fake_embedding
mock_response.data = [mock_embedding_obj]
mock_client.embeddings.create.return_value = mock_response
mock_openai.OpenAI.return_value = mock_client
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(dimension=3, api_key="sk-test")
embedding_func.embed.cache_clear()
result = embedding_func.embed("test text")
assert result == [0.1, 0.2, 0.3]
mock_client.embeddings.create.assert_called_once_with(
model="text-embedding-3-small", input="test text", dimensions=3
)
@patch("zvec.extension.openai_function.require_module")
def test_embed_with_custom_model(self, mock_require_module):
"""Test embedding with custom model."""
mock_openai = Mock()
mock_client = Mock()
mock_response = Mock()
fake_embedding = [0.1] * 1536
mock_embedding_obj = Mock()
mock_embedding_obj.embedding = fake_embedding
mock_response.data = [mock_embedding_obj]
mock_client.embeddings.create.return_value = mock_response
mock_openai.OpenAI.return_value = mock_client
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(
model="text-embedding-ada-002", api_key="sk-test"
)
embedding_func.embed.cache_clear()
result = embedding_func.embed("test text")
assert len(result) == 1536
mock_client.embeddings.create.assert_called_once_with(
model="text-embedding-ada-002", input="test text"
)
@patch("zvec.extension.openai_function.require_module")
def test_embed_api_error(self, mock_require_module):
"""Test handling of API errors."""
mock_openai = Mock()
mock_client = Mock()
# Simulate API error
api_error = Mock()
api_error.__class__.__name__ = "APIError"
mock_openai.APIError = type("APIError", (Exception,), {})
mock_openai.APIConnectionError = type("APIConnectionError", (Exception,), {})
mock_client.embeddings.create.side_effect = mock_openai.APIError(
"Rate limit exceeded"
)
mock_openai.OpenAI.return_value = mock_client
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
embedding_func.embed.cache_clear()
with pytest.raises(RuntimeError, match="Failed to call OpenAI API"):
embedding_func.embed("test text")
@patch("zvec.extension.openai_function.require_module")
def test_embed_invalid_response(self, mock_require_module):
"""Test handling of invalid API response."""
mock_openai = Mock()
mock_client = Mock()
mock_response = Mock()
# Empty response data
mock_response.data = []
mock_client.embeddings.create.return_value = mock_response
mock_openai.OpenAI.return_value = mock_client
mock_openai.APIError = type("APIError", (Exception,), {})
mock_openai.APIConnectionError = type("APIConnectionError", (Exception,), {})
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
embedding_func.embed.cache_clear()
with pytest.raises(ValueError, match="no embedding data returned"):
embedding_func.embed("test text")
@patch("zvec.extension.openai_function.require_module")
def test_embed_dimension_mismatch(self, mock_require_module):
"""Test handling of dimension mismatch."""
mock_openai = Mock()
mock_client = Mock()
mock_response = Mock()
# Return embedding with wrong dimension
fake_embedding = [0.1] * 512
mock_embedding_obj = Mock()
mock_embedding_obj.embedding = fake_embedding
mock_response.data = [mock_embedding_obj]
mock_client.embeddings.create.return_value = mock_response
mock_openai.OpenAI.return_value = mock_client
mock_openai.APIError = type("APIError", (Exception,), {})
mock_openai.APIConnectionError = type("APIConnectionError", (Exception,), {})
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(dimension=1536, api_key="sk-test")
embedding_func.embed.cache_clear()
with pytest.raises(ValueError, match="Dimension mismatch"):
embedding_func.embed("test text")
@patch("zvec.extension.openai_function.require_module")
def test_embed_callable(self, mock_require_module):
"""Test that embedding function is callable."""
mock_openai = Mock()
mock_client = Mock()
mock_response = Mock()
fake_embedding = [0.1] * 1536
mock_embedding_obj = Mock()
mock_embedding_obj.embedding = fake_embedding
mock_response.data = [mock_embedding_obj]
mock_client.embeddings.create.return_value = mock_response
mock_openai.OpenAI.return_value = mock_client
mock_openai.APIError = type("APIError", (Exception,), {})
mock_openai.APIConnectionError = type("APIConnectionError", (Exception,), {})
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(api_key="sk-test")
embedding_func.embed.cache_clear()
# Test calling the function directly
result = embedding_func("test text")
assert isinstance(result, list)
assert len(result) == 1536
@patch("zvec.extension.openai_function.require_module")
def test_embed_with_base_url(self, mock_require_module):
"""Test embedding with custom base URL."""
mock_openai = Mock()
mock_client = Mock()
mock_response = Mock()
fake_embedding = [0.1] * 1536
mock_embedding_obj = Mock()
mock_embedding_obj.embedding = fake_embedding
mock_response.data = [mock_embedding_obj]
mock_client.embeddings.create.return_value = mock_response
mock_openai.OpenAI.return_value = mock_client
mock_openai.APIError = type("APIError", (Exception,), {})
mock_openai.APIConnectionError = type("APIConnectionError", (Exception,), {})
mock_require_module.return_value = mock_openai
embedding_func = OpenAIDenseEmbedding(
api_key="sk-test", base_url="https://custom.openai.com/"
)
embedding_func.embed.cache_clear()
result = embedding_func.embed("test text")
# Verify client was created with custom base URL
mock_openai.OpenAI.assert_called_once_with(
api_key="sk-test", base_url="https://custom.openai.com/"
)
assert len(result) == 1536
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
def test_real_embed_success(self):
"""Integration test with real OpenAI API.
To run this test, set environment variable:
export ZVEC_RUN_INTEGRATION_TESTS=1
export OPENAI_API_KEY=sk-...
"""
embedding_func = OpenAIDenseEmbedding(
model="text-embedding-v4",
dimension=256,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
vector = embedding_func.embed("Hello, world!")
assert len(vector) == 256
assert isinstance(vector, list)
assert all(isinstance(x, float) for x in vector)
# ----------------------------
# DefaultLocalDenseEmbedding Test Case
# ----------------------------
class TestDefaultLocalDenseEmbedding:
"""Test cases for DefaultLocalDenseEmbedding."""
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_init_success(self, mock_require_module):
"""Test successful initialization with mocked model."""
# Mock sentence_transformers module
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_model.device = "cpu"
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
# Initialize embedding function
emb_func = DefaultLocalDenseEmbedding()
# Assertions
assert emb_func.dimension == 384
assert emb_func.model_name == "all-MiniLM-L6-v2"
assert emb_func.model_source == "huggingface"
assert emb_func.device == "cpu"
mock_st.SentenceTransformer.assert_called_once_with(
"all-MiniLM-L6-v2", device=None, trust_remote_code=True
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_init_with_custom_device(self, mock_require_module):
"""Test initialization with custom device."""
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_model.device = "cuda"
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding(device="cuda")
assert emb_func.device == "cuda"
mock_st.SentenceTransformer.assert_called_once_with(
"all-MiniLM-L6-v2", device="cuda", trust_remote_code=True
)
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_init_with_modelscope(self, mock_require_module):
"""Test initialization with ModelScope as model source."""
mock_st = Mock()
mock_ms = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_model.device = "cpu"
mock_st.SentenceTransformer.return_value = mock_model
def require_module_side_effect(module_name):
if module_name == "sentence_transformers":
return mock_st
elif module_name == "modelscope":
return mock_ms
raise ImportError(f"No module named '{module_name}'")
mock_require_module.side_effect = require_module_side_effect
# Mock snapshot_download at the correct import location
with patch(
"modelscope.hub.snapshot_download.snapshot_download",
return_value="/path/to/cached/model",
):
emb_func = DefaultLocalDenseEmbedding(model_source="modelscope")
# Assertions
assert emb_func.dimension == 384
assert emb_func.model_name == "iic/nlp_gte_sentence-embedding_chinese-small"
assert emb_func.model_source == "modelscope"
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_init_with_invalid_model_source(self, mock_require_module):
"""Test initialization with invalid model_source raises ValueError."""
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
with pytest.raises(ValueError, match="Invalid model_source"):
DefaultLocalDenseEmbedding(model_source="invalid_source")
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_success(self, mock_require_module):
"""Test successful embedding generation."""
# Mock embedding output
fake_embedding = np.random.rand(384).astype(np.float32)
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
# Configure encode method
mock_model.encode = Mock(return_value=fake_embedding)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding()
result = emb_func.embed("Hello, world!")
# Assertions
assert isinstance(result, list)
assert len(result) == 384
assert all(isinstance(x, float) for x in result)
mock_model.encode.assert_called_once_with(
"Hello, world!",
convert_to_numpy=True,
normalize_embeddings=True,
batch_size=32,
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_with_normalization(self, mock_require_module):
"""Test embedding with L2 normalization."""
# Create a normalized vector
fake_embedding = np.random.rand(384).astype(np.float32)
fake_embedding = fake_embedding / np.linalg.norm(fake_embedding)
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
# Configure encode method
mock_model.encode = Mock(return_value=fake_embedding)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding(normalize_embeddings=True)
result = emb_func.embed("Test sentence")
# Check if vector is normalized (L2 norm should be close to 1.0)
result_array = np.array(result)
norm = np.linalg.norm(result_array)
assert abs(norm - 1.0) < 1e-5
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_empty_string(self, mock_require_module):
"""Test embedding with empty string raises ValueError."""
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding()
with pytest.raises(ValueError, match="Input text cannot be empty"):
emb_func.embed("")
with pytest.raises(ValueError, match="Input text cannot be empty"):
emb_func.embed(" ")
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_non_string_input(self, mock_require_module):
"""Test embedding with non-string input raises TypeError."""
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding()
with pytest.raises(TypeError, match="Expected 'input' to be str"):
emb_func.embed(123)
with pytest.raises(TypeError, match="Expected 'input' to be str"):
emb_func.embed(None)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_callable(self, mock_require_module):
"""Test that embedding function is callable."""
fake_embedding = np.random.rand(384).astype(np.float32)
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
# Configure encode method
mock_model.encode = Mock(return_value=fake_embedding)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding()
# Test calling the function directly
result = emb_func("Test text")
assert isinstance(result, list)
assert len(result) == 384
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_semantic_similarity(self, mock_require_module):
"""Test semantic similarity between similar and different texts."""
# Create mock embeddings for similar and different texts
similar_emb_1 = np.array([1.0, 0.0, 0.0] + [0.0] * 381, dtype=np.float32)
similar_emb_2 = np.array([0.9, 0.1, 0.0] + [0.0] * 381, dtype=np.float32)
different_emb = np.array([0.0, 0.0, 1.0] + [0.0] * 381, dtype=np.float32)
# Normalize
similar_emb_1 = similar_emb_1 / np.linalg.norm(similar_emb_1)
similar_emb_2 = similar_emb_2 / np.linalg.norm(similar_emb_2)
different_emb = different_emb / np.linalg.norm(different_emb)
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
# Configure encode method with side_effect for multiple calls
mock_model.encode = Mock(
side_effect=[similar_emb_1, similar_emb_2, different_emb]
)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding()
v1 = emb_func.embed("The cat sits on the mat")
v2 = emb_func.embed("A feline rests on a rug")
v3 = emb_func.embed("Python programming")
# Calculate similarities
similarity_high = np.dot(v1, v2)
similarity_low = np.dot(v1, v3)
assert similarity_high > similarity_low
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_model_loading_error(self, mock_require_module):
"""Test handling of model loading failure."""
# Clear model cache
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
mock_st = Mock()
mock_st.SentenceTransformer.side_effect = Exception("Model not found")
mock_require_module.return_value = mock_st
with pytest.raises(
ValueError, match="Failed to load Sentence Transformer model"
):
DefaultLocalDenseEmbedding()
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_modelscope_import_error(self, mock_require_module):
"""Test handling of ModelScope import error."""
mock_st = Mock()
def require_module_side_effect(module_name):
if module_name == "sentence_transformers":
return mock_st
elif module_name == "modelscope":
raise ImportError("No module named 'modelscope'")
mock_require_module.side_effect = require_module_side_effect
with pytest.raises(
ImportError, match="ModelScope support requires the 'modelscope' package"
):
DefaultLocalDenseEmbedding(model_source="modelscope")
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_dimension_mismatch(self, mock_require_module):
"""Test handling of dimension mismatch in embedding output."""
# Return embedding with wrong dimension
fake_embedding = np.random.rand(256).astype(np.float32)
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
# Configure encode method
mock_model.encode = Mock(return_value=fake_embedding)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
emb_func = DefaultLocalDenseEmbedding()
with pytest.raises(ValueError, match="Dimension mismatch"):
emb_func.embed("Test text")
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
def test_real_embedding_generation(self):
"""Integration test with real model (requires sentence-transformers).
To run this test, set environment variable:
export ZVEC_RUN_INTEGRATION_TESTS=1
Note: First run will download the model (~80MB).
"""
emb_func = DefaultLocalDenseEmbedding()
# Test basic embedding
vector = emb_func.embed("Hello, world!")
assert len(vector) == 384
assert isinstance(vector, list)
assert all(isinstance(x, float) for x in vector)
# Test normalization
norm = np.linalg.norm(vector)
assert abs(norm - 1.0) < 1e-5
# Test semantic similarity
v1 = emb_func.embed("The cat sits on the mat")
v2 = emb_func.embed("A feline rests on a rug")
v3 = emb_func.embed("Python programming language")
similarity_high = np.dot(v1, v2)
similarity_low = np.dot(v1, v3)
assert similarity_high > similarity_low
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_model_properties(self, mock_require_module):
"""Test model_name and model_source properties."""
mock_st = Mock()
mock_model = Mock()
mock_model.get_sentence_embedding_dimension.return_value = 384
mock_model.device = "cpu"
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
# Test Hugging Face
emb_func_hf = DefaultLocalDenseEmbedding(model_source="huggingface")
assert emb_func_hf.model_name == "all-MiniLM-L6-v2"
assert emb_func_hf.model_source == "huggingface"
# Test ModelScope
with patch(
"modelscope.hub.snapshot_download.snapshot_download",
return_value="/path/to/model",
):
mock_ms = Mock()
mock_require_module.side_effect = lambda m: (
mock_st if m == "sentence_transformers" else mock_ms
)
emb_func_ms = DefaultLocalDenseEmbedding(model_source="modelscope")
assert (
emb_func_ms.model_name == "iic/nlp_gte_sentence-embedding_chinese-small"
)
assert emb_func_ms.model_source == "modelscope"
# -----------------------------------
# DefaultLocalSparseEmbedding Test Case
# -----------------------------------
class TestDefaultLocalSparseEmbedding:
"""Test suite for DefaultLocalSparseEmbedding (SPLADE sparse embedding).
Note:
DefaultLocalSparseEmbedding uses naver/splade-cocondenser-ensembledistil
instead of naver/splade-v3 because:
- splade-v3 is a gated model requiring Hugging Face authentication
- cocondenser-ensembledistil is publicly accessible
- Performance difference is minimal (~2%)
- Avoids "Access to model is restricted" errors
This allows all users to run tests without authentication setup.
"""
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_init_success(self, mock_require_module):
"""Test successful initialization.
Verifies that DefaultLocalSparseEmbedding initializes with the publicly
accessible naver/splade-cocondenser-ensembledistil model instead of
the gated naver/splade-v3 model.
"""
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cpu"
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
assert sparse_emb.model_name == "naver/splade-cocondenser-ensembledistil"
assert sparse_emb.model_source == "huggingface"
assert sparse_emb.device == "cpu"
mock_st.SentenceTransformer.assert_called_once_with(
"naver/splade-cocondenser-ensembledistil",
device=None,
trust_remote_code=True,
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_init_with_custom_device(self, mock_require_module):
"""Test initialization with custom device."""
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cuda"
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding(device="cuda")
assert sparse_emb.device == "cuda"
mock_st.SentenceTransformer.assert_called_once_with(
"naver/splade-cocondenser-ensembledistil",
device="cuda",
trust_remote_code=True,
)
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_success(self, mock_require_module):
"""Test successful sparse embedding generation with official API."""
import numpy as np
# Clear model cache to ensure fresh mock
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
# Create a mock sparse matrix that simulates scipy.sparse behavior
# The code will call: sparse_matrix[0].toarray().flatten()
mock_sparse_matrix = Mock()
# Create a dense array representation with vocab_size=30522
vocab_size = 30522
dense_array = np.zeros(vocab_size)
# Set specific non-zero values at indices [10, 245, 1023, 5678]
dense_array[10] = 0.5
dense_array[245] = 0.8
dense_array[1023] = 1.2
dense_array[5678] = 0.3
# Mock the method chain: sparse_matrix[0].toarray().flatten()
mock_row = Mock()
mock_dense = Mock()
mock_row.toarray.return_value = mock_dense
mock_dense.flatten.return_value = dense_array
mock_sparse_matrix.__getitem__ = Mock(return_value=mock_row)
# Also mock hasattr check for 'toarray'
mock_sparse_matrix.toarray = Mock()
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cpu"
# Configure mock methods to return sparse matrix
# Must set return_value BEFORE hasattr() check in the code
mock_model.encode_query = Mock(return_value=mock_sparse_matrix)
mock_model.encode_document = Mock(return_value=mock_sparse_matrix)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
result = sparse_emb.embed("machine learning")
# Verify result is a dictionary
assert isinstance(result, dict)
# Verify keys are integers and values are floats
assert all(isinstance(k, int) for k in result.keys())
assert all(isinstance(v, float) for v in result.values())
# Verify all values are positive
assert all(v > 0 for v in result.values())
# Sparse vectors should have specific dimensions
assert len(result) == 4
# Verify output is sorted by indices (keys)
keys = list(result.keys())
assert keys == sorted(keys), (
"Sparse vector keys must be sorted in ascending order"
)
# Verify expected keys
assert keys == [10, 245, 1023, 5678]
# Verify encode_query was called with a list
mock_model.encode_query.assert_called_once()
call_args = mock_model.encode_query.call_args[0][0]
assert isinstance(call_args, list)
assert call_args == ["machine learning"]
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_empty_input(self, mock_require_module):
"""Test embedding with empty input."""
mock_st = Mock()
mock_model = Mock()
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
with pytest.raises(ValueError, match="Input text cannot be empty"):
sparse_emb.embed("")
with pytest.raises(ValueError, match="Input text cannot be empty"):
sparse_emb.embed(" ")
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_embed_non_string_input(self, mock_require_module):
"""Test embedding with non-string input."""
mock_st = Mock()
mock_model = Mock()
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
with pytest.raises(TypeError, match="Expected 'input' to be str"):
sparse_emb.embed(123)
with pytest.raises(TypeError, match="Expected 'input' to be str"):
sparse_emb.embed(["text"])
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_callable_interface(self, mock_require_module):
"""Test that DefaultSparseEmbedding is callable."""
import numpy as np
# Clear model cache
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
# Create a mock sparse matrix
mock_sparse_matrix = Mock()
# Create a dense array representation with vocab_size=30522
vocab_size = 30522
dense_array = np.zeros(vocab_size)
# Set specific non-zero values at indices [100, 200, 300]
dense_array[100] = 1.0
dense_array[200] = 0.5
dense_array[300] = 0.8
# Mock the method chain: sparse_matrix[0].toarray().flatten()
mock_row = Mock()
mock_dense = Mock()
mock_row.toarray.return_value = mock_dense
mock_dense.flatten.return_value = dense_array
mock_sparse_matrix.__getitem__ = Mock(return_value=mock_row)
# Also mock hasattr check for 'toarray'
mock_sparse_matrix.toarray = Mock()
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cpu"
# Configure mock methods
mock_model.encode_query = Mock(return_value=mock_sparse_matrix)
mock_model.encode_document = Mock(return_value=mock_sparse_matrix)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
# Test callable interface
result = sparse_emb("test input")
assert isinstance(result, dict)
assert all(isinstance(k, int) for k in result.keys())
# Verify sorted output
keys = list(result.keys())
assert keys == sorted(keys), "Callable interface must also return sorted keys"
assert keys == [100, 200, 300]
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_model_loading_failure(self, mock_require_module):
"""Test handling of model loading failure."""
# Clear model cache to ensure the test actually tries to load the model
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
mock_st = Mock()
mock_st.SentenceTransformer.side_effect = Exception("Model not found")
mock_require_module.return_value = mock_st
with pytest.raises(
ValueError, match="Failed to load Sentence Transformer model"
):
DefaultLocalSparseEmbedding()
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_inference_failure(self, mock_require_module):
"""Test handling of inference failure."""
# Clear model cache
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cpu"
# Configure mock methods to raise RuntimeError
mock_model.encode_query = Mock(side_effect=RuntimeError("CUDA out of memory"))
mock_model.encode_document = Mock(
side_effect=RuntimeError("CUDA out of memory")
)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
with pytest.raises(RuntimeError, match="Failed to generate sparse embedding"):
sparse_emb.embed("test input")
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_sparse_vector_properties(self, mock_require_module):
"""Test properties of sparse vectors (sparsity, non-zero values, sorted order)."""
import numpy as np
# Clear model cache
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
# Create a mock sparse matrix that simulates scipy.sparse behavior
# The code will call: sparse_matrix[0].toarray().flatten()
mock_sparse_matrix = Mock()
# Create a dense array representation with vocab_size=30522
vocab_size = 30522
dense_array = np.zeros(vocab_size)
# Set specific non-zero values at indices [50, 100, 200, 400, 500]
dense_array[50] = 3.0
dense_array[100] = 2.0
dense_array[200] = 1.5
dense_array[400] = 2.5
dense_array[500] = 1.8
# Mock the method chain: sparse_matrix[0].toarray().flatten()
mock_row = Mock()
mock_dense = Mock()
mock_row.toarray.return_value = mock_dense
mock_dense.flatten.return_value = dense_array
mock_sparse_matrix.__getitem__ = Mock(return_value=mock_row)
# Also mock hasattr check for 'toarray'
mock_sparse_matrix.toarray = Mock()
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cpu"
# Configure mock methods
mock_model.encode_query = Mock(return_value=mock_sparse_matrix)
mock_model.encode_document = Mock(return_value=mock_sparse_matrix)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
result = sparse_emb.embed("test")
# Verify sparsity: result should have much fewer dimensions than vocab_size
assert len(result) < vocab_size
# All values should be positive
assert all(v > 0 for v in result.values())
# Verify keys are sorted in ascending order
keys = list(result.keys())
assert keys == sorted(keys), "Sparse vector keys must be sorted"
# Verify the specific non-zero indices are present and sorted
# Expected order: [50, 100, 200, 400, 500] (sorted)
expected_keys = [50, 100, 200, 400, 500]
assert keys == expected_keys, f"Expected {expected_keys}, got {keys}"
# First key should be smallest
if len(result) > 0:
first_key = next(iter(result.keys()))
assert first_key == min(result.keys()), "First key must be the smallest"
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_output_sorted_by_indices(self, mock_require_module):
"""Test that output dictionary is always sorted by indices (keys) in ascending order."""
import numpy as np
# Clear model cache
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
# Create sparse output with deliberately out-of-order indices
# Non-sequential indices: 9999, 5, 1234, 77, 500
mock_sparse_matrix = Mock()
# Create a dense array representation with vocab_size=30522
vocab_size = 30522
dense_array = np.zeros(vocab_size)
# Set specific non-zero values at out-of-order indices
dense_array[9999] = 1.5
dense_array[5] = 2.0
dense_array[1234] = 0.8
dense_array[77] = 3.2
dense_array[500] = 1.1
# Mock the method chain: sparse_matrix[0].toarray().flatten()
mock_row = Mock()
mock_dense = Mock()
mock_row.toarray.return_value = mock_dense
mock_dense.flatten.return_value = dense_array
mock_sparse_matrix.__getitem__ = Mock(return_value=mock_row)
# Also mock hasattr check for 'toarray'
mock_sparse_matrix.toarray = Mock()
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cpu"
# Configure mock methods
mock_model.encode_query = Mock(return_value=mock_sparse_matrix)
mock_model.encode_document = Mock(return_value=mock_sparse_matrix)
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding()
result = sparse_emb.embed("test sorting")
# Extract keys from result
result_keys = list(result.keys())
# Verify keys are sorted
assert result_keys == sorted(result_keys), (
f"Keys must be sorted in ascending order. "
f"Got: {result_keys}, Expected: {sorted(result_keys)}"
)
# Verify expected keys are present and in correct order
# Expected sorted order: [5, 77, 500, 1234, 9999]
expected_sorted_keys = [5, 77, 500, 1234, 9999]
assert result_keys == expected_sorted_keys, (
f"All expected keys should be present in sorted order. "
f"Expected: {expected_sorted_keys}, Got: {result_keys}"
)
# Verify first and last keys
assert result_keys[0] == 5, "First key must be minimum"
assert result_keys[-1] == 9999, "Last key must be maximum"
# Verify iteration order matches sorted order
for i, (key, value) in enumerate(result.items()):
if i > 0:
prev_key = list(result.keys())[i - 1]
assert key > prev_key, (
f"Key at position {i} must be greater than previous key"
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_device_property(self, mock_require_module):
"""Test device property returns correct device."""
mock_st = Mock()
mock_model = Mock()
mock_model.device = "cuda"
mock_st.SentenceTransformer.return_value = mock_model
mock_require_module.return_value = mock_st
sparse_emb = DefaultLocalSparseEmbedding(device="cuda")
assert sparse_emb.device == "cuda"
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test: requires ZVEC_RUN_INTEGRATION_TESTS=1 and model download",
)
@patch("zvec.extension.sentence_transformer_function.require_module")
def test_modelscope_source(self, mock_require_module):
"""Test initialization with ModelScope source."""
mock_st = Mock()
mock_ms = Mock()
mock_model = Mock()
mock_model.device = "cpu"
mock_st.SentenceTransformer.return_value = mock_model
# Mock ModelScope snapshot_download
with patch(
"modelscope.hub.snapshot_download.snapshot_download",
return_value="/cache/splade-cocondenser",
):
mock_require_module.side_effect = lambda m: (
mock_st if m == "sentence_transformers" else mock_ms
)
sparse_emb = DefaultLocalSparseEmbedding(model_source="modelscope")
assert sparse_emb.model_name == "naver/splade-cocondenser-ensembledistil"
assert sparse_emb.model_source == "modelscope"
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test: requires ZVEC_RUN_INTEGRATION_TESTS=1 and model download",
)
def test_integration_real_model(self):
"""Integration test with real SPLADE model (requires model download).
This test uses naver/splade-cocondenser-ensembledistil instead of
naver/splade-v3 because splade-v3 requires Hugging Face authentication.
The cocondenser-ensembledistil model is publicly accessible and provides
comparable performance.
To run this test:
export ZVEC_RUN_INTEGRATION_TESTS=1
pytest tests/test_embedding.py::TestDefaultSparseEmbedding::test_integration_real_model -v
Note: First run will download ~100MB model from Hugging Face.
Alternative models:
If you have access to splade-v3, you can create a custom embedding
class following the example in DefaultSparseEmbedding docstring.
"""
# Clear model cache to ensure fresh load
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
sparse_emb = DefaultLocalSparseEmbedding()
# Test with real input
text = "machine learning and artificial intelligence"
result = sparse_emb.embed(text)
# Verify result structure
assert isinstance(result, dict)
assert len(result) > 0
assert all(isinstance(k, int) and k >= 0 for k in result.keys())
assert all(isinstance(v, float) and v > 0 for v in result.values())
# SPLADE typically produces 100-300 non-zero dimensions
assert 50 < len(result) < 500
# Verify keys are sorted in ascending order
keys = list(result.keys())
assert keys == sorted(keys), "Real model output must be sorted by indices"
# Test callable interface
result2 = sparse_emb(text)
assert result == result2
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test: requires ZVEC_RUN_INTEGRATION_TESTS=1",
)
def test_integration_multiple_inputs(self):
"""Integration test with multiple different inputs."""
# Clear model cache
from zvec.extension.sentence_transformer_embedding_function import (
DefaultLocalSparseEmbedding,
)
DefaultLocalSparseEmbedding.clear_cache()
sparse_emb = DefaultLocalSparseEmbedding()
texts = [
"Hello, world!",
"Machine learning is fascinating",
"Python programming language",
]
results = [sparse_emb.embed(text) for text in texts]
# All results should be different
assert len(results) == 3
assert all(isinstance(r, dict) for r in results)
# Different inputs should produce different sparse vectors
assert results[0] != results[1]
assert results[1] != results[2]
# All results must be sorted by indices
for i, result in enumerate(results):
keys = list(result.keys())
assert keys == sorted(keys), f"Result {i} must have sorted keys"
# ----------------------------
# BM25EmbeddingFunction Test Case
# ----------------------------
class TestBM25EmbeddingFunction:
"""Test suite for BM25EmbeddingFunction (BM25-based sparse embedding using DashText SDK)."""
def test_init_with_built_in_encoder(self):
"""Test successful initialization with built-in encoder (no corpus)."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
# Test with default language (Chinese)
bm25 = BM25EmbeddingFunction()
assert bm25.corpus_size == 0
assert bm25.encoding_type == "query"
assert bm25.language == "zh"
mock_dashtext.SparseVectorEncoder.default.assert_called_once_with(name="zh")
def test_init_with_custom_encoder(self):
"""Test successful initialization with custom encoder (with corpus)."""
corpus = [
"a cat is a feline and likes to purr",
"a dog is the human's best friend",
"a bird is a beautiful animal that can fly",
]
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction(corpus=corpus, b=0.75, k1=1.2)
assert bm25.corpus_size == 3
assert bm25.encoding_type == "query"
mock_dashtext.SparseVectorEncoder.assert_called_once_with(b=0.75, k1=1.2)
mock_encoder.train.assert_called_once_with(corpus)
def test_init_with_empty_corpus(self):
"""Test initialization with empty corpus raises ValueError."""
with pytest.raises(ValueError, match="Corpus must be a non-empty list"):
BM25EmbeddingFunction(corpus=[])
def test_init_with_invalid_corpus(self):
"""Test initialization with invalid corpus elements."""
with pytest.raises(ValueError, match="All corpus documents must be strings"):
BM25EmbeddingFunction(corpus=["text", 123, "another"])
with pytest.raises(ValueError, match="All corpus documents must be strings"):
BM25EmbeddingFunction(corpus=[None, "text"])
def test_init_with_language_parameter(self):
"""Test initialization with different language settings."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
# Test English language
bm25_en = BM25EmbeddingFunction(language="en")
assert bm25_en.language == "en"
mock_dashtext.SparseVectorEncoder.default.assert_called_with(name="en")
def test_init_with_encoding_type(self):
"""Test initialization with different encoding types."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
# Test document encoding type
bm25_doc = BM25EmbeddingFunction(encoding_type="document")
assert bm25_doc.encoding_type == "document"
def test_init_with_missing_dashtext_library(self):
"""Test initialization fails when dashtext library is not installed."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_require.side_effect = ImportError("dashtext package is required")
with pytest.raises(ImportError, match="dashtext package is required"):
BM25EmbeddingFunction()
def test_embed_with_query_encoding(self):
"""Test successful sparse embedding generation with query encoding."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
# Mock encode_queries to return sparse vector
mock_encoder.encode_queries.return_value = {
5: 0.89,
12: 1.45,
23: 0.67,
45: 1.12,
}
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction(encoding_type="query")
# Clear LRU cache to ensure fresh call
bm25.embed.cache_clear()
result = bm25.embed("cat purr loud")
# Verify result structure
assert isinstance(result, dict)
assert all(isinstance(k, int) for k in result.keys())
assert all(isinstance(v, float) for v in result.values())
# Verify all values are positive
assert all(v > 0 for v in result.values())
# Verify output is sorted by indices
keys = list(result.keys())
assert keys == sorted(keys), "Output must be sorted by indices"
# Verify expected keys from mock response
assert result == {5: 0.89, 12: 1.45, 23: 0.67, 45: 1.12}
# Verify encode_queries was called
mock_encoder.encode_queries.assert_called_once_with("cat purr loud")
def test_embed_with_document_encoding(self):
"""Test successful sparse embedding generation with document encoding."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
# Mock encode_documents to return sparse vector
mock_encoder.encode_documents.return_value = {10: 1.5, 20: 2.3}
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction(encoding_type="document")
bm25.embed.cache_clear()
result = bm25.embed("document text")
assert result == {10: 1.5, 20: 2.3}
mock_encoder.encode_documents.assert_called_once_with("document text")
def test_embed_with_empty_input(self):
"""Test embedding with empty input raises ValueError."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction()
with pytest.raises(ValueError, match="Input text cannot be empty"):
bm25.embed("")
with pytest.raises(ValueError, match="Input text cannot be empty"):
bm25.embed(" ")
def test_embed_with_non_string_input(self):
"""Test embedding with non-string input raises TypeError."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction()
# Test with hashable non-string types - should get our custom error message
with pytest.raises(TypeError, match="Expected 'input' to be str"):
bm25.embed(123)
with pytest.raises(TypeError, match="Expected 'input' to be str"):
bm25.embed(None)
# Test with unhashable type (list)
# Note: lru_cache raises TypeError("unhashable type: 'list'") before our type check
# This is still a valid type error, just caught at a different layer
with pytest.raises(TypeError, match="unhashable type"):
bm25.embed(["text"])
def test_embed_callable_interface(self):
"""Test that BM25EmbeddingFunction is callable."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_encoder.encode_queries.return_value = {10: 1.5}
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction()
bm25.embed.cache_clear()
# Test callable interface
result = bm25("test query")
assert isinstance(result, dict)
assert 10 in result
def test_embed_output_sorted_by_indices(self):
"""Test that output is always sorted by indices in ascending order."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
# Mock encode_queries with unsorted indices
mock_encoder.encode_queries.return_value = {
9999: 1.5,
5: 2.0,
1234: 0.8,
77: 3.2,
500: 1.1,
}
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction()
bm25.embed.cache_clear()
result = bm25.embed("test query")
# Verify keys are sorted
result_keys = list(result.keys())
assert result_keys == sorted(result_keys), (
f"Keys must be sorted. Got: {result_keys}, Expected: {sorted(result_keys)}"
)
# Verify expected sorted order: [5, 77, 500, 1234, 9999]
expected_keys = [5, 77, 500, 1234, 9999]
assert result_keys == expected_keys
def test_embed_filters_zero_values(self):
"""Test that zero and negative values are filtered out."""
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
# Mock encode_queries with zero and negative values
mock_encoder.encode_queries.return_value = {
0: 1.5, # Positive - should be included
1: 0.0, # Zero - should be filtered
2: -0.5, # Negative - should be filtered
}
mock_dashtext.SparseVectorEncoder.default.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction()
bm25.embed.cache_clear()
result = bm25.embed("test")
# Only positive token should be in result
assert 0 in result
assert 1 not in result # Zero value filtered
assert 2 not in result # Negative value filtered
assert all(v > 0 for v in result.values())
def test_properties(self):
"""Test property accessors."""
corpus = ["doc1", "doc2", "doc3"]
with patch(
"zvec.extension.bm25_embedding_function.require_module"
) as mock_require:
mock_dashtext = Mock()
mock_encoder = Mock()
mock_dashtext.SparseVectorEncoder.return_value = mock_encoder
mock_require.return_value = mock_dashtext
bm25 = BM25EmbeddingFunction(
corpus=corpus,
encoding_type="document",
language="en",
b=0.8,
k1=1.5,
custom_param="test",
)
assert bm25.corpus_size == 3
assert bm25.encoding_type == "document"
assert bm25.language == "en"
assert bm25.extra_params == {"custom_param": "test"}
@pytest.mark.skipif(
not RUN_INTEGRATION_TESTS,
reason="Integration test skipped. Set ZVEC_RUN_INTEGRATION_TESTS=1 to run.",
)
def test_real_dashtext_bm25_embedding(self):
"""Integration test with real DashText library.
To run this test:
export ZVEC_RUN_INTEGRATION_TESTS=1
pip install dashtext
Note: This test requires the dashtext package to be installed.
"""
# Test built-in encoder (Chinese)
bm25_zh = BM25EmbeddingFunction(language="zh", encoding_type="query")
query_zh = "什么是向量检索服务"
result_zh = bm25_zh.embed(query_zh)
assert isinstance(result_zh, dict)
assert len(result_zh) > 0
assert all(isinstance(k, int) for k in result_zh.keys())
assert all(isinstance(v, float) and v > 0 for v in result_zh.values())
# Verify sorted output
keys = list(result_zh.keys())
assert keys == sorted(keys), "Real DashText BM25 output must be sorted"
# Test custom corpus
corpus = [
"The cat sits on the mat",
"The dog plays in the garden",
"Birds fly in the sky",
"Fish swim in the water",
]
bm25_custom = BM25EmbeddingFunction(corpus=corpus, encoding_type="query")
query_en = "cat on mat"
result_en = bm25_custom.embed(query_en)
assert isinstance(result_en, dict)
assert len(result_en) > 0
assert all(isinstance(k, int) for k in result_en.keys())
assert all(isinstance(v, float) and v > 0 for v in result_en.values())
# Test callable interface
result2 = bm25_custom(query_en)
assert result_en == result2
# Verify properties
assert bm25_custom.corpus_size == 4