From 5aa0bad9555adca9d0aee08c520953a6329a269c Mon Sep 17 00:00:00 2001 From: Nate Anderson Date: Thu, 26 Oct 2023 14:14:31 -0700 Subject: [PATCH] feat: Add MVI data methods Add the upsert, delete, and search MVI methods to the vector client. Add an internal MVI data client and grpc manager. Update the protos to get the latest MVI changes. --- src/Momento.Sdk/IPreviewVectorIndexClient.cs | 88 ++++- .../Internal/VectorIndexDataClient.cs | 198 +++++++++++ .../Internal/VectorIndexDataGrpcManager.cs | 120 +++++++ .../Messages/Vector/MetadataValue.cs | 280 +++++++++++++++ src/Momento.Sdk/Momento.Sdk.csproj | 2 +- src/Momento.Sdk/PreviewVectorIndexClient.cs | 33 +- .../Requests/Vector/MetadataFields.cs | 56 +++ .../Requests/Vector/VectorIndexItem.cs | 50 +++ src/Momento.Sdk/Responses/Vector/SearchHit.cs | 96 +++++ .../Vector/VectorDeleteItemBatchResponse.cs | 58 +++ .../Responses/Vector/VectorSearchResponse.cs | 83 +++++ .../Vector/VectorUpsertItemBatchResponse.cs | 58 +++ .../VectorIndexControlTest.cs | 2 +- .../Momento.Sdk.Tests/VectorIndexDataTest.cs | 333 ++++++++++++++++++ 14 files changed, 1447 insertions(+), 10 deletions(-) create mode 100644 src/Momento.Sdk/Internal/VectorIndexDataClient.cs create mode 100644 src/Momento.Sdk/Internal/VectorIndexDataGrpcManager.cs create mode 100644 src/Momento.Sdk/Messages/Vector/MetadataValue.cs create mode 100644 src/Momento.Sdk/Requests/Vector/MetadataFields.cs create mode 100644 src/Momento.Sdk/Requests/Vector/VectorIndexItem.cs create mode 100644 src/Momento.Sdk/Responses/Vector/SearchHit.cs create mode 100644 src/Momento.Sdk/Responses/Vector/VectorDeleteItemBatchResponse.cs create mode 100644 src/Momento.Sdk/Responses/Vector/VectorSearchResponse.cs create mode 100644 src/Momento.Sdk/Responses/Vector/VectorUpsertItemBatchResponse.cs create mode 100644 tests/Integration/Momento.Sdk.Tests/VectorIndexDataTest.cs diff --git a/src/Momento.Sdk/IPreviewVectorIndexClient.cs b/src/Momento.Sdk/IPreviewVectorIndexClient.cs index b96ae3dd..0582f482 100644 --- a/src/Momento.Sdk/IPreviewVectorIndexClient.cs +++ b/src/Momento.Sdk/IPreviewVectorIndexClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Momento.Sdk.Requests.Vector; using Momento.Sdk.Responses.Vector; @@ -11,7 +12,7 @@ namespace Momento.Sdk; /// /// Includes control operations and data operations. /// -public interface IPreviewVectorIndexClient: IDisposable +public interface IPreviewVectorIndexClient : IDisposable { /// /// Creates a vector index if it does not exist. @@ -57,8 +58,9 @@ public interface IPreviewVectorIndexClient: IDisposable /// /// /// - public Task CreateIndexAsync(string indexName, ulong numDimensions, SimilarityMetric similarityMetric = SimilarityMetric.CosineSimilarity); - + public Task CreateIndexAsync(string indexName, ulong numDimensions, + SimilarityMetric similarityMetric = SimilarityMetric.CosineSimilarity); + /// /// Lists all vector indexes. /// @@ -103,4 +105,84 @@ public interface IPreviewVectorIndexClient: IDisposable /// /// public Task DeleteIndexesAsync(string indexName); + + /// + /// Upserts a batch of items into a vector index. + /// If an item with the same ID already exists in the index, it will be replaced. + /// Otherwise, it will be added to the index. + /// + /// The name of the vector index to delete. + /// The items to upsert into the index. + /// + /// Task representing the result of the upsert operation. The + /// response object is resolved to a type-safe object of one of + /// the following subtypes: + /// + /// VectorUpsertItemBatchResponse.Success + /// VectorUpsertItemBatchResponse.Error + /// + /// Pattern matching can be used to operate on the appropriate subtype. + /// For example: + /// + /// if (response is VectorUpsertItemBatchResponse.Error errorResponse) + /// { + /// // handle error as appropriate + /// } + /// + /// + public Task UpsertItemBatchAsync(string indexName, + IEnumerable items); + + /// + /// Deletes all items with the given IDs from the index. + /// + /// The name of the vector index to delete. + /// The IDs of the items to delete from the index. + /// + /// Task representing the result of the upsert operation. The + /// response object is resolved to a type-safe object of one of + /// the following subtypes: + /// + /// VectorDeleteItemBatchResponse.Success + /// VectorDeleteItemBatchResponse.Error + /// + /// Pattern matching can be used to operate on the appropriate subtype. + /// For example: + /// + /// if (response is VectorDeleteItemBatchResponse.Error errorResponse) + /// { + /// // handle error as appropriate + /// } + /// + /// + public Task DeleteItemBatchAsync(string indexName, IEnumerable ids); + + /// + /// Searches for the most similar vectors to the query vector in the index. + /// Ranks the vectors according to the similarity metric specified when the + /// index was created. + /// + /// The name of the vector index to delete. + /// The vector to search for. + /// The number of results to return. Defaults to 10. + /// A list of metadata fields to return with each result. + /// + /// Task representing the result of the upsert operation. The + /// response object is resolved to a type-safe object of one of + /// the following subtypes: + /// + /// VectorDeleteItemBatchResponse.Success + /// VectorDeleteItemBatchResponse.Error + /// + /// Pattern matching can be used to operate on the appropriate subtype. + /// For example: + /// + /// if (response is VectorDeleteItemBatchResponse.Error errorResponse) + /// { + /// // handle error as appropriate + /// } + /// + /// + public Task SearchAsync(string indexName, IEnumerable queryVector, uint topK = 10, + MetadataFields? metadataFields = null); } \ No newline at end of file diff --git a/src/Momento.Sdk/Internal/VectorIndexDataClient.cs b/src/Momento.Sdk/Internal/VectorIndexDataClient.cs new file mode 100644 index 00000000..70f5dc20 --- /dev/null +++ b/src/Momento.Sdk/Internal/VectorIndexDataClient.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Momento.Sdk.Config; +using Momento.Sdk.Exceptions; +using Momento.Sdk.Messages.Vector; +using Momento.Sdk.Requests.Vector; +using Momento.Sdk.Responses.Vector; +using Vectorindex; + +namespace Momento.Sdk.Internal; + +internal sealed class VectorIndexDataClient : IDisposable +{ + private readonly VectorIndexDataGrpcManager grpcManager; + private readonly TimeSpan deadline = TimeSpan.FromSeconds(60); + + private readonly ILogger _logger; + private readonly CacheExceptionMapper _exceptionMapper; + + public VectorIndexDataClient(IVectorIndexConfiguration config, string authToken, string endpoint) + { + grpcManager = new VectorIndexDataGrpcManager(config, authToken, endpoint); + _logger = config.LoggerFactory.CreateLogger(); + _exceptionMapper = new CacheExceptionMapper(config.LoggerFactory); + } + + public async Task UpsertItemBatchAsync(string indexName, + IEnumerable items) + { + try + { + _logger.LogTraceVectorIndexRequest("upsertItemBatch", indexName); + CheckValidIndexName(indexName); + var request = new _UpsertItemBatchRequest() { IndexName = indexName, Items = { items.Select(Convert) } }; + + await grpcManager.Client.UpsertItemBatchAsync(request, new CallOptions(deadline: CalculateDeadline())); + return _logger.LogTraceVectorIndexRequestSuccess("upsertItemBatch", indexName, + new VectorUpsertItemBatchResponse.Success()); + } + catch (Exception e) + { + return _logger.LogTraceVectorIndexRequestError("upsertItemBatch", indexName, + new VectorUpsertItemBatchResponse.Error(_exceptionMapper.Convert(e))); + } + } + + public async Task DeleteItemBatchAsync(string indexName, IEnumerable ids) + { + try + { + _logger.LogTraceVectorIndexRequest("deleteItemBatch", indexName); + CheckValidIndexName(indexName); + var request = new _DeleteItemBatchRequest() { IndexName = indexName, Ids = { ids } }; + + await grpcManager.Client.DeleteItemBatchAsync(request, new CallOptions(deadline: CalculateDeadline())); + return _logger.LogTraceVectorIndexRequestSuccess("deleteItemBatch", indexName, + new VectorDeleteItemBatchResponse.Success()); + } + catch (Exception e) + { + return _logger.LogTraceVectorIndexRequestError("deleteItemBatch", indexName, + new VectorDeleteItemBatchResponse.Error(_exceptionMapper.Convert(e))); + } + } + + public async Task SearchAsync(string indexName, IEnumerable queryVector, uint topK, + MetadataFields? metadataFields) + { + try + { + _logger.LogTraceVectorIndexRequest("search", indexName); + CheckValidIndexName(indexName); + metadataFields ??= new List(); + var metadataRequest = metadataFields switch + { + MetadataFields.AllFields => new _MetadataRequest { All = new _MetadataRequest.Types.All() }, + MetadataFields.List list => new _MetadataRequest + { + Some = new _MetadataRequest.Types.Some { Fields = { list.Fields } } + }, + _ => throw new InvalidArgumentException($"Unknown metadata fields type {metadataFields.GetType()}") + }; + + var request = new _SearchRequest + { + IndexName = indexName, + QueryVector = new _Vector { Elements = { queryVector } }, + TopK = topK, + MetadataFields = metadataRequest + }; + + var response = + await grpcManager.Client.SearchAsync(request, new CallOptions(deadline: CalculateDeadline())); + var searchHits = response.Hits.Select(Convert).ToList(); + return _logger.LogTraceVectorIndexRequestSuccess("search", indexName, + new VectorSearchResponse.Success(searchHits)); + } + catch (Exception e) + { + return _logger.LogTraceVectorIndexRequestError("search", indexName, + new VectorSearchResponse.Error(_exceptionMapper.Convert(e))); + } + } + + private static _Item Convert(VectorIndexItem item) + { + return new _Item + { + Id = item.Id, Vector = new _Vector { Elements = { item.Vector } }, Metadata = { Convert(item.Metadata) } + }; + } + + private static IEnumerable<_Metadata> Convert(Dictionary metadata) + { + var convertedMetadataList = new List<_Metadata>(); + foreach (var metadataPair in metadata) + { + _Metadata convertedMetadata; + switch (metadataPair.Value) + { + case StringValue stringValue: + convertedMetadata = new _Metadata { Field = metadataPair.Key, StringValue = stringValue.Value }; + break; + case LongValue longValue: + convertedMetadata = new _Metadata { Field = metadataPair.Key, IntegerValue = longValue.Value }; + break; + case DoubleValue doubleValue: + convertedMetadata = new _Metadata { Field = metadataPair.Key, DoubleValue = doubleValue.Value }; + break; + case BoolValue boolValue: + convertedMetadata = new _Metadata { Field = metadataPair.Key, BooleanValue = boolValue.Value }; + break; + case StringListValue stringListValue: + var listOfStrings = new _Metadata.Types._ListOfStrings { Values = { stringListValue.Value } }; + convertedMetadata = new _Metadata { Field = metadataPair.Key, ListOfStringsValue = listOfStrings }; + break; + default: + throw new InvalidArgumentException($"Unknown metadata type {metadataPair.Value.GetType()}"); + } + + convertedMetadataList.Add(convertedMetadata); + } + + return convertedMetadataList; + } + + private static Dictionary Convert(IEnumerable<_Metadata> metadata) + { + return metadata.ToDictionary(m => m.Field, Convert); + } + + private static MetadataValue Convert(_Metadata metadata) + { + switch (metadata.ValueCase) + { + case _Metadata.ValueOneofCase.StringValue: + return new StringValue(metadata.StringValue); + case _Metadata.ValueOneofCase.IntegerValue: + return new LongValue(metadata.IntegerValue); + case _Metadata.ValueOneofCase.DoubleValue: + return new DoubleValue(metadata.DoubleValue); + case _Metadata.ValueOneofCase.BooleanValue: + return new BoolValue(metadata.BooleanValue); + case _Metadata.ValueOneofCase.ListOfStringsValue: + return new StringListValue(metadata.ListOfStringsValue.Values.ToList()); + case _Metadata.ValueOneofCase.None: + default: + throw new UnknownException($"Unknown metadata type {metadata.ValueCase}"); + } + } + + private static SearchHit Convert(_SearchHit hit) + { + return new SearchHit(hit.Id, hit.Distance, Convert(hit.Metadata)); + } + + private static void CheckValidIndexName(string indexName) + { + if (string.IsNullOrWhiteSpace(indexName)) + { + throw new InvalidArgumentException("Index name must be nonempty"); + } + } + + private DateTime CalculateDeadline() + { + return DateTime.UtcNow.Add(deadline); + } + + public void Dispose() + { + grpcManager.Dispose(); + } +} \ No newline at end of file diff --git a/src/Momento.Sdk/Internal/VectorIndexDataGrpcManager.cs b/src/Momento.Sdk/Internal/VectorIndexDataGrpcManager.cs new file mode 100644 index 00000000..f30a865e --- /dev/null +++ b/src/Momento.Sdk/Internal/VectorIndexDataGrpcManager.cs @@ -0,0 +1,120 @@ +#pragma warning disable 1591 +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Grpc.Core; +using Grpc.Net.Client; +#if USE_GRPC_WEB +using System.Net.Http; +using Grpc.Net.Client.Web; +#endif +using Microsoft.Extensions.Logging; +using Momento.Sdk.Config; +using Momento.Sdk.Config.Middleware; +using Momento.Sdk.Internal.Middleware; +using Vectorindex; +using static System.Reflection.Assembly; + +namespace Momento.Sdk.Internal; + +public interface IVectorIndexDataClient +{ + public Task<_UpsertItemBatchResponse> UpsertItemBatchAsync(_UpsertItemBatchRequest request, CallOptions callOptions); + public Task<_SearchResponse> SearchAsync(_SearchRequest request, CallOptions callOptions); + public Task<_DeleteItemBatchResponse> DeleteItemBatchAsync(_DeleteItemBatchRequest request, CallOptions callOptions); +} + + +// Ideally we would implement our middleware based on gRPC Interceptors. Unfortunately, +// the their method signatures are not asynchronous. Thus, for any middleware that may +// require asynchronous actions (such as our MaxConcurrentRequestsMiddleware), we would +// end up blocking threads to wait for the completion of the async task, which would have +// a big negative impact on performance. Instead, in this commit, we implement a thin +// middleware layer of our own that uses asynchronous signatures throughout. This has +// the nice side effect of making the user-facing API for writing Middlewares a bit less +// of a learning curve for anyone not super deep on gRPC internals. +public class VectorIndexDataClientWithMiddleware : IVectorIndexDataClient +{ + private readonly IList _middlewares; + private readonly VectorIndex.VectorIndexClient _generatedClient; + + public VectorIndexDataClientWithMiddleware(VectorIndex.VectorIndexClient generatedClient, IList middlewares) + { + _generatedClient = generatedClient; + _middlewares = middlewares; + } + + public async Task<_UpsertItemBatchResponse> UpsertItemBatchAsync(_UpsertItemBatchRequest request, CallOptions callOptions) + { + var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.UpsertItemBatchAsync(r, o)); + return await wrapped.ResponseAsync; + } + + public async Task<_SearchResponse> SearchAsync(_SearchRequest request, CallOptions callOptions) + { + var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.SearchAsync(r, o)); + return await wrapped.ResponseAsync; + } + + public async Task<_DeleteItemBatchResponse> DeleteItemBatchAsync(_DeleteItemBatchRequest request, CallOptions callOptions) + { + var wrapped = await _middlewares.WrapRequest(request, callOptions, (r, o) => _generatedClient.DeleteItemBatchAsync(r, o)); + return await wrapped.ResponseAsync; + } +} + +public class VectorIndexDataGrpcManager : IDisposable +{ + private readonly GrpcChannel channel; + + public readonly IVectorIndexDataClient Client; + +#if USE_GRPC_WEB + private const string Moniker = "dotnet-web"; +#else + private const string Moniker = "dotnet"; +#endif + private readonly string version = $"{Moniker}:{GetAssembly(typeof(Responses.CacheGetResponse)).GetName().Version}"; + // Some System.Environment.Version remarks to be aware of + // https://learn.microsoft.com/en-us/dotnet/api/system.environment.version?view=netstandard-2.0#remarks + private readonly string runtimeVersion = $"{Moniker}:{Environment.Version}"; + private readonly ILogger _logger; + + internal VectorIndexDataGrpcManager(IVectorIndexConfiguration config, string authToken, string endpoint) + { +#if USE_GRPC_WEB + // Note: all web SDK requests are routed to a `web.` subdomain to allow us flexibility on the server + endpoint = $"web.{endpoint}"; +#endif + var uri = $"https://{endpoint}"; + var channelOptions = config.TransportStrategy.GrpcConfig.GrpcChannelOptions; + channelOptions.LoggerFactory ??= config.LoggerFactory; + channelOptions.Credentials = ChannelCredentials.SecureSsl; +#if USE_GRPC_WEB + channelOptions.HttpHandler = new GrpcWebHandler(new HttpClientHandler()); +#endif + + channel = GrpcChannel.ForAddress(uri, channelOptions); + var headers = new List
{ new(name: Header.AuthorizationKey, value: authToken), new(name: Header.AgentKey, value: version), new(name: Header.RuntimeVersionKey, value: runtimeVersion) }; + + _logger = config.LoggerFactory.CreateLogger(); + + var invoker = channel.CreateCallInvoker(); + + var middlewares = new List { + new HeaderMiddleware(config.LoggerFactory, headers) + }; + + var client = new VectorIndex.VectorIndexClient(invoker); + + + + Client = new VectorIndexDataClientWithMiddleware(client, middlewares); + } + + public void Dispose() + { + channel.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Momento.Sdk/Messages/Vector/MetadataValue.cs b/src/Momento.Sdk/Messages/Vector/MetadataValue.cs new file mode 100644 index 00000000..997481d0 --- /dev/null +++ b/src/Momento.Sdk/Messages/Vector/MetadataValue.cs @@ -0,0 +1,280 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Momento.Sdk.Messages.Vector; + +/// +/// Container for a piece of vector metadata. +/// +public abstract class MetadataValue +{ + /// + public abstract override string ToString(); + + /// + /// Implicitly convert a string to a StringValue. + /// + /// The string to convert. + public static implicit operator MetadataValue(string value) => new StringValue(value); + + /// + /// Implicitly convert a long to a LongValue. + /// + /// The long to convert. + public static implicit operator MetadataValue(long value) => new LongValue(value); + + /// + /// Implicitly convert a double to a DoubleValue. + /// + /// The double to convert. + public static implicit operator MetadataValue(double value) => new DoubleValue(value); + + /// + /// Implicitly convert a bool to a BoolValue. + /// + /// The bool to convert. + public static implicit operator MetadataValue(bool value) => new BoolValue(value); + + /// + /// Implicitly convert a list of strings to a StringListValue. + /// + /// The list of strings to convert. + public static implicit operator MetadataValue(List value) => new StringListValue(value); +} + +/// +/// String vector metadata. +/// +public class StringValue : MetadataValue +{ + /// + /// Constructs a StringValue. + /// + /// the string to wrap. + public StringValue(string value) + { + Value = value; + } + + /// + /// The wrapped string. + /// + public string Value { get; } + + /// + public override string ToString() => Value; + + /// + /// Implicitly convert a string to a StringValue. + /// + /// The string to convert. + public static implicit operator StringValue(string value) => new StringValue(value); + + /// + /// Explicitly convert a StringValue to a string. + /// + /// The StringValue to convert. + public static explicit operator string(StringValue value) => value.Value; + + /// + public override bool Equals(object obj) + { + return obj is StringValue other && Value == other.Value; + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} + +/// +/// Long vector metadata. +/// +public class LongValue : MetadataValue +{ + /// + /// Constructs a LongValue. + /// + /// the long to wrap. + public LongValue(long value) + { + Value = value; + } + + /// + /// The wrapped long. + /// + public long Value { get; } + + /// + public override string ToString() => Value.ToString(); + + /// + /// Implicitly convert a long to a LongValue. + /// + /// The long to convert. + public static implicit operator LongValue(long value) => new LongValue(value); + + /// + /// Explicitly convert a LongValue to a long. + /// + /// The LongValue to convert. + public static explicit operator long(LongValue value) => value.Value; + + /// + public override bool Equals(object obj) + { + return obj is LongValue other && Value == other.Value; + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} + +/// +/// Double vector metadata. +/// +public class DoubleValue : MetadataValue +{ + /// + /// Constructs a DoubleValue. + /// + /// the double to wrap. + public DoubleValue(double value) + { + Value = value; + } + + /// + /// The wrapped double. + /// + public double Value { get; } + + /// + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + /// + /// Implicitly convert a double to a DoubleValue. + /// + /// The double to convert. + public static implicit operator DoubleValue(double value) => new DoubleValue(value); + + /// + /// Explicitly convert a DoubleValue to a double. + /// + /// The DoubleValue to convert. + public static explicit operator double(DoubleValue value) => value.Value; + + /// + public override bool Equals(object obj) + { + // ReSharper disable once CompareOfFloatsByEqualityOperator + return obj is DoubleValue other && Value == other.Value; + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} + +/// +/// Boolean vector metadata. +/// +public class BoolValue : MetadataValue +{ + /// + /// Constructs a BoolValue. + /// + /// the bool to wrap. + public BoolValue(bool value) + { + Value = value; + } + + /// + /// The wrapped bool. + /// + public bool Value { get; } + + /// + public override string ToString() => Value.ToString(); + + /// + /// Implicitly convert a bool to a BoolValue. + /// + /// The bool to convert. + public static implicit operator BoolValue(bool value) => new BoolValue(value); + + /// + /// Explicitly convert a BoolValue to a bool. + /// + /// The BoolValue to convert. + public static explicit operator bool(BoolValue value) => value.Value; + + /// + public override bool Equals(object obj) + { + return obj is BoolValue other && Value == other.Value; + } + + /// + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} + +/// +/// String list vector metadata. +/// +public class StringListValue : MetadataValue +{ + /// + /// Constructs a StringListValue. + /// + /// the list of strings to wrap. + public StringListValue(List value) + { + Value = value; + } + + /// + /// The wrapped string list. + /// + public List Value { get; } + + /// + public override string ToString() => string.Join(", ", Value); + + /// + /// Implicitly convert a list of strings to a StringListValue. + /// + /// The list of strings to convert. + public static implicit operator StringListValue(List value) => new StringListValue(value); + + /// + /// Explicitly convert a StringListValue to a list of strings. + /// + /// The StringListValue to convert. + public static explicit operator List(StringListValue value) => value.Value; + + /// + public override bool Equals(object obj) + { + return obj is StringListValue other && Value.SequenceEqual(other.Value); + } + + /// + public override int GetHashCode() + { + return Value.Aggregate(0, (acc, val) => acc ^ (val != null ? val.GetHashCode() : 0)); + } +} \ No newline at end of file diff --git a/src/Momento.Sdk/Momento.Sdk.csproj b/src/Momento.Sdk/Momento.Sdk.csproj index 30c56965..474a251c 100644 --- a/src/Momento.Sdk/Momento.Sdk.csproj +++ b/src/Momento.Sdk/Momento.Sdk.csproj @@ -55,7 +55,7 @@ - + diff --git a/src/Momento.Sdk/PreviewVectorIndexClient.cs b/src/Momento.Sdk/PreviewVectorIndexClient.cs index 3f5742f8..97e7ba4e 100644 --- a/src/Momento.Sdk/PreviewVectorIndexClient.cs +++ b/src/Momento.Sdk/PreviewVectorIndexClient.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Momento.Sdk.Auth; using Momento.Sdk.Config; @@ -7,10 +8,10 @@ namespace Momento.Sdk; -public class PreviewVectorIndexClient: IPreviewVectorIndexClient +public class PreviewVectorIndexClient : IPreviewVectorIndexClient { private readonly VectorIndexControlClient controlClient; - + private readonly VectorIndexDataClient dataClient; /// @@ -23,9 +24,11 @@ public class PreviewVectorIndexClient: IPreviewVectorIndexClient public PreviewVectorIndexClient(IVectorIndexConfiguration config, ICredentialProvider authProvider) { var loggerFactory = config.LoggerFactory; - controlClient = new VectorIndexControlClient(loggerFactory, authProvider.AuthToken, authProvider.ControlEndpoint); + controlClient = + new VectorIndexControlClient(loggerFactory, authProvider.AuthToken, authProvider.ControlEndpoint); + dataClient = new VectorIndexDataClient(config, authProvider.AuthToken, authProvider.CacheEndpoint); } - + /// public async Task CreateIndexAsync(string indexName, ulong numDimensions, SimilarityMetric similarityMetric = SimilarityMetric.CosineSimilarity) @@ -44,7 +47,27 @@ public async Task DeleteIndexesAsync(string indexName { return await controlClient.DeleteIndexAsync(indexName); } - + + /// + public async Task UpsertItemBatchAsync(string indexName, + IEnumerable items) + { + return await dataClient.UpsertItemBatchAsync(indexName, items); + } + + /// + public async Task DeleteItemBatchAsync(string indexName, IEnumerable ids) + { + return await dataClient.DeleteItemBatchAsync(indexName, ids); + } + + /// + public async Task SearchAsync(string indexName, IEnumerable queryVector, + uint topK = 10, MetadataFields? metadataFields = null) + { + return await dataClient.SearchAsync(indexName, queryVector, topK, metadataFields); + } + /// public void Dispose() { diff --git a/src/Momento.Sdk/Requests/Vector/MetadataFields.cs b/src/Momento.Sdk/Requests/Vector/MetadataFields.cs new file mode 100644 index 00000000..b4c51763 --- /dev/null +++ b/src/Momento.Sdk/Requests/Vector/MetadataFields.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +namespace Momento.Sdk.Requests.Vector; + +/// +/// Wrapper for a list of metadata fields. Used in vector methods that can either take a +/// list of metadata to look up, or a value specifying that all metadata should be returned. +/// +public abstract class MetadataFields +{ + /// + /// Static value representing all metadata fields. + /// + public static readonly AllFields All = new AllFields(); + + /// + /// Implicitly convert a list of strings to a MetadataFields. Allows for passing a bare list instead + /// of having to explicitly create a MetadataFields object. + /// + /// The fields to look up. + /// + public static implicit operator MetadataFields(List fields) => new List(fields); + + /// + /// MetadataFields implementation representing a list of specific fields. + /// + public class List : MetadataFields + { + /// + /// Constructs a MetadataFields.List with specific fields. + /// + /// The fields to look up. + public List(IEnumerable fields) + { + Fields = fields; + } + + /// + /// The fields to look up. + /// + public IEnumerable Fields { get; } + } + + /// + /// MetadataFields implementation representing all fields. + /// + public class AllFields : MetadataFields + { + /// + /// Constructs a MetadataFields.All. + /// + public AllFields() + { + } + } +} \ No newline at end of file diff --git a/src/Momento.Sdk/Requests/Vector/VectorIndexItem.cs b/src/Momento.Sdk/Requests/Vector/VectorIndexItem.cs new file mode 100644 index 00000000..5833b217 --- /dev/null +++ b/src/Momento.Sdk/Requests/Vector/VectorIndexItem.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using Momento.Sdk.Messages.Vector; + +namespace Momento.Sdk.Requests.Vector; + +/// +/// A item in a vector index. Contains an ID, the vector, and any associated metadata. +/// +public class VectorIndexItem +{ + /// + /// Constructs a VectorIndexItem with no metadata. + /// + /// the ID of the vector. + /// the vector. + public VectorIndexItem(string id, List vector) + { + Id = id; + Vector = vector; + Metadata = new Dictionary(); + } + + /// + /// Constructs a VectorIndexItem. + /// + /// the ID of the vector. + /// the vector. + /// Metadata associated with the vector. + public VectorIndexItem(string id, List vector, Dictionary metadata) + { + Id = id; + Vector = vector; + Metadata = metadata; + } + + /// + /// The ID of the vector. + /// + public string Id { get; } + + /// + /// The vector. + /// + public List Vector { get; } + + /// + /// Metadata associated with the vector. + /// + public Dictionary Metadata { get; } +} \ No newline at end of file diff --git a/src/Momento.Sdk/Responses/Vector/SearchHit.cs b/src/Momento.Sdk/Responses/Vector/SearchHit.cs new file mode 100644 index 00000000..2d1b34bc --- /dev/null +++ b/src/Momento.Sdk/Responses/Vector/SearchHit.cs @@ -0,0 +1,96 @@ +using Momento.Sdk.Messages.Vector; + +namespace Momento.Sdk.Responses.Vector; + +using System.Collections.Generic; + +/// +/// A hit from a vector search. Contains the ID of the vector, the distance from the query vector, +/// and any requested metadata. +/// +public class SearchHit +{ + /// + /// The ID of the hit. + /// + public string Id { get; } + + /// + /// The distance from the query vector. + /// + public double Distance { get; } + + /// + /// Requested metadata associated with the hit. + /// + public Dictionary Metadata { get; } + + /// + /// Constructs a SearchHit with no metadata. + /// + /// The ID of the hit. + /// The distance from the query vector. + public SearchHit(string id, double distance) + { + Id = id; + Distance = distance; + Metadata = new Dictionary(); + } + + /// + /// Constructs a SearchHit. + /// + /// The ID of the hit. + /// The distance from the query vector. + /// Requested metadata associated with the hit + public SearchHit(string id, double distance, Dictionary metadata) + { + Id = id; + Distance = distance; + Metadata = metadata; + } + + /// + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) return true; + if (obj is null || GetType() != obj.GetType()) return false; + + var other = (SearchHit)obj; + + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (Id != other.Id || Distance != other.Distance) return false; + + // Compare Metadata dictionaries + if (Metadata.Count != other.Metadata.Count) return false; + + foreach (var pair in Metadata) + { + if (!other.Metadata.TryGetValue(pair.Key, out var value)) return false; + if (!value.Equals(pair.Value)) return false; + } + + return true; + } + + /// + public override int GetHashCode() + { + unchecked // Overflow is fine, just wrap + { + var hash = 17; + + hash = hash * 23 + Id.GetHashCode(); + hash = hash * 23 + Distance.GetHashCode(); + + foreach (var pair in Metadata) + { + hash = hash * 23 + pair.Key.GetHashCode(); + hash = hash * 23 + (pair.Value?.GetHashCode() ?? 0); + } + + return hash; + } + } +} + diff --git a/src/Momento.Sdk/Responses/Vector/VectorDeleteItemBatchResponse.cs b/src/Momento.Sdk/Responses/Vector/VectorDeleteItemBatchResponse.cs new file mode 100644 index 00000000..3a054034 --- /dev/null +++ b/src/Momento.Sdk/Responses/Vector/VectorDeleteItemBatchResponse.cs @@ -0,0 +1,58 @@ +namespace Momento.Sdk.Responses.Vector; + +using Exceptions; + +/// +/// Parent response type for a vector delete item batch request. The +/// response object is resolved to a type-safe object of one of +/// the following subtypes: +/// +/// VectorDeleteItemBatchResponse.Success +/// VectorDeleteItemBatchResponse.Error +/// +/// Pattern matching can be used to operate on the appropriate subtype. +/// For example: +/// +/// if (response is VectorDeleteItemBatchResponse.Success successResponse) +/// { +/// // handle success if needed +/// } +/// else if (response is VectorDeleteItemBatchResponse.Error errorResponse) +/// { +/// // handle error as appropriate +/// } +/// +/// +public abstract class VectorDeleteItemBatchResponse +{ + + /// + public class Success : VectorDeleteItemBatchResponse { } + + /// + public class Error : VectorDeleteItemBatchResponse, IError + { + /// + public Error(SdkException error) + { + InnerException = error; + } + + /// + public SdkException InnerException { get; } + + /// + public MomentoErrorCode ErrorCode => InnerException.ErrorCode; + + /// + public string Message => $"{InnerException.MessageWrapper}: {InnerException.Message}"; + + /// + public override string ToString() + { + return $"{base.ToString()}: {Message}"; + } + + } + +} diff --git a/src/Momento.Sdk/Responses/Vector/VectorSearchResponse.cs b/src/Momento.Sdk/Responses/Vector/VectorSearchResponse.cs new file mode 100644 index 00000000..1e2c9bb6 --- /dev/null +++ b/src/Momento.Sdk/Responses/Vector/VectorSearchResponse.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using Momento.Sdk.Exceptions; + +namespace Momento.Sdk.Responses.Vector; + +/// +/// Parent response type for a list vector indexes request. The +/// response object is resolved to a type-safe object of one of +/// the following subtypes: +/// +/// VectorSearchResponse.Success +/// VectorSearchResponse.Error +/// +/// Pattern matching can be used to operate on the appropriate subtype. +/// For example: +/// +/// if (response is VectorSearchResponse.Success successResponse) +/// { +/// return successResponse.Hits; +/// } +/// else if (response is VectorSearchResponse.Error errorResponse) +/// { +/// // handle error as appropriate +/// } +/// else +/// { +/// // handle unexpected response +/// } +/// +/// +public abstract class VectorSearchResponse +{ + /// + public class Success : VectorSearchResponse + { + /// + /// The list of hits returned by the search. + /// + public List Hits { get; } + + /// + /// the search results + public Success(List hits) + { + Hits = hits; + } + + /// + public override string ToString() + { + var displayedHits = Hits.Take(5).Select(hit => $"{hit.Id} ({hit.Distance})"); + return $"{base.ToString()}: {string.Join(", ", displayedHits)}..."; + } + + } + + /// + public class Error : VectorSearchResponse, IError + { + /// + public Error(SdkException error) + { + InnerException = error; + } + + /// + public SdkException InnerException { get; } + + /// + public MomentoErrorCode ErrorCode => InnerException.ErrorCode; + + /// + public string Message => $"{InnerException.MessageWrapper}: {InnerException.Message}"; + + /// + public override string ToString() + { + return $"{base.ToString()}: {Message}"; + } + + } +} diff --git a/src/Momento.Sdk/Responses/Vector/VectorUpsertItemBatchResponse.cs b/src/Momento.Sdk/Responses/Vector/VectorUpsertItemBatchResponse.cs new file mode 100644 index 00000000..d44878ae --- /dev/null +++ b/src/Momento.Sdk/Responses/Vector/VectorUpsertItemBatchResponse.cs @@ -0,0 +1,58 @@ +namespace Momento.Sdk.Responses.Vector; + +using Exceptions; + +/// +/// Parent response type for a vector upsert item batch request. The +/// response object is resolved to a type-safe object of one of +/// the following subtypes: +/// +/// VectorUpsertItemBatchResponse.Success +/// VectorUpsertItemBatchResponse.Error +/// +/// Pattern matching can be used to operate on the appropriate subtype. +/// For example: +/// +/// if (response is VectorUpsertItemBatchResponse.Success successResponse) +/// { +/// // handle success if needed +/// } +/// else if (response is VectorUpsertItemBatchResponse.Error errorResponse) +/// { +/// // handle error as appropriate +/// } +/// +/// +public abstract class VectorUpsertItemBatchResponse +{ + + /// + public class Success : VectorUpsertItemBatchResponse { } + + /// + public class Error : VectorUpsertItemBatchResponse, IError + { + /// + public Error(SdkException error) + { + InnerException = error; + } + + /// + public SdkException InnerException { get; } + + /// + public MomentoErrorCode ErrorCode => InnerException.ErrorCode; + + /// + public string Message => $"{InnerException.MessageWrapper}: {InnerException.Message}"; + + /// + public override string ToString() + { + return $"{base.ToString()}: {Message}"; + } + + } + +} diff --git a/tests/Integration/Momento.Sdk.Tests/VectorIndexControlTest.cs b/tests/Integration/Momento.Sdk.Tests/VectorIndexControlTest.cs index c5d27eba..a5e0eb41 100644 --- a/tests/Integration/Momento.Sdk.Tests/VectorIndexControlTest.cs +++ b/tests/Integration/Momento.Sdk.Tests/VectorIndexControlTest.cs @@ -46,7 +46,7 @@ public async Task CreateIndexAsync_AlreadyExistsError() [Fact] public async Task CreateIndexAsync_InvalidIndexName() { - var createResponse = await vectorIndexClient.CreateIndexAsync(null, 3); + var createResponse = await vectorIndexClient.CreateIndexAsync(null!, 3); Assert.True(createResponse is CreateVectorIndexResponse.Error, $"Unexpected response: {createResponse}"); var createErr = (CreateVectorIndexResponse.Error)createResponse; Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, createErr.InnerException.ErrorCode); diff --git a/tests/Integration/Momento.Sdk.Tests/VectorIndexDataTest.cs b/tests/Integration/Momento.Sdk.Tests/VectorIndexDataTest.cs new file mode 100644 index 00000000..e7d63041 --- /dev/null +++ b/tests/Integration/Momento.Sdk.Tests/VectorIndexDataTest.cs @@ -0,0 +1,333 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Momento.Sdk.Messages.Vector; +using Momento.Sdk.Requests.Vector; +using Momento.Sdk.Responses.Vector; + +namespace Momento.Sdk.Tests.Integration; + +public class VectorIndexDataTest : IClassFixture +{ + private readonly IPreviewVectorIndexClient vectorIndexClient; + + public VectorIndexDataTest(VectorIndexClientFixture vectorIndexFixture) + { + vectorIndexClient = vectorIndexFixture.Client; + } + + [Fact] + public async Task UpsertItemBatchAsync_InvalidIndexName() + { + var response = await vectorIndexClient.UpsertItemBatchAsync(null!, new List()); + Assert.True(response is VectorUpsertItemBatchResponse.Error, $"Unexpected response: {response}"); + var error = (VectorUpsertItemBatchResponse.Error)response; + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, error.InnerException.ErrorCode); + + response = await vectorIndexClient.UpsertItemBatchAsync("", new List()); + Assert.True(response is VectorUpsertItemBatchResponse.Error, $"Unexpected response: {response}"); + error = (VectorUpsertItemBatchResponse.Error)response; + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, error.InnerException.ErrorCode); + } + + [Fact] + public async Task DeleteItemBatchAsync_InvalidIndexName() + { + var response = await vectorIndexClient.DeleteItemBatchAsync(null!, new List()); + Assert.True(response is VectorDeleteItemBatchResponse.Error, $"Unexpected response: {response}"); + var error = (VectorDeleteItemBatchResponse.Error)response; + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, error.InnerException.ErrorCode); + + response = await vectorIndexClient.DeleteItemBatchAsync("", new List()); + Assert.True(response is VectorDeleteItemBatchResponse.Error, $"Unexpected response: {response}"); + error = (VectorDeleteItemBatchResponse.Error)response; + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, error.InnerException.ErrorCode); + } + + [Fact] + public async Task SearchAsync_InvalidIndexName() + { + var response = await vectorIndexClient.SearchAsync(null!, new List { 1.0f }); + Assert.True(response is VectorSearchResponse.Error, $"Unexpected response: {response}"); + var error = (VectorSearchResponse.Error)response; + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, error.InnerException.ErrorCode); + + response = await vectorIndexClient.SearchAsync("", new List { 1.0f }); + Assert.True(response is VectorSearchResponse.Error, $"Unexpected response: {response}"); + error = (VectorSearchResponse.Error)response; + Assert.Equal(MomentoErrorCode.INVALID_ARGUMENT_ERROR, error.InnerException.ErrorCode); + } + + [Fact] + public async Task UpsertAndSearch_InnerProduct() + { + var indexName = $"index-{Utils.NewGuidString()}"; + + var createResponse = await vectorIndexClient.CreateIndexAsync(indexName, 2, SimilarityMetric.InnerProduct); + Assert.True(createResponse is CreateVectorIndexResponse.Success, $"Unexpected response: {createResponse}"); + + try + { + var upsertResponse = await vectorIndexClient.UpsertItemBatchAsync(indexName, new List + { + new("test_item", new List { 1.0f, 2.0f }) + }); + Assert.True(upsertResponse is VectorUpsertItemBatchResponse.Success, + $"Unexpected response: {upsertResponse}"); + + await Task.Delay(2_000); + + var searchResponse = await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 2.0f }); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + var successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item", 5.0f) + }, successResponse.Hits); + } + finally + { + await vectorIndexClient.DeleteIndexesAsync(indexName); + } + } + + [Fact] + public async Task UpsertAndSearch_CosineSimilarity() + { + var indexName = $"index-{Utils.NewGuidString()}"; + + var createResponse = await vectorIndexClient.CreateIndexAsync(indexName, 2); + Assert.True(createResponse is CreateVectorIndexResponse.Success, $"Unexpected response: {createResponse}"); + + try + { + var upsertResponse = await vectorIndexClient.UpsertItemBatchAsync(indexName, new List + { + new("test_item_1", new List { 1.0f, 1.0f }), + new("test_item_2", new List { -1.0f, 1.0f }), + new("test_item_3", new List { -1.0f, -1.0f }) + }); + Assert.True(upsertResponse is VectorUpsertItemBatchResponse.Success, + $"Unexpected response: {upsertResponse}"); + + await Task.Delay(2_000); + + var searchResponse = await vectorIndexClient.SearchAsync(indexName, new List { 2.0f, 2.0f }); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + var successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_1", 1.0f), + new("test_item_2", 0.0f), + new("test_item_3", -1.0f) + }, successResponse.Hits); + } + finally + { + await vectorIndexClient.DeleteIndexesAsync(indexName); + } + } + + [Fact] + public async Task UpsertAndSearch_EuclideanSimilarity() + { + var indexName = $"index-{Utils.NewGuidString()}"; + + var createResponse = + await vectorIndexClient.CreateIndexAsync(indexName, 2, SimilarityMetric.EuclideanSimilarity); + Assert.True(createResponse is CreateVectorIndexResponse.Success, $"Unexpected response: {createResponse}"); + + try + { + var upsertResponse = await vectorIndexClient.UpsertItemBatchAsync(indexName, new List + { + new("test_item_1", new List { 1.0f, 1.0f }), + new("test_item_2", new List { -1.0f, 1.0f }), + new("test_item_3", new List { -1.0f, -1.0f }) + }); + Assert.True(upsertResponse is VectorUpsertItemBatchResponse.Success, + $"Unexpected response: {upsertResponse}"); + + await Task.Delay(2_000); + + var searchResponse = await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 1.0f }); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + var successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_1", 0.0f), + new("test_item_2", 4.0f), + new("test_item_3", 8.0f) + }, successResponse.Hits); + } + finally + { + await vectorIndexClient.DeleteIndexesAsync(indexName); + } + } + + [Fact] + public async Task UpsertAndSearch_TopKLimit() + { + var indexName = $"index-{Utils.NewGuidString()}"; + + var createResponse = await vectorIndexClient.CreateIndexAsync(indexName, 2, SimilarityMetric.InnerProduct); + Assert.True(createResponse is CreateVectorIndexResponse.Success, $"Unexpected response: {createResponse}"); + + try + { + var upsertResponse = await vectorIndexClient.UpsertItemBatchAsync(indexName, new List + { + new("test_item_1", new List { 1.0f, 2.0f }), + new("test_item_2", new List { 3.0f, 4.0f }), + new("test_item_3", new List { 5.0f, 6.0f }) + }); + Assert.True(upsertResponse is VectorUpsertItemBatchResponse.Success, + $"Unexpected response: {upsertResponse}"); + + await Task.Delay(2_000); + + var searchResponse = await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 2.0f }, 2); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + var successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_3", 17.0f), + new("test_item_2", 11.0f) + }, successResponse.Hits); + } + finally + { + await vectorIndexClient.DeleteIndexesAsync(indexName); + } + } + + [Fact] + public async Task UpsertAndSearch_WithMetadata() + { + var indexName = $"index-{Utils.NewGuidString()}"; + + var createResponse = await vectorIndexClient.CreateIndexAsync(indexName, 2, SimilarityMetric.InnerProduct); + Assert.True(createResponse is CreateVectorIndexResponse.Success, $"Unexpected response: {createResponse}"); + + try + { + var upsertResponse = await vectorIndexClient.UpsertItemBatchAsync(indexName, new List + { + new("test_item_1", new List { 1.0f, 2.0f }, + new Dictionary { { "key1", "value1" } }), + new("test_item_2", new List { 3.0f, 4.0f }, + new Dictionary { { "key2", "value2" } }), + new("test_item_3", new List { 5.0f, 6.0f }, + new Dictionary + { { "key1", "value3" }, { "key3", "value3" } }) + }); + Assert.True(upsertResponse is VectorUpsertItemBatchResponse.Success, + $"Unexpected response: {upsertResponse}"); + + await Task.Delay(2_000); + + var searchResponse = await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 2.0f }, 3, + new List { "key1" }); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + var successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_3", 17.0f, + new Dictionary { { "key1", "value3" } }), + new("test_item_2", 11.0f, new Dictionary()), + new("test_item_1", 5.0f, + new Dictionary { { "key1", "value1" } }) + }, successResponse.Hits); + + searchResponse = await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 2.0f }, 3, + new List { "key1", "key2", "key3", "key4" }); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_3", 17.0f, + new Dictionary + { { "key1", "value3" }, { "key3", "value3" } }), + new("test_item_2", 11.0f, + new Dictionary { { "key2", "value2" } }), + new("test_item_1", 5.0f, + new Dictionary { { "key1", "value1" } }) + }, successResponse.Hits); + + searchResponse = + await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 2.0f }, 3, MetadataFields.All); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_3", 17.0f, + new Dictionary + { { "key1", "value3" }, { "key3", "value3" } }), + new("test_item_2", 11.0f, + new Dictionary { { "key2", "value2" } }), + new("test_item_1", 5.0f, + new Dictionary { { "key1", "value1" } }) + }, successResponse.Hits); + } + finally + { + await vectorIndexClient.DeleteIndexesAsync(indexName); + } + } + + [Fact] + public async Task UpsertAndSearch_WithDiverseMetadata() + { + var indexName = $"index-{Utils.NewGuidString()}"; + + var createResponse = await vectorIndexClient.CreateIndexAsync(indexName, 2, SimilarityMetric.InnerProduct); + Assert.True(createResponse is CreateVectorIndexResponse.Success, $"Unexpected response: {createResponse}"); + + try + { + var metadata = new Dictionary + { + { "string_key", "string_value" }, + { "long_key", 123 }, + { "double_key", 3.14 }, + { "bool_key", true }, + { "list_key", new List { "a", "b", "c" } }, + { "empty_list_key", new List() } + }; + var upsertResponse = await vectorIndexClient.UpsertItemBatchAsync(indexName, new List + { + new("test_item_1", new List { 1.0f, 2.0f }, metadata) + }); + Assert.True(upsertResponse is VectorUpsertItemBatchResponse.Success, + $"Unexpected response: {upsertResponse}"); + + await Task.Delay(2_000); + + var searchResponse = + await vectorIndexClient.SearchAsync(indexName, new List { 1.0f, 2.0f }, 1, MetadataFields.All); + Assert.True(searchResponse is VectorSearchResponse.Success, $"Unexpected response: {searchResponse}"); + var successResponse = (VectorSearchResponse.Success)searchResponse; + Assert.Equal(new List + { + new("test_item_1", 5.0f, metadata) + }, successResponse.Hits); + } + finally + { + await vectorIndexClient.DeleteIndexesAsync(indexName); + } + } + + [Fact] + public async Task TempDeleteAllIndexes() + { + var listResponse = await vectorIndexClient.ListIndexesAsync(); + Assert.True(listResponse is ListVectorIndexesResponse.Success, $"Unexpected response: {listResponse}"); + var listOk = (ListVectorIndexesResponse.Success)listResponse; + foreach (var indexName in listOk.IndexNames) + { + var deleteResponse = await vectorIndexClient.DeleteIndexesAsync(indexName); + Assert.True(deleteResponse is DeleteVectorIndexResponse.Success, $"Unexpected response: {deleteResponse}"); + } + } +} \ No newline at end of file