1105 lines
46 KiB
Python
1105 lines
46 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import ast
|
|
import asyncio
|
|
import sys
|
|
from collections.abc import Sequence
|
|
from importlib import metadata
|
|
from typing import Any, ClassVar, Final, Generic, TypeVar
|
|
|
|
from azure.core.credentials_async import AsyncTokenCredential
|
|
from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy
|
|
from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError
|
|
from azure.cosmos.partition_key import PartitionKey
|
|
from pydantic import HttpUrl, SecretStr, ValidationError
|
|
from pymongo import AsyncMongoClient
|
|
from pymongo.driver_info import DriverInfo
|
|
from typing_extensions import override
|
|
|
|
from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase
|
|
from semantic_kernel.connectors.mongodb import (
|
|
DEFAULT_DB_NAME,
|
|
MONGODB_SCORE_FIELD,
|
|
MongoDBAtlasCollection,
|
|
MongoDBAtlasStore,
|
|
)
|
|
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 (
|
|
VectorSearchExecutionException,
|
|
VectorStoreInitializationException,
|
|
VectorStoreModelDeserializationException,
|
|
VectorStoreModelException,
|
|
VectorStoreOperationException,
|
|
)
|
|
from semantic_kernel.kernel_pydantic import KernelBaseModel, KernelBaseSettings
|
|
from semantic_kernel.utils.feature_stage_decorator import release_candidate
|
|
from semantic_kernel.utils.telemetry.user_agent import SEMANTIC_KERNEL_USER_AGENT
|
|
|
|
if sys.version_info >= (3, 12):
|
|
from typing import override # pragma: no cover
|
|
else:
|
|
from typing_extensions import override # pragma: no cover
|
|
|
|
|
|
@release_candidate
|
|
class CosmosNoSqlCompositeKey(KernelBaseModel):
|
|
"""CosmosDB NoSQL composite key."""
|
|
|
|
partition_key: str
|
|
key: str
|
|
|
|
|
|
TKey = TypeVar("TKey", bound=str)
|
|
TNoSQLKey = TypeVar("TNoSQLKey", str, CosmosNoSqlCompositeKey)
|
|
|
|
# region: Constants
|
|
|
|
COSMOS_ITEM_ID_PROPERTY_NAME: Final[str] = "id"
|
|
NOSQL_SCORE_PROPERTY_NAME: Final[str] = "distance"
|
|
INDEX_KIND_MAP_NOSQL: Final[dict[IndexKind, str]] = {
|
|
IndexKind.FLAT: "flat",
|
|
IndexKind.QUANTIZED_FLAT: "quantizedFlat",
|
|
IndexKind.DISK_ANN: "diskANN",
|
|
IndexKind.DEFAULT: "flat",
|
|
}
|
|
INDEX_KIND_MAP_MONGODB: Final[dict[IndexKind, str]] = {
|
|
IndexKind.IVF_FLAT: "vector-ivf",
|
|
IndexKind.HNSW: "vector-hnsw",
|
|
IndexKind.DISK_ANN: "vector-diskann",
|
|
IndexKind.DEFAULT: "vector-ivf",
|
|
}
|
|
DISTANCE_FUNCTION_MAP_NOSQL: Final[dict[DistanceFunction, str]] = {
|
|
DistanceFunction.COSINE_SIMILARITY: "cosine",
|
|
DistanceFunction.DOT_PROD: "dotproduct",
|
|
DistanceFunction.EUCLIDEAN_DISTANCE: "euclidean",
|
|
DistanceFunction.DEFAULT: "cosine",
|
|
}
|
|
DISTANCE_FUNCTION_MAP_MONGODB: Final[dict[DistanceFunction, str]] = {
|
|
DistanceFunction.COSINE_SIMILARITY: "COS",
|
|
DistanceFunction.DOT_PROD: "IP",
|
|
DistanceFunction.EUCLIDEAN_DISTANCE: "L2",
|
|
DistanceFunction.DEFAULT: "COS",
|
|
}
|
|
VECTOR_DATATYPES_MAP: Final[dict[str, str]] = {
|
|
"default": "float32",
|
|
"float": "float32",
|
|
"list[float]": "float32",
|
|
"int": "int32",
|
|
"list[int]": "int32",
|
|
}
|
|
|
|
# region: Helpers
|
|
|
|
|
|
def _create_default_indexing_policy_nosql(definition: VectorStoreCollectionDefinition) -> dict[str, Any]:
|
|
"""Creates a default indexing policy for the Azure Cosmos DB NoSQL container.
|
|
|
|
A default indexing policy is created based on the data model definition and has an automatic indexing policy.
|
|
|
|
Args:
|
|
definition (VectorStoreRecordDefinition): The definition of the data model.
|
|
|
|
Returns:
|
|
dict[str, Any]: The indexing policy.
|
|
|
|
Raises:
|
|
VectorStoreModelException: If the field is not full text searchable and not filterable.
|
|
"""
|
|
indexing_policy: dict[str, Any] = {
|
|
"automatic": True,
|
|
"includedPaths": [
|
|
{
|
|
"path": "/*",
|
|
}
|
|
],
|
|
"excludedPaths": [
|
|
{
|
|
"path": '/"_etag"/?',
|
|
}
|
|
],
|
|
"vectorIndexes": [],
|
|
}
|
|
|
|
for field in definition.fields:
|
|
if field.field_type == FieldTypes.DATA and (not field.is_full_text_indexed and not field.is_indexed):
|
|
indexing_policy["excludedPaths"].append({"path": f'/"{field.storage_name or field.name}"/*'})
|
|
|
|
if field.field_type == FieldTypes.VECTOR:
|
|
if field.index_kind not in INDEX_KIND_MAP_NOSQL:
|
|
raise VectorStoreModelException(
|
|
f"Index kind '{field.index_kind}' is not supported by Azure Cosmos DB NoSQL container."
|
|
)
|
|
indexing_policy["vectorIndexes"].append({
|
|
"path": f'/"{field.storage_name or field.name}"',
|
|
"type": INDEX_KIND_MAP_NOSQL[field.index_kind],
|
|
})
|
|
# Exclude the vector field from the index for performance optimization.
|
|
indexing_policy["excludedPaths"].append({"path": f'/"{field.storage_name or field.name}"/*'})
|
|
|
|
return indexing_policy
|
|
|
|
|
|
def _create_default_vector_embedding_policy(definition: VectorStoreCollectionDefinition) -> dict[str, Any]:
|
|
"""Creates a default vector embedding policy for the Azure Cosmos DB NoSQL container.
|
|
|
|
A default vector embedding policy is created based on the data model definition.
|
|
|
|
Args:
|
|
definition (VectorStoreRecordDefinition): The definition of the data model.
|
|
|
|
Returns:
|
|
dict[str, Any]: The vector embedding policy.
|
|
|
|
Raises:
|
|
VectorStoreModelException: If the datatype or distance function is not supported by Azure Cosmos DB NoSQL.
|
|
|
|
"""
|
|
vector_embedding_policy: dict[str, Any] = {"vectorEmbeddings": []}
|
|
|
|
for field in definition.fields:
|
|
if field.field_type == FieldTypes.VECTOR:
|
|
if field.distance_function not in DISTANCE_FUNCTION_MAP_NOSQL:
|
|
raise VectorStoreModelException(
|
|
f"Distance function '{field.distance_function}' is not supported by Azure Cosmos DB NoSQL."
|
|
)
|
|
if field.type_ and field.type_ not in VECTOR_DATATYPES_MAP:
|
|
raise VectorStoreModelException(
|
|
f"Vector property type '{field.type_}' is not supported by Azure Cosmos DB NoSQL."
|
|
)
|
|
|
|
vector_embedding_policy["vectorEmbeddings"].append({
|
|
"path": f'/"{field.storage_name or field.name}"',
|
|
"dataType": VECTOR_DATATYPES_MAP[field.type_ or "default"],
|
|
"distanceFunction": DISTANCE_FUNCTION_MAP_NOSQL[field.distance_function],
|
|
"dimensions": field.dimensions,
|
|
})
|
|
|
|
return vector_embedding_policy
|
|
|
|
|
|
def _get_key(key: str | CosmosNoSqlCompositeKey) -> str:
|
|
"""Gets the key value from the key."""
|
|
if isinstance(key, CosmosNoSqlCompositeKey):
|
|
return key.key
|
|
return key
|
|
|
|
|
|
def _get_partition_key(key: str | CosmosNoSqlCompositeKey) -> str:
|
|
"""Gets the partition key value from the key."""
|
|
if isinstance(key, CosmosNoSqlCompositeKey):
|
|
return key.partition_key
|
|
return key
|
|
|
|
|
|
# region: Settings
|
|
|
|
|
|
@release_candidate
|
|
class CosmosMongoSettings(KernelBaseSettings):
|
|
"""Azure CosmosDB for MongoDB settings.
|
|
|
|
The settings are first loaded from environment variables with
|
|
the prefix 'AZURE_COSMOS_DB_MONGODB_'.
|
|
If the environment variables are not found, the settings can
|
|
be loaded from a .env file with the encoding 'utf-8'.
|
|
If the settings are not found in the .env file, the settings
|
|
are ignored; however, validation will fail alerting that the
|
|
settings are missing.
|
|
|
|
Required settings for prefix 'AZURE_COSMOS_DB_MONGODB_':
|
|
- connection_string: The connection string of the Azure CosmosDB for MongoDB account.
|
|
This value can be found in the Keys & Endpoint section when examining
|
|
your resource from the Azure portal.
|
|
(Env var name: AZURE_COSMOS_DB_MONGODB_CONNECTION_STRING)
|
|
- database_name: str - The name of the database. Please refer to this documentation
|
|
on Azure CosmosDB NoSQL resource model:
|
|
https://learn.microsoft.com/en-us/azure/cosmos-db/resource-model
|
|
(Env var name: AZURE_COSMOS_DB_MONGODB_DATABASE_NAME)
|
|
"""
|
|
|
|
env_prefix: ClassVar[str] = "AZURE_COSMOS_DB_MONGODB_"
|
|
|
|
connection_string: SecretStr
|
|
database_name: str = DEFAULT_DB_NAME
|
|
|
|
|
|
@release_candidate
|
|
class CosmosNoSqlSettings(KernelBaseSettings):
|
|
"""Azure CosmosDB NoSQL settings.
|
|
|
|
The settings are first loaded from environment variables with
|
|
the prefix 'AZURE_COSMOS_DB_NO_SQL_'.
|
|
If the environment variables are not found, the settings can
|
|
be loaded from a .env file with the encoding 'utf-8'.
|
|
If the settings are not found in the .env file, the settings
|
|
are ignored; however, validation will fail alerting that the
|
|
settings are missing.
|
|
|
|
Required settings for prefix 'AZURE_COSMOS_DB_NO_SQL_':
|
|
- url: HttpsUrl - The uri of the Azure CosmosDB NoSQL account.
|
|
This value can be found in the Keys & Endpoint section when examining
|
|
your resource from the Azure portal.
|
|
(Env var name: AZURE_COSMOS_DB_NO_SQL_URL)
|
|
|
|
Optional settings for prefix 'AZURE_COSMOS_DB_NO_SQL_':
|
|
- key: SecretStr - The primary key of the Azure CosmosDB NoSQL account.
|
|
This value can be found in the Keys & Endpoint section when examining
|
|
your resource from the Azure portal.
|
|
(Env var name: AZURE_COSMOS_DB_NO_SQL_KEY)
|
|
- database_name: str - The name of the database. Please refer to this documentation
|
|
on Azure CosmosDB NoSQL resource model:
|
|
https://learn.microsoft.com/en-us/azure/cosmos-db/resource-model
|
|
(Env var name: AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME)
|
|
"""
|
|
|
|
env_prefix: ClassVar[str] = "AZURE_COSMOS_DB_NO_SQL_"
|
|
|
|
url: HttpUrl
|
|
key: SecretStr | None = None
|
|
database_name: str | None = None
|
|
|
|
|
|
# region: Mongo Collection
|
|
|
|
|
|
@release_candidate
|
|
class CosmosMongoCollection(MongoDBAtlasCollection[TKey, TModel], Generic[TKey, TModel]):
|
|
"""Azure Cosmos DB for MongoDB collection."""
|
|
|
|
def __init__(
|
|
self,
|
|
record_type: type[TModel],
|
|
definition: VectorStoreCollectionDefinition | None = None,
|
|
collection_name: str | None = None,
|
|
embedding_generator: EmbeddingGeneratorBase | None = None,
|
|
mongo_client: AsyncMongoClient | None = None,
|
|
connection_string: str | None = None,
|
|
database_name: str | None = None,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initializes a new instance of the CosmosMongoCollection class.
|
|
|
|
Args:
|
|
record_type: The type of the data model.
|
|
definition: The model definition, optional.
|
|
collection_name: The name of the collection, optional.
|
|
embedding_generator: The embedding generator to use for generating embeddings.
|
|
mongo_client: The MongoDB client for interacting with Azure CosmosDB for MongoDB,
|
|
used for creating and deleting collections.
|
|
connection_string: The connection string for MongoDB Atlas, optional.
|
|
Can be read from environment variables.
|
|
database_name: The name of the database, will be filled from the env when this is not set.
|
|
connection_string: str | None = None,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None
|
|
**kwargs: Additional keyword arguments
|
|
|
|
"""
|
|
if not collection_name:
|
|
collection_name = _get_collection_name_from_model(record_type, definition)
|
|
if not collection_name:
|
|
raise VectorStoreInitializationException(
|
|
"The collection name is required, can be passed directly or through the data model."
|
|
)
|
|
managed_client = not mongo_client
|
|
if mongo_client:
|
|
super().__init__(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
mongo_client=mongo_client,
|
|
collection_name=collection_name,
|
|
database_name=database_name or DEFAULT_DB_NAME,
|
|
managed_client=managed_client,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
return
|
|
|
|
try:
|
|
settings = CosmosMongoSettings(
|
|
env_file_path=env_file_path,
|
|
env_file_encoding=env_file_encoding,
|
|
connection_string=connection_string,
|
|
database_name=database_name,
|
|
)
|
|
except ValidationError as exc:
|
|
raise VectorStoreInitializationException("Failed to create Azure CosmosDB for MongoDB settings.") from exc
|
|
|
|
mongo_client = AsyncMongoClient(
|
|
settings.connection_string.get_secret_value(),
|
|
driver=DriverInfo(SEMANTIC_KERNEL_USER_AGENT, metadata.version("semantic-kernel")),
|
|
)
|
|
|
|
super().__init__(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=collection_name,
|
|
mongo_client=mongo_client,
|
|
managed_client=managed_client,
|
|
database_name=settings.database_name,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
|
|
@override
|
|
async def ensure_collection_exists(self, **kwargs) -> None:
|
|
"""Create a new collection in Azure CosmosDB for MongoDB.
|
|
|
|
This first creates a collection, with the kwargs.
|
|
Then creates a search index based on the data model definition.
|
|
|
|
By the naming convection of MongoDB indexes are created by using the field name
|
|
with a underscore.
|
|
|
|
Args:
|
|
**kwargs: Additional keyword arguments.
|
|
These are the additional keyword arguments for creating
|
|
vector indexes in Azure Cosmos DB for MongoDB.
|
|
And they depend on the kind of index you are creating.
|
|
See https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/vector-search
|
|
for more information.
|
|
Other kwargs are passed to the create_collection method.
|
|
"""
|
|
await self._get_database().create_collection(self.collection_name, **kwargs)
|
|
await self._get_database().command(command=self._get_index_definitions(**kwargs))
|
|
|
|
def _get_index_definitions(self, **kwargs: Any) -> dict[str, Any]:
|
|
"""Creates index definitions for the collection."""
|
|
indexes = [
|
|
{
|
|
"name": f"{field.storage_name or field.name}_",
|
|
FieldTypes.KEY: {field.storage_name or field.name: 1},
|
|
}
|
|
for field in self.definition.data_fields
|
|
if field.is_indexed or field.is_full_text_indexed
|
|
]
|
|
for field in self.definition.vector_fields:
|
|
if field.index_kind not in INDEX_KIND_MAP_MONGODB:
|
|
raise VectorStoreModelException(
|
|
f"Index kind '{field.index_kind}' is not supported by Azure Cosmos DB for MongoDB."
|
|
)
|
|
if field.distance_function not in DISTANCE_FUNCTION_MAP_MONGODB:
|
|
raise VectorStoreModelException(
|
|
f"Distance function '{field.distance_function}' is not supported by Azure Cosmos DB for MongoDB."
|
|
)
|
|
index_name = f"{field.storage_name or field.name}_"
|
|
index_kind = DISTANCE_FUNCTION_MAP_MONGODB[field.distance_function]
|
|
index: dict[str, Any] = {
|
|
"name": index_name,
|
|
FieldTypes.KEY: {field.storage_name or field.name: "cosmosSearch"},
|
|
"cosmosSearchOptions": {
|
|
"kind": index_kind,
|
|
"similarity": DISTANCE_FUNCTION_MAP_MONGODB[field.distance_function],
|
|
"dimensions": field.dimensions,
|
|
},
|
|
}
|
|
match index_kind:
|
|
case "vector-diskann":
|
|
if "maxDegree" in kwargs:
|
|
index["cosmosSearchOptions"]["maxDegree"] = kwargs["maxDegree"]
|
|
if "lBuild" in kwargs:
|
|
index["cosmosSearchOptions"]["lBuild"] = kwargs["lBuild"]
|
|
case "vector-hnsw":
|
|
if "m" in kwargs:
|
|
index["cosmosSearchOptions"]["m"] = kwargs["m"]
|
|
if "efConstruction" in kwargs:
|
|
index["cosmosSearchOptions"]["efConstruction"] = kwargs["efConstruction"]
|
|
case "vector-ivf":
|
|
if "numList" in kwargs:
|
|
index["cosmosSearchOptions"]["numList"] = kwargs["numList"]
|
|
indexes.append(index)
|
|
|
|
return {"createIndexes": self.collection_name, "indexes": indexes}
|
|
|
|
@override
|
|
async def _inner_vector_search(
|
|
self,
|
|
options: VectorSearchOptions,
|
|
values: Any | None = None,
|
|
vector: Sequence[float | int] | None = None,
|
|
**kwargs: Any,
|
|
) -> KernelSearchResults[VectorSearchResult[TModel]]:
|
|
collection = self._get_collection()
|
|
vector_field = self.definition.try_get_vector_field(options.vector_property_name)
|
|
if not vector_field:
|
|
raise VectorStoreModelException(
|
|
f"Vector field '{options.vector_property_name}' not found in the data model definition."
|
|
)
|
|
if not vector:
|
|
vector = await self._generate_vector_from_values(values, options)
|
|
vector_search_query: dict[str, Any] = {
|
|
"k": options.top + options.skip,
|
|
"index": f"{vector_field.storage_name or vector_field.name}_",
|
|
"vector": vector,
|
|
"path": vector_field.storage_name or vector_field.name,
|
|
}
|
|
if filter := self._build_filter(options.filter): # type: ignore
|
|
vector_search_query["filter"] = filter if isinstance(filter, dict) else {"$and": filter}
|
|
|
|
projection_query: dict[str, int | dict] = {
|
|
field: 1
|
|
for field in self.definition.get_names(
|
|
include_vector_fields=options.include_vectors,
|
|
include_key_field=False, # _id is always included
|
|
)
|
|
}
|
|
projection_query[MONGODB_SCORE_FIELD] = {"$meta": "searchScore"}
|
|
try:
|
|
raw_results = await collection.aggregate([
|
|
{"$search": {"cosmosSearch": vector_search_query}},
|
|
{"$project": projection_query},
|
|
])
|
|
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=None, # no way to get a count before looping through the result cursor
|
|
)
|
|
|
|
|
|
# region: Mongo Store
|
|
|
|
|
|
@release_candidate
|
|
class CosmosMongoStore(MongoDBAtlasStore):
|
|
"""Azure Cosmos DB for MongoDB store."""
|
|
|
|
def __init__(
|
|
self,
|
|
connection_string: str | None = None,
|
|
database_name: str | None = None,
|
|
mongo_client: AsyncMongoClient | 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 CosmosMongoStore class."""
|
|
managed_client: bool = not mongo_client
|
|
if mongo_client:
|
|
super().__init__(
|
|
mongo_client=mongo_client,
|
|
managed_client=managed_client,
|
|
database_name=database_name,
|
|
embedding_generator=embedding_generator,
|
|
)
|
|
return
|
|
try:
|
|
settings = CosmosMongoSettings(
|
|
env_file_path=env_file_path,
|
|
env_file_encoding=env_file_encoding,
|
|
connection_string=connection_string,
|
|
database_name=database_name,
|
|
)
|
|
except ValidationError as exc:
|
|
raise VectorStoreInitializationException("Failed to create Azure CosmosDB for MongoDB settings.") from exc
|
|
|
|
mongo_client = AsyncMongoClient(
|
|
settings.connection_string.get_secret_value(),
|
|
driver=DriverInfo(SEMANTIC_KERNEL_USER_AGENT, metadata.version("semantic-kernel")),
|
|
)
|
|
|
|
super().__init__(
|
|
mongo_client=mongo_client,
|
|
managed_client=managed_client,
|
|
database_name=settings.database_name,
|
|
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,
|
|
**kwargs: Any,
|
|
) -> CosmosMongoCollection:
|
|
return CosmosMongoCollection(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=collection_name,
|
|
mongo_client=self.mongo_client,
|
|
managed_client=False,
|
|
database_name=self.database_name,
|
|
embedding_generator=embedding_generator or self.embedding_generator,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
# region: NoSQL Base
|
|
|
|
|
|
@release_candidate
|
|
class CosmosNoSqlBase(KernelBaseModel):
|
|
"""An Azure Cosmos DB NoSQL collection stores documents in a Azure Cosmos DB NoSQL account."""
|
|
|
|
cosmos_client: CosmosClient
|
|
database_name: str
|
|
cosmos_db_nosql_settings: CosmosNoSqlSettings
|
|
# If create_database is True, the database will be created
|
|
# if it does not exist when an operation requires a database.
|
|
create_database: bool
|
|
|
|
def __init__(
|
|
self,
|
|
url: str | None = None,
|
|
key: str | None = None,
|
|
database_name: str | None = None,
|
|
cosmos_client: CosmosClient | None = None,
|
|
create_database: bool = False,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None,
|
|
credential: AsyncTokenCredential | None = None,
|
|
**kwargs,
|
|
):
|
|
"""Initialize the CosmosNoSqlBase.
|
|
|
|
Args:
|
|
url (str): The URL of the Azure Cosmos DB NoSQL account. Defaults to None.
|
|
key (str): The key of the Azure Cosmos DB NoSQL account. Defaults to None.
|
|
database_name (str): The name of the database. The database may not exist yet. If it does not exist,
|
|
it will be created when the first collection is created. Defaults to None.
|
|
cosmos_client (CosmosClient): The custom Azure Cosmos DB NoSQL client whose lifetime is managed by the user.
|
|
Defaults to None.
|
|
create_database (bool): If True, the database will be created if it does not exist.
|
|
Defaults to False.
|
|
env_file_path (str): The path to the .env file. Defaults to None.
|
|
env_file_encoding (str): The encoding of the .env file. Defaults to None.
|
|
credential: The credential to use for authentication to Azure Cosmos DB NoSQL.
|
|
kwargs: Additional keyword arguments.
|
|
"""
|
|
try:
|
|
cosmos_db_nosql_settings = CosmosNoSqlSettings(
|
|
url=url,
|
|
key=key,
|
|
database_name=database_name,
|
|
env_file_path=env_file_path,
|
|
env_file_encoding=env_file_encoding,
|
|
)
|
|
except ValidationError as e:
|
|
raise VectorStoreInitializationException("Failed to validate Azure Cosmos DB NoSQL settings.") from e
|
|
|
|
if cosmos_db_nosql_settings.database_name is None:
|
|
raise VectorStoreInitializationException("The name of the Azure Cosmos DB NoSQL database is missing.")
|
|
|
|
if cosmos_client is None:
|
|
if cosmos_db_nosql_settings.key is not None:
|
|
cosmos_client = CosmosClient(
|
|
str(cosmos_db_nosql_settings.url), credential=cosmos_db_nosql_settings.key.get_secret_value()
|
|
)
|
|
else:
|
|
if credential is None:
|
|
raise VectorStoreInitializationException(
|
|
"The 'credential' parameter is required for authentication."
|
|
)
|
|
cosmos_client = CosmosClient(str(cosmos_db_nosql_settings.url), credential=credential)
|
|
|
|
super().__init__(
|
|
cosmos_client=cosmos_client,
|
|
database_name=cosmos_db_nosql_settings.database_name,
|
|
cosmos_db_nosql_settings=cosmos_db_nosql_settings,
|
|
create_database=create_database,
|
|
**kwargs,
|
|
)
|
|
|
|
async def _does_database_exist(self) -> bool:
|
|
"""Checks if the database exists."""
|
|
try:
|
|
await self.cosmos_client.get_database_client(self.database_name).read()
|
|
return True
|
|
except CosmosResourceNotFoundError:
|
|
return False
|
|
except Exception as e:
|
|
raise VectorStoreOperationException(
|
|
f"Failed to check if database '{self.database_name}' exists, with message {e}"
|
|
) from e
|
|
|
|
async def _get_database_proxy(self, **kwargs) -> DatabaseProxy:
|
|
"""Gets the database proxy."""
|
|
try:
|
|
if await self._does_database_exist():
|
|
return self.cosmos_client.get_database_client(self.database_name)
|
|
|
|
if self.create_database:
|
|
return await self.cosmos_client.create_database(self.database_name, **kwargs)
|
|
raise VectorStoreOperationException(f"Database '{self.database_name}' does not exist.")
|
|
except Exception as e:
|
|
raise VectorStoreOperationException(f"Failed to get database proxy for '{id}'.") from e
|
|
|
|
async def _get_container_proxy(self, container_name: str, **kwargs) -> ContainerProxy:
|
|
"""Gets the container proxy."""
|
|
try:
|
|
database_proxy = await self._get_database_proxy(**kwargs)
|
|
return database_proxy.get_container_client(container_name)
|
|
except Exception as e:
|
|
raise VectorStoreOperationException(f"Failed to get container proxy for '{container_name}'.") from e
|
|
|
|
|
|
# region: NoSQL Collection
|
|
|
|
|
|
@release_candidate
|
|
class CosmosNoSqlCollection(
|
|
CosmosNoSqlBase,
|
|
VectorStoreCollection[TNoSQLKey, TModel],
|
|
VectorSearch[TNoSQLKey, TModel],
|
|
Generic[TNoSQLKey, TModel],
|
|
):
|
|
"""An Azure Cosmos DB NoSQL collection stores documents in a Azure Cosmos DB NoSQL account."""
|
|
|
|
partition_key: PartitionKey
|
|
supported_key_types: ClassVar[set[str] | None] = {"str", "CosmosNoSqlCompositeKey"}
|
|
supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR, SearchType.KEYWORD_HYBRID}
|
|
|
|
def __init__(
|
|
self,
|
|
record_type: type[TModel],
|
|
definition: VectorStoreCollectionDefinition | None = None,
|
|
collection_name: str | None = None,
|
|
database_name: str | None = None,
|
|
embedding_generator: EmbeddingGeneratorBase | None = None,
|
|
url: str | None = None,
|
|
key: str | None = None,
|
|
cosmos_client: CosmosClient | None = None,
|
|
partition_key: PartitionKey | str | None = None,
|
|
create_database: bool = False,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None,
|
|
credential: AsyncTokenCredential | None = None,
|
|
):
|
|
"""Initializes a new instance of the CosmosNoSqlCollection class.
|
|
|
|
Args:
|
|
record_type: The type of the data model.
|
|
collection_name: The name of the collection.
|
|
database_name: The name of the database. Used to create a database proxy if not provided.
|
|
Defaults to None.
|
|
embedding_generator: The embedding generator to use for generating embeddings.
|
|
definition: The definition of the data model. Defaults to None.
|
|
url: The URL of the Azure Cosmos DB NoSQL account. Defaults to None.
|
|
key: The key of the Azure Cosmos DB NoSQL account. Defaults to None.
|
|
cosmos_client: The custom Azure Cosmos DB NoSQL client whose lifetime is managed by the user.
|
|
partition_key: The partition key. Defaults to None. If not provided, the partition
|
|
key will be based on the key field of the data model definition.
|
|
https://learn.microsoft.com/en-us/azure/cosmos-db/partitioning-overview
|
|
create_database: Indicates whether to create the database if it does not exist.
|
|
Defaults to False.
|
|
env_file_path: The path to the .env file. Defaults to None.
|
|
env_file_encoding: The encoding of the .env file. Defaults to None.
|
|
credential: The credential to use for authentication to Azure Cosmos DB NoSQL.
|
|
"""
|
|
if not collection_name:
|
|
collection_name = _get_collection_name_from_model(record_type, definition)
|
|
if not partition_key:
|
|
partition_key = PartitionKey(path=f"/{COSMOS_ITEM_ID_PROPERTY_NAME}")
|
|
elif isinstance(partition_key, str):
|
|
partition_key = PartitionKey(path=f"/{partition_key.strip('/')}")
|
|
|
|
super().__init__(
|
|
partition_key=partition_key,
|
|
url=url,
|
|
key=key,
|
|
database_name=database_name,
|
|
cosmos_client=cosmos_client,
|
|
create_database=create_database,
|
|
env_file_path=env_file_path,
|
|
env_file_encoding=env_file_encoding,
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=collection_name,
|
|
managed_client=cosmos_client is None,
|
|
embedding_generator=embedding_generator,
|
|
credential=credential,
|
|
)
|
|
|
|
@override
|
|
async def _inner_upsert(
|
|
self,
|
|
records: Sequence[Any],
|
|
**kwargs: Any,
|
|
) -> Sequence[TNoSQLKey]:
|
|
container_proxy = await self._get_container_proxy(self.collection_name, **kwargs)
|
|
results = await asyncio.gather(*(container_proxy.upsert_item(record) for record in records))
|
|
return [result[COSMOS_ITEM_ID_PROPERTY_NAME] for result in results]
|
|
|
|
@override
|
|
async def _inner_get( # type: ignore
|
|
self,
|
|
keys: Sequence[TNoSQLKey] | None = None,
|
|
options: GetFilteredRecordOptions | None = None,
|
|
**kwargs: Any,
|
|
) -> Sequence[Any] | None:
|
|
if not keys:
|
|
if options is not None:
|
|
raise NotImplementedError("Get without keys is not yet implemented.")
|
|
return None
|
|
include_vectors = kwargs.pop("include_vectors", False)
|
|
query = (
|
|
f"SELECT {self._build_select_clause(include_vectors)} FROM c WHERE " # nosec: B608
|
|
f"c.id IN ({', '.join([f'@id{i}' for i in range(len(keys))])})" # nosec: B608
|
|
) # nosec: B608
|
|
parameters: list[dict[str, Any]] = [{"name": f"@id{i}", "value": _get_key(key)} for i, key in enumerate(keys)]
|
|
|
|
container_proxy = await self._get_container_proxy(self.collection_name, **kwargs)
|
|
return [item async for item in container_proxy.query_items(query=query, parameters=parameters)]
|
|
|
|
@override
|
|
async def _inner_delete(self, keys: Sequence[TNoSQLKey], **kwargs: Any) -> None: # type: ignore
|
|
container_proxy = await self._get_container_proxy(self.collection_name, **kwargs)
|
|
results = await asyncio.gather(
|
|
*[container_proxy.delete_item(item=_get_key(key), partition_key=_get_partition_key(key)) for key in keys],
|
|
return_exceptions=True,
|
|
)
|
|
exceptions = [result for result in results if isinstance(result, Exception)]
|
|
if exceptions:
|
|
raise VectorStoreOperationException("Failed to delete item(s).", exceptions)
|
|
|
|
@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]]:
|
|
params = [{"name": "@top", "value": options.top}]
|
|
vector_field = self.definition.try_get_vector_field(options.vector_property_name)
|
|
if not vector_field:
|
|
raise VectorStoreModelException(
|
|
f"Vector field '{options.vector_property_name}' not found in the data model definition."
|
|
)
|
|
if not vector:
|
|
vector = await self._generate_vector_from_values(values, options)
|
|
|
|
if where_clauses := self._build_filter(options.filter): # type: ignore
|
|
where_clauses = (
|
|
f"WHERE {where_clauses} "
|
|
if isinstance(where_clauses, str)
|
|
else f"WHERE ({' AND '.join(where_clauses)}) "
|
|
)
|
|
else:
|
|
where_clauses = "" # Empty string instead of None
|
|
vector_field_name = vector_field.storage_name or vector_field.name
|
|
select_clause = self._build_select_clause(options.include_vectors)
|
|
params.append({"name": "@vector", "value": vector})
|
|
if vector_field.distance_function not in DISTANCE_FUNCTION_MAP_NOSQL:
|
|
raise VectorStoreModelException(
|
|
f"Distance function '{vector_field.distance_function}' is not supported by Azure Cosmos DB NoSQL."
|
|
)
|
|
# Cosmos DB VectorDistance function only accepts 2 parameters: field and vector
|
|
# Distance function is configured in the vector index, not in the query
|
|
if search_type == SearchType.VECTOR:
|
|
distance_clause = f"VectorDistance(c.{vector_field_name}, @vector)"
|
|
elif search_type == SearchType.KEYWORD_HYBRID:
|
|
# Hybrid search: requires both a vector and keywords
|
|
params.append({"name": "@keywords", "value": values})
|
|
text_field = options.additional_property_name
|
|
if not text_field:
|
|
raise VectorStoreModelException("Hybrid search requires 'keyword_field_name' in options.")
|
|
distance_clause = (
|
|
f"RRF(VectorDistance(c.{vector_field_name}, @vector), FullTextScore(c.{text_field}, @keywords))"
|
|
)
|
|
else:
|
|
raise VectorStoreModelException(f"Search type '{search_type}' is not supported.")
|
|
query = (
|
|
f"SELECT TOP @top {select_clause}, " # nosec: B608
|
|
f"{distance_clause} as {NOSQL_SCORE_PROPERTY_NAME} " # nosec: B608
|
|
"FROM c "
|
|
f"{where_clauses}" # nosec: B608
|
|
f"ORDER BY {distance_clause}" # nosec: B608
|
|
)
|
|
|
|
container_proxy = await self._get_container_proxy(self.collection_name, **kwargs)
|
|
try:
|
|
results = container_proxy.query_items(query, parameters=params)
|
|
except Exception as exc:
|
|
raise VectorSearchExecutionException("Failed to search items.") from exc
|
|
return KernelSearchResults(
|
|
results=self._get_vector_search_results_from_results(results, options),
|
|
total_count=None,
|
|
)
|
|
|
|
def _build_select_clause(self, include_vectors: bool) -> str:
|
|
"""Create the select clause for a CosmosDB query."""
|
|
included_fields = [field for field in self.definition.get_storage_names(include_vector_fields=include_vectors)]
|
|
if self.definition.key_name != COSMOS_ITEM_ID_PROPERTY_NAME:
|
|
# Replace the key field name with the Cosmos item id property name
|
|
included_fields = [
|
|
field if field != self.definition.key_name else COSMOS_ITEM_ID_PROPERTY_NAME
|
|
for field in included_fields
|
|
]
|
|
|
|
return ", ".join(f"c.{field}" for field in included_fields)
|
|
|
|
@override
|
|
def _lambda_parser(self, node: ast.AST) -> Any:
|
|
match node:
|
|
case ast.Compare():
|
|
if len(node.ops) > 1:
|
|
# Chain comparisons (e.g., a < b < c) become AND-ed comparisons
|
|
values = []
|
|
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 "(" + " AND ".join([self._lambda_parser(v) for v in values]) + ")"
|
|
left = self._lambda_parser(node.left)
|
|
right = self._lambda_parser(node.comparators[0])
|
|
op = node.ops[0]
|
|
match op:
|
|
case ast.In():
|
|
# Cosmos DB: ARRAY_CONTAINS(right, left)
|
|
return f"ARRAY_CONTAINS({right}, {left})"
|
|
case ast.NotIn():
|
|
return f"NOT ARRAY_CONTAINS({right}, {left})"
|
|
case ast.Eq():
|
|
return f"{left} = {right}"
|
|
case ast.NotEq():
|
|
return f"{left} != {right}"
|
|
case ast.Gt():
|
|
return f"{left} > {right}"
|
|
case ast.GtE():
|
|
return f"{left} >= {right}"
|
|
case ast.Lt():
|
|
return f"{left} < {right}"
|
|
case ast.LtE():
|
|
return f"{left} <= {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.Not():
|
|
return f"NOT ({self._lambda_parser(node.operand)})"
|
|
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.")
|
|
raise NotImplementedError(f"Unsupported unary operator: {type(node.op)}")
|
|
case ast.Attribute():
|
|
# Cosmos DB: c.field_name
|
|
if node.attr not in self.definition.storage_names:
|
|
raise VectorStoreOperationException(
|
|
f"Field '{node.attr}' not in data model (storage property names are used)."
|
|
)
|
|
return f"c.{node.attr}"
|
|
case ast.Name():
|
|
# Could be a variable or constant; not supported
|
|
raise NotImplementedError("Constants or variables are not supported, use a value or attribute.")
|
|
case ast.Constant():
|
|
# Quote strings, leave numbers as is
|
|
if isinstance(node.value, str):
|
|
return "'" + node.value.replace("'", "''") + "'"
|
|
if isinstance(node.value, (float, int)):
|
|
return str(node.value)
|
|
if node.value is None:
|
|
return "null"
|
|
raise NotImplementedError(f"Unsupported constant type: {type(node.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(NOSQL_SCORE_PROPERTY_NAME)
|
|
|
|
@override
|
|
def _serialize_dicts_to_store_models(self, records: Sequence[dict[str, Any]], **kwargs: Any) -> Sequence[Any]:
|
|
serialized_records = []
|
|
|
|
key_field_name = self.definition.key_name
|
|
for record in records:
|
|
serialized_record = {**record, COSMOS_ITEM_ID_PROPERTY_NAME: record[key_field_name]}
|
|
if key_field_name != COSMOS_ITEM_ID_PROPERTY_NAME:
|
|
# Remove the key field from the serialized record
|
|
serialized_record.pop(key_field_name, None)
|
|
|
|
serialized_records.append(serialized_record)
|
|
|
|
return serialized_records
|
|
|
|
@override
|
|
def _deserialize_store_models_to_dicts(self, records: Sequence[Any], **kwargs: Any) -> Sequence[dict[str, Any]]:
|
|
deserialized_records = []
|
|
|
|
key_field_name = self.definition.key_name
|
|
for record in records:
|
|
if COSMOS_ITEM_ID_PROPERTY_NAME not in record:
|
|
raise VectorStoreModelDeserializationException(
|
|
f"The record does not have the {COSMOS_ITEM_ID_PROPERTY_NAME} property."
|
|
)
|
|
|
|
deserialized_record = {**record, key_field_name: record[COSMOS_ITEM_ID_PROPERTY_NAME]}
|
|
if key_field_name != COSMOS_ITEM_ID_PROPERTY_NAME:
|
|
# Remove the id property from the deserialized record
|
|
deserialized_record.pop(COSMOS_ITEM_ID_PROPERTY_NAME, None)
|
|
|
|
deserialized_records.append(deserialized_record)
|
|
|
|
return deserialized_records
|
|
|
|
@override
|
|
async def ensure_collection_exists(self, **kwargs) -> None:
|
|
indexing_policy = kwargs.pop("indexing_policy", _create_default_indexing_policy_nosql(self.definition))
|
|
vector_embedding_policy = kwargs.pop(
|
|
"vector_embedding_policy", _create_default_vector_embedding_policy(self.definition)
|
|
)
|
|
database_proxy = await self._get_database_proxy(**kwargs)
|
|
try:
|
|
await database_proxy.create_container_if_not_exists(
|
|
id=self.collection_name,
|
|
partition_key=self.partition_key,
|
|
indexing_policy=indexing_policy,
|
|
vector_embedding_policy=vector_embedding_policy,
|
|
**kwargs,
|
|
)
|
|
except CosmosHttpResponseError as e:
|
|
raise VectorStoreOperationException("Failed to create container.") from e
|
|
|
|
@override
|
|
async def collection_exists(self, **kwargs) -> bool:
|
|
container_proxy = await self._get_container_proxy(self.collection_name, **kwargs)
|
|
try:
|
|
await container_proxy.read(**kwargs)
|
|
return True
|
|
except CosmosHttpResponseError:
|
|
return False
|
|
|
|
@override
|
|
async def ensure_collection_deleted(self, **kwargs) -> None:
|
|
database_proxy = await self._get_database_proxy(**kwargs)
|
|
try:
|
|
await database_proxy.delete_container(self.collection_name)
|
|
except Exception as e:
|
|
raise VectorStoreOperationException("Container could not be deleted.") from e
|
|
|
|
@override
|
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
"""Exit the context manager."""
|
|
if self.managed_client:
|
|
await self.cosmos_client.close()
|
|
|
|
|
|
# region: NoSQL Store
|
|
|
|
|
|
@release_candidate
|
|
class CosmosNoSqlStore(CosmosNoSqlBase, VectorStore):
|
|
"""A VectorStore implementation that uses Azure CosmosDB NoSQL as the backend storage."""
|
|
|
|
def __init__(
|
|
self,
|
|
url: str | None = None,
|
|
key: str | None = None,
|
|
database_name: str | None = None,
|
|
cosmos_client: CosmosClient | None = None,
|
|
create_database: bool = False,
|
|
embedding_generator: EmbeddingGeneratorBase | None = None,
|
|
env_file_path: str | None = None,
|
|
env_file_encoding: str | None = None,
|
|
credential: AsyncTokenCredential | None = None,
|
|
):
|
|
"""Initialize the CosmosNoSqlStore.
|
|
|
|
Args:
|
|
url: The URL of the Azure Cosmos DB NoSQL account. Defaults to None.
|
|
key: The key of the Azure Cosmos DB NoSQL account. Defaults to None.
|
|
database_name: The name of the database. The database may not exist yet. If it does not exist,
|
|
it will be created when the first collection is created. Defaults to None.
|
|
cosmos_client: The custom Azure Cosmos DB NoSQL client whose lifetime is managed by the user.
|
|
Defaults to None.
|
|
create_database: If True, the database will be created if it does not exist.
|
|
Defaults to False.
|
|
embedding_generator: The embedding generator to use for generating embeddings.
|
|
env_file_path: The path to the .env file. Defaults to None.
|
|
env_file_encoding: The encoding of the .env file. Defaults to None.
|
|
credential: The credential to use for authentication to Azure Cosmos DB NoSQL.
|
|
"""
|
|
super().__init__(
|
|
url=url,
|
|
key=key,
|
|
database_name=database_name,
|
|
cosmos_client=cosmos_client,
|
|
create_database=create_database,
|
|
embedding_generator=embedding_generator,
|
|
env_file_path=env_file_path,
|
|
env_file_encoding=env_file_encoding,
|
|
credential=credential,
|
|
)
|
|
|
|
@override
|
|
def get_collection(
|
|
self,
|
|
record_type: type[TModel],
|
|
*,
|
|
definition: VectorStoreCollectionDefinition | None = None,
|
|
collection_name: str | None = None,
|
|
embedding_generator: EmbeddingGeneratorBase | None = None,
|
|
**kwargs: Any,
|
|
) -> CosmosNoSqlCollection:
|
|
return CosmosNoSqlCollection(
|
|
record_type=record_type,
|
|
definition=definition,
|
|
collection_name=collection_name,
|
|
database_name=self.database_name,
|
|
embedding_generator=embedding_generator or self.embedding_generator,
|
|
url=str(self.cosmos_db_nosql_settings.url),
|
|
key=self.cosmos_db_nosql_settings.key.get_secret_value() if self.cosmos_db_nosql_settings.key else None,
|
|
cosmos_client=self.cosmos_client,
|
|
partition_key=None,
|
|
create_database=self.create_database,
|
|
env_file_path=None,
|
|
env_file_encoding=None,
|
|
**kwargs,
|
|
)
|
|
|
|
@override
|
|
async def list_collection_names(self, **kwargs) -> Sequence[str]:
|
|
try:
|
|
database = await self._get_database_proxy()
|
|
containers = database.list_containers()
|
|
return [container["id"] async for container in containers]
|
|
except Exception as e:
|
|
raise VectorStoreOperationException("Failed to list collection names.") from e
|
|
|
|
@override
|
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
"""Exit the context manager."""
|
|
if self.managed_client:
|
|
await self.cosmos_client.close()
|