853 lines
37 KiB
Python
853 lines
37 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import ast
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from collections.abc import Sequence
|
|
from typing import Any, ClassVar, Final, Generic, TypeVar
|
|
|
|
from azure.core.credentials import AzureKeyCredential
|
|
from azure.core.credentials_async import AsyncTokenCredential
|
|
from azure.search.documents.aio import SearchClient
|
|
from azure.search.documents.indexes.aio import SearchIndexClient
|
|
from azure.search.documents.indexes.models import (
|
|
ExhaustiveKnnAlgorithmConfiguration,
|
|
ExhaustiveKnnParameters,
|
|
HnswAlgorithmConfiguration,
|
|
HnswParameters,
|
|
SearchField,
|
|
SearchFieldDataType,
|
|
SearchIndex,
|
|
SearchResourceEncryptionKey,
|
|
SimpleField,
|
|
VectorSearchAlgorithmMetric,
|
|
VectorSearchProfile,
|
|
)
|
|
from azure.search.documents.indexes.models import VectorSearch as AZSVectorSearch
|
|
from azure.search.documents.models import VectorizableTextQuery, VectorizedQuery
|
|
from pydantic import SecretStr, ValidationError
|
|
|
|
from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase
|
|
from semantic_kernel.data.vector import (
|
|
DistanceFunction,
|
|
FieldTypes,
|
|
GetFilteredRecordOptions,
|
|
IndexKind,
|
|
KernelSearchResults,
|
|
SearchType,
|
|
TModel,
|
|
VectorSearch,
|
|
VectorSearchOptions,
|
|
VectorSearchResult,
|
|
VectorStore,
|
|
VectorStoreCollection,
|
|
VectorStoreCollectionDefinition,
|
|
_get_collection_name_from_model,
|
|
)
|
|
from semantic_kernel.exceptions import (
|
|
ServiceInitializationError,
|
|
VectorSearchExecutionException,
|
|
VectorStoreInitializationException,
|
|
VectorStoreOperationException,
|
|
)
|
|
from semantic_kernel.kernel_pydantic import HttpsUrl, KernelBaseSettings
|
|
from semantic_kernel.utils.feature_stage_decorator import release_candidate
|
|
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
|
|
|
|
if sys.version_info >= (3, 12):
|
|
from typing import override # pragma: no cover
|
|
else:
|
|
from typing_extensions import override # pragma: no cover
|
|
|
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
|
|
|
|
TKey = TypeVar("TKey", bound=str)
|
|
|
|
INDEX_ALGORITHM_MAP: Final[dict[IndexKind, tuple[type, type]]] = {
|
|
IndexKind.HNSW: (HnswAlgorithmConfiguration, HnswParameters),
|
|
IndexKind.FLAT: (ExhaustiveKnnAlgorithmConfiguration, ExhaustiveKnnParameters),
|
|
IndexKind.DEFAULT: (HnswAlgorithmConfiguration, HnswParameters),
|
|
}
|
|
DISTANCE_FUNCTION_MAP: Final[dict[DistanceFunction, VectorSearchAlgorithmMetric]] = {
|
|
DistanceFunction.COSINE_DISTANCE: VectorSearchAlgorithmMetric.COSINE,
|
|
DistanceFunction.DOT_PROD: VectorSearchAlgorithmMetric.DOT_PRODUCT,
|
|
DistanceFunction.EUCLIDEAN_DISTANCE: VectorSearchAlgorithmMetric.EUCLIDEAN,
|
|
DistanceFunction.HAMMING: VectorSearchAlgorithmMetric.HAMMING,
|
|
DistanceFunction.DEFAULT: VectorSearchAlgorithmMetric.COSINE,
|
|
}
|
|
TYPE_MAP_DATA: Final[dict[str, str]] = {
|
|
"default": SearchFieldDataType.String,
|
|
"str": SearchFieldDataType.String,
|
|
"int": SearchFieldDataType.Int64,
|
|
"float": SearchFieldDataType.Double,
|
|
"bool": SearchFieldDataType.Boolean,
|
|
"list[str]": SearchFieldDataType.Collection(SearchFieldDataType.String),
|
|
"list[int]": SearchFieldDataType.Collection(SearchFieldDataType.Int64),
|
|
"list[float]": SearchFieldDataType.Collection(SearchFieldDataType.Double),
|
|
"list[bool]": SearchFieldDataType.Collection(SearchFieldDataType.Boolean),
|
|
"list[dict]": SearchFieldDataType.Collection(SearchFieldDataType.ComplexType),
|
|
"dict": SearchFieldDataType.ComplexType,
|
|
SearchFieldDataType.ComplexType: SearchFieldDataType.ComplexType,
|
|
SearchFieldDataType.String: SearchFieldDataType.String,
|
|
SearchFieldDataType.Int64: SearchFieldDataType.Int64,
|
|
SearchFieldDataType.Double: SearchFieldDataType.Double,
|
|
SearchFieldDataType.Boolean: SearchFieldDataType.Boolean,
|
|
SearchFieldDataType.Collection(SearchFieldDataType.String): SearchFieldDataType.Collection(
|
|
SearchFieldDataType.String
|
|
),
|
|
SearchFieldDataType.Collection(SearchFieldDataType.Int64): SearchFieldDataType.Collection(
|
|
SearchFieldDataType.Int64
|
|
),
|
|
SearchFieldDataType.Collection(SearchFieldDataType.Double): SearchFieldDataType.Collection(
|
|
SearchFieldDataType.Double
|
|
),
|
|
SearchFieldDataType.Collection(SearchFieldDataType.Boolean): SearchFieldDataType.Collection(
|
|
SearchFieldDataType.Boolean
|
|
),
|
|
SearchFieldDataType.Collection(SearchFieldDataType.ComplexType): SearchFieldDataType.Collection(
|
|
SearchFieldDataType.ComplexType
|
|
),
|
|
SearchFieldDataType.Collection(SearchFieldDataType.Single): SearchFieldDataType.Collection(
|
|
SearchFieldDataType.Single
|
|
),
|
|
SearchFieldDataType.DateTimeOffset: SearchFieldDataType.DateTimeOffset,
|
|
SearchFieldDataType.GeographyPoint: SearchFieldDataType.GeographyPoint,
|
|
SearchFieldDataType.Single: SearchFieldDataType.Single,
|
|
}
|
|
|
|
TYPE_MAP_VECTOR: Final[dict[str, str]] = {
|
|
"float": SearchFieldDataType.Collection(SearchFieldDataType.Single),
|
|
"int": "Collection(Edm.Int16)",
|
|
"binary": "Collection(Edm.Byte)",
|
|
"default": SearchFieldDataType.Collection(SearchFieldDataType.Single),
|
|
}
|
|
|
|
__all__ = [
|
|
"AzureAISearchCollection",
|
|
"AzureAISearchSettings",
|
|
"AzureAISearchStore",
|
|
]
|
|
|
|
|
|
@release_candidate
|
|
class AzureAISearchSettings(KernelBaseSettings):
|
|
"""Azure AI Search model settings currently used by the AzureCognitiveSearchMemoryStore connector.
|
|
|
|
Args:
|
|
- api_key: SecretStr - Azure AI Search API key (Env var AZURE_AI_SEARCH_API_KEY)
|
|
- endpoint: HttpsUrl - Azure AI Search endpoint (Env var AZURE_AI_SEARCH_ENDPOINT)
|
|
- index_name: str - Azure AI Search index name (Env var AZURE_AI_SEARCH_INDEX_NAME)
|
|
"""
|
|
|
|
env_prefix: ClassVar[str] = "AZURE_AI_SEARCH_"
|
|
|
|
api_key: SecretStr | None = None
|
|
endpoint: HttpsUrl
|
|
index_name: str | None = None
|
|
|
|
|
|
def _get_search_client(
|
|
endpoint: str,
|
|
collection_name: str | None,
|
|
credential: "AzureKeyCredential | AsyncTokenCredential",
|
|
**kwargs: Any,
|
|
) -> SearchClient:
|
|
"""Create a search client for a collection."""
|
|
if not collection_name:
|
|
raise VectorStoreInitializationException("Collection name is required to create a search client.")
|
|
try:
|
|
return SearchClient(endpoint, collection_name, credential, **kwargs)
|
|
except ValueError as exc:
|
|
raise VectorStoreInitializationException(
|
|
f"Failed to create Azure Cognitive Search client for collection {collection_name}."
|
|
) from exc
|
|
|
|
|
|
def _resolve_credential(
|
|
azure_ai_search_settings: AzureAISearchSettings,
|
|
azure_credential: AzureKeyCredential | None = None,
|
|
token_credential: "AsyncTokenCredential | None" = None,
|
|
) -> "AzureKeyCredential | AsyncTokenCredential":
|
|
"""Resolve the credential to use for Azure AI Search.
|
|
|
|
Args:
|
|
azure_ai_search_settings: Azure AI Search settings.
|
|
azure_credential: Optional Azure credentials (default: {None}).
|
|
token_credential: Optional Token credential (default: {None}).
|
|
"""
|
|
if azure_credential:
|
|
return azure_credential
|
|
if token_credential:
|
|
return token_credential
|
|
if azure_ai_search_settings.api_key:
|
|
return AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value())
|
|
raise ServiceInitializationError("Error: missing Azure AI Search client credentials.")
|
|
|
|
|
|
def _get_search_index_client(
|
|
azure_ai_search_settings: AzureAISearchSettings,
|
|
azure_credential: AzureKeyCredential | None = None,
|
|
token_credential: "AsyncTokenCredential | None" = None,
|
|
) -> SearchIndexClient:
|
|
"""Return a client for Azure AI Search.
|
|
|
|
Args:
|
|
azure_ai_search_settings: Azure AI Search settings.
|
|
azure_credential: Optional Azure credentials (default: {None}).
|
|
token_credential: Optional Token credential (default: {None}).
|
|
"""
|
|
credential = _resolve_credential(azure_ai_search_settings, azure_credential, token_credential)
|
|
|
|
return SearchIndexClient(
|
|
endpoint=str(azure_ai_search_settings.endpoint),
|
|
credential=credential,
|
|
headers=prepend_semantic_kernel_to_user_agent({}) if APP_INFO else None,
|
|
)
|
|
|
|
|
|
def _definition_to_azure_ai_search_index(
|
|
collection_name: str,
|
|
definition: VectorStoreCollectionDefinition,
|
|
encryption_key: SearchResourceEncryptionKey | None = None,
|
|
) -> SearchIndex:
|
|
"""Convert a VectorStoreRecordDefinition to an Azure AI Search index."""
|
|
fields = []
|
|
search_profiles = []
|
|
search_algos = []
|
|
|
|
for field in definition.fields:
|
|
if field.field_type == FieldTypes.DATA:
|
|
if not field.type_:
|
|
logger.debug(f"Field {field.name} has not specified type, defaulting to Edm.String.")
|
|
if field.type_ and field.type_ not in TYPE_MAP_DATA:
|
|
if field.type_.startswith("dict"):
|
|
type_ = TYPE_MAP_DATA["dict"]
|
|
elif field.type_.startswith("list") and "dict" in field.type_:
|
|
type_ = TYPE_MAP_DATA["list[dict]"]
|
|
else:
|
|
raise VectorStoreOperationException(f"{field.type_} not supported in Azure AI Search.")
|
|
else:
|
|
type_ = TYPE_MAP_DATA[field.type_ or "default"]
|
|
fields.append(
|
|
SearchField(
|
|
name=field.storage_name or field.name,
|
|
type=type_,
|
|
filterable=field.is_indexed or field.is_full_text_indexed,
|
|
# searchable is set first on the value of is_full_text_searchable,
|
|
# if it is None it checks the field type, if text then it is searchable
|
|
searchable=type_ in ("Edm.String", "Collection(Edm.String)")
|
|
if field.is_full_text_indexed is None
|
|
else field.is_full_text_indexed,
|
|
sortable=not type_.startswith("Collection") or type_ == "Edm.ComplexType",
|
|
hidden=False,
|
|
)
|
|
)
|
|
elif field.field_type == FieldTypes.KEY:
|
|
fields.append(
|
|
SimpleField(
|
|
name=field.storage_name or field.name,
|
|
type="Edm.String", # hardcoded, only allowed type for key
|
|
key=True,
|
|
filterable=True,
|
|
searchable=True,
|
|
)
|
|
)
|
|
elif field.field_type == FieldTypes.VECTOR:
|
|
if not field.type_:
|
|
logger.debug(f"Field {field.name} has not specified type, defaulting to Collection(Edm.Single).")
|
|
if field.index_kind not in INDEX_ALGORITHM_MAP:
|
|
raise VectorStoreOperationException(f"{field.index_kind} not supported in Azure AI Search.")
|
|
if field.distance_function not in DISTANCE_FUNCTION_MAP:
|
|
raise VectorStoreOperationException(f"{field.distance_function} not supported in Azure AI Search.")
|
|
|
|
profile_name = f"{field.storage_name or field.name}_profile"
|
|
algo_name = f"{field.storage_name or field.name}_algorithm"
|
|
fields.append(
|
|
SearchField(
|
|
name=field.storage_name or field.name,
|
|
type=TYPE_MAP_VECTOR[field.type_ or "default"],
|
|
searchable=True,
|
|
vector_search_dimensions=field.dimensions,
|
|
vector_search_profile_name=profile_name,
|
|
hidden=False,
|
|
)
|
|
)
|
|
search_profiles.append(
|
|
VectorSearchProfile(
|
|
name=profile_name,
|
|
algorithm_configuration_name=algo_name,
|
|
)
|
|
)
|
|
algo_class, algo_params = INDEX_ALGORITHM_MAP[field.index_kind]
|
|
distance_metric = DISTANCE_FUNCTION_MAP[field.distance_function]
|
|
search_algos.append(algo_class(name=algo_name, parameters=algo_params(metric=distance_metric)))
|
|
return SearchIndex(
|
|
name=collection_name,
|
|
fields=fields,
|
|
vector_search=AZSVectorSearch(profiles=search_profiles, algorithms=search_algos),
|
|
encryption_key=encryption_key,
|
|
)
|
|
|
|
|
|
@release_candidate
|
|
class AzureAISearchCollection(
|
|
VectorStoreCollection[TKey, TModel],
|
|
VectorSearch[TKey, TModel],
|
|
Generic[TKey, TModel],
|
|
):
|
|
"""Azure AI Search collection implementation."""
|
|
|
|
search_client: SearchClient
|
|
search_index_client: SearchIndexClient
|
|
search_endpoint: str | None = None
|
|
search_credential: Any = None
|
|
supported_key_types: ClassVar[set[str] | None] = {"str"}
|
|
supported_vector_types: ClassVar[set[str] | None] = {"float", "int"}
|
|
supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR, SearchType.KEYWORD_HYBRID}
|
|
managed_search_index_client: bool = True
|
|
|
|
def __init__(
|
|
self,
|
|
record_type: type[TModel],
|
|
definition: VectorStoreCollectionDefinition | None = None,
|
|
collection_name: str | None = None,
|
|
search_index_client: SearchIndexClient | None = None,
|
|
search_client: SearchClient | None = None,
|
|
embedding_generator: "EmbeddingGeneratorBase | None" = None,
|
|
search_credential: "AzureKeyCredential | AsyncTokenCredential | None" = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initializes a new instance of the AzureAISearchCollection class.
|
|
|
|
The collection name can be set in four ways:
|
|
1. By passing it in the constructor.
|
|
2. By passing it in the data model definition or record_type.
|
|
3. By passing it in the search client.
|
|
4. By setting the AZURE_AI_SEARCH_INDEX_NAME environment variable.
|
|
|
|
They are checked in that order, so if the collection name is passed in the constructor it is used.
|
|
|
|
Args:
|
|
record_type: The type of the data model.
|
|
definition: The model definition, optional.
|
|
collection_name: The name of the collection, optional.
|
|
search_index_client: The search index client for interacting with Azure AI Search,
|
|
used for creating and deleting indexes.
|
|
search_client: The search client for interacting with Azure AI Search,
|
|
used for record operations.
|
|
search_credential: The credential used to authenticate with Azure AI Search.
|
|
If not provided, it will be resolved from azure_credentials, token_credentials,
|
|
or api_key in kwargs/environment.
|
|
embedding_generator: The embedding generator, optional.
|
|
**kwargs: Additional keyword arguments, including:
|
|
The same keyword arguments used for AzureAISearchVectorStore:
|
|
search_endpoint: The endpoint of the Azure AI Search service, optional.
|
|
api_key: str | None = None,
|
|
azure_credentials: AzureKeyCredential | None = None,
|
|
token_credentials: AsyncTokenCredential | None = None,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None
|
|
|
|
"""
|
|
if not collection_name:
|
|
collection_name = _get_collection_name_from_model(record_type, definition)
|
|
if not collection_name and search_client:
|
|
collection_name = search_client._index_name
|
|
if search_client and search_index_client:
|
|
if collection_name and search_client._index_name != collection_name:
|
|
search_client._index_name = collection_name
|
|
super().__init__(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=collection_name,
|
|
search_client=search_client,
|
|
search_index_client=search_index_client,
|
|
search_endpoint=kwargs.get("search_endpoint"),
|
|
search_credential=search_credential,
|
|
managed_search_index_client=False,
|
|
managed_client=False,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
return
|
|
|
|
if search_index_client:
|
|
try:
|
|
azure_ai_search_settings = AzureAISearchSettings(
|
|
env_file_path=kwargs.get("env_file_path"),
|
|
endpoint=kwargs.get("search_endpoint"),
|
|
api_key=kwargs.get("api_key"),
|
|
env_file_encoding=kwargs.get("env_file_encoding"),
|
|
index_name=collection_name,
|
|
)
|
|
except ValidationError as exc:
|
|
raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc
|
|
endpoint = str(azure_ai_search_settings.endpoint)
|
|
credential = search_credential or _resolve_credential(
|
|
azure_ai_search_settings,
|
|
azure_credential=kwargs.get("azure_credentials"),
|
|
token_credential=kwargs.get("token_credentials"),
|
|
)
|
|
super().__init__(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=azure_ai_search_settings.index_name,
|
|
search_client=_get_search_client(
|
|
endpoint=endpoint,
|
|
collection_name=azure_ai_search_settings.index_name,
|
|
credential=credential,
|
|
),
|
|
search_index_client=search_index_client,
|
|
search_endpoint=endpoint,
|
|
search_credential=credential,
|
|
managed_search_index_client=False,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
return
|
|
|
|
try:
|
|
azure_ai_search_settings = AzureAISearchSettings(
|
|
env_file_path=kwargs.get("env_file_path"),
|
|
endpoint=kwargs.get("search_endpoint"),
|
|
api_key=kwargs.get("api_key"),
|
|
env_file_encoding=kwargs.get("env_file_encoding"),
|
|
index_name=collection_name,
|
|
)
|
|
except ValidationError as exc:
|
|
raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc
|
|
endpoint = str(azure_ai_search_settings.endpoint)
|
|
credential = search_credential or _resolve_credential(
|
|
azure_ai_search_settings,
|
|
azure_credential=kwargs.get("azure_credentials"),
|
|
token_credential=kwargs.get("token_credentials"),
|
|
)
|
|
search_index_client = _get_search_index_client(
|
|
azure_ai_search_settings=azure_ai_search_settings,
|
|
azure_credential=kwargs.get("azure_credentials"),
|
|
token_credential=kwargs.get("token_credentials"),
|
|
)
|
|
super().__init__(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=azure_ai_search_settings.index_name,
|
|
search_client=_get_search_client(
|
|
endpoint=endpoint,
|
|
collection_name=azure_ai_search_settings.index_name,
|
|
credential=credential,
|
|
),
|
|
search_index_client=search_index_client,
|
|
search_endpoint=endpoint,
|
|
search_credential=credential,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
|
|
@override
|
|
async def _inner_upsert(
|
|
self,
|
|
records: Sequence[Any],
|
|
**kwargs: Any,
|
|
) -> Sequence[TKey]:
|
|
if not isinstance(records, list):
|
|
records = list(records)
|
|
results = await self.search_client.merge_or_upload_documents(documents=records, **kwargs)
|
|
return [result.key for result in results] # type: ignore
|
|
|
|
@override
|
|
async def _inner_get(
|
|
self,
|
|
keys: Sequence[TKey] | None = None,
|
|
options: GetFilteredRecordOptions | None = None,
|
|
**kwargs: Any,
|
|
) -> Sequence[dict[str, Any]]:
|
|
client = self.search_client
|
|
if "selected_fields" in kwargs:
|
|
selected_fields = kwargs["selected_fields"]
|
|
elif kwargs.get("include_vectors"):
|
|
selected_fields = ["*"]
|
|
else:
|
|
selected_fields = self.definition.get_storage_names(include_vector_fields=False)
|
|
if keys is not None:
|
|
gather_result = await asyncio.gather(
|
|
*[client.get_document(key=key, selected_fields=selected_fields) for key in keys], # type: ignore
|
|
return_exceptions=True,
|
|
)
|
|
return [res for res in gather_result if not isinstance(res, BaseException)]
|
|
if options is not None:
|
|
ordering = []
|
|
if options.order_by:
|
|
for field, asc_flag in options.order_by.items():
|
|
if field not in self.definition.storage_names:
|
|
logger.warning(f"Field {field} not in data model, skipping.")
|
|
continue
|
|
ordering.append(field if asc_flag else f"{field} desc")
|
|
|
|
result = await client.search(
|
|
search_text="*",
|
|
top=options.top,
|
|
skip=options.skip,
|
|
select=selected_fields,
|
|
order_by=ordering,
|
|
)
|
|
return [res async for res in result]
|
|
raise VectorStoreOperationException("No keys or options provided for get operation.")
|
|
|
|
@override
|
|
async def _inner_delete(self, keys: Sequence[TKey], **kwargs: Any) -> None:
|
|
await self.search_client.delete_documents(documents=[{self._key_field_name: key} for key in keys])
|
|
|
|
@override
|
|
def _serialize_dicts_to_store_models(self, records: Sequence[dict[str, Any]], **kwargs: Any) -> Sequence[Any]:
|
|
return records
|
|
|
|
@override
|
|
def _deserialize_store_models_to_dicts(self, records: Sequence[Any], **kwargs: Any) -> Sequence[dict[str, Any]]:
|
|
return records
|
|
|
|
@override
|
|
async def ensure_collection_exists(self, **kwargs) -> None:
|
|
"""Create a new collection in Azure AI Search.
|
|
|
|
Args:
|
|
**kwargs: Additional keyword arguments.
|
|
index (SearchIndex): The search index to create, if this is supplied
|
|
this is used instead of a index created based on the definition.
|
|
encryption_key (SearchResourceEncryptionKey): The encryption key to use,
|
|
not used when index is supplied.
|
|
other kwargs are passed to the create_index method.
|
|
"""
|
|
if index := kwargs.pop("index", None):
|
|
if isinstance(index, SearchIndex):
|
|
await self.search_index_client.create_index(index=index, **kwargs)
|
|
return
|
|
raise VectorStoreOperationException("Invalid index type supplied, should be a SearchIndex object.")
|
|
await self.search_index_client.create_index(
|
|
index=_definition_to_azure_ai_search_index(
|
|
collection_name=self.collection_name,
|
|
definition=self.definition,
|
|
encryption_key=kwargs.pop("encryption_key", None),
|
|
),
|
|
**kwargs,
|
|
)
|
|
|
|
@override
|
|
async def collection_exists(self, **kwargs) -> bool:
|
|
if "params" not in kwargs:
|
|
kwargs["params"] = {"select": ["name"]}
|
|
return self.collection_name in [
|
|
index_name async for index_name in self.search_index_client.list_index_names(**kwargs)
|
|
]
|
|
|
|
@override
|
|
async def ensure_collection_deleted(self, **kwargs) -> None:
|
|
await self.search_index_client.delete_index(self.collection_name, **kwargs)
|
|
|
|
@override
|
|
async def _inner_search(
|
|
self,
|
|
search_type: SearchType,
|
|
options: VectorSearchOptions,
|
|
values: Any | None = None,
|
|
vector: Sequence[float | int] | None = None,
|
|
**kwargs: Any,
|
|
) -> KernelSearchResults[VectorSearchResult[TModel]]:
|
|
search_args: dict[str, Any] = {
|
|
"top": options.top,
|
|
"skip": options.skip,
|
|
"include_total_count": options.include_total_count,
|
|
}
|
|
if options.include_vectors:
|
|
search_args["select"] = ["*"]
|
|
else:
|
|
search_args["select"] = self.definition.get_storage_names(include_vector_fields=False)
|
|
if filter := self._build_filter(options.filter):
|
|
search_args["filter"] = filter if isinstance(filter, str) else " and ".join(filter)
|
|
match search_type:
|
|
case SearchType.VECTOR:
|
|
if vector is not None:
|
|
vector_field = self.definition.try_get_vector_field(options.vector_property_name)
|
|
search_args["vector_queries"] = [
|
|
VectorizedQuery(
|
|
vector=vector, # type: ignore
|
|
fields=vector_field.storage_name or vector_field.name if vector_field else None,
|
|
)
|
|
]
|
|
elif values is not None:
|
|
generated_vector = await self._generate_vector_from_values(values or "*", options)
|
|
vector_field = self.definition.try_get_vector_field(options.vector_property_name)
|
|
if generated_vector is not None:
|
|
search_args["vector_queries"] = [
|
|
VectorizedQuery(
|
|
vector=generated_vector, # type: ignore
|
|
fields=vector_field.storage_name or vector_field.name if vector_field else None,
|
|
)
|
|
]
|
|
else:
|
|
search_args["vector_queries"] = [
|
|
VectorizableTextQuery(
|
|
text=values,
|
|
fields=vector_field.storage_name or vector_field.name if vector_field else None,
|
|
)
|
|
]
|
|
else:
|
|
raise VectorStoreOperationException("No vector or keywords provided for vector search.")
|
|
case SearchType.KEYWORD_HYBRID:
|
|
if values is None:
|
|
raise VectorStoreOperationException("No vector and/or keywords provided for search.")
|
|
vector_field = self.definition.try_get_vector_field(options.vector_property_name)
|
|
search_args["search_fields"] = (
|
|
[options.additional_property_name]
|
|
if options.additional_property_name is not None
|
|
else [
|
|
field.name
|
|
for field in self.definition.fields
|
|
if field.field_type == FieldTypes.DATA and field.is_full_text_indexed
|
|
]
|
|
)
|
|
if not search_args["search_fields"]:
|
|
raise VectorStoreOperationException("No searchable fields found for hybrid search.")
|
|
search_args["search_text"] = values
|
|
|
|
vector = await self._generate_vector_from_values(values, options) if vector is None else vector
|
|
if vector is not None:
|
|
search_args["vector_queries"] = [
|
|
VectorizedQuery(
|
|
vector=vector, # type: ignore
|
|
fields=vector_field.name if vector_field else None,
|
|
)
|
|
]
|
|
else:
|
|
search_args["vector_queries"] = [
|
|
VectorizableTextQuery(
|
|
text=values,
|
|
fields=vector_field.name if vector_field else None,
|
|
)
|
|
]
|
|
try:
|
|
raw_results = await self.search_client.search(**search_args, **kwargs)
|
|
except Exception as exc:
|
|
raise VectorSearchExecutionException("Failed to search the collection.") from exc
|
|
return KernelSearchResults(
|
|
results=self._get_vector_search_results_from_results(raw_results, options),
|
|
total_count=await raw_results.get_count() if options.include_total_count else None,
|
|
)
|
|
|
|
@override
|
|
def _lambda_parser(self, node: ast.AST) -> Any:
|
|
def _parse_attribute_chain(attr_node: ast.Attribute) -> str:
|
|
parts = []
|
|
current = attr_node
|
|
while isinstance(current, ast.Attribute):
|
|
parts.append(current.attr)
|
|
current = current.value # type: ignore
|
|
if isinstance(current, ast.Name):
|
|
# skip the root variable name (e.g., 'x')
|
|
pass
|
|
else:
|
|
raise NotImplementedError(f"Unsupported attribute chain root: {type(current)}")
|
|
# reverse to get the correct order
|
|
prop_path = "/".join(reversed(parts))
|
|
# Check if the top-level property is in the data model
|
|
top_level = parts[-1] if parts else None
|
|
if top_level and top_level not in self.definition.storage_names:
|
|
raise VectorStoreOperationException(
|
|
f"Field '{top_level}' not in data model (storage property names are used)."
|
|
)
|
|
return prop_path
|
|
|
|
match node:
|
|
case ast.Compare():
|
|
if len(node.ops) > 1:
|
|
values: list[ast.expr] = []
|
|
for idx in range(len(node.ops)):
|
|
if idx == 0:
|
|
values.append(
|
|
ast.Compare(
|
|
left=node.left,
|
|
ops=[node.ops[idx]],
|
|
comparators=[node.comparators[idx]],
|
|
)
|
|
)
|
|
else:
|
|
values.append(
|
|
ast.Compare(
|
|
left=node.comparators[idx - 1],
|
|
ops=[node.ops[idx]],
|
|
comparators=[node.comparators[idx]],
|
|
)
|
|
)
|
|
return self._lambda_parser(ast.BoolOp(op=ast.And(), values=values))
|
|
left = self._lambda_parser(node.left)
|
|
right = self._lambda_parser(node.comparators[0])
|
|
op = node.ops[0]
|
|
match op:
|
|
case ast.In():
|
|
return f"search.ismatch({left}, '{right}')"
|
|
case ast.NotIn():
|
|
return f"not search.ismatch({left}, '{right}')"
|
|
case ast.Eq():
|
|
return f"{left} eq {right}"
|
|
case ast.NotEq():
|
|
return f"{left} ne {right}"
|
|
case ast.Gt():
|
|
return f"{left} gt {right}"
|
|
case ast.GtE():
|
|
return f"{left} ge {right}"
|
|
case ast.Lt():
|
|
return f"{left} lt {right}"
|
|
case ast.LtE():
|
|
return f"{left} le {right}"
|
|
raise NotImplementedError(f"Unsupported operator: {type(op)}")
|
|
case ast.BoolOp():
|
|
op_str = "and" if isinstance(node.op, ast.And) else "or"
|
|
return "(" + f" {op_str} ".join([self._lambda_parser(v) for v in node.values]) + ")"
|
|
case ast.UnaryOp():
|
|
match node.op:
|
|
case ast.UAdd():
|
|
return f"+{self._lambda_parser(node.operand)}"
|
|
case ast.USub():
|
|
return f"-{self._lambda_parser(node.operand)}"
|
|
case ast.Invert():
|
|
raise NotImplementedError("Invert operation is not supported.")
|
|
case ast.Not():
|
|
return f"not {self._lambda_parser(node.operand)}"
|
|
case ast.Attribute():
|
|
# Support nested property chains
|
|
return _parse_attribute_chain(node)
|
|
case ast.Name():
|
|
raise NotImplementedError("Constants are not supported, make sure to use a value or a attribute.")
|
|
case ast.Constant():
|
|
value = node.value
|
|
if isinstance(value, str):
|
|
return "'" + value.replace("'", "''") + "'"
|
|
if isinstance(value, bytes):
|
|
return "'" + value.decode("utf-8").replace("'", "''") + "'"
|
|
if isinstance(value, bool):
|
|
return str(value).lower()
|
|
if value is None:
|
|
return "null"
|
|
if isinstance(value, (int, float)):
|
|
return str(value)
|
|
raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}")
|
|
raise NotImplementedError(f"Unsupported AST node: {type(node)}")
|
|
|
|
@override
|
|
def _get_record_from_result(self, result: dict[str, Any]) -> dict[str, Any]:
|
|
return result
|
|
|
|
@override
|
|
def _get_score_from_result(self, result: dict[str, Any]) -> float | None:
|
|
return result.get("@search.score")
|
|
|
|
@override
|
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
"""Exit the context manager."""
|
|
if self.managed_client:
|
|
await self.search_client.close()
|
|
if self.managed_search_index_client:
|
|
await self.search_index_client.close()
|
|
|
|
|
|
@release_candidate
|
|
class AzureAISearchStore(VectorStore):
|
|
"""Azure AI Search store implementation."""
|
|
|
|
search_index_client: SearchIndexClient
|
|
search_endpoint: str | None = None
|
|
search_credential: Any = None
|
|
|
|
def __init__(
|
|
self,
|
|
search_endpoint: str | None = None,
|
|
api_key: str | None = None,
|
|
azure_credentials: "AzureKeyCredential | None" = None,
|
|
token_credentials: "AsyncTokenCredential | None" = None,
|
|
search_index_client: SearchIndexClient | None = None,
|
|
embedding_generator: "EmbeddingGeneratorBase | None" = None,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None,
|
|
) -> None:
|
|
"""Initializes a new instance of the AzureAISearchStore class."""
|
|
managed_client: bool = False
|
|
endpoint: str | None = None
|
|
credential: AzureKeyCredential | AsyncTokenCredential | None = None
|
|
if not search_index_client:
|
|
try:
|
|
azure_ai_search_settings = AzureAISearchSettings(
|
|
env_file_path=env_file_path,
|
|
endpoint=search_endpoint,
|
|
api_key=api_key,
|
|
env_file_encoding=env_file_encoding,
|
|
)
|
|
except ValidationError as exc:
|
|
raise VectorStoreInitializationException("Failed to create Azure AI Search settings.") from exc
|
|
endpoint = str(azure_ai_search_settings.endpoint)
|
|
credential = _resolve_credential(
|
|
azure_ai_search_settings,
|
|
azure_credential=azure_credentials,
|
|
token_credential=token_credentials,
|
|
)
|
|
search_index_client = _get_search_index_client(
|
|
azure_ai_search_settings=azure_ai_search_settings,
|
|
azure_credential=azure_credentials,
|
|
token_credential=token_credentials,
|
|
)
|
|
managed_client = True
|
|
else:
|
|
endpoint = search_endpoint
|
|
credential = azure_credentials or token_credentials or (AzureKeyCredential(api_key) if api_key else None)
|
|
|
|
super().__init__(
|
|
search_index_client=search_index_client,
|
|
search_endpoint=endpoint,
|
|
search_credential=credential,
|
|
managed_client=managed_client,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
|
|
@override
|
|
def get_collection(
|
|
self,
|
|
record_type: type[TModel],
|
|
*,
|
|
definition: VectorStoreCollectionDefinition | None = None,
|
|
collection_name: str | None = None,
|
|
embedding_generator: EmbeddingGeneratorBase | None = None,
|
|
search_client: SearchClient | None = None,
|
|
**kwargs: Any,
|
|
) -> AzureAISearchCollection:
|
|
"""Get a AzureAISearchCollection tied to a collection.
|
|
|
|
Args:
|
|
collection_name: The name of the collection.
|
|
record_type: The type of the data model.
|
|
definition: The model fields, optional.
|
|
search_client: The search client for interacting with Azure AI Search,
|
|
will be created if not supplied.
|
|
embedding_generator: The embedding generator, optional.
|
|
**kwargs: Additional keyword arguments, passed to the collection constructor.
|
|
"""
|
|
return AzureAISearchCollection(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=collection_name,
|
|
search_index_client=self.search_index_client,
|
|
search_client=search_client,
|
|
embedding_generator=embedding_generator or self.embedding_generator,
|
|
search_credential=self.search_credential,
|
|
search_endpoint=self.search_endpoint,
|
|
**kwargs,
|
|
)
|
|
|
|
@override
|
|
async def list_collection_names(self, **kwargs: Any) -> list[str]:
|
|
if "params" not in kwargs:
|
|
kwargs["params"] = {"select": ["name"]}
|
|
return [index async for index in self.search_index_client.list_index_names(**kwargs)]
|
|
|
|
@override
|
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
"""Exit the context manager."""
|
|
if self.managed_client:
|
|
await self.search_index_client.close()
|