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