Files
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

485 lines
20 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import ast
import logging
import sys
from collections.abc import MutableSequence, Sequence
from typing import Any, ClassVar, Final, Generic, TypeVar
from chromadb import Client, Collection, GetResult, QueryResult
from chromadb.api import ClientAPI
from chromadb.api.collection_configuration import CreateCollectionConfiguration, CreateHNSWConfiguration
from chromadb.api.types import EmbeddingFunction, Space
from chromadb.config import Settings
from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase
from semantic_kernel.data.vector import (
DistanceFunction,
GetFilteredRecordOptions,
IndexKind,
KernelSearchResults,
SearchType,
TModel,
VectorSearch,
VectorSearchOptions,
VectorSearchResult,
VectorStore,
VectorStoreCollection,
VectorStoreCollectionDefinition,
_get_collection_name_from_model,
)
from semantic_kernel.exceptions.vector_store_exceptions import (
VectorStoreInitializationException,
VectorStoreModelException,
VectorStoreModelValidationError,
VectorStoreOperationException,
)
from semantic_kernel.utils.feature_stage_decorator import release_candidate
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger = logging.getLogger(__name__)
TKey = TypeVar("TKey", bound=str)
DISTANCE_FUNCTION_MAP: Final[dict[DistanceFunction, Space]] = {
DistanceFunction.COSINE_SIMILARITY: "cosine",
DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE: "l2",
DistanceFunction.DOT_PROD: "ip",
DistanceFunction.DEFAULT: "l2",
}
INDEX_KIND_MAP: Final[dict[IndexKind, str]] = {
IndexKind.HNSW: "hnsw",
IndexKind.DEFAULT: "hnsw",
}
@release_candidate
class ChromaCollection(
VectorStoreCollection[TKey, TModel],
VectorSearch[TKey, TModel],
Generic[TKey, TModel],
):
"""Chroma vector store collection."""
client: ClientAPI
embedding_func: EmbeddingFunction | None = None
supported_key_types: ClassVar[set[str] | None] = {"str"}
supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR}
def __init__(
self,
record_type: type[object],
definition: VectorStoreCollectionDefinition | None = None,
collection_name: str | None = None,
persist_directory: str | None = None,
client_settings: "Settings | None" = None,
client: "ClientAPI | None" = None,
embedding_generator: EmbeddingGeneratorBase | None = None,
embedding_func: EmbeddingFunction | None = None,
**kwargs: Any,
):
"""Initialize the Chroma vector store collection.
Args:
record_type: The type of the data model.
definition: The definition of the data model.
collection_name: The name of the collection.
persist_directory: The directory to persist the collection.
client_settings: The settings for the Chroma client.
client: The Chroma client.
embedding_generator: The embedding generator to use.
This is the Semantic Kernel embedding generator that will be used to generate the embeddings.
embedding_func: The embedding function to use.
This is a Chroma specific function that will be used to generate the embeddings.
kwargs: Additional arguments to pass to the parent class.
"""
if not collection_name:
collection_name = _get_collection_name_from_model(record_type, definition)
managed_client = not client
if client is None:
settings = client_settings or Settings()
if persist_directory is not None:
settings.is_persistent = True
settings.persist_directory = persist_directory
client = Client(settings)
super().__init__(
collection_name=collection_name,
record_type=record_type,
definition=definition,
client=client,
managed_client=managed_client,
embedding_func=embedding_func,
embedding_generator=embedding_generator,
**kwargs,
)
def _get_collection(self) -> Collection:
try:
return self.client.get_collection(name=self.collection_name, embedding_function=self.embedding_func)
except Exception as e:
raise RuntimeError(f"Failed to get collection {self.collection_name}") from e
@override
async def collection_exists(self, **kwargs: Any) -> bool:
"""Check if the collection exists."""
try:
self.client.get_collection(name=self.collection_name, embedding_function=self.embedding_func)
return True
except Exception:
return False
@override
async def ensure_collection_exists(self, **kwargs: Any) -> None:
"""Create the collection.
Will create a metadata object with the hnsw arguments.
By default only the distance function will be set based on the data model.
To tweak the other hnsw parameters, pass them in the kwargs.
For example:
```python
await collection.create_collection(
configuration={"hnsw": {"max_neighbors": 16, "ef_construction": 200, "ef_search": 200}}
)
```
if the `space` is set, it will be overridden, by the distance function set in the data model.
To use the built-in Chroma embedding functions, set the `embedding_func` parameter in the class constructor.
Args:
kwargs: Additional arguments are passed to the metadata parameter of the create_collection method.
See the Chroma documentation for more details.
"""
if self.definition.vector_fields:
configuration = kwargs.pop("configuration", {})
configuration = CreateCollectionConfiguration(**configuration)
vector_field = self.definition.vector_fields[0]
if vector_field.index_kind not in INDEX_KIND_MAP:
raise VectorStoreInitializationException(f"Index kind {vector_field.index_kind} is not supported.")
if vector_field.distance_function not in DISTANCE_FUNCTION_MAP:
raise VectorStoreInitializationException(
f"Distance function {vector_field.distance_function} is not supported."
)
if "hnsw" not in configuration or configuration["hnsw"] is None:
configuration["hnsw"] = CreateHNSWConfiguration(
space=DISTANCE_FUNCTION_MAP[vector_field.distance_function]
)
else:
configuration["hnsw"]["space"] = DISTANCE_FUNCTION_MAP[vector_field.distance_function]
kwargs["configuration"] = configuration
if "get_or_create" not in kwargs:
kwargs["get_or_create"] = True
self.client.create_collection(name=self.collection_name, embedding_function=self.embedding_func, **kwargs)
@override
async def ensure_collection_deleted(self, **kwargs: Any) -> None:
"""Delete the collection."""
try:
self.client.delete_collection(name=self.collection_name)
except ValueError:
logger.info(f"Collection {self.collection_name} could not be deleted because it doesn't exist.")
except Exception as e:
raise VectorStoreOperationException(
f"Failed to delete collection {self.collection_name} with error: {e}"
) from e
def _validate_data_model(self):
super()._validate_data_model()
if len(self.definition.vector_fields) > 1:
raise VectorStoreModelValidationError(
f"Chroma only supports one vector field, but {len(self.definition.vector_fields)} were provided."
)
@override
def _serialize_dicts_to_store_models(self, records: Sequence[dict[str, Any]], **kwargs: Any) -> Sequence[Any]:
vector_field = self.definition.vector_fields[0]
id_field_name = self.definition.key_name
store_models = []
for record in records:
store_model = {
"id": record[id_field_name],
"metadata": {
k: v
for k, v in record.items()
if k not in [id_field_name, vector_field.storage_name or vector_field.name]
},
}
if self.embedding_func:
store_model["document"] = (record[vector_field.storage_name or vector_field.name],)
else:
store_model["embedding"] = record[vector_field.storage_name or vector_field.name]
if store_model["metadata"] == {}:
store_model.pop("metadata")
store_models.append(store_model)
return store_models
@override
def _deserialize_store_models_to_dicts(self, records: Sequence[Any], **kwargs: Any) -> Sequence[dict[str, Any]]:
vector_field = self.definition.vector_fields[0]
# replace back the name of the vector, content and id fields
for record in records:
record[self.definition.key_name] = record.pop("id")
record[vector_field.name] = record.pop("document", None) or record.pop("embedding", None)
return records
@override
async def _inner_upsert(
self,
records: Sequence[Any],
**kwargs: Any,
) -> Sequence[TKey]:
upsert_obj: dict[str, Any] = {"ids": [], "metadatas": []}
if self.embedding_func:
upsert_obj["documents"] = []
else:
upsert_obj["embeddings"] = []
for record in records:
upsert_obj["ids"].append(record["id"])
if "embedding" in record:
upsert_obj["embeddings"].append(record["embedding"])
if "document" in record:
upsert_obj["documents"].append(record["document"])
if "metadata" in record:
upsert_obj["metadatas"].append(record["metadata"])
if not upsert_obj["metadatas"]:
upsert_obj.pop("metadatas")
self._get_collection().add(**upsert_obj)
return upsert_obj["ids"]
@override
async def _inner_get(
self,
keys: Sequence[str] | None = None,
options: GetFilteredRecordOptions | None = None,
**kwargs: Any,
) -> Sequence[Any] | None:
include_vectors = kwargs.get("include_vectors", True)
if self.embedding_func:
include = ["documents", "metadatas"]
elif include_vectors:
include = ["embeddings", "metadatas"]
else:
include = ["metadatas"]
args: dict[str, Any] = {"include": include}
if keys:
args["ids"] = keys
if options:
args["limit"] = options.top
args["offset"] = options.skip
results = self._get_collection().get(**args)
return self._unpack_results(results, include_vectors)
def _unpack_results(
self, results: QueryResult | GetResult, include_vectors: bool, include_distances: bool = False
) -> Sequence[dict[str, Any]]:
try:
if isinstance(results["ids"][0], str):
for k, v in results.items():
results[k] = [v] # type: ignore
except IndexError:
return []
records: MutableSequence[dict[str, Any]] = []
# Determine available fields
ids = results["ids"][0] if "ids" in results else []
metadatas = results.get("metadatas")
documents = results.get("documents")
embeddings = results.get("embeddings")
distances = results.get("distances")
# Build records dynamically based on available fields
for idx, id in enumerate(ids):
record: dict[str, Any] = {"id": id}
# Add vector field if present
if documents is not None and documents[0] is not None and idx < len(documents[0]):
record["document"] = documents[0][idx]
elif embeddings is not None and embeddings[0] is not None and idx < len(embeddings[0]):
record["embedding"] = embeddings[0][idx]
# Add distance if present
if distances is not None and distances[0] is not None and idx < len(distances[0]): # type: ignore
record["distance"] = distances[0][idx] # type: ignore
# Add metadata if present
if metadatas is not None and metadatas[0] is not None and idx < len(metadatas[0]):
metadata = metadatas[0] if isinstance(metadatas[0], dict) else metadatas[0][idx] # type: ignore
if metadata and isinstance(metadata, dict):
record.update(metadata)
records.append(record)
return records
@override
async def _inner_delete(self, keys: Sequence[TKey], **kwargs: Any) -> None:
self._get_collection().delete(ids=keys) # type: ignore
@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]]:
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."
)
include = ["metadatas", "distances"]
if options.include_vectors:
include.append("documents" if self.embedding_func else "embeddings")
args: dict[str, Any] = {
"n_results": options.top,
"include": include,
}
if filter := self._build_filter(options.filter): # type: ignore
args["where"] = filter if isinstance(filter, dict) else {"$and": filter}
if self.embedding_func:
args["query_texts"] = values
elif vector is not None:
args["query_embeddings"] = vector
else:
args["query_embeddings"] = await self._generate_vector_from_values(values, options)
results = self._get_collection().query(**args)
records = self._unpack_results(results, options.include_vectors, include_distances=True)
return KernelSearchResults(
results=self._get_vector_search_results_from_results(records), total_count=len(records) if records else 0
)
@override
def _get_record_from_result(self, result: Any) -> Any:
return result
@override
def _get_score_from_result(self, result: Any) -> float | None:
return result["distance"]
@override
def _lambda_parser(self, node: ast.AST) -> dict[str, Any] | str | int | float | bool | None: # type: ignore
# Comparison operations
match node:
case ast.Compare():
if len(node.ops) > 1:
# Chain comparisons (e.g., 1 < x < 3) become $and of each comparison
values = []
for idx in range(len(node.ops)):
left = node.left if idx == 0 else node.comparators[idx - 1]
right = node.comparators[idx]
op = node.ops[idx]
values.append(self._lambda_parser(ast.Compare(left=left, ops=[op], comparators=[right])))
return {"$and": values}
left = self._lambda_parser(node.left) # type: ignore
right = self._lambda_parser(node.comparators[0]) # type: ignore
op = node.ops[0]
match op:
case ast.In():
return {left: {"$in": right}} # type: ignore
case ast.NotIn():
return {left: {"$nin": right}} # type: ignore
case ast.Eq():
# Chroma allows short form: {field: value}
return {left: right} # type: ignore
case ast.NotEq():
return {left: {"$ne": right}} # type: ignore
case ast.Gt():
return {left: {"$gt": right}} # type: ignore
case ast.GtE():
return {left: {"$gte": right}} # type: ignore
case ast.Lt():
return {left: {"$lt": right}} # type: ignore
case ast.LtE():
return {left: {"$lte": right}} # type: ignore
raise NotImplementedError(f"Unsupported operator: {type(op)}")
case ast.BoolOp():
op = node.op # type: ignore
values = [self._lambda_parser(v) for v in node.values]
if isinstance(op, ast.And):
return {"$and": values}
if isinstance(op, ast.Or):
return {"$or": values}
raise NotImplementedError(f"Unsupported BoolOp: {type(op)}")
case ast.UnaryOp():
raise NotImplementedError("Unary +, -, ~ and ! are not supported in Chroma filters.")
case ast.Attribute():
# Only allow attributes that are in the data model
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 node.attr
case ast.Name():
# Only allow names that are in the data model
if node.id not in self.definition.storage_names:
raise VectorStoreOperationException(
f"Field '{node.id}' not in data model (storage property names are used)."
)
return node.id
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, (int, float, bool)) or value is None:
return value
raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}")
raise NotImplementedError(f"Unsupported AST node: {type(node)}")
@release_candidate
class ChromaStore(VectorStore):
"""Chroma vector store."""
client: ClientAPI
def __init__(
self,
persist_directory: str | None = None,
client_settings: "Settings | None" = None,
client: ClientAPI | None = None,
embedding_generator: EmbeddingGeneratorBase | None = None,
**kwargs: Any,
):
"""Initialize the Chroma vector store."""
managed_client = not client
settings = client_settings or Settings()
if persist_directory is not None:
settings.is_persistent = True
settings.persist_directory = persist_directory
if client is None:
client = Client(settings)
super().__init__(
client=client, managed_client=managed_client, embedding_generator=embedding_generator, **kwargs
)
@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,
) -> ChromaCollection:
"""Get a vector record store."""
return ChromaCollection(
client=self.client,
collection_name=collection_name,
record_type=record_type,
definition=definition,
embedding_generator=embedding_generator or self.embedding_generator,
**kwargs,
)
@override
async def list_collection_names(self, **kwargs) -> Sequence[str]:
return [coll.name for coll in self.client.list_collections()]