diff --git a/.gitignore b/.gitignore index 8092b3e..c7a08b5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ obj *.user .azure appsettings.*.json +testsettings.*.json *.orig test-results [Tt]est[Rr]esults diff --git a/CommunityToolkit.AI.slnx b/CommunityToolkit.AI.slnx index acb7bee..35e2b7c 100644 --- a/CommunityToolkit.AI.slnx +++ b/CommunityToolkit.AI.slnx @@ -11,6 +11,7 @@ + @@ -27,6 +28,8 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 4c5f90e..442478e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/MEVD/MEVD.slnf b/MEVD/MEVD.slnf index 2b48cd6..80d205a 100644 --- a/MEVD/MEVD.slnf +++ b/MEVD/MEVD.slnf @@ -6,6 +6,7 @@ "MEVD/src/AzureAISearch/AzureAISearch.csproj", "MEVD/src/AzureCosmosDB/AzureCosmosDB.csproj", "MEVD/src/AzureDocumentDB/AzureDocumentDB.csproj", + "MEVD/src/Chroma/Chroma.csproj", "MEVD/src/InMemory/InMemory.csproj", "MEVD/src/PgVector/PgVector.csproj", "MEVD/src/Qdrant/Qdrant.csproj", @@ -20,6 +21,8 @@ "MEVD/test/AzureCosmosDB.ConformanceTests/AzureCosmosDB.ConformanceTests.csproj", "MEVD/test/AzureDocumentDB.UnitTests/AzureDocumentDB.UnitTests.csproj", "MEVD/test/AzureDocumentDB.ConformanceTests/AzureDocumentDB.ConformanceTests.csproj", + "MEVD/test/Chroma.UnitTests/Chroma.UnitTests.csproj", + "MEVD/test/Chroma.ConformanceTests/Chroma.ConformanceTests.csproj", "MEVD/test/InMemory.UnitTests/InMemory.UnitTests.csproj", "MEVD/test/InMemory.ConformanceTests/InMemory.ConformanceTests.csproj", "MEVD/test/PgVector.UnitTests/PgVector.UnitTests.csproj", diff --git a/MEVD/src/Chroma/AssemblyInfo.cs b/MEVD/src/Chroma/AssemblyInfo.cs new file mode 100644 index 0000000..d6395e8 --- /dev/null +++ b/MEVD/src/Chroma/AssemblyInfo.cs @@ -0,0 +1,2 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. diff --git a/MEVD/src/Chroma/Chroma.csproj b/MEVD/src/Chroma/Chroma.csproj new file mode 100644 index 0000000..3a155c3 --- /dev/null +++ b/MEVD/src/Chroma/Chroma.csproj @@ -0,0 +1,26 @@ + + + + 1.0.0-preview + CommunityToolkit.VectorData.Chroma + $(AssemblyName) + net10.0;net8.0;netstandard2.0;net462 + true + + Chroma provider for Microsoft.Extensions.VectorData + Chroma provider for Microsoft.Extensions.VectorData by the .NET Community Toolkit + + + + + + + + + + + + + + + diff --git a/MEVD/src/Chroma/ChromaCollection.cs b/MEVD/src/Chroma/ChromaCollection.cs new file mode 100644 index 0000000..2e19dbe --- /dev/null +++ b/MEVD/src/Chroma/ChromaCollection.cs @@ -0,0 +1,534 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using ChromaDB.Client; +using ChromaDB.Client.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData.ProviderServices; +using Microsoft.Shared.Diagnostics; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Service for storing and retrieving vector records, that uses Chroma as the underlying storage. +/// +/// The data type of the record key. Can be either or . +/// The data model to use for adding, updating and retrieving data from storage. +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public class ChromaCollection : VectorStoreCollection, IKeywordHybridSearchable + where TKey : notnull + where TRecord : class +#pragma warning restore CA1711 // Identifiers should not have incorrect suffix +{ + /// Metadata about vector store record collection. + private readonly VectorStoreCollectionMetadata _collectionMetadata; + + /// The default options for vector search. + private static readonly VectorSearchOptions s_defaultVectorSearchOptions = new(); + + /// The default options for hybrid search. + private static readonly HybridSearchOptions s_defaultHybridSearchOptions = new(); + + /// The name of the upsert operation for telemetry purposes. + private const string UpsertName = "Upsert"; + + /// The name of the Delete operation for telemetry purposes. + private const string DeleteName = "Delete"; + + /// Chroma client that can be used to manage the collections and records in a Chroma store. + private readonly SharedChromaClient _chromaClient; + + /// Chroma client of the records of the collection. + private readonly ChromaCollectionClient _records; + + /// The model for this collection. + private readonly CollectionModel _model; + + /// A mapper to use for converting between Chroma records and consumer models. + private readonly ChromaMapper _mapper; + + /// The properties to create a BM25 index for when the collection is created. + private readonly List _bm25Properties; + + /// Whether the collection was disposed: it releases its share of the client only once. + private int _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Chroma client that can be used to manage the collections and records in a Chroma store. + /// The name of the collection that this will access. + /// A value indicating whether is disposed when the collection is disposed. + /// Optional configuration options for this class. + /// Thrown if the is null. + /// Thrown for any misconfigured options. + [RequiresDynamicCode("This constructor is incompatible with NativeAOT. For dynamic mapping via Dictionary, instantiate ChromaDynamicCollection instead.")] + [RequiresUnreferencedCode("This constructor is incompatible with trimming. For dynamic mapping via Dictionary, instantiate ChromaDynamicCollection instead")] + public ChromaCollection(ChromaClient chromaClient, string name, bool ownsClient, ChromaCollectionOptions? options = null) + : this(() => new SharedChromaClient(chromaClient, ownsClient), name, options) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Chroma client factory. + /// The name of the collection that this will access. + /// Optional configuration options for this class. + /// Thrown if the is null. + /// Thrown for any misconfigured options. + [RequiresDynamicCode("This constructor is incompatible with NativeAOT. For dynamic mapping via Dictionary, instantiate ChromaDynamicCollection instead.")] + [RequiresUnreferencedCode("This constructor is incompatible with trimming. For dynamic mapping via Dictionary, instantiate ChromaDynamicCollection instead")] + internal ChromaCollection(Func clientFactory, string name, ChromaCollectionOptions? options = null) + : this( + clientFactory, + name, + static options => typeof(TRecord) == typeof(Dictionary) + ? throw new NotSupportedException(VectorDataStrings.NonDynamicCollectionWithDictionaryNotSupported(typeof(ChromaDynamicCollection))) + : new ChromaModelBuilder().Build(typeof(TRecord), typeof(TKey), options.Definition, options.EmbeddingGenerator), + options) + { + } + + internal ChromaCollection(Func clientFactory, string name, Func modelFactory, ChromaCollectionOptions? options) + { + // Verify. + Throw.IfNull(clientFactory); + Throw.IfNullOrWhitespace(name); + + if (typeof(TKey) != typeof(string) && typeof(TKey) != typeof(Guid) && typeof(TKey) != typeof(object)) + { + throw new NotSupportedException("Only string and Guid keys are supported."); + } + + options ??= ChromaCollectionOptions.Default; + + // Assign. + Name = name; + _model = modelFactory(options); + _mapper = new ChromaMapper(_model); + _bm25Properties = ChromaCollectionCreateMapping.GetBm25Properties(_model); + + // The code above can throw, so we need to create the client after the model is built and verified. + // In case an exception is thrown, we don't need to dispose any resources. + _chromaClient = clientFactory(); + + var records = _chromaClient.Client.GetCollectionClient(name).WithMetadataValues(ChromaMetadataValues.Exact); + _records = _mapper.DocumentProperty is { } documentProperty ? records.WithDocumentCopyKey(documentProperty.StorageName) : records; + + _collectionMetadata = new() + { + VectorStoreSystemName = ChromaConstants.VectorStoreSystemName, + VectorStoreName = _chromaClient.DatabaseName, + CollectionName = name + }; + } + + /// + protected override void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + _chromaClient.Dispose(); + } + + base.Dispose(disposing); + } + + /// + public override string Name { get; } + + /// + public override Task CollectionExistsAsync(CancellationToken cancellationToken = default) + => RunOperationAsync( + "CollectionExists", + () => _chromaClient.Client.CollectionExistsAsync(Name, cancellationToken: cancellationToken)); + + /// + public override Task EnsureCollectionExistsAsync(CancellationToken cancellationToken = default) + { + // Chroma indexes every metadata field for filtering, so IsIndexed has no effect. + var definition = ChromaCollectionCreateMapping.MapCollectionDefinition(Name, _model.VectorProperty, _bm25Properties, _mapper.DocumentProperty); + + return RunOperationAsync( + "EnsureCollectionExists", + () => _chromaClient.Client.GetOrCreateCollectionAsync(definition, cancellationToken: cancellationToken)); + } + + /// + public override Task EnsureCollectionDeletedAsync(CancellationToken cancellationToken = default) + => RunOperationAsync( + "DeleteCollection", + // Chroma 1.5 gives the lists of the records of a deleted collection to the records of other collections. + () => _chromaClient.Client.DeleteCollectionIfExistsAsync(Name, deleteRecordsFirst: true, cancellationToken: cancellationToken)); + + /// + public override async Task GetAsync(TKey key, RecordRetrievalOptions? options = null, CancellationToken cancellationToken = default) + { + Throw.IfNull(key); + + var records = await GetAsync([key], options, cancellationToken).ToListAsync(cancellationToken).ConfigureAwait(false); + return records.FirstOrDefault(); + } + + /// + public override async IAsyncEnumerable GetAsync( + IEnumerable keys, + RecordRetrievalOptions? options = default, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + const string OperationName = "Get"; + + Throw.IfNull(keys); + + var includeVectors = options?.IncludeVectors ?? false; + if (includeVectors && _model.EmbeddingGenerationRequired) + { + throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); + } + + var ids = keys.Select(key => ChromaFieldMapping.ToId(key)).ToList(); + if (ids.Count == 0) + { + yield break; + } + + var entries = await RunOperationAsync( + OperationName, + () => _records.GetAsync(ids, include: GetInclude(includeVectors), cancellationToken: cancellationToken)).ConfigureAwait(false); + + foreach (var entry in entries) + { + yield return _mapper.MapFromStorageToDataModel(entry.Id, entry.Embedding, entry.Metadata, entry.Document, includeVectors); + } + } + + /// + public override Task DeleteAsync(TKey key, CancellationToken cancellationToken = default) + { + Throw.IfNull(key); + + return DeleteAsync([key], cancellationToken); + } + + /// + public override Task DeleteAsync(IEnumerable keys, CancellationToken cancellationToken = default) + { + Throw.IfNull(keys); + + var ids = keys.Select(key => ChromaFieldMapping.ToId(key)).ToList(); + if (ids.Count == 0) + { + return Task.CompletedTask; + } + + return RunOperationAsync( + DeleteName, + () => _records.DeleteAsync(ids, cancellationToken: cancellationToken)); + } + + /// + public override async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default) + { + Throw.IfNull(record); + + await UpsertAsync([record], cancellationToken).ConfigureAwait(false); + } + + /// + public override async Task UpsertAsync(IEnumerable records, CancellationToken cancellationToken = default) + { + Throw.IfNull(records); + + GeneratedEmbeddings>?[]? generatedEmbeddings = null; + + var vectorProperty = _model.VectorProperty; + if (!ChromaModelBuilder.IsVectorPropertyTypeValidCore(vectorProperty.Type, out _)) + { + // The vector property's type isn't natively supported - we need to generate embeddings. + Debug.Assert(vectorProperty.EmbeddingGenerator is not null); + + var recordsList = records is IReadOnlyList r ? r : records.ToList(); + if (recordsList.Count == 0) + { + return; + } + + records = recordsList; + generatedEmbeddings = [(GeneratedEmbeddings>)await vectorProperty.GenerateEmbeddingsAsync(records.Select(r => vectorProperty.GetValueAsObject(r)), cancellationToken).ConfigureAwait(false)]; + } + + // Create the Chroma records. + var keyProperty = _model.KeyProperty; + var ids = new List(); + var embeddings = new List>(); + var metadatas = new List?>(); + var documents = new List(); + var recordIndex = 0; + foreach (var record in records) + { + if (keyProperty.IsAutoGenerated && keyProperty.GetValue(record) == Guid.Empty) + { + keyProperty.SetValue(record, Guid.NewGuid()); + } + + var storageRecord = _mapper.MapFromDataToStorageModel(record, recordIndex++, generatedEmbeddings); + ids.Add(storageRecord.Id); + embeddings.Add(storageRecord.Embedding); + metadatas.Add(storageRecord.Metadata); + documents.Add(storageRecord.Document); + } + + if (ids.Count == 0) + { + return; + } + + var chromaRecords = new ChromaRecords(ids) + { + Embeddings = embeddings, + Metadatas = metadatas, + Documents = _mapper.HasDocument ? documents : null, + NullDocumentsDelete = true, + }; + + await RunOperationAsync( + UpsertName, + () => _records.UpsertAsync(chromaRecords, cancellationToken)).ConfigureAwait(false); + } + + #region Search + + /// + public override async IAsyncEnumerable> SearchAsync( + TInput searchValue, + int top, + VectorSearchOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(searchValue); + Throw.IfLessThan(top, 1); + + options ??= s_defaultVectorSearchOptions; + if (options.IncludeVectors && _model.EmbeddingGenerationRequired) + { + throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); + } + + var vectorProperty = _model.GetVectorPropertyOrSingle(options); + + var filter = options.Filter is not null + ? new ChromaFilterTranslator().Translate(options.Filter, _model, _mapper.DocumentProperty) + : ChromaWhereOperator.All; + + var vector = await GetSearchVectorAsync(searchValue, vectorProperty, cancellationToken).ConfigureAwait(false); + + var include = ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances; + if (_mapper.HasDocument) + { + include |= ChromaQueryInclude.Documents; + } + if (options.IncludeVectors) + { + include |= ChromaQueryInclude.Embeddings; + } + + // The scores come from the distances in the space of the vector property. + var query = new ChromaQuery([vector]) + { + NResults = top, + Offset = options.Skip, + Where = filter, + Include = include, + ExpectedSpace = ChromaCollectionCreateMapping.GetSpace(vectorProperty), + }; + var entries = await RunOperationAsync( + "Query", + () => _records.QueryAsync(query, cancellationToken)).ConfigureAwait(false); + + foreach (var entry in entries[0]) + { + var score = ChromaCollectionSearchMapping.ToScore(entry.Distance!.Value, vectorProperty.DistanceFunction); + if (!ChromaCollectionSearchMapping.PassesThreshold(score, options.ScoreThreshold, vectorProperty.DistanceFunction)) + { + continue; + } + + yield return new VectorSearchResult( + _mapper.MapFromStorageToDataModel(entry.Id, entry.Embedding, entry.Metadata, entry.Document, options.IncludeVectors), + score); + } + } + + /// + public async IAsyncEnumerable> HybridSearchAsync( + TInput searchValue, + ICollection keywords, + int top, + HybridSearchOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TInput : notnull + { + Throw.IfNull(searchValue); + Throw.IfNull(keywords); + Throw.IfLessThan(top, 1); + + options ??= s_defaultHybridSearchOptions; + if (options.IncludeVectors && _model.EmbeddingGenerationRequired) + { + throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); + } + + var vectorProperty = _model.GetVectorPropertyOrSingle(new() { VectorProperty = options.VectorProperty }); + var textProperty = _model.GetFullTextDataPropertyOrSingle(options.AdditionalProperty); + + var filter = options.Filter is not null + ? new ChromaFilterTranslator().Translate(options.Filter, _model, _mapper.DocumentProperty) + : ChromaWhereOperator.All; + + var vector = await GetSearchVectorAsync(searchValue, vectorProperty, cancellationToken).ConfigureAwait(false); + + List select = [ChromaSearchKeys.Metadata, ChromaSearchKeys.Score]; + if (_mapper.HasDocument) + { + select.Add(ChromaSearchKeys.Document); + } + if (options.IncludeVectors) + { + select.Add(ChromaSearchKeys.Embedding); + } + + var entries = await RunOperationAsync( + "Search", + async () => + { + var index = await _records.FindBm25IndexAsync(textProperty.StorageName, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"The Chroma collection '{Name}' has no BM25 index on the text of the property '{textProperty.ModelName}', which hybrid search needs. " + + "Only Chroma Cloud has BM25 indexes."); + return await _records.SearchAsync( + new ChromaSearch + { + Where = filter, + Rank = ChromaRank.HybridRrf(vector, string.Join(" ", keywords), index.Key, top + options.Skip), + Limit = top, + Offset = options.Skip, + Select = select, + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + + foreach (var entry in entries) + { + var score = (double)entry.Score!.Value; + if (options.ScoreThreshold is { } threshold && score < threshold) + { + continue; + } + + yield return new VectorSearchResult( + _mapper.MapFromStorageToDataModel(entry.Id, entry.Embedding, entry.Metadata, entry.Document, options.IncludeVectors), + score); + } + } + + private static async ValueTask> GetSearchVectorAsync(TInput searchValue, VectorPropertyModel vectorProperty, CancellationToken cancellationToken) + where TInput : notnull + => searchValue switch + { + float[] array => array, + ReadOnlyMemory r => r, + Embedding e => e.Vector, + _ when vectorProperty.EmbeddingGenerationDispatcher is not null + => ((Embedding)await vectorProperty.GenerateEmbeddingAsync(searchValue, cancellationToken).ConfigureAwait(false)).Vector, + + _ => vectorProperty.EmbeddingGenerator is null + ? throw new NotSupportedException(VectorDataStrings.InvalidSearchInputAndNoEmbeddingGeneratorWasConfigured(searchValue.GetType(), ChromaModelBuilder.SupportedVectorTypes)) + : throw new InvalidOperationException(VectorDataStrings.IncompatibleEmbeddingGeneratorWasConfiguredForInputType(typeof(TInput), vectorProperty.EmbeddingGenerator.GetType())) + }; + + #endregion Search + + /// + public override async IAsyncEnumerable GetAsync(Expression> filter, int top, + FilteredRecordRetrievalOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(filter); + Throw.IfLessThan(top, 1); + + options ??= new(); + + if (options.IncludeVectors && _model.EmbeddingGenerationRequired) + { + throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration); + } + + if (options.OrderBy?.Invoke(new()).Values is { Count: > 0 }) + { + throw new NotSupportedException("Chroma does not support ordering."); + } + + var chromaFilter = new ChromaFilterTranslator().Translate(filter, _model, _mapper.DocumentProperty); + + var entries = await RunOperationAsync( + "Get", + () => _records.GetAsync( + ids: null, + chromaFilter, + whereDocument: null, + top, + options.Skip, + GetInclude(options.IncludeVectors), + cancellationToken)).ConfigureAwait(false); + + foreach (var entry in entries) + { + yield return _mapper.MapFromStorageToDataModel(entry.Id, entry.Embedding, entry.Metadata, entry.Document, options.IncludeVectors); + } + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(VectorStoreCollectionMetadata) ? _collectionMetadata : + serviceType == typeof(ChromaClient) ? _chromaClient.Client : + // Hybrid search needs the BM25 indexes, which the client creates only on Chroma Cloud. + serviceType == typeof(IKeywordHybridSearchable) ? (_bm25Properties.Count > 0 && _chromaClient.Client.Options.IsChromaCloud ? this : null) : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + private ChromaGetInclude GetInclude(bool includeVectors) + => ChromaGetInclude.Metadatas + | (includeVectors ? ChromaGetInclude.Embeddings : 0) + | (_mapper.HasDocument ? ChromaGetInclude.Documents : 0); + + /// + /// Run the given operation and wrap any with . + /// + /// The type of database operation being run. + /// The operation to run. + /// The result of the operation. + private Task RunOperationAsync(string operationName, Func operation) + => VectorStoreErrorHandler.RunOperationAsync(_collectionMetadata, operationName, operation); + + /// + /// Run the given operation and wrap any with . + /// + /// The response type of the operation. + /// The type of database operation being run. + /// The operation to run. + /// The result of the operation. + // Awaited here rather than returned: otherwise the NativeAOT compiler of .NET 10 fails on the state machine of the handler (dotnet/runtime#120847). + private async Task RunOperationAsync(string operationName, Func> operation) + => await VectorStoreErrorHandler.RunOperationAsync(_collectionMetadata, operationName, operation).ConfigureAwait(false); +} diff --git a/MEVD/src/Chroma/ChromaCollectionCreateMapping.cs b/MEVD/src/Chroma/ChromaCollectionCreateMapping.cs new file mode 100644 index 0000000..74c229b --- /dev/null +++ b/MEVD/src/Chroma/ChromaCollectionCreateMapping.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ChromaDB.Client; +using ChromaDB.Client.Models; +using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData.ProviderServices; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Contains mapping helpers to use when creating a Chroma collection. +/// +internal static class ChromaCollectionCreateMapping +{ + /// The distance functions Chroma supports, with its three spaces. + private const string SupportedDistanceFunctions = + $"{DistanceFunction.CosineSimilarity}, {DistanceFunction.CosineDistance}, {DistanceFunction.DotProductSimilarity}, " + + $"{DistanceFunction.NegativeDotProductSimilarity}, {DistanceFunction.EuclideanSquaredDistance}, {DistanceFunction.EuclideanDistance}"; + + /// + /// Maps the vector property and the BM25 indexes to the definition of the Chroma collection. + /// + /// The name of the collection. + /// The vector property. + /// The properties to create a BM25 index for. + /// The property stored as the document, whose BM25 index comes from the document, or . + /// The definition to create the collection with. + public static ChromaCollectionDefinition MapCollectionDefinition(string name, VectorPropertyModel vectorProperty, IReadOnlyList bm25Properties, DataPropertyModel? documentProperty = null) + { + ChromaCollectionSchema? schema = null; + foreach (var property in bm25Properties) + { + schema = (schema ?? new()).WithBm25Index(property == documentProperty ? ChromaSearchKeys.Document : property.StorageName, ifSupported: true); + } + + return new(name) { Configuration = new() { Space = GetSpace(vectorProperty) }, Schema = schema }; + } + + /// + /// Get the string properties with full-text indexing, the ones a BM25 index can be created for. + /// + /// The model of the collection. + public static List GetBm25Properties(CollectionModel model) + => model.DataProperties.Where(ChromaFieldMapping.IsFullTextString).ToList(); + + /// + /// Get the Chroma distance function, called space, for the given . + /// If none is configured, the default is cosine. + /// + /// The vector property definition. + /// The Chroma space. + /// Thrown if a distance function is chosen that Chroma does not support. + public static ChromaSpace GetSpace(VectorPropertyModel vectorProperty) + => vectorProperty.DistanceFunction switch + { + DistanceFunction.CosineSimilarity or DistanceFunction.CosineDistance or null => ChromaSpace.Cosine, + DistanceFunction.DotProductSimilarity or DistanceFunction.NegativeDotProductSimilarity => ChromaSpace.InnerProduct, + DistanceFunction.EuclideanSquaredDistance or DistanceFunction.EuclideanDistance => ChromaSpace.L2, + + _ => throw new NotSupportedException( + $"Distance function '{vectorProperty.DistanceFunction}' for {nameof(VectorStoreVectorProperty)} '{vectorProperty.ModelName}' is not supported by the Chroma VectorStore. " + + $"Supported distance functions: {SupportedDistanceFunctions}.") + }; +} diff --git a/MEVD/src/Chroma/ChromaCollectionOptions.cs b/MEVD/src/Chroma/ChromaCollectionOptions.cs new file mode 100644 index 0000000..9aadf4c --- /dev/null +++ b/MEVD/src/Chroma/ChromaCollectionOptions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.VectorData; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Options when creating a . +/// +public sealed class ChromaCollectionOptions : VectorStoreCollectionOptions +{ + internal static readonly ChromaCollectionOptions Default = new(); + + /// + /// Initializes a new instance of the class. + /// + public ChromaCollectionOptions() + { + } + + internal ChromaCollectionOptions(ChromaCollectionOptions? source) : base(source) + { + } +} diff --git a/MEVD/src/Chroma/ChromaCollectionSearchMapping.cs b/MEVD/src/Chroma/ChromaCollectionSearchMapping.cs new file mode 100644 index 0000000..56267ca --- /dev/null +++ b/MEVD/src/Chroma/ChromaCollectionSearchMapping.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.VectorData; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Contains mapping helpers to use when searching for records using Chroma. +/// +internal static class ChromaCollectionSearchMapping +{ + /// + /// Convert the distance Chroma returns to the score of the given distance function. + /// + /// + /// Chroma returns 1 - cosine similarity for cosine, 1 - dot product for ip, and the squared Euclidean distance for l2. + /// + /// The distance returned by Chroma. + /// The distance function of the vector property. + /// The score. + public static double ToScore(float distance, string? distanceFunction) + => distanceFunction switch + { + DistanceFunction.CosineSimilarity or null => 1 - distance, + DistanceFunction.CosineDistance => distance, + DistanceFunction.DotProductSimilarity => 1 - distance, + DistanceFunction.NegativeDotProductSimilarity => distance - 1, + DistanceFunction.EuclideanSquaredDistance => distance, + DistanceFunction.EuclideanDistance => Math.Sqrt(distance), + + // The model builder rejects the other distance functions. + _ => throw new UnreachableException($"Distance function '{distanceFunction}' is not supported by the Chroma VectorStore.") + }; + + /// + /// Whether the score passes the threshold: a similarity must reach it, a distance must not go beyond it. + /// + /// The score. + /// The threshold, or for none. + /// The distance function of the vector property. + public static bool PassesThreshold(double score, double? threshold, string? distanceFunction) + => threshold is not { } t + || (distanceFunction is DistanceFunction.CosineSimilarity or DistanceFunction.DotProductSimilarity or null + ? score >= t + : score <= t); +} diff --git a/MEVD/src/Chroma/ChromaConstants.cs b/MEVD/src/Chroma/ChromaConstants.cs new file mode 100644 index 0000000..5c78ec6 --- /dev/null +++ b/MEVD/src/Chroma/ChromaConstants.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.VectorData.Chroma; + +internal static class ChromaConstants +{ + internal const string VectorStoreSystemName = "chroma"; +} diff --git a/MEVD/src/Chroma/ChromaDynamicCollection.cs b/MEVD/src/Chroma/ChromaDynamicCollection.cs new file mode 100644 index 0000000..a4438e3 --- /dev/null +++ b/MEVD/src/Chroma/ChromaDynamicCollection.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ChromaDB.Client; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Represents a collection of vector store records in a Chroma database, mapped to a dynamic Dictionary<string, object?>. +/// +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public sealed class ChromaDynamicCollection : ChromaCollection> +#pragma warning restore CA1711 // Identifiers should not have incorrect suffix +{ + /// + /// Initializes a new instance of the class. + /// + /// Chroma client that can be used to manage the collections and records in a Chroma store. + /// The name of the collection. + /// A value indicating whether is disposed when the collection is disposed. + /// Optional configuration options for this class. + public ChromaDynamicCollection(ChromaClient chromaClient, string name, bool ownsClient, ChromaCollectionOptions options) + : this(() => new SharedChromaClient(chromaClient, ownsClient), name, options) + { + } + + internal ChromaDynamicCollection(Func clientFactory, string name, ChromaCollectionOptions options) + : base( + clientFactory, + name, + static options => new ChromaModelBuilder() + .BuildDynamic( + options.Definition ?? throw new ArgumentException("Definition is required for dynamic collections"), + options.EmbeddingGenerator), + options) + { + } +} diff --git a/MEVD/src/Chroma/ChromaFieldMapping.cs b/MEVD/src/Chroma/ChromaFieldMapping.cs new file mode 100644 index 0000000..bc6bc1f --- /dev/null +++ b/MEVD/src/Chroma/ChromaFieldMapping.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.VectorData.ProviderServices; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Contains helper methods for mapping the properties of a record to a Chroma record. +/// +internal static class ChromaFieldMapping +{ + /// + /// Get the property stored as the Chroma document: the only full-text indexed string property, or when + /// there is none or more than one. + /// + public static DataPropertyModel? GetDocumentProperty(CollectionModel model) + { + var fullTextProperties = model.DataProperties.Where(IsFullTextString).Take(2).ToList(); + return fullTextProperties.Count == 1 ? fullTextProperties[0] : null; + } + + /// + /// Whether the property is a string property with full-text indexing. + /// + public static bool IsFullTextString(DataPropertyModel property) + => property.IsFullTextIndexed && property.Type == typeof(string); + + /// + /// Convert the given key to a Chroma record id. + /// + public static string ToId(object key) + => key switch + { + null => throw new ArgumentNullException(nameof(key)), + string id => id, + Guid id => id.ToString("D"), + _ => throw new NotSupportedException($"The provided key type '{key.GetType().Name}' is not supported by Chroma.") + }; +} diff --git a/MEVD/src/Chroma/ChromaFilterTranslator.cs b/MEVD/src/Chroma/ChromaFilterTranslator.cs new file mode 100644 index 0000000..09c8190 --- /dev/null +++ b/MEVD/src/Chroma/ChromaFilterTranslator.cs @@ -0,0 +1,342 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using ChromaDB.Client; +using Microsoft.Extensions.VectorData.ProviderServices; +using Microsoft.Extensions.VectorData.ProviderServices.Filter; + +namespace CommunityToolkit.VectorData.Chroma; + +// https://docs.trychroma.com/docs/querying-collections/metadata-filtering +internal sealed class ChromaFilterTranslator : FilterTranslatorBase +{ + private const string TextFilterNotSupported + = "Chroma filters text with Contains only on the single full-text indexed string property, which is stored as the document."; + + private const string KeyFilterNotSupported + = "Chroma filters on the key only with == or Contains over a list of keys."; + + private DataPropertyModel? _documentProperty; + + /// + /// Translate the filter to the where clause of a Chroma request. + /// + /// The filter. + /// The model of the collection. + /// The property stored as the document, or . + internal ChromaWhereOperator Translate(LambdaExpression lambdaExpression, CollectionModel model, DataPropertyModel? documentProperty) + { + _documentProperty = documentProperty; + return Translate(PreprocessFilter(lambdaExpression, model, new FilterPreprocessingOptions())); + } + + private static bool IsStringContains(Expression expression, [NotNullWhen(true)] out Expression? target, [NotNullWhen(true)] out Expression? argument) + { + if (expression is MethodCallExpression { Method.Name: nameof(string.Contains), Object: { } instance, Arguments: [var single] } call + && call.Method.DeclaringType == typeof(string) + && single.Type == typeof(string)) + { + target = instance; + argument = single; + return true; + } + + target = null; + argument = null; + return false; + } + + private bool TryBindKey(Expression expression) + => TryBindProperty(expression, out var property) && property is KeyPropertyModel; + + // The key is the Chroma id of a record, not a metadata field, and the vector is not in the metadata. + private bool TryBindDataProperty(Expression expression, [NotNullWhen(true)] out DataPropertyModel? property) + { + if (!TryBindProperty(expression, out var bound)) + { + property = null; + return false; + } + + property = bound switch + { + DataPropertyModel dataProperty => dataProperty, + KeyPropertyModel => throw new NotSupportedException(KeyFilterNotSupported), + _ => throw new NotSupportedException("Chroma does not filter on vector properties.") + }; + return true; + } + + // negated: whether the node is under an odd number of negations. + private ChromaWhereOperator Translate(Expression node, bool negated = false) + => node switch + { + BinaryExpression { NodeType: ExpressionType.Equal } equal => TranslateEqual(equal.Left, equal.Right), + BinaryExpression { NodeType: ExpressionType.NotEqual } notEqual => ChromaWhereOperator.Not(TranslateEqual(notEqual.Left, notEqual.Right)), + + BinaryExpression { NodeType: ExpressionType.GreaterThan } comparison => TranslateComparison(comparison, greater: true, orEqual: false, negated), + BinaryExpression { NodeType: ExpressionType.GreaterThanOrEqual } comparison => TranslateComparison(comparison, greater: true, orEqual: true, negated), + BinaryExpression { NodeType: ExpressionType.LessThan } comparison => TranslateComparison(comparison, greater: false, orEqual: false, negated), + BinaryExpression { NodeType: ExpressionType.LessThanOrEqual } comparison => TranslateComparison(comparison, greater: false, orEqual: true, negated), + + BinaryExpression { NodeType: ExpressionType.AndAlso } andAlso => Translate(andAlso.Left, negated) & Translate(andAlso.Right, negated), + BinaryExpression { NodeType: ExpressionType.OrElse } orElse => Translate(orElse.Left, negated) | Translate(orElse.Right, negated), + + UnaryExpression { NodeType: ExpressionType.Not } not => ChromaWhereOperator.Not(Translate(not.Operand, !negated)), + + // A bool property as a condition: r => r.Bool. + Expression when node.Type == typeof(bool) && TryBindDataProperty(node, out var property) + => ChromaWhereOperator.Equal(property.StorageName, true), + + // r => true matches every record, which is useful for fetching all records, and r => false none. + ConstantExpression { Value: bool value } => value ? ChromaWhereOperator.All : ChromaWhereOperator.None, + + MethodCallExpression methodCall => TranslateMethodCall(methodCall), + + _ => throw new NotSupportedException("Chroma does not support the following NodeType in filters: " + node.NodeType) + }; + + private ChromaWhereOperator TranslateEqual(Expression left, Expression right) + { + // The key of a record is its Chroma id. + if (TryBindKey(left) && TryGetConstant(right, out var key) || TryBindKey(right) && TryGetConstant(left, out key)) + { + return ChromaWhereOperator.In(ChromaSearchKeys.Id, ToId(key)); + } + + var (property, value) = + TryBindDataProperty(left, out var leftProperty) && TryGetConstant(right, out var rightConstant) + ? (leftProperty, rightConstant) + : TryBindDataProperty(right, out var rightProperty) && TryGetConstant(left, out var leftConstant) + ? (rightProperty, leftConstant) + : throw new NotSupportedException("Invalid equality/comparison"); + + // A property of a value type that is not nullable is never null: == null matches no record, and != null every record, + // with no need for the null values that Chroma metadata does not have. + if (value is null && property.Type.IsValueType && Nullable.GetUnderlyingType(property.Type) is null) + { + return ChromaWhereOperator.None; + } + + return ChromaWhereOperator.Equal(property.StorageName, ToFilterValue(value)); + } + + private ChromaWhereOperator TranslateComparison(BinaryExpression comparison, bool greater, bool orEqual, bool negated) + { + // Normalize to property-on-the-left: 5 < r.Int is r.Int > 5. + DataPropertyModel? property; + object? value; + if (TryBindDataProperty(comparison.Left, out property) && TryGetConstant(comparison.Right, out value)) + { + } + else if (TryBindDataProperty(comparison.Right, out property) && TryGetConstant(comparison.Left, out value)) + { + greater = !greater; + } + else + { + throw new NotSupportedException("Comparison expression not supported by Chroma"); + } + + // A comparison with null is false whatever the value, as in C#: it matches no record. + if (value is null) + { + return ChromaWhereOperator.None; + } + + // In C# the negation is true for a null value, which the negated comparison of Chroma does not match. + if (negated && Nullable.GetUnderlyingType(property.Type) is not null) + { + throw new NotSupportedException( + $"Chroma does not support a negated comparison on the nullable property '{property.ModelName}': it would leave out the records where the property is null."); + } + + if (value is not (int or long or float or double)) + { + throw new NotSupportedException($"Chroma supports comparisons on numbers only, not on '{value.GetType().Name}'."); + } + + return (greater, orEqual) switch + { + (true, false) => ChromaWhereOperator.GreaterThan(property.StorageName, value), + (true, true) => ChromaWhereOperator.GreaterThanOrEqual(property.StorageName, value), + (false, false) => ChromaWhereOperator.LessThan(property.StorageName, value), + (false, true) => ChromaWhereOperator.LessThanOrEqual(property.StorageName, value) + }; + } + + // A constant compared with a nullable property is converted to the nullable type, e.g. r => r.NullableInt == 5, and a captured + // number to the type of the property, e.g. r => r.Double > intVariable, with the implicit conversions of C#. + private static bool TryGetConstant(Expression expression, out object? value) + { + switch (expression) + { + case ConstantExpression constant: + value = constant.Value; + return true; + + case UnaryExpression { NodeType: ExpressionType.Convert, Operand: ConstantExpression constant } convert + when Nullable.GetUnderlyingType(convert.Type) == constant.Type: + value = constant.Value; + return true; + + case UnaryExpression { NodeType: ExpressionType.Convert, Operand: ConstantExpression { Value: { } number } } convert + when Widen(number, Nullable.GetUnderlyingType(convert.Type) ?? convert.Type) is { } widened: + value = widened; + return true; + + default: + value = null; + return false; + } + } + + // The implicit numeric conversions of C# to the numbers Chroma compares; an explicit cast, which can change the value, is not one. + private static object? Widen(object number, Type type) + => type == typeof(double) ? number switch { int i => (double)i, long l => (double)l, float f => (double)f, _ => null } + : type == typeof(float) ? number switch { int i => (float)i, long l => (float)l, _ => null } + : type == typeof(long) ? number switch { int i => (long)i, _ => null } + : null; + + private ChromaWhereOperator TranslateMethodCall(MethodCallExpression methodCall) + => methodCall switch + { + // string.Contains() on the property stored as the document; Chroma has no substring filter on metadata. + _ when IsStringContains(methodCall, out var target, out var argument) => TranslateTextContains(target, argument), + + // Enumerable.Contains(), List.Contains(), MemoryExtensions.Contains() + _ when TryMatchContains(methodCall, out var source, out var item) + => TranslateContains(source, item), + + // Enumerable.Any() with a Contains predicate (r => r.Strings.Any(s => array.Contains(s))) + { Method.Name: nameof(Enumerable.Any), Arguments: [var anySource, LambdaExpression lambda] } any + when any.Method.DeclaringType == typeof(Enumerable) + => TranslateAny(anySource, lambda), + + _ => throw new NotSupportedException($"Unsupported method call: {methodCall.Method.DeclaringType?.Name}.{methodCall.Method.Name}") + }; + + private ChromaWhereOperator TranslateTextContains(Expression target, Expression argument) + { + if (_documentProperty is null || !TryBindProperty(target, out var property) || property != _documentProperty) + { + throw new NotSupportedException(TextFilterNotSupported); + } + + if (!TryGetConstant(argument, out var value) || value is not string text) + { + throw new NotSupportedException("Chroma filters the text of the document with Contains over a constant string."); + } + + return ChromaWhereOperator.Document(ChromaWhereDocumentOperator.Contains(text)); + } + + private ChromaWhereOperator TranslateContains(Expression source, Expression item) + { + // Contains over a list of keys: new[] { "a", "b" }.Contains(r.Key). + if (TryBindKey(item)) + { + if (!TryGetValues(source, out var keys)) + { + throw new NotSupportedException(KeyFilterNotSupported); + } + + return ChromaWhereOperator.In(ChromaSearchKeys.Id, keys.Cast().Select(ToId).Distinct().ToArray()); + } + + // Contains over an array property: r.Strings.Contains("a"). + if (TryBindDataProperty(source, out var property)) + { + if (!TryGetConstant(item, out var value)) + { + throw new NotSupportedException("Chroma supports Contains over an array property only with a constant item."); + } + + return ChromaWhereOperator.Contains(property.StorageName, ToFilterValue(value)); + } + + // Contains over a list of values: new[] { "a", "b" }.Contains(r.String); over no values it matches no record. + if (TryGetValues(source, out var elements)) + { + if (!TryBindDataProperty(item, out var itemProperty)) + { + throw new NotSupportedException("Unsupported item type in Contains"); + } + + return ChromaWhereOperator.In(itemProperty.StorageName, elements.Cast().Select(ToFilterValue).Distinct().ToArray()); + } + + throw new NotSupportedException("Unsupported Contains expression"); + } + + // r.Strings.Any(s => array.Contains(s)) is true when the field contains at least one of the values; over no values it matches no record. + private ChromaWhereOperator TranslateAny(Expression source, LambdaExpression lambda) + { + if (!TryBindDataProperty(source, out var property) + || lambda.Body is not MethodCallExpression containsCall + || !TryMatchContains(containsCall, out var valuesExpression, out var itemExpression) + || itemExpression != lambda.Parameters[0] + || !TryGetValues(valuesExpression, out var values)) + { + throw new NotSupportedException("Unsupported method call: Enumerable.Any"); + } + + return values.Cast() + .Select(ToFilterValue) + .Distinct() + .Aggregate(ChromaWhereOperator.None, (any, value) => any | ChromaWhereOperator.Contains(property.StorageName, value)); + } + + // The values of a list in a filter: an inline array, or a captured list or array, which the preprocessing makes a constant. + private static bool TryGetValues(Expression expression, [NotNullWhen(true)] out IEnumerable? values) + { + values = expression switch + { + NewArrayExpression newArray => GetInlineArrayElements(newArray), + ConstantExpression { Value: IEnumerable enumerable and not string } => enumerable, + _ => null + }; + return values is not null; + } + + // The elements of an inline array: new[] { "a", "b" }, or none for new string[0], whose only expression is its length. + private static object?[] GetInlineArrayElements(NewArrayExpression newArray) + => newArray switch + { + { NodeType: ExpressionType.NewArrayInit } => newArray.Expressions + .Select(element => element is ConstantExpression { Value: var value } + ? value + : throw new NotSupportedException("Inline array elements must be constants")) + .ToArray(), + { NodeType: ExpressionType.NewArrayBounds, Expressions: [ConstantExpression { Value: 0 }] } => [], + _ => throw new NotSupportedException("Unsupported inline array") + }; + + private static string ToId(object? key) + => key is null + ? throw new NotSupportedException("Chroma does not support filtering on a null key.") + : ChromaFieldMapping.ToId(key); + + private static object ToFilterValue(object? value) + { + switch (value) + { + case null: + throw new NotSupportedException("Chroma does not support filtering on null values."); + case IEnumerable and not string: + throw new NotSupportedException("Chroma does not support comparing an array property with an array."); + } + + try + { + return ChromaMetadataConvert.ToMetadataValue(value)!; + } + catch (ArgumentException exception) + { + throw new NotSupportedException($"Chroma does not support filtering on values of type '{value.GetType().Name}'.", exception); + } + } +} diff --git a/MEVD/src/Chroma/ChromaMapper.cs b/MEVD/src/Chroma/ChromaMapper.cs new file mode 100644 index 0000000..7151b43 --- /dev/null +++ b/MEVD/src/Chroma/ChromaMapper.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using ChromaDB.Client; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData.ProviderServices; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// A record as Chroma stores it: an id, an embedding and metadata. +/// +internal readonly record struct ChromaStorageRecord(string Id, ReadOnlyMemory Embedding, IReadOnlyDictionary? Metadata, string? Document); + +/// +/// Mapper between a Chroma record and the consumer data model. +/// +/// The consumer data model to map to or from. +internal sealed class ChromaMapper(CollectionModel model) + where TRecord : class +{ + private readonly DataPropertyModel? _documentProperty = ChromaFieldMapping.GetDocumentProperty(model); + + /// Gets the property stored as the Chroma document: the only string property with full-text indexing, or . + public DataPropertyModel? DocumentProperty => _documentProperty; + + /// Gets a value indicating whether the records have a Chroma document, from the full-text property. + public bool HasDocument => _documentProperty is not null; + + public ChromaStorageRecord MapFromDataToStorageModel(TRecord dataModel, int recordIndex, GeneratedEmbeddings>?[]? generatedEmbeddings) + { + var keyProperty = model.KeyProperty; + var key = keyProperty.GetValueAsObject(dataModel) + ?? throw new InvalidOperationException($"Missing key property '{keyProperty.ModelName}' on provided record of type '{typeof(TRecord).Name}'."); + + // The property stored as the document is the document. + var metadata = ChromaMetadataConvert.ToMetadata(model.DataProperties + .Where(property => property != _documentProperty) + .Select(property => new KeyValuePair(property.StorageName, property.GetValueAsObject(dataModel)))); + + // There is exactly one vector property, as verified by the model builder. + Debug.Assert( + generatedEmbeddings is null || generatedEmbeddings.Length == 1 && generatedEmbeddings[0] is not null, + "There should be exactly one generated embedding, for the single vector property."); + var embedding = GetVector( + model.VectorProperty, + generatedEmbeddings is null + ? model.VectorProperty.GetValueAsObject(dataModel) + : generatedEmbeddings[0]![recordIndex]); + + var document = _documentProperty?.GetValueAsObject(dataModel) as string; + + return new ChromaStorageRecord(ChromaFieldMapping.ToId(key), embedding, metadata, document); + + // The model builder accepts these three vector types only, and the model checks the values of a dynamic record. + static ReadOnlyMemory GetVector(PropertyModel property, object? embedding) + => embedding switch + { + null => throw new InvalidOperationException($"Vector property '{property.ModelName}' on provided record of type '{typeof(TRecord).Name}' may not be null."), + Embedding e => e.Vector, + float[] a => a, + _ => (ReadOnlyMemory)embedding + }; + } + + public TRecord MapFromStorageToDataModel(string id, ReadOnlyMemory? embedding, IReadOnlyDictionary? metadata, string? document, bool includeVectors) + { + var outputRecord = model.CreateRecord()!; + + // The model builder accepts string and Guid keys only. + model.KeyProperty.SetValueAsObject(outputRecord, model.KeyProperty.Type == typeof(Guid) ? Guid.Parse(id) : id); + + if (includeVectors && embedding is { } vector) + { + // The model builder accepts ReadOnlyMemory, Embedding and float[] only. + var property = model.VectorProperty; + var vectorType = Nullable.GetUnderlyingType(property.Type) ?? property.Type; + property.SetValueAsObject( + outputRecord, + vectorType == typeof(Embedding) ? new Embedding(vector) + : vectorType == typeof(float[]) ? vector.ToArray() + : (object)vector); + } + + foreach (var dataProperty in model.DataProperties) + { + if (dataProperty == _documentProperty) + { + dataProperty.SetValueAsObject(outputRecord, document); + } + else if (metadata is not null && metadata.TryGetValue(dataProperty.StorageName, out var value)) + { + object? propertyValue; + try + { + propertyValue = ChromaMetadataConvert.FromMetadataValue(value, dataProperty.Type); + } + // Another Chroma client can write any value under the key of a property: a value of another type, a date that + // does not parse, or a number too large for the property. + catch (InvalidCastException exception) + { + throw ReadFailed(dataProperty, id, exception); + } + + dataProperty.SetValueAsObject(outputRecord, propertyValue); + } + else if (!dataProperty.Type.IsValueType || Nullable.GetUnderlyingType(dataProperty.Type) is not null) + { + // A null value is not stored, since Chroma metadata has no null values; a missing one is null. + dataProperty.SetValueAsObject(outputRecord, null); + } + } + + return outputRecord; + + static InvalidOperationException ReadFailed(DataPropertyModel property, string id, Exception exception) + => new($"Failed to read the metadata key '{property.StorageName}' of the record '{id}' into the property '{property.ModelName}' of type '{property.Type.Name}'.", exception); + } +} diff --git a/MEVD/src/Chroma/ChromaModelBuilder.cs b/MEVD/src/Chroma/ChromaModelBuilder.cs new file mode 100644 index 0000000..bbe4e47 --- /dev/null +++ b/MEVD/src/Chroma/ChromaModelBuilder.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData.ProviderServices; + +namespace CommunityToolkit.VectorData.Chroma; + +internal class ChromaModelBuilder() : CollectionModelBuilder(s_modelBuildingOptions) +{ + internal const string SupportedVectorTypes = "ReadOnlyMemory, Embedding, float[]"; + + // A Chroma record has exactly one embedding. + private static readonly CollectionModelBuildingOptions s_modelBuildingOptions = new() + { + RequiresAtLeastOneVector = true, + SupportsMultipleVectors = false, + }; + + protected override void ValidateKeyProperty(KeyPropertyModel keyProperty) + { + base.ValidateKeyProperty(keyProperty); + + var type = keyProperty.Type; + + if (type != typeof(string) && type != typeof(Guid)) + { + throw new NotSupportedException( + $"Property '{keyProperty.ModelName}' has unsupported type '{type.Name}'. Key properties must be either string or Guid."); + } + + // String and Guid keys are supported by the Chroma provider, but we support auto-generation of keys only for GUIDs + if (keyProperty.IsAutoGenerated && keyProperty.Type != typeof(Guid)) + { + throw new NotSupportedException( + $"Auto-generation is only supported for key properties of type Guid. Property '{keyProperty.ModelName}' has type '{keyProperty.Type.Name}'."); + } + } + + // Checked when the collection object is constructed, rather than when it is first used. + protected override void ValidateProperty(PropertyModel propertyModel, VectorStoreCollectionDefinition? definition) + { + base.ValidateProperty(propertyModel, definition); + + switch (propertyModel) + { + case VectorPropertyModel vectorProperty: + if (vectorProperty.IndexKind is not null and not IndexKind.Hnsw) + { + throw new NotSupportedException( + $"Index kind '{vectorProperty.IndexKind}' for {nameof(VectorStoreVectorProperty)} '{vectorProperty.ModelName}' is not supported by the Chroma VectorStore. " + + $"Supported index kinds: {IndexKind.Hnsw}."); + } + + // Throws for a distance function Chroma does not support. + _ = ChromaCollectionCreateMapping.GetSpace(vectorProperty); + break; + + case DataPropertyModel { IsFullTextIndexed: true } dataProperty when dataProperty.Type != typeof(string): + throw new InvalidOperationException( + $"Property '{dataProperty.ModelName}' has {nameof(VectorStoreDataProperty.IsFullTextIndexed)} set, but is not a string: Chroma indexes the text of string properties only."); + } + } + + protected override bool IsDataPropertyTypeValid(Type type, [NotNullWhen(false)] out string? supportedTypes) + { + supportedTypes = "string, int, long, double, float, bool, DateTime, DateTimeOffset," +#if NET + + " DateOnly," +#endif + + " or arrays/lists of these types"; + + if (Nullable.GetUnderlyingType(type) is Type underlyingType) + { + type = underlyingType; + } + + return IsValid(type) + || (type.IsArray && IsValid(type.GetElementType()!)) + || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>) && IsValid(type.GenericTypeArguments[0])); + + static bool IsValid(Type type) + => type == typeof(string) || + type == typeof(int) || + type == typeof(long) || + type == typeof(double) || + type == typeof(float) || + type == typeof(bool) || + type == typeof(DateTime) || +#if NET + type == typeof(DateOnly) || +#endif + type == typeof(DateTimeOffset); + } + + protected override bool IsVectorPropertyTypeValid(Type type, [NotNullWhen(false)] out string? supportedTypes) + => IsVectorPropertyTypeValidCore(type, out supportedTypes); + + internal static bool IsVectorPropertyTypeValidCore(Type type, [NotNullWhen(false)] out string? supportedTypes) + { + supportedTypes = SupportedVectorTypes; + + return type == typeof(ReadOnlyMemory) + || type == typeof(ReadOnlyMemory?) + || type == typeof(Embedding) + || type == typeof(float[]); + } +} diff --git a/MEVD/src/Chroma/ChromaServiceCollectionExtensions.cs b/MEVD/src/Chroma/ChromaServiceCollectionExtensions.cs new file mode 100644 index 0000000..74992e1 --- /dev/null +++ b/MEVD/src/Chroma/ChromaServiceCollectionExtensions.cs @@ -0,0 +1,276 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using ChromaDB.Client; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods to register and instances on an . +/// +public static class ChromaServiceCollectionExtensions +{ + private const string DynamicCodeMessage = "This method is incompatible with NativeAOT, consult the documentation for adding collections in a way that's compatible with NativeAOT."; + private const string UnreferencedCodeMessage = "This method is incompatible with trimming, consult the documentation for adding collections in a way that's compatible with NativeAOT."; + + /// + /// Registers a as + /// with returned by + /// or retrieved from the dependency injection container if was not provided. + /// + /// + public static IServiceCollection AddChromaVectorStore( + this IServiceCollection services, + Func? clientProvider = default, + Func? optionsProvider = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddKeyedChromaVectorStore(services, serviceKey: null, clientProvider, optionsProvider, lifetime); + + /// + /// Registers a keyed as + /// with returned by + /// or retrieved from the dependency injection container if was not provided. + /// + /// The to register the on. + /// The key with which to associate the vector store. + /// The provider. + /// Options provider to further configure the . + /// The service lifetime for the store. Defaults to . + /// Service collection. + public static IServiceCollection AddKeyedChromaVectorStore( + this IServiceCollection services, + object? serviceKey, + Func? clientProvider = default, + Func? optionsProvider = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + Throw.IfNull(services); + + return AddKeyedChromaVectorStore(services, serviceKey, lifetime, (sp, options) => + { + var client = clientProvider is null ? sp.GetRequiredService() : clientProvider(sp); + + // The client was restored from the DI container, so we do not own it. + return new ChromaVectorStore(client, ownsClient: false, options); + }, optionsProvider); + } + + /// + /// Registers a as + /// that connects to Chroma with . + /// + /// + public static IServiceCollection AddChromaVectorStore( + this IServiceCollection services, + string connectionString, + ChromaVectorStoreOptions? options = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddKeyedChromaVectorStore(services, serviceKey: null, connectionString, options, lifetime); + + /// + /// Registers a keyed as + /// that connects to Chroma with . + /// + /// The to register the on. + /// The key with which to associate the vector store. + /// Chroma connection string: the URI of the server, or Endpoint=...;Token=...;Tenant=...;Database=.... + /// Options to further configure the . + /// The service lifetime for the store. Defaults to . + /// Service collection. + public static IServiceCollection AddKeyedChromaVectorStore( + this IServiceCollection services, + object? serviceKey, + string connectionString, + ChromaVectorStoreOptions? options = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + Throw.IfNull(services); + Throw.IfNullOrWhitespace(connectionString); + + // The vector store creates the client, so it owns it. + var configurationOptions = ChromaConfigurationOptions.FromConnectionString(connectionString); + return AddKeyedChromaVectorStore(services, serviceKey, lifetime, + (_, storeOptions) => new ChromaVectorStore(new ChromaClient(configurationOptions), ownsClient: true, storeOptions), + _ => options!); + } + + private static IServiceCollection AddKeyedChromaVectorStore( + IServiceCollection services, + object? serviceKey, + ServiceLifetime lifetime, + Func storeFactory, + Func? optionsProvider) + { + services.Add(new ServiceDescriptor(typeof(ChromaVectorStore), serviceKey, + (sp, _) => storeFactory(sp, GetStoreOptions(sp, optionsProvider)), lifetime)); + + services.Add(new ServiceDescriptor(typeof(VectorStore), serviceKey, + static (sp, key) => sp.GetRequiredKeyedService(key), lifetime)); + + return services; + } + + /// + /// Registers a as + /// with returned by + /// or retrieved from the dependency injection container if was not provided. + /// + /// + [RequiresUnreferencedCode(UnreferencedCodeMessage)] + [RequiresDynamicCode(DynamicCodeMessage)] + public static IServiceCollection AddChromaCollection( + this IServiceCollection services, + string name, + Func? clientProvider = default, + Func? optionsProvider = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TKey : notnull + where TRecord : class + => AddKeyedChromaCollection(services, serviceKey: null, name, clientProvider, optionsProvider, lifetime); + + /// + /// Registers a keyed as + /// with returned by + /// or retrieved from the dependency injection container if was not provided. + /// + /// The to register the on. + /// The key with which to associate the collection. + /// The name of the collection. + /// The provider. + /// Options provider to further configure the . + /// The service lifetime for the collection. Defaults to . + /// Service collection. + [RequiresUnreferencedCode(UnreferencedCodeMessage)] + [RequiresDynamicCode(DynamicCodeMessage)] + public static IServiceCollection AddKeyedChromaCollection( + this IServiceCollection services, + object? serviceKey, + string name, + Func? clientProvider = default, + Func? optionsProvider = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TKey : notnull + where TRecord : class + { + Throw.IfNull(services); + Throw.IfNullOrWhitespace(name); + + return AddKeyedChromaCollection(services, serviceKey, lifetime, (sp, options) => + { + var client = clientProvider is null ? sp.GetRequiredService() : clientProvider(sp); + + // The client was restored from the DI container, so we do not own it. + return new ChromaCollection(client, name, ownsClient: false, options); + }, optionsProvider); + } + + /// + /// Registers a as + /// that connects to Chroma with . + /// + /// + [RequiresUnreferencedCode(UnreferencedCodeMessage)] + [RequiresDynamicCode(DynamicCodeMessage)] + public static IServiceCollection AddChromaCollection( + this IServiceCollection services, + string name, + string connectionString, + ChromaCollectionOptions? options = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TKey : notnull + where TRecord : class + => AddKeyedChromaCollection(services, serviceKey: null, name, connectionString, options, lifetime); + + /// + /// Registers a keyed as + /// that connects to Chroma with . + /// + /// The to register the on. + /// The key with which to associate the collection. + /// The name of the collection. + /// Chroma connection string: the URI of the server, or Endpoint=...;Token=...;Tenant=...;Database=.... + /// Options to further configure the . + /// The service lifetime for the collection. Defaults to . + /// Service collection. + [RequiresUnreferencedCode(UnreferencedCodeMessage)] + [RequiresDynamicCode(DynamicCodeMessage)] + public static IServiceCollection AddKeyedChromaCollection( + this IServiceCollection services, + object? serviceKey, + string name, + string connectionString, + ChromaCollectionOptions? options = default, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TKey : notnull + where TRecord : class + { + Throw.IfNull(services); + Throw.IfNullOrWhitespace(name); + Throw.IfNullOrWhitespace(connectionString); + + // The collection creates the client, so it owns it. + var configurationOptions = ChromaConfigurationOptions.FromConnectionString(connectionString); + return AddKeyedChromaCollection(services, serviceKey, lifetime, + (_, collectionOptions) => new ChromaCollection(new ChromaClient(configurationOptions), name, ownsClient: true, collectionOptions), + _ => options!); + } + + [RequiresUnreferencedCode(UnreferencedCodeMessage)] + [RequiresDynamicCode(DynamicCodeMessage)] + private static IServiceCollection AddKeyedChromaCollection( + IServiceCollection services, + object? serviceKey, + ServiceLifetime lifetime, + Func> collectionFactory, + Func? optionsProvider) + where TKey : notnull + where TRecord : class + { + services.Add(new ServiceDescriptor(typeof(ChromaCollection), serviceKey, + (sp, _) => collectionFactory(sp, GetCollectionOptions(sp, optionsProvider)), lifetime)); + + services.Add(new ServiceDescriptor(typeof(VectorStoreCollection), serviceKey, + static (sp, key) => sp.GetRequiredKeyedService>(key), lifetime)); + + services.Add(new ServiceDescriptor(typeof(IVectorSearchable), serviceKey, + static (sp, key) => sp.GetRequiredKeyedService>(key), lifetime)); + + services.Add(new ServiceDescriptor(typeof(IKeywordHybridSearchable), serviceKey, + static (sp, key) => sp.GetRequiredKeyedService>(key), lifetime)); + + return services; + } + + private static ChromaVectorStoreOptions? GetStoreOptions(IServiceProvider sp, Func? optionsProvider) + { + var options = optionsProvider?.Invoke(sp); + if (options?.EmbeddingGenerator is not null) + { + return options; // The user has provided everything, there is nothing to change. + } + + var embeddingGenerator = sp.GetService(); + return embeddingGenerator is null + ? options // There is nothing to change. + : new(options) { EmbeddingGenerator = embeddingGenerator }; // Create a brand new copy in order to avoid modifying the original options. + } + + private static ChromaCollectionOptions? GetCollectionOptions(IServiceProvider sp, Func? optionsProvider) + { + var options = optionsProvider?.Invoke(sp); + if (options?.EmbeddingGenerator is not null) + { + return options; // The user has provided everything, there is nothing to change. + } + + var embeddingGenerator = sp.GetService(); + return embeddingGenerator is null + ? options // There is nothing to change. + : new(options) { EmbeddingGenerator = embeddingGenerator }; // Create a brand new copy in order to avoid modifying the original options. + } +} diff --git a/MEVD/src/Chroma/ChromaVectorStore.cs b/MEVD/src/Chroma/ChromaVectorStore.cs new file mode 100644 index 0000000..0d1c5b6 --- /dev/null +++ b/MEVD/src/Chroma/ChromaVectorStore.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using ChromaDB.Client; +using ChromaDB.Client.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData.ProviderServices; +using Microsoft.Shared.Diagnostics; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Class for accessing the list of collections in a Chroma vector store. +/// +/// +/// This class can be used with collections of any schema type, but requires you to provide schema information when getting a collection. +/// +public sealed class ChromaVectorStore : VectorStore +{ + /// Metadata about vector store. + private readonly VectorStoreMetadata _metadata; + + /// Chroma client that can be used to manage the collections and records in a Chroma store. + private readonly SharedChromaClient _chromaClient; + + /// A general purpose definition that can be used to construct a collection when needing to proxy schema agnostic operations. + private static readonly VectorStoreCollectionDefinition s_generalPurposeDefinition = new() { Properties = [new VectorStoreKeyProperty("Key", typeof(string)), new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory), 1)] }; + + private readonly IEmbeddingGenerator? _embeddingGenerator; + + /// Whether the store was disposed: it releases its share of the client only once. + private int _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Chroma client that can be used to manage the collections and records in a Chroma store. + /// A value indicating whether is disposed after the vector store and the collections it returns are all disposed. + /// Optional configuration options for this class. + public ChromaVectorStore(ChromaClient chromaClient, bool ownsClient, ChromaVectorStoreOptions? options = default) + : this(new SharedChromaClient(chromaClient, ownsClient), options) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Chroma client that can be used to manage the collections and records in a Chroma store. + /// Optional configuration options for this class. + internal ChromaVectorStore(SharedChromaClient chromaClient, ChromaVectorStoreOptions? options = default) + { + Throw.IfNull(chromaClient); + + _chromaClient = chromaClient; + + options ??= ChromaVectorStoreOptions.Default; + _embeddingGenerator = options.EmbeddingGenerator; + + _metadata = new() + { + VectorStoreSystemName = ChromaConstants.VectorStoreSystemName, + VectorStoreName = chromaClient.DatabaseName + }; + } + + /// + protected override void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + _chromaClient.Dispose(); + } + + base.Dispose(disposing); + } + +#pragma warning disable IDE0090 // Use 'new(...)' + /// + [RequiresDynamicCode("This overload of GetCollection() is incompatible with NativeAOT. For dynamic mapping via Dictionary, call GetDynamicCollection() instead.")] + [RequiresUnreferencedCode("This overload of GetCollection() is incompatible with trimming. For dynamic mapping via Dictionary, call GetDynamicCollection() instead.")] +#if NET + public override ChromaCollection GetCollection(string name, VectorStoreCollectionDefinition? definition = null) +#else + public override VectorStoreCollection GetCollection(string name, VectorStoreCollectionDefinition? definition = null) +#endif + => typeof(TRecord) == typeof(Dictionary) + ? throw new ArgumentException(VectorDataStrings.GetCollectionWithDictionaryNotSupported) + : new ChromaCollection(_chromaClient.Share, name, new() + { + Definition = definition, + EmbeddingGenerator = _embeddingGenerator + }); + + /// +#if NET + public override ChromaDynamicCollection GetDynamicCollection(string name, VectorStoreCollectionDefinition definition) +#else + public override VectorStoreCollection> GetDynamicCollection(string name, VectorStoreCollectionDefinition definition) +#endif + => new ChromaDynamicCollection(_chromaClient.Share, name, new ChromaCollectionOptions() + { + Definition = definition, + EmbeddingGenerator = _embeddingGenerator + }); +#pragma warning restore IDE0090 + + /// + public override async IAsyncEnumerable ListCollectionNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + const string OperationName = "ListCollectionNames"; + + var collections = await VectorStoreErrorHandler.RunOperationAsync, ChromaException>( + _metadata, + OperationName, + () => _chromaClient.Client.ListCollectionsAsync(cancellationToken: cancellationToken)).ConfigureAwait(false); + + foreach (var collection in collections) + { + yield return collection.Name; + } + } + + /// + public override async Task CollectionExistsAsync(string name, CancellationToken cancellationToken = default) + { + using var collection = GetDynamicCollection(name, s_generalPurposeDefinition); + return await collection.CollectionExistsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async Task EnsureCollectionDeletedAsync(string name, CancellationToken cancellationToken = default) + { + using var collection = GetDynamicCollection(name, s_generalPurposeDefinition); + await collection.EnsureCollectionDeletedAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(VectorStoreMetadata) ? _metadata : + serviceType == typeof(ChromaClient) ? _chromaClient.Client : + serviceType.IsInstanceOfType(this) ? this : + null; + } +} diff --git a/MEVD/src/Chroma/ChromaVectorStoreOptions.cs b/MEVD/src/Chroma/ChromaVectorStoreOptions.cs new file mode 100644 index 0000000..3569cd9 --- /dev/null +++ b/MEVD/src/Chroma/ChromaVectorStoreOptions.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.AI; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// Options when creating a . +/// +public sealed class ChromaVectorStoreOptions +{ + internal static readonly ChromaVectorStoreOptions Default = new(); + + /// + /// Initializes a new instance of the class. + /// + public ChromaVectorStoreOptions() + { + } + + internal ChromaVectorStoreOptions(ChromaVectorStoreOptions? source) + { + EmbeddingGenerator = source?.EmbeddingGenerator; + } + + /// + /// Gets or sets the default embedding generator to use when generating vector embeddings with this vector store. + /// + public IEmbeddingGenerator? EmbeddingGenerator { get; set; } +} diff --git a/MEVD/src/Chroma/README.md b/MEVD/src/Chroma/README.md new file mode 100644 index 0000000..a84c90d --- /dev/null +++ b/MEVD/src/Chroma/README.md @@ -0,0 +1,29 @@ +# CommunityToolkit.VectorData.Chroma + +Chroma provider for [Microsoft.Extensions.VectorData](https://learn.microsoft.com/dotnet/ai/vector-stores/overview), by the .NET Community Toolkit. + +[Chroma](https://www.trychroma.com/) is an open-source vector database for AI applications. + +The provider is built on [ChromaDotNet.Client](https://www.nuget.org/packages/ChromaDotNet.Client), a .NET client for Chroma and Chroma Cloud. + +## Quick start + +1. Run Chroma with Docker: + +```bash +docker run -d --name chroma -p 8000:8000 chromadb/chroma +``` + +2. Install the NuGet package: + +```bash +dotnet add package CommunityToolkit.VectorData.Chroma +``` + +For more information, see the [Microsoft.Extensions.VectorData documentation](https://learn.microsoft.com/dotnet/ai/vector-stores/overview). + +## Limitations + +- On Chroma Cloud, a vector search returns at most 300 results, `Skip` included, the default quota of Chroma Cloud: beyond that, Chroma Cloud answers with a quota error. +- Chroma does not store empty lists, so an empty array or list comes back as `null`. +- Array and list properties, like the namespaces of the `TextSearchStore` of Semantic Kernel, need Chroma 1.5.0 or later. diff --git a/MEVD/src/Chroma/SharedChromaClient.cs b/MEVD/src/Chroma/SharedChromaClient.cs new file mode 100644 index 0000000..1342c6c --- /dev/null +++ b/MEVD/src/Chroma/SharedChromaClient.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ChromaDB.Client; +using Microsoft.Shared.Diagnostics; + +namespace CommunityToolkit.VectorData.Chroma; + +/// +/// The of a vector store or of a collection. A vector store shares it with the collections it returns, +/// and the it owns is disposed when the last of them is disposed. +/// +internal sealed class SharedChromaClient : IDisposable +{ + private readonly ChromaClient? _ownedClient; + private int _referenceCount = 1; + + /// + /// Initializes a new instance of the class. + /// + /// Chroma client that can be used to manage the collections and records in a Chroma store. + /// A value indicating whether is disposed with the last of the vector store and its collections. + public SharedChromaClient(ChromaClient chromaClient, bool ownsClient) + { + Throw.IfNull(chromaClient); + + Client = chromaClient; + _ownedClient = ownsClient ? chromaClient : null; + } + + /// + /// Gets the client. + /// + public ChromaClient Client { get; } + + /// + /// Gets the database the client works in, or for the default database of the server. + /// + public string? DatabaseName => Client.Options.Database; + + /// + /// Gets this client for one more user, a collection of the vector store, which disposes it too. + /// + public SharedChromaClient Share() + { + if (_ownedClient is not null) + { + Interlocked.Increment(ref _referenceCount); + } + + return this; + } + + /// + public void Dispose() + { + if (_ownedClient is not null && Interlocked.Decrement(ref _referenceCount) == 0) + { + _ownedClient.Dispose(); + } + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/Chroma.ConformanceTests.csproj b/MEVD/test/Chroma.ConformanceTests/Chroma.ConformanceTests.csproj new file mode 100644 index 0000000..db4975c --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/Chroma.ConformanceTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0;$(NetFrameworkTfm) + enable + enable + true + false + Chroma.ConformanceTests + b7762d10-e29b-4bb1-8b74-b6d69a667dd4 + + + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaCollectionManagementTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaCollectionManagementTests.cs new file mode 100644 index 0000000..3a0bea8 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaCollectionManagementTests.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using CommunityToolkit.VectorData.Chroma; +using VectorData.ConformanceTests; +using Xunit; + +namespace Chroma.ConformanceTests; + +public class ChromaCollectionManagementTests(ChromaFixture fixture) + : CollectionManagementTests(fixture), IClassFixture +{ +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaDependencyInjectionTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaDependencyInjectionTests.cs new file mode 100644 index 0000000..0039a61 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaDependencyInjectionTests.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ChromaDB.Client; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using VectorData.ConformanceTests; +using Xunit; + +namespace Chroma.ConformanceTests; + +public class ChromaDependencyInjectionTests + : DependencyInjectionTests.Record>, string, DependencyInjectionTests.Record> +{ + private const string ConnectionString = "Endpoint=http://localhost:8000;Token=fakeToken"; + + protected override void PopulateConfiguration(ConfigurationManager configuration, object? serviceKey = null) + => configuration.AddInMemoryCollection( + [ + new(CreateConfigKey("Chroma", serviceKey, "ConnectionString"), ConnectionString), + ]); + + private static string ConnectionStringProvider(IServiceProvider sp, object? serviceKey = null) + => sp.GetRequiredService().GetRequiredSection(CreateConfigKey("Chroma", serviceKey, "ConnectionString")).Value!; + + private static ChromaClient CreateClient(string connectionString) + => new(ChromaConfigurationOptions.FromConnectionString(connectionString)); + + public override IEnumerable> CollectionDelegates + { + get + { + yield return (services, serviceKey, name, lifetime) => serviceKey is null + ? services + .AddSingleton(sp => CreateClient(ConnectionString)) + .AddChromaCollection(name, lifetime: lifetime) + : services + .AddSingleton(sp => CreateClient(ConnectionString)) + .AddKeyedChromaCollection(serviceKey, name, lifetime: lifetime); + + yield return (services, serviceKey, name, lifetime) => serviceKey is null + ? services.AddChromaCollection(name, ConnectionString, lifetime: lifetime) + : services.AddKeyedChromaCollection(serviceKey, name, ConnectionString, lifetime: lifetime); + + yield return (services, serviceKey, name, lifetime) => serviceKey is null + ? services.AddChromaCollection( + name, sp => CreateClient(ConnectionStringProvider(sp)), lifetime: lifetime) + : services.AddKeyedChromaCollection( + serviceKey, name, sp => CreateClient(ConnectionStringProvider(sp, serviceKey)), lifetime: lifetime); + } + } + + public override IEnumerable> StoreDelegates + { + get + { + yield return (services, serviceKey, lifetime) => serviceKey is null + ? services.AddChromaVectorStore(ConnectionString, lifetime: lifetime) + : services.AddKeyedChromaVectorStore(serviceKey, ConnectionString, lifetime: lifetime); + + yield return (services, serviceKey, lifetime) => serviceKey is null + ? services + .AddSingleton(sp => CreateClient(ConnectionString)) + .AddChromaVectorStore(lifetime: lifetime) + : services + .AddSingleton(sp => CreateClient(ConnectionString)) + .AddKeyedChromaVectorStore(serviceKey, lifetime: lifetime); + + yield return (services, serviceKey, lifetime) => serviceKey is null + ? services.AddChromaVectorStore( + sp => CreateClient(ConnectionStringProvider(sp)), lifetime: lifetime) + : services.AddKeyedChromaVectorStore( + serviceKey, sp => CreateClient(ConnectionStringProvider(sp, serviceKey)), lifetime: lifetime); + } + } + + [Fact] + public void ConnectionStringCantBeNullOrEmpty() + { + IServiceCollection services = new ServiceCollection(); + + Assert.Throws(() => services.AddChromaVectorStore(connectionString: null!)); + Assert.Throws(() => services.AddKeyedChromaVectorStore(serviceKey: "notNull", connectionString: null!)); + Assert.Throws(() => services.AddChromaCollection( + name: "notNull", connectionString: null!)); + Assert.Throws(() => services.AddChromaCollection( + name: "notNull", connectionString: "")); + Assert.Throws(() => services.AddKeyedChromaCollection( + serviceKey: "notNull", name: "notNull", connectionString: null!)); + Assert.Throws(() => services.AddKeyedChromaCollection( + serviceKey: "notNull", name: "notNull", connectionString: "")); + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaDistanceFunctionTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaDistanceFunctionTests.cs new file mode 100644 index 0000000..55f44a5 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaDistanceFunctionTests.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests; + +public class ChromaDistanceFunctionTests(ChromaDistanceFunctionTests.Fixture fixture) + : DistanceFunctionTests(fixture), IClassFixture +{ + // Chroma supports cosine, inner product (ip) and squared Euclidean (l2) only + public override Task ManhattanDistance() => Assert.ThrowsAsync(base.ManhattanDistance); + public override Task HammingDistance() => Assert.ThrowsAsync(base.HammingDistance); + + public new class Fixture() : DistanceFunctionTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaEmbeddingGenerationTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaEmbeddingGenerationTests.cs new file mode 100644 index 0000000..97cddc3 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaEmbeddingGenerationTests.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using ChromaDB.Client; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; +using VectorData.ConformanceTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests; + +public class ChromaEmbeddingGenerationTests(ChromaEmbeddingGenerationTests.StringVectorFixture stringVectorFixture, ChromaEmbeddingGenerationTests.RomOfFloatVectorFixture romOfFloatVectorFixture) + : EmbeddingGenerationTests(stringVectorFixture, romOfFloatVectorFixture), IClassFixture, IClassFixture +{ + public new class StringVectorFixture : EmbeddingGenerationTests.StringVectorFixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + + public override VectorStore CreateVectorStore(IEmbeddingGenerator? embeddingGenerator) + => ChromaTestStore.Instance.GetVectorStore(new() { EmbeddingGenerator = embeddingGenerator }); + + public override Func[] DependencyInjectionStoreRegistrationDelegates => + [ + services => services + .AddSingleton(ChromaTestStore.Instance.Client) + .AddChromaVectorStore() + ]; + + public override Func[] DependencyInjectionCollectionRegistrationDelegates => + [ + services => services + .AddSingleton(ChromaTestStore.Instance.Client) + .AddChromaCollection(this.CollectionName) + ]; + } + + public new class RomOfFloatVectorFixture : EmbeddingGenerationTests.RomOfFloatVectorFixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + + public override VectorStore CreateVectorStore(IEmbeddingGenerator? embeddingGenerator) + => ChromaTestStore.Instance.GetVectorStore(new() { EmbeddingGenerator = embeddingGenerator }); + + public override Func[] DependencyInjectionStoreRegistrationDelegates => + [ + services => services + .AddSingleton(ChromaTestStore.Instance.Client) + .AddChromaVectorStore() + ]; + + public override Func[] DependencyInjectionCollectionRegistrationDelegates => + [ + services => services + .AddSingleton(ChromaTestStore.Instance.Client) + .AddChromaCollection(this.CollectionName) + ]; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaFilterTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaFilterTests.cs new file mode 100644 index 0000000..8644ead --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaFilterTests.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests; + +public class ChromaFilterTests(ChromaFilterTests.Fixture fixture) + : FilterTests(fixture), IClassFixture +{ + #region Null checking + + // Chroma metadata has no null values, and Chroma has no filter for a missing field + public override Task Equal_with_null_reference_type() + => Assert.ThrowsAsync(() => base.Equal_with_null_reference_type()); + + public override Task Equal_with_null_captured() + => Assert.ThrowsAsync(() => base.Equal_with_null_captured()); + + public override Task NotEqual_with_null_reference_type() + => Assert.ThrowsAsync(() => base.NotEqual_with_null_reference_type()); + + public override Task NotEqual_with_null_captured() + => Assert.ThrowsAsync(() => base.NotEqual_with_null_captured()); + + #endregion + + public new class Fixture : FilterTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaHybridSearchTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaHybridSearchTests.cs new file mode 100644 index 0000000..eee271e --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaHybridSearchTests.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests; + +/// +/// Hybrid search needs the Search API and the sparse vector indexes of Chroma, which only Chroma Cloud has: +/// these tests run only when Chroma:ConnectionString has a Chroma Cloud address, like https://api.trychroma.com, and are skipped otherwise. See the README for the settings. +/// +public class ChromaHybridSearchTests(ChromaHybridSearchTests.VectorAndStringFixture vectorAndStringFixture, ChromaHybridSearchTests.MultiTextFixture multiTextFixture) + : HybridSearchTests(vectorAndStringFixture, multiTextFixture), + IClassFixture, + IClassFixture +{ + private const string SkipReason = "Hybrid search needs Chroma Cloud: set Chroma:ConnectionString to a Chroma Cloud address, like https://api.trychroma.com to run it."; + + public override Task HybridSearchAsync() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync(); + } + + public override Task HybridSearchAsync_with_filter() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync_with_filter(); + } + + public override Task HybridSearchAsync_with_top() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync_with_top(); + } + + public override Task HybridSearchAsync_with_Skip() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync_with_Skip(); + } + + public override Task HybridSearchAsync_with_multiple_keywords_ranks_matched_keywords_higher() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync_with_multiple_keywords_ranks_matched_keywords_higher(); + } + + public override Task HybridSearchAsync_with_multiple_text_properties() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync_with_multiple_text_properties(); + } + + public override Task HybridSearchAsync_without_explicitly_specified_property_fails() + { + Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason); + return base.HybridSearchAsync_without_explicitly_specified_property_fails(); + } + + public new class VectorAndStringFixture : HybridSearchTests.VectorAndStringFixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } + + public new class MultiTextFixture : HybridSearchTests.MultiTextFixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaIndexKindTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaIndexKindTests.cs new file mode 100644 index 0000000..b9a7658 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaIndexKindTests.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using Microsoft.Extensions.VectorData; +using VectorData.ConformanceTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests; + +public class ChromaIndexKindTests(ChromaIndexKindTests.Fixture fixture) + : IndexKindTests(fixture), IClassFixture +{ + // Chroma does not support index-less searching + public override Task Flat() => Assert.ThrowsAsync(base.Flat); + + [Fact] + public virtual Task Hnsw() + => this.Test(IndexKind.Hnsw); + + public new class Fixture() : IndexKindTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ChromaTestSuiteImplementationTests.cs b/MEVD/test/Chroma.ConformanceTests/ChromaTestSuiteImplementationTests.cs new file mode 100644 index 0000000..c61619c --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ChromaTestSuiteImplementationTests.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using VectorData.ConformanceTests; +using VectorData.ConformanceTests.ModelTests; + +namespace Chroma.ConformanceTests; + +public class ChromaTestSuiteImplementationTests : TestSuiteImplementationTests +{ + protected override ICollection IgnoredTestBases { get; } = + [ + // A Chroma record has exactly one vector + typeof(MultiVectorModelTests<>), + typeof(NoVectorModelTests<>), + ]; +} diff --git a/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaBasicModelTests.cs b/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaBasicModelTests.cs new file mode 100644 index 0000000..1c193b5 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaBasicModelTests.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests.ModelTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests.ModelTests; + +public class ChromaBasicModelTests(ChromaBasicModelTests.Fixture fixture) + : BasicModelTests(fixture), IClassFixture +{ + public override async Task GetAsync_with_filter_and_OrderBy() + { + var exception = await Assert.ThrowsAsync(base.GetAsync_with_filter_and_OrderBy); + Assert.Equal("Chroma does not support ordering.", exception.Message); + } + + public override async Task GetAsync_with_filter_and_OrderBy_and_Skip() + { + var exception = await Assert.ThrowsAsync(base.GetAsync_with_filter_and_OrderBy_and_Skip); + Assert.Equal("Chroma does not support ordering.", exception.Message); + } + + public override async Task GetAsync_with_filter_and_multiple_OrderBys() + { + var exception = await Assert.ThrowsAsync(base.GetAsync_with_filter_and_multiple_OrderBys); + Assert.Equal("Chroma does not support ordering.", exception.Message); + } + + public new class Fixture : BasicModelTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaDynamicModelTests.cs b/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaDynamicModelTests.cs new file mode 100644 index 0000000..290d0f2 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaDynamicModelTests.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests.ModelTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests.ModelTests; + +public class ChromaDynamicModelTests(ChromaDynamicModelTests.Fixture fixture) + : DynamicModelTests(fixture), IClassFixture +{ + public override async Task GetAsync_with_filter_and_OrderBy() + { + var exception = await Assert.ThrowsAsync(base.GetAsync_with_filter_and_OrderBy); + Assert.Equal("Chroma does not support ordering.", exception.Message); + } + + public override async Task GetAsync_with_filter_and_OrderBy_and_Skip() + { + var exception = await Assert.ThrowsAsync(base.GetAsync_with_filter_and_OrderBy_and_Skip); + Assert.Equal("Chroma does not support ordering.", exception.Message); + } + + public override async Task GetAsync_with_filter_and_multiple_OrderBys() + { + var exception = await Assert.ThrowsAsync(base.GetAsync_with_filter_and_multiple_OrderBys); + Assert.Equal("Chroma does not support ordering.", exception.Message); + } + + public new class Fixture : DynamicModelTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaNoDataModelTests.cs b/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaNoDataModelTests.cs new file mode 100644 index 0000000..70c8239 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/ModelTests/ChromaNoDataModelTests.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests.ModelTests; +using VectorData.ConformanceTests.Support; +using Xunit; + +namespace Chroma.ConformanceTests.ModelTests; + +public class ChromaNoDataModelTests(ChromaNoDataModelTests.Fixture fixture) + : NoDataModelTests(fixture), IClassFixture +{ + public new class Fixture : NoDataModelTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/README.md b/MEVD/test/Chroma.ConformanceTests/README.md new file mode 100644 index 0000000..2c5a6e2 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/README.md @@ -0,0 +1,54 @@ +# Chroma Vector Store Conformance Tests + +This project contains conformance tests for the Chroma Vector Store implementation. + +## Running the Tests + +By default, the tests will automatically use a testcontainer to spin up a Chroma instance. Docker must be running on your machine for this to work. + +The hybrid search tests need Chroma Cloud, the only Chroma with the sparse vector indexes that hybrid search uses: they run only when `Chroma:ConnectionString` has a Chroma Cloud address, like `https://api.trychroma.com`, and are skipped otherwise. + +### Using an External Chroma Instance + +If you want to run the tests against an external Chroma instance (e.g., Chroma Cloud, a local Chroma server, or any other instance), you can provide a connection string through one of the following methods: + +#### Option 1: Environment Variable + +Set the `Chroma__ConnectionString` environment variable: + +```bash +# Bash/Linux/macOS +export Chroma__ConnectionString="Endpoint=https://api.trychroma.com;Token=ck-...;Tenant=my-tenant;Database=my-test-database" + +# PowerShell +$env:Chroma__ConnectionString = "Endpoint=https://api.trychroma.com;Token=ck-...;Tenant=my-tenant;Database=my-test-database" +``` + +#### Option 2: Configuration File + +Create a `testsettings.development.json` file in this directory with the following content: + +```json +{ + "Chroma": { + "ConnectionString": "Endpoint=https://api.trychroma.com;Token=ck-...;Tenant=my-tenant;Database=my-test-database" + } +} +``` + +This file is git-ignored and safe for local development. + +#### Option 3: User Secrets + +```bash +cd MEVD/test/Chroma.ConformanceTests +dotnet user-secrets set "Chroma:ConnectionString" "Endpoint=https://api.trychroma.com;Token=ck-...;Tenant=my-tenant;Database=my-test-database" +``` + +## Benefits of Using an External Instance + +Using an external Chroma instance can be beneficial when: +- You want to avoid the overhead of spinning up Docker containers +- You need to test against Chroma Cloud specifically +- You want faster test execution (no container startup time) +- You're running tests in an environment where Docker is not available diff --git a/MEVD/test/Chroma.ConformanceTests/Support/ChromaFixture.cs b/MEVD/test/Chroma.ConformanceTests/Support/ChromaFixture.cs new file mode 100644 index 0000000..ec497ee --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/Support/ChromaFixture.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using VectorData.ConformanceTests.Support; + +namespace Chroma.ConformanceTests.Support; + +public class ChromaFixture : VectorStoreFixture +{ + public override TestStore TestStore => ChromaTestStore.Instance; +} diff --git a/MEVD/test/Chroma.ConformanceTests/Support/ChromaTestEnvironment.cs b/MEVD/test/Chroma.ConformanceTests/Support/ChromaTestEnvironment.cs new file mode 100644 index 0000000..d546825 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/Support/ChromaTestEnvironment.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Chroma.ConformanceTests.Support; + +#pragma warning disable CA1810 // Initialize all static fields when those fields are declared + +internal static class ChromaTestEnvironment +{ + public static readonly string? ConnectionString; + + public static bool IsConnectionStringDefined => !string.IsNullOrEmpty(ConnectionString); + + static ChromaTestEnvironment() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true) + .AddJsonFile(path: "testsettings.development.json", optional: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + var chromaSection = configuration.GetSection("Chroma"); + ConnectionString = chromaSection["ConnectionString"]; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/Support/ChromaTestStore.cs b/MEVD/test/Chroma.ConformanceTests/Support/ChromaTestStore.cs new file mode 100644 index 0000000..cbbb30c --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/Support/ChromaTestStore.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ChromaDB.Client; +using CommunityToolkit.VectorData.Chroma; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Microsoft.Extensions.VectorData; +using VectorData.ConformanceTests.Support; + +namespace Chroma.ConformanceTests.Support; + +#pragma warning disable CA1001 // Type owns disposable fields but is not disposable + +internal sealed class ChromaTestStore : TestStore +{ + private const ushort ChromaPort = 8000; + + public static ChromaTestStore Instance { get; } = new(); + + // Chroma indexes vectors with HNSW only + public override string DefaultIndexKind => IndexKind.Hnsw; + + // Only when no external instance is configured, see ChromaTestEnvironment. + private readonly IContainer? _container = ChromaTestEnvironment.IsConnectionStringDefined + ? null + : new ContainerBuilder("chromadb/chroma:1.5.9") + .WithPortBinding(ChromaPort, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPath("/api/v2/heartbeat").ForPort(ChromaPort))) + .Build(); + + private ChromaClient? _client; + + /// + /// Chroma normalizes the vectors of cosine collections, so the vectors it returns + /// can differ from the upserted ones in the last digits; we can only check that + /// a vector was returned. + /// + public override bool VectorsComparable => false; + + public ChromaClient Client => this._client ?? throw new InvalidOperationException("Not initialized"); + + /// + /// Whether the tests run against Chroma Cloud, the only Chroma with the Search API and the sparse vector indexes that hybrid search needs. + /// + public static bool IsChromaCloud => ChromaTestEnvironment.IsConnectionStringDefined + && ChromaConfigurationOptions.FromConnectionString(ChromaTestEnvironment.ConnectionString!).Uri.Host.EndsWith(".trychroma.com", StringComparison.OrdinalIgnoreCase); + + public ChromaVectorStore GetVectorStore(ChromaVectorStoreOptions options) + => new(this.Client, ownsClient: false, options); // The client is shared, it's not owned by the vector store. + + private ChromaTestStore() + { + } + + protected override async Task StartAsync() + { + ChromaConfigurationOptions options; + if (this._container is not null) + { + await this._container.StartAsync(); + options = new ChromaConfigurationOptions( + new UriBuilder(Uri.UriSchemeHttp, this._container.Hostname, this._container.GetMappedPublicPort(ChromaPort)).ToString()); + } + else + { + options = ChromaConfigurationOptions.FromConnectionString(ChromaTestEnvironment.ConnectionString!); + } + + this._client = new ChromaClient(options); + + // It's a shared static instance, we don't want any of the tests to dispose the client. + this.DefaultVectorStore = new ChromaVectorStore(this._client, ownsClient: false); + } + + protected override async Task StopAsync() + { + this._client?.Dispose(); + + if (this._container is not null) + { + await this._container.StopAsync(); + } + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaDataTypeTests.cs b/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaDataTypeTests.cs new file mode 100644 index 0000000..59946f4 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaDataTypeTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests.Support; +using VectorData.ConformanceTests.TypeTests; +using Xunit; + +namespace Chroma.ConformanceTests.TypeTests; + +public class ChromaDataTypeTests(ChromaDataTypeTests.Fixture fixture) + : DataTypeTests.DefaultRecord>(fixture), IClassFixture +{ + // Chroma does not compare an array field with an array + [Fact] + public override Task String_array() + => this.Test( + "StringArray", + ["foo", "bar"], + ["foo", "baz"], + isFilterable: false); + + public new class Fixture : DataTypeTests.DefaultRecord>.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + + // Chroma metadata has no null values, and Chroma has no filter for a missing field + public override bool IsNullFilteringSupported => false; + + public override Type[] UnsupportedDefaultTypes { get; } = + [ + typeof(byte), + typeof(short), + typeof(decimal), + typeof(Guid), +#if NET + typeof(TimeOnly) +#endif + ]; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaEmbeddingTypeTests.cs b/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaEmbeddingTypeTests.cs new file mode 100644 index 0000000..645beda --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaEmbeddingTypeTests.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests.Support; +using VectorData.ConformanceTests.TypeTests; +using Xunit; + +#pragma warning disable CA2000 // Dispose objects before losing scope + +namespace Chroma.ConformanceTests.TypeTests; + +public class ChromaEmbeddingTypeTests(ChromaEmbeddingTypeTests.Fixture fixture) + : EmbeddingTypeTests(fixture), IClassFixture +{ + public new class Fixture : EmbeddingTypeTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaKeyTypeTests.cs b/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaKeyTypeTests.cs new file mode 100644 index 0000000..f236497 --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/TypeTests/ChromaKeyTypeTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Chroma.ConformanceTests.Support; +using VectorData.ConformanceTests.Support; +using VectorData.ConformanceTests.TypeTests; +using Xunit; + +namespace Chroma.ConformanceTests.TypeTests; + +public class ChromaKeyTypeTests(ChromaKeyTypeTests.Fixture fixture) + : KeyTypeTests(fixture), IClassFixture +{ + [Fact] + public virtual Task String() => this.Test("foo", "bar"); + + public new class Fixture : KeyTypeTests.Fixture + { + public override TestStore TestStore => ChromaTestStore.Instance; + } +} diff --git a/MEVD/test/Chroma.ConformanceTests/testsettings.json b/MEVD/test/Chroma.ConformanceTests/testsettings.json new file mode 100644 index 0000000..f3a1e9d --- /dev/null +++ b/MEVD/test/Chroma.ConformanceTests/testsettings.json @@ -0,0 +1,8 @@ +{ + // Optional: Provide a connection string to use an external Chroma instance instead of testcontainers + // This is useful for running tests against Chroma Cloud, which hybrid search needs + // If not specified, tests will automatically use a testcontainer + "Chroma": { + "ConnectionString": "" + } +} diff --git a/MEVD/test/Chroma.UnitTests/.editorconfig b/MEVD/test/Chroma.UnitTests/.editorconfig new file mode 100644 index 0000000..394eef6 --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/.editorconfig @@ -0,0 +1,6 @@ +# Suppressing errors for Test projects under dotnet folder +[*.cs] +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations diff --git a/MEVD/test/Chroma.UnitTests/Chroma.UnitTests.csproj b/MEVD/test/Chroma.UnitTests/Chroma.UnitTests.csproj new file mode 100644 index 0000000..cf1a76f --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/Chroma.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + CommunityToolkit.VectorData.Chroma.UnitTests + CommunityToolkit.VectorData.Chroma.UnitTests + net10.0 + true + enable + disable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/MEVD/test/Chroma.UnitTests/ChromaCollectionCreateMappingTests.cs b/MEVD/test/Chroma.UnitTests/ChromaCollectionCreateMappingTests.cs new file mode 100644 index 0000000..c119772 --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaCollectionCreateMappingTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Text.Json.Nodes; +using ChromaDB.Client; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData.ProviderServices; +using Xunit; + +namespace Chroma.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChromaCollectionCreateMappingTests +{ + [Theory] + [InlineData(null, ChromaSpace.Cosine)] + [InlineData(DistanceFunction.CosineSimilarity, ChromaSpace.Cosine)] + [InlineData(DistanceFunction.CosineDistance, ChromaSpace.Cosine)] + [InlineData(DistanceFunction.DotProductSimilarity, ChromaSpace.InnerProduct)] + [InlineData(DistanceFunction.NegativeDotProductSimilarity, ChromaSpace.InnerProduct)] + [InlineData(DistanceFunction.EuclideanDistance, ChromaSpace.L2)] + [InlineData(DistanceFunction.EuclideanSquaredDistance, ChromaSpace.L2)] + public void MapCollectionDefinitionSetsTheSpace(string? distanceFunction, ChromaSpace expectedSpace) + { + // Arrange. + var vectorProperty = new VectorPropertyModel("Vector", typeof(ReadOnlyMemory)) { DistanceFunction = distanceFunction }; + + // Act. + var definition = ChromaCollectionCreateMapping.MapCollectionDefinition("hotels", vectorProperty, []); + + // Assert. + Assert.Equal("hotels", definition.Name); + Assert.Equal(expectedSpace, definition.Configuration?.Space); + Assert.Null(definition.Schema); + } + + [Theory] + [InlineData(DistanceFunction.ManhattanDistance)] + [InlineData(DistanceFunction.HammingDistance)] + public void MapCollectionDefinitionThrowsForUnsupportedDistanceFunction(string distanceFunction) + { + // Arrange. + var vectorProperty = new VectorPropertyModel("Vector", typeof(ReadOnlyMemory)) { DistanceFunction = distanceFunction }; + + // Act and assert. + Assert.Throws(() => ChromaCollectionCreateMapping.MapCollectionDefinition("hotels", vectorProperty, [])); + } + + [Fact] + public void MapCollectionDefinitionAddsASchemaForTheBm25Properties() + { + // Arrange. + var model = ChromaTestModel.Build(); + + // Act. + var definition = ChromaCollectionCreateMapping.MapCollectionDefinition("hotels", model.VectorProperty, ChromaCollectionCreateMapping.GetBm25Properties(model)); + + // Assert. + var expected = + """{"defaults":{},"keys":{"Description_bm25":""" + + """{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"source_key":"Description","bm25":true,"embedding_function":{"type":"known","name":"chroma_bm25","config":{"k":1.2,"b":0.75,"avg_doc_length":256,"token_max_length":40,"include_tokens":false}}}}}},"Review_bm25":""" + + """{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"source_key":"Review","bm25":true,"embedding_function":{"type":"known","name":"chroma_bm25","config":{"k":1.2,"b":0.75,"avg_doc_length":256,"token_max_length":40,"include_tokens":false}}}}}}}}"""; + var actual = definition.Schema!.ToString(); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)), actual); + } + + [Fact] + public void MapCollectionDefinitionTakesTheBm25VectorsOfTheDocumentPropertyFromTheDocument() + { + // Arrange: the only full-text property is stored as the document. + var model = ChromaTestModel.Build(); + + // Act. + var definition = ChromaCollectionCreateMapping.MapCollectionDefinition("hotels", model.VectorProperty, ChromaCollectionCreateMapping.GetBm25Properties(model), ChromaFieldMapping.GetDocumentProperty(model)); + + // Assert. + Assert.Contains("\"document_bm25\":{\"sparse_vector\":{\"sparse_vector_index\":{\"enabled\":true,\"config\":{", definition.Schema!.ToString()); + Assert.Contains("\"source_key\":\"#document\"", definition.Schema.ToString()); + } + + [Fact] + public void GetBm25PropertiesTakesTheStringPropertiesWithFullTextIndexing() + { + Assert.Equal(["Description", "Review"], ChromaCollectionCreateMapping.GetBm25Properties(ChromaTestModel.Build()).Select(p => p.ModelName)); + Assert.Empty(ChromaCollectionCreateMapping.GetBm25Properties(ChromaTestModel.Build>())); + } +} diff --git a/MEVD/test/Chroma.UnitTests/ChromaCollectionSearchMappingTests.cs b/MEVD/test/Chroma.UnitTests/ChromaCollectionSearchMappingTests.cs new file mode 100644 index 0000000..ad6e68e --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaCollectionSearchMappingTests.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.VectorData; +using Xunit; + +namespace Chroma.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChromaCollectionSearchMappingTests +{ + [Theory] + [InlineData(null, 0.25f, 0.75)] + [InlineData(DistanceFunction.CosineSimilarity, 0.25f, 0.75)] + [InlineData(DistanceFunction.CosineDistance, 0.25f, 0.25)] + [InlineData(DistanceFunction.DotProductSimilarity, 0.25f, 0.75)] + [InlineData(DistanceFunction.NegativeDotProductSimilarity, 0.25f, -0.75)] + [InlineData(DistanceFunction.EuclideanSquaredDistance, 4f, 4)] + [InlineData(DistanceFunction.EuclideanDistance, 4f, 2)] + public void ToScoreConvertsTheChromaDistance(string? distanceFunction, float distance, double expectedScore) + => Assert.Equal(expectedScore, ChromaCollectionSearchMapping.ToScore(distance, distanceFunction), precision: 6); + + [Theory] + [InlineData(DistanceFunction.CosineSimilarity, 0.8, 0.7, true)] + [InlineData(DistanceFunction.CosineSimilarity, 0.6, 0.7, false)] + [InlineData(DistanceFunction.CosineDistance, 0.2, 0.3, true)] + [InlineData(DistanceFunction.CosineDistance, 0.4, 0.3, false)] + [InlineData(DistanceFunction.EuclideanDistance, 1.5, 2, true)] + [InlineData(DistanceFunction.EuclideanDistance, 2.5, 2, false)] + [InlineData(null, 0.8, 0.7, true)] + [InlineData(DistanceFunction.DotProductSimilarity, 0.8, 0.7, true)] + [InlineData(DistanceFunction.DotProductSimilarity, 0.6, 0.7, false)] + [InlineData(DistanceFunction.NegativeDotProductSimilarity, -0.8, -0.7, true)] + [InlineData(DistanceFunction.NegativeDotProductSimilarity, -0.6, -0.7, false)] + [InlineData(DistanceFunction.EuclideanSquaredDistance, 2, 4, true)] + [InlineData(DistanceFunction.EuclideanSquaredDistance, 5, 4, false)] + public void PassesThresholdKeepsTheSimilarAndTheNear(string? distanceFunction, double score, double threshold, bool expected) + => Assert.Equal(expected, ChromaCollectionSearchMapping.PassesThreshold(score, threshold, distanceFunction)); + + [Fact] + public void ToScoreThrowsForADistanceFunctionTheModelBuilderRejects() + => Assert.Throws(() => ChromaCollectionSearchMapping.ToScore(0.25f, DistanceFunction.ManhattanDistance)); + + [Fact] + public void PassesThresholdWithoutThresholdKeepsEverything() + => Assert.True(ChromaCollectionSearchMapping.PassesThreshold(-100, null, DistanceFunction.CosineSimilarity)); +} diff --git a/MEVD/test/Chroma.UnitTests/ChromaCollectionTests.cs b/MEVD/test/Chroma.UnitTests/ChromaCollectionTests.cs new file mode 100644 index 0000000..5eb6224 --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaCollectionTests.cs @@ -0,0 +1,518 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ChromaDB.Client; +using ChromaDB.Client.Models; +using CommunityToolkit.VectorData.Chroma; +using InMemory.UnitTests; +using Microsoft.Extensions.VectorData; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Chroma.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChromaCollectionTests +{ + private const string TestCollectionName = "testcollection"; + + private readonly Mock _chromaClientMock = new(MockBehavior.Strict); + private readonly Mock _collectionClientMock = new(MockBehavior.Strict); + + // A token that is not the default one, so that the strict mocks also check that it reaches the client. + private readonly CancellationToken _testCancellationToken = TestContext.Current.CancellationToken; + + public ChromaCollectionTests() + { + this._chromaClientMock + .Setup(x => x.Options) + .Returns(new ChromaConfigurationOptions("http://localhost:8000")); + this._chromaClientMock + .Setup(x => x.GetCollectionClient(TestCollectionName)) + .Returns(this._collectionClientMock.Object); + this._collectionClientMock + .Setup(x => x.WithDocumentCopyKey(It.IsAny())) + .Returns(this._collectionClientMock.Object); + this._collectionClientMock + .Setup(x => x.WithMetadataValues(ChromaMetadataValues.Exact)) + .Returns(this._collectionClientMock.Object); + } + + #region Construction and services + + [Fact] + public void RejectsADictionaryRecord() + => Assert.Throws(() => new ChromaCollection>(this._chromaClientMock.Object, TestCollectionName, ownsClient: false)); + + [Fact] + public void DynamicCollectionsTakeAClientAndADefinition() + { + // Arrange. + var definition = new VectorStoreCollectionDefinition + { + Properties = [new VectorStoreKeyProperty("Key", typeof(string)), new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory), 4)] + }; + + // Act. + using var sut = new ChromaDynamicCollection(this._chromaClientMock.Object, TestCollectionName, ownsClient: false, new() { Definition = definition }); + + // Assert. + Assert.Equal(TestCollectionName, sut.Name); + Assert.Same(this._chromaClientMock.Object, sut.GetService(typeof(ChromaClient))); + } + + [Fact] + public void ReadsTheRecordsWithExactMetadataValues() + { + // The mapper reads strings as strings, and lists as lists of values, also with a client that infers them. + this._chromaClientMock + .Setup(x => x.Options) + .Returns(new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Inferred)); + + using var sut = new ChromaCollection>(this._chromaClientMock.Object, TestCollectionName, ownsClient: false); + + this._collectionClientMock.Verify(x => x.WithMetadataValues(ChromaMetadataValues.Exact), Times.Once); + } + + [Fact] + public void DynamicCollectionsRequireADefinition() + => Assert.Throws(() => new ChromaDynamicCollection(this._chromaClientMock.Object, TestCollectionName, ownsClient: false, new())); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DisposeDisposesTheClientOnlyWhenTheCollectionOwnsIt(bool ownsClient) + { + // Arrange. + this._chromaClientMock.Protected().Setup("Dispose", ItExpr.IsAny()); + var sut = new ChromaCollection>(this._chromaClientMock.Object, TestCollectionName, ownsClient); + + // Act. + sut.Dispose(); + + // Assert. + this._chromaClientMock.Protected().Verify("Dispose", ownsClient ? Times.Once() : Times.Never(), ItExpr.IsAny()); + } + + [Fact] + public void GetServiceReturnsTheMetadataTheClientAndTheCollection() + { + using var sut = this.CreateCollection>(); + + Assert.Equal(TestCollectionName, Assert.IsType(sut.GetService(typeof(VectorStoreCollectionMetadata))).CollectionName); + Assert.Same(this._chromaClientMock.Object, sut.GetService(typeof(ChromaClient))); + Assert.Same(sut, sut.GetService(typeof(VectorStoreCollection>))); + Assert.Null(sut.GetService(typeof(VectorStoreCollection>), "key")); + Assert.Null(sut.GetService(typeof(string))); + } + + [Theory] + [InlineData("https://api.trychroma.com", true)] + [InlineData("http://localhost:8000", false)] + public void GetServiceOffersHybridSearchOnlyWithFullTextPropertiesOnChromaCloud(string uri, bool offered) + { + // Arrange. + this._chromaClientMock + .Setup(x => x.Options) + .Returns(new ChromaConfigurationOptions(uri)); + using var fullText = this.CreateCollection(); + using var noFullText = this.CreateCollection>(); + + // Act and assert. + Assert.Equal(offered, fullText.GetService(typeof(IKeywordHybridSearchable)) is not null); + Assert.Null(noFullText.GetService(typeof(IKeywordHybridSearchable>))); + } + + #endregion + + #region Get by key + + [Fact] + public async Task GetReadsTheVectorsAndTheDocumentWhenAskedAsync() + { + // Arrange. + using var sut = this.CreateCollection(); + this._collectionClientMock + .Setup(x => x.GetAsync(new List { "h1" }, null, null, null, null, ChromaGetInclude.Metadatas | ChromaGetInclude.Embeddings | ChromaGetInclude.Documents, this._testCancellationToken)) + .ReturnsAsync([new ChromaCollectionEntry("h1") { Embedding = new float[] { 1, 2, 3, 4 }, Document = "A pool" }]); + + // Act. + var hotel = await sut.GetAsync("h1", new() { IncludeVectors = true }, this._testCancellationToken); + + // Assert. + Assert.Equal(new float[] { 1, 2, 3, 4 }, hotel!.Embedding!.Value.ToArray()); + Assert.Equal("A pool", hotel.Description); + } + + #endregion + + #region Upsert + + [Fact] + public async Task UpsertDeletesTheNullValuesAndCopiesTheDocumentAsync() + { + // Arrange. + using var sut = this.CreateCollection(); + var upserted = this.CaptureUpsert(); + + // Act. + await sut.UpsertAsync( + [ + new FullTextHotel { HotelId = "h1", Description = null, Rating = 4, Embedding = new float[] { 1, 2, 3, 4 } }, + new FullTextHotel { HotelId = "h2", Description = "A pool", Embedding = new float[] { 1, 2, 3, 4 } }, + ], this._testCancellationToken); + + // Assert. + var records = upserted(); + Assert.True(records.NullDocumentsDelete); + this._collectionClientMock.Verify(x => x.WithDocumentCopyKey("Description"), Times.Once); + Assert.Equal(new[] { null, "A pool" }, records.Documents!.ToArray()); + Assert.All(records.Metadatas!, metadata => Assert.False(metadata!.ContainsKey("Description"))); + Assert.Equal(4, records.Metadatas![0]!["Rating"]); + Assert.Equal(0, records.Metadatas[1]!["Rating"]); + } + + #endregion + + #region Vector search + + [Fact] + public async Task SearchSkipsConvertsAndFiltersTheResultsAsync() + { + // Arrange. + using var sut = this.CreateCollection>(); + var query = this.SetupQuery( + new ChromaCollectionQueryEntry("kept") { Distance = 0.2f }, + new ChromaCollectionQueryEntry("too far") { Distance = 0.6f }); + + // Act. + var results = await sut.SearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), top: 2, new() { Skip = 1, ScoreThreshold = 0.5 }, this._testCancellationToken).ToListAsync(); + + // Assert. + Assert.Equal((2, 1), (query().NResults, query().Offset)); + Assert.Same(ChromaWhereOperator.All, query().Where); + Assert.Equal(ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances, query().Include); + var result = Assert.Single(results); + Assert.Equal("kept", result.Record.HotelId); + Assert.Equal(0.8, result.Score!.Value, precision: 6); + } + + [Fact] + public async Task SearchSendsTheKeysOfTheFilterInTheWhereClauseAsync() + { + // Arrange. + using var sut = this.CreateCollection>(); + var query = this.SetupQuery(new ChromaCollectionQueryEntry("h1") { Distance = 0.1f }); + + // Act. + var results = await sut.SearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), top: 2, new() { Filter = h => new[] { "h1", "h2" }.Contains(h.HotelId) }, this._testCancellationToken).ToListAsync(); + + // Assert. + Assert.Equal("""{"#id":{"$in":["h1","h2"]}}""", query().Where!.ToString()); + Assert.Equal("h1", Assert.Single(results).Record.HotelId); + } + + [Fact] + public async Task SearchReadsTheVectorsAndTheDocumentWhenAskedAsync() + { + // Arrange. + using var sut = this.CreateCollection(); + var query = this.SetupQuery(new ChromaCollectionQueryEntry("h1") { Distance = 0.1f, Embedding = new float[] { 1, 2, 3, 4 }, Document = "A pool" }); + + // Act. + var result = Assert.Single(await sut.SearchAsync(new float[] { 1, 2, 3, 4 }, top: 1, new() { IncludeVectors = true }, this._testCancellationToken).ToListAsync()); + + // Assert. + Assert.Equal(ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances | ChromaQueryInclude.Documents | ChromaQueryInclude.Embeddings, query().Include); + Assert.Equal("A pool", result.Record.Description); + Assert.Equal(new float[] { 1, 2, 3, 4 }, result.Record.Embedding!.Value.ToArray()); + } + + [Fact] + public async Task SearchExpectsTheSpaceOfTheDistanceFunctionAsync() + { + // Arrange. + using var dotProduct = this.CreateCollection(); + using var byDefault = this.CreateCollection>(); + var query = this.SetupQuery(); + + // Act and assert. + await dotProduct.SearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), top: 1, cancellationToken: this._testCancellationToken).ToListAsync(); + Assert.Equal(ChromaSpace.InnerProduct, query().ExpectedSpace); + await byDefault.SearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), top: 1, cancellationToken: this._testCancellationToken).ToListAsync(); + Assert.Equal(ChromaSpace.Cosine, query().ExpectedSpace); + } + + #endregion + + #region Hybrid search + + [Fact] + public async Task HybridSearchSendsTheRrfOfTheVectorAndBm25SearchesAsync() + { + // Arrange: the collection has the BM25 index the provider creates. + using var sut = this.CreateHybridCollection(Bm25Index("Description_bm25", "Description")); + ChromaSearch? search = null; + this._collectionClientMock + .Setup(x => x.SearchAsync(It.IsAny(), null, this._testCancellationToken)) + .Callback((s, _, _) => search = s) + .ReturnsAsync( + [ + new ChromaSearchEntry("h1") { Score = 0.032f, Document = "A pool and a spa", Metadata = new Dictionary { ["Description"] = "A pool and a spa", ["Rating"] = 5L } }, + new ChromaSearchEntry("h2") { Score = 0.016f, Document = "A gym", Metadata = new Dictionary { ["Description"] = "A gym", ["Rating"] = 4L } }, + ]); + + // Act. + var results = await sut.HybridSearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), ["pool", "spa"], top: 2, new() { Skip = 1, Filter = h => h.Rating >= 4, ScoreThreshold = 0.02 }, this._testCancellationToken).ToListAsync(); + + // Assert. + Assert.Equal(2, search!.Limit); + Assert.Equal(1, search.Offset); + Assert.Equal("""{"Rating":{"$gte":4}}""", search.Where!.ToString()); + Assert.Equal([ChromaSearchKeys.Metadata, ChromaSearchKeys.Score, ChromaSearchKeys.Document], search.Select); + + // The threshold applies to the RRF score. + var result = Assert.Single(results); + Assert.Equal("h1", result.Record.HotelId); + Assert.Equal("A pool and a spa", result.Record.Description); + Assert.Equal(0.032, result.Score!.Value, precision: 6); + } + + [Fact] + public async Task HybridSearchReadsTheVectorsWhenAskedAsync() + { + // Arrange. + using var sut = this.CreateHybridCollection(Bm25Index("Description_bm25", "Description")); + ChromaSearch? search = null; + this._collectionClientMock + .Setup(x => x.SearchAsync(It.IsAny(), null, this._testCancellationToken)) + .Callback((s, _, _) => search = s) + .ReturnsAsync([new ChromaSearchEntry("h1") { Score = 0.032f, Embedding = new float[] { 1, 2, 3, 4 } }]); + + // Act. + var result = Assert.Single(await sut.HybridSearchAsync(new float[] { 1, 2, 3, 4 }, ["pool"], top: 1, new() { IncludeVectors = true }, this._testCancellationToken).ToListAsync()); + + // Assert. + Assert.Contains(ChromaSearchKeys.Embedding, search!.Select!); + Assert.Equal(new float[] { 1, 2, 3, 4 }, result.Record.Embedding!.Value.ToArray()); + } + + [Fact] + public async Task HybridSearchThrowsWithoutABm25IndexAsync() + { + // Arrange: the strict mock fails on any search. + using var sut = this.CreateHybridCollection(); + + // Act and assert. + var exception = await Assert.ThrowsAsync(async () => await sut.HybridSearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), ["pool"], top: 1, cancellationToken: this._testCancellationToken).ToListAsync()); + Assert.Contains("Chroma Cloud", exception.Message); + } + + [Fact] + public async Task HybridSearchThrowsWithoutAnIndexOnTheChosenPropertyAsync() + { + // Arrange: an index on the other full-text property only. + using var sut = this.CreateHybridCollection(Bm25Index("Description_bm25", "Description")); + + // Act and assert. + var exception = await Assert.ThrowsAsync(async () => await sut.HybridSearchAsync(new ReadOnlyMemory([1, 2, 3, 4]), ["great"], top: 1, new() { AdditionalProperty = h => h.Review }, this._testCancellationToken).ToListAsync()); + Assert.Contains("'Review'", exception.Message); + } + + [Fact] + public async Task HybridSearchCannotReadVectorsWithEmbeddingGenerationAsync() + { + // Arrange. + using var sut = new ChromaCollection(this._chromaClientMock.Object, TestCollectionName, ownsClient: false, new ChromaCollectionOptions { EmbeddingGenerator = new FakeEmbeddingGenerator() }); + + // Act and assert. + await Assert.ThrowsAsync(async () => await sut.HybridSearchAsync("a pool", ["pool"], top: 1, new() { IncludeVectors = true }, this._testCancellationToken).ToListAsync()); + } + + #endregion + + #region Get with a filter + + [Fact] + public async Task GetSendsTheKeysOfTheFilterInTheWhereClauseAsync() + { + // Arrange. + using var sut = this.CreateCollection>(); + this._collectionClientMock + .Setup(x => x.GetAsync( + null, + It.Is(where => where.ToString() == """{"$and":[{"#id":{"$in":["h1"]}},{"parking_is_included":{"$eq":true}}]}"""), + null, + 5, + 0, + ChromaGetInclude.Metadatas, + this._testCancellationToken)) + .ReturnsAsync([new ChromaCollectionEntry("h1")]); + + // Act. + var results = await sut.GetAsync(h => h.HotelId == "h1" && h.Parking, top: 5, cancellationToken: this._testCancellationToken).ToListAsync(); + + // Assert. + Assert.Equal("h1", Assert.Single(results).HotelId); + } + + [Fact] + public async Task GetWithAFilterPassesTopAndSkipAndReadsTheVectorsWhenAskedAsync() + { + // Arrange: Chroma reads the records with a limit and an offset. + using var sut = this.CreateCollection>(); + this._collectionClientMock + .Setup(x => x.GetAsync(null, It.IsNotNull(), null, 5, 1, ChromaGetInclude.Metadatas | ChromaGetInclude.Embeddings, this._testCancellationToken)) + .ReturnsAsync([new ChromaCollectionEntry("h1") { Embedding = new float[] { 1, 2, 3, 4 } }]); + + // Act. + var hotel = Assert.Single(await sut.GetAsync(h => h.Parking, top: 5, new() { Skip = 1, IncludeVectors = true }, this._testCancellationToken).ToListAsync()); + + // Assert. + Assert.Equal(new float[] { 1, 2, 3, 4 }, hotel.Embedding!.Value.ToArray()); + } + + [Fact] + public async Task TheTokenOfTheEnumeratorReachesChromaAsync() + { + // Arrange: a caller that passes a token to the method, and another one, cancelled, to the enumerator. + using var methodSource = new CancellationTokenSource(); + using var enumeratorSource = new CancellationTokenSource(); + enumeratorSource.Cancel(); + var collection = HybridChromaCollection(Bm25Index("Description_bm25", "Description")); + this._collectionClientMock + .Setup(x => x.FindBm25IndexAsync(It.IsAny(), It.Is(t => t.IsCancellationRequested))) + .ReturnsAsync((string key, CancellationToken _) => collection.FindBm25Index(key)); + using var sut = this.CreateCollection(); + this._collectionClientMock + .Setup(x => x.GetAsync(It.IsAny>(), null, null, null, null, ChromaGetInclude.Metadatas | ChromaGetInclude.Documents, It.Is(t => t.IsCancellationRequested))) + .ReturnsAsync([new ChromaCollectionEntry("h1")]); + this._collectionClientMock + .Setup(x => x.GetAsync(null, It.IsNotNull(), null, 2, 0, ChromaGetInclude.Metadatas | ChromaGetInclude.Documents, It.Is(t => t.IsCancellationRequested))) + .ReturnsAsync([new ChromaCollectionEntry("h1")]); + this._collectionClientMock + .Setup(x => x.QueryAsync(It.IsAny(), It.Is(t => t.IsCancellationRequested))) + .ReturnsAsync(new List> { new[] { new ChromaCollectionQueryEntry("h1") { Distance = 0.1f } } }); + this._collectionClientMock + .Setup(x => x.SearchAsync(It.IsAny(), null, It.Is(t => t.IsCancellationRequested))) + .ReturnsAsync([new ChromaSearchEntry("h1") { Score = 0.032f }]); + var vector = new float[] { 1, 2, 3, 4 }; + + // Act. + var byKeys = await ReadAllAsync(sut.GetAsync(["h1"], cancellationToken: methodSource.Token), enumeratorSource.Token); + var byFilter = await ReadAllAsync(sut.GetAsync(h => h.Rating > 1, top: 2, cancellationToken: methodSource.Token), enumeratorSource.Token); + var bySearch = await ReadAllAsync(sut.SearchAsync(vector, top: 2, cancellationToken: methodSource.Token), enumeratorSource.Token); + var byHybridSearch = await ReadAllAsync(sut.HybridSearchAsync(vector, ["pool"], top: 2, cancellationToken: methodSource.Token), enumeratorSource.Token); + + // Assert: each request was sent with the cancelled token, which the mocks require. + Assert.Single(byKeys); + Assert.Single(byFilter); + Assert.Single(bySearch); + Assert.Single(byHybridSearch); + + static async Task> ReadAllAsync(IAsyncEnumerable results, CancellationToken cancellationToken) + { + var list = new List(); + await foreach (var result in results.WithCancellation(cancellationToken)) + { + list.Add(result); + } + + return list; + } + } + + #endregion + + #region Errors + + [Fact] + public async Task ThrowsWhenTheCollectionIsMissingAsync() + { + // Arrange. + using var sut = this.CreateCollection>(); + this._collectionClientMock + .Setup(x => x.GetAsync(It.IsAny>(), null, null, null, null, ChromaGetInclude.Metadatas, this._testCancellationToken)) + .ThrowsAsync(new ChromaException("Collection does not exist.") { StatusCode = HttpStatusCode.NotFound, ErrorType = "NotFoundError" }); + + // Act and assert. + await Assert.ThrowsAsync(() => sut.GetAsync("h1", cancellationToken: this._testCancellationToken)); + } + + [Fact] + public async Task WrapsChromaExceptionsInVectorStoreExceptionsAsync() + { + // Arrange. + using var sut = this.CreateCollection>(); + this._chromaClientMock + .Setup(x => x.CollectionExistsAsync(TestCollectionName, null, null, this._testCancellationToken)) + .ThrowsAsync(new ChromaException("Unexpected status code")); + + // Act. + var exception = await Assert.ThrowsAsync(() => sut.CollectionExistsAsync(this._testCancellationToken)); + + // Assert. + Assert.Equal("chroma", exception.VectorStoreSystemName); + Assert.IsType(exception.InnerException); + } + + #endregion + + private Func CaptureUpsert() + { + ChromaRecords? records = null; + this._collectionClientMock + .Setup(x => x.UpsertAsync(It.IsAny(), this._testCancellationToken)) + .Callback((r, _) => records = r) + .Returns(Task.CompletedTask); + return () => records!; + } + + // A query of one embedding, which the client answers with the given records. + private Func SetupQuery(params ChromaCollectionQueryEntry[] results) + { + ChromaQuery? query = null; + this._collectionClientMock + .Setup(x => x.QueryAsync(It.IsAny(), this._testCancellationToken)) + .Callback((q, _) => query = q) + .ReturnsAsync(new List> { results }); + return () => query!; + } + + // The collection is not read. + private ChromaCollection CreateCollection() + where TKey : notnull + where TRecord : class + => new(this._chromaClientMock.Object, TestCollectionName, ownsClient: false); + + private ChromaCollection CreateHybridCollection(params string[] indexes) + where TRecord : class + { + // The client finds the BM25 index of a key in the schema of the collection. + var collection = HybridChromaCollection(indexes); + this._collectionClientMock + .Setup(x => x.FindBm25IndexAsync(It.IsAny(), this._testCancellationToken)) + .ReturnsAsync((string key, CancellationToken _) => collection.FindBm25Index(key)); + + return new(this._chromaClientMock.Object, TestCollectionName, ownsClient: false); + } + + // A collection of Chroma Cloud with the given sparse vector indexes in its schema. + private static ChromaCollection HybridChromaCollection(params string[] indexes) + => new(TestCollectionName) { Id = Guid.NewGuid(), SchemaJson = JsonDocument.Parse("{\"keys\":{" + string.Join(",", indexes) + "}}").RootElement.Clone() }; + + // A sparse vector index as Chroma Cloud returns it in the schema of a collection. + private static string Bm25Index(string key, string sourceKey) + => "\"" + key + "\":{\"sparse_vector\":{\"sparse_vector_index\":{\"enabled\":true,\"config\":{\"source_key\":\"" + sourceKey + "\",\"bm25\":true," + + "\"embedding_function\":{\"type\":\"known\",\"name\":\"chroma_bm25\",\"config\":{\"k\":1.2,\"b\":0.75,\"avg_doc_length\":256,\"token_max_length\":40}}}}}}"; +} diff --git a/MEVD/test/Chroma.UnitTests/ChromaFilterTranslatorTests.cs b/MEVD/test/Chroma.UnitTests/ChromaFilterTranslatorTests.cs new file mode 100644 index 0000000..442e4d4 --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaFilterTranslatorTests.cs @@ -0,0 +1,401 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using ChromaDB.Client; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.VectorData; +using Xunit; + +namespace Chroma.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChromaFilterTranslatorTests +{ + [Fact] + public void TranslatesContainsOverAnInlineArray() + => Assert.Equal("""{"HotelName":{"$nin":["a","b"]}}""", Translate(h => !new[] { "a", "b" }.Contains(h.HotelName))); + + [Fact] + public void TranslatesFalseToNoRecord() + => Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => false)); + + [Fact] + public void ThrowsForAComparisonOnAString() + => Assert.Throws(() => Translate(h => string.Compare(h.HotelName, "b", StringComparison.Ordinal) > 0)); + + [Fact] + public void TranslatesANegatedContainsOverAnArrayProperty() + => Assert.Equal("""{"Tags":{"$not_contains":"pool"}}""", Translate(h => !h.Tags!.Contains("pool"))); + + [Fact] + public void TranslatesANegatedAnyWithContainsToAnAndOfNotContains() + => Assert.Equal( + """{"$and":[{"Tags":{"$not_contains":"pool"}},{"Tags":{"$not_contains":"spa"}}]}""", + Translate(h => !h.Tags!.Any(t => new[] { "pool", "spa" }.Contains(t)))); + + [Fact] + public void TranslatesAnyOverAnEmptyArrayToNoRecord() + => Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => h.Tags!.Any(t => new string[0].Contains(t)))); + + [Fact] + public void TranslatesANegatedAnyOverAnEmptyArrayToMatchAll() + => AssertMatchesAll(TranslateFilter(h => !h.Tags!.Any(t => new string[0].Contains(t)))); + + [Fact] + public void TranslatesContainsOverAnEmptyInlineArrayToNoRecord() + => Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => new string[0].Contains(h.HotelName))); + + [Fact] + public void TranslatesContainsOverAnEmptyCapturedListToNoRecord() + { + var names = new List(); + + Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => names.Contains(h.HotelName!))); + } + + [Fact] + public void TranslatesANegatedContainsOverAnEmptyInlineArrayToMatchAll() + => AssertMatchesAll(TranslateFilter(h => !new string[0].Contains(h.HotelName))); + + [Fact] + public void DropsAnOrBranchThatMatchesNoRecord() + => Assert.Equal("""{"Rating":{"$gte":4}}""", Translate(h => new string[0].Contains(h.HotelName) || h.Rating >= 4)); + + [Fact] + public void TranslatesAnAndWithABranchThatMatchesNoRecordToNoRecord() + => Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => new string[0].Contains(h.HotelName) && h.Rating >= 4)); + + [Fact] + public void TranslatesKeyEqualityToTheIds() + => Assert.Equal("""{"#id":{"$in":["h1"]}}""", Translate(h => h.HotelId == "h1")); + + [Fact] + public void TranslatesKeyEqualityWithTheConstantOnTheLeftToTheIds() + => Assert.Equal("""{"#id":{"$in":["h1"]}}""", Translate(h => "h1" == h.HotelId)); + + [Fact] + public void TranslatesContainsOverAListOfKeysToTheIds() + { + var keys = new List { "h1", "h2", "h1" }; + + Assert.Equal("""{"#id":{"$in":["h1","h2"]}}""", Translate(h => keys.Contains(h.HotelId))); + } + + [Fact] + public void TranslatesKeyConditionsWithTheOtherConditions() + => Assert.Equal( + """{"$or":[{"$and":[{"Rating":{"$gte":4}},{"#id":{"$in":["h1","h2"]}}]},{"#id":{"$nin":["h3"]}}]}""", + Translate(h => h.Rating >= 4 && new[] { "h1", "h2" }.Contains(h.HotelId) || h.HotelId != "h3")); + + [Fact] + public void TranslatesContainsOverAnEmptyListOfKeysToNoRecord() + => Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => new string[0].Contains(h.HotelId))); + + [Fact] + public void TranslatesAGuidKeyToItsId() + { + var key = new Guid("11111111-2222-3333-4444-555555555555"); + + Assert.Equal("""{"#id":{"$in":["11111111-2222-3333-4444-555555555555"]}}""", Translate>(h => h.HotelId == key).ToString()); + } + + [Fact] + public void ThrowsForTheKeyComparedWithAProperty() + => Assert.Throws(() => Translate(h => h.HotelId == h.HotelName)); + + [Fact] + public void TranslatesContainsOnTheFullTextPropertyToADocumentFilter() + => Assert.Equal("""{"#document":{"$contains":"pool"}}""", TranslateFullText(h => h.Description!.Contains("pool")).ToString()); + + [Fact] + public void TranslatesTextConditionsWithTheOtherConditions() + => Assert.Equal( + """{"$or":[{"$and":[{"#document":{"$contains":"pool"}},{"Rating":{"$gte":4}}]},{"#document":{"$not_contains":"spa"}}]}""", + TranslateFullText(h => h.Description!.Contains("pool") && h.Rating >= 4 || !h.Description.Contains("spa")).ToString()); + + [Fact] + public void ThrowsForContainsOnAStringPropertyThatIsNotTheDocument() + => Assert.Throws(() => TranslateFilter(h => h.HotelName!.Contains("Grand"))); + + [Fact] + public void EqualityOnADateTimeOffsetMatchesItsUtcForm() + { + var opened = new DateTimeOffset(2026, 10, 5, 13, 0, 0, TimeSpan.FromHours(2)); + + Assert.Equal("""{"Opened":{"$eq":"2026-10-05T11:00:00.0000000+00:00"}}""", TranslateDated(h => h.Opened == opened)); + Assert.Equal("""{"Opened":{"$ne":"2026-10-05T11:00:00.0000000+00:00"}}""", TranslateDated(h => h.Opened != opened)); + } + + [Fact] + public void EqualityOnADateTimeMatchesItsOwnKind() + { + var updated = new DateTime(2026, 10, 5, 11, 0, 0, DateTimeKind.Unspecified); + var utc = DateTime.SpecifyKind(updated, DateTimeKind.Utc); + + Assert.Equal("""{"Updated":{"$eq":"2026-10-05T11:00:00.0000000"}}""", TranslateDated(h => h.Updated == updated)); + Assert.Equal("""{"Updated":{"$eq":"2026-10-05T11:00:00.0000000Z"}}""", TranslateDated(h => h.Updated == utc)); + } + + [Fact] + public void ContainsOnADateTimeOffsetArrayMatchesItsUtcForm() + { + var visit = new DateTimeOffset(2026, 10, 5, 13, 0, 0, TimeSpan.FromHours(2)); + + Assert.Equal("""{"Visits":{"$contains":"2026-10-05T11:00:00.0000000+00:00"}}""", TranslateDated(h => h.Visits!.Contains(visit))); + Assert.Equal("""{"Visits":{"$not_contains":"2026-10-05T11:00:00.0000000+00:00"}}""", TranslateDated(h => !h.Visits!.Contains(visit))); + } + + [Theory] + [InlineData(">", """{"Price":{"$gt":5.0}}""")] + [InlineData("<=", """{"Price":{"$lte":5.0}}""")] + [InlineData("5 <", """{"Price":{"$gt":5.0}}""")] + [InlineData("5 <=", """{"Price":{"$gte":5.0}}""")] + [InlineData("5 >=", """{"Price":{"$lte":5.0}}""")] + [InlineData("!>", """{"Price":{"$lte":5.0}}""")] + [InlineData("!<", """{"Price":{"$gte":5.0}}""")] + [InlineData("!<=", """{"Price":{"$gt":5.0}}""")] + public void TranslatesEachComparison(string comparison, string expected) + { + Expression, bool>> filter = comparison switch + { + ">" => h => h.Price > 5, + "<=" => h => h.Price <= 5, + "5 <" => h => 5 < h.Price, + "5 <=" => h => 5 <= h.Price, + "5 >=" => h => 5 >= h.Price, + "!>" => h => !(h.Price > 5), + "!<" => h => !(h.Price < 5), + "!<=" => h => !(h.Price <= 5), + _ => throw new ArgumentOutOfRangeException(nameof(comparison)) + }; + + Assert.Equal(expected, Translate(filter)); + } + + [Fact] + public void ConvertsACapturedNumberToTheTypeOfTheProperty() + { + // The compiler converts a captured number to the type of the property, as C# converts it implicitly. + var intValue = 5; + var longValue = 5L; + var floatValue = 5.5f; + + Assert.Equal("""{"Price":{"$gt":5.0}}""", Translate(h => h.Price > intValue)); + Assert.Equal("""{"Price":{"$gt":5.0}}""", Translate(h => h.Price > longValue)); + Assert.Equal("""{"Price":{"$gt":5.5}}""", Translate(h => h.Price > floatValue)); + Assert.Equal("""{"Stars":{"$lt":5.0}}""", Translate(h => h.Stars < intValue).ToString()); + Assert.Equal("""{"Stars":{"$lt":5.0}}""", Translate(h => h.Stars < longValue).ToString()); + Assert.Equal("""{"Visits":{"$lte":5}}""", Translate(h => h.Visits <= intValue).ToString()); + } + + [Fact] + public void ThrowsForAnExplicitCastOfACapturedNumber() + { + // An explicit cast can change the value: (long)5.5f is 5. + var floatValue = 5.5f; + var doubleValue = 5.5; + var decimalValue = 5.5m; + var longValue = 5L; + + Assert.Throws(() => Translate(h => h.Visits >= (long)floatValue)); + Assert.Throws(() => Translate(h => h.Stars >= (float)doubleValue)); + Assert.Throws(() => Translate(h => h.Price >= (double)decimalValue)); + Assert.Throws(() => TranslateFullText(h => h.Rating == (int)longValue)); + } + + [Fact] + public void ThrowsForAComparisonOnADate() + => Assert.Throws(() => TranslateDated(h => h.Opened > DateTimeOffset.UnixEpoch)); + + [Fact] + public void TranslatesABoolPropertyBehindACast() + => Assert.Equal("""{"parking_is_included":{"$eq":true}}""", Translate(h => (bool)(object)h.Parking)); + + [Fact] + public void TranslatesAComparisonWithNullToNoRecordAndItsNegationToEveryRecord() + { + // As in C#, a comparison with null is false whatever the value. + int? none = null; + + Assert.Same(ChromaWhereOperator.None, TranslateFilter(h => h.Rating > none)); + AssertMatchesAll(TranslateFilter(h => !(h.Rating > none))); + } + + [Fact] + public void ThrowsForANegatedComparisonOnANullableProperty() + { + Assert.Throws(() => Translate(h => !(h.Rating > 4))); + Assert.Throws(() => Translate(h => !(h.Rating > 4 && h.Parking))); + Assert.Equal("""{"Rating":{"$gt":4}}""", Translate(h => !!(h.Rating > 4))); + } + + [Fact] + public void ThrowsForAFilterOnTheVector() + => Assert.Throws(() => Translate(h => h.Embedding!.Contains(1f))); + + [Fact] + public void ThrowsForAValueOfAnotherType() + { + var values = new List { Guid.NewGuid() }; + + Assert.Throws(() => TranslateFilter(h => Enumerable.Contains(h.HotelName!, 'G'))); + Assert.Throws(() => TranslateFilter(h => values.Contains(h.HotelName!))); + } + + [Fact] + public void ThrowsForAComparisonOfTwoProperties() + => Assert.Throws(() => Translate(h => h.Price > h.Price)); + + [Fact] + public void ThrowsForAnEqualityOfTwoProperties() + => Assert.Throws(() => Translate(h => h.HotelName == h.HotelName)); + + [Fact] + public void ThrowsForAnEqualityWithoutAProperty() + { + var name = "Grand"; + + Assert.Throws(() => Translate(h => name == "Grand")); + } + + [Fact] + public void ThrowsForAnArrayComparedWithAnArray() + { + var tags = new List { "pool" }; + + Assert.Throws(() => Translate(h => h.Tags == tags)); + } + + [Fact] + public void ThrowsForAnUnsupportedNodeType() + => Assert.Throws(() => Translate(h => h.Parking ? h.Rating > 1 : h.Rating < 1)); + + [Fact] + public void ThrowsForAnUnsupportedMethod() + => Assert.Throws(() => Translate(h => h.Tags!.Any())); + + [Fact] + public void ThrowsForContainsOverAnArrayPropertyWithAnotherProperty() + => Assert.Throws(() => Translate(h => h.Tags!.Contains(h.HotelName!))); + + [Fact] + public void ThrowsForContainsOverAnExpressionThatIsNeitherAPropertyNorAList() + => Assert.Throws(() => Translate(h => h.Tags!.Concat(new[] { "spa" }).Contains("pool"))); + + [Fact] + public void ThrowsForContainsOverAListOfAConstant() + => Assert.Throws(() => Translate(h => new[] { "pool" }.Contains("spa"))); + + [Fact] + public void ThrowsForAnInlineArrayWithAnElementThatIsNotAConstant() + => Assert.Throws(() => Translate(h => new[] { h.HotelId }.Contains(h.HotelName))); + + [Fact] + public void ThrowsForAnInlineArrayWithALength() + => Assert.Throws(() => Translate(h => new string[2].Contains(h.HotelName))); + + [Fact] + public void TranslatesContainsOverACapturedList() + { + var names = new List { "a", "b" }; + + Assert.Equal("""{"HotelName":{"$in":["a","b"]}}""", Translate(h => names.Contains(h.HotelName!))); + } + + [Theory] + [InlineData("not a property")] + [InlineData("not a method")] + [InlineData("not contains")] + [InlineData("not the element")] + [InlineData("not a list")] + public void ThrowsForAnyThatIsNotAContainsOverTheElements(string any) + { + Expression, bool>> filter = any switch + { + "not a property" => h => new[] { "pool" }.Any(t => new[] { "pool" }.Contains(t)), + "not a method" => h => h.Tags!.Any(t => t == "pool"), + "not contains" => h => h.Tags!.Any(t => t.StartsWith("p")), + "not the element" => h => h.Tags!.Any(t => new[] { "pool" }.Contains(h.HotelName)), + "not a list" => h => h.Tags!.Any(t => h.Tags!.Contains(t)), + _ => throw new ArgumentOutOfRangeException(nameof(any)) + }; + + Assert.Throws(() => Translate(filter)); + } + + [Fact] + public void ThrowsForANullKey() + => Assert.Throws(() => TranslateFilter(h => new[] { "h1", null }.Contains(h.HotelId))); + + [Fact] + public void ThrowsForAKeyInAListThatIsNotAConstant() + => Assert.Throws(() => TranslateFilter(h => h.Tags!.Contains(h.HotelId))); + + [Fact] + public void ThrowsForATextConditionWithoutAConstant() + => Assert.Throws(() => TranslateFullText(h => h.Description!.Contains(h.HotelId))); + + [Theory] + [InlineData("all and")] + [InlineData("and all")] + [InlineData("or nothing")] + [InlineData("nothing and or")] + [InlineData("and nothing or")] + public void SimplifiesConditionsThatMatchEverythingOrNothing(string condition) + { + var none = Array.Empty(); + Expression, bool>> filter = condition switch + { + "all and" => h => (true && h.Rating >= 4) || none.Contains(h.HotelName!), + "and all" => h => (h.Rating >= 4 && true) || none.Contains(h.HotelName!), + "or nothing" => h => h.Rating >= 4 || none.Contains(h.HotelName!), + "nothing and or" => h => (none.Contains(h.HotelName!) && h.Parking) || h.Rating >= 4, + "and nothing or" => h => (h.Parking && none.Contains(h.HotelName!)) || h.Rating >= 4, + _ => throw new ArgumentOutOfRangeException(nameof(condition)) + }; + + Assert.Equal("""{"Rating":{"$gte":4}}""", Translate(filter)); + } + + [Theory] + [InlineData("true or")] + [InlineData("or true")] + public void TranslatesAnOrWithASideThatMatchesEverythingToNoFilter(string condition) + { + Expression, bool>> filter = condition == "true or" + ? h => true || h.Parking + : h => h.Parking || true; + + AssertMatchesAll(TranslateFilter(filter)); + } + + // The JSON of the client escapes the + of an offset as \u002B, as System.Text.Json does: put it back to read the dates. + private static string TranslateDated(Expression> filter) + => Translate(filter).ToString().Replace("\\u002B", "+"); + + private static ChromaWhereOperator TranslateFullText(Expression> filter) + => Translate(filter); + + // The filter of a collection of the record: with the property stored as the document, as the collection gives it. + private static ChromaWhereOperator Translate(Expression> filter) + { + var model = ChromaTestModel.Build(); + return new ChromaFilterTranslator().Translate(filter, model, ChromaFieldMapping.GetDocumentProperty(model)); + } + + // The JSON that ChromaDotNet.Client sends in the where clause. + private static string Translate(Expression, bool>> filter) + => TranslateFilter(filter).ToString()!; + + private static ChromaWhereOperator TranslateFilter(Expression, bool>> filter) + => Translate>(filter); + + private static void AssertMatchesAll(ChromaWhereOperator filter) + => Assert.Same(ChromaWhereOperator.All, filter); +} diff --git a/MEVD/test/Chroma.UnitTests/ChromaHotel.cs b/MEVD/test/Chroma.UnitTests/ChromaHotel.cs new file mode 100644 index 0000000..016f95c --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaHotel.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.VectorData.Chroma; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData.ProviderServices; + +namespace Chroma.UnitTests; + +/// +/// Builds the model of a test record, as the collection does. +/// +internal static class ChromaTestModel +{ + public static CollectionModel Build(IEmbeddingGenerator? embeddingGenerator = null) + => new ChromaModelBuilder().Build( + typeof(TRecord), + typeof(TRecord).GetProperties().Single(property => property.IsDefined(typeof(VectorStoreKeyAttribute), inherit: false)).PropertyType, + definition: null, + embeddingGenerator); +} + +public sealed class ChromaHotel +{ + [VectorStoreKey] + public TKey HotelId { get; set; } = default!; + + [VectorStoreData] + public string? HotelName { get; set; } + + [VectorStoreData] + public int? Rating { get; set; } + + [VectorStoreData] + public double Price { get; set; } + + [VectorStoreData(StorageName = "parking_is_included")] + public bool Parking { get; set; } + + [VectorStoreData] + public List? Tags { get; set; } + + [VectorStoreVector(4)] + public ReadOnlyMemory? Embedding { get; set; } +} + +public sealed class DatedHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreData] + public DateTimeOffset Opened { get; set; } + + [VectorStoreData] + public DateTime Updated { get; set; } + + [VectorStoreData] + public List? Visits { get; set; } + + [VectorStoreVector(4)] + public ReadOnlyMemory? Embedding { get; set; } +} + +public sealed class DotProductHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreVector(4, DistanceFunction = DistanceFunction.DotProductSimilarity)] + public ReadOnlyMemory? Embedding { get; set; } +} + +public sealed class TwoFullTextHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreData(IsFullTextIndexed = true)] + public string? Description { get; set; } + + [VectorStoreData(IsFullTextIndexed = true)] + public string? Review { get; set; } + + [VectorStoreVector(4)] + public ReadOnlyMemory? Embedding { get; set; } +} + +public sealed class FullTextHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreData(IsFullTextIndexed = true)] + public string? Description { get; set; } + + [VectorStoreData] + public int Rating { get; set; } + + [VectorStoreVector(4)] + public ReadOnlyMemory? Embedding { get; set; } +} + +public sealed class NumberHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreData] + public long Visits { get; set; } + + [VectorStoreData] + public float Stars { get; set; } + + [VectorStoreVector(4)] + public ReadOnlyMemory? Embedding { get; set; } +} + +public sealed class EmbeddingHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreVector(4)] + public Embedding? Embedding { get; set; } +} + +public sealed class ArrayHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreVector(4)] + public float[]? Embedding { get; set; } +} + +public sealed class TextHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreVector(4)] + public string? Embedding { get; set; } +} + +public sealed class FullTextTextHotel +{ + [VectorStoreKey] + public string HotelId { get; set; } = default!; + + [VectorStoreData(IsFullTextIndexed = true)] + public string? Description { get; set; } + + [VectorStoreVector(4)] + public string? Embedding { get; set; } +} diff --git a/MEVD/test/Chroma.UnitTests/ChromaMapperTests.cs b/MEVD/test/Chroma.UnitTests/ChromaMapperTests.cs new file mode 100644 index 0000000..58de8ff --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaMapperTests.cs @@ -0,0 +1,281 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.VectorData.Chroma; +using InMemory.UnitTests; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Xunit; + +namespace Chroma.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChromaMapperTests +{ + [Fact] + public void MapsAGuidKeyToItsStringForm() + { + // Arrange. + var key = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + var hotel = new ChromaHotel { HotelId = key, Embedding = new float[] { 1, 2, 3, 4 } }; + + // Act. + var storageRecord = sut.MapFromDataToStorageModel(hotel, 0, generatedEmbeddings: null); + + // Assert. + Assert.Equal("11111111-1111-1111-1111-111111111111", storageRecord.Id); + Assert.Equal(new float[] { 1, 2, 3, 4 }, storageRecord.Embedding.ToArray()); + } + + [Fact] + public void WritesNullPropertiesAsExplicitNulls() + { + // An upsert of an existing record merges its metadata in Chroma: an explicit null deletes the old value. + // Arrange. + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + var hotel = new ChromaHotel { HotelId = "h1", HotelName = null, Rating = 4, Embedding = new float[] { 1, 2, 3, 4 } }; + + // Act. + var metadata = sut.MapFromDataToStorageModel(hotel, 0, generatedEmbeddings: null).Metadata!; + + // Assert. + Assert.True(metadata.ContainsKey("HotelName")); + Assert.Null(metadata["HotelName"]); + Assert.Equal(4, metadata["Rating"]); + Assert.Equal(0d, metadata["Price"]); + Assert.Equal(false, metadata["parking_is_included"]); + } + + [Fact] + public void WritesEmptyListsAsExplicitNulls() + { + // Arrange. + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + var hotel = new ChromaHotel { HotelId = "h1", Tags = [], Embedding = new float[] { 1, 2, 3, 4 } }; + + // Act. + var metadata = sut.MapFromDataToStorageModel(hotel, 0, generatedEmbeddings: null).Metadata!; + + // Assert. + Assert.True(metadata.ContainsKey("Tags")); + Assert.Null(metadata["Tags"]); + } + + [Fact] + public void WritesNoDocumentForANullFullTextProperty() + { + // Arrange: the collection empties the document of a record that exists. + var sut = new ChromaMapper(ChromaTestModel.Build()); + var hotel = new FullTextHotel { HotelId = "h1", Description = null, Embedding = new float[] { 1, 2, 3, 4 } }; + + // Act. + var record = sut.MapFromDataToStorageModel(hotel, 0, generatedEmbeddings: null); + + // Assert. + Assert.Null(record.Document); + Assert.False(record.Metadata!.ContainsKey("Description")); + } + + [Fact] + public void ReadsTheDocumentAsTheClientGivesIt() + { + var sut = new ChromaMapper(ChromaTestModel.Build()); + var copy = new Dictionary { ["Description"] = "A copy" }; + + Assert.Equal("", sut.MapFromStorageToDataModel("h1", embedding: null, metadata: copy, document: "", includeVectors: false).Description); + Assert.Null(sut.MapFromStorageToDataModel("h1", embedding: null, metadata: copy, document: null, includeVectors: false).Description); + } + + [Fact] + public void WritesTheFullTextPropertyAsTheDocument() + { + // Arrange. + var sut = new ChromaMapper(ChromaTestModel.Build()); + var hotel = new FullTextHotel { HotelId = "h1", Description = "A pool and a spa", Embedding = new float[] { 1, 2, 3, 4 } }; + + // Act. + var record = sut.MapFromDataToStorageModel(hotel, 0, generatedEmbeddings: null); + + // Assert. + Assert.Equal("A pool and a spa", record.Document); + Assert.False(record.Metadata!.ContainsKey("Description")); + } + + [Fact] + public void ReadsTheFullTextPropertyFromTheDocumentWhenTheMetadataLacksIt() + { + // Arrange: a record written by another Chroma client, with its text in the document only. + var sut = new ChromaMapper(ChromaTestModel.Build()); + + // Act. + var hotel = sut.MapFromStorageToDataModel("h1", embedding: null, metadata: null, document: "A pool and a spa", includeVectors: false); + + // Assert. + Assert.Equal("A pool and a spa", hotel.Description); + } + + [Fact] + public void WritesNoDocumentWithoutAFullTextProperty() + => Assert.Null(new ChromaMapper>(ChromaTestModel.Build>()) + .MapFromDataToStorageModel(new ChromaHotel { HotelId = "h1", HotelName = "Grand", Embedding = new float[] { 1, 2, 3, 4 } }, 0, generatedEmbeddings: null) + .Document); + + [Fact] + public void WritesNoDocumentWithTwoFullTextProperties() + => Assert.Null(new ChromaMapper(ChromaTestModel.Build()) + .MapFromDataToStorageModel(new TwoFullTextHotel { HotelId = "h1", Description = "A pool", Review = "Great", Embedding = new float[] { 1, 2, 3, 4 } }, 0, generatedEmbeddings: null) + .Document); + + [Fact] + public void ThrowsWhenTheVectorIsMissing() + { + // Arrange. + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + var hotel = new ChromaHotel { HotelId = "h1" }; + + // Act and assert. + Assert.Throws(() => sut.MapFromDataToStorageModel(hotel, 0, generatedEmbeddings: null)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MapsAChromaRecordToTheDataModel(bool includeVectors) + { + // Arrange. + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + var metadata = new Dictionary { ["HotelName"] = "Grand", ["Rating"] = 5L, ["Price"] = 120.5, ["parking_is_included"] = true }; + + // Act. + var hotel = sut.MapFromStorageToDataModel("11111111-1111-1111-1111-111111111111", new float[] { 1, 2, 3, 4 }, metadata, document: null, includeVectors); + + // Assert. + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), hotel.HotelId); + Assert.Equal("Grand", hotel.HotelName); + Assert.Equal(5, hotel.Rating); + Assert.Equal(120.5, hotel.Price); + Assert.True(hotel.Parking); + Assert.Equal(includeVectors, hotel.Embedding.HasValue); + } + + [Fact] + public void ThrowsWhenTheKeyIsMissing() + { + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + + Assert.Throws(() => sut.MapFromDataToStorageModel(new ChromaHotel { HotelId = null!, Embedding = new float[] { 1, 2, 3, 4 } }, 0, generatedEmbeddings: null)); + } + + [Fact] + public void WritesAndReadsAnEmbeddingVector() + { + // Arrange. + var sut = new ChromaMapper(ChromaTestModel.Build()); + + // Act. + var storageRecord = sut.MapFromDataToStorageModel(new EmbeddingHotel { HotelId = "h1", Embedding = new Embedding(new float[] { 1, 2, 3, 4 }) }, 0, generatedEmbeddings: null); + var hotel = sut.MapFromStorageToDataModel("h1", new float[] { 1, 2, 3, 4 }, metadata: null, document: null, includeVectors: true); + + // Assert. + Assert.Equal(new float[] { 1, 2, 3, 4 }, storageRecord.Embedding.ToArray()); + Assert.Null(storageRecord.Metadata); + Assert.Equal(new float[] { 1, 2, 3, 4 }, hotel.Embedding!.Vector.ToArray()); + } + + [Fact] + public void WritesAndReadsAnArrayVector() + { + // Arrange. + var sut = new ChromaMapper(ChromaTestModel.Build()); + + // Act. + var storageRecord = sut.MapFromDataToStorageModel(new ArrayHotel { HotelId = "h1", Embedding = [1, 2, 3, 4] }, 0, generatedEmbeddings: null); + var hotel = sut.MapFromStorageToDataModel("h1", new float[] { 1, 2, 3, 4 }, metadata: null, document: null, includeVectors: true); + + // Assert. + Assert.Equal(new float[] { 1, 2, 3, 4 }, storageRecord.Embedding.ToArray()); + Assert.Equal(new float[] { 1, 2, 3, 4 }, hotel.Embedding); + } + + [Fact] + public void WritesTheGeneratedEmbeddingOfTheRecord() + { + // Arrange. + var sut = new ChromaMapper(ChromaTestModel.Build(new FakeEmbeddingGenerator())); + var generated = new GeneratedEmbeddings>([new Embedding(new float[] { 1, 2, 3, 4 }), new Embedding(new float[] { 5, 6, 7, 8 })]); + + // Act. + var storageRecord = sut.MapFromDataToStorageModel(new TextHotel { HotelId = "h2", Embedding = "a spa" }, 1, [generated]); + + // Assert. + Assert.Equal(new float[] { 5, 6, 7, 8 }, storageRecord.Embedding.ToArray()); + } + + [Fact] + public void ReadsNoVectorWhenChromaReturnsNone() + { + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + + Assert.Null(sut.MapFromStorageToDataModel("h1", embedding: null, metadata: null, document: null, includeVectors: true).Embedding); + } + + public static TheoryData ValuesThatCannotBeRead => new() + { + // Another type, as another Chroma client may write under the key of a property. + { "Rating", "five" }, + // A number too large for the property. + { "Rating", long.MaxValue }, + }; + + [Theory] + [MemberData(nameof(ValuesThatCannotBeRead))] + public void ThrowsAnErrorThatNamesThePropertyAndTheRecordForAValueThatCannotBeRead(string key, object value) + { + // Arrange. + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + + // Act. + var exception = Assert.Throws(() => sut.MapFromStorageToDataModel("h1", embedding: null, new Dictionary { [key] = value }, document: null, includeVectors: false)); + + // Assert. + Assert.Contains($"'{key}'", exception.Message); + Assert.Contains("'h1'", exception.Message); + Assert.NotNull(exception.InnerException); + } + + [Fact] + public void ThrowsAnErrorThatNamesThePropertyForADateThatDoesNotParse() + { + // Arrange. + var sut = new ChromaMapper(ChromaTestModel.Build()); + + // Act. + var exception = Assert.Throws(() => sut.MapFromStorageToDataModel("h1", embedding: null, new Dictionary { ["Opened"] = "not a date" }, document: null, includeVectors: false)); + + // Assert. + Assert.Contains("'Opened'", exception.Message); + Assert.IsType(Assert.IsType(exception.InnerException).InnerException); + } + + [Fact] + public void ReadsAMissingMetadataValueAsNull() + { + // Arrange. + var sut = new ChromaMapper>(ChromaTestModel.Build>()); + + // Act. + var hotel = sut.MapFromStorageToDataModel("h1", embedding: null, new Dictionary { ["Price"] = 10.0 }, document: null, includeVectors: false); + + // Assert. + Assert.Null(hotel.HotelName); + Assert.Null(hotel.Rating); + Assert.Null(hotel.Tags); + Assert.Equal(10.0, hotel.Price); + } +} diff --git a/MEVD/test/Chroma.UnitTests/ChromaVectorStoreTests.cs b/MEVD/test/Chroma.UnitTests/ChromaVectorStoreTests.cs new file mode 100644 index 0000000..7a58d24 --- /dev/null +++ b/MEVD/test/Chroma.UnitTests/ChromaVectorStoreTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ChromaDB.Client; +using ChromaDB.Client.Models; +using CommunityToolkit.VectorData.Chroma; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Chroma.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChromaVectorStoreTests +{ + private const string TestCollectionName = "testcollection"; + + private readonly Mock _chromaClientMock = new(MockBehavior.Strict); + + // A token that is not the default one, so that the strict mocks also check that it reaches the client. + private readonly CancellationToken _testCancellationToken = TestContext.Current.CancellationToken; + + public ChromaVectorStoreTests() + { + this._chromaClientMock + .Setup(x => x.Options) + .Returns(new ChromaConfigurationOptions("http://localhost:8000")); + var collectionClientMock = new Mock(MockBehavior.Strict); + collectionClientMock + .Setup(x => x.WithMetadataValues(ChromaMetadataValues.Exact)) + .Returns(collectionClientMock.Object); + this._chromaClientMock + .Setup(x => x.GetCollectionClient(It.IsAny())) + .Returns(collectionClientMock.Object); + } + + [Fact] + public void GetCollectionReturnsChromaCollection() + { + // Arrange. + using var sut = new ChromaVectorStore(this._chromaClientMock.Object, ownsClient: false); + + // Act. + using var actual = sut.GetCollection>(TestCollectionName); + + // Assert. + Assert.IsType>>(actual); + } + + [Fact] + public void GetCollectionThrowsForInvalidKeyType() + { + // Arrange. + using var sut = new ChromaVectorStore(this._chromaClientMock.Object, ownsClient: false); + + // Act & Assert. + Assert.Throws(() => sut.GetCollection>(TestCollectionName)); + } + + [Fact] + public async Task ListCollectionNamesCallsClientAsync() + { + // Arrange. + this._chromaClientMock + .Setup(x => x.ListCollectionsAsync(null, null, this._testCancellationToken)) + .ReturnsAsync([new ChromaCollection("collection1"), new ChromaCollection("collection2")]); + using var sut = new ChromaVectorStore(this._chromaClientMock.Object, ownsClient: false); + + // Act. + var collectionNames = await sut.ListCollectionNamesAsync(this._testCancellationToken).ToListAsync(); + + // Assert. + Assert.Equal(["collection1", "collection2"], collectionNames); + } + + [Fact] + public void DisposingTwiceReleasesTheClientOnce() + { + // Arrange. + this._chromaClientMock.Protected().Setup("Dispose", ItExpr.IsAny()); + var sut = new ChromaVectorStore(this._chromaClientMock.Object, ownsClient: true); + var collection = sut.GetCollection>(TestCollectionName); + + // Act: the collection still uses the client after the store is disposed. + sut.Dispose(); + sut.Dispose(); + this._chromaClientMock.Protected().Verify("Dispose", Times.Never(), ItExpr.IsAny()); + collection.Dispose(); + collection.Dispose(); + + // Assert. + this._chromaClientMock.Protected().Verify("Dispose", Times.Once(), ItExpr.IsAny()); + } + + [Fact] + public async Task CollectionExistsAndEnsureCollectionDeletedReleaseTheClientAsync() + { + // Arrange. + this._chromaClientMock.Protected().Setup("Dispose", ItExpr.IsAny()); + this._chromaClientMock + .Setup(x => x.CollectionExistsAsync(TestCollectionName, null, null, this._testCancellationToken)) + .ReturnsAsync(true); + this._chromaClientMock + .Setup(x => x.DeleteCollectionIfExistsAsync(TestCollectionName, null, null, true, this._testCancellationToken)) + .ReturnsAsync(true); + var sut = new ChromaVectorStore(this._chromaClientMock.Object, ownsClient: true); + + // Act. + Assert.True(await sut.CollectionExistsAsync(TestCollectionName, this._testCancellationToken)); + await sut.EnsureCollectionDeletedAsync(TestCollectionName, this._testCancellationToken); + sut.Dispose(); + + // Assert. + this._chromaClientMock.Protected().Verify("Dispose", Times.Once(), ItExpr.IsAny()); + } +} diff --git a/README.md b/README.md index 3ef7a71..38ce531 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The following NuGet packages have been published: | [CommunityToolkit.VectorData.AzureAISearch] | ![AzureAISearch Version][v-azureaisearch] | ![AzureAISearch Downloads][d-azureaisearch] | | [CommunityToolkit.VectorData.AzureCosmosDB] | ![AzureCosmosDB Version][v-azurecosmosdb] | ![AzureCosmosDB Downloads][d-azurecosmosdb] | | [CommunityToolkit.VectorData.AzureDocumentDB] | ![AzureDocumentDB Version][v-azuredocumentdb] | ![AzureDocumentDB Downloads][d-azuredocumentdb] | +| [CommunityToolkit.VectorData.Chroma] | ![Chroma Version][v-chroma] | ![Chroma Downloads][d-chroma] | | [CommunityToolkit.VectorData.InMemory] | ![InMemory Version][v-inmemory] | ![InMemory Downloads][d-inmemory] | | [CommunityToolkit.VectorData.PgVector] | ![PgVector Version][v-pgvector] | ![PgVector Downloads][d-pgvector] | | [CommunityToolkit.VectorData.Qdrant] | ![Qdrant Version][v-qdrant] | ![Qdrant Downloads][d-qdrant] | @@ -54,6 +55,7 @@ The Vector Data connectors in this toolkit were initially part of the [Semantic [CommunityToolkit.VectorData.AzureAISearch]: https://www.nuget.org/packages/CommunityToolkit.VectorData.AzureAISearch [CommunityToolkit.VectorData.AzureCosmosDB]: https://www.nuget.org/packages/CommunityToolkit.VectorData.AzureCosmosDB [CommunityToolkit.VectorData.AzureDocumentDB]: https://www.nuget.org/packages/CommunityToolkit.VectorData.AzureDocumentDB +[CommunityToolkit.VectorData.Chroma]: https://www.nuget.org/packages/CommunityToolkit.VectorData.Chroma [CommunityToolkit.VectorData.InMemory]: https://www.nuget.org/packages/CommunityToolkit.VectorData.InMemory [CommunityToolkit.VectorData.PgVector]: https://www.nuget.org/packages/CommunityToolkit.VectorData.PgVector [CommunityToolkit.VectorData.Qdrant]: https://www.nuget.org/packages/CommunityToolkit.VectorData.Qdrant @@ -66,6 +68,7 @@ The Vector Data connectors in this toolkit were initially part of the [Semantic [v-azureaisearch]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.AzureAISearch [v-azurecosmosdb]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.AzureCosmosDB [v-azuredocumentdb]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.AzureDocumentDB +[v-chroma]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.Chroma [v-inmemory]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.InMemory [v-pgvector]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.PgVector [v-qdrant]: https://badgen.net/nuget/v/CommunityToolkit.VectorData.Qdrant @@ -78,6 +81,7 @@ The Vector Data connectors in this toolkit were initially part of the [Semantic [d-azureaisearch]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.AzureAISearch [d-azurecosmosdb]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.AzureCosmosDB [d-azuredocumentdb]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.AzureDocumentDB +[d-chroma]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.Chroma [d-inmemory]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.InMemory [d-pgvector]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.PgVector [d-qdrant]: https://badgen.net/nuget/dt/CommunityToolkit.VectorData.Qdrant